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) {
|
||||
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 }
|
||||
|
|
|
@ -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?>,
|
||||
|
|
|
@ -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?>,
|
||||
|
|
|
@ -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.
|
||||
///
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@_spi(RuntimeWarn)
|
||||
@_transparent
|
||||
@inline(__always)
|
||||
@usableFromInline
|
||||
func runtimeWarn(
|
||||
public func runtimeWarn(
|
||||
_ message: @autoclosure () -> String,
|
||||
category: String? = "SwiftUINavigation",
|
||||
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