Support synchronous `ButtonState` action closures (#70)

* Support synchronous action closures

When we added async support for alert/dialog action closures, we broke
implicit animations, because it's impossible to run an async closure
inside `SwiftUI.withAnimation`. Let's bring back this functionality, and
runtime warn loudly whenever an animated action is emitted to an async
action handler.

* wip
This commit is contained in:
Stephen Celis 2023-01-23 16:52:59 -08:00 committed by GitHub
parent 2500304089
commit 34af246f52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 376 additions and 33 deletions

View File

@ -23,9 +23,7 @@ class InventoryModel: ObservableObject {
}
func delete(item: Item) {
withAnimation {
_ = self.inventory.remove(id: item.id)
}
_ = self.inventory.remove(id: item.id)
}
func add(item: Item) {
@ -56,15 +54,11 @@ class InventoryModel: ObservableObject {
for itemRowModel in self.inventory {
itemRowModel.onDelete = { [weak self, weak itemRowModel] in
guard let self, let itemRowModel else { return }
withAnimation {
self.delete(item: itemRowModel.item)
}
self.delete(item: itemRowModel.item)
}
itemRowModel.onDuplicate = { [weak self] item in
guard let self else { return }
withAnimation {
self.add(item: item)
}
self.add(item: item)
}
itemRowModel.onTap = { [weak self, weak itemRowModel] in
guard let self, let itemRowModel else { return }

View File

@ -105,7 +105,40 @@ extension View {
#if swift(>=5.7)
/// Presents an alert from a binding to optional ``AlertState``.
///
/// See <doc:AlertsDialogs> for more information on how to use this API.
/// See <doc:AlertsDialogs> for more information on how to use this API.
///
/// - Parameters:
/// - value: A binding to an optional value that determines whether an alert should be
/// presented. When the binding is updated with non-`nil` value, it is unwrapped and used to
/// populate the fields of an alert that the system displays to the user. When the user
/// presses or taps one of the alert's actions, the system sets this value to `nil` and
/// dismisses the alert, and the action is fed to the `action` closure.
/// - handler: A closure that is called with an action from a particular alert button when
/// tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Value>(
unwrapping value: Binding<AlertState<Value>?>,
action handler: @escaping (Value) -> Void = { (_: Void) in }
) -> some View {
self.alert(
(value.wrappedValue?.title).map(Text.init) ?? Text(""),
isPresented: value.isPresent(),
presenting: value.wrappedValue,
actions: {
ForEach($0.buttons) {
Button($0, action: handler)
}
},
message: { $0.message.map { Text($0) } }
)
}
/// Presents an alert from a binding to optional ``AlertState``.
///
/// See <doc:AlertsDialogs> for more information on how to use this API.
///
/// > Warning: Async closures cannot be performed with animation. If the underlying action is
/// > animated, a runtime warning will be emitted.
///
/// - Parameters:
/// - value: A binding to an optional value that determines whether an alert should be
@ -126,11 +159,7 @@ extension View {
presenting: value.wrappedValue,
actions: {
ForEach($0.buttons) {
Button($0) { action in
Task {
await handler(action)
}
}
Button($0, action: handler)
}
},
message: { $0.message.map { Text($0) } }
@ -155,6 +184,35 @@ extension View {
/// - handler: A closure that is called with an action from a particular alert button when
/// tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Enum, Value>(
unwrapping `enum`: Binding<Enum?>,
case casePath: CasePath<Enum, AlertState<Value>>,
action handler: @escaping (Value) -> Void = { (_: Void) in }
) -> some View {
self.alert(unwrapping: `enum`.case(casePath), action: handler)
}
/// Presents an alert from a binding to an optional enum, and a [case path][case-paths-gh] to a
/// specific case of ``AlertState``.
///
/// A version of `alert(unwrapping:)` that works with enum state. See <doc:AlertsDialogs> for
/// more information on how to use this API.
///
/// > Warning: Async closures cannot be performed with animation. If the underlying action is
/// > animated, a runtime warning will be emitted.
///
/// [case-paths-gh]: http://github.com/pointfreeco/swift-case-paths
///
/// - Parameters:
/// - enum: A binding to an optional enum that holds alert state at a particular case. When
/// the binding is updated with a non-`nil` enum, the case path will attempt to extract this
/// state and use it to populate the fields of an alert that the system displays to the user.
/// When the user presses or taps one of the alert's actions, the system sets this value to
/// `nil` and dismisses the alert, and the action is fed to the `action` closure.
/// - casePath: A case path that identifies a particular case that holds alert state.
/// - handler: A closure that is called with an action from a particular alert button when
/// tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Enum, Value>(
unwrapping `enum`: Binding<Enum?>,
case casePath: CasePath<Enum, AlertState<Value>>,
@ -163,6 +221,24 @@ extension View {
self.alert(unwrapping: `enum`.case(casePath), action: handler)
}
#else
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Value>(
unwrapping value: Binding<AlertState<Value>?>,
action handler: @escaping (Value) -> Void
) -> some View {
self.alert(
(value.wrappedValue?.title).map(Text.init) ?? Text(""),
isPresented: value.isPresent(),
presenting: value.wrappedValue,
actions: {
ForEach($0.buttons) {
Button($0, action: handler)
}
},
message: { $0.message.map { Text($0) } }
)
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Value>(
unwrapping value: Binding<AlertState<Value>?>,
@ -174,11 +250,7 @@ extension View {
presenting: value.wrappedValue,
actions: {
ForEach($0.buttons) {
Button($0) { action in
Task {
await handler(action)
}
}
Button($0, action: handler)
}
},
message: { $0.message.map { Text($0) } }
@ -202,6 +274,15 @@ extension View {
)
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Enum, Value>(
unwrapping `enum`: Binding<Enum?>,
case casePath: CasePath<Enum, AlertState<Value>>,
action handler: @escaping (Value) -> Void
) -> some View {
self.alert(unwrapping: `enum`.case(casePath), action: handler)
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func alert<Enum, Value>(
unwrapping `enum`: Binding<Enum?>,

View File

@ -124,6 +124,40 @@ extension View {
/// - handler: A closure that is called with an action from a particular dialog button when
/// tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Value>(
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
action handler: @escaping (Value) -> Void = { (_: Void) in }
) -> some View {
self.confirmationDialog(
value.wrappedValue.flatMap { Text($0.title) } ?? Text(""),
isPresented: value.isPresent(),
titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic,
presenting: value.wrappedValue,
actions: {
ForEach($0.buttons) {
Button($0, action: handler)
}
},
message: { $0.message.map { Text($0) } }
)
}
/// Presents a confirmation dialog from a binding to optional ``ConfirmationDialogState``.
///
/// See <doc:AlertsDialogs> for more information on how to use this API.
///
/// > Warning: Async closures cannot be performed with animation. If the underlying action is
/// > animated, a runtime warning will be emitted.
///
/// - Parameters:
/// - value: A binding to an optional value that determines whether a confirmation dialog should
/// be presented. When the binding is updated with non-`nil` value, it is unwrapped and used
/// to populate the fields of a dialog that the system displays to the user. When the user
/// presses or taps one of the dialog's actions, the system sets this value to `nil` and
/// dismisses the dialog, and the action is fed to the `action` closure.
/// - handler: A closure that is called with an action from a particular dialog button when
/// tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Value>(
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
action handler: @escaping (Value) async -> Void = { (_: Void) async in }
@ -135,11 +169,7 @@ extension View {
presenting: value.wrappedValue,
actions: {
ForEach($0.buttons) {
Button($0) { action in
Task {
await handler(action)
}
}
Button($0, action: handler)
}
},
message: { $0.message.map { Text($0) } }
@ -162,6 +192,36 @@ extension View {
/// - handler: A closure that is called with an action from a particular dialog button when
/// tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Enum, Value>(
unwrapping `enum`: Binding<Enum?>,
case casePath: CasePath<Enum, ConfirmationDialogState<Value>>,
action handler: @escaping (Value) -> Void = { (_: Void) in }
) -> some View {
self.confirmationDialog(
unwrapping: `enum`.case(casePath),
action: handler
)
}
/// Presents a confirmation dialog from a binding to an optional enum, and a case path to a
/// specific case of ``ConfirmationDialogState``.
///
/// A version of `confirmationDialog(unwrapping:)` that works with enum state. See
/// <doc:AlertsDialogs> for more information on how to use this API.
///
/// > Warning: Async closures cannot be performed with animation. If the underlying action is
/// > animated, a runtime warning will be emitted.
///
/// - Parameters:
/// - enum: A binding to an optional enum that holds dialog state at a particular case. When
/// the binding is updated with a non-`nil` enum, the case path will attempt to extract this
/// state and use it to populate the fields of an dialog that the system displays to the user.
/// When the user presses or taps one of the dialog's actions, the system sets this value to
/// `nil` and dismisses the dialog, and the action is fed to the `action` closure.
/// - casePath: A case path that identifies a particular case that holds dialog state.
/// - handler: A closure that is called with an action from a particular dialog button when
/// tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Enum, Value>(
unwrapping `enum`: Binding<Enum?>,
case casePath: CasePath<Enum, ConfirmationDialogState<Value>>,
@ -173,6 +233,25 @@ extension View {
)
}
#else
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Value>(
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
action handler: @escaping (Value) -> Void
) -> some View {
self.confirmationDialog(
value.wrappedValue.flatMap { Text($0.title) } ?? Text(""),
isPresented: value.isPresent(),
titleVisibility: value.wrappedValue.map { .init($0.titleVisibility) } ?? .automatic,
presenting: value.wrappedValue,
actions: {
ForEach($0.buttons) {
Button($0, action: handler)
}
},
message: { $0.message.map { Text($0) } }
)
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Value>(
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
@ -185,11 +264,7 @@ extension View {
presenting: value.wrappedValue,
actions: {
ForEach($0.buttons) {
Button($0) { action in
Task {
await handler(action)
}
}
Button($0, action: handler)
}
},
message: { $0.message.map { Text($0) } }
@ -206,6 +281,18 @@ extension View {
)
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Enum, Value>(
unwrapping `enum`: Binding<Enum?>,
case casePath: CasePath<Enum, ConfirmationDialogState<Value>>,
action handler: @escaping (Value) -> Void
) -> some View {
self.confirmationDialog(
unwrapping: `enum`.case(casePath),
action: handler
)
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public func confirmationDialog<Enum, Value>(
unwrapping `enum`: Binding<Enum?>,

View File

@ -1,4 +1,5 @@
import SwiftUI
@_spi(RuntimeWarn) import _SwiftUINavigationState
/// A view that can switch over a binding of enum state and exhaustively handle each case.
///

View File

@ -210,6 +210,29 @@ extension Alert {
)
}
}
/// Creates an alert from alert state.
///
/// - Parameters:
/// - state: Alert state used to populate the alert.
/// - action: An action handler, called when a button with an action is tapped, by passing the
/// action to the closure.
public init<Action>(_ state: AlertState<Action>, action: @escaping (Action) async -> Void) {
if state.buttons.count == 2 {
self.init(
title: Text(state.title),
message: state.message.map { Text($0) },
primaryButton: .init(state.buttons[0], action: action),
secondaryButton: .init(state.buttons[1], action: action)
)
} else {
self.init(
title: Text(state.title),
message: state.message.map { Text($0) },
dismissButton: state.buttons.first.map { .init($0, action: action) }
)
}
}
}
// MARK: - Deprecations

View File

@ -83,7 +83,7 @@ public struct ButtonState<Action>: Identifiable {
switch self.action?.type {
case let .send(action):
perform(action)
case let .animatedSend(action, animation: animation):
case let .animatedSend(action, animation):
withAnimation(animation) {
perform(action)
}
@ -91,6 +91,35 @@ public struct ButtonState<Action>: Identifiable {
return
}
}
/// Handle the button's action in an async closure.
///
/// > Warning: Async closures cannot be performed with animation. If the underlying action is
/// > animated, a runtime warning will be emitted.
///
/// - Parameter perform: Unwraps and passes a button's action to a closure to be performed.
public func withAction(_ perform: (Action) async -> Void) async {
guard let handler = self.action else { return }
switch handler.type {
case let .send(action):
await perform(action)
case let .animatedSend(action, _):
var output = ""
customDump(handler, to: &output, indent: 4)
runtimeWarn(
"""
An animated action was performed asynchronously:
Action:
\((output))
Asynchronous actions cannot be animated. Evaluate this action in a synchronous closure, or \
use 'SwiftUI.withAnimation' explicitly.
"""
)
await perform(action)
}
}
}
extension ButtonState: CustomDumpReflectable {
@ -166,6 +195,11 @@ extension ButtonState: Hashable where Action: Hashable {
// MARK: - SwiftUI bridging
extension Alert.Button {
/// Initializes a `SwiftUI.Alert.Button` from `ButtonState` and an action handler.
///
/// - Parameters:
/// - button: Button state.
/// - action: An action closure that is invoked when the button is tapped.
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) -> Void) {
let action = { button.withAction(action) }
switch button.role {
@ -177,6 +211,26 @@ extension Alert.Button {
self = .default(Text(button.label), action: action)
}
}
/// Initializes a `SwiftUI.Alert.Button` from `ButtonState` and an async action handler.
///
/// > Warning: Async closures cannot be performed with animation. If the underlying action is
/// > animated, a runtime warning will be emitted.
///
/// - Parameters:
/// - button: Button state.
/// - action: An action closure that is invoked when the button is tapped.
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) async -> Void) {
let action = { _ = Task { await button.withAction(action) } }
switch button.role {
case .cancel:
self = .cancel(Text(button.label), action: action)
case .destructive:
self = .destructive(Text(button.label), action: action)
case .none:
self = .default(Text(button.label), action: action)
}
}
}
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
@ -192,6 +246,11 @@ extension ButtonRole {
}
extension Button where Label == Text {
/// Initializes a `SwiftUI.Button` from `ButtonState` and an async action handler.
///
/// - Parameters:
/// - button: Button state.
/// - action: An action closure that is invoked when the button is tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) -> Void) {
self.init(
@ -201,6 +260,24 @@ extension Button where Label == Text {
Text(button.label)
}
}
/// Initializes a `SwiftUI.Button` from `ButtonState` and an action handler.
///
/// > Warning: Async closures cannot be performed with animation. If the underlying action is
/// > animated, a runtime warning will be emitted.
///
/// - Parameters:
/// - button: Button state.
/// - action: An action closure that is invoked when the button is tapped.
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) async -> Void) {
self.init(
role: button.role.map(ButtonRole.init),
action: { Task { await button.withAction(action) } }
) {
Text(button.label)
}
}
}
// MARK: - Deprecations
@ -257,3 +334,51 @@ extension ButtonState {
}
}
}
@usableFromInline
func debugCaseOutput(_ value: Any) -> String {
func debugCaseOutputHelp(_ value: Any) -> String {
let mirror = Mirror(reflecting: value)
switch mirror.displayStyle {
case .enum:
guard let child = mirror.children.first else {
let childOutput = "\(value)"
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
}
let childOutput = debugCaseOutputHelp(child.value)
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
case .tuple:
return mirror.children.map { label, value in
let childOutput = debugCaseOutputHelp(value)
return
"\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
}
.joined(separator: ", ")
default:
return ""
}
}
return (value as? CustomDebugStringConvertible)?.debugDescription
?? "\(typeName(type(of: value)))\(debugCaseOutputHelp(value))"
}
private func isUnlabeledArgument(_ label: String) -> Bool {
label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil
}
@usableFromInline
func typeName(_ type: Any.Type) -> String {
var name = _typeName(type, qualified: true)
if let index = name.firstIndex(of: ".") {
name.removeSubrange(...index)
}
let sanitizedName =
name
.replacingOccurrences(
of: #"<.+>|\(unknown context at \$[[:xdigit:]]+\)\."#,
with: "",
options: .regularExpression
)
return sanitizedName
}

View File

@ -1,7 +1,7 @@
@_spi(RuntimeWarn)
@_transparent
@inline(__always)
@usableFromInline
func runtimeWarn(
public func runtimeWarn(
_ message: @autoclosure () -> String,
category: String? = "SwiftUINavigation",
file: StaticString? = nil,

View File

@ -0,0 +1,32 @@
import CustomDump
import SwiftUI
import SwiftUINavigation
import XCTest
@MainActor
final class ButtonStateTests: XCTestCase {
func testAsyncAnimationWarning() async {
XCTExpectFailure {
$0.compactDescription == """
An animated action was performed asynchronously:
Action:
ButtonState.Handler.send(
(),
animation: Animation.easeInOut
)
Asynchronous actions cannot be animated. Evaluate this action in a synchronous closure, or \
use 'SwiftUI.withAnimation' explicitly.
"""
}
let button = ButtonState(action: .send((), animation: .default)) {
TextState("Animate!")
}
await button.withAction {
await Task.yield()
}
}
}