Skip to content

Instantly share code, notes, and snippets.

@davidepedranz
Last active April 15, 2023 12:40
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save davidepedranz/0856384fccf91485027612b375eb020a to your computer and use it in GitHub Desktop.
Save davidepedranz/0856384fccf91485027612b375eb020a to your computer and use it in GitHub Desktop.
[SwiftUI + TCA] Programmatic sheet dismissal
///
/// Demo application based on SwiftUI and The Composable Architecture (TCA)
/// that shows a possible approach to programmativally dismiss sheets.
///
/// Is this approach the best one? Fedback wanted :pray:
///
import ComposableArchitecture
import SwiftUI
// MARK - application entrypoint
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
MainView(
store: Store(
initialState: AppState(),
reducer: appReducer.debug(),
environment: .live
)
)
}
}
}
// MARK - MainView
struct Item: Equatable, Identifiable {
let id: UInt64
let name: String
}
struct AppState: Equatable {
var items: [Item] = []
var addSheet: AddItemSheetState? = nil
var isAddSheetPresented: Bool { self.addSheet != nil }
}
enum AppAction {
case addButtonTapped
case addSheetDismissed
case addSheet(_ action: AddItemSheetAction)
}
struct AppEnvironment {
var id: () -> UInt64
static let live = AppEnvironment(
id: { UInt64.random(in: 0...UInt64.max) }
)
}
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())
}
}
}
// MARK - AddSheet
struct AddItemSheetState: Equatable {
var input: String = ""
var inputTrimmed: String { self.input.trim() }
var isInputValid: Bool { self.inputTrimmed.count > 0 }
}
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
}
}
struct AddItemSheetView: View {
let store: Store<AddItemSheetState, AddItemSheetAction>
var body: some View {
WithViewStore(self.store) { viewStore in
NavigationView {
Form {
TextField("Name", text: viewStore.binding(
get: \.input,
send: AddItemSheetAction.inputChanged
))
}
.navigationBarTitle("Add Item", displayMode: .inline)
.navigationBarItems(
leading:
Button("Cancel") {
viewStore.send(.cancelButtonTapped)
},
trailing:
Button("Done") {
viewStore.send(.doneButtonTapped(viewStore.inputTrimmed))
}
.disabled(!viewStore.isInputValid)
)
}
}
}
}
// MARK - extensions
extension String {
func trim() -> String {
return self.trimmingCharacters(in: NSCharacterSet.whitespaces)
}
}
///
/// Demo application based on SwiftUI and The Composable Architecture (TCA)
/// that shows a possible approach to programmativally dismiss sheets.
///
/// Attempt #2
///
import ComposableArchitecture
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ListView(
store: Store(
initialState: AppState(),
reducer: appReducer.debug(),
environment: .live
)
)
}
}
}
struct Item: Equatable, Identifiable {
let id: UInt64
let name: String
}
struct AppState: Equatable {
var items: [Item] = []
var modal: ModalState? = nil
var isAddSheetPresented: Bool { self.modal != nil }
}
enum AppAction {
case list(ListAction)
case modal(ModalAction)
}
struct AppEnvironment {
var id: () -> UInt64
static let live = AppEnvironment(
id: { UInt64.random(in: 0...UInt64.max) }
)
}
// composition here looks fine
let appReducer = Reducer.combine(
listReducer.pullback(
state: \.self,
action: /AppAction.list,
environment: { _ in }
),
modalReducer.pullback(
state: \.self,
action: /AppAction.modal,
environment: { $0 }
)
)
enum ListAction {
case addButtonTapped
case addSheetDismissed
}
let listReducer = Reducer<AppState, ListAction, Void> { state, action, _ in
switch action {
case .addButtonTapped:
state.modal = .init()
return .none
case .addSheetDismissed:
state.modal = nil
return .none
}
}
struct ListView: View {
let store: Store<AppState, AppAction>
var body: some View {
let listStore: Store<AppState, ListAction> = self.store.scope(
state: { $0 },
action: AppAction.list
)
WithViewStore(listStore) { 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: \.modal, action: AppAction.modal)) { store in
ModalView(store: store)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
struct ModalState: Equatable {
var input: String = ""
var inputTrimmed: String { self.input.trim() }
var isInputValid: Bool { self.inputTrimmed.count > 0 }
}
enum ModalAction {
case inputChanged(String)
case cancelButtonTapped
case doneButtonTapped
}
// TODO: 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
}
}
struct ModalView: View {
let store: Store<ModalState, ModalAction>
var body: some View {
WithViewStore(self.store) { viewStore in
NavigationView {
Form {
TextField("Name", text: viewStore.binding(
get: \.input,
send: ModalAction.inputChanged
))
}
.navigationBarTitle("Add Item", displayMode: .inline)
.navigationBarItems(
leading:
Button("Cancel") {
viewStore.send(.cancelButtonTapped)
},
trailing:
Button("Done") {
viewStore.send(.doneButtonTapped)
}
.disabled(!viewStore.isInputValid)
)
}
}
}
}
extension String {
func trim() -> String {
return self.trimmingCharacters(in: NSCharacterSet.whitespaces)
}
}
///
/// Demo application based on SwiftUI and The Composable Architecture (TCA)
/// that shows a possible approach to programmativally dismiss sheets.
///
/// Attempt #3 - everything is working now, thanks to @mbrandonw input :pray:
///
import ComposableArchitecture
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ListView(
store: Store(
initialState: AppState(),
reducer: appReducer.debug(),
environment: .live
)
)
}
}
}
struct Item: Equatable, Identifiable {
let id: UInt64
let name: String
}
struct AppState: Equatable {
var items: [Item] = []
var modal: ModalState? = nil
var isAddSheetPresented: Bool { self.modal != nil }
}
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
}
}
}
enum AppAction {
case list(ListAction)
case modal(ModalAction)
}
struct AppEnvironment {
var id: () -> UInt64
static let live = AppEnvironment(
id: { UInt64.random(in: 0...UInt64.max) }
)
}
// composition here looks fine
let appReducer = Reducer.combine(
listReducer.pullback(
state: \.self,
action: /AppAction.list,
environment: { _ in }
),
modalReducer
.optional()
.pullback(
state: \.modalFeature,
action: /AppAction.modal,
environment: { $0 }
)
)
enum ListAction {
case addButtonTapped
case addSheetDismissed
}
let listReducer = Reducer<AppState, ListAction, Void> { state, action, _ in
switch action {
case .addButtonTapped:
state.modal = .init()
return .none
case .addSheetDismissed:
state.modal = nil
return .none
}
}
struct ListView: View {
let store: Store<AppState, AppAction>
var body: some View {
let listStore: Store<AppState, ListAction> = self.store.scope(
state: { $0 },
action: AppAction.list
)
WithViewStore(listStore) { 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: \.modal, action: AppAction.modal)) { store in
ModalView(store: store)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
struct ModalFeatureState {
var items: [Item] = []
var modal: ModalState = .init()
}
struct ModalState: Equatable {
var input: String = ""
var isPresented: Bool = true
var inputTrimmed: String { self.input.trim() }
var isInputValid: Bool { self.inputTrimmed.count > 0 }
}
enum ModalAction {
case inputChanged(String)
case cancelButtonTapped
case doneButtonTapped
}
let modalReducer = Reducer<ModalFeatureState, ModalAction, AppEnvironment> { state, action, environment in
switch action {
case let .inputChanged(newValue):
state.modal.input = newValue
return .none
case .cancelButtonTapped:
state.modal.isPresented = false
return .none
case .doneButtonTapped:
let item = Item(id: environment.id(), name: state.modal.inputTrimmed)
state.items.append(item)
state.modal.isPresented = false
return .none
}
}
struct ModalView: View {
@Environment(\.presentationMode) var presentationMode
let store: Store<ModalState, ModalAction>
var body: some View {
WithViewStore(self.store) { viewStore in
NavigationView {
Form {
TextField("Name", text: viewStore.binding(
get: \.input,
send: ModalAction.inputChanged
))
}
.navigationBarTitle("Add Item", displayMode: .inline)
.navigationBarItems(
leading:
Button("Cancel") {
viewStore.send(.cancelButtonTapped)
},
trailing:
Button("Done") {
viewStore.send(.doneButtonTapped)
}
.disabled(!viewStore.isInputValid)
)
}
.onChange(of: viewStore.isPresented) { isPresented in
if !isPresented {
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
extension String {
func trim() -> String {
return self.trimmingCharacters(in: NSCharacterSet.whitespaces)
}
}
struct ModalView_Previews: PreviewProvider {
static var previews: some View {
ModalView(
store: Store(
initialState: ModalFeatureState(),
reducer: modalReducer,
environment: .live
)
.scope(state: \.modal)
)
}
}
@davidepedranz
Copy link
Author

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