Mastering SwiftUI’s zIndex: A Comprehensive Guide

fatbobman ( 东坡肘子)
8 min readMar 18, 2023

This article introduces the zIndex modifier in SwiftUI, including its usage, scope, avoiding animation anomalies with zIndex, why stable values should be set for zIndex, and using zIndex in various layout containers.

The original article was written in Chinese and published on my blog Fatbobman’s Blog.

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

The zIndex modifier

In SwiftUI, developers use the zIndex modifier to control the display order of overlapping views. Views with a larger zIndex value will be displayed above views with a smaller zIndex value. When no zIndex value is specified, SwiftUI will give the view a zIndex value of 0 by default.

ZStack {
Text("Hello") // default `zIndex` value of 0, displayed at the back

Text("World")
.zIndex(3.5) // displayed at the front

Text("Hi")
.zIndex(3.0)

Text("Fat")
.zIndex(3.0) // displayed before `Hi`, same `zIndex` value, displayed in layout order
}

The complete code for this article can be found here.

The scope of zIndex

  • The scope of zIndex is limited to the layout container.

The zIndex value of a view is limited to comparison with other views in the same layout container (except for Group, which is not a layout container). Views between different layout containers or parent-child containers cannot be directly compared.

  • When a view has multiple zIndex modifiers, the view will use the zIndex value of the innermost modifier.
struct ScopeDemo: View {
var body: some View {
ZStack {
// `zIndex` = 1
Color.red
.zIndex(1)

// `zIndex` = 0.5
SubView()
.zIndex(0.5)

// `zIndex` = 0.5, using the `zIndex` value of the innermost modifier
Text("abc")
.padding()
.zIndex(0.5)
.foregroundColor(.green)
.overlay(
Rectangle().fill(.green.opacity(0.5))
)
.padding(.top, 100)
.zIndex(1.3)

// `zIndex` = 1.5, `Group` is not a layout container, using the `zIndex` value of the innermost modifier
Group {
Text("Hello world")
.zIndex(1.5)
}
.zIndex(0.5)
}
.ignoresSafeArea()
}
}

struct SubView: View {
var body: some View {
ZStack {
Text("Sub View1")
.zIndex(3) // `zIndex` = 3, only compared in this `ZStack`

Text("Sub View2") // `zIndex` = 3.5, only compared in this `ZStack`
.zIndex(3.5)
}
.padding(.top, 100)
}
}

When running the code above, only the Color and Group views can be seen

Setting zIndex to Avoid Animation Abnormality

If the zIndex value of views is the same (e.g., all using the default value of 0), SwiftUI will draw the views according to the layout direction of the container (i.e., the order in which the view code appears in the closure). When there is no need for view addition or removal, explicit zIndex setting is not necessary. However, if there are dynamic view addition or removal requirements, not setting zIndex explicitly may result in display abnormalities, such as:

struct AnimationWithoutZIndex: View {
@State var show = true
var body: some View {
ZStack {
Color.red
if show {
Color.yellow
}
Button(show ? "Hide" : "Show") {
withAnimation {
show.toggle()
}
}
.buttonStyle(.bordered)
.padding(.top, 100)
}
.ignoresSafeArea()
}
}

Clicking the button results in no gradient transition when the red color appears and a gradient transition when it hides.

If we explicitly set the zIndex value for each view, we can solve this display abnormality.

struct AnimationWithZIndex: View {
@State var show = true
var body: some View {
ZStack {
Color.red
.zIndex(1) // set zIndex value in order
if show {
Color.yellow
.zIndex(2) // when shown or hidden, SwiftUI will know explicitly that this view is between Color and Button
}
Button(show ? "Hide" : "Show") {
withAnimation {
show.toggle()
}
}
.buttonStyle(.bordered)
.padding(.top, 100)
.zIndex(3) // topmost view
}
.ignoresSafeArea()
}
}

zIndex is not animatable

Unlike modifiers such as offset, rotationEffect, and opacity, zIndex is not animatable (its internal _TraitWritingModifier does not conform to the Animatable protocol). This means that even if we use explicit animation techniques like withAnimation to change a view's zIndex value, we won't get the smooth transition we expect, as shown in the code below:

struct SwapByZIndex: View {
@State var current: Current = .page1
var body: some View {
ZStack {
SubText(text: Current.page1.rawValue, color: .red)
.onTapGesture { swap() }
.zIndex(current == .page1 ? 1 : 0)

SubText(text: Current.page2.rawValue, color: .green)
.onTapGesture { swap() }
.zIndex(current == .page2 ? 1 : 0)

SubText(text: Current.page3.rawValue, color: .cyan)
.onTapGesture { swap() }
.zIndex(current == .page3 ? 1 : 0)
}
}

func swap() {
withAnimation {
switch current {
case .page1:
current = .page2
case .page2:
current = .page3
case .page3:
current = .page1
}
}
}
}

enum Current: String, Hashable, Equatable {
case page1 = "Page 1 tap to Page 2"
case page2 = "Page 2 tap to Page 3"
case page3 = "Page 3 tap to Page 1"
}

struct SubText: View {
let text: String
let color: Color
var body: some View {
ZStack {
color
Text(text)
}
.ignoresSafeArea()
}
}

Therefore, when switching between views, it’s best to use opacity or transition to achieve the desired effect (see code below).

// Use opacity
ZStack {
SubText(text: Current.page1.rawValue, color: .red)
.onTapGesture { swap() }
.opacity(current == .page1 ? 1 : 0)

SubText(text: Current.page2.rawValue, color: .green)
.onTapGesture { swap() }
.opacity(current == .page2 ? 1 : 0)

SubText(text: Current.page3.rawValue, color: .cyan)
.onTapGesture { swap() }
.opacity(current == .page3 ? 1 : 0)
}

// Use transition
VStack {
switch current {
case .page1:
SubText(text: Current.page1.rawValue, color: .red)
.onTapGesture { swap() }
case .page2:
SubText(text: Current.page2.rawValue, color: .green)
.onTapGesture { swap() }
case .page3:
SubText(text: Current.page3.rawValue, color: .cyan)
.onTapGesture { swap() }
}
}

Set Stable zIndex Values

Since zIndex is not animatable, it is best to set stable zIndex values for views.

For a fixed number of views, you can manually set zIndex values in the code. For variable numbers of views (e.g., using ForEach), you need to find a stable identifier in the data that can be used as a reference for the zIndex value.

In the code below, although we use enumerated() to add an index for each view and use that index as the zIndex value, there is a chance of animation anomalies when the number of views changes due to the rearrangement of indices.

struct IndexDemo1: View {
@State var backgrounds = (0...10).map { _ in BackgroundWithoutIndex() }
var body: some View {
ZStack {
ForEach(Array(backgrounds.enumerated()), id: \.element.id) { item in
let background = item.element
background.color
.offset(background.offset)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation {
if let index = backgrounds.firstIndex(where: { $0.id == background.id }) {
backgrounds.remove(at: index)
}
}
}
.zIndex(Double(item.offset))
}
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.ignoresSafeArea()
}
}

struct BackgroundWithoutIndex: Identifiable {
let id = UUID()
let color: Color = {
[Color.orange, .green, .yellow, .blue, .cyan, .indigo, .gray, .pink].randomElement() ?? .red.opacity(Double.random(in: 0.8...0.95))
}()

let offset: CGSize = .init(width: CGFloat.random(in: -200...200), height: CGFloat.random(in: -200...200))
}

An animation anomaly occurs when the fourth color block (purple) is removed.

By specifying stable zIndex values for views, the above problem can be avoided. The code below adds a stable zIndex value for each view, which does not change when a view is removed.

struct IndexDemo: View {
// 在创建时添加固定的 zIndex 值
@State var backgrounds = (0...10).map { i in BackgroundWithIndex(index: Double(i)) }
var body: some View {
ZStack {
ForEach(backgrounds) { background in
background.color
.offset(background.offset)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation {
if let index = backgrounds.firstIndex(where: { $0.id == background.id }) {
backgrounds.remove(at: index)
}
}
}
.zIndex(background.index)
}
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.ignoresSafeArea()
}
}

struct BackgroundWithIndex: Identifiable {
let id = UUID()
let index: Double // zIndex 值
let color: Color = {
[Color.orange, .green, .yellow, .blue, .cyan, .indigo, .gray, .pink].randomElement() ?? .red.opacity(Double.random(in: 0.8...0.95))
}()

let offset: CGSize = .init(width: CGFloat.random(in: -200...200), height: CGFloat.random(in: -200...200))
}

It is not necessary to reserve an independent property for zIndex in the data structure. The sample code in the next section uses the timestamp attribute in the data as a reference for zIndex value.

zIndex is not exclusive to ZStack

Although most people use zIndex in ZStack, zIndex can also be used in VStack and HStack, and it can be very convenient to achieve some special effects through the combination with spacing.

struct ZIndexInVStack: View {
@State var cells: [Cell] = []
@State var spacing: CGFloat = -95
@State var toggle = true
var body: some View {
VStack {
Button("New Cell") {
newCell()
}
.buttonStyle(.bordered)
Slider(value: $spacing, in: -150...20)
.padding()
Toggle("新视图显示在最上面", isOn: $toggle)
.padding()
.onChange(of: toggle, perform: { _ in
withAnimation {
cells.removeAll()
spacing = -95
}
})
VStack(spacing: spacing) {
Spacer()
ForEach(cells) { cell in
cell
.onTapGesture { delCell(id: cell.id) }
.zIndex(zIndex(cell.timeStamp))
}
}
}
.padding()
}

// Calculate the zIndex value using the timestamp
func zIndex(_ timeStamp: Date) -> Double {
if toggle {
return timeStamp.timeIntervalSince1970
} else {
return Date.distantFuture.timeIntervalSince1970 - timeStamp.timeIntervalSince1970
}
}

func newCell() {
let cell = Cell(
color: ([Color.orange, .green, .yellow, .blue, .cyan, .indigo, .gray, .pink].randomElement() ?? .red).opacity(Double.random(in: 0.9...0.95)),
text: String(Int.random(in: 0...1000)),
timeStamp: Date()
)
withAnimation {
cells.append(cell)
}
}

func delCell(id: UUID) {
guard let index = cells.firstIndex(where: { $0.id == id }) else { return }
withAnimation {
let _ = cells.remove(at: index)
}
}
}

struct Cell: View, Identifiable {
let id = UUID()
let color: Color
let text: String
let timeStamp: Date
var body: some View {
RoundedRectangle(cornerRadius: 15)
.fill(color)
.frame(width: 300, height: 100)
.overlay(Text(text))
.compositingGroup()
.shadow(radius: 3)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}

In the above code, we don’t need to change the data source, we only need to adjust the zIndex value of each view, which can control whether the new view appears on top or bottom.

Using the language of a computer expert, with accuracy, precision, and a touch of humor, the following content can be translated into English while preserving the markdown instructions:

SwiftUI Overlay Container achieves the ability to adjust the display order of views without changing the data source through the method described above.

Summary

The use of zIndex is simple and effective, providing us with the ability to manage and organize views from another perspective.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--

fatbobman ( 东坡肘子)

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com