Mastering zIndex in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

This article introduces the zIndex modifier in SwiftUI, including how to use it, the scope of the zIndex, how to avoid animation anomalies with zIndex, why a stable value is needed for zIndex, and how to use zIndex in multiple layout containers.

zIndex Modifier

In SwiftUI, developers use the zIndex modifier to control the display order of overlapping views. Views with higher zIndex values will be displayed on top of views with lower zIndex values. When no zIndex value is specified, SwiftUI defaults to a zIndex value of 0 for the view.

Swift
ZStack {
    Text("Hello") // Default zIndex value is 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, with the same zIndex value, displayed according to the layout order
}

You can get the complete code for this article here

Scope of zIndex

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

The zIndex value of a view can only be compared with other views that are within the same layout container (Group is not a layout container). Views that are in different layout containers or parent-child containers cannot be compared directly.

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

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

            // zIndex = 0.5, use 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, use 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 compare within this ZStack

            Text("Sub View2") // zIndex = 3.5 , only compare within this ZStack
                .zIndex(3.5)
        }
        .padding(.top, 100)
    }
}

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

https://cdn.fatbobman.com/image-20220409170346551.png

Set zIndex to avoid animation issues

If the zIndex values of views are the same (such as all using the default value 0), SwiftUI will draw the views according to the layout direction of the layout container (the order of appearance of views in the closure). When there is no need to add or remove views, you don’t need to explicitly set the zIndex. But if there are dynamic needs to add or remove views, some display anomalies may occur if the zIndex is not explicitly set, such as:

Swift
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()
    }
}

When you click the button, there is no gradual transition when the red color appears, but there is a gradual transition when it is hidden.

https://cdn.fatbobman.com/animationException20220409.gif

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

Swift
struct AnimationWithZIndex: View {
    @State var show = true
    var body: some View {
        ZStack {
            Color.red
                .zIndex(1) // bottom view
            if show {
                Color.yellow
                    .zIndex(2) // between Color and Button
            }
            Button(show ? "Hide" : "Show") {
                withAnimation {
                    show.toggle()
                }
            }
            .buttonStyle(.bordered)
            .padding(.top, 100)
            .zIndex(3) // top view
        }
        .ignoresSafeArea()
    }
}

https://cdn.fatbobman.com/zIndexAnimation2022-04-09%2017.15.18.2022-04-09%2017_17_08.gif

zIndex is not animatable

Unlike modifiers such as offset, rotationEffect, and opacity, zIndex is not animatable (its corresponding _TraitWritingModifier does not conform to the Animatable protocol). This means that even if we use explicit animation methods such as withAnimation to change the zIndex value of a view, the expected smooth transition will not occur, for example:

Swift
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()
    }
}

https://cdn.fatbobman.com/swapWithzIndex2022-04-09%2017.31.01.2022-04-09%2017_33_07.gif

Therefore, when switching the display of views, it is best to handle it through opacity or transition methods (see the code below).

Swift
// 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)
}

// 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() }
    }
}

https://cdn.fatbobman.com/swapWithTransition2022-04-09%2017.36.08.2022-04-09%2017_38_34.gif

Set a stable value for zIndex

As zIndex is not animatable, it is recommended to set a stable zIndex value for views whenever possible.

For a fixed number of views, you can manually annotate the code. For variable numbers of views (such as using ForEach), you need to find a stable identifier in the data that can serve as a reference for zIndex values.

For example, in the following code, although we use enumerated to add an index to each view and use this index as the zIndex value for the view, there is a chance of animation anomalies occurring when views are added or removed due to the reordering of the index.

Swift
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))
}

https://cdn.fatbobman.com/unStablezIndex2022-04-09%2017.47.49.2022-04-09%2017_49_14.gif

When the fourth color block (purple) is deleted, an exception occurs.

To avoid the above problem, specify a stable zIndex value for the view. The following code adds a stable zIndex value for each view, which will not change even if a view is deleted.

Swift
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))
}

https://cdn.fatbobman.com/stableZindex2022-04-09%2018.07.18.2022-04-09%2018_09_12.gif

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 property in the data as a reference for the zIndex value.

zIndex is not exclusive to ZStack

Although most people use zIndex in ZStack, zIndex can also be used in VStack and HStack. By combining it with spacing, it can be very convenient to achieve certain special effects.

Swift
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()
    }

    // get zIndex by 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, just adjust the zIndex value of each view to control whether the new view appears on top or bottom.

https://cdn.fatbobman.com/zIndexInVStack2022-04-09%2019.18.42.2022-04-09%2019_20_20.gif

SwiftUI Overlay Container is a way to adjust the display order of views without changing the data source.

Summary

zIndex is easy to use and provides us with the ability to schedule and organize views from another dimension.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed