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.
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.
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.
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:
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.
If we explicitly set the zIndex value for each view, we can solve this display issue.
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()
}
}
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:
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 the display of views, it is best to handle it through opacity
or transition
methods (see the code below).
// 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() }
}
}
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.
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))
}
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.
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 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.
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.
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.