Order of modifiers in SwiftUI view impacts view appearance

Yes. It does. In the SwiftUI Essentials session, Apple tried explaining this as simple as possible.

enter image description here

After changing the order -

enter image description here


Wall of text incoming

It is better not to think of the modifiers as modifying the MapView. Instead, think of MapView().edgesIgnoringSafeArea(.top) as returning a SafeAreaIgnoringView whose body is the MapView, and which lays out its body differently depending on whether its own top edge is at the top edge of the safe area. You should think of it that way because that is what it actually does.

How can you be sure I'm telling the truth? Drop this code into your application(_:didFinishLaunchingWithOptions:) method:

let mapView = MapView()
let safeAreaIgnoringView = mapView.edgesIgnoringSafeArea(.top)
let framedView = safeAreaIgnoringView.frame(height: 300)
print("framedView = \(framedView)")

Now option-click mapView to see its inferred type, which is plain MapView.

Next, option-click safeAreaIgnoringView to see its inferred type. Its type is _ModifiedContent<MapView, _SafeAreaIgnoringLayout>. _ModifiedContent is an implementation detail of SwiftUI and it conforms to View when its first generic parameter (named Content) conforms to View. In this case, its Content is MapView, so this _ModifiedContent is also a View.

Next, option-click framedView to see its inferred type. Its type is _ModifiedContent<_ModifiedContent<MapView, _SafeAreaIgnoringLayout>, _FrameLayout>.

So you can see that, at the type level, framedView is a view whose content has the type of safeAreaIgnoringView, and safeAreaIgnoringView is a view whose content has the type of mapView.

But those are just types, and the nested structure of the types might not be represented at run time in the actual data, right? Run the app (on a simulator or a device) and look at the output of the print statement:

framedView =
    _ModifiedContent<
        _ModifiedContent<
            MapView,
            _SafeAreaIgnoringLayout
        >,
        _FrameLayout
    >(
        content:
            SwiftUI._ModifiedContent<
                Landmarks.MapView,
                SwiftUI._SafeAreaIgnoringLayout
            >(
                content: Landmarks.MapView(),
                modifier: SwiftUI._SafeAreaIgnoringLayout(
                    edges: SwiftUI.Edge.Set(rawValue: 1)
                )
            ),
        modifier:
            SwiftUI._FrameLayout(
                width: nil,
                height: Optional(300.0),
                alignment: SwiftUI.Alignment(
                    horizontal: SwiftUI.HorizontalAlignment(
                        key: SwiftUI.AlignmentKey(bits: 4484726064)
                    ),
                    vertical: SwiftUI.VerticalAlignment(
                        key: SwiftUI.AlignmentKey(bits: 4484726041)
                    )
                )
            )
    )

I've reformatted the output because Swift prints it on a single line, which makes it very hard to understand.

Anyway, we can see that in fact framedView apparently has a content property whose value is the type of safeAreaIgnoringView, and that object has its own content property whose value is a MapView.

So, when you apply a “modifier” to a View, you're not really modifying the view. You're creating a new View whose body/content is the original View.


Now that we understand what modifiers do (they construct wrapper Views), we can make a reasonable guess about how these two modifiers (edgesIgnoringSafeAreas and frame) affect layout.

At some point, SwiftUI traverses the tree to compute each view's frame. It starts with the screen's safe area as the frame of our top-level ContentView. It then visits the ContentView's body, which is (in the first tutorial) a VStack. For a VStack, SwiftUI divides up the frame of the VStack among the stack's children, which are three _ModifiedContents followed by a Spacer. SwiftUI looks through the children to figure out how much space to allot to each. The first _ModifiedChild (which ultimately contains the MapView) has a _FrameLayout modifier whose height is 300 points, so that's how much of the VStack's height gets assigned to the first _ModifiedChild.

Eventually SwiftUI figures out which part of the VStack's frame to assign to each of the children. Then it visits each of the children to assign their frames and lay out the children's children. So it visits that _ModifiedContent with the _FrameLayout modifier, setting its frame to a rectangle that meets the top edge of the safe area and has a height of 300 points.

Since the view is a _ModifiedContent with a _FrameLayout modifier whose height is 300, SwiftUI checks that the assigned height is acceptable to the modifier. It is, so SwiftUI doesn't have to change the frame further.

Then it visits the child of that _ModifiedContent, arriving at the _ModifiedContent whose modifier is `_SafeAreaIgnoringLayout. It sets the frame of the safe-area-ignoring view to the same frame as the parent (frame-setting) view.

Next SwiftUI needs to compute the frame of the safe-area-ignoring view's child (the MapView). By default, the child gets the same frame as the parent. But since this parent is a _ModifiedContent whose modifier is _SafeAreaIgnoringLayout, SwiftUI knows it might need to adjust the child's frame. Since the modifier's edges is set to .top, SwiftUI compares the top edge of the parent's frame to the top edge of the safe area. In this case, they coincide, so Swift expands the frame of the child to cover the extent of the screen above the top of the safe area. Thus the child's frame extends outside of the parent's frame.

Then SwiftUI visits the MapView and assigns it the frame computed above, which extends beyond the safe area to the edge of the screen. Thus the MapView's height is 300 plus the extent beyond the top edge of the safe area.

Let's check this by drawing a red border around the safe-area-ignoring view, and a blue border around the frame-setting view:

MapView()
    .edgesIgnoringSafeArea(.top)
    .border(Color.red, width: 2)
    .frame(height: 300)
    .border(Color.blue, width: 1)

screen shot of original tutorial code with added borders

The screen shot reveals that, indeed, the frames of the two _ModifiedContent views coincide and don't extend outside the safe area. (You might need to zoom in on the content to see both borders.)


That's how SwiftUI works with the code in the tutorial project. Now what if we swap the modifiers on the MapView around as you proposed?

When SwiftUI visits the VStack child of the ContentView, it needs to divvy up the VStack's vertical extent amongst the stack's children, just like in the prior example.

This time, the first _ModifiedContent is the one with the _SafeAreaIgnoringLayout modifier. SwiftUI sees that it doesn't have a specific height, so it looks to the _ModifiedContent's child, which is now the _ModifiedContent with the _FrameLayout modifier. This view has a fixed height of 300 points, so SwiftUI now knows that the safe-area-ignoring _ModifiedContent should be 300 points high. So SwiftUI grants the top 300 points of the VStack's extent to the stack's first child (the safe-area-ignoring _ModifiedContent).

Later, SwiftUI visits that first child to assign its actual frame and lay out its children. So SwiftUI sets the safe-area-ignoring _ModifiedContent's frame to exactly the top 300 points of the safe area.

Next SwiftUI needs to compute the frame of the safe-area-ignoring _ModifiedContent's child, which is the frame-setting _ModifiedContent. Normally the child gets the same frame as the parent. But since the parent is a _ModifiedContent with a modifier of _SafeAreaIgnoringLayout whose edges is .top, SwiftUI compares the top edge of the parent's frame to the top edge of the safe area. In this example, they coincide, so SwiftUI extends the frame of the child to the top edge of the screen. The frame is thus 300 points plus the extent above the top of the safe area.

When SwiftUI goes to set the frame of the child, it sees that the child is a _ModifiedContent with a modifier of _FrameLayout whose height is 300. Since the frame is more than 300 points high, it isn't compatible with the modifier, so SwiftUI is forced to adjust the frame. It changes the frame height to 300, but it does not end up with the same frame as the parent. The extra extent (outside the safe area) was added to the top of the frame, but changing the frame's height modifies the bottom edge of the frame.

So the final effect is that the frame is moved, rather than expanded, by the extent above the safe area. The frame-setting _ModifiedContent gets a frame that covers the top 300 points of the screen, rather than the top 300 points of the safe area.

SwiftUI then visits the child of the frame-setting view, which is the MapView, and gives it the same frame.

We can check this using the same border-drawing technique:

if false {
    // Original tutorial modifier order
    MapView()
        .edgesIgnoringSafeArea(.top)
        .border(Color.red, width: 2)
        .frame(height: 300)
        .border(Color.blue, width: 1)
} else {
    // LinusGeffarth's reversed modifier order
    MapView()
        .frame(height: 300)
        .border(Color.red, width: 2)
        .edgesIgnoringSafeArea(.top)
        .border(Color.blue, width: 1)
}

screen shot of modified tutorial code with added borders

Here we can see that the safe-area-ignoring _ModifiedContent (with the blue border this time) has the same frame as in the original code: it starts at the top of the safe area. But we can also see that now the frame of the frame-setting _ModifiedContent (with the red border this time) starts at the top edge of the screen, not the top edge of the safe area, and the bottom edge of the frame has also been shifted up by the same extent.

Tags:

Swift

Swiftui