Mastering SwiftUI’s zIndex: A Comprehensive Guide
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 thezIndex
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.