SwiftUI offers several different ways for us to create stacks of overlapping views that can be arranged along the Z axis, which in turn enables us to define various kinds of overlays and backgrounds for the views that we build. Let’s explore some of those built-in stacking methods and what sort of UIs that they enable us to create.
Like its name implies, SwiftUI’s ZStack
type is the Z-axis equivalent of the horizontally-oriented HStack
and the vertical VStack
. When placing multiple views within a ZStack
, they’re (by default) rendered back-to-front, with the first view being placed at the back. For example, here we’re creating a full-screen ContentView
, which renders a gradient with a text stacked on top:
struct ContentView: View {
var body: some View {
ZStack {
LinearGradient(
colors: [.orange, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
Text("Swift by Sundell")
.foregroundColor(.white)
.font(.title)
}
}
}
Tip: You can use the above code sample’s PREVIEW
button to see what it’ll look like when rendered.
The reason that the above ContentView
is rendered across all of the available screen space is because a LinearGradient
will always occupy as much space as possible by default, and since a any stack’s size defaults to the total size of its children, that leads to our ZStack
being resized to occupy that same full-screen space.
However, sometimes we might not want a given background to stretch out to fill all available space, and while we could address that by applying various sizing modifiers to our background view, SwiftUI ships with a built-in tool that automatically resizes a given view’s background to perfectly fit its parent — the background
modifier.
Here’s how we could use that modifier to instead apply our LinearGradient
background directly to our Text
-based view, which makes that background take on the exact same size as our text itself (including its padding):
struct ContentView: View {
var body: some View {
Text("Swift by Sundell")
.foregroundColor(.white)
.font(.title)
.padding(35)
.background(
LinearGradient(
colors: [.orange, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
The reason that the padding is included when calculating our background’s size in the above example is because we’re applying the padding
modifier before adding our background. To learn more about that, check out “When does the order of SwiftUI modifiers matter, and why?”.
One thing that’s important to point out, though, is that even though a view’s background
does indeed get resized according to the parent view itself, there’s no form of clipping applied by default. So if we were to give our LinearGradient
an explicit size that’s larger than its parent, then it’ll actually be rendered out of bounds (which we can clearly demonstrate by adding a border to our main Text
-based view):
struct ContentView: View {
var body: some View {
Text("Swift by Sundell")
.foregroundColor(.white)
.font(.title)
.padding(35)
.background(
LinearGradient(
colors: [.orange, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: 300, height: 300)
)
.border(Color.blue)
}
}
There are multiple ways to apply clipping to a view, though, which would remove the above sort of out-of-bounds rendering. For example, we could use either the clipped
or clipShape
modifier to tell the view to apply a clipping mask to its bounds, or we could give our view rounded corners (which also introduces clipping) — like this:
struct ContentView: View {
var body: some View {
Text("Swift by Sundell")
.foregroundColor(.white)
.font(.title)
.padding(35)
.background(
LinearGradient(
colors: [.orange, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: 300, height: 300)
)
.cornerRadius(20)
}
}
Of course, the simplest way to avoid drawing a background outside of the bounds of its parent view is to simply let the SwiftUI layout system automatically determine the size of each background. That way, the size of a given background will always perfectly match the size of its parent view.
SwiftUI also supports adding overlays to views as well, which essentially act as the inverse of backgrounds — in that they’re rendered on top of their parent views (with the same sizing behaviors as we explored above).
Both overlays and backgrounds also support alignment customization, which lets us decide how such a view should be placed within its parent’s coordinate system. For views that are fully resizable (like our above LinearGradient
), the alignment doesn’t matter (since those views will be resized to fit their parent view anyway), but for smaller views, specifying an alignment lets us move a view to any of its parent’s corners.
For example, here’s how we could add a star image overlay to the top-trailing corner of our ContentView
:
struct ContentView: View {
var body: some View {
Text("Swift by Sundell")
.foregroundColor(.white)
.font(.title)
.padding(35)
.background(
LinearGradient(
colors: [.orange, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(starOverlay, alignment: .topTrailing)
.cornerRadius(20)
}
private var starOverlay: some View {
Image(systemName: "star")
.foregroundColor(.white)
.padding([.top, .trailing], 5)
}
}
Specifying an alignment for a background is done the exact same way, by passing an alignment
argument when using the background
modifier.
An overlay or background also inherits all of its parent’s environment values. In the case of our ContentView
example, that means that we don’t actually have to apply the same foregroundColor
modifier twice, like we’re doing above (since foreground colors automatically become part of the SwiftUI environment). So if we instead apply that modifier after we’ve added our overlay, then that same color will be applied to both our text and our star icon:
struct ContentView: View {
var body: some View {
Text("Swift by Sundell")
.font(.title)
.padding(35)
.background(
LinearGradient(
colors: [.orange, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(starOverlay, alignment: .topTrailing)
.foregroundColor(.white)
.cornerRadius(20)
}
private var starOverlay: some View {
Image(systemName: "star")
.padding([.top, .trailing], 5)
}
}
Sometimes, though, we might only want to apply a given overlay (or background) under certain conditions. For example, let’s say that we wanted to add a second overlay that displays a progress view while our ContentView
is in some form of loading state.
Since we don’t want to introduce any if/else
control flow to our ContentView
itself (since that’ll essentially make SwiftUI treat those two code paths as two separate views), we could create such a conditional overlay by defining a new @ViewBuilder
-marked computed property — like this:
struct ContentView: View {
@State private var isLoading = true
var body: some View {
Text("Swift by Sundell")
.font(.title)
.padding(35)
.background(
LinearGradient(
colors: [.orange, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(starOverlay, alignment: .topTrailing)
.overlay(loadingOverlay)
.foregroundColor(.white)
.cornerRadius(20)
}
...
@ViewBuilder private var loadingOverlay: some View {
if isLoading {
ProgressView()
}
}
}
Note that it’s perfectly fine to apply multiple overlays or backgrounds to a single view. They’ll simply be stacked on top of each other, just like when using a ZStack
.
However, our ProgressView
isn’t currently very easy to see, and could definitely use a background of its own. In this case, we do actually want that background to occupy the same space as our ContentView
itself, which can be done using the ZStack
-based technique that we initially explored in this article:
struct ContentView: View {
...
@ViewBuilder private var loadingOverlay: some View {
if isLoading {
ZStack {
Color(white: 0, opacity: 0.75)
ProgressView().tint(.white)
}
}
}
}
Note that if our app supported iOS 14 or earlier, then we’d have to apply CircularProgressViewStyle(tint: .white)
to our progress view (using the progressViewStyle
modifier), rather than using tint
, since that modifier was introduced in iOS 15.
iOS 15 also introduced new APIs for defining backgrounds and overlays using @ViewBuilder
-marked closures. The benefit of those new APIs is that they let us use control flow (like if
statements) inline within those modifier calls, which in our case would enable us to easily define both of our two overlays right within our view’s body
:
struct ContentView: View {
@State private var isLoading = true
var body: some View {
Text("Swift by Sundell")
.font(.title)
.padding(35)
.background(
LinearGradient(
colors: [.orange, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(alignment: .topTrailing) {
Image(systemName: "star")
.padding([.top, .trailing], 5)
}
.overlay {
if isLoading {
ZStack {
Color(white: 0, opacity: 0.75)
ProgressView().tint(.white)
}
}
}
.foregroundColor(.white)
.cornerRadius(20)
}
}
That’s not to say that delegating certain accessory view definitions to separate properties isn’t a good idea — in fact, that’s an excellent pattern that can help us break certain massive body
properties up into more manageable pieces (without having to define a ton of new view types). But sometimes, defining everything inline might be the way to go, especially for simpler overlays, and the new closure-based background
and overlay
modifiers certainly make doing so much easier.
It is a bit of a shame that those new closure-based APIs are iOS 15-only, though, so let’s wrap up this article by fixing that. It’s important to remember just how composable SwiftUI was designed to be, and that most of the convenience APIs that have been introduced recently are merely composing some of SwiftUI’s older building blocks — which is something that we can do ourselves as well.
So here’s how we could use the same technique as we previously used to make certain async/await-based system APIs backward compatible — that is, by defining an extension that overrides the system-provided APIs with our own, iOS 13-compatible versions:
@available(iOS, deprecated: 15.0, message: "Use the built-in APIs instead")
extension View {
func background<T: View>(
alignment: Alignment = .center,
@ViewBuilder content: () -> T
) -> some View {
background(Group(content: content), alignment: alignment)
}
func overlay<T: View>(
alignment: Alignment = .center,
@ViewBuilder content: () -> T
) -> some View {
overlay(Group(content: content), alignment: alignment)
}
}
With that simple extension in place, we can now use the closure-based variants of both background
and overlay
even within projects that need to support earlier operating system versions.
Backgrounds and overlays are two incredibly useful layout tools when working with SwiftUI, and along with ZStack
, make it possible to define all sorts of depth-stacked view hierarchies. To learn more about SwiftUI’s layout system, make sure to check out my three-part guide right here, and head over to the SwiftUI Discover page if you’d like to continue exploring many other aspects of the framework.
Of course, you’re always welcome to contact me via either Twitter or email if you have any questions, comments, or feedback.
Thanks for reading!