Transitions in ZStacks

The other day, I found a fun little SwiftUI edge case: using a .transition() modifier in a ZStack doesn’t work. As a basic demo, let’s do something like this:

struct DemoView: View {
@State var showing = true

var body: some View {
ZStack {
Rectangle().fill(Color.red)
.onTap {
showing.toggle()
}
if (showing) {
Text("Hello, world!")
.transition(.opacity)
}
}
}
}

Pretty simple example, yeah? Seems like it’d Just Work(TM) out of the box. It doesn’t, though; instead of a lovely opacity animation, it just pops in and out. Hmm.

Worry not, though, as I have a fix:

struct DemoView: View {
@State var showing = true

var body: some View {
ZStack {
Rectangle().fill(Color.red)
.onTap {
showing.toggle()
}
.zIndex(1)
if (showing) {
Text("Hello, world!")
.transition(.opacity)
.zIndex(2)
}
}
}
}

Et voila, your transition works!

Now, I haven’t exactly done rigorous experimentation to figure out why, exactly, this works, but I do have a theory: when SwiftUI removes the Text, it loses its place in the ZStack, and gets assigned to “behind everything else.” By manually specifying the order, the slot stays reserved, and it can stay there while it fades in and out.