Skip to content

Commit a3061e8

Browse files
committed
Add keyboardToolbar modifier
1 parent fd4f296 commit a3061e8

File tree

7 files changed

+218
-9
lines changed

7 files changed

+218
-9
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 extraKeys: [ObjectIdentifier: Any]
49+
50+
public subscript<T: EnvironmentKey>(_ key: T.Type) -> T.Value {
51+
get {
52+
extraKeys[ObjectIdentifier(T.self), default: T.defaultValue] as! T.Value
53+
}
54+
set {
55+
extraKeys[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+
extraKeys = [:]
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 over the built-in values.
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.

Sources/SwiftCrossUI/Views/View.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ extension View {
138138

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

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)