Dismissed view empties before animation starts when using SwiftUI navigation in combination with TCA

540 Views Asked by At

We are struggling with SwiftUI navigation in combination with TCA and I am wondering is someone else did encounter similar issue.

The problem is that when we set parameters isPresented or isActive on .sheet or NavigationLink to false to dismiss it, then all content seems to be replaced with empty view before the animation starts (see attached gif).

In the code we store state (boolean value) indicating if child view is presented in parent view. When button to go back on child view is tapped, then we catch this action in parent view and change the boolean value to false to dismiss child view. It works like a charm instead of navigation animation.

I would be endlessly happy for any help or suggestions.

enter image description here

1

There are 1 best solutions below

0
On

I have a similar problem. My example is a bit more involved but arguably more generic. Hope it helps.

My TCA setup looks like this:

struct State: Equatable {
    var child: ChildState?
    // ...
}

One can get the child store by scoping the parent store:

let childStore = parentStore.scope { $0.child ?? ChildState() }

And the child view’s body looks like this:

var body: some View {
    WithViewStore(childStore) { viewStore in
        // ...
    }
}

Here is what happened:

  1. Some reducer sets parent.child = nil.
  2. This notifies every scoped store that the root state changes.
  3. Each scoped store then applies the scope function to get a new child state, and compares it with the previous one. More likely than not, we get a different child state. Some objectWillChange is called.
  4. The corresponding WithViewStore is marked dirty.
  5. The content closure in WithViewStore is called, and generates a new child view tree.
  6. SwiftUI then generates an animation from the old child view tree to the new child view tree, in addition to the dismissal animation for the whole child view tree.

My solution is to kill step 3. First, let's extend the child state with another property:

struct ChildState: Equatable {
    var isBeingDismissed: Bool = false
}

Second, when scoping, let's do:

let childStore = parentStore.scope { x -> ChildState in
    if let child = x.child {
        return child
    }
    var child = ChildState()
    child.isBeingDismissed = true
    return true
}

Lastly, we can modify the WithViewStore with a custom removeDuplicates closure:

var body: some View {
    WithViewStore(childStore) { old, new in 
        if old == new {
            return true
        }
        // Key
        if new.isBeingDismissed {
            return true
        }
        return false
    } content: { viewStore in
        // ...
    }
}

Here is how it works. The scoped child state can be nil in two cases, when the view is first created and the view is being dismissed. In the first case, the old is nil. In the second case, the new is nil. The custom removeDuplicates essentially does this: when a child state is being dismissed, we skip any update. So the only animation being played is the transition animation. The content stays the same.