Optional State for Sheet and onDisappear actions

How do you correctly call onDisappear action, when Sheet gets interactively dismissed (drag)?

Let's say I have parent state with Route enum, indicating whether I should be displaying sheet. This child (sheet) state has some kind of side effects, in this case running Camera session for scanning codes. When we call onDisappear on Camera view, it gets correctly deinitialized and camera stops running (green indicator in status bar disappears).

However if user dismisses entire sheet, before scanning any code, this camera session is still running and simply setting state.route = nil erases the child state and subsequently the onDisappear action is ignored and even throws a runtime error about state being nil.

Camera indicator stays on until either app is closed or user reenters the Camera view and scans a valid code, which triggers a NavigationLink and onDisappear on CameraView gets called

Parent state:

public struct ParentState: Equatable {
    public var route: Route?

    public enum Route: Equatable {
        ...
        case qrScannerSheet(SheetState)
        ...
    }
}

View code:

.sheet(isPresented: viewStore.binding(
    get: \.showingSheet,
    send: ParentAction.setShowingSheet
)) {
    IfLetStore(store.scope(
        state: { (/ParentState.Route.sheet).extract(from: $0.route) },
        action: ParentAction.sheetAction
    ), then: SheetView.init(store:))
}

Parent reducer:

case .setShowingSheet(true): return .none
case .setShowingSheet(false):
    state.route = nil
    return .none

EDIT:
Managed to solve it by changing the binding action on sheet to send a new child "clean" action on false case, which first clears all state inside child state and then react to this action in Parent Reducer to set route to nil.

send: { $0 ? .setShowingSheet(true) : .sheet(.clean) })
and react to it in reducer with

case .sheet(.clean):
    return .init(value: . setShowingSheet(false))

This feels like a hack tho, since the parent has to know about this "clean" action, and how to call and react to it.

1 Like

Hi @Berhtulf, this is a good question, and unfortunately it is a sharp edge. Currently the best way to handle is as you have done. The parent is the only domain that can see the "on disappear" event since by the time the child domain gets that action, the state has already been nil'd out.

We do have plans to improve this very soon. We are currently putting the finishing touches on adding more concurrency tools to the library (see here), and included in those tools is the ability to tie an effect to the lifetime of the view so that it is automatically cancelled when the view goes away.

And then in the near future we have plans to add navigation tools to the library (here's a sneak peek for stacks) that further ties an entire feature's domain to the lifecycle of a view.

In the meantime what you have done should be sufficient though!


Another option that comes to mind, if you really do want to get ahold of onDisappear in the view, is to make the child state non-optional in the parent, and instead drive navigation via a boolean. This is not ideal from a domain modeling perspective as it allows invalid states, but it does mean you don't have to worry about sending actions when state is nil.

Hope this helps!

3 Likes

Since this is from 2022, is there an updated recommendation on how to handle onDisappear from a child view presented from a sheet?

Right now from parent state we have

@Presents var child: Child.State?

and from parent action we have

case chilldAction(PresentationAction<Child.Action>)

and from parent reducer we have

.ifLet(\.$child, action: /Action.childAction) {
  Child()
}

When the child view is dismissed, if an onDisappear action is sent, I still get this runtime warning:

An "ifLet" at "ParentFeature.swift" received a presentation action when destination state was absent. This is generally considered an application logic error, and can happen for a few reasons: A parent reducer set destination state to "nil" before this reducer ran.
...

What is the best way to run some logic when the child view is closed, ideally without making it non-optional?

Hi @bao, what kind of logic are you wanting to execute when the view disappears?

For now I'm just trying to cancel some Combine publishers that are set up when the view appeared, something like this in the reducer:

case .onAppear:
  return .publisher { ... }.cancellable(id: someId)
case .onDisappear:
  return .cancel(someId)

Is this even necessary? (i.e. with ifLet on parent reducer, does side effect cancellable happen automatically for child reducers?)

Hi @bao, if using the navigation tools from the library, such as ifLet, effects will be automatically cancelled when state is nil'd out. No need to do it in onDisappear.

Thanks a lot @mbrandonw! In case in the future if we want to perform some manual operations (for example sending an analytics event indicating that user closed the child feature sheet), what would be a good place to do so?

The techniques I mentioned above would work, but here is the more modern way of achieving it.

First you need some kind of onFirstAppear view modifier for SwiftUI in order to send just one single action to the store when a view first appears. It's straightforward to make such a modifier, but there are examples online.

Then you would add an onFirstAppear action to your domain, and you would run a long living effect that suspends until cancellation:

case .onFirstAppear: 
  return .run { _ in 
    try? await Task.never() // Suspends until cancellation
    // Track analytics here
  }

Now one tricky thing here is if your analytics client cooperatively cancels. I think there are good reasons for having it cooperatively cancel, as well as not being cooperative, but if it is cooperative then any analytics you track above will not actually track since the async context has been cancelled. So, if you want to force some work to be executed regardless of the surrounding cancellation context, you can use our fireAndForget dependency:

@Dependency(\.fireAndForget) var fireAndForget

…

case .onFirstAppear: 
  return .run { _ in 
    try? await Task.never() // Suspends until cancellation
    await fireAndForget { 
      // Track analytics here      
    }
  }
3 Likes

This is pretty cool, thanks!

1 Like