Skip to content

Instantly share code, notes, and snippets.

@simme
Created April 4, 2023 08:44
Show Gist options
  • Save simme/cff23d521653f380310c04fc785364c5 to your computer and use it in GitHub Desktop.
Save simme/cff23d521653f380310c04fc785364c5 to your computer and use it in GitHub Desktop.
UIKit helpers for presenting alerts and confirmation dialogs
/*
Example usage:
confirmationDialog(
store: store.scope(state: \.destination, action: UserRecipesListReducer.Action.destination),
state: /UserRecipesListReducer.Destination.State.confirmation,
action: UserRecipesListReducer.Destination.Action.confirmation
).store(in: &subscriptions)
alert(
store: store.scope(state: \.destination, action: UserRecipesListReducer.Action.destination),
state: /UserRecipesListReducer.Destination.State.alert,
action: UserRecipesListReducer.Destination.Action.alert
).store(in: &subscriptions)
*/
extension StoreViewController {
func alert<DestinationState, DestinationAction, Action>(
store: Store<DestinationState?, PresentationAction<DestinationAction>>,
state toAlertState: @escaping (DestinationState) -> AlertState<Action>?,
action fromAlertAction: @escaping (Action) -> DestinationAction
) -> any Cancellable {
self.alert(
store: store.scope(
state: { $0.flatMap(toAlertState) },
action: {
switch $0 {
case .dismiss: return .dismiss
case let .presented(action): return .presented(fromAlertAction(action))
}
}
)
)
}
func alert<Action>(
store: Store<AlertState<Action>?, PresentationAction<Action>>
) -> any Cancellable {
let viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) })
return viewStore.publisher.sink { alertState in
if let alertState {
let alertController = UIAlertController(state: alertState) { action in
if let action {
viewStore.send(.presented(action))
}
}
self.present(alertController, animated: true)
} else {
self.dismiss(animated: true)
}
}
}
func confirmationDialog<DestinationState, DestinationAction, Action>(
store: Store<DestinationState?, PresentationAction<DestinationAction>>,
state toConfirmationDialogState: @escaping (DestinationState) -> ConfirmationDialogState<Action>?,
action fromConfirmationAction: @escaping (Action) -> DestinationAction
) -> any Cancellable {
self.confirmationDialog(
store: store.scope(
state: { $0.flatMap(toConfirmationDialogState) },
action: {
switch $0 {
case .dismiss: return .dismiss
case let .presented(action): return .presented(fromConfirmationAction(action))
}
}
)
)
}
func confirmationDialog<Action>(
store: Store<ConfirmationDialogState<Action>?, PresentationAction<Action>>
) -> any Cancellable {
let viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) })
return viewStore.publisher.sink { confirmationState in
if let confirmationState {
let alertController = UIAlertController(state: confirmationState) { action in
if let action {
viewStore.send(.presented(action))
}
}
self.present(alertController, animated: true)
} else {
self.dismiss(animated: true)
}
}
}
}
@acosmicflamingo
Copy link

acosmicflamingo commented Apr 4, 2023

This is NICE API! :) I see there isn't anything for navigationDestination, so maybe this will help ;)

/*
Example usage:

    navigationDestination(
      store: store.scope(state: \.destination, action: /UserRecipesListReducer.Action.destination),
      state: /UserRecipesListReducer.Destination.State.settings,
      action: UserRecipesListReducer.Destination.Action.settings,
    ) { store in
      UserRecipesListReducerViewController(store: store)
    }.store(in: &subscriptions)

 */
extension StoreViewController {
  public func navigationDestination<
    DestinationState, DestinationAction, State, Action
  >(
    store: Store<DestinationState?, PresentationAction<DestinationAction>>,
    state toAlertState: @escaping (DestinationState) -> State?,
    action fromAlertAction: @escaping (Action) -> DestinationAction,
    _ destination: @escaping (Store<State, Action>) -> some UIViewController,
    animated: Bool? = nil
  ) -> any Cancellable {
    _navigationDestination(
      store: store.scope(
        state: { $0.flatMap(toAlertState) },
        action: {
          switch $0 {
          case let .presented(action):
            return .presented(fromAlertAction(action))

          case .dismiss:
            return .dismiss
          }
        }
      ),
      destination: destination,
      animated: animated
    )
  }

  private func _navigationDestination<State, Action>(
    store: Store<State?, PresentationAction<Action>>,
    destination: @escaping (Store<State, Action>) -> some UIViewController,
    animated: Bool? = nil
  ) -> any Cancellable {
    store.ifLet { [weak self] store in
      guard let self else { return }

      navigationController?.pushViewController(
        destination(
          store.scope(
            state: { $0 },
            action: { .presented($0) }
          )
        ),
        animated: animated ?? (viewIfLoaded?.window != nil)
      )
    } else: { [weak self] in
      guard let self else { return }

      // When view controller intitially subscribes to
      // publisher, the `else` block will be executed.
      // This can be quite annoying when setting up
      // multiple navigation destinations in the same
      // view controller. Better to only pop when the
      // view controller has actually been loaded into view
      if viewIfLoaded?.window != nil {
        navigationController?.popToViewController(self, animated: animated ?? true)
      }
    }
  }
}

@acosmicflamingo
Copy link

Ah, I thought I introduced a retain cycle but it turns out that some of your closures could benefit from weakifying self:

extension StoreViewController {

  func alert<DestinationState, DestinationAction, Action>(
    store: Store<DestinationState?, PresentationAction<DestinationAction>>,
    state toAlertState: @escaping (DestinationState) -> AlertState<Action>?,
    action fromAlertAction: @escaping (Action) -> DestinationAction
  ) -> any Cancellable {
    self.alert(
      store: store.scope(
        state: { $0.flatMap(toAlertState) },
        action: {
          switch $0 {
          case .dismiss: return .dismiss
          case let .presented(action): return .presented(fromAlertAction(action))
          }
        }
      )
    )
  }

  func alert<Action>(
    store: Store<AlertState<Action>?, PresentationAction<Action>>
  ) -> any Cancellable {
    let viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) })
-   return viewStore.publisher.sink { alertState in
+   return viewStore.publisher.sink { [weak self] alertState in
      if let alertState {
        let alertController = UIAlertController(state: alertState) { action in
          if let action {
            viewStore.send(.presented(action))
          }
        }
-       self.present(alertController, animated: true)
+       self?.present(alertController, animated: true)
      } else {
-       self.dismiss(animated: true)
+       self?.dismiss(animated: true)
      }
    }
  }

  func confirmationDialog<DestinationState, DestinationAction, Action>(
    store: Store<DestinationState?, PresentationAction<DestinationAction>>,
    state toConfirmationDialogState: @escaping (DestinationState) -> ConfirmationDialogState<Action>?,
    action fromConfirmationAction: @escaping (Action) -> DestinationAction
  ) -> any Cancellable {
    self.confirmationDialog(
      store: store.scope(
        state: { $0.flatMap(toConfirmationDialogState) },
        action: {
          switch $0 {
          case .dismiss: return .dismiss
          case let .presented(action): return .presented(fromConfirmationAction(action))
          }
        }
      )
    )
  }

  func confirmationDialog<Action>(
    store: Store<ConfirmationDialogState<Action>?, PresentationAction<Action>>
  ) -> any Cancellable {
    let viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) })
-   return viewStore.publisher.sink { confirmationState in
+   return viewStore.publisher.sink { [weak self] confirmationState in
      if let confirmationState {
        let alertController = UIAlertController(state: confirmationState) { action in
          if let action {
            viewStore.send(.presented(action))
          }
        }
-       self.present(alertController, animated: true)
+       self?.present(alertController, animated: true)
      } else {
-       self.dismiss(animated: true)
+       self?.dismiss(animated: true)
      }
    }
  }
}

@simme
Copy link
Author

simme commented Apr 5, 2023

How does the child state get nilled when the user backs up with the back button?

@acosmicflamingo
Copy link

@simme that's a really good point...I created my own navigation controller and I had to override the popViewController like this:

public final class MyNavigationController: UIViewController {
  // ...
  override public func popViewController(animated: Bool) -> UIViewController? {
    closeSearchControllersIfNeeded()
    configureTransitionAnimationIfNeeded(animated: animated)
    let poppedViewController = super.popViewController(animated: animated)
    if let navigationSupportedController = poppedViewController as?
        UIViewController & TCANavigationSupport,
       animationPhase == .popFromTap
    {
      // Where you'd call popViewController from StoreViewController
      navigationSupportedController.popViewController()
    }
    return poppedViewController
  }

Then in your StoreViewController you'd do something like this:

public final class StoreViewController: UIViewController {
  // ...
  public func popViewController() {
    viewStore.send(.requestToPopViewController)
  }
}

Finally, in your reducer you nil out state:

public struct MyReducer: Reducer {
  // ..
  case .requestToPopViewController:
  defer { state.destination = nil }
  return .none
}

An absolute boilerplate party, which I wish I didn't have to attend but I want to support iOS 15 (which means I can't override navigationItem.backButtonAction), and I don't like how I lose the original back button design when creating my own back button. It's an ugly workaround :(

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment