Programmatic sheet dismissal

Hello!

First of all, thanks for the amazing work on the Swift Composable Architecture!

I am playing with programmatic sheets dismissal. I would like to add a "Cancel" button in the sheet navigation bar that dismissed the sheet when pressed (like in the picture below).

Ideally, I would also like to put this logic in the reducer controlling the sheet, so that I can isolate them in their own module.

The best I could come up so far is trigger the action to dismiss the sheet from its reducer, but handling it from the "upper-level" reducer. While this approach is working perfectly, the logic to handle sheet actions is now spread across different reducers. Are there better approaches?

I paste here the relevant snippets. The rest of the application can be found at the following gist: [SwiftUI + TCA] Programmatic sheet dismissal · GitHub

struct AppState: Equatable {
  var items: [Item] = []
  var addSheet: AddItemSheetState? = nil
  
  var isAddSheetPresented: Bool { self.addSheet != nil }
}

struct AddItemSheetState: Equatable {
  var input: String = ""
  
  var inputTrimmed: String { self.input.trim() }
  var isInputValid: Bool { self.inputTrimmed.count > 0 }
}

enum AppAction {
  case addButtonTapped
  case addSheetDismissed
  
  case addSheet(_ action: AddItemSheetAction)
}

enum AddItemSheetAction {
  case inputChanged(String)
  case cancelButtonTapped
  case doneButtonTapped(String)
}

let addItemSheetReducer = Reducer<AddItemSheetState, AddItemSheetAction, Void> { state, action, _ in
  switch action {
  
  case let .inputChanged(newValue):
    state.input = newValue
    return .none
    
  // TODO: is there a better way to programmatically cancel the sheet from inside it?
  case .cancelButtonTapped, .doneButtonTapped(_):
    // currently, these action are handled in the "upper-level" reducer
    return .none
  }
}

let appReducer = addItemSheetReducer
  .optional()
  .pullback(
    state: \.addSheet,
    action: /AppAction.addSheet,
    environment: { _ in }
  )
  .combined(
    with: Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
      switch action {
      
      case .addButtonTapped:
        state.addSheet = .init()
        return .none
        
      case .addSheetDismissed:
        state.addSheet = nil
        return .none
        
      // TODO: I currently handle sheet cancellation in the "upper-level" reducer
      //       Is there a way to push it to the "addItemSheetReducer"?
      case .addSheet(.cancelButtonTapped):
        state.addSheet = nil
        return .none
        
      // TODO: What is the best way to read the input from the sheet?
      //       I pass the input from the sheet through this action for convenience
      case let .addSheet(.doneButtonTapped(name)):
        // ... this is the alternative way, but I have to force the unwrapping of the optional state :(
        let alternativeName = state.addSheet!.input
        assert(name == alternativeName)
        
        let item = Item(id: environment.id(), name: name)
        state.items.append(item)
        state.addSheet = nil
        return .none
        
      case .addSheet(_):
        return .none
      }
    }
  )

struct MainView: View {
  let store: Store<AppState, AppAction>
  
  var body: some View {
    WithViewStore(self.store) { viewStore in
      NavigationView {
        List {
          ForEach(viewStore.items) { item in
            Text(item.name)
          }
        }
        .navigationBarTitle("Homepage", displayMode: .inline)
        .navigationBarItems(
          trailing:
            Button("Add") {
              viewStore.send(.addButtonTapped)
            }
        )
        .sheet(isPresented: viewStore.binding(
          get: \.isAddSheetPresented,
          send: .addSheetDismissed
        )) {
          IfLetStore(
            self.store.scope(state: \.addSheet, action: AppAction.addSheet),
            then: AddItemSheetView.init(store:)
          )
        }
      }
      .navigationViewStyle(StackNavigationViewStyle())
    }
  }
}

Thanks a lot in advance! :pray: :pray: :pray:

Hey there, you've got two other options you can try out.

First of all, if you simply want the "Cancel" button to dismiss the modal with no other further logic you can use the environment value presentationMode to programmatically dismiss:

struct ModalView: View {
  @Environment(\.presentationMode) var presentationMode

  var body: some View {
    Button("Cancel") { self.presentationMode.dismiss() }
  }
}

This will automatically communicate to the binding that drives the modal, which in term causes an action to be sent to the store (since you are using the viewStore.binding helper), and so everything will "just work".

However, because the .dismiss() is happening entirely in the view, outside the purview of your modal reducer, you won't have the ability to do any custom logic when "Cancel" is tapped (e.g. maybe you want to track analytics or something).

So another way is to create some local state in your modal's domain, and then use that with the .onChange view modifier to determine when the view should invoke presentationMode.dismiss():

struct ModalState {
  var isPresented = true
  ...
}

let modalReducer = Reducer<...> { state, action, environment in 
  switch action {
  case .cancelButtonTapped:
    state.isPresented = false
    return .none
  
  ...
  }
}

struct ModalView: View {
  @Environment(\.presentationMode) var presentationMode

  var body: some View {
    Button("Cancel") { viewStore.send(.cancelButtonTapped) }
      .onChange(viewStore.isPresented) { isPresented in 
        if !isPresented { presentationMode.dismiss() }
      }
  }
}

With the above you have full control over the dismissal of the modal. You could even fire off an async effect, and when you get a response you flip isPresented to false to dismiss.

Hello Brandon,

Thanks for the quick answer. I appreciate it. I experimented with both solutions, and they work like a charm.

Now I am trying to refactor the logic for the "Done" button inside the modal reducer. Pressing "Done" should create a new item with the value in the TextField, append it to the list (shown in the parent view), and finally dismiss the modal. I am trying to use the second technique, but I struggle with appending the item to the list. I need somehow to expand the modal state to include also the list of items, but doing that breaks the nice encapsulation of AppState:

struct AppState: Equatable {
  var items: [Item] = []
  var modal: ModalState? = nil
  ...
}

I played a bit with it, but could not find an elegant way to do it. The best I could think of is passing the entire AppState to the modal reducer:

// this is working, but I feel like there is something wrong in the optional value unwrapping
let modalReducer = Reducer<AppState, ModalAction, AppEnvironment> { state, action, environment in
  switch action {
  
  case let .inputChanged(newValue):
    state.modal?.input = newValue
    return .none
    
  case .cancelButtonTapped:
    state.modal = nil
    return .none
    
  case .doneButtonTapped:
    let item = Item(id: environment.id(), name: state.modal!.inputTrimmed)
    state.items.append(item)
    state.modal = nil
    return .none
  }
}

The nice thing is that I do not need the variable isPresented and the presentationMode environment value since I have direct access to the entire modal state and can use state.modal = nil in the modalReducer to dismiss the modal.

The things I do not like much:

  • The modalReducer now needs to handle nil values.
  • There is a mismatch between the state managed by the modalReducer and what the ModalView really needs (that still accepts a ModalState). Somehow, this feels wrong :thinking:
struct ModalView: View {
  let store: Store<ModalState, ModalAction>
  ...
}

What do you think? Is there a better solution?

Thanks again :pray:

(I updated the gist to include this second attempt: [SwiftUI + TCA] Programmatic sheet dismissal · GitHub)

There is something you can do that is kinda like sharing all of AppState with your modal domain, but it's a little more focused. You can create a new domain that holds everything your core modal feature needs along with any additional data you need from the AppState:

struct ModalFeatureState {
  var items: [Item] = []
  var modal: ModalState
}

Note that this state doesn't hold anything from AppState that your modal feature doesn't need to know about. Your modal reducer will operate on that state, and so it will be quite easy for you to append items to it when the "Done" button is tapped.

But the real magic happens by constructing a custom key path that projects out those two pieces of state from AppState, which allows you to pull back your reducer. That can look like this:

extension AppState {
  var modalFeature: ModalFeatureState? {
    get {
      self.modal.map { 
        .init(items: self.items, modal: $0)
      }
    }
    set {
      self.items = newValue?.items ?? self.items
      self.modal = newValue?.modal
    }
  }
}

If you pull back your reducer along this key path you instantly get the ability to share just a little bit of AppState with your modal reducer, and so any changes your modal reducer makes to it will be instantly reflected in the main application.

Thanks a lot, that did the trick! I even though about something like that, but I was missing this key trick: self.items = newValue?.items ?? self.items. I love the result. I find it very clear and elegant.

I have updated the gist with the entire code of the final solution, in case it might be useful for other developers: [SwiftUI + TCA] Programmatic sheet dismissal · GitHub

Do you think this example might be a good addition to the CaseStudies app in the Composable Architecture repository? I think this is a common use case (I found it in many Apple apps, for example), and many developers at the first steps with the Composable Architecture might benefit from it. I would be happy to contribute with a Pull Request.