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:
parent
2500304089
commit
34af246f52
|
@ -23,9 +23,7 @@ class InventoryModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(item: Item) {
|
func delete(item: Item) {
|
||||||
withAnimation {
|
_ = self.inventory.remove(id: item.id)
|
||||||
_ = self.inventory.remove(id: item.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func add(item: Item) {
|
func add(item: Item) {
|
||||||
|
@ -56,15 +54,11 @@ class InventoryModel: ObservableObject {
|
||||||
for itemRowModel in self.inventory {
|
for itemRowModel in self.inventory {
|
||||||
itemRowModel.onDelete = { [weak self, weak itemRowModel] in
|
itemRowModel.onDelete = { [weak self, weak itemRowModel] in
|
||||||
guard let self, let itemRowModel else { return }
|
guard let self, let itemRowModel else { return }
|
||||||
withAnimation {
|
self.delete(item: itemRowModel.item)
|
||||||
self.delete(item: itemRowModel.item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemRowModel.onDuplicate = { [weak self] item in
|
itemRowModel.onDuplicate = { [weak self] item in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
withAnimation {
|
self.add(item: item)
|
||||||
self.add(item: item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
itemRowModel.onTap = { [weak self, weak itemRowModel] in
|
itemRowModel.onTap = { [weak self, weak itemRowModel] in
|
||||||
guard let self, let itemRowModel else { return }
|
guard let self, let itemRowModel else { return }
|
||||||
|
|
|
@ -105,7 +105,40 @@ extension View {
|
||||||
#if swift(>=5.7)
|
#if swift(>=5.7)
|
||||||
/// Presents an alert from a binding to optional ``AlertState``.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - value: A binding to an optional value that determines whether an alert should be
|
/// - value: A binding to an optional value that determines whether an alert should be
|
||||||
|
@ -126,11 +159,7 @@ extension View {
|
||||||
presenting: value.wrappedValue,
|
presenting: value.wrappedValue,
|
||||||
actions: {
|
actions: {
|
||||||
ForEach($0.buttons) {
|
ForEach($0.buttons) {
|
||||||
Button($0) { action in
|
Button($0, action: handler)
|
||||||
Task {
|
|
||||||
await handler(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
message: { $0.message.map { Text($0) } }
|
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
|
/// - handler: A closure that is called with an action from a particular alert button when
|
||||||
/// tapped.
|
/// tapped.
|
||||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
@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>(
|
public func alert<Enum, Value>(
|
||||||
unwrapping `enum`: Binding<Enum?>,
|
unwrapping `enum`: Binding<Enum?>,
|
||||||
case casePath: CasePath<Enum, AlertState<Value>>,
|
case casePath: CasePath<Enum, AlertState<Value>>,
|
||||||
|
@ -163,6 +221,24 @@ extension View {
|
||||||
self.alert(unwrapping: `enum`.case(casePath), action: handler)
|
self.alert(unwrapping: `enum`.case(casePath), action: handler)
|
||||||
}
|
}
|
||||||
#else
|
#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, *)
|
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||||
public func alert<Value>(
|
public func alert<Value>(
|
||||||
unwrapping value: Binding<AlertState<Value>?>,
|
unwrapping value: Binding<AlertState<Value>?>,
|
||||||
|
@ -174,11 +250,7 @@ extension View {
|
||||||
presenting: value.wrappedValue,
|
presenting: value.wrappedValue,
|
||||||
actions: {
|
actions: {
|
||||||
ForEach($0.buttons) {
|
ForEach($0.buttons) {
|
||||||
Button($0) { action in
|
Button($0, action: handler)
|
||||||
Task {
|
|
||||||
await handler(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
message: { $0.message.map { Text($0) } }
|
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, *)
|
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||||
public func alert<Enum, Value>(
|
public func alert<Enum, Value>(
|
||||||
unwrapping `enum`: Binding<Enum?>,
|
unwrapping `enum`: Binding<Enum?>,
|
||||||
|
|
|
@ -124,6 +124,40 @@ extension View {
|
||||||
/// - handler: A closure that is called with an action from a particular dialog button when
|
/// - handler: A closure that is called with an action from a particular dialog button when
|
||||||
/// tapped.
|
/// tapped.
|
||||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
@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>(
|
public func confirmationDialog<Value>(
|
||||||
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
|
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
|
||||||
action handler: @escaping (Value) async -> Void = { (_: Void) async in }
|
action handler: @escaping (Value) async -> Void = { (_: Void) async in }
|
||||||
|
@ -135,11 +169,7 @@ extension View {
|
||||||
presenting: value.wrappedValue,
|
presenting: value.wrappedValue,
|
||||||
actions: {
|
actions: {
|
||||||
ForEach($0.buttons) {
|
ForEach($0.buttons) {
|
||||||
Button($0) { action in
|
Button($0, action: handler)
|
||||||
Task {
|
|
||||||
await handler(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
message: { $0.message.map { Text($0) } }
|
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
|
/// - handler: A closure that is called with an action from a particular dialog button when
|
||||||
/// tapped.
|
/// tapped.
|
||||||
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
@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>(
|
public func confirmationDialog<Enum, Value>(
|
||||||
unwrapping `enum`: Binding<Enum?>,
|
unwrapping `enum`: Binding<Enum?>,
|
||||||
case casePath: CasePath<Enum, ConfirmationDialogState<Value>>,
|
case casePath: CasePath<Enum, ConfirmationDialogState<Value>>,
|
||||||
|
@ -173,6 +233,25 @@ extension View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#else
|
#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, *)
|
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||||
public func confirmationDialog<Value>(
|
public func confirmationDialog<Value>(
|
||||||
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
|
unwrapping value: Binding<ConfirmationDialogState<Value>?>,
|
||||||
|
@ -185,11 +264,7 @@ extension View {
|
||||||
presenting: value.wrappedValue,
|
presenting: value.wrappedValue,
|
||||||
actions: {
|
actions: {
|
||||||
ForEach($0.buttons) {
|
ForEach($0.buttons) {
|
||||||
Button($0) { action in
|
Button($0, action: handler)
|
||||||
Task {
|
|
||||||
await handler(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
message: { $0.message.map { Text($0) } }
|
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, *)
|
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||||
public func confirmationDialog<Enum, Value>(
|
public func confirmationDialog<Enum, Value>(
|
||||||
unwrapping `enum`: Binding<Enum?>,
|
unwrapping `enum`: Binding<Enum?>,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
@_spi(RuntimeWarn) import _SwiftUINavigationState
|
||||||
|
|
||||||
/// A view that can switch over a binding of enum state and exhaustively handle each case.
|
/// A view that can switch over a binding of enum state and exhaustively handle each case.
|
||||||
///
|
///
|
||||||
|
|
|
@ -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
|
// MARK: - Deprecations
|
||||||
|
|
|
@ -83,7 +83,7 @@ public struct ButtonState<Action>: Identifiable {
|
||||||
switch self.action?.type {
|
switch self.action?.type {
|
||||||
case let .send(action):
|
case let .send(action):
|
||||||
perform(action)
|
perform(action)
|
||||||
case let .animatedSend(action, animation: animation):
|
case let .animatedSend(action, animation):
|
||||||
withAnimation(animation) {
|
withAnimation(animation) {
|
||||||
perform(action)
|
perform(action)
|
||||||
}
|
}
|
||||||
|
@ -91,6 +91,35 @@ public struct ButtonState<Action>: Identifiable {
|
||||||
return
|
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 {
|
extension ButtonState: CustomDumpReflectable {
|
||||||
|
@ -166,6 +195,11 @@ extension ButtonState: Hashable where Action: Hashable {
|
||||||
// MARK: - SwiftUI bridging
|
// MARK: - SwiftUI bridging
|
||||||
|
|
||||||
extension Alert.Button {
|
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) {
|
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) -> Void) {
|
||||||
let action = { button.withAction(action) }
|
let action = { button.withAction(action) }
|
||||||
switch button.role {
|
switch button.role {
|
||||||
|
@ -177,6 +211,26 @@ extension Alert.Button {
|
||||||
self = .default(Text(button.label), action: action)
|
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, *)
|
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||||
|
@ -192,6 +246,11 @@ extension ButtonRole {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Button where Label == Text {
|
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, *)
|
@available(iOS 15, macOS 12, tvOS 15, watchOS 8, *)
|
||||||
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) -> Void) {
|
public init<Action>(_ button: ButtonState<Action>, action: @escaping (Action) -> Void) {
|
||||||
self.init(
|
self.init(
|
||||||
|
@ -201,6 +260,24 @@ extension Button where Label == Text {
|
||||||
Text(button.label)
|
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
|
// 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
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
@_spi(RuntimeWarn)
|
||||||
@_transparent
|
@_transparent
|
||||||
@inline(__always)
|
@inline(__always)
|
||||||
@usableFromInline
|
public func runtimeWarn(
|
||||||
func runtimeWarn(
|
|
||||||
_ message: @autoclosure () -> String,
|
_ message: @autoclosure () -> String,
|
||||||
category: String? = "SwiftUINavigation",
|
category: String? = "SwiftUINavigation",
|
||||||
file: StaticString? = nil,
|
file: StaticString? = nil,
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue