Better support for alert
with custom reducers
#2816
Replies: 4 comments 14 replies
-
Any thoughts on this @stephencelis or @mbrandonw? |
Beta Was this translation helpful? Give feedback.
-
So, here's a full example of where I'm using my custom /// Represents the state of a file export operation.
/// - Note: This is a generic type, allowing it to be used with any type that conforms to the `FileDocument` and `Equatable` protocols.
@Reducer
public struct FileExport<Document: FileDocument & Equatable> {
/// The stage the export is at.
@ObservableState
public enum Status: Equatable {
@ObservableState
public enum Completion: Equatable {
/// The export was cancelled by the user.
case cancelled
/// The file was exported to the specified `URL`.
case exported(to: URL)
/// The export failed, with the specified failure message.
case failure(message: String)
}
/// Selecting/exporting the file.
case exporting
/// The export window has closed, but no completion has been
case exportingEnded
/// The export completed.
case completed(Completion)
}
/// The user message.
@Reducer(state: .equatable)
public enum Message {
/// The export was successful.
case success(SuccessMessage)
/// The export failed.
case failure(FailureMessage)
}
@ObservableState
public struct State: Equatable {
/// The document to export.
public let document: Document
/// The default filename to suggest to the user.
public let defaultFileName: String?
/// The content type to save as.
public let contentType: UTType
/// The stage the export is at.
public var status: Status
/// The message, if present.
@Presents public var message: Message.State?
/// Indicates if we are selecting a file.
public var isExporting: Bool {
get { status == .exporting }
set {
// only update if clearing.
if status == .exporting && !newValue {
status = .exportingEnded
}
}
}
/// The export URL, if available.
public var exportUrl: URL? {
switch status {
case let .completed(.exported(to: url)):
return url
default:
return nil
}
}
/// The failure message, if available.
public var failureMessage: String? {
switch status {
case let .completed(.failure(message: message)):
return message
default:
return nil
}
}
/// Initializes the `State`.
/// - Parameters:
/// - document: The document being exported.
/// - defaultFileName: The default file name. May be `nil`.
/// - contentType: The content types to export as.
/// - status: The stage the export is at. (default: `.exporting`)
/// - message: The state for the ``Message`` (default: `nil`)
/// - Note: If no content types are provided, the document's default content types will be used.
public init(
document: Document,
defaultFileName: String?,
contentType: UTType,
status: Status = .exporting,
message: Message.State? = nil
) {
self.document = document
self.defaultFileName = defaultFileName
self.contentType = contentType
self.status = status
self.message = message
}
}
/// The `Action`.
public enum Action: BindableAction {
/// Actions that are delegated to the parent reducer.
public enum Delegate {
/// The export was cancelled.
case cancelled
/// The export was successful.
case exported(to: URL)
/// The export failed
case failure(message: String)
}
/// Handles updating state from bindings in the `View`
case binding(BindingAction<State>)
/// The export was cancelled
case cancelled
/// Actions intended to be handled by the parent.
case delegate(Delegate)
/// The export completed with either a `URL` with the export location, or an `Error`.
case exportResult(Result<URL, Error>)
/// An action occurred in a message alert.
case message(PresentationAction<Message.Action>)
}
public var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
case .cancelled:
state.status = .completed(.cancelled)
return .send(.delegate(.cancelled))
case .delegate:
return .none
case let .exportResult(result):
// There was a result from the export
switch result {
case let .success(url):
// update the status and display a success message.
state.status = .completed(.exported(to: url))
state.message = .success(.init(file: url))
return .none
case let .failure(error):
// update the status and display a failure message.
let message = error.localizedDescription
state.status = .completed(.failure(message: message))
state.message = .failure(.init(message: message))
return .none
}
case .message(.dismiss):
state.message = nil
switch state.status {
case .completed(.cancelled):
return .send(.delegate(.cancelled))
case let .completed(.exported(to: url)):
return .send(.delegate(.exported(to: url)))
case let .completed(.failure(message: message)):
return .send(.delegate(.failure(message: message)))
case .exportingEnded, .exporting:
return .none
}
case .message:
return .none
}
}
.ifLet(\.$message, action: \.message)
}
}
// MARK: - SuccessMessage
extension FileExport {
/// Represents an alert notifying the user the save operation was successful, giving them the options to say OK or reveal the file in the finder.
@Reducer
public struct SuccessMessage {
@ObservableState
public struct State: Equatable {
/// The file URL.
public let file: URL
/// The user-friendly file path.
public var filePath: String {
file.path(percentEncoded: false)
}
/// The file name.
public var fileName: String {
file.lastPathComponent
}
/// Constructs the success message.
/// - Parameter file: The `URL` that the file was saved at.
public init(file: URL) {
self.file = file
}
}
/// The available actions.
public enum Action {
/// The OK button is was tapped.
case okButtonTapped
/// The Reveal in Finder button was tapped.
case revealInFinderButtonTapped
}
@Dependency(\.fileOpener) var fileOpener
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .okButtonTapped:
// Handled by the parent
return .none
case .revealInFinderButtonTapped:
fileOpener.revealInFinder(url: state.file)
return .none
}
}
}
}
}
// MARK: - FailureMessage
extension FileExport {
/// Represents an alert notifying the user the save operation failed.
@Reducer
public struct FailureMessage {
@ObservableState
public struct State: Equatable {
/// The error message.
public let message: String
}
public enum Action {
/// The OK button is was tapped.
case okButtonTapped
}
public var body: some ReducerOf<Self> {
Reduce { _, action in
switch action {
case .okButtonTapped:
return .none
}
}
}
}
}
// MARK: - View
public struct FileExportView<ExportingMessage: View, Document: FileDocument & Equatable>: View {
@Bindable var store: StoreOf<FileExport<Document>>
@ViewBuilder let exportingMessage: () -> ExportingMessage
public init(store: StoreOf<FileExport<Document>>, @ViewBuilder exportingMessage: @escaping () -> ExportingMessage) {
self.store = store
self.exportingMessage = exportingMessage
}
public var body: some View {
exportingMessage()
.fileExporter(
isPresented: $store.isExporting,
document: store.document,
contentTypes: [store.contentType],
defaultFilename: store.defaultFileName
) { result in
store.send(.exportResult(result))
} onCancellation: {
store.send(.cancelled)
}
.alert(
$store.scope(state: \.message?.success, action: \.message.success),
title: "Success",
actions: { store in
Button {
store.send(.okButtonTapped)
} label: {
Text("OK")
}
Button {
store.send(.revealInFinderButtonTapped)
} label: {
Text("Reveal in Finder")
}
},
message: { store in
Text("Saved \"\(store.fileName)\".")
}
)
.alert(
$store.scope(state: \.message?.failure, action: \.message.failure),
title: "An error occurred",
actions: { store in
Button {
store.send(.okButtonTapped)
} label: {
Text("OK")
}
},
message: { store in
Text(store.message)
}
)
}
} This essentially gives me a reusable State I can use for exporting any kind of I'm using it in something like this (trimmed for readability): @Reducer
public struct FileConverter {
/// Indicates we are performing a file export.
@Reducer(state: .equatable)
public enum Exporting {
case converted(FileExport<MyDocument>)
case warnings(FileExport<WarningsDocument>)
}
/// Represents an FCPXML file being operated on in the UI
@ObservableState
public struct State: Equatable {
/// The source document.
public let sourceDocument: MyDocument
/// The target output ``TargetDetails/State``.
public var target: TargetDetails.State
public var exporting: Exporting.State?
/// The target filename.
var targetFileNameWithoutExtension: String {
"\(sourceDocument.nameWithoutExtension ?? "Untitled")\(target.version.map { " (v\($0))" } ?? "")"
}
public init(
sourceDocument: MyDocument,
target: TargetDetails.State = .init(),
exporting: Exporting.State? = nil
) {
self.sourceDocument = sourceDocument
self.target = target
self.exporting = exporting
}
}
@CasePathable
@dynamicMemberLookup
/// Represents actions which can be taken on an FCPXMLFile.
public enum Action: BindableAction {
/// These actions are delegated to the parent.
public enum Delegate {
case dismissed
case conversionSaved(file: URL)
case reportError(title: LocalizedStringKey, message: LocalizedStringKey)
}
/// Used by SwiftUI views to update state via a binding.
case binding(BindingAction<State>)
/// Indicates a delegated action occurred.
case delegate(Delegate)
/// The close button was tapped.
case closeButtonTapped
/// The "Convert" button was tapped.
case convertButtonTapped
/// The "Export Warnings" button was tapped.
case exportWarningsButtonTapped
/// A destination action occurred.
case exporting(Exporting.Action)
/// An action occurred for the ``TargetDetails`` reducer.
case target(TargetDetails.Action)
}
@Dependency(\.documentConverter) var documentConverter: DocumentConverter
public init() {}
/// Manages converting actions + state into events.
public var body: some ReducerOf<Self> {
// Scopes the `.target` state/action into the `TargetDetails` reducer.
Scope(state: \.target, action: \.target) {
TargetDetails()
}
Scope(state: \.exporting, action: \.exporting) {
Scope(state: \.fcpxml, action: \.fcpxml) {
FileExport()
}
Scope(state: \.warnings, action: \.warnings) {
FileExport()
}
}
// Does some automatic work for `Binding` values
BindingReducer()
Reduce {
state,
action in
switch action {
case .binding:
return .none
case .closeButtonTapped:
return .send(.delegate(.dismissed))
case .delegate:
// Pass it up the chain
return .none
case .exporting(.fcpxml(.delegate)):
state.exporting = nil
return .none
case .exporting(.warnings(.delegate)):
state.exporting = nil
return .none
case .exporting:
return .none
case .convertButtonTapped:
guard let converted = documentConverter.convert(
document: state.sourceDocument,
to: state.target.version,
withFormat: state.target.format
) else {
return .send(
.delegate(
.reportError(
title: "Unable to Convert",
message: "Unable to convert from \(state.sourceVersion.description) to \(targetVersion.description) in \(targetFormat.description) format."
)
)
)
}
state.exporting = .converted(
.init(
document: converted,
defaultFileName: "\(state.targetFileNameWithoutExtension).\(converted.extension)",
contentType: targetFormat.contentType
)
)
return .none
case .exportWarningsButtonTapped:
let warnings = state.affectedCapabilities.map { capability in
Warning(title: capability.name, description: capability.downgradeDescription)
}
let warningsDoc = WarningsDocument(sourceVersion: state.sourceVersion, targetVersion: state.target.version, warnings: warnings)
let fileName = "\(state.targetFileNameWithoutExtension) (WARNINGS).txt"
state.exporting = .warnings(
.init(
document: warningsDoc,
defaultFileName: fileName,
contentType: .text
)
)
return .none
case .target:
return .none
}
}
}
} |
Beta Was this translation helpful? Give feedback.
-
@randomeizer We chatted about this today! The main thing we realized is that the shape of this operator isn't super Because of this, I think we'd be open to explore the following to make things more accessible to TCA:
If you agree with this, would you be open to working with us on tackling any of it? |
Beta Was this translation helpful? Give feedback.
-
Just an update. Have a preliminary PR here for this: pointfreeco/swiftui-navigation#145 Would like feedback on the implementation, notes are in the comments. I've also added a case study, although it's basically the same case study as that using Anyway, that PR is here: |
Beta Was this translation helpful? Give feedback.
-
While
AlertState
is pretty clever, I have a few issues with it. I'm not a big fan of putting what is essentially presentation logic into state, even if it's "sequestered" in a stateful way. But it's also pretty limited - you can have a message and two buttons, and that's about it.I came up with a new
View.alert(...)
override that lets me define a new feature with whatever state and buttons I want in a similar way to standard SwiftUIalert
functions, but scoping into the feature.Here's the extension:
And here's an example of it in use:
It allows putting the
View
specifics back into the View, and the feature is now just plain old state and actions again.I'm sure there are edge cases, and better ways defining the API, but it does work in my current project.
Would love to hear thoughts about this becoming part of the standard library.
Beta Was this translation helpful? Give feedback.
All reactions