diff --git a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj index 82a9203a6dac..2c8971970c8c 100644 --- a/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj +++ b/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ CA3E4C5B24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3E4C5A24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift */; }; CA410EE0247A15FE00E41798 /* 02-Effects-WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */; }; CA410EE2247C73B400E41798 /* 02-Effects-WebSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */; }; - CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift */; }; + CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */; }; CA5ECF92267A79F0002067FF /* FactClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5ECF91267A79F0002067FF /* FactClient.swift */; }; CA6AC2642451135C00C71CB3 /* ReusableComponents-Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */; }; CA6AC2652451135C00C71CB3 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */; }; @@ -32,7 +32,7 @@ CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */; }; CABC4F3926AEE00C00D5FA2C /* 02-Effects-Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */; }; CABC4F3B26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */; }; - CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift */; }; + CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */; }; CAF069D024ACC5AF00A1AAEF /* 00-Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF069CF24ACC5AF00A1AAEF /* 00-Core.swift */; }; CAF88E7324B8E26D00539345 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E7224B8E26D00539345 /* AppDelegate.swift */; }; CAF88E7524B8E26D00539345 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E7424B8E26D00539345 /* RootView.swift */; }; @@ -160,7 +160,7 @@ CA3E4C5A24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-Lifecycle.swift"; sourceTree = ""; }; CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-WebSocket.swift"; sourceTree = ""; }; CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-WebSocketTests.swift"; sourceTree = ""; }; - CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndActionSheetsTests.swift"; sourceTree = ""; }; + CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndConfirmationDialogsTests.swift"; sourceTree = ""; }; CA5ECF91267A79F0002067FF /* FactClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactClient.swift; sourceTree = ""; }; CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReusableComponents-Download.swift"; sourceTree = ""; }; CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; @@ -175,7 +175,7 @@ CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLivingTests.swift"; sourceTree = ""; }; CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Refreshable.swift"; sourceTree = ""; }; CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-RefreshableTests.swift"; sourceTree = ""; }; - CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndActionSheets.swift"; sourceTree = ""; }; + CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndConfirmationDialogs.swift"; sourceTree = ""; }; CAF069CF24ACC5AF00A1AAEF /* 00-Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "00-Core.swift"; sourceTree = ""; }; CAF88E7024B8E26D00539345 /* tvOSCaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = tvOSCaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; CAF88E7224B8E26D00539345 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -393,7 +393,7 @@ DC89C42424460F96006900B9 /* Info.plist */, CAF069CF24ACC5AF00A1AAEF /* 00-Core.swift */, DC89C41A24460F95006900B9 /* 00-RootView.swift */, - CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift */, + CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */, DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */, CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */, DC5B505025C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift */, @@ -432,7 +432,7 @@ DC89C42C24460F96006900B9 /* SwiftUICaseStudiesTests */ = { isa = PBXGroup; children = ( - CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift */, + CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */, CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */, DC27215525BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift */, 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift */, @@ -763,7 +763,7 @@ DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */, CABC4F3926AEE00C00D5FA2C /* 02-Effects-Refreshable.swift in Sources */, DCC68EAB244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift in Sources */, - CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift in Sources */, + CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift in Sources */, CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */, DC88D8A6245341EC0077F427 /* 01-GettingStarted-Animations.swift in Sources */, DC89C44D244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift in Sources */, @@ -791,7 +791,7 @@ CA34170824A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift in Sources */, CA0C0C4724B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift in Sources */, DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */, - CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift in Sources */, + CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift in Sources */, CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */, 4F5AC11F24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift in Sources */, CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */, diff --git a/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift b/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift index d73feb31e78a..74c8e9976e71 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift @@ -4,7 +4,7 @@ import UIKit import XCTestDynamicOverlay struct RootState { - var alertAndActionSheet = AlertAndSheetState() + var alertAndConfirmationDialog = AlertAndConfirmationDialogState() var animation = AnimationsState() var bindingBasics = BindingBasicsState() #if compiler(>=5.4) @@ -40,7 +40,7 @@ struct RootState { } enum RootAction { - case alertAndActionSheet(AlertAndSheetAction) + case alertAndConfirmationDialog(AlertAndConfirmationDialogAction) case animation(AnimationsAction) case bindingBasics(BindingBasicsAction) #if compiler(>=5.4) @@ -110,10 +110,10 @@ let rootReducer = Reducer.combine( return .none } }, - alertAndSheetReducer + alertAndConfirmationDialogReducer .pullback( - state: \.alertAndActionSheet, - action: /RootAction.alertAndActionSheet, + state: \.alertAndConfirmationDialog, + action: /RootAction.alertAndConfirmationDialog, environment: { _ in .init() } ), animationsReducer diff --git a/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift b/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift index 16e5ebf30f19..1c54d13a8afe 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift @@ -73,11 +73,11 @@ struct RootView: View { ) NavigationLink( - "Alerts and Action Sheets", - destination: AlertAndSheetView( + "Alerts and Confirmation Dialogs", + destination: AlertAndConfirmationDialogView( store: self.store.scope( - state: \.alertAndActionSheet, - action: RootAction.alertAndActionSheet + state: \.alertAndConfirmationDialog, + action: RootAction.alertAndConfirmationDialog ) ) ) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndActionSheets.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift similarity index 50% rename from Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndActionSheets.swift rename to Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift index af80f7128e4b..ffd581189d7c 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndActionSheets.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift @@ -2,65 +2,50 @@ import ComposableArchitecture import SwiftUI private let readMe = """ - This demonstrates how to best handle alerts and action sheets in the Composable Architecture. + This demonstrates how to best handle alerts and confirmation dialogs in the Composable \ + Architecture. Because the library demands that all data flow through the application in a single direction, we \ cannot leverage SwiftUI's two-way bindings because they can make changes to state without going \ through a reducer. This means we can't directly use the standard API to display alerts and sheets. - However, the library comes with two types, `AlertState` and `ActionSheetState`, which can be \ - constructed from reducers and control whether or not an alert or action sheet is displayed. \ - Further, it automatically handles sending actions when you tap their buttons, which allows you \ - to properly handle their functionality in the reducer rather than in two-way bindings and action \ - closures. + However, the library comes with two types, `AlertState` and `ConfirmationDialogState`, which can \ + be constructed from reducers and control whether or not an alert or confirmation dialog is \ + displayed. Further, it automatically handles sending actions when you tap their buttons, which \ + allows you to properly handle their functionality in the reducer rather than in two-way bindings \ + and action closures. The benefit of doing this is that you can get full test coverage on how a user interacts with \ - alerts and action sheets in your application + alerts and dialogs in your application """ -struct AlertAndSheetState: Equatable { - var actionSheet: ActionSheetState? - var alert: AlertState? +struct AlertAndConfirmationDialogState: Equatable { + var alert: AlertState? + var confirmationDialog: ConfirmationDialogState? var count = 0 } -enum AlertAndSheetAction: Equatable { - case actionSheetButtonTapped - case actionSheetDismissed +enum AlertAndConfirmationDialogAction: Equatable { case alertButtonTapped case alertDismissed + case confirmationDialogButtonTapped + case confirmationDialogDismissed case decrementButtonTapped case incrementButtonTapped } -struct AlertAndSheetEnvironment {} +struct AlertAndConfirmationDialogEnvironment {} -let alertAndSheetReducer = Reducer< - AlertAndSheetState, AlertAndSheetAction, AlertAndSheetEnvironment +let alertAndConfirmationDialogReducer = Reducer< + AlertAndConfirmationDialogState, AlertAndConfirmationDialogAction, AlertAndConfirmationDialogEnvironment > { state, action, _ in switch action { - case .actionSheetButtonTapped: - state.actionSheet = .init( - title: .init("Action sheet"), - message: .init("This is an action sheet."), - buttons: [ - .cancel(), - .default(.init("Increment"), action: .send(.incrementButtonTapped)), - .default(.init("Decrement"), action: .send(.decrementButtonTapped)), - ] - ) - return .none - - case .actionSheetDismissed: - state.actionSheet = nil - return .none - case .alertButtonTapped: state.alert = .init( title: .init("Alert!"), message: .init("This is an alert"), - primaryButton: .cancel(), + primaryButton: .cancel(.init("Cancel")), secondaryButton: .default(.init("Increment"), action: .send(.incrementButtonTapped)) ) return .none @@ -69,6 +54,22 @@ let alertAndSheetReducer = Reducer< state.alert = nil return .none + case .confirmationDialogButtonTapped: + state.confirmationDialog = .init( + title: .init("Confirmation dialog"), + message: .init("This is a confirmation dialog."), + buttons: [ + .cancel(.init("Cancel")), + .default(.init("Increment"), action: .send(.incrementButtonTapped)), + .default(.init("Decrement"), action: .send(.decrementButtonTapped)), + ] + ) + return .none + + case .confirmationDialogDismissed: + state.confirmationDialog = nil + return .none + case .decrementButtonTapped: state.alert = .init(title: .init("Decremented!")) state.count -= 1 @@ -81,8 +82,8 @@ let alertAndSheetReducer = Reducer< } } -struct AlertAndSheetView: View { - let store: Store +struct AlertAndConfirmationDialogView: View { + let store: Store var body: some View { WithViewStore(self.store) { viewStore in @@ -96,25 +97,25 @@ struct AlertAndSheetView: View { dismiss: .alertDismissed ) - Button("Action sheet") { viewStore.send(.actionSheetButtonTapped) } - .actionSheet( - self.store.scope(state: \.actionSheet), - dismiss: .actionSheetDismissed + Button("Confirmation Dialog") { viewStore.send(.confirmationDialogButtonTapped) } + .confirmationDialog( + self.store.scope(state: \.confirmationDialog), + dismiss: .confirmationDialogDismissed ) } } } - .navigationBarTitle("Alerts & Action Sheets") + .navigationBarTitle("Alerts & Confirmation Dialogs") } } -struct AlertAndSheet_Previews: PreviewProvider { +struct AlertAndConfirmationDialog_Previews: PreviewProvider { static var previews: some View { NavigationView { - AlertAndSheetView( + AlertAndConfirmationDialogView( store: .init( initialState: .init(), - reducer: alertAndSheetReducer, + reducer: alertAndConfirmationDialogReducer, environment: .init() ) ) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift index 71f5020b6e43..600a0a2d6005 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift @@ -85,7 +85,7 @@ let animationsReducer = Reducer() -> Binding where Value == Wrapped? { + .init( + get: { self.wrappedValue != nil }, + set: { isPresent, transaction in + guard !isPresent else { return } + self.transaction(transaction).wrappedValue = nil + } + ) + } +} diff --git a/Sources/ComposableArchitecture/Internal/Deprecations.swift b/Sources/ComposableArchitecture/Internal/Deprecations.swift index 009b8443142e..2d02c7a09a48 100644 --- a/Sources/ComposableArchitecture/Internal/Deprecations.swift +++ b/Sources/ComposableArchitecture/Internal/Deprecations.swift @@ -4,6 +4,34 @@ import SwiftUI // NB: Deprecated after 0.27.1: +extension AlertState.Button { + @available(*, deprecated, message: "Cancel buttons must be given an explicit label as their first argument") + public static func cancel(action: AlertState.ButtonAction? = nil) -> Self { + .init(action: action, label: TextState("Cancel"), role: .cancel) + } +} + +@available(iOS 13, *) +@available(macOS 12, *) +@available(tvOS 13, *) +@available(watchOS 6, *) +@available(*, deprecated, renamed: "ConfirmationDialogState") +public typealias ActionSheetState = ConfirmationDialogState + +extension View { + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + @available(*, deprecated, renamed: "confirmationDialog") + public func actionSheet( + _ store: Store?, Action>, + dismiss: Action + ) -> some View { + self.confirmationDialog(store, dismiss: dismiss) + } +} + extension Store { @available( *, deprecated, @@ -219,14 +247,14 @@ extension AlertState.Button { _ label: TextState, send action: Action? ) -> Self { - Self(action: action.map(AlertState.ButtonAction.send), type: .cancel(label: label)) + .cancel(label, action: action.map(AlertState.ButtonAction.send)) } @available(*, deprecated, renamed: "cancel(action:)") public static func cancel( send action: Action? ) -> Self { - Self(action: action.map(AlertState.ButtonAction.send), type: .cancel(label: nil)) + .cancel(action: action.map(AlertState.ButtonAction.send)) } @available(*, deprecated, renamed: "default(_:action:)") @@ -234,7 +262,7 @@ extension AlertState.Button { _ label: TextState, send action: Action? ) -> Self { - Self(action: action.map(AlertState.ButtonAction.send), type: .default(label: label)) + .default(label, action: action.map(AlertState.ButtonAction.send)) } @available(*, deprecated, renamed: "destructive(_:action:)") @@ -242,7 +270,7 @@ extension AlertState.Button { _ label: TextState, send action: Action? ) -> Self { - Self(action: action.map(AlertState.ButtonAction.send), type: .destructive(label: label)) + .destructive(label, action: action.map(AlertState.ButtonAction.send)) } } diff --git a/Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift b/Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift deleted file mode 100644 index 78b706c59074..000000000000 --- a/Sources/ComposableArchitecture/SwiftUI/ActionSheet.swift +++ /dev/null @@ -1,229 +0,0 @@ -import CustomDump -import SwiftUI - -/// A data type that describes the state of an action sheet that can be shown to the user. The -/// `Action` generic is the type of actions that can be sent from tapping on a button in the sheet. -/// -/// This type can be used in your application's state in order to control the presentation or -/// dismissal of action sheets. It is preferable to use this API instead of the default SwiftUI API -/// for action sheets because SwiftUI uses 2-way bindings in order to control the showing and -/// dismissal of sheets, and that does not play nicely with the Composable Architecture. The library -/// requires that all state mutations happen by sending an action so that a reducer can handle that -/// logic, which greatly simplifies how data flows through your application, and gives you instant -/// testability on all parts of your application. -/// -/// To use this API, you model all the action sheet actions in your domain's action enum: -/// -/// ```swift -/// enum AppAction: Equatable { -/// case cancelTapped -/// case deleteTapped -/// case favoriteTapped -/// case infoTapped -/// -/// // Your other actions -/// } -/// ``` -/// -/// And you model the state for showing the action sheet in your domain's state, and it can start -/// off in a `nil` state: -/// -/// ```swift -/// struct AppState: Equatable { -/// var actionSheet: ActionSheetState? -/// -/// // Your other state -/// } -/// ``` -/// -/// Then, in the reducer you can construct an `ActionSheetState` value to represent the action -/// sheet you want to show to the user: -/// -/// ```swift -/// let appReducer = Reducer { state, action, env in -/// switch action -/// case .cancelTapped: -/// state.actionSheet = nil -/// return .none -/// -/// case .deleteTapped: -/// state.actionSheet = nil -/// // Do deletion logic... -/// -/// case .favoriteTapped: -/// state.actionSheet = nil -/// // Do favoriting logic -/// -/// case .infoTapped: -/// state.actionSheet = .init( -/// title: "What would you like to do?", -/// buttons: [ -/// .default(TextState("Favorite"), action: .send(.favoriteTapped)), -/// .destructive(TextState("Delete"), action: .send(.deleteTapped)), -/// .cancel(), -/// ] -/// ) -/// return .none -/// } -/// } -/// ``` -/// -/// And then, in your view you can use the `.actionSheet(_:send:dismiss:)` method on `View` in order -/// to present the action sheet in a way that works best with the Composable Architecture: -/// -/// ```swift -/// Button("Info") { viewStore.send(.infoTapped) } -/// .actionSheet( -/// self.store.scope(state: \.actionSheet), -/// dismiss: .cancelTapped -/// ) -/// ``` -/// -/// This makes your reducer in complete control of when the action sheet is shown or dismissed, and -/// makes it so that any choice made in the action sheet is automatically fed back into the reducer -/// so that you can handle its logic. -/// -/// Even better, you can instantly write tests that your action sheet behavior works as expected: -/// -/// ```swift -/// let store = TestStore( -/// initialState: AppState(), -/// reducer: appReducer, -/// environment: .mock -/// ) -/// -/// store.send(.infoTapped) { -/// $0.actionSheet = .init( -/// title: "What would you like to do?", -/// buttons: [ -/// .default(TextState("Favorite"), send: .favoriteTapped), -/// .destructive(TextState("Delete"), send: .deleteTapped), -/// .cancel(), -/// ] -/// ) -/// } -/// store.send(.favoriteTapped) { -/// $0.actionSheet = nil -/// // Also verify that favoriting logic executed correctly -/// } -/// ``` -/// -@available(iOS 13, *) -@available(macCatalyst 13, *) -@available(macOS, unavailable) -@available(tvOS 13, *) -@available(watchOS 6, *) -public struct ActionSheetState { - public let id = UUID() - public var buttons: [Button] - public var message: TextState? - public var title: TextState - - public init( - title: TextState, - message: TextState? = nil, - buttons: [Button] - ) { - self.buttons = buttons - self.message = message - self.title = title - } - - public typealias Button = AlertState.Button -} - -@available(iOS 13, *) -@available(macCatalyst 13, *) -@available(macOS, unavailable) -@available(tvOS 13, *) -@available(watchOS 6, *) -extension ActionSheetState: CustomDumpReflectable { - public var customDumpMirror: Mirror { - Mirror( - self, - children: [ - "title": self.title, - "message": self.message as Any, - "buttons": self.buttons, - ], - displayStyle: .struct - ) - } -} - -@available(iOS 13, *) -@available(macCatalyst 13, *) -@available(macOS, unavailable) -@available(tvOS 13, *) -@available(watchOS 6, *) -extension ActionSheetState: Equatable where Action: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.title == rhs.title - && lhs.message == rhs.message - && lhs.buttons == rhs.buttons - } -} - -@available(iOS 13, *) -@available(macCatalyst 13, *) -@available(macOS, unavailable) -@available(tvOS 13, *) -@available(watchOS 6, *) -extension ActionSheetState: Hashable where Action: Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(self.title) - hasher.combine(self.message) - hasher.combine(self.buttons) - } -} - -@available(iOS 13, *) -@available(macCatalyst 13, *) -@available(macOS, unavailable) -@available(tvOS 13, *) -@available(watchOS 6, *) -extension ActionSheetState: Identifiable {} - -extension View { - /// Displays an action sheet when the store's state becomes non-`nil`, and dismisses it when it - /// becomes `nil`. - /// - /// - Parameters: - /// - store: A store that describes if the action sheet is shown or dismissed. - /// - dismissal: An action to send when the action sheet is dismissed through non-user actions, - /// such as when an action sheet is automatically dismissed by the system. Use this action to - /// `nil` out the associated action sheet state. - @available(iOS 13, *) - @available(macCatalyst 13, *) - @available(macOS, unavailable) - @available(tvOS 13, *) - @available(watchOS 6, *) - public func actionSheet( - _ store: Store?, Action>, - dismiss: Action - ) -> some View { - - WithViewStore(store, removeDuplicates: { $0?.id == $1?.id }) { viewStore in - self.actionSheet(item: viewStore.binding(send: dismiss)) { state in - state.toSwiftUI(send: viewStore.send) - } - } - } -} - -@available(iOS 13, *) -@available(macCatalyst 13, *) -@available(macOS, unavailable) -@available(tvOS 13, *) -@available(watchOS 6, *) -extension ActionSheetState { - fileprivate func toSwiftUI(send: @escaping (Action) -> Void) -> SwiftUI.ActionSheet { - SwiftUI.ActionSheet( - title: Text(self.title), - message: self.message.map { Text($0) }, - buttons: self.buttons.map { - $0.toSwiftUI(send: send) - } - ) - } -} diff --git a/Sources/ComposableArchitecture/SwiftUI/Alert.swift b/Sources/ComposableArchitecture/SwiftUI/Alert.swift index 4a89151463c9..cabeec2d0838 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Alert.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Alert.swift @@ -54,7 +54,7 @@ import SwiftUI /// title: TextState("Delete"), /// message: TextState("Are you sure you want to delete this? It cannot be undone."), /// primaryButton: .default(TextState("Confirm"), action: .send(.confirmTapped)), -/// secondaryButton: .cancel() +/// secondaryButton: .cancel(TextState("Cancel")) /// ) /// return .none /// } @@ -90,7 +90,7 @@ import SwiftUI /// title: TextState("Delete"), /// message: TextState("Are you sure you want to delete this? It cannot be undone."), /// primaryButton: .default(TextState("Confirm"), action: .send(.confirmTapped)), -/// secondaryButton: .cancel(action: .send(.cancelTapped)) +/// secondaryButton: .cancel(TextState("Cancel")) /// ) /// } /// store.send(.deleteTapped) { @@ -101,11 +101,21 @@ import SwiftUI /// public struct AlertState { public let id = UUID() + public var buttons: [Button] public var message: TextState? - public var primaryButton: Button? - public var secondaryButton: Button? public var title: TextState + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + public init( + title: TextState, + message: TextState? = nil, + buttons: [Button] + ) { + self.title = title + self.message = message + self.buttons = buttons + } + public init( title: TextState, message: TextState? = nil, @@ -113,7 +123,7 @@ public struct AlertState { ) { self.title = title self.message = message - self.primaryButton = dismissButton + self.buttons = dismissButton.map { [$0] } ?? [] } public init( @@ -124,39 +134,33 @@ public struct AlertState { ) { self.title = title self.message = message - self.primaryButton = primaryButton - self.secondaryButton = secondaryButton + self.buttons = [primaryButton, secondaryButton] } public struct Button { public var action: ButtonAction? - public var type: ButtonType + public var label: TextState + public var role: ButtonRole? public static func cancel( _ label: TextState, action: ButtonAction? = nil ) -> Self { - Self(action: action, type: .cancel(label: label)) - } - - public static func cancel( - action: ButtonAction? = nil - ) -> Self { - Self(action: action, type: .cancel(label: nil)) + Self(action: action, label: label, role: .cancel) } public static func `default`( _ label: TextState, action: ButtonAction? = nil ) -> Self { - Self(action: action, type: .default(label: label)) + Self(action: action, label: label, role: nil) } public static func destructive( _ label: TextState, action: ButtonAction? = nil ) -> Self { - Self(action: action, type: .destructive(label: label)) + Self(action: action, label: label, role: .destructive) } } @@ -177,10 +181,21 @@ public struct AlertState { } } - public enum ButtonType { - case cancel(label: TextState?) - case `default`(label: TextState) - case destructive(label: TextState) + public enum ButtonRole { + case cancel + case destructive + + #if compiler(>=5.5) && canImport(_Concurrency) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + var toSwiftUI: SwiftUI.ButtonRole { + switch self { + case .cancel: + return .cancel + case .destructive: + return .destructive + } + } + #endif } } @@ -197,11 +212,26 @@ extension View { _ store: Store?, Action>, dismiss: Action ) -> some View { - WithViewStore(store, removeDuplicates: { $0?.id == $1?.id }) { viewStore in - self.alert(item: viewStore.binding(send: dismiss)) { state in - state.toSwiftUI(send: viewStore.send) - } + #if compiler(>=5.5) && canImport(_Concurrency) + if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + self.alert( + (viewStore.state?.title).map { Text($0) } ?? Text(""), + isPresented: viewStore.binding(send: dismiss).isPresent(), + presenting: viewStore.state, + actions: { $0.toSwiftUIActions(send: viewStore.send) }, + message: { $0.message.map { Text($0) } } + ) + } else { + self.alert(item: viewStore.binding(send: dismiss)) { state in + state.toSwiftUIAlert(send: viewStore.send) + } + } + #else + self.alert(item: viewStore.binding(send: dismiss)) { state in + state.toSwiftUIAlert(send: viewStore.send) + } + #endif } } } @@ -213,8 +243,7 @@ extension AlertState: CustomDumpReflectable { children: [ "title": self.title, "message": self.message as Any, - "primaryButton": self.primaryButton as Any, - "secondaryButton": self.secondaryButton as Any, + "buttons": self.buttons, ], displayStyle: .struct ) @@ -223,22 +252,12 @@ extension AlertState: CustomDumpReflectable { extension AlertState.Button: CustomDumpReflectable { public var customDumpMirror: Mirror { - let buttonLabel: TextState? - switch self.type { - case let .cancel(label): - buttonLabel = label - case let .default(label): - buttonLabel = label - case let .destructive(label): - buttonLabel = label - } - - return Mirror( + Mirror( self, children: [ - Mirror(reflecting: self.type).children.first!.label!: ( - action: self.action, - label: buttonLabel + self.role.map { "\($0)" } ?? "default": ( + self.label, + action: self.action ) ], displayStyle: .enum @@ -246,12 +265,34 @@ extension AlertState.Button: CustomDumpReflectable { } } +extension AlertState.ButtonAction: CustomDumpReflectable { + public var customDumpMirror: Mirror { + switch self.type { + case let .send(action): + return Mirror( + self, + children: [ + "send": action + ], + displayStyle: .enum + ) + case let .animatedSend(action, animation): + return Mirror( + self, + children: [ + "send": (action, animation: animation) + ], + displayStyle: .enum + ) + } + } +} + extension AlertState: Equatable where Action: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { lhs.title == rhs.title && lhs.message == rhs.message - && lhs.primaryButton == rhs.primaryButton - && lhs.secondaryButton == rhs.secondaryButton + && lhs.buttons == rhs.buttons } } @@ -259,8 +300,7 @@ extension AlertState: Hashable where Action: Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(self.title) hasher.combine(self.message) - hasher.combine(self.primaryButton) - hasher.combine(self.secondaryButton) + hasher.combine(self.buttons) } } @@ -268,7 +308,7 @@ extension AlertState: Identifiable {} extension AlertState.ButtonAction: Equatable where Action: Equatable {} extension AlertState.ButtonAction.ActionType: Equatable where Action: Equatable {} -extension AlertState.ButtonType: Equatable {} +extension AlertState.ButtonRole: Equatable {} extension AlertState.Button: Equatable where Action: Equatable {} extension AlertState.ButtonAction: Hashable where Action: Hashable {} @@ -280,17 +320,18 @@ extension AlertState.ButtonAction.ActionType: Hashable where Action: Hashable { } } } -extension AlertState.ButtonType: Hashable {} +extension AlertState.ButtonRole: Hashable {} extension AlertState.Button: Hashable where Action: Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(self.action) - hasher.combine(self.type) + hasher.combine(self.label) + hasher.combine(self.role) } } extension AlertState.Button { - func toSwiftUI(send: @escaping (Action) -> Void) -> SwiftUI.Alert.Button { - let action = { + func toSwiftUIAction(send: @escaping (Action) -> Void) -> () -> Void { + return { switch self.action?.type { case .none: return @@ -300,33 +341,57 @@ extension AlertState.Button { withAnimation(animation) { send(action) } } } - switch self.type { - case let .cancel(.some(label)): + } + + func toSwiftUIAlertButton(send: @escaping (Action) -> Void) -> SwiftUI.Alert.Button { + let action = self.toSwiftUIAction(send: send) + switch self.role { + case .cancel: return .cancel(Text(label), action: action) - case .cancel(.none): - return .cancel(action) - case let .default(label): - return .default(Text(label), action: action) - case let .destructive(label): + case .destructive: return .destructive(Text(label), action: action) + case .none: + return .default(Text(label), action: action) } } + + #if compiler(>=5.5) && canImport(_Concurrency) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + func toSwiftUIButton(send: @escaping (Action) -> Void) -> some View { + SwiftUI.Button( + role: self.role?.toSwiftUI, + action: self.toSwiftUIAction(send: send) + ) { + Text(self.label) + } + } + #endif } extension AlertState { - fileprivate func toSwiftUI(send: @escaping (Action) -> Void) -> SwiftUI.Alert { - if let primaryButton = self.primaryButton, let secondaryButton = self.secondaryButton { + #if compiler(>=5.5) && canImport(_Concurrency) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @ViewBuilder + fileprivate func toSwiftUIActions(send: @escaping (Action) -> Void) -> some View { + ForEach(self.buttons.indices, id: \.self) { + self.buttons[$0].toSwiftUIButton(send: send) + } + } + #endif + + fileprivate func toSwiftUIAlert(send: @escaping (Action) -> Void) -> SwiftUI.Alert { + if self.buttons.count == 2 { return SwiftUI.Alert( title: Text(self.title), message: self.message.map { Text($0) }, - primaryButton: primaryButton.toSwiftUI(send: send), - secondaryButton: secondaryButton.toSwiftUI(send: send) + primaryButton: self.buttons[0].toSwiftUIAlertButton(send: send), + secondaryButton: self.buttons[1].toSwiftUIAlertButton(send: send) ) } else { return SwiftUI.Alert( title: Text(self.title), message: self.message.map { Text($0) }, - dismissButton: self.primaryButton?.toSwiftUI(send: send) + dismissButton: self.buttons.first?.toSwiftUIAlertButton(send: send) ) } } diff --git a/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift new file mode 100644 index 000000000000..21bdec845daa --- /dev/null +++ b/Sources/ComposableArchitecture/SwiftUI/ConfirmationDialog.swift @@ -0,0 +1,290 @@ +import CustomDump +import SwiftUI + +/// A data type that describes the state of a confirmation dialog that can be shown to the user. The +/// `Action` generic is the type of actions that can be sent from tapping on a button in the sheet. +/// +/// This type can be used in your application's state in order to control the presentation or +/// dismissal of dialogs. It is preferable to use this API instead of the default SwiftUI API for +/// dialogs because SwiftUI uses 2-way bindings in order to control the showing and dismissal of +/// dialogs, and that does not play nicely with the Composable Architecture. The library requires +/// that all state mutations happen by sending an action so that a reducer can handle that logic, +/// which greatly simplifies how data flows through your application, and gives you instant +/// testability on all parts of your application. +/// +/// To use this API, you model all the dialog actions in your domain's action enum: +/// +/// ```swift +/// enum AppAction: Equatable { +/// case cancelTapped +/// case deleteTapped +/// case favoriteTapped +/// case infoTapped +/// +/// // Your other actions +/// } +/// ``` +/// +/// And you model the state for showing the dialog in your domain's state, and it can start off in a +/// `nil` state: +/// +/// ```swift +/// struct AppState: Equatable { +/// var confirmationDialog: ConfirmationDialogState? +/// +/// // Your other state +/// } +/// ``` +/// +/// Then, in the reducer you can construct an `ConfirmationDialogState` value to represent the +/// dialog you want to show to the user: +/// +/// ```swift +/// let appReducer = Reducer { state, action, env in +/// switch action +/// case .cancelTapped: +/// state.confirmationDialog = nil +/// return .none +/// +/// case .deleteTapped: +/// state.confirmationDialog = nil +/// // Do deletion logic... +/// +/// case .favoriteTapped: +/// state.confirmationDialog = nil +/// // Do favoriting logic +/// +/// case .infoTapped: +/// state.confirmationDialog = .init( +/// title: "What would you like to do?", +/// buttons: [ +/// .default(TextState("Favorite"), action: .send(.favoriteTapped)), +/// .destructive(TextState("Delete"), action: .send(.deleteTapped)), +/// .cancel(), +/// ] +/// ) +/// return .none +/// } +/// } +/// ``` +/// +/// And then, in your view you can use the `confirmationDialog(_:dismiss:)` method on `View` in +/// order to present the dialog in a way that works best with the Composable Architecture: +/// +/// ```swift +/// Button("Info") { viewStore.send(.infoTapped) } +/// .confirmationDialog( +/// self.store.scope(state: \.confirmationDialog), +/// dismiss: .cancelTapped +/// ) +/// ``` +/// +/// This makes your reducer in complete control of when the dialog is shown or dismissed, and makes +/// it so that any choice made in the dialog is automatically fed back into the reducer so that you +/// can handle its logic. +/// +/// Even better, you can instantly write tests that your dialog behavior works as expected: +/// +/// ```swift +/// let store = TestStore( +/// initialState: AppState(), +/// reducer: appReducer, +/// environment: .mock +/// ) +/// +/// store.send(.infoTapped) { +/// $0.confirmationDialog = .init( +/// title: "What would you like to do?", +/// buttons: [ +/// .default(TextState("Favorite"), send: .favoriteTapped), +/// .destructive(TextState("Delete"), send: .deleteTapped), +/// .cancel(), +/// ] +/// ) +/// } +/// store.send(.favoriteTapped) { +/// $0.confirmationDialog = nil +/// // Also verify that favoriting logic executed correctly +/// } +/// ``` +/// +@available(iOS 13, *) +@available(macOS 12, *) +@available(tvOS 13, *) +@available(watchOS 6, *) +public struct ConfirmationDialogState { + public let id = UUID() + public var buttons: [Button] + public var message: TextState? + public var title: TextState + public var titleVisibility: Visibility + + @available(iOS 15, *) + @available(macOS 12, *) + @available(tvOS 15, *) + @available(watchOS 8, *) + public init( + title: TextState, + titleVisibility: Visibility, + message: TextState? = nil, + buttons: [Button] = [] + ) { + self.buttons = buttons + self.message = message + self.title = title + self.titleVisibility = titleVisibility + } + + public init( + title: TextState, + message: TextState? = nil, + buttons: [Button] = [] + ) { + self.buttons = buttons + self.message = message + self.title = title + self.titleVisibility = .automatic + } + + public typealias Button = AlertState.Button + + public enum Visibility { + case automatic + case hidden + case visible + + #if compiler(>=5.5) && canImport(_Concurrency) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + var toSwiftUI: SwiftUI.Visibility { + switch self { + case .automatic: + return .automatic + case .hidden: + return .hidden + case .visible: + return .visible + } + } + #endif + } +} + +@available(iOS 13, *) +@available(macOS 12, *) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ConfirmationDialogState: CustomDumpReflectable { + public var customDumpMirror: Mirror { + Mirror( + self, + children: [ + "title": self.title, + "message": self.message as Any, + "buttons": self.buttons, + ], + displayStyle: .struct + ) + } +} + +@available(iOS 13, *) +@available(macOS 12, *) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ConfirmationDialogState: Equatable where Action: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + && lhs.message == rhs.message + && lhs.buttons == rhs.buttons + } +} + +@available(iOS 13, *) +@available(macOS 12, *) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ConfirmationDialogState: Hashable where Action: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.title) + hasher.combine(self.message) + hasher.combine(self.buttons) + } +} + +@available(iOS 13, *) +@available(macOS 12, *) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ConfirmationDialogState: Identifiable {} + +extension View { + /// Displays a dialog when the store's state becomes non-`nil`, and dismisses it when it becomes + /// `nil`. + /// + /// - Parameters: + /// - store: A store that describes if the dialog is shown or dismissed. + /// - dismissal: An action to send when the dialog is dismissed through non-user actions, such + /// as when a dialog is automatically dismissed by the system. Use this action to `nil` out + /// the associated dialog state. + @available(iOS 13, *) + @available(macOS 12, *) + @available(tvOS 13, *) + @available(watchOS 6, *) + public func confirmationDialog( + _ store: Store?, Action>, + dismiss: Action + ) -> some View { + + WithViewStore(store, removeDuplicates: { $0?.id == $1?.id }) { viewStore in + #if compiler(>=5.5) && canImport(_Concurrency) + if #available(iOS 15, tvOS 15, watchOS 8, *) { + self.confirmationDialog( + (viewStore.state?.title).map { Text($0) } ?? Text(""), + isPresented: viewStore.binding(send: dismiss).isPresent(), + titleVisibility: viewStore.state?.titleVisibility.toSwiftUI ?? .automatic, + presenting: viewStore.state, + actions: { $0.toSwiftUIActions(send: viewStore.send) }, + message: { $0.message.map { Text($0) } } + ) + } else { + #if !os(macOS) + self.actionSheet(item: viewStore.binding(send: dismiss)) { state in + state.toSwiftUIActionSheet(send: viewStore.send) + } + #endif + } + #elseif !os(macOS) + self.actionSheet(item: viewStore.binding(send: dismiss)) { state in + state.toSwiftUIActionSheet(send: viewStore.send) + } + #endif + } + } +} + +@available(iOS 13, *) +@available(macOS 12, *) +@available(tvOS 13, *) +@available(watchOS 6, *) +extension ConfirmationDialogState { + #if compiler(>=5.5) && canImport(_Concurrency) + @available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) + @ViewBuilder + fileprivate func toSwiftUIActions(send: @escaping (Action) -> Void) -> some View { + ForEach(self.buttons.indices, id: \.self) { + self.buttons[$0].toSwiftUIButton(send: send) + } + } + #endif + + @available(macOS, unavailable) + fileprivate func toSwiftUIActionSheet(send: @escaping (Action) -> Void) -> SwiftUI.ActionSheet { + SwiftUI.ActionSheet( + title: Text(self.title), + message: self.message.map { Text($0) }, + buttons: self.buttons.map { + $0.toSwiftUIAlertButton(send: send) + } + ) + } +} diff --git a/Sources/ComposableArchitecture/SwiftUI/TextState.swift b/Sources/ComposableArchitecture/SwiftUI/TextState.swift index a702c9ddb19b..6af7df6b87c9 100644 --- a/Sources/ComposableArchitecture/SwiftUI/TextState.swift +++ b/Sources/ComposableArchitecture/SwiftUI/TextState.swift @@ -37,15 +37,15 @@ import SwiftUI /// } /// ``` /// -/// Certain SwiftUI APIs, like alerts and action sheets, take `Text` values and, not views. To -/// convert ``TextState`` to `SwiftUI.Text` for this purpose, you can use the `Text` initializer: +/// Certain SwiftUI APIs, like alerts and confirmation dialogs, take `Text` values and, not views. +/// To convert ``TextState`` to `SwiftUI.Text` for this purpose, you can use the `Text` initializer: /// /// ```swift /// Alert(title: Text(viewStore.label)) /// ``` /// -/// The Composable Architecture comes with a few convenience APIs for alerts and action sheets that -/// wrap ``TextState`` under the hood. See ``AlertState`` and `ActionState` accordingly. +/// The Composable Architecture comes with a few convenience APIs for alerts and dialogs that wrap +/// ``TextState`` under the hood. See ``AlertState`` and `ActionState` accordingly. /// /// In the future, should `SwiftUI.Text` and `SwiftUI.LocalizedStringKey` reliably conform to /// `Equatable`, ``TextState`` may be deprecated. diff --git a/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift b/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift index 2a73b7611cea..d27468a2b4e6 100644 --- a/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift +++ b/Sources/ComposableArchitecture/UIKit/AlertStateUIKit.swift @@ -48,36 +48,48 @@ self.init( title: String(state: state.title), message: state.message.map { String(state: $0) }, - preferredStyle: .alert) - - if let primaryButton = state.primaryButton { - self.addAction(primaryButton.toUIAlertAction(send: send)) - } - - if let secondaryButton = state.secondaryButton { - self.addAction(secondaryButton.toUIAlertAction(send: send)) + preferredStyle: .alert + ) + for button in state.buttons { + self.addAction(button.toUIAlertAction(send: send)) } } - /// Creates a `UIAlertController` from `ActionSheetState`. + /// Creates a `UIAlertController` from `ConfirmationDialogState`. /// /// - Parameters: - /// - state: The state of an action sheet that can be shown to the user. - /// - send: A function that wraps a alert action in the view store's action type. + /// - state: The state of dialog that can be shown to the user. + /// - send: A function that wraps a dialog action in the view store's action type. public convenience init( - state: ActionSheetState, send: @escaping (Action) -> Void + state: ConfirmationDialogState, send: @escaping (Action) -> Void ) { self.init( title: String(state: state.title), message: state.message.map { String(state: $0) }, - preferredStyle: .actionSheet) - + preferredStyle: .actionSheet + ) state.buttons.forEach { button in self.addAction(button.toUIAlertAction(send: send)) } } } + @available(iOS 13, *) + @available(macCatalyst 13, *) + @available(macOS, unavailable) + @available(tvOS 13, *) + @available(watchOS, unavailable) + extension AlertState.ButtonRole { + var toUIKit: UIAlertAction.Style { + switch self { + case .cancel: + return .cancel + case .destructive: + return .destructive + } + } + } + @available(iOS 13, *) @available(macCatalyst 13, *) @available(macOS, unavailable) @@ -85,28 +97,12 @@ @available(watchOS, unavailable) extension AlertState.Button { func toUIAlertAction(send: @escaping (Action) -> Void) -> UIAlertAction { - let action = { - switch self.action?.type { - case .none: - return - case let .some(.send(action)), - let .some(.animatedSend(action, animation: _)): // Doesn't support animation in UIKit - send(action) - } - } - switch self.type { - case let .cancel(.some(title)): - return UIAlertAction( - title: String(state: title), style: .cancel, handler: { _ in action() }) - case .cancel(.none): - return UIAlertAction(title: nil, style: .cancel, handler: { _ in action() }) - case let .default(title): - return UIAlertAction( - title: String(state: title), style: .default, handler: { _ in action() }) - case let .destructive(title): - return UIAlertAction( - title: String(state: title), style: .destructive, handler: { _ in action() }) - } + let action = self.toSwiftUIAction(send: send) + return UIAlertAction( + title: String(state: self.label), + style: self.role?.toUIKit ?? .default, + handler: { _ in action() } + ) } } #endif diff --git a/Tests/ComposableArchitectureTests/DebugTests.swift b/Tests/ComposableArchitectureTests/DebugTests.swift index 7de0f05c655f..f7e4635b6a7d 100644 --- a/Tests/ComposableArchitectureTests/DebugTests.swift +++ b/Tests/ComposableArchitectureTests/DebugTests.swift @@ -5,6 +5,78 @@ import XCTest @testable import ComposableArchitecture final class DebugTests: XCTestCase { + func testAlertState() { + var dump = "" + customDump( + AlertState( + title: .init("Alert!"), + message: .init("Something went wrong..."), + primaryButton: .destructive(.init("Destroy"), action: .send(true, animation: .default)), + secondaryButton: .cancel(.init("Cancel"), action: .send(false)) + ), + to: &dump + ) + XCTAssertNoDifference( + dump, + """ + AlertState( + title: "Alert!", + message: "Something went wrong...", + buttons: [ + [0]: AlertState.Button.destructive( + "Destroy", + action: AlertState.ButtonAction.send( + true, + animation: Animation.easeInOut + ) + ), + [1]: AlertState.Button.cancel( + "Cancel", + action: AlertState.ButtonAction.send(false) + ) + ] + ) + """ + ) + + if #available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) { + dump = "" + customDump( + ConfirmationDialogState( + title: .init("Alert!"), + message: .init("Something went wrong..."), + buttons: [ + .destructive(.init("Destroy"), action: .send(true, animation: .default)), + .cancel(.init("Cancel"), action: .send(false)) + ] + ), + to: &dump + ) + XCTAssertNoDifference( + dump, + """ + ConfirmationDialogState( + title: "Alert!", + message: "Something went wrong...", + buttons: [ + [0]: AlertState.Button.destructive( + "Destroy", + action: AlertState.ButtonAction.send( + true, + animation: Animation.easeInOut + ) + ), + [1]: AlertState.Button.cancel( + "Cancel", + action: AlertState.ButtonAction.send(false) + ) + ] + ) + """ + ) + } + } + func testTextState() { var dump = "" customDump(TextState("Hello, world!"), to: &dump)