Last active
April 15, 2023 12:40
-
-
Save davidepedranz/0856384fccf91485027612b375eb020a to your computer and use it in GitHub Desktop.
[SwiftUI + TCA] Programmatic sheet dismissal
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// | |
/// 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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// | |
/// 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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// | |
/// 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) | |
) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Posted here: https://forums.swift.org/t/programmatically-sheet-dismissal/40901