Skip to content

Commit c1a2343

Browse files
authored
Cache keyboard toolbar items on update (#115)
* Cache toolbar items for update * Use `as?` instead of `as!`
1 parent d835410 commit c1a2343

File tree

2 files changed

+163
-42
lines changed

2 files changed

+163
-42
lines changed

Sources/UIKitBackend/KeyboardToolbar.swift

+161-41
Original file line numberDiff line numberDiff line change
@@ -9,75 +9,90 @@ import UIKit
99
/// containing the ``View/keyboardToolbar(animateChanges:body:)`` modifier is updated, so any
1010
/// state necessary for the toolbar should live in the view itself.
1111
public protocol ToolbarItem {
12-
/// Convert the item to a `UIBarButtonItem`, which will be placed in the keyboard toolbar.
13-
func asBarButtonItem() -> UIBarButtonItem
12+
/// The type of bar button item used to represent this item in UIKit.
13+
associatedtype ItemType: UIBarButtonItem
14+
15+
/// Convert the item to an instance of `ItemType`.
16+
func createBarButtonItem() -> ItemType
17+
18+
/// Update the item with new information (e.g. updated bindings). May be a no-op.
19+
func updateBarButtonItem(_ item: inout ItemType)
1420
}
1521

1622
@resultBuilder
1723
public enum ToolbarBuilder {
18-
public typealias Component = [any ToolbarItem]
19-
20-
public static func buildExpression(_ expression: some ToolbarItem) -> Component {
21-
[expression]
24+
public enum Component {
25+
case expression(any ToolbarItem)
26+
case block([Component])
27+
case array([Component])
28+
indirect case optional(Component?)
29+
indirect case eitherFirst(Component)
30+
indirect case eitherSecond(Component)
2231
}
32+
public typealias FinalResult = Component
2333

2434
public static func buildExpression(_ expression: any ToolbarItem) -> Component {
25-
[expression]
35+
.expression(expression)
2636
}
2737

2838
public static func buildBlock(_ components: Component...) -> Component {
29-
components.flatMap { $0 }
39+
.block(components)
3040
}
3141

3242
public static func buildArray(_ components: [Component]) -> Component {
33-
components.flatMap { $0 }
43+
.array(components)
3444
}
3545

3646
public static func buildOptional(_ component: Component?) -> Component {
37-
component ?? []
47+
.optional(component)
3848
}
3949

4050
public static func buildEither(first component: Component) -> Component {
41-
component
51+
.eitherFirst(component)
4252
}
4353

4454
public static func buildEither(second component: Component) -> Component {
45-
component
55+
.eitherSecond(component)
4656
}
4757
}
4858

49-
final class CallbackBarButtonItem: UIBarButtonItem {
50-
private var callback: () -> Void
59+
extension Button: ToolbarItem {
60+
public final class ItemType: UIBarButtonItem {
61+
var callback: () -> Void
5162

52-
init(title: String, callback: @escaping () -> Void) {
53-
self.callback = callback
54-
super.init()
63+
init(title: String, callback: @escaping () -> Void) {
64+
self.callback = callback
65+
super.init()
5566

56-
self.title = title
57-
self.target = self
58-
self.action = #selector(onTap)
59-
}
67+
self.title = title
68+
self.target = self
69+
self.action = #selector(onTap)
70+
}
71+
72+
@available(*, unavailable)
73+
required init?(coder: NSCoder) {
74+
fatalError("init(coder:) is not used for this item")
75+
}
6076

61-
@available(*, unavailable)
62-
required init?(coder: NSCoder) {
63-
fatalError("init(coder:) is not used for this item")
77+
@objc
78+
func onTap() {
79+
callback()
80+
}
6481
}
6582

66-
@objc
67-
func onTap() {
68-
callback()
83+
public func createBarButtonItem() -> ItemType {
84+
ItemType(title: label, callback: action)
6985
}
70-
}
7186

72-
extension Button: ToolbarItem {
73-
public func asBarButtonItem() -> UIBarButtonItem {
74-
CallbackBarButtonItem(title: label, callback: action)
87+
public func updateBarButtonItem(_ item: inout ItemType) {
88+
item.callback = action
89+
item.title = label
7590
}
7691
}
7792

7893
@available(iOS 14, macCatalyst 14, tvOS 14, *)
7994
extension Spacer: ToolbarItem {
80-
public func asBarButtonItem() -> UIBarButtonItem {
95+
public func createBarButtonItem() -> UIBarButtonItem {
8196
if let minLength, minLength > 0 {
8297
print(
8398
"""
@@ -89,44 +104,64 @@ extension Spacer: ToolbarItem {
89104
}
90105
return .flexibleSpace()
91106
}
107+
108+
public func updateBarButtonItem(_: inout UIBarButtonItem) {
109+
// no-op
110+
}
92111
}
93112

94113
struct FixedWidthToolbarItem<Base: ToolbarItem>: ToolbarItem {
95114
var base: Base
96115
var width: Int?
97116

98-
func asBarButtonItem() -> UIBarButtonItem {
99-
let item = base.asBarButtonItem()
117+
func createBarButtonItem() -> Base.ItemType {
118+
let item = base.createBarButtonItem()
100119
if let width {
101120
item.width = CGFloat(width)
102121
}
103122
return item
104123
}
124+
125+
func updateBarButtonItem(_ item: inout Base.ItemType) {
126+
base.updateBarButtonItem(&item)
127+
if let width {
128+
item.width = CGFloat(width)
129+
}
130+
}
105131
}
106132

107133
// Setting width on a flexible space is ignored, you must use a fixed space from the outset
108134
@available(iOS 14, macCatalyst 14, tvOS 14, *)
109135
struct FixedWidthSpacerItem: ToolbarItem {
110136
var width: Int?
111137

112-
func asBarButtonItem() -> UIBarButtonItem {
138+
func createBarButtonItem() -> UIBarButtonItem {
113139
if let width {
114140
.fixedSpace(CGFloat(width))
115141
} else {
116142
.flexibleSpace()
117143
}
118144
}
145+
146+
func updateBarButtonItem(_ item: inout UIBarButtonItem) {
147+
item = createBarButtonItem()
148+
}
119149
}
120150

121151
struct ColoredToolbarItem<Base: ToolbarItem>: ToolbarItem {
122152
var base: Base
123153
var color: Color
124154

125-
func asBarButtonItem() -> UIBarButtonItem {
126-
let item = base.asBarButtonItem()
155+
func createBarButtonItem() -> Base.ItemType {
156+
let item = base.createBarButtonItem()
127157
item.tintColor = color.uiColor
128158
return item
129159
}
160+
161+
func updateBarButtonItem(_ item: inout Base.ItemType) {
162+
base.updateBarButtonItem(&item)
163+
item.tintColor = color.uiColor
164+
}
130165
}
131166

132167
extension ToolbarItem {
@@ -150,12 +185,97 @@ extension ToolbarItem {
150185
}
151186
}
152187

188+
indirect enum ToolbarItemLocation: Hashable {
189+
case expression(inside: ToolbarItemLocation?)
190+
case block(index: Int, inside: ToolbarItemLocation?)
191+
case array(index: Int, inside: ToolbarItemLocation?)
192+
case optional(inside: ToolbarItemLocation?)
193+
case eitherFirst(inside: ToolbarItemLocation?)
194+
case eitherSecond(inside: ToolbarItemLocation?)
195+
}
196+
197+
final class KeyboardToolbar: UIToolbar {
198+
var locations: [ToolbarItemLocation: UIBarButtonItem] = [:]
199+
200+
func setItems(
201+
_ components: ToolbarBuilder.FinalResult,
202+
animated: Bool
203+
) {
204+
var newItems: [UIBarButtonItem] = []
205+
var newLocations: [ToolbarItemLocation: UIBarButtonItem] = [:]
206+
207+
visitItems(component: components, inside: nil) { location, expression in
208+
var item =
209+
if let oldItem = locations[location] {
210+
updateErasedItem(expression, oldItem)
211+
} else {
212+
expression.createBarButtonItem()
213+
}
214+
215+
newItems.append(item)
216+
newLocations[location] = item
217+
}
218+
219+
super.setItems(newItems, animated: animated)
220+
self.locations = newLocations
221+
}
222+
223+
/// Used to open the existential to call ``ToolbarItem/updateBarButtonItem(_:)``.
224+
private func updateErasedItem<T: ToolbarItem>(_ expression: T, _ item: UIBarButtonItem)
225+
-> UIBarButtonItem
226+
{
227+
if var castedItem = item as? T.ItemType {
228+
expression.updateBarButtonItem(&castedItem)
229+
return castedItem
230+
} else {
231+
return expression.createBarButtonItem()
232+
}
233+
}
234+
235+
/// DFS on the `component` tree
236+
private func visitItems(
237+
component: ToolbarBuilder.Component,
238+
inside container: ToolbarItemLocation?,
239+
callback: (ToolbarItemLocation, any ToolbarItem) -> Void
240+
) {
241+
switch component {
242+
case .expression(let expression):
243+
callback(.expression(inside: container), expression)
244+
case .block(let elements):
245+
for (i, element) in elements.enumerated() {
246+
visitItems(
247+
component: element, inside: .block(index: i, inside: container),
248+
callback: callback)
249+
}
250+
case .array(let elements):
251+
for (i, element) in elements.enumerated() {
252+
visitItems(
253+
component: element, inside: .array(index: i, inside: container),
254+
callback: callback)
255+
}
256+
case .optional(let element):
257+
if let element {
258+
visitItems(
259+
component: element, inside: .optional(inside: container), callback: callback
260+
)
261+
}
262+
case .eitherFirst(let element):
263+
visitItems(
264+
component: element, inside: .eitherFirst(inside: container), callback: callback)
265+
case .eitherSecond(let element):
266+
visitItems(
267+
component: element, inside: .eitherSecond(inside: container), callback: callback
268+
)
269+
}
270+
}
271+
}
272+
153273
enum ToolbarKey: EnvironmentKey {
154-
static let defaultValue: ((UIToolbar) -> Void)? = nil
274+
static let defaultValue: ((KeyboardToolbar) -> Void)? = nil
155275
}
156276

157277
extension EnvironmentValues {
158-
var updateToolbar: ((UIToolbar) -> Void)? {
278+
var updateToolbar: ((KeyboardToolbar) -> Void)? {
159279
get { self[ToolbarKey.self] }
160280
set { self[ToolbarKey.self] = newValue }
161281
}
@@ -169,11 +289,11 @@ extension View {
169289
/// - body: The toolbar's contents
170290
public func keyboardToolbar(
171291
animateChanges: Bool = true,
172-
@ToolbarBuilder body: @escaping () -> ToolbarBuilder.Component
292+
@ToolbarBuilder body: @escaping () -> ToolbarBuilder.FinalResult
173293
) -> some View {
174294
EnvironmentModifier(self) { environment in
175295
environment.with(\.updateToolbar) { toolbar in
176-
toolbar.setItems(body().map { $0.asBarButtonItem() }, animated: animateChanges)
296+
toolbar.setItems(body(), animated: animateChanges)
177297
toolbar.sizeToFit()
178298
}
179299
}

Sources/UIKitBackend/UIKitBackend+Control.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ extension UIKitBackend {
204204
textFieldWidget.onSubmit = onSubmit
205205

206206
if let updateToolbar = environment.updateToolbar {
207-
let toolbar = (textFieldWidget.child.inputAccessoryView as? UIToolbar) ?? UIToolbar()
207+
let toolbar =
208+
(textFieldWidget.child.inputAccessoryView as? KeyboardToolbar) ?? KeyboardToolbar()
208209
updateToolbar(toolbar)
209210
textFieldWidget.child.inputAccessoryView = toolbar
210211
} else {

0 commit comments

Comments
 (0)