How to fix ZStack's views disappear transition not animated in SwiftUI

⋅ 6 min read ⋅ SwiftUI ZStack Transition Animation

Table of Contents

The problem: Disappear transition for a view in ZStack not animated

If you animated hiding/showing transition a view in a ZStack, you might experience a disappear transition, not animated from time to time.

To see the problem clearer, I set up a ZStack with a Color as a background and a Text view that appears and disappears based on the isShow state variable.

struct ContentView: View {
@State private var isShow = true

var body: some View {
ZStack {
Color.pink
.edgesIgnoringSafeArea(.all)
if isShow {
Text("Hello, SwiftUI!")
.font(.system(size: 56, weight: .heavy))
}

VStack {
Spacer()
Button("Show / Hide") {
withAnimation {
isShow.toggle()
}
}.foregroundColor(.black)
}
}
}
}

As you can see, the text view is disappearing without animation, but appearing animation work as expected.

No animation for disappearing transition.
No animation for disappearing transition.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

Cause of the problem

Implicit zIndex isn't preserve rendering order when a view is removing from the ZStack. When we set isShow to false, the Text view is put at the bottom of the ZStack, behind the pink color background. This makes our text view appear to be hiding immediately without animation. We can confirm this behavior by changing our pink background color to half of the screen width.

struct ContentView: View {
@State private var isShow = true

var body: some View {
ZStack {
GeometryReader { geometry in
Color.pink
.edgesIgnoringSafeArea(.all)
.frame(width: geometry.size.width / 2) // 1
}

if isShow {
Text("Hello, SwiftUI!")
.font(.system(size: 56, weight: .heavy))
}

VStack {
Spacer()
Button("Show / Hide") {
withAnimation {
isShow.toggle()
}
}.foregroundColor(.black)
}
}
}
}

<1> We set Color to half the size to see the Text view behind it.

You can see that the Text view is still animated (Fading out), but it was doing so behind the pink color view.

Text view is still animated. It just does so behind the pink background.
Text view is still animated. It just does so behind the pink background.

Investigation

Since we know that the problem revolves around the z-index, let's set up hypotheses and confirm each one of them. In the end, we will know the conditions that cause the bug. If you are in a hurry, you can jump to the solution.

Hypothesis 1: This bug only occurs when zIndex is not set

To test this, I explicitly set zIndex of all views to zero.

struct ContentView: View {
@State private var isShow = true

var body: some View {
ZStack {
Color.pink
.edgesIgnoringSafeArea(.all)
.zIndex(0)

if isShow {
Text("Hello, SwiftUI!")
.font(.system(size: 56, weight: .heavy))
.zIndex(0)
}

VStack {
Spacer()
Button("Show / Hide") {
withAnimation {
isShow.toggle()
}
}.foregroundColor(.black)

}
.zIndex(0)
}
}
}

I got the same problem as when we didn't set z-index, so the cause is not related to whether we implicit or explicit set z-index. So, it might be related to the zIndex value.

Hypothesis 2: This bug only occurs when zIndex is the same

My next guess is that the problem might come from rendering views with the same zIndex, so I set them all to one this time.

struct ContentView: View {
@State private var isShow = true

var body: some View {
ZStack {
Color.pink
.edgesIgnoringSafeArea(.all)
.zIndex(1)

if isShow {
Text("Hello, SwiftUI!")
.font(.system(size: 56, weight: .heavy))
.zIndex(1)
}

VStack {
Spacer()
Button("Show / Hide") {
withAnimation {
isShow.toggle()
}
}.foregroundColor(.black)

}
.zIndex(1)
}
}
}

The problem is gone, and the transition animation shows correctly, so SwiftUI works just fine even though all views got the same zIndex, as long as it is not a non-zero value.

Hypothesis 3: What about negative zIndex

To ensure that the problem only occurs on zero zIndex, let have another experiment with negative zIndex.

struct ContentView: View {
@State private var isShow = true

var body: some View {
ZStack {
Color.pink
.edgesIgnoringSafeArea(.all)
.zIndex(-1)

if isShow {
Text("Hello, SwiftUI!")
.font(.system(size: 56, weight: .heavy))
.zIndex(-1)
}

VStack {
Spacer()
Button("Show / Hide") {
withAnimation {
isShow.toggle()
}
}.foregroundColor(.black)

}
.zIndex(-1)
}
}
}

We set zIndex to -1, and the animation work fine.

Condition that causes the problem

From our hypothesizes, we can conclude that the problem occurs only when the problem view and views below it have zero z-index.

Solution

The problem only occurs when the problem view and views below it have zIndex of zero.

ZStack uses zIndex to control the front-to-back ordering of views. If views got the same zIndex, ZStack arranges each successive child view a higher z-axis value than the one before it.

To fix the problem, make sure you don't rely on the default ordering behavior of ZStack mentioned above when your z-index is zero.

To solve the problem, explicitly set zIndex of the problem view or the views below it to any value other than 0.

Set the problem view's z-index to non-zero value

For our example, we only need to set Text view zIndex to 1.

struct ContentView: View {
@State private var isShow = true

var body: some View {
ZStack {
Color.pink
.edgesIgnoringSafeArea(.all)

if isShow {
Text("Hello, SwiftUI!")
.font(.system(size: 56, weight: .heavy))
.zIndex(1)
}

VStack {
Spacer()
Button("Show / Hide") {
withAnimation {
isShow.toggle()
}
}.foregroundColor(.black)
}
}
}
}

We set zIndex of Text to 1 to avoid the rendering problem.

The transition animation work as expected.
The transition animation work as expected.

Set the views below the problem view to a non-zero value

Depend on your view layout, set zIndex for views below the problem view might be easier for you. In this case, we set the Color view to a negative z-index.

struct ContentView: View {
@State private var isShow = true

var body: some View {
ZStack {
Color.pink
.edgesIgnoringSafeArea(.all)
.zIndex(-1)

if isShow {
Text("Hello, SwiftUI!")
.font(.system(size: 56, weight: .heavy))
}

VStack {
Spacer()
Button("Show / Hide") {
withAnimation {
isShow.toggle()
}
}.foregroundColor(.black)
}
}
}
}

Put the problem view under VStack

If you have a complicated view structure, wrapping the problem view under VStack might be a better choice since you don't have to set zIndex explicitly. With this approach, text view appearing and disappearing won't affect the rendering order since VStack will always be there.

struct ContentView: View {
@State private var isShow = true

var body: some View {
ZStack {
Color.pink
.edgesIgnoringSafeArea(.all)

VStack { // 1
if isShow {
Text("Hello, SwiftUI!")
.font(.system(size: 56, weight: .heavy))
}
}

VStack {
Spacer()
Button("Show / Hide") {
withAnimation {
isShow.toggle()
}
}.foregroundColor(.black)
}
}
}
}

<1> Put the problem view under VStack, so removing the Text view doesn't change the ZStack rendering order.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

Conclusion

This problem only happens when the problem view and views below it got the same zero z-index. I have shown you the condition that causes it and some ways to solve the problem. You can pick the right one that suitable for your situation and your view layout.


Read more article about SwiftUI, ZStack, Transition, Animation, or see all available topic

Enjoy the read?

If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.

Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Buy me a coffee Tweet Share
Previous
Tuist init: How to use Tuist templates to bootstrap your project

Learn how to use, and limitations of tuist init, a command that bootstrap a new project.

Next
Tuist scaffold: How to use the Tuist template to create a new module for an ongoing project

Learn how the scaffold command helps you to bootstrap new components or features such as a new VIPER module or a new framework for your new feature.

← Home