Skip to content

Commit d835410

Browse files
authored
Add keyboardToolbar modifier and extensible environment values (#114)
* Add keyboardToolbar modifier * remove unused package modifier * Address PR comments
1 parent fd4f296 commit d835410

File tree

6 files changed

+218
-8
lines changed

6 files changed

+218
-8
lines changed

Sources/SwiftCrossUI/Environment/EnvironmentValues.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ public struct EnvironmentValues {
4444
/// a bottom-up update chain up which resize events can propagate.
4545
var onResize: (_ newSize: ViewSize) -> Void
4646

47+
// Backing storage for extensible subscript
48+
private var extraValues: [ObjectIdentifier: Any]
49+
50+
public subscript<T: EnvironmentKey>(_ key: T.Type) -> T.Value {
51+
get {
52+
extraValues[ObjectIdentifier(T.self), default: T.defaultValue] as! T.Value
53+
}
54+
set {
55+
extraValues[ObjectIdentifier(T.self)] = newValue
56+
}
57+
}
58+
4759
/// Brings the current window forward, not guaranteed to always bring
4860
/// the window to the top (due to focus stealing prevention).
4961
func bringWindowForward() {
@@ -121,6 +133,7 @@ public struct EnvironmentValues {
121133
colorScheme = .light
122134
windowScaleFactor = 1
123135
window = nil
136+
extraValues = [:]
124137
}
125138

126139
/// Returns a copy of the environment with the specified property set to the
@@ -131,3 +144,11 @@ public struct EnvironmentValues {
131144
return environment
132145
}
133146
}
147+
148+
/// A key that can be used to extend the environment with new properties.
149+
public protocol EnvironmentKey {
150+
/// The type of value the key can hold.
151+
associatedtype Value
152+
/// The default value for the key.
153+
static var defaultValue: Value { get }
154+
}

Sources/SwiftCrossUI/Modifiers/EnvironmentModifier.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
struct EnvironmentModifier<Child: View>: View {
2-
var body: TupleView1<Child>
1+
package struct EnvironmentModifier<Child: View>: View {
2+
package var body: TupleView1<Child>
33
var modification: (EnvironmentValues) -> EnvironmentValues
44

5-
init(_ child: Child, modification: @escaping (EnvironmentValues) -> EnvironmentValues) {
5+
package init(_ child: Child, modification: @escaping (EnvironmentValues) -> EnvironmentValues) {
66
self.body = TupleView1(child)
77
self.modification = modification
88
}
99

10-
func children<Backend: AppBackend>(
10+
package func children<Backend: AppBackend>(
1111
backend: Backend,
1212
snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
1313
environment: EnvironmentValues
@@ -19,7 +19,7 @@ struct EnvironmentModifier<Child: View>: View {
1919
)
2020
}
2121

22-
func update<Backend: AppBackend>(
22+
package func update<Backend: AppBackend>(
2323
_ widget: Backend.Widget,
2424
children: any ViewGraphNodeChildren,
2525
proposedSize: SIMD2<Int>,

Sources/SwiftCrossUI/Views/Button.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/// A control that initiates an action.
22
public struct Button: ElementaryView, View {
33
/// The label to show on the button.
4-
var label: String
4+
package var label: String
55
/// The action to be performed when the button is clicked.
6-
var action: () -> Void
6+
package var action: () -> Void
77
/// The button's forced width if provided.
88
var width: Int?
99

Sources/SwiftCrossUI/Views/Spacer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
public struct Spacer: ElementaryView, View {
44
/// The minimum length this spacer can be shrunk to, along the axis of
55
/// expansion.
6-
private var minLength: Int?
6+
package var minLength: Int?
77

88
/// Creates a spacer with a given minimum length along its axis or axes
99
/// of expansion.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import SwiftCrossUI
2+
import UIKit
3+
4+
/// An item which can be displayed in a keyboard toolbar. Implementers of this do not have
5+
/// to implement ``SwiftCrossUI/View``.
6+
///
7+
/// Toolbar items are expected to be "stateless". Mutations of `@State` properties of toolbar
8+
/// items will not cause the toolbar to be updated. The toolbar is only updated when the view
9+
/// containing the ``View/keyboardToolbar(animateChanges:body:)`` modifier is updated, so any
10+
/// state necessary for the toolbar should live in the view itself.
11+
public protocol ToolbarItem {
12+
/// Convert the item to a `UIBarButtonItem`, which will be placed in the keyboard toolbar.
13+
func asBarButtonItem() -> UIBarButtonItem
14+
}
15+
16+
@resultBuilder
17+
public enum ToolbarBuilder {
18+
public typealias Component = [any ToolbarItem]
19+
20+
public static func buildExpression(_ expression: some ToolbarItem) -> Component {
21+
[expression]
22+
}
23+
24+
public static func buildExpression(_ expression: any ToolbarItem) -> Component {
25+
[expression]
26+
}
27+
28+
public static func buildBlock(_ components: Component...) -> Component {
29+
components.flatMap { $0 }
30+
}
31+
32+
public static func buildArray(_ components: [Component]) -> Component {
33+
components.flatMap { $0 }
34+
}
35+
36+
public static func buildOptional(_ component: Component?) -> Component {
37+
component ?? []
38+
}
39+
40+
public static func buildEither(first component: Component) -> Component {
41+
component
42+
}
43+
44+
public static func buildEither(second component: Component) -> Component {
45+
component
46+
}
47+
}
48+
49+
final class CallbackBarButtonItem: UIBarButtonItem {
50+
private var callback: () -> Void
51+
52+
init(title: String, callback: @escaping () -> Void) {
53+
self.callback = callback
54+
super.init()
55+
56+
self.title = title
57+
self.target = self
58+
self.action = #selector(onTap)
59+
}
60+
61+
@available(*, unavailable)
62+
required init?(coder: NSCoder) {
63+
fatalError("init(coder:) is not used for this item")
64+
}
65+
66+
@objc
67+
func onTap() {
68+
callback()
69+
}
70+
}
71+
72+
extension Button: ToolbarItem {
73+
public func asBarButtonItem() -> UIBarButtonItem {
74+
CallbackBarButtonItem(title: label, callback: action)
75+
}
76+
}
77+
78+
@available(iOS 14, macCatalyst 14, tvOS 14, *)
79+
extension Spacer: ToolbarItem {
80+
public func asBarButtonItem() -> UIBarButtonItem {
81+
if let minLength, minLength > 0 {
82+
print(
83+
"""
84+
Warning: Spacer's minLength property is ignored within keyboard toolbars \
85+
due to UIKit limitations. Use `Spacer()` for unconstrained spacers and \
86+
`Spacer().frame(width: _)` for fixed-length spacers.
87+
"""
88+
)
89+
}
90+
return .flexibleSpace()
91+
}
92+
}
93+
94+
struct FixedWidthToolbarItem<Base: ToolbarItem>: ToolbarItem {
95+
var base: Base
96+
var width: Int?
97+
98+
func asBarButtonItem() -> UIBarButtonItem {
99+
let item = base.asBarButtonItem()
100+
if let width {
101+
item.width = CGFloat(width)
102+
}
103+
return item
104+
}
105+
}
106+
107+
// Setting width on a flexible space is ignored, you must use a fixed space from the outset
108+
@available(iOS 14, macCatalyst 14, tvOS 14, *)
109+
struct FixedWidthSpacerItem: ToolbarItem {
110+
var width: Int?
111+
112+
func asBarButtonItem() -> UIBarButtonItem {
113+
if let width {
114+
.fixedSpace(CGFloat(width))
115+
} else {
116+
.flexibleSpace()
117+
}
118+
}
119+
}
120+
121+
struct ColoredToolbarItem<Base: ToolbarItem>: ToolbarItem {
122+
var base: Base
123+
var color: Color
124+
125+
func asBarButtonItem() -> UIBarButtonItem {
126+
let item = base.asBarButtonItem()
127+
item.tintColor = color.uiColor
128+
return item
129+
}
130+
}
131+
132+
extension ToolbarItem {
133+
/// A toolbar item with the specified width.
134+
///
135+
/// If `width` is positive, the item will have that exact width. If `width` is zero or
136+
/// nil, the item will have its natural size.
137+
public func frame(width: Int?) -> any ToolbarItem {
138+
if #available(iOS 14, macCatalyst 14, tvOS 14, *),
139+
self is Spacer || self is FixedWidthSpacerItem
140+
{
141+
FixedWidthSpacerItem(width: width)
142+
} else {
143+
FixedWidthToolbarItem(base: self, width: width)
144+
}
145+
}
146+
147+
/// A toolbar item with the specified foreground color.
148+
public func foregroundColor(_ color: Color) -> some ToolbarItem {
149+
ColoredToolbarItem(base: self, color: color)
150+
}
151+
}
152+
153+
enum ToolbarKey: EnvironmentKey {
154+
static let defaultValue: ((UIToolbar) -> Void)? = nil
155+
}
156+
157+
extension EnvironmentValues {
158+
var updateToolbar: ((UIToolbar) -> Void)? {
159+
get { self[ToolbarKey.self] }
160+
set { self[ToolbarKey.self] = newValue }
161+
}
162+
}
163+
164+
extension View {
165+
/// Set a toolbar that will be shown above the keyboard for text fields within this view.
166+
/// - Parameters:
167+
/// - animateChanges: Whether to animate updates when an item is added, removed, or
168+
/// updated
169+
/// - body: The toolbar's contents
170+
public func keyboardToolbar(
171+
animateChanges: Bool = true,
172+
@ToolbarBuilder body: @escaping () -> ToolbarBuilder.Component
173+
) -> some View {
174+
EnvironmentModifier(self) { environment in
175+
environment.with(\.updateToolbar) { toolbar in
176+
toolbar.setItems(body().map { $0.asBarButtonItem() }, animated: animateChanges)
177+
toolbar.sizeToFit()
178+
}
179+
}
180+
}
181+
}

Sources/UIKitBackend/UIKitBackend+Control.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ extension UIKitBackend {
202202
textFieldWidget.child.textColor = UIColor(color: environment.suggestedForegroundColor)
203203
textFieldWidget.onChange = onChange
204204
textFieldWidget.onSubmit = onSubmit
205+
206+
if let updateToolbar = environment.updateToolbar {
207+
let toolbar = (textFieldWidget.child.inputAccessoryView as? UIToolbar) ?? UIToolbar()
208+
updateToolbar(toolbar)
209+
textFieldWidget.child.inputAccessoryView = toolbar
210+
} else {
211+
textFieldWidget.child.inputAccessoryView = nil
212+
}
205213
}
206214

207215
public func setContent(ofTextField textField: Widget, to content: String) {

0 commit comments

Comments
 (0)