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.
After changing the order -
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 View
s), 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 _ModifiedContent
s 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)
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)
}
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.