Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions Sources/SwiftNavigation/ButtonState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@ public import Foundation
public struct ButtonState<Action>: Identifiable {
public let id: UUID
public let action: ButtonStateAction<Action>
/// A Boolean value that indicates whether supporting UI components should make this button the
/// preferred action.
public let isPreferred: Bool
public let label: TextState
public let role: ButtonStateRole?

init(
id: UUID,
action: ButtonStateAction<Action>,
label: TextState,
role: ButtonStateRole?
role: ButtonStateRole?,
isPreferred: Bool = false
) {
self.id = id
self.action = action
self.isPreferred = isPreferred
self.label = label
self.role = role
}
Expand Down Expand Up @@ -118,7 +123,21 @@ public struct ButtonState<Action>: Identifiable {
id: self.id,
action: self.action.map(transform),
label: self.label,
role: self.role
role: self.role,
isPreferred: self.isPreferred
)
}

/// Returns a copy of the button state that is or is not preferred.
///
/// Supporting UI components can use a preferred button as their default action.
public func preferred(_ isPreferred: Bool = true) -> Self {
ButtonState(
id: self.id,
action: self.action,
label: self.label,
role: self.role,
isPreferred: isPreferred
)
}
}
Expand Down Expand Up @@ -190,6 +209,7 @@ extension ButtonStateRole: Equatable {}
extension ButtonState: Equatable where Action: Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.action == rhs.action
&& lhs.isPreferred == rhs.isPreferred
&& lhs.label == rhs.label
&& lhs.role == rhs.role
}
Expand All @@ -212,6 +232,7 @@ extension ButtonStateRole: Hashable {}
extension ButtonState: Hashable where Action: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(self.action)
hasher.combine(self.isPreferred)
hasher.combine(self.label)
hasher.combine(self.role)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- ``init(role:action:label:)-65t48``
- ``init(role:action:label:)-8tmop``
- ``preferred(_:)``
- ``ButtonStateRole``
- ``ButtonStateAction``

Expand All @@ -17,6 +18,7 @@

- ``id``
- ``role-swift.property``
- ``isPreferred``
- ``action``
- ``label``

Expand Down
3 changes: 3 additions & 0 deletions Sources/SwiftNavigation/Traits/CustomDump.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
if let role = self.role {
children.append(("role", role))
}
if self.isPreferred {
children.append(("isPreferred", self.isPreferred))
}
children.append(("action", self.action))
children.append(("label", self.label))
return Mirror(
Expand Down
29 changes: 23 additions & 6 deletions Sources/UIKitNavigation/Navigation/UIAlertController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@
message: state.message.map { String(state: $0) },
preferredStyle: .alert
)
for button in state.buttons {
addAction(UIAlertAction(button, action: handler))
}
self.addActions(state.buttons, stateName: "AlertState", handler: handler)
if state.buttons.isEmpty {
addAction(UIAlertAction(title: "OK", style: .cancel))
}
Expand Down Expand Up @@ -73,13 +71,32 @@
message: state.message.map { String(state: $0) },
preferredStyle: .actionSheet
)
for button in state.buttons {
addAction(UIAlertAction(button, action: handler))
}
self.addActions(state.buttons, stateName: "ConfirmationDialogState", handler: handler)
if state.buttons.isEmpty {
addAction(UIAlertAction(title: "OK", style: .cancel))
}
}

private func addActions<Action>(
_ buttons: [ButtonState<Action>],
stateName: String,
handler: @escaping (_ action: Action?) -> Void
) {
let actions = buttons.map { UIAlertAction($0, action: handler) }
for action in actions {
self.addAction(action)
}
let preferred = zip(buttons, actions).filter(\.0.isPreferred).map(\.1)
self.preferredAction = preferred.first
if preferred.count > 1 {
reportIssue(
"""
'UIAlertController' received '\(stateName)' with multiple preferred buttons. Will use \
the first preferred button.
"""
)
}
}
}

@available(iOS 13, *)
Expand Down
41 changes: 41 additions & 0 deletions Tests/SwiftNavigationTests/ButtonStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@
import Testing

struct ButtonStateTests {
@Test
func preferred() {
let button = ButtonState(action: true) {
TextState("OK")
}

#expect(!button.isPreferred)
#expect(button.preferred().isPreferred)
#expect(!button.preferred(false).isPreferred)
#expect(button != button.preferred())

var dump = ""
customDump(button.preferred(), to: &dump)
expectNoDifference(
dump,
"""
ButtonState(
isPreferred: true,
action: .send(
true
),
label: "OK"
)
"""
)
}

@Test
func mapPreservesPreferred() {
let button = ButtonState(action: 42) {
TextState("OK")
}
.preferred()

let mappedButton = button.map { action in
action.map(String.init)
}

#expect(mappedButton.isPreferred)
}

@Test
func testAsyncAnimationWarning() async {
let button = ButtonState(action: .send((), animation: .easeInOut)) {
Expand Down
78 changes: 78 additions & 0 deletions Tests/UIKitNavigationTests/UIAlertControllerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#if canImport(UIKit) && !os(watchOS)
import UIKitNavigation
import XCTest

@available(iOS 13, tvOS 13, *)
final class UIAlertControllerTests: XCTestCase {
@MainActor
func testAlertPreferredAction() {
let controller = UIAlertController(
state: AlertState {
TextState("Title")
} actions: {
ButtonState(action: 1) {
TextState("First")
}
ButtonState(action: 2) {
TextState("Second")
}
.preferred()
}
)

XCTAssertTrue(controller.preferredAction === controller.actions[1])
}

@MainActor
func testConfirmationDialogPreferredAction() {
let controller = UIAlertController(
state: ConfirmationDialogState {
TextState("Title")
} actions: {
ButtonState(action: 1) {
TextState("First")
}
ButtonState(action: 2) {
TextState("Second")
}
.preferred()
}
)

XCTAssertTrue(controller.preferredAction === controller.actions[1])
}

@MainActor
func testMultiplePreferredActionsReportIssueAndUseFirstPreferredAction() {
var controller: UIAlertController?

XCTExpectFailure {
controller = UIAlertController(
state: ConfirmationDialogState {
TextState("Title")
} actions: {
ButtonState(action: 1) {
TextState("First")
}
.preferred()
ButtonState(action: 2) {
TextState("Second")
}
.preferred()
}
)
} issueMatcher: {
$0.compactDescription
== """
failed - 'UIAlertController' received 'ConfirmationDialogState' with multiple preferred buttons. Will use the first preferred button.
"""
}

guard let controller else {
XCTFail("Expected a controller.")
return
}
XCTAssertTrue(controller.preferredAction === controller.actions[0])
}
}
#endif
Loading