craft-native 0.0.123 → 0.0.125
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/android/src/index.d.ts +3 -0
- package/dist/android/src/index.js +22 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +151 -7
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivity.kt.template +17 -0
- package/dist/android/templates/MainActivityNative.kt.template +1085 -84
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +250 -2
- package/dist/cli.js +3 -3
- package/dist/index.cjs +22 -1
- package/dist/index.js +22 -1
- package/dist/ios/src/index.d.ts +125 -3
- package/dist/ios/src/index.js +284 -47
- package/dist/ios/templates/CraftApp.swift +1186 -69
- package/dist/ios/templates/CraftHybrid.swift +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +310 -10
- package/dist/ios/templates/CraftNativeFlatList.swift +226 -17
- package/dist/ios/templates/CraftNativeScreen.swift +2217 -220
- package/dist/ios/templates/CraftNativeUI.swift +562 -0
- package/dist/ios/templates/Info.plist.template +2 -1
- package/dist/mobile.js +204 -0
- package/package.json +1 -1
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import JavaScriptCore
|
|
3
|
+
import OSLog
|
|
3
4
|
import SwiftUI
|
|
4
5
|
import UIKit
|
|
5
6
|
|
|
6
7
|
private let craftNativeCapabilityTimeoutMilliseconds = 30_000
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
private func craftNativeGridColumnCount(_ value: Any?) -> Int {
|
|
10
|
+
if let number = value as? NSNumber { return max(1, number.intValue) }
|
|
11
|
+
guard let text = value as? String else { return 1 }
|
|
12
|
+
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
13
|
+
if trimmed.hasPrefix("repeat(") {
|
|
14
|
+
let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
|
|
15
|
+
if let count = Int(digits) { return max(1, count) }
|
|
16
|
+
}
|
|
17
|
+
let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
|
|
18
|
+
return max(1, tracks.count)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// A WebView-free host for the first stx native vertical slice. The bundled
|
|
9
22
|
/// JavaScript sends whole, compiled view trees to UIKit and receives control
|
|
10
23
|
/// events and Craft API replies through JavaScriptCore.
|
|
11
24
|
struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
@@ -18,59 +31,242 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
|
18
31
|
func updateUIViewController(_ controller: UINavigationController, context: Context) {}
|
|
19
32
|
}
|
|
20
33
|
|
|
34
|
+
/// A length as a style gives it: points, or a percentage of the parent's
|
|
35
|
+
/// content box. A percentage against a size that is not known yet (a column
|
|
36
|
+
/// whose height comes from its content) resolves to nothing, as on the web.
|
|
37
|
+
private enum CraftNativeLength {
|
|
38
|
+
case points(CGFloat)
|
|
39
|
+
case percent(CGFloat)
|
|
40
|
+
|
|
41
|
+
init?(_ value: Any?) {
|
|
42
|
+
if let number = value as? NSNumber {
|
|
43
|
+
self = .points(CGFloat(truncating: number))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
47
|
+
if text.hasSuffix("%"), let fraction = Double(text.dropLast()) {
|
|
48
|
+
self = .percent(CGFloat(fraction) / 100)
|
|
49
|
+
} else if text.hasSuffix("px"), let points = Double(text.dropLast(2)) {
|
|
50
|
+
self = .points(CGFloat(points))
|
|
51
|
+
} else if let points = Double(text) {
|
|
52
|
+
self = .points(CGFloat(points))
|
|
53
|
+
} else {
|
|
54
|
+
return nil
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func resolve(_ base: CGFloat?) -> CGFloat? {
|
|
59
|
+
switch self {
|
|
60
|
+
case .points(let value): return value
|
|
61
|
+
case .percent(let fraction): return base.map { max(0, $0 * fraction) }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// How a parent lays a child out, read once from the child's style.
|
|
21
67
|
private struct CraftNativeLayoutStyle {
|
|
22
|
-
let width:
|
|
23
|
-
let height:
|
|
24
|
-
let minWidth:
|
|
25
|
-
let maxWidth:
|
|
26
|
-
let minHeight:
|
|
27
|
-
let maxHeight:
|
|
68
|
+
let width: CraftNativeLength?
|
|
69
|
+
let height: CraftNativeLength?
|
|
70
|
+
let minWidth: CraftNativeLength?
|
|
71
|
+
let maxWidth: CraftNativeLength?
|
|
72
|
+
let minHeight: CraftNativeLength?
|
|
73
|
+
let maxHeight: CraftNativeLength?
|
|
28
74
|
let position: String
|
|
29
75
|
let top: CGFloat?
|
|
30
76
|
let right: CGFloat?
|
|
31
77
|
let bottom: CGFloat?
|
|
32
78
|
let left: CGFloat?
|
|
33
79
|
let alignSelf: String?
|
|
80
|
+
let flexGrow: CGFloat
|
|
81
|
+
/// Nil when the style does not say: the parent picks the default for the
|
|
82
|
+
/// kind of view (text shrinks along a row, a box does not).
|
|
83
|
+
let flexShrink: CGFloat?
|
|
84
|
+
let flexBasis: CraftNativeLength?
|
|
85
|
+
let marginTop: CGFloat
|
|
86
|
+
let marginRight: CGFloat
|
|
87
|
+
let marginBottom: CGFloat
|
|
88
|
+
let marginLeft: CGFloat
|
|
89
|
+
let display: String
|
|
90
|
+
let gridColumns: Int
|
|
91
|
+
let gridAutoRows: CGFloat?
|
|
92
|
+
|
|
93
|
+
static let empty = CraftNativeLayoutStyle([:])
|
|
34
94
|
|
|
35
95
|
init(_ raw: [String: Any]) {
|
|
36
96
|
func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
37
|
-
width =
|
|
38
|
-
height =
|
|
39
|
-
minWidth =
|
|
40
|
-
maxWidth =
|
|
41
|
-
minHeight =
|
|
42
|
-
maxHeight =
|
|
97
|
+
width = CraftNativeLength(raw["width"])
|
|
98
|
+
height = CraftNativeLength(raw["height"])
|
|
99
|
+
minWidth = CraftNativeLength(raw["minWidth"])
|
|
100
|
+
maxWidth = CraftNativeLength(raw["maxWidth"])
|
|
101
|
+
minHeight = CraftNativeLength(raw["minHeight"])
|
|
102
|
+
maxHeight = CraftNativeLength(raw["maxHeight"])
|
|
43
103
|
position = raw["position"] as? String ?? "relative"
|
|
44
104
|
top = number(raw["top"])
|
|
45
105
|
right = number(raw["right"])
|
|
46
106
|
bottom = number(raw["bottom"])
|
|
47
107
|
left = number(raw["left"])
|
|
48
|
-
|
|
108
|
+
let align = raw["alignSelf"] as? String
|
|
109
|
+
alignSelf = align == "auto" ? nil : align
|
|
110
|
+
// `flex: n` is the web's and React Native's shorthand: a positive
|
|
111
|
+
// number grows by n, shrinks, and starts from nothing, so `flex-1`
|
|
112
|
+
// siblings share a row equally whatever their content.
|
|
113
|
+
let flex = number(raw["flex"])
|
|
114
|
+
flexGrow = number(raw["flexGrow"]) ?? flex.map { max(0, $0) } ?? 0
|
|
115
|
+
flexShrink = number(raw["flexShrink"]) ?? flex.map { $0 != 0 ? 1 : 0 }
|
|
116
|
+
flexBasis = CraftNativeLength(raw["flexBasis"]) ?? flex.flatMap { $0 > 0 ? .points(0) : nil }
|
|
117
|
+
let margin = number(raw["margin"]) ?? 0
|
|
118
|
+
let horizontalMargin = number(raw["marginHorizontal"]) ?? margin
|
|
119
|
+
let verticalMargin = number(raw["marginVertical"]) ?? margin
|
|
120
|
+
marginTop = number(raw["marginTop"]) ?? verticalMargin
|
|
121
|
+
marginRight = number(raw["marginRight"]) ?? horizontalMargin
|
|
122
|
+
marginBottom = number(raw["marginBottom"]) ?? verticalMargin
|
|
123
|
+
marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
|
|
124
|
+
display = raw["display"] as? String ?? "flex"
|
|
125
|
+
gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
|
|
126
|
+
gridAutoRows = number(raw["gridAutoRows"])
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private final class CraftNativeLayoutStyleBox {
|
|
131
|
+
let style: CraftNativeLayoutStyle
|
|
132
|
+
init(_ style: CraftNativeLayoutStyle) { self.style = style }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private var craftNativeLayoutStyleKey: UInt8 = 0
|
|
136
|
+
private var craftNativeCornerRadiusKey: UInt8 = 0
|
|
137
|
+
|
|
138
|
+
extension UIView {
|
|
139
|
+
/// The layout style this view's parent reads. It lives on the view, so an
|
|
140
|
+
/// in-place style update reaches the parent's next layout pass without the
|
|
141
|
+
/// parent being reconciled too.
|
|
142
|
+
fileprivate var craftLayoutStyle: CraftNativeLayoutStyle {
|
|
143
|
+
get { (objc_getAssociatedObject(self, &craftNativeLayoutStyleKey) as? CraftNativeLayoutStyleBox)?.style ?? .empty }
|
|
144
|
+
set { objc_setAssociatedObject(self, &craftNativeLayoutStyleKey, CraftNativeLayoutStyleBox(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// The corner radius the style asked for. The layer gets at most half the
|
|
148
|
+
/// shortest side: Core Animation draws nothing at all for a radius larger
|
|
149
|
+
/// than the view (`rounded-full` is 9999).
|
|
150
|
+
var craftRequestedCornerRadius: CGFloat? {
|
|
151
|
+
get { (objc_getAssociatedObject(self, &craftNativeCornerRadiusKey) as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
152
|
+
set {
|
|
153
|
+
objc_setAssociatedObject(self, &craftNativeCornerRadiusKey, newValue.map { NSNumber(value: Double($0)) }, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
154
|
+
craftApplyCornerRadius()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func craftApplyCornerRadius() {
|
|
159
|
+
let requested = craftRequestedCornerRadius ?? 0
|
|
160
|
+
let limit = min(bounds.width, bounds.height) / 2
|
|
161
|
+
let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
|
|
162
|
+
if layer.cornerRadius != radius { layer.cornerRadius = radius }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Measures any native-screen view the way its parent's flex layout needs:
|
|
167
|
+
/// at a width (or height) the parent fixes, or at its natural size within the
|
|
168
|
+
/// room there is. Shared by the flow views, the scroll view and FlatList rows.
|
|
169
|
+
enum CraftNativeFlexLayout {
|
|
170
|
+
static let unbounded: CGFloat = 1_000_000
|
|
171
|
+
|
|
172
|
+
static func measure(_ view: UIView, width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
173
|
+
let maxWidth = max(0, maxWidth)
|
|
174
|
+
let maxHeight = max(0, maxHeight)
|
|
175
|
+
switch view {
|
|
176
|
+
case let flow as CraftNativeFlowView:
|
|
177
|
+
return flow.measure(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
178
|
+
case let scroll as CraftNativeScrollView:
|
|
179
|
+
return scroll.measure(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
180
|
+
case let list as CraftNativeFlatList:
|
|
181
|
+
let listWidth = width ?? maxWidth
|
|
182
|
+
return CGSize(width: listWidth, height: height ?? list.fittingHeight(width: listWidth))
|
|
183
|
+
case let label as UILabel:
|
|
184
|
+
// Text takes the width it needs up to the room there is, and
|
|
185
|
+
// wraps or truncates inside it rather than widening its parent.
|
|
186
|
+
let limit = width ?? maxWidth
|
|
187
|
+
let natural = label.sizeThatFits(CGSize(width: limit, height: unbounded))
|
|
188
|
+
let resolvedWidth = width ?? min(ceil(natural.width), limit)
|
|
189
|
+
let resolvedHeight = height ?? ceil(resolvedWidth < limit
|
|
190
|
+
? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
|
|
191
|
+
: natural.height)
|
|
192
|
+
return CGSize(width: resolvedWidth, height: resolvedHeight)
|
|
193
|
+
case let image as UIImageView where image.image == nil:
|
|
194
|
+
return CGSize(width: width ?? 0, height: height ?? 0)
|
|
195
|
+
default:
|
|
196
|
+
let intrinsic = view.intrinsicContentSize
|
|
197
|
+
let fitted = view.sizeThatFits(CGSize(width: width ?? maxWidth, height: height ?? maxHeight))
|
|
198
|
+
let naturalWidth = intrinsic.width > 0 && intrinsic.width != UIView.noIntrinsicMetric ? intrinsic.width : fitted.width
|
|
199
|
+
let naturalHeight = intrinsic.height > 0 && intrinsic.height != UIView.noIntrinsicMetric ? intrinsic.height : fitted.height
|
|
200
|
+
return CGSize(width: width ?? min(max(0, naturalWidth), maxWidth), height: height ?? max(0, naturalHeight))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Marks every flex container above `view` for measuring again, after a
|
|
205
|
+
/// change inside it that may change its size. A FlatList row stops the
|
|
206
|
+
/// walk: the list measures its rows itself.
|
|
207
|
+
static func invalidateAncestors(of view: UIView) {
|
|
208
|
+
var current = view.superview
|
|
209
|
+
while let ancestor = current, !(ancestor is UICollectionViewCell) {
|
|
210
|
+
if let flow = ancestor as? CraftNativeFlowView { flow.invalidateMeasurements() }
|
|
211
|
+
if let scroll = ancestor as? CraftNativeScrollView { scroll.setContentNeedsLayout() }
|
|
212
|
+
current = ancestor.superview
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// A frame snapped to the screen's pixels edge by edge, so neighbours
|
|
217
|
+
/// neither overlap nor leave a hairline between them.
|
|
218
|
+
static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
|
|
219
|
+
let scale = max(1, scale)
|
|
220
|
+
func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
|
|
221
|
+
let minX = snap(rect.minX), minY = snap(rect.minY)
|
|
222
|
+
return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
|
|
49
223
|
}
|
|
50
224
|
}
|
|
51
225
|
|
|
226
|
+
/// A flexbox container: rows and columns with grow, shrink and basis, wrap,
|
|
227
|
+
/// gaps, alignment, margins, percentages, absolute children and a simple
|
|
228
|
+
/// grid. It is a UIStackView in name only, so existing callers and tests keep
|
|
229
|
+
/// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
|
|
230
|
+
/// constraints are never created, and every frame comes from `layout`.
|
|
52
231
|
private final class CraftNativeFlowView: UIStackView {
|
|
53
|
-
var
|
|
54
|
-
var
|
|
55
|
-
var
|
|
56
|
-
var
|
|
57
|
-
var
|
|
58
|
-
var
|
|
232
|
+
var pressActiveOpacity: CGFloat?
|
|
233
|
+
var pressBaseOpacity: CGFloat = 1
|
|
234
|
+
var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
|
|
235
|
+
var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
|
|
236
|
+
var justifyContent = "flex-start" { didSet { distribution = distributionValue; if justifyContent != oldValue { invalidateMeasurements() } } }
|
|
237
|
+
var gap: CGFloat = 0 { didSet { spacing = gap; if gap != oldValue { invalidateMeasurements() } } }
|
|
238
|
+
var rowGap: CGFloat? { didSet { if rowGap != oldValue { invalidateMeasurements() } } }
|
|
239
|
+
var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
|
|
240
|
+
var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
|
|
241
|
+
var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
|
|
242
|
+
var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
|
|
59
243
|
var padding = UIEdgeInsets.zero {
|
|
60
244
|
didSet {
|
|
61
245
|
layoutMargins = padding
|
|
62
246
|
isLayoutMarginsRelativeArrangement = true
|
|
63
|
-
|
|
64
|
-
setNeedsLayout()
|
|
247
|
+
if padding != oldValue { invalidateMeasurements() }
|
|
65
248
|
}
|
|
66
249
|
}
|
|
67
|
-
|
|
250
|
+
override var axis: NSLayoutConstraint.Axis {
|
|
251
|
+
didSet { if axis != oldValue { invalidateMeasurements() } }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private var flowChildren: [UIView] = []
|
|
255
|
+
private var measurements: [MeasureKey: CGSize] = [:]
|
|
256
|
+
|
|
257
|
+
private struct MeasureKey: Hashable {
|
|
258
|
+
let width: CGFloat?
|
|
259
|
+
let height: CGFloat?
|
|
260
|
+
let maxWidth: CGFloat
|
|
261
|
+
let maxHeight: CGFloat
|
|
262
|
+
}
|
|
68
263
|
|
|
69
264
|
override init(frame: CGRect) {
|
|
70
265
|
super.init(frame: frame)
|
|
71
266
|
alignment = .fill
|
|
72
267
|
distribution = .fill
|
|
73
268
|
isLayoutMarginsRelativeArrangement = true
|
|
269
|
+
insetsLayoutMarginsFromSafeArea = false
|
|
74
270
|
}
|
|
75
271
|
|
|
76
272
|
required init(coder: NSCoder) { super.init(coder: coder) }
|
|
@@ -93,178 +289,731 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
93
289
|
}
|
|
94
290
|
}
|
|
95
291
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
292
|
+
// MARK: Children
|
|
293
|
+
|
|
294
|
+
override var arrangedSubviews: [UIView] { flowChildren }
|
|
295
|
+
|
|
296
|
+
override func addArrangedSubview(_ view: UIView) {
|
|
297
|
+
insertArrangedSubview(view, at: flowChildren.count)
|
|
100
298
|
}
|
|
101
299
|
|
|
102
|
-
func
|
|
300
|
+
override func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
|
|
301
|
+
flowChildren.removeAll { $0 === view }
|
|
302
|
+
flowChildren.insert(view, at: min(max(0, stackIndex), flowChildren.count))
|
|
303
|
+
if view.superview !== self {
|
|
304
|
+
view.removeFromSuperview()
|
|
305
|
+
addSubview(view)
|
|
306
|
+
}
|
|
307
|
+
view.translatesAutoresizingMaskIntoConstraints = true
|
|
308
|
+
invalidateMeasurements()
|
|
309
|
+
}
|
|
103
310
|
|
|
104
|
-
override func
|
|
105
|
-
|
|
106
|
-
|
|
311
|
+
override func removeArrangedSubview(_ view: UIView) {
|
|
312
|
+
flowChildren.removeAll { $0 === view }
|
|
313
|
+
invalidateMeasurements()
|
|
107
314
|
}
|
|
108
315
|
|
|
109
316
|
override func willRemoveSubview(_ subview: UIView) {
|
|
110
|
-
|
|
317
|
+
flowChildren.removeAll { $0 === subview }
|
|
111
318
|
super.willRemoveSubview(subview)
|
|
319
|
+
invalidateMeasurements()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
|
|
323
|
+
view.craftLayoutStyle = CraftNativeLayoutStyle(raw)
|
|
324
|
+
invalidateMeasurements()
|
|
112
325
|
}
|
|
113
326
|
|
|
327
|
+
func removeLayoutStyle(for view: UIView) {
|
|
328
|
+
view.craftLayoutStyle = .empty
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/// Forgets measured sizes and lays out again on the next pass.
|
|
332
|
+
func invalidateMeasurements() {
|
|
333
|
+
measurements.removeAll(keepingCapacity: true)
|
|
334
|
+
setNeedsLayout()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// MARK: Measuring and layout
|
|
338
|
+
|
|
114
339
|
override var intrinsicContentSize: CGSize {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
340
|
+
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
344
|
+
measure(width: nil, height: nil, maxWidth: size.width, maxHeight: size.height)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
348
|
+
let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
349
|
+
if let cached = measurements[key] { return cached }
|
|
350
|
+
let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
|
|
351
|
+
measurements[key] = size
|
|
352
|
+
return size
|
|
125
353
|
}
|
|
126
354
|
|
|
127
355
|
override func layoutSubviews() {
|
|
128
356
|
super.layoutSubviews()
|
|
129
|
-
|
|
130
|
-
let
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
357
|
+
craftApplyCornerRadius()
|
|
358
|
+
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
359
|
+
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
360
|
+
for (child, frame) in result.frames {
|
|
361
|
+
let snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
|
|
362
|
+
if child.frame != snapped { child.frame = snapped }
|
|
363
|
+
child.craftApplyCornerRadius()
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private struct Item {
|
|
368
|
+
let view: UIView
|
|
369
|
+
let style: CraftNativeLayoutStyle
|
|
370
|
+
let align: String
|
|
371
|
+
let mainLeading: CGFloat
|
|
372
|
+
let mainTrailing: CGFloat
|
|
373
|
+
let crossLeading: CGFloat
|
|
374
|
+
let crossTrailing: CGFloat
|
|
375
|
+
let minMain: CGFloat?
|
|
376
|
+
let maxMain: CGFloat?
|
|
377
|
+
let minCross: CGFloat?
|
|
378
|
+
let maxCross: CGFloat?
|
|
379
|
+
let fixedCross: CGFloat?
|
|
380
|
+
let grow: CGFloat
|
|
381
|
+
let shrink: CGFloat
|
|
382
|
+
var basis: CGFloat
|
|
134
383
|
var main: CGFloat = 0
|
|
135
384
|
var cross: CGFloat = 0
|
|
136
385
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
386
|
+
var mainMargins: CGFloat { mainLeading + mainTrailing }
|
|
387
|
+
var crossMargins: CGFloat { crossLeading + crossTrailing }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private func clamp(_ value: CGFloat, _ lower: CGFloat?, _ upper: CGFloat?) -> CGFloat {
|
|
391
|
+
var result = value
|
|
392
|
+
if let upper { result = min(result, upper) }
|
|
393
|
+
if let lower { result = max(result, lower) }
|
|
394
|
+
return max(0, result)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// Children that take part in the flow: shown, not a modal overlay.
|
|
398
|
+
private var visibleChildren: [UIView] {
|
|
399
|
+
flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// The flexbox algorithm, single pass. `width`/`height` are this view's
|
|
403
|
+
/// outer size when its parent fixes it, nil when it sizes to its content
|
|
404
|
+
/// within `maxWidth`/`maxHeight`. Returns its outer size and, when
|
|
405
|
+
/// placing, every child's frame in its own coordinates.
|
|
406
|
+
private func layout(
|
|
407
|
+
width: CGFloat?,
|
|
408
|
+
height: CGFloat?,
|
|
409
|
+
maxWidth: CGFloat,
|
|
410
|
+
maxHeight: CGFloat,
|
|
411
|
+
place: Bool
|
|
412
|
+
) -> (size: CGSize, frames: [(UIView, CGRect)]) {
|
|
413
|
+
let children = visibleChildren
|
|
414
|
+
let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
|
|
415
|
+
let horizontal = axis == .horizontal
|
|
416
|
+
let paddingWidth = padding.left + padding.right
|
|
417
|
+
let paddingHeight = padding.top + padding.bottom
|
|
418
|
+
let innerWidth = width.map { max(0, $0 - paddingWidth) }
|
|
419
|
+
let innerHeight = height.map { max(0, $0 - paddingHeight) }
|
|
420
|
+
let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
|
|
421
|
+
let roomHeight = max(0, (height ?? maxHeight) - paddingHeight)
|
|
422
|
+
|
|
423
|
+
var frames: [(UIView, CGRect)] = []
|
|
424
|
+
var contentSize: CGSize
|
|
425
|
+
if grid {
|
|
426
|
+
contentSize = layoutGrid(flow, innerWidth: innerWidth, roomWidth: roomWidth, innerHeight: innerHeight, place: place, frames: &frames)
|
|
427
|
+
} else {
|
|
428
|
+
contentSize = layoutFlex(
|
|
429
|
+
flow,
|
|
430
|
+
horizontal: horizontal,
|
|
431
|
+
innerWidth: innerWidth,
|
|
432
|
+
innerHeight: innerHeight,
|
|
433
|
+
roomWidth: roomWidth,
|
|
434
|
+
roomHeight: roomHeight,
|
|
435
|
+
place: place,
|
|
436
|
+
frames: &frames
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
|
|
440
|
+
|
|
441
|
+
if place {
|
|
442
|
+
let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
|
|
443
|
+
for child in children where child.craftLayoutStyle.position == "absolute" {
|
|
444
|
+
let style = child.craftLayoutStyle
|
|
445
|
+
let fixedWidth = style.left != nil && style.right != nil
|
|
446
|
+
? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
|
|
447
|
+
: style.width?.resolve(content.width)
|
|
448
|
+
let fixedHeight = style.top != nil && style.bottom != nil
|
|
449
|
+
? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
|
|
450
|
+
: style.height?.resolve(content.height)
|
|
451
|
+
let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
|
|
452
|
+
let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
|
|
453
|
+
let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
|
|
454
|
+
frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return (size, frames)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private func layoutFlex(
|
|
461
|
+
_ children: [UIView],
|
|
462
|
+
horizontal: Bool,
|
|
463
|
+
innerWidth: CGFloat?,
|
|
464
|
+
innerHeight: CGFloat?,
|
|
465
|
+
roomWidth: CGFloat,
|
|
466
|
+
roomHeight: CGFloat,
|
|
467
|
+
place: Bool,
|
|
468
|
+
frames: inout [(UIView, CGRect)]
|
|
469
|
+
) -> CGSize {
|
|
470
|
+
let definiteMain = horizontal ? innerWidth : innerHeight
|
|
471
|
+
let definiteCross = horizontal ? innerHeight : innerWidth
|
|
472
|
+
let roomMain = horizontal ? roomWidth : roomHeight
|
|
473
|
+
let roomCross = horizontal ? roomHeight : roomWidth
|
|
474
|
+
let mainGap = horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
|
|
475
|
+
let crossGap = horizontal ? (rowGap ?? 0) : (columnGap ?? 0)
|
|
476
|
+
|
|
477
|
+
func measure(_ view: UIView, main: CGFloat?, cross: CGFloat?, mainLimit: CGFloat, crossLimit: CGFloat) -> (main: CGFloat, cross: CGFloat) {
|
|
478
|
+
let size = horizontal
|
|
479
|
+
? CraftNativeFlexLayout.measure(view, width: main, height: cross, maxWidth: mainLimit, maxHeight: crossLimit)
|
|
480
|
+
: CraftNativeFlexLayout.measure(view, width: cross, height: main, maxWidth: crossLimit, maxHeight: mainLimit)
|
|
481
|
+
return horizontal ? (size.width, size.height) : (size.height, size.width)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
var items: [Item] = children.map { view in
|
|
485
|
+
let style = view.craftLayoutStyle
|
|
486
|
+
let mainLeading = horizontal ? style.marginLeft : style.marginTop
|
|
487
|
+
let mainTrailing = horizontal ? style.marginRight : style.marginBottom
|
|
488
|
+
let crossLeading = horizontal ? style.marginTop : style.marginLeft
|
|
489
|
+
let crossTrailing = horizontal ? style.marginBottom : style.marginRight
|
|
490
|
+
let align = style.alignSelf ?? alignItems
|
|
491
|
+
let explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
|
|
492
|
+
let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
|
|
493
|
+
let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
|
|
494
|
+
let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
|
|
495
|
+
let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
|
|
496
|
+
let maxCross = (horizontal ? style.maxHeight : style.maxWidth)?.resolve(definiteCross)
|
|
497
|
+
var fixedCross = explicitCross
|
|
498
|
+
if fixedCross == nil, align == "stretch", !wrap, let definiteCross {
|
|
499
|
+
fixedCross = definiteCross - crossLeading - crossTrailing
|
|
500
|
+
}
|
|
501
|
+
fixedCross = fixedCross.map { clamp($0, minCross, maxCross) }
|
|
502
|
+
let defaultShrink: CGFloat
|
|
503
|
+
if view is UIScrollView {
|
|
504
|
+
defaultShrink = 1
|
|
505
|
+
} else if view is UILabel {
|
|
506
|
+
defaultShrink = horizontal ? 1 : 0
|
|
507
|
+
} else {
|
|
508
|
+
defaultShrink = 0
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// The basis: a flex-basis, else the explicit size, else the
|
|
512
|
+
// content's size. A zero basis (`flex-1`) means "share the free
|
|
513
|
+
// space" only when there is a free space to share; in a container
|
|
514
|
+
// sized by its content, the content is the basis, as on the web.
|
|
515
|
+
let basis: CGFloat
|
|
516
|
+
if let flexBasis = style.flexBasis?.resolve(definiteMain), definiteMain != nil || flexBasis > 0 {
|
|
517
|
+
basis = flexBasis
|
|
518
|
+
} else if let explicitMain {
|
|
519
|
+
basis = explicitMain
|
|
520
|
+
} else {
|
|
521
|
+
basis = measure(
|
|
522
|
+
view,
|
|
523
|
+
main: nil,
|
|
524
|
+
cross: fixedCross,
|
|
525
|
+
mainLimit: max(0, (maxMain ?? roomMain) - mainLeading - mainTrailing),
|
|
526
|
+
crossLimit: max(0, (fixedCross ?? (maxCross ?? roomCross - crossLeading - crossTrailing)))
|
|
527
|
+
).main
|
|
528
|
+
}
|
|
529
|
+
return Item(
|
|
530
|
+
view: view,
|
|
531
|
+
style: style,
|
|
532
|
+
align: align,
|
|
533
|
+
mainLeading: mainLeading,
|
|
534
|
+
mainTrailing: mainTrailing,
|
|
535
|
+
crossLeading: crossLeading,
|
|
536
|
+
crossTrailing: crossTrailing,
|
|
537
|
+
minMain: minMain,
|
|
538
|
+
maxMain: maxMain,
|
|
539
|
+
minCross: minCross,
|
|
540
|
+
maxCross: maxCross,
|
|
541
|
+
fixedCross: fixedCross,
|
|
542
|
+
grow: style.flexGrow,
|
|
543
|
+
shrink: style.flexShrink ?? defaultShrink,
|
|
544
|
+
basis: clamp(basis, minMain, maxMain)
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Lines: one, or as many as wrapping needs.
|
|
549
|
+
var lines: [Range<Int>] = []
|
|
550
|
+
let lineLimit = definiteMain ?? roomMain
|
|
551
|
+
var start = 0
|
|
552
|
+
var used: CGFloat = 0
|
|
553
|
+
for index in items.indices {
|
|
554
|
+
let size = items[index].basis + items[index].mainMargins
|
|
555
|
+
let next = index == start ? size : used + mainGap + size
|
|
556
|
+
if wrap, index > start, next > lineLimit {
|
|
557
|
+
lines.append(start..<index)
|
|
558
|
+
start = index
|
|
559
|
+
used = size
|
|
560
|
+
} else {
|
|
561
|
+
used = next
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if start < items.count { lines.append(start..<items.count) }
|
|
565
|
+
|
|
566
|
+
var lineMains: [CGFloat] = []
|
|
567
|
+
var lineCrosses: [CGFloat] = []
|
|
568
|
+
for line in lines {
|
|
569
|
+
let gaps = CGFloat(max(0, line.count - 1)) * mainGap
|
|
570
|
+
let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + gaps
|
|
571
|
+
// A row sized by its content is as wide as that content, up to the
|
|
572
|
+
// room it has; a column sized by its content is as tall as it is.
|
|
573
|
+
let lineMain = definiteMain ?? (horizontal ? min(hypothetical, roomMain) : hypothetical)
|
|
574
|
+
let free = lineMain - hypothetical
|
|
575
|
+
if free > 0 {
|
|
576
|
+
let total = line.reduce(0) { $0 + items[$1].grow }
|
|
577
|
+
for index in line {
|
|
578
|
+
let share = total > 0 ? free * items[index].grow / total : 0
|
|
579
|
+
items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
|
|
173
580
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
581
|
+
} else if free < 0 {
|
|
582
|
+
let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
|
|
583
|
+
for index in line {
|
|
584
|
+
let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
|
|
585
|
+
items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
|
|
586
|
+
}
|
|
587
|
+
} else {
|
|
588
|
+
for index in line { items[index].main = items[index].basis }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
var lineCross: CGFloat = 0
|
|
592
|
+
for index in line {
|
|
593
|
+
let item = items[index]
|
|
594
|
+
if let fixedCross = item.fixedCross {
|
|
595
|
+
items[index].cross = fixedCross
|
|
177
596
|
} else {
|
|
178
|
-
|
|
597
|
+
let measured = measure(
|
|
598
|
+
item.view,
|
|
599
|
+
main: item.main,
|
|
600
|
+
cross: nil,
|
|
601
|
+
mainLimit: item.main,
|
|
602
|
+
crossLimit: max(0, (item.maxCross ?? roomCross) - item.crossMargins)
|
|
603
|
+
)
|
|
604
|
+
items[index].cross = clamp(measured.cross, item.minCross, item.maxCross)
|
|
179
605
|
}
|
|
180
|
-
|
|
181
|
-
|
|
606
|
+
lineCross = max(lineCross, items[index].cross + item.crossMargins)
|
|
607
|
+
}
|
|
608
|
+
// A single line is as thick as the container when the container's
|
|
609
|
+
// size is known: stretched children fill it, others align in it.
|
|
610
|
+
if !wrap, let definiteCross { lineCross = definiteCross }
|
|
611
|
+
for index in line where items[index].fixedCross == nil && items[index].align == "stretch" {
|
|
612
|
+
let item = items[index]
|
|
613
|
+
if case .some = (horizontal ? item.style.height : item.style.width) { continue }
|
|
614
|
+
items[index].cross = clamp(lineCross - item.crossMargins, item.minCross, item.maxCross)
|
|
182
615
|
}
|
|
183
|
-
|
|
616
|
+
lineMains.append(line.reduce(0) { $0 + items[$1].main + items[$1].mainMargins } + gaps)
|
|
617
|
+
lineCrosses.append(lineCross)
|
|
184
618
|
}
|
|
185
619
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
620
|
+
let contentMain = definiteMain ?? (lineMains.max() ?? 0)
|
|
621
|
+
let contentCross = definiteCross
|
|
622
|
+
?? (lineCrosses.reduce(0, +) + CGFloat(max(0, lineCrosses.count - 1)) * crossGap)
|
|
623
|
+
|
|
624
|
+
if place {
|
|
625
|
+
var crossOffset: CGFloat = 0
|
|
626
|
+
for (lineIndex, line) in lines.enumerated() {
|
|
627
|
+
let free = max(0, contentMain - lineMains[lineIndex])
|
|
628
|
+
let count = line.count
|
|
629
|
+
let leading: CGFloat
|
|
630
|
+
let between: CGFloat
|
|
631
|
+
switch justifyContent {
|
|
632
|
+
case "center": leading = free / 2; between = 0
|
|
633
|
+
case "flex-end": leading = free; between = 0
|
|
634
|
+
case "space-between": leading = 0; between = count > 1 ? free / CGFloat(count - 1) : 0
|
|
635
|
+
case "space-around": leading = free / CGFloat(count * 2); between = free / CGFloat(count)
|
|
636
|
+
case "space-evenly": leading = free / CGFloat(count + 1); between = free / CGFloat(count + 1)
|
|
637
|
+
default: leading = 0; between = 0
|
|
638
|
+
}
|
|
639
|
+
let lineCross = lineCrosses[lineIndex]
|
|
640
|
+
var mainOffset = leading
|
|
641
|
+
for index in line {
|
|
642
|
+
let item = items[index]
|
|
643
|
+
let crossPosition: CGFloat
|
|
644
|
+
switch item.align {
|
|
645
|
+
case "center": crossPosition = item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
|
|
646
|
+
case "flex-end": crossPosition = lineCross - item.crossTrailing - item.cross
|
|
647
|
+
default: crossPosition = item.crossLeading
|
|
648
|
+
}
|
|
649
|
+
let mainPosition = mainOffset + item.mainLeading
|
|
650
|
+
let frame = horizontal
|
|
651
|
+
? CGRect(x: padding.left + mainPosition, y: padding.top + crossOffset + crossPosition, width: item.main, height: item.cross)
|
|
652
|
+
: CGRect(x: padding.left + crossOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
|
|
653
|
+
frames.append((item.view, frame))
|
|
654
|
+
mainOffset += item.mainMargins + item.main + mainGap + between
|
|
655
|
+
}
|
|
656
|
+
crossOffset += lineCross + crossGap
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return horizontal ? CGSize(width: contentMain, height: contentCross) : CGSize(width: contentCross, height: contentMain)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private func layoutGrid(
|
|
663
|
+
_ children: [UIView],
|
|
664
|
+
innerWidth: CGFloat?,
|
|
665
|
+
roomWidth: CGFloat,
|
|
666
|
+
innerHeight: CGFloat?,
|
|
667
|
+
place: Bool,
|
|
668
|
+
frames: inout [(UIView, CGRect)]
|
|
669
|
+
) -> CGSize {
|
|
670
|
+
let columns = max(1, gridColumns)
|
|
671
|
+
let horizontalGap = columnGap ?? gap
|
|
672
|
+
let verticalGap = rowGap ?? gap
|
|
673
|
+
let gaps = CGFloat(columns - 1) * horizontalGap
|
|
674
|
+
var cellWidth: CGFloat
|
|
675
|
+
if let innerWidth {
|
|
676
|
+
cellWidth = max(0, (innerWidth - gaps) / CGFloat(columns))
|
|
677
|
+
} else {
|
|
678
|
+
// Sized by content: the widest cell, as long as the row fits.
|
|
679
|
+
let widest = children.map { child -> CGFloat in
|
|
680
|
+
let style = child.craftLayoutStyle
|
|
681
|
+
return CraftNativeFlexLayout.measure(child, width: style.width?.resolve(nil), height: nil, maxWidth: roomWidth, maxHeight: CraftNativeFlexLayout.unbounded).width
|
|
682
|
+
+ style.marginLeft + style.marginRight
|
|
683
|
+
}.max() ?? 0
|
|
684
|
+
cellWidth = min(widest, max(0, (roomWidth - gaps) / CGFloat(columns)))
|
|
195
685
|
}
|
|
686
|
+
var rowHeights: [CGFloat] = []
|
|
687
|
+
var sizes: [CGSize] = []
|
|
688
|
+
for (index, child) in children.enumerated() {
|
|
689
|
+
let style = child.craftLayoutStyle
|
|
690
|
+
let available = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
691
|
+
let align = style.alignSelf ?? alignItems
|
|
692
|
+
let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
|
|
693
|
+
let size = CraftNativeFlexLayout.measure(
|
|
694
|
+
child,
|
|
695
|
+
width: fixedWidth.map { min($0, available) },
|
|
696
|
+
height: style.height?.resolve(nil),
|
|
697
|
+
maxWidth: available,
|
|
698
|
+
maxHeight: CraftNativeFlexLayout.unbounded
|
|
699
|
+
)
|
|
700
|
+
sizes.append(size)
|
|
701
|
+
let row = index / columns
|
|
702
|
+
while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
|
|
703
|
+
rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
|
|
704
|
+
}
|
|
705
|
+
let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
|
|
706
|
+
let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
707
|
+
if place {
|
|
708
|
+
var rowTop = padding.top
|
|
709
|
+
for (index, child) in children.enumerated() {
|
|
710
|
+
let style = child.craftLayoutStyle
|
|
711
|
+
let row = index / columns
|
|
712
|
+
let column = index % columns
|
|
713
|
+
let rowHeight = rowHeights[row]
|
|
714
|
+
let size = sizes[index]
|
|
715
|
+
let align = style.alignSelf ?? alignItems
|
|
716
|
+
let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
717
|
+
let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
|
|
718
|
+
let childWidth = min(availableWidth, size.width)
|
|
719
|
+
let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
|
|
720
|
+
let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
|
|
721
|
+
+ (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
|
|
722
|
+
let y = rowTop + style.marginTop
|
|
723
|
+
+ (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
|
|
724
|
+
frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
|
|
725
|
+
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + verticalGap }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return CGSize(width: width, height: innerHeight ?? height)
|
|
196
729
|
}
|
|
730
|
+
}
|
|
197
731
|
|
|
198
|
-
|
|
199
|
-
|
|
732
|
+
private final class CraftNativeModalView: UIView {
|
|
733
|
+
let contentStack = CraftNativeFlowView()
|
|
734
|
+
private let blocker = UIView()
|
|
735
|
+
private var renderedVisible = false
|
|
736
|
+
var onRequestClose: (() -> Void)?
|
|
200
737
|
|
|
201
|
-
|
|
202
|
-
|
|
738
|
+
var transparent = false {
|
|
739
|
+
didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
|
|
203
740
|
}
|
|
204
741
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
742
|
+
override init(frame: CGRect) {
|
|
743
|
+
super.init(frame: frame)
|
|
744
|
+
isUserInteractionEnabled = true
|
|
745
|
+
blocker.isUserInteractionEnabled = true
|
|
746
|
+
blocker.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
|
747
|
+
blocker.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blockerTapped)))
|
|
748
|
+
addSubview(blocker)
|
|
749
|
+
contentStack.backgroundColor = .clear
|
|
750
|
+
addSubview(contentStack)
|
|
212
751
|
}
|
|
213
752
|
|
|
214
|
-
|
|
215
|
-
var result = value
|
|
216
|
-
if let lower { result = max(result, lower) }
|
|
217
|
-
if let upper { result = min(result, upper) }
|
|
218
|
-
return result
|
|
219
|
-
}
|
|
753
|
+
required init?(coder: NSCoder) { nil }
|
|
220
754
|
|
|
221
|
-
|
|
222
|
-
guard
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
755
|
+
func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
|
|
756
|
+
guard renderedVisible != visible || isHidden != !visible else { return }
|
|
757
|
+
renderedVisible = visible
|
|
758
|
+
guard visible else {
|
|
759
|
+
isHidden = true
|
|
760
|
+
alpha = 0
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
isHidden = false
|
|
764
|
+
if animationType == "none" {
|
|
765
|
+
alpha = opacity
|
|
766
|
+
} else {
|
|
767
|
+
alpha = 0
|
|
768
|
+
UIView.animate(withDuration: animationType == "slide" ? 0.24 : 0.18) { self.alpha = opacity }
|
|
230
769
|
}
|
|
231
770
|
}
|
|
771
|
+
|
|
772
|
+
@objc private func blockerTapped() { onRequestClose?() }
|
|
773
|
+
|
|
774
|
+
override func layoutSubviews() {
|
|
775
|
+
super.layoutSubviews()
|
|
776
|
+
blocker.frame = bounds
|
|
777
|
+
contentStack.frame = bounds
|
|
778
|
+
}
|
|
232
779
|
}
|
|
233
780
|
|
|
234
|
-
private final class CraftNativeScrollView: UIScrollView {
|
|
781
|
+
private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
|
|
235
782
|
let contentStack = CraftNativeFlowView()
|
|
236
|
-
private
|
|
783
|
+
private let keyboardTapGesture = UITapGestureRecognizer()
|
|
784
|
+
private var keyboardShouldPersistTaps = "never"
|
|
785
|
+
private var contentNeedsLayout = true
|
|
786
|
+
private var laidOutSize: CGSize = .zero
|
|
787
|
+
private let pullToRefresh = UIRefreshControl()
|
|
788
|
+
private var refreshHandler: (() -> Void)?
|
|
237
789
|
|
|
238
790
|
override init(frame: CGRect) {
|
|
239
791
|
super.init(frame: frame)
|
|
240
|
-
|
|
792
|
+
keyboardTapGesture.cancelsTouchesInView = false
|
|
793
|
+
keyboardTapGesture.delegate = self
|
|
794
|
+
keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
|
|
795
|
+
addGestureRecognizer(keyboardTapGesture)
|
|
796
|
+
pullToRefresh.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
|
241
797
|
addSubview(contentStack)
|
|
242
|
-
NSLayoutConstraint.activate([
|
|
243
|
-
contentStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
|
|
244
|
-
contentStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
|
|
245
|
-
contentStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
|
|
246
|
-
contentStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor)
|
|
247
|
-
])
|
|
248
798
|
setAxis(.vertical)
|
|
249
799
|
}
|
|
250
800
|
|
|
251
801
|
required init?(coder: NSCoder) { nil }
|
|
252
802
|
|
|
803
|
+
var isVertical: Bool { contentStack.axis == .vertical }
|
|
804
|
+
|
|
805
|
+
/// Lays the content out again on the next pass. Scrolling alone does not:
|
|
806
|
+
/// a scroll view lays out on every frame of a scroll.
|
|
807
|
+
func setContentNeedsLayout() {
|
|
808
|
+
contentNeedsLayout = true
|
|
809
|
+
setNeedsLayout()
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/// The size of the content laid out across the visible width (or, sideways,
|
|
813
|
+
/// height), which is also the scroll view's own size when nothing fixes it.
|
|
814
|
+
func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
|
|
815
|
+
if isVertical {
|
|
816
|
+
let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
|
|
817
|
+
return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
|
|
818
|
+
}
|
|
819
|
+
let contentHeight = height.map { max(0, $0 - adjustedContentInset.top - adjustedContentInset.bottom) }
|
|
820
|
+
return contentStack.measure(width: nil, height: contentHeight, maxWidth: CraftNativeFlexLayout.unbounded, maxHeight: contentHeight ?? CraftNativeFlexLayout.unbounded)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
824
|
+
if isVertical {
|
|
825
|
+
let resolvedWidth = width ?? maxWidth
|
|
826
|
+
return CGSize(width: resolvedWidth, height: height ?? contentFitting(width: resolvedWidth, height: nil).height)
|
|
827
|
+
}
|
|
828
|
+
let content = contentFitting(width: nil, height: height)
|
|
829
|
+
return CGSize(width: width ?? min(content.width, maxWidth), height: height ?? content.height)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
override func layoutSubviews() {
|
|
833
|
+
super.layoutSubviews()
|
|
834
|
+
craftApplyCornerRadius()
|
|
835
|
+
guard contentNeedsLayout || laidOutSize != bounds.size else { return }
|
|
836
|
+
contentNeedsLayout = false
|
|
837
|
+
laidOutSize = bounds.size
|
|
838
|
+
var size = contentFitting(width: bounds.width, height: bounds.height)
|
|
839
|
+
if isVertical {
|
|
840
|
+
size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
|
|
841
|
+
} else {
|
|
842
|
+
size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
|
|
843
|
+
}
|
|
844
|
+
let frame = CGRect(origin: .zero, size: size)
|
|
845
|
+
if contentStack.frame != frame { contentStack.frame = frame }
|
|
846
|
+
if contentSize != size { contentSize = size }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
override func adjustedContentInsetDidChange() {
|
|
850
|
+
super.adjustedContentInsetDidChange()
|
|
851
|
+
setContentNeedsLayout()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private var refreshingProp = false
|
|
855
|
+
|
|
856
|
+
/// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
|
|
857
|
+
/// as in React Native. The spinner shows while `refreshing` is true; a
|
|
858
|
+
/// pull that the screen does not answer with `refreshing` true ends at
|
|
859
|
+
/// once instead of spinning forever.
|
|
860
|
+
func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
|
|
861
|
+
refreshHandler = handler
|
|
862
|
+
refreshingProp = refreshing
|
|
863
|
+
if handler == nil {
|
|
864
|
+
if pullToRefresh.isRefreshing { pullToRefresh.endRefreshing() }
|
|
865
|
+
if refreshControl === pullToRefresh { refreshControl = nil }
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
if refreshControl !== pullToRefresh { refreshControl = pullToRefresh }
|
|
869
|
+
if refreshing, !pullToRefresh.isRefreshing {
|
|
870
|
+
pullToRefresh.beginRefreshing()
|
|
871
|
+
} else if !refreshing, pullToRefresh.isRefreshing {
|
|
872
|
+
pullToRefresh.endRefreshing()
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
|
|
877
|
+
|
|
878
|
+
@objc private func refreshPulled() {
|
|
879
|
+
refreshHandler?()
|
|
880
|
+
// The handler's synchronous render has landed by now.
|
|
881
|
+
DispatchQueue.main.async { [weak self] in
|
|
882
|
+
guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
|
|
883
|
+
self.pullToRefresh.endRefreshing()
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
func setKeyboardShouldPersistTaps(_ value: Any?) {
|
|
888
|
+
let requested = value as? String
|
|
889
|
+
keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
|
|
890
|
+
keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
@objc private func keyboardTap() {
|
|
894
|
+
guard keyboardShouldPersistTaps != "always" else { return }
|
|
895
|
+
if keyboardShouldPersistTaps == "handled" {
|
|
896
|
+
let point = keyboardTapGesture.location(in: self)
|
|
897
|
+
var hit = hitTest(point, with: nil)
|
|
898
|
+
while let view = hit, view !== self {
|
|
899
|
+
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
|
|
900
|
+
hit = view.superview
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
window?.endEditing(true)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
907
|
+
true
|
|
908
|
+
}
|
|
909
|
+
|
|
253
910
|
func setAxis(_ axis: NSLayoutConstraint.Axis) {
|
|
911
|
+
if contentStack.axis != axis { setContentNeedsLayout() }
|
|
254
912
|
contentStack.axis = axis
|
|
255
|
-
crossAxisConstraint?.isActive = false
|
|
256
|
-
crossAxisConstraint = axis == .vertical
|
|
257
|
-
? contentStack.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor)
|
|
258
|
-
: contentStack.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor)
|
|
259
|
-
crossAxisConstraint?.isActive = true
|
|
260
913
|
alwaysBounceVertical = axis == .vertical
|
|
261
914
|
alwaysBounceHorizontal = axis == .horizontal
|
|
262
915
|
}
|
|
263
916
|
}
|
|
264
917
|
|
|
918
|
+
/// Text with padding of its own: the padding is inside the label, around
|
|
919
|
+
/// the text, and counts in its measured size.
|
|
920
|
+
private final class CraftNativeLabel: UILabel {
|
|
921
|
+
var insets = UIEdgeInsets.zero {
|
|
922
|
+
didSet {
|
|
923
|
+
guard insets != oldValue else { return }
|
|
924
|
+
invalidateIntrinsicContentSize()
|
|
925
|
+
setNeedsDisplay()
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
override func drawText(in rect: CGRect) {
|
|
930
|
+
super.drawText(in: rect.inset(by: insets))
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect {
|
|
934
|
+
guard insets != .zero else { return super.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) }
|
|
935
|
+
let inner = super.textRect(forBounds: bounds.inset(by: insets), limitedToNumberOfLines: numberOfLines)
|
|
936
|
+
return CGRect(
|
|
937
|
+
x: inner.minX - insets.left,
|
|
938
|
+
y: inner.minY - insets.top,
|
|
939
|
+
width: inner.width + insets.left + insets.right,
|
|
940
|
+
height: inner.height + insets.top + insets.bottom
|
|
941
|
+
)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
override func layoutSubviews() {
|
|
945
|
+
super.layoutSubviews()
|
|
946
|
+
craftApplyCornerRadius()
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
265
950
|
private final class CraftNativeFlexSpacer: UIView {}
|
|
266
951
|
|
|
267
|
-
|
|
952
|
+
/// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
|
|
953
|
+
/// when the style gives it no size at all.
|
|
954
|
+
private final class CraftNativeIconView: UIImageView {
|
|
955
|
+
var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
|
|
956
|
+
|
|
957
|
+
override var intrinsicContentSize: CGSize {
|
|
958
|
+
if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
|
|
959
|
+
return super.intrinsicContentSize
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private final class CraftNativeTextView: UITextView {
|
|
964
|
+
private let placeholderLabel = UILabel()
|
|
965
|
+
var placeholder: String? {
|
|
966
|
+
didSet { refreshPlaceholder() }
|
|
967
|
+
}
|
|
968
|
+
var placeholderColor: UIColor? {
|
|
969
|
+
didSet { placeholderLabel.textColor = placeholderColor ?? .placeholderText }
|
|
970
|
+
}
|
|
971
|
+
var placeholderFont: UIFont? {
|
|
972
|
+
didSet { placeholderLabel.font = placeholderFont ?? .systemFont(ofSize: 16) }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
976
|
+
super.init(frame: frame, textContainer: textContainer)
|
|
977
|
+
configurePlaceholder()
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
convenience init() {
|
|
981
|
+
self.init(frame: .zero, textContainer: nil)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
required init?(coder: NSCoder) {
|
|
985
|
+
super.init(coder: coder)
|
|
986
|
+
configurePlaceholder()
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
override func layoutSubviews() {
|
|
990
|
+
super.layoutSubviews()
|
|
991
|
+
let inset = textContainerInset
|
|
992
|
+
let padding = textContainer.lineFragmentPadding
|
|
993
|
+
placeholderLabel.frame = CGRect(
|
|
994
|
+
x: inset.left + padding,
|
|
995
|
+
y: inset.top,
|
|
996
|
+
width: max(0, bounds.width - inset.left - inset.right - padding * 2),
|
|
997
|
+
height: max(0, bounds.height - inset.top - inset.bottom)
|
|
998
|
+
)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
func refreshPlaceholder() {
|
|
1002
|
+
placeholderLabel.text = placeholder
|
|
1003
|
+
placeholderLabel.isHidden = !(text?.isEmpty ?? true) || placeholder?.isEmpty != false
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private func configurePlaceholder() {
|
|
1007
|
+
placeholderLabel.numberOfLines = 0
|
|
1008
|
+
placeholderLabel.font = font
|
|
1009
|
+
placeholderLabel.textColor = .placeholderText
|
|
1010
|
+
placeholderLabel.isUserInteractionEnabled = false
|
|
1011
|
+
addSubview(placeholderLabel)
|
|
1012
|
+
refreshPlaceholder()
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
|
|
268
1017
|
private final class RenderedNode {
|
|
269
1018
|
let identity: String
|
|
270
1019
|
let type: String
|
|
@@ -288,25 +1037,55 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
288
1037
|
private let jsContext = JSContext()!
|
|
289
1038
|
private let rootStack = CraftNativeFlowView()
|
|
290
1039
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
1040
|
+
private var pressInHandlers: [ObjectIdentifier: String] = [:]
|
|
1041
|
+
private var pressOutHandlers: [ObjectIdentifier: String] = [:]
|
|
291
1042
|
private var focusHandlers: [ObjectIdentifier: String] = [:]
|
|
292
1043
|
private var blurHandlers: [ObjectIdentifier: String] = [:]
|
|
1044
|
+
private var endEditingHandlers: [ObjectIdentifier: String] = [:]
|
|
293
1045
|
private var submitHandlers: [ObjectIdentifier: String] = [:]
|
|
1046
|
+
private var inputIdentities: [ObjectIdentifier: String] = [:]
|
|
1047
|
+
private var inputDrafts: [String: String] = [:]
|
|
1048
|
+
private var longPressHandlers: [ObjectIdentifier: String] = [:]
|
|
1049
|
+
private var textMaxLengths: [ObjectIdentifier: Int] = [:]
|
|
294
1050
|
private var scrollHandlers: [ObjectIdentifier: String] = [:]
|
|
295
1051
|
private var scrollBeginHandlers: [ObjectIdentifier: String] = [:]
|
|
296
1052
|
private var scrollEndHandlers: [ObjectIdentifier: String] = [:]
|
|
1053
|
+
private var scrollMomentumBeginHandlers: [ObjectIdentifier: String] = [:]
|
|
1054
|
+
private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
|
|
1055
|
+
private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
|
|
1056
|
+
private var sliderSteps: [ObjectIdentifier: Float] = [:]
|
|
1057
|
+
private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
|
|
1058
|
+
private var modalVisibility: [ObjectIdentifier: Bool] = [:]
|
|
1059
|
+
private var layoutHandlers: [ObjectIdentifier: String] = [:]
|
|
1060
|
+
private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
|
|
297
1061
|
private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
|
|
1062
|
+
private var longPressRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
|
|
1063
|
+
private var pressFeedbackRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
|
|
298
1064
|
private var imageSources: [ObjectIdentifier: String] = [:]
|
|
299
1065
|
private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
|
|
1066
|
+
private var imageErrors: [ObjectIdentifier: String] = [:]
|
|
1067
|
+
private var tintedImages = Set<ObjectIdentifier>()
|
|
1068
|
+
private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
|
|
1069
|
+
private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
|
|
1070
|
+
private var imageLoadEndHandlers: [ObjectIdentifier: String] = [:]
|
|
1071
|
+
private var imageErrorHandlers: [ObjectIdentifier: String] = [:]
|
|
300
1072
|
private var renderedRoot: RenderedNode?
|
|
301
|
-
private weak var lastFocusedInput:
|
|
1073
|
+
private weak var lastFocusedInput: UIView?
|
|
302
1074
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
303
1075
|
private var flatListOwners: [String: String] = [:]
|
|
304
1076
|
private let mutationDocument = CraftNativeMutationDocument()
|
|
305
1077
|
private let capabilityScope = UUID().uuidString
|
|
306
1078
|
private var pendingCapabilityRequests = Set<String>()
|
|
307
1079
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
1080
|
+
private var pendingTimers: [Int: DispatchWorkItem] = [:]
|
|
308
1081
|
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
309
1082
|
private var deepLinkListener: UUID?
|
|
1083
|
+
private var keyboardSafeAreaInset: CGFloat = 0
|
|
1084
|
+
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
1085
|
+
/// app's native screens: told of the first frame, of a first frame that
|
|
1086
|
+
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
1087
|
+
weak var hybridEvents: CraftHybridScreenEvents?
|
|
1088
|
+
private var hybridRendered = false
|
|
310
1089
|
|
|
311
1090
|
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
|
|
312
1091
|
self.config = config
|
|
@@ -322,6 +1101,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
322
1101
|
deinit {
|
|
323
1102
|
imageTasks.values.forEach { $0.cancel() }
|
|
324
1103
|
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
1104
|
+
pendingTimers.values.forEach { $0.cancel() }
|
|
325
1105
|
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
326
1106
|
lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
|
|
327
1107
|
if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
|
|
@@ -335,8 +1115,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
335
1115
|
rootStack.alignItems = "stretch"
|
|
336
1116
|
rootStack.translatesAutoresizingMaskIntoConstraints = false
|
|
337
1117
|
view.addSubview(rootStack)
|
|
1118
|
+
rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
|
|
1119
|
+
rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
|
|
338
1120
|
NSLayoutConstraint.activate([
|
|
339
|
-
|
|
1121
|
+
rootTopToSafeArea!,
|
|
340
1122
|
rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
341
1123
|
rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
342
1124
|
rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
|
|
@@ -346,18 +1128,80 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
346
1128
|
observeNativeEvents()
|
|
347
1129
|
}
|
|
348
1130
|
|
|
1131
|
+
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
1132
|
+
super.traitCollectionDidChange(previousTraitCollection)
|
|
1133
|
+
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
|
|
1134
|
+
refreshTraitDefaults()
|
|
1135
|
+
send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
override func viewWillAppear(_ animated: Bool) {
|
|
1139
|
+
super.viewWillAppear(animated)
|
|
1140
|
+
if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
|
|
1141
|
+
navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
|
|
1142
|
+
} else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
|
|
1143
|
+
navigationController?.viewControllers.first !== self {
|
|
1144
|
+
// A screen that did not ask for a hidden bar shows one, even after
|
|
1145
|
+
// a screen that hid its own.
|
|
1146
|
+
navigationController?.setNavigationBarHidden(false, animated: animated)
|
|
1147
|
+
}
|
|
1148
|
+
refreshTraitDefaults()
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private func refreshTraitDefaults() {
|
|
1152
|
+
view.backgroundColor = config.resolvedBackgroundColor
|
|
1153
|
+
if let document = mutationDocument.node("root") {
|
|
1154
|
+
renderCommitted(document)
|
|
1155
|
+
} else {
|
|
1156
|
+
rootStack.setNeedsLayout()
|
|
1157
|
+
}
|
|
1158
|
+
refreshTraitDefaults(in: renderedRoot)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
private func refreshTraitDefaults(in node: RenderedNode?) {
|
|
1162
|
+
guard let node else { return }
|
|
1163
|
+
(node.view as? CraftNativeFlatList)?.refreshThemeDefaults()
|
|
1164
|
+
node.children.forEach { refreshTraitDefaults(in: $0) }
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
override func viewDidLayoutSubviews() {
|
|
1168
|
+
super.viewDidLayoutSubviews()
|
|
1169
|
+
emitLayoutEvents()
|
|
1170
|
+
}
|
|
1171
|
+
|
|
349
1172
|
private func setupJavaScript() {
|
|
350
1173
|
if let routeName = routeName {
|
|
351
1174
|
jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
352
1175
|
}
|
|
353
1176
|
jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
354
|
-
jsContext.exceptionHandler = {
|
|
355
|
-
|
|
1177
|
+
jsContext.exceptionHandler = { [weak self] context, exception in
|
|
1178
|
+
let message = exception?.toString() ?? "unknown"
|
|
1179
|
+
let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
|
|
1180
|
+
CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
|
|
1181
|
+
self?.hybridFailed(message)
|
|
356
1182
|
}
|
|
357
1183
|
let postMessage: @convention(block) (String) -> Void = { [weak self] json in
|
|
358
1184
|
self?.receive(json)
|
|
359
1185
|
}
|
|
360
1186
|
jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
1187
|
+
// JavaScriptCore has no timers of its own. The callbacks stay in
|
|
1188
|
+
// JavaScript; native only owns the main-queue deadline, so route
|
|
1189
|
+
// teardown cancels every pending timer with the controller.
|
|
1190
|
+
let scheduleTimer: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
|
|
1191
|
+
guard let self = self else { return }
|
|
1192
|
+
let work = DispatchWorkItem { [weak self] in
|
|
1193
|
+
guard let self = self, self.pendingTimers.removeValue(forKey: id) != nil else { return }
|
|
1194
|
+
self.jsContext.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
|
|
1195
|
+
}
|
|
1196
|
+
self.pendingTimers[id] = work
|
|
1197
|
+
let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
|
|
1198
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
|
|
1199
|
+
}
|
|
1200
|
+
let cancelTimer: @convention(block) (Int) -> Void = { [weak self] id in
|
|
1201
|
+
self?.pendingTimers.removeValue(forKey: id)?.cancel()
|
|
1202
|
+
}
|
|
1203
|
+
jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
|
|
1204
|
+
jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
|
|
361
1205
|
let capabilities = CraftNativeActions.capabilities(config: config)
|
|
362
1206
|
let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
|
|
363
1207
|
let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
@@ -370,12 +1214,185 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
370
1214
|
capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
|
|
371
1215
|
capabilities: \(capabilityJSON),
|
|
372
1216
|
initialAppState: "\(CraftNativeActions.currentAppState())",
|
|
1217
|
+
colorScheme: "\(colorScheme)",
|
|
373
1218
|
postMessage: function(message) { craftNativePostMessage(message); },
|
|
374
1219
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
375
1220
|
};
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
1221
|
+
""")
|
|
1222
|
+
installHostAPIs()
|
|
1223
|
+
jsContext.evaluateScript("""
|
|
1224
|
+
if (typeof globalThis.setTimeout !== 'function') {
|
|
1225
|
+
(function() {
|
|
1226
|
+
var timers = new Map();
|
|
1227
|
+
var nextTimer = 0;
|
|
1228
|
+
globalThis.setTimeout = function(callback, delay) {
|
|
1229
|
+
var args = Array.prototype.slice.call(arguments, 2);
|
|
1230
|
+
var id = ++nextTimer;
|
|
1231
|
+
timers.set(id, function() {
|
|
1232
|
+
if (typeof callback === 'function') callback.apply(undefined, args);
|
|
1233
|
+
});
|
|
1234
|
+
craftNativeScheduleTimer(id, Number(delay) || 0);
|
|
1235
|
+
return id;
|
|
1236
|
+
};
|
|
1237
|
+
globalThis.clearTimeout = function(id) {
|
|
1238
|
+
if (timers.delete(id)) craftNativeCancelTimer(id);
|
|
1239
|
+
};
|
|
1240
|
+
globalThis.__craftNativeFireTimer = function(id) {
|
|
1241
|
+
var callback = timers.get(id);
|
|
1242
|
+
if (!callback) return;
|
|
1243
|
+
timers.delete(id);
|
|
1244
|
+
callback();
|
|
1245
|
+
};
|
|
1246
|
+
})();
|
|
1247
|
+
}
|
|
1248
|
+
""")
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
|
|
1252
|
+
/// the window's appearance once there is one, else the app's setting.
|
|
1253
|
+
private var colorScheme: String {
|
|
1254
|
+
let traits = [view.window?.traitCollection, CraftPresenter.keyWindow()?.traitCollection, traitCollection]
|
|
1255
|
+
if let style = traits.compactMap({ $0 }).first(where: { $0.userInterfaceStyle != .unspecified })?.userInterfaceStyle {
|
|
1256
|
+
return style == .dark ? "dark" : "light"
|
|
1257
|
+
}
|
|
1258
|
+
return config.appearance == "dark" ? "dark" : "light"
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/// The screen's name as its script knows it: the route the host opened,
|
|
1262
|
+
/// else the one a route bundle selected for itself.
|
|
1263
|
+
private func screenName(in context: JSContext? = nil) -> String {
|
|
1264
|
+
if let routeName, !routeName.isEmpty { return routeName }
|
|
1265
|
+
if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
|
|
1266
|
+
return selected.toString()
|
|
1267
|
+
}
|
|
1268
|
+
return "screen"
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/// Host functions every screen script can call synchronously, installed
|
|
1272
|
+
/// before the script runs (contract sections 3, 4 and 5):
|
|
1273
|
+
///
|
|
1274
|
+
/// - `console.*` writes to the unified log, subsystem `craft.native`,
|
|
1275
|
+
/// category the screen's name.
|
|
1276
|
+
/// - `craft.storage.getSync(key)` / `setSync(key, value)`: the store the
|
|
1277
|
+
/// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
|
|
1278
|
+
/// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
|
|
1279
|
+
/// promise): `Application Support/craft-snapshots/<name>.json`.
|
|
1280
|
+
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
1281
|
+
/// `craft.secureStorage.set` wrote.
|
|
1282
|
+
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
1283
|
+
/// rightButtons })`; a right button's tap is the event `navButton`.
|
|
1284
|
+
///
|
|
1285
|
+
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
1286
|
+
/// these: each namespace is a property whose setter adds them back to the
|
|
1287
|
+
/// object it is given.
|
|
1288
|
+
private func installHostAPIs() {
|
|
1289
|
+
let log: @convention(block) (String, String, String) -> Void = { level, category, message in
|
|
1290
|
+
CraftNativeConsole.write(level, category: category, message: message)
|
|
1291
|
+
}
|
|
1292
|
+
let storageGet: @convention(block) (String) -> String? = { key in
|
|
1293
|
+
CraftNativeActions.storageJSON(forKey: key)
|
|
1294
|
+
}
|
|
1295
|
+
let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
|
|
1296
|
+
CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
|
|
1297
|
+
}
|
|
1298
|
+
let snapshotGet: @convention(block) (String) -> String? = { name in
|
|
1299
|
+
CraftSnapshots.read(name)
|
|
1300
|
+
}
|
|
1301
|
+
let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
|
|
1302
|
+
CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
|
|
1303
|
+
done.call(withArguments: [ok])
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
let config = self.config
|
|
1307
|
+
let secureGet: @convention(block) (String) -> String? = { key in
|
|
1308
|
+
config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
|
|
1309
|
+
}
|
|
1310
|
+
let setOptions: @convention(block) (String) -> Void = { [weak self] json in
|
|
1311
|
+
guard let data = json.data(using: .utf8),
|
|
1312
|
+
let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
1313
|
+
self?.applyNavigationOptions(options)
|
|
1314
|
+
}
|
|
1315
|
+
jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
|
|
1316
|
+
jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
|
|
1317
|
+
jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
|
|
1318
|
+
jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
|
|
1319
|
+
jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
|
|
1320
|
+
jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
1321
|
+
jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
|
|
1322
|
+
jsContext.evaluateScript("""
|
|
1323
|
+
(function() {
|
|
1324
|
+
function text(value) {
|
|
1325
|
+
if (typeof value === 'string') return value;
|
|
1326
|
+
if (value === undefined) return 'undefined';
|
|
1327
|
+
if (value instanceof Error) return value.stack ? value.message + '\\n' + value.stack : String(value);
|
|
1328
|
+
try { var json = JSON.stringify(value); return json === undefined ? String(value) : json; }
|
|
1329
|
+
catch (error) { return String(value); }
|
|
1330
|
+
}
|
|
1331
|
+
function screen() {
|
|
1332
|
+
var route = globalThis.__stxNativeRoute;
|
|
1333
|
+
return typeof route === 'string' && route ? route : 'screen';
|
|
1334
|
+
}
|
|
1335
|
+
function writer(level) {
|
|
1336
|
+
return function() {
|
|
1337
|
+
craftNativeLog(level, screen(), Array.prototype.map.call(arguments, text).join(' '));
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
globalThis.console = {
|
|
1341
|
+
log: writer('log'), info: writer('info'), debug: writer('debug'), trace: writer('debug'),
|
|
1342
|
+
warn: writer('warn'), error: writer('error')
|
|
1343
|
+
};
|
|
1344
|
+
|
|
1345
|
+
function parsed(json) {
|
|
1346
|
+
if (json === null || json === undefined) return null;
|
|
1347
|
+
try { return JSON.parse(json); } catch (error) { return null; }
|
|
1348
|
+
}
|
|
1349
|
+
function encoded(value) {
|
|
1350
|
+
return value === null || value === undefined ? null : JSON.stringify(value);
|
|
1351
|
+
}
|
|
1352
|
+
var craft = globalThis.craft = globalThis.craft || {};
|
|
1353
|
+
function keep(name, extras) {
|
|
1354
|
+
var current = {};
|
|
1355
|
+
function adopt(next) {
|
|
1356
|
+
current = next && typeof next === 'object' ? next : {};
|
|
1357
|
+
Object.keys(extras).forEach(function(key) {
|
|
1358
|
+
if (!(key in current)) current[key] = extras[key];
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
adopt(craft[name]);
|
|
1362
|
+
Object.defineProperty(craft, name, {
|
|
1363
|
+
configurable: true,
|
|
1364
|
+
enumerable: true,
|
|
1365
|
+
get: function() { return current; },
|
|
1366
|
+
set: adopt
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
keep('storage', {
|
|
1370
|
+
getSync: function(key) { return parsed(craftNativeStorageGet(String(key))); },
|
|
1371
|
+
setSync: function(key, value) {
|
|
1372
|
+
if (!craftNativeStorageSet(String(key), encoded(value))) throw new Error('craft.storage.setSync could not store ' + key);
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
keep('snapshots', {
|
|
1376
|
+
get: function(name) { return parsed(craftNativeSnapshotGet(String(name))); },
|
|
1377
|
+
set: function(name, value) {
|
|
1378
|
+
return new Promise(function(resolve, reject) {
|
|
1379
|
+
craftNativeSnapshotSet(String(name), encoded(value), function(ok) {
|
|
1380
|
+
if (ok) resolve();
|
|
1381
|
+
else reject(new Error('craft.snapshots.set could not write ' + name));
|
|
1382
|
+
});
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
keep('secureStorage', {
|
|
1387
|
+
getSync: function(key) {
|
|
1388
|
+
var value = craftNativeSecureGet(String(key));
|
|
1389
|
+
return value === undefined ? null : value;
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
keep('navigation', {
|
|
1393
|
+
setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
|
|
1394
|
+
});
|
|
1395
|
+
})();
|
|
379
1396
|
""")
|
|
380
1397
|
}
|
|
381
1398
|
|
|
@@ -383,15 +1400,110 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
383
1400
|
guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
|
|
384
1401
|
?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
|
|
385
1402
|
let script = try? String(contentsOf: url, encoding: .utf8) else {
|
|
386
|
-
showError("Missing dist/native-screen.js. Compile a .stx screen with stx
|
|
1403
|
+
showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
|
|
387
1404
|
return
|
|
388
1405
|
}
|
|
389
1406
|
jsContext.evaluateScript(script)
|
|
390
|
-
|
|
391
|
-
|
|
1407
|
+
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
1408
|
+
// its title is the app's, not the word "undefined". A title the
|
|
1409
|
+
// script set with `navigation.setOptions` wins over both.
|
|
1410
|
+
if routeName == nil, !titleFromOptions,
|
|
1411
|
+
let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
|
|
1412
|
+
let name = selected.toString(), !name.isEmpty {
|
|
1413
|
+
navigationItem.title = name
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// MARK: Navigation bar options
|
|
1418
|
+
|
|
1419
|
+
private var titleFromOptions = false
|
|
1420
|
+
private var rootTopToSafeArea: NSLayoutConstraint?
|
|
1421
|
+
private var rootTopToView: NSLayoutConstraint?
|
|
1422
|
+
private var navigationBarHidden: Bool?
|
|
1423
|
+
private var prefersLargeTitle: Bool?
|
|
1424
|
+
private weak var trackedScrollView: UIScrollView?
|
|
1425
|
+
|
|
1426
|
+
/// `craft.navigation.setOptions`: this screen's bar. Every key is
|
|
1427
|
+
/// optional and only the keys given change.
|
|
1428
|
+
private func applyNavigationOptions(_ options: [String: Any]) {
|
|
1429
|
+
if options.keys.contains("title") {
|
|
1430
|
+
navigationItem.title = options["title"] as? String
|
|
1431
|
+
titleFromOptions = true
|
|
1432
|
+
}
|
|
1433
|
+
if options.keys.contains("backTitle") {
|
|
1434
|
+
navigationItem.backButtonTitle = options["backTitle"] as? String
|
|
1435
|
+
}
|
|
1436
|
+
if let largeTitle = options["largeTitle"] as? Bool {
|
|
1437
|
+
prefersLargeTitle = largeTitle
|
|
1438
|
+
navigationItem.largeTitleDisplayMode = largeTitle ? .always : .never
|
|
1439
|
+
if largeTitle { navigationController?.navigationBar.prefersLargeTitles = true }
|
|
1440
|
+
trackContentScrollView()
|
|
1441
|
+
}
|
|
1442
|
+
if let hidden = options["hidden"] as? Bool {
|
|
1443
|
+
navigationBarHidden = hidden
|
|
1444
|
+
if navigationController?.topViewController === self {
|
|
1445
|
+
navigationController?.setNavigationBarHidden(hidden, animated: view.window != nil)
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
if options.keys.contains("rightButtons") {
|
|
1449
|
+
let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
|
|
1450
|
+
navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
|
|
392
1451
|
}
|
|
393
1452
|
}
|
|
394
1453
|
|
|
1454
|
+
/// A bar button from `{ id, symbol | title }`. Its tap is the event
|
|
1455
|
+
/// `navButton` with `{ id }`.
|
|
1456
|
+
private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
|
|
1457
|
+
guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
|
|
1458
|
+
let action = UIAction { [weak self] _ in
|
|
1459
|
+
self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": id]])
|
|
1460
|
+
}
|
|
1461
|
+
let item: UIBarButtonItem
|
|
1462
|
+
if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
|
|
1463
|
+
item = UIBarButtonItem(image: image, primaryAction: action)
|
|
1464
|
+
} else if let title = spec["title"] as? String {
|
|
1465
|
+
item = UIBarButtonItem(title: title, primaryAction: action)
|
|
1466
|
+
} else {
|
|
1467
|
+
return nil
|
|
1468
|
+
}
|
|
1469
|
+
item.accessibilityIdentifier = "nav-\(id)"
|
|
1470
|
+
if let label = (spec["accessibilityLabel"] as? String) ?? (spec["title"] as? String) {
|
|
1471
|
+
item.accessibilityLabel = label
|
|
1472
|
+
} else {
|
|
1473
|
+
item.accessibilityLabel = id
|
|
1474
|
+
}
|
|
1475
|
+
if let tint = (spec["color"] as? String).flatMap({ UIColor(hex: $0) }) { item.tintColor = tint }
|
|
1476
|
+
return item
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/// The first vertical scroll view of the screen drives the bar: a large
|
|
1480
|
+
/// title collapses as it scrolls, and the bar's background follows it.
|
|
1481
|
+
private func trackContentScrollView() {
|
|
1482
|
+
guard let renderedRoot else { return }
|
|
1483
|
+
func find(_ node: RenderedNode) -> UIScrollView? {
|
|
1484
|
+
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical, scroll.isScrollEnabled { return scroll }
|
|
1485
|
+
if let list = node.view as? CraftNativeFlatList, list.isScrollEnabled,
|
|
1486
|
+
(list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { return list }
|
|
1487
|
+
for child in node.children {
|
|
1488
|
+
if let match = find(child) { return match }
|
|
1489
|
+
}
|
|
1490
|
+
return nil
|
|
1491
|
+
}
|
|
1492
|
+
let scroll = find(renderedRoot)
|
|
1493
|
+
// Under a large title the screen runs edge to edge, as UIKit's own
|
|
1494
|
+
// screens do: the scroll view starts under the bar and insets its
|
|
1495
|
+
// content by it, so the title collapses and pull to refresh draws
|
|
1496
|
+
// in the bar's space. Other screens stay inside the safe area.
|
|
1497
|
+
let edgeToEdge = scroll != nil && prefersLargeTitle == true
|
|
1498
|
+
if rootTopToView?.isActive != edgeToEdge {
|
|
1499
|
+
rootTopToSafeArea?.isActive = !edgeToEdge
|
|
1500
|
+
rootTopToView?.isActive = edgeToEdge
|
|
1501
|
+
}
|
|
1502
|
+
guard scroll !== trackedScrollView else { return }
|
|
1503
|
+
trackedScrollView = scroll
|
|
1504
|
+
setContentScrollView(scroll, for: .top)
|
|
1505
|
+
}
|
|
1506
|
+
|
|
395
1507
|
private func observeNativeEvents() {
|
|
396
1508
|
func observe(_ name: Notification.Name, state: String) {
|
|
397
1509
|
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
@@ -403,6 +1515,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
403
1515
|
observe(UIApplication.didBecomeActiveNotification, state: "active")
|
|
404
1516
|
observe(UIApplication.willResignActiveNotification, state: "inactive")
|
|
405
1517
|
observe(UIApplication.didEnterBackgroundNotification, state: "background")
|
|
1518
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
1519
|
+
forName: UIResponder.keyboardWillChangeFrameNotification,
|
|
1520
|
+
object: nil,
|
|
1521
|
+
queue: .main
|
|
1522
|
+
) { [weak self] notification in
|
|
1523
|
+
self?.applyKeyboardSafeArea(notification)
|
|
1524
|
+
})
|
|
1525
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
1526
|
+
forName: UIResponder.keyboardWillHideNotification,
|
|
1527
|
+
object: nil,
|
|
1528
|
+
queue: .main
|
|
1529
|
+
) { [weak self] notification in
|
|
1530
|
+
self?.applyKeyboardSafeArea(notification, hidden: true)
|
|
1531
|
+
})
|
|
406
1532
|
sendAppState(CraftNativeActions.currentAppState())
|
|
407
1533
|
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
|
|
408
1534
|
guard let self = self, self.navigationController?.topViewController === self else { return }
|
|
@@ -412,6 +1538,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
412
1538
|
}
|
|
413
1539
|
}
|
|
414
1540
|
|
|
1541
|
+
private func applyKeyboardSafeArea(_ notification: Notification, hidden: Bool = false) {
|
|
1542
|
+
let inset: CGFloat
|
|
1543
|
+
if hidden {
|
|
1544
|
+
inset = 0
|
|
1545
|
+
} else if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
|
1546
|
+
let keyboardFrame = view.convert(frame.cgRectValue, from: nil)
|
|
1547
|
+
inset = max(0, view.bounds.intersection(keyboardFrame).height - view.safeAreaInsets.bottom)
|
|
1548
|
+
} else {
|
|
1549
|
+
return
|
|
1550
|
+
}
|
|
1551
|
+
guard abs(inset - keyboardSafeAreaInset) > 0.5 else { return }
|
|
1552
|
+
keyboardSafeAreaInset = inset
|
|
1553
|
+
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
|
1554
|
+
let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
|
|
1555
|
+
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
|
|
1556
|
+
self.additionalSafeAreaInsets.bottom = inset
|
|
1557
|
+
self.view.layoutIfNeeded()
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
415
1561
|
private func sendAppState(_ state: String) {
|
|
416
1562
|
guard navigationController?.topViewController === self else { return }
|
|
417
1563
|
send(type: "APP_STATE", payload: ["state": state])
|
|
@@ -426,6 +1572,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
426
1572
|
rootStack.addArrangedSubview(label)
|
|
427
1573
|
rootStack.setLayoutStyle(["alignSelf": "stretch"], for: label)
|
|
428
1574
|
NSLog("[craft native] %@", message)
|
|
1575
|
+
hybridFailed(message)
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/// Only before the first frame: later exceptions belong to a screen that
|
|
1579
|
+
/// is up, and are logged.
|
|
1580
|
+
private func hybridFailed(_ message: String) {
|
|
1581
|
+
guard !hybridRendered else { return }
|
|
1582
|
+
hybridEvents?.nativeScreen(self, didFail: message)
|
|
429
1583
|
}
|
|
430
1584
|
|
|
431
1585
|
private func receive(_ raw: String) {
|
|
@@ -469,11 +1623,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
469
1623
|
let navigation = navigationController else { return }
|
|
470
1624
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
471
1625
|
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
1626
|
+
next.hybridEvents = hybridEvents
|
|
472
1627
|
if type == "NAVIGATE" {
|
|
473
1628
|
navigation.pushViewController(next, animated: true)
|
|
474
1629
|
} else {
|
|
475
1630
|
navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
|
|
476
1631
|
}
|
|
1632
|
+
case "NAVIGATION_SET_OPTIONS":
|
|
1633
|
+
applyNavigationOptions(payload)
|
|
1634
|
+
case "NAVIGATE_OPEN":
|
|
1635
|
+
// A path rather than a screen: the hybrid shell opens it, natively
|
|
1636
|
+
// or in the web view.
|
|
1637
|
+
guard let path = payload["path"] as? String, !path.isEmpty else { return }
|
|
1638
|
+
hybridEvents?.nativeScreen(self, open: path)
|
|
477
1639
|
case "NAVIGATE_BACK":
|
|
478
1640
|
if navigationController?.topViewController === self {
|
|
479
1641
|
navigationController?.popViewController(animated: true)
|
|
@@ -536,10 +1698,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
536
1698
|
}
|
|
537
1699
|
}
|
|
538
1700
|
|
|
1701
|
+
/// Runs script in the screen's JavaScript context. Internal for
|
|
1702
|
+
/// simulator-hosted XCTest of the host APIs.
|
|
1703
|
+
@discardableResult
|
|
1704
|
+
func evaluateScript(_ script: String) -> JSValue? {
|
|
1705
|
+
jsContext.evaluateScript(script)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
539
1708
|
// Internal so simulator-hosted XCTest can assert actual UIView identity.
|
|
540
1709
|
func render(_ document: [String: Any]) {
|
|
541
1710
|
mutationDocument.replace(with: document)
|
|
542
1711
|
renderCommitted(document)
|
|
1712
|
+
if !hybridRendered {
|
|
1713
|
+
hybridRendered = true
|
|
1714
|
+
hybridEvents?.nativeScreenDidRender(self)
|
|
1715
|
+
}
|
|
543
1716
|
}
|
|
544
1717
|
|
|
545
1718
|
@discardableResult
|
|
@@ -557,7 +1730,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
557
1730
|
let target = flatListOwners[id] ?? id
|
|
558
1731
|
guard applied.insert(target).inserted else { continue }
|
|
559
1732
|
guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
|
|
560
|
-
|
|
1733
|
+
let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
|
|
1734
|
+
// A style patch is applied in place, layout included: the parent
|
|
1735
|
+
// reads the node's new layout style on its next pass.
|
|
1736
|
+
CraftNativeFlexLayout.invalidateAncestors(of: next.view)
|
|
561
1737
|
}
|
|
562
1738
|
restoreFocus(focused)
|
|
563
1739
|
return true
|
|
@@ -578,10 +1754,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
578
1754
|
let next = reconcile(document, identity: "root", path: "root", previous: previous)
|
|
579
1755
|
if previous?.view !== next.view {
|
|
580
1756
|
if let old = previous { detach(old, from: rootStack) }
|
|
581
|
-
rootStack.addArrangedSubview(next.view)
|
|
582
1757
|
}
|
|
1758
|
+
// A failed or interrupted reconciliation can leave an older root
|
|
1759
|
+
// attached even though `renderedRoot` points at the latest tree. Keep
|
|
1760
|
+
// the host single-rooted so accessibility queries and event routing
|
|
1761
|
+
// never see stale copies of the native screen.
|
|
1762
|
+
for child in rootStack.arrangedSubviews where child !== next.view {
|
|
1763
|
+
rootStack.removeArrangedSubview(child)
|
|
1764
|
+
child.removeFromSuperview()
|
|
1765
|
+
}
|
|
1766
|
+
if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
|
|
583
1767
|
rootStack.setLayoutStyle(next.style, for: next.view)
|
|
1768
|
+
rootStack.invalidateMeasurements()
|
|
584
1769
|
renderedRoot = next
|
|
1770
|
+
trackContentScrollView()
|
|
585
1771
|
restoreFocus(focused)
|
|
586
1772
|
}
|
|
587
1773
|
|
|
@@ -610,72 +1796,242 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
610
1796
|
let events = node["events"] as? [String: String] ?? [:]
|
|
611
1797
|
let children = node["children"] as? [Any] ?? []
|
|
612
1798
|
let current: RenderedNode
|
|
613
|
-
|
|
1799
|
+
let wantsMultilineTextView = type == "TextInput" && props["multiline"] as? Bool == true
|
|
1800
|
+
let canReuseInput = type != "TextInput" || ((previous?.view is UITextView) == wantsMultilineTextView)
|
|
1801
|
+
if let previous = previous, previous.identity == identity, previous.type == type, canReuseInput {
|
|
614
1802
|
current = previous
|
|
615
1803
|
} else {
|
|
616
|
-
current = RenderedNode(identity: identity, type: type, view: makeView(type))
|
|
1804
|
+
current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
|
|
617
1805
|
}
|
|
618
1806
|
|
|
619
1807
|
let result = current.view
|
|
620
1808
|
current.protocolId = node["id"] as? String
|
|
621
1809
|
current.style = style
|
|
622
1810
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
623
|
-
if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" {
|
|
1811
|
+
if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
|
|
624
1812
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
625
1813
|
}
|
|
626
1814
|
applyViewStyle(style, to: result, node: current)
|
|
1815
|
+
updateLayoutHandler(events["onLayout"], for: result)
|
|
627
1816
|
|
|
628
1817
|
switch type {
|
|
629
1818
|
case "Text":
|
|
630
1819
|
let label = result as! UILabel
|
|
1820
|
+
label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
|
|
1821
|
+
label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
1822
|
+
(label as? CraftNativeLabel)?.insets = paddingInsets(style)
|
|
631
1823
|
configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
|
|
632
|
-
case "Button":
|
|
1824
|
+
case "Button", "Link":
|
|
633
1825
|
let button = result as! UIButton
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
1826
|
+
let title = props["title"] as? String ?? children.compactMap { $0 as? String }.joined()
|
|
1827
|
+
let transformedTitle = transformedText(title, style: style)
|
|
1828
|
+
let titleColor = color(props["color"]) ?? color(style["color"]) ?? .systemBlue
|
|
1829
|
+
let font = textFont(
|
|
637
1830
|
style,
|
|
638
|
-
default:
|
|
1831
|
+
default: .systemFont(ofSize: 16)
|
|
639
1832
|
)
|
|
640
|
-
|
|
1833
|
+
// The plain title too, so `title(for:)` and accessibility read it.
|
|
1834
|
+
if button.title(for: .normal) != transformedTitle { button.setTitle(transformedTitle, for: .normal) }
|
|
1835
|
+
button.setAttributedTitle(NSAttributedString(
|
|
1836
|
+
string: transformedTitle,
|
|
1837
|
+
attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
|
|
1838
|
+
), for: .normal)
|
|
1839
|
+
button.contentHorizontalAlignment = buttonAlignment(style["textAlign"])
|
|
1840
|
+
button.isEnabled = props["disabled"] as? Bool != true
|
|
1841
|
+
updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
|
|
641
1842
|
case "TextInput":
|
|
1843
|
+
if let textView = result as? UITextView {
|
|
1844
|
+
inputIdentities[ObjectIdentifier(textView)] = current.identity
|
|
1845
|
+
configureTextView(
|
|
1846
|
+
textView,
|
|
1847
|
+
props: props,
|
|
1848
|
+
style: style,
|
|
1849
|
+
events: events,
|
|
1850
|
+
defaultValue: previous?.view === textView
|
|
1851
|
+
? nil
|
|
1852
|
+
: inputDrafts[current.identity] ?? props["defaultValue"] as? String
|
|
1853
|
+
)
|
|
1854
|
+
break
|
|
1855
|
+
}
|
|
642
1856
|
let field = result as! UITextField
|
|
643
1857
|
field.placeholder = props["placeholder"] as? String
|
|
1858
|
+
if let placeholder = field.placeholder,
|
|
1859
|
+
let placeholderColor = color(props["placeholderTextColor"]) {
|
|
1860
|
+
field.attributedPlaceholder = NSAttributedString(
|
|
1861
|
+
string: placeholder,
|
|
1862
|
+
attributes: [.foregroundColor: placeholderColor]
|
|
1863
|
+
)
|
|
1864
|
+
} else {
|
|
1865
|
+
field.attributedPlaceholder = nil
|
|
1866
|
+
}
|
|
1867
|
+
field.tintColor = color(props["selectionColor"])
|
|
1868
|
+
let fieldId = ObjectIdentifier(field)
|
|
1869
|
+
inputIdentities[fieldId] = current.identity
|
|
1870
|
+
if let maxLength = (props["maxLength"] as? NSNumber)?.intValue, maxLength > 0 {
|
|
1871
|
+
textMaxLengths[fieldId] = maxLength
|
|
1872
|
+
} else {
|
|
1873
|
+
textMaxLengths.removeValue(forKey: fieldId)
|
|
1874
|
+
}
|
|
1875
|
+
field.delegate = self
|
|
644
1876
|
field.textColor = color(style["color"]) ?? .label
|
|
645
|
-
field.font = textFont(style, default:
|
|
1877
|
+
field.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
646
1878
|
field.textAlignment = textAlignment(style["textAlign"])
|
|
1879
|
+
field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment)
|
|
647
1880
|
let desiredKeyboardType = keyboardType(props["keyboardType"])
|
|
648
1881
|
if field.keyboardType != desiredKeyboardType { field.keyboardType = desiredKeyboardType }
|
|
649
1882
|
let desiredReturnKeyType = returnKeyType(props["returnKeyType"])
|
|
650
1883
|
if field.returnKeyType != desiredReturnKeyType { field.returnKeyType = desiredReturnKeyType }
|
|
651
1884
|
let desiredAutocorrection: UITextAutocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
|
|
652
1885
|
if field.autocorrectionType != desiredAutocorrection { field.autocorrectionType = desiredAutocorrection }
|
|
653
|
-
|
|
654
|
-
if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
|
|
1886
|
+
// Secure entry first: turning it on resets the capitalization.
|
|
655
1887
|
let desiredSecureEntry = props["secureTextEntry"] as? Bool == true
|
|
656
1888
|
if field.isSecureTextEntry != desiredSecureEntry { field.isSecureTextEntry = desiredSecureEntry }
|
|
1889
|
+
let desiredCapitalization = capitalizationType(props["autoCapitalize"])
|
|
1890
|
+
if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
|
|
657
1891
|
let desiredEnabled = props["editable"] as? Bool != false
|
|
658
1892
|
if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
|
|
659
|
-
|
|
660
|
-
|
|
1893
|
+
if let value = props["value"] as? String {
|
|
1894
|
+
updateField(field, value: value)
|
|
1895
|
+
} else if previous?.view !== field {
|
|
1896
|
+
updateField(field, value: inputDrafts[current.identity] ?? props["defaultValue"] as? String)
|
|
1897
|
+
}
|
|
1898
|
+
updateHandler(nonEmptyHandler(events["onChange"]) ?? nonEmptyHandler(events["onChangeText"]), for: field)
|
|
661
1899
|
updateAuxiliaryHandler(events["onFocus"], in: &focusHandlers, for: field)
|
|
662
|
-
updateAuxiliaryHandler(events["onBlur"]
|
|
1900
|
+
updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: field)
|
|
1901
|
+
updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
|
|
663
1902
|
updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
|
|
664
1903
|
if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
|
|
665
1904
|
DispatchQueue.main.async { _ = field.becomeFirstResponder() }
|
|
666
1905
|
}
|
|
1906
|
+
case "Picker":
|
|
1907
|
+
let picker = result as! UIPickerView
|
|
1908
|
+
let options = children.compactMap { child -> (value: String, label: String)? in
|
|
1909
|
+
guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
|
|
1910
|
+
let optionProps = node["props"] as? [String: Any] ?? [:]
|
|
1911
|
+
let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
|
|
1912
|
+
guard !label.isEmpty else { return nil }
|
|
1913
|
+
return (value: optionProps["value"] as? String ?? label, label: label)
|
|
1914
|
+
}
|
|
1915
|
+
let pickerID = ObjectIdentifier(picker)
|
|
1916
|
+
pickerOptions[pickerID] = options
|
|
1917
|
+
picker.reloadAllComponents()
|
|
1918
|
+
if let selected = (props["selectedValue"] as? String) ?? (props["value"] as? String),
|
|
1919
|
+
let row = options.firstIndex(where: { $0.value == selected }) {
|
|
1920
|
+
picker.selectRow(row, inComponent: 0, animated: false)
|
|
1921
|
+
} else if !options.isEmpty {
|
|
1922
|
+
picker.selectRow(0, inComponent: 0, animated: false)
|
|
1923
|
+
}
|
|
1924
|
+
picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
|
|
1925
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
|
|
1926
|
+
case "Modal":
|
|
1927
|
+
let modal = result as! CraftNativeModalView
|
|
1928
|
+
modal.transparent = props["transparent"] as? Bool == true
|
|
1929
|
+
let visible = props["visible"] as? Bool == true
|
|
1930
|
+
let modalID = ObjectIdentifier(modal)
|
|
1931
|
+
let wasVisible = modalVisibility[modalID]
|
|
1932
|
+
modalVisibility[modalID] = visible
|
|
1933
|
+
modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
1934
|
+
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
1935
|
+
{ [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
1936
|
+
}
|
|
1937
|
+
if wasVisible != visible, visible || wasVisible == true {
|
|
1938
|
+
let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
|
|
1939
|
+
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
|
|
1940
|
+
send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
configureStack(modal.contentStack, style: style)
|
|
1944
|
+
reconcileChildren(children, in: modal.contentStack, parent: current, path: path, style: style)
|
|
1945
|
+
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
1946
|
+
let pressable = result as! CraftNativeFlowView
|
|
1947
|
+
configureStack(pressable, style: style)
|
|
1948
|
+
reconcileChildren(children, in: pressable, parent: current, path: path, style: style)
|
|
1949
|
+
pressable.pressActiveOpacity = props["disabled"] as? Bool == true
|
|
1950
|
+
? nil : number(props["activeOpacity"]) ?? 0.5
|
|
1951
|
+
pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
|
|
1952
|
+
case "Switch":
|
|
1953
|
+
let toggle = result as! UISwitch
|
|
1954
|
+
toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
|
|
1955
|
+
toggle.isEnabled = props["disabled"] as? Bool != true
|
|
1956
|
+
if let track = props["trackColor"] as? [String: Any] {
|
|
1957
|
+
toggle.onTintColor = color(track["true"])
|
|
1958
|
+
toggle.tintColor = color(track["false"])
|
|
1959
|
+
} else {
|
|
1960
|
+
toggle.onTintColor = nil
|
|
1961
|
+
toggle.tintColor = nil
|
|
1962
|
+
}
|
|
1963
|
+
toggle.thumbTintColor = color(props["thumbColor"])
|
|
1964
|
+
if let background = color(props["ios_backgroundColor"]) { toggle.tintColor = background }
|
|
1965
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
|
|
1966
|
+
case "Slider":
|
|
1967
|
+
let slider = result as! UISlider
|
|
1968
|
+
slider.minimumValue = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
|
|
1969
|
+
slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, (props["maximumValue"] as? NSNumber)?.floatValue ?? 1)
|
|
1970
|
+
let value = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
|
|
1971
|
+
if let step = (props["step"] as? NSNumber)?.floatValue, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
|
|
1972
|
+
else { sliderSteps.removeValue(forKey: ObjectIdentifier(slider)) }
|
|
1973
|
+
slider.value = snappedSliderValue(value, for: slider)
|
|
1974
|
+
slider.minimumTrackTintColor = color(props["minimumTrackTintColor"])
|
|
1975
|
+
slider.maximumTrackTintColor = color(props["maximumTrackTintColor"])
|
|
1976
|
+
slider.thumbTintColor = color(props["thumbTintColor"])
|
|
1977
|
+
slider.isEnabled = props["disabled"] as? Bool != true
|
|
1978
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: slider)
|
|
1979
|
+
updateAuxiliaryHandler(events["onSlidingComplete"], in: &sliderCompleteHandlers, for: slider)
|
|
1980
|
+
case "ActivityIndicator":
|
|
1981
|
+
let indicator = result as! UIActivityIndicatorView
|
|
1982
|
+
let requestedSize = props["size"]
|
|
1983
|
+
indicator.style = (requestedSize as? String) == "large" ? .large : .medium
|
|
1984
|
+
let scale: CGFloat
|
|
1985
|
+
if let numericSize = requestedSize as? NSNumber {
|
|
1986
|
+
scale = max(0.5, CGFloat(numericSize.doubleValue / 20.0))
|
|
1987
|
+
} else if (requestedSize as? String) == "small" {
|
|
1988
|
+
scale = 0.75
|
|
1989
|
+
} else {
|
|
1990
|
+
scale = 1
|
|
1991
|
+
}
|
|
1992
|
+
indicator.transform = CGAffineTransform(scaleX: scale, y: scale)
|
|
1993
|
+
indicator.color = color(props["color"]) ?? .tintColor
|
|
1994
|
+
indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
|
|
1995
|
+
if props["animating"] as? Bool == false {
|
|
1996
|
+
indicator.stopAnimating()
|
|
1997
|
+
} else {
|
|
1998
|
+
indicator.startAnimating()
|
|
1999
|
+
}
|
|
2000
|
+
case "Icon":
|
|
2001
|
+
configureIcon(result as! UIImageView, props: props, style: style)
|
|
667
2002
|
case "Image":
|
|
668
2003
|
let image = result as! UIImageView
|
|
669
|
-
|
|
2004
|
+
let imageID = ObjectIdentifier(image)
|
|
2005
|
+
if let tint = color(style["tintColor"]) {
|
|
2006
|
+
tintedImages.insert(imageID)
|
|
2007
|
+
image.tintColor = tint
|
|
2008
|
+
} else {
|
|
2009
|
+
tintedImages.remove(imageID)
|
|
2010
|
+
image.tintColor = nil
|
|
2011
|
+
}
|
|
2012
|
+
if let current = image.image { image.image = imageForDisplay(current, view: image) }
|
|
2013
|
+
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
2014
|
+
image.contentMode = imageContentMode(requestedResizeMode)
|
|
670
2015
|
image.clipsToBounds = image.contentMode == .scaleAspectFill
|
|
2016
|
+
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
|
|
2017
|
+
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
|
|
2018
|
+
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
2019
|
+
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
|
|
671
2020
|
updateImage(image, source: props["source"])
|
|
672
2021
|
case "ScrollView":
|
|
673
2022
|
let scroll = result as! CraftNativeScrollView
|
|
674
|
-
let
|
|
2023
|
+
let flexDirection = style["flexDirection"] as? String
|
|
2024
|
+
let direction = (props["horizontal"] as? Bool) == true
|
|
2025
|
+
|| flexDirection == "row"
|
|
2026
|
+
|| flexDirection == "row-reverse"
|
|
675
2027
|
? NSLayoutConstraint.Axis.horizontal : .vertical
|
|
676
2028
|
scroll.setAxis(direction)
|
|
677
2029
|
scroll.delegate = self
|
|
678
2030
|
scroll.isScrollEnabled = props["scrollEnabled"] as? Bool != false
|
|
2031
|
+
scroll.bounces = props["bounces"] as? Bool ?? true
|
|
2032
|
+
scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
|
|
2033
|
+
scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
|
|
2034
|
+
scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
|
|
679
2035
|
scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
|
|
680
2036
|
scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
|
|
681
2037
|
scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
|
|
@@ -683,8 +2039,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
683
2039
|
updateAuxiliaryHandler(events["onScroll"], in: &scrollHandlers, for: scroll)
|
|
684
2040
|
updateAuxiliaryHandler(events["onScrollBeginDrag"], in: &scrollBeginHandlers, for: scroll)
|
|
685
2041
|
updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
|
|
686
|
-
|
|
687
|
-
|
|
2042
|
+
updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
|
|
2043
|
+
updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
|
|
2044
|
+
scroll.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
|
|
2045
|
+
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
2046
|
+
}, refreshing: props["refreshing"] as? Bool == true)
|
|
2047
|
+
var contentStyle = style
|
|
2048
|
+
if let override = props["contentContainerStyle"] as? [String: Any] {
|
|
2049
|
+
contentStyle.merge(override) { _, next in next }
|
|
2050
|
+
}
|
|
2051
|
+
configureStack(scroll.contentStack, style: contentStyle)
|
|
2052
|
+
reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
|
|
688
2053
|
case "FlatList":
|
|
689
2054
|
let list = result as! CraftNativeFlatList
|
|
690
2055
|
reconcileFlatList(
|
|
@@ -700,24 +2065,37 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
700
2065
|
configureStack(stack, style: style)
|
|
701
2066
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
702
2067
|
}
|
|
703
|
-
|
|
704
|
-
|
|
2068
|
+
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
2069
|
+
if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
2070
|
+
let disabled = props["disabled"] as? Bool == true
|
|
2071
|
+
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
2072
|
+
updatePressFeedback(
|
|
2073
|
+
disabled ? nil : nonEmptyHandler(events["onPressIn"]),
|
|
2074
|
+
disabled ? nil : nonEmptyHandler(events["onPressOut"]),
|
|
2075
|
+
for: result
|
|
2076
|
+
)
|
|
2077
|
+
if disabled { result.isUserInteractionEnabled = false }
|
|
705
2078
|
}
|
|
706
2079
|
applyAccessibility(props, type: type, to: result)
|
|
707
2080
|
return current
|
|
708
2081
|
}
|
|
709
2082
|
|
|
710
|
-
private func makeView(_ type: String) -> UIView {
|
|
2083
|
+
private func makeView(_ type: String, props: [String: Any]) -> UIView {
|
|
711
2084
|
switch type {
|
|
712
2085
|
case "Text":
|
|
713
|
-
let label =
|
|
2086
|
+
let label = CraftNativeLabel()
|
|
714
2087
|
label.numberOfLines = 0
|
|
715
2088
|
return label
|
|
716
|
-
case "Button":
|
|
2089
|
+
case "Button", "Link":
|
|
717
2090
|
let button = UIButton(type: .system)
|
|
718
2091
|
button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
|
|
719
2092
|
return button
|
|
720
2093
|
case "TextInput":
|
|
2094
|
+
if props["multiline"] as? Bool == true {
|
|
2095
|
+
let textView = CraftNativeTextView()
|
|
2096
|
+
textView.delegate = self
|
|
2097
|
+
return textView
|
|
2098
|
+
}
|
|
721
2099
|
let field = UITextField()
|
|
722
2100
|
field.borderStyle = .roundedRect
|
|
723
2101
|
field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
|
|
@@ -725,19 +2103,45 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
725
2103
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
726
2104
|
field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
|
|
727
2105
|
return field
|
|
2106
|
+
case "Picker":
|
|
2107
|
+
let picker = UIPickerView()
|
|
2108
|
+
picker.dataSource = self
|
|
2109
|
+
picker.delegate = self
|
|
2110
|
+
return picker
|
|
2111
|
+
case "Modal":
|
|
2112
|
+
return CraftNativeModalView()
|
|
2113
|
+
case "Switch":
|
|
2114
|
+
let toggle = UISwitch()
|
|
2115
|
+
toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
|
|
2116
|
+
return toggle
|
|
2117
|
+
case "Slider":
|
|
2118
|
+
let slider = UISlider()
|
|
2119
|
+
slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
|
|
2120
|
+
slider.addTarget(self, action: #selector(sliderFinished(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
|
2121
|
+
return slider
|
|
2122
|
+
case "ActivityIndicator":
|
|
2123
|
+
return UIActivityIndicatorView(style: .medium)
|
|
728
2124
|
case "Image":
|
|
729
2125
|
return UIImageView()
|
|
2126
|
+
case "Icon":
|
|
2127
|
+
let icon = CraftNativeIconView()
|
|
2128
|
+
icon.contentMode = .scaleAspectFit
|
|
2129
|
+
return icon
|
|
730
2130
|
case "ScrollView":
|
|
731
2131
|
return CraftNativeScrollView()
|
|
732
2132
|
case "FlatList":
|
|
733
2133
|
return CraftNativeFlatList()
|
|
2134
|
+
case "View", "SafeAreaView":
|
|
2135
|
+
return CraftNativeFlowView()
|
|
2136
|
+
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
2137
|
+
return CraftNativeFlowView()
|
|
734
2138
|
default:
|
|
735
2139
|
return CraftNativeFlowView()
|
|
736
2140
|
}
|
|
737
2141
|
}
|
|
738
2142
|
|
|
739
2143
|
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
740
|
-
// The current stx
|
|
2144
|
+
// The current stx native compiler emits key in props; accept the IR
|
|
741
2145
|
// field too so keyed children keep working when the compiler adopts it.
|
|
742
2146
|
[node["id"], node["key"], props["key"], props["testID"]]
|
|
743
2147
|
.compactMap { $0 as? String }
|
|
@@ -780,7 +2184,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
780
2184
|
for child in parent.children where !next.contains(where: { $0 === child }) {
|
|
781
2185
|
detach(child, from: stack)
|
|
782
2186
|
}
|
|
783
|
-
for
|
|
2187
|
+
for modal in stack.arrangedSubviews where modal is CraftNativeModalView {
|
|
2188
|
+
stack.removeArrangedSubview(modal)
|
|
2189
|
+
}
|
|
2190
|
+
let flowChildren = next.filter { $0.type != "Modal" }
|
|
2191
|
+
for (index, child) in flowChildren.enumerated() {
|
|
784
2192
|
if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view {
|
|
785
2193
|
stack.setLayoutStyle(child.style, for: child.view)
|
|
786
2194
|
continue
|
|
@@ -789,6 +2197,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
789
2197
|
stack.insertArrangedSubview(child.view, at: min(index, stack.arrangedSubviews.count))
|
|
790
2198
|
stack.setLayoutStyle(child.style, for: child.view)
|
|
791
2199
|
}
|
|
2200
|
+
for child in next where child.type == "Modal" {
|
|
2201
|
+
guard let modal = child.view as? CraftNativeModalView else { continue }
|
|
2202
|
+
if modal.superview !== stack {
|
|
2203
|
+
modal.removeFromSuperview()
|
|
2204
|
+
stack.addSubview(modal)
|
|
2205
|
+
}
|
|
2206
|
+
modal.frame = stack.bounds
|
|
2207
|
+
modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
2208
|
+
stack.bringSubviewToFront(modal)
|
|
2209
|
+
}
|
|
792
2210
|
parent.children = next
|
|
793
2211
|
addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
|
|
794
2212
|
}
|
|
@@ -805,6 +2223,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
805
2223
|
let owner = parent.protocolId ?? path
|
|
806
2224
|
flatListOwners = flatListOwners.filter { $0.value != owner }
|
|
807
2225
|
for child in children { registerFlatListOwnership(child, owner: owner) }
|
|
2226
|
+
list.bounces = props["bounces"] as? Bool ?? true
|
|
2227
|
+
list.isScrollEnabled = props["scrollEnabled"] as? Bool != false
|
|
2228
|
+
list.onFittingHeightChanged = { [weak list] in
|
|
2229
|
+
guard let list else { return }
|
|
2230
|
+
CraftNativeFlexLayout.invalidateAncestors(of: list)
|
|
2231
|
+
}
|
|
2232
|
+
list.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
|
|
2233
|
+
list.setContentContainerStyle(props["contentContainerStyle"] as? [String: Any])
|
|
2234
|
+
list.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
|
|
2235
|
+
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
2236
|
+
}, refreshing: props["refreshing"] as? Bool == true)
|
|
2237
|
+
list.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
|
|
2238
|
+
list.onScrollEvent = nonEmptyHandler(events["onScroll"]).map { handler in
|
|
2239
|
+
{ [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
|
|
2240
|
+
}
|
|
2241
|
+
list.onScrollBeginDrag = nonEmptyHandler(events["onScrollBeginDrag"]).map { handler in
|
|
2242
|
+
{ [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
|
|
2243
|
+
}
|
|
2244
|
+
list.onScrollEndDrag = nonEmptyHandler(events["onScrollEndDrag"]).map { handler in
|
|
2245
|
+
{ [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
|
|
2246
|
+
}
|
|
2247
|
+
list.onMomentumScrollBegin = nonEmptyHandler(events["onMomentumScrollBegin"]).map { handler in
|
|
2248
|
+
{ [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
|
|
2249
|
+
}
|
|
2250
|
+
list.onMomentumScrollEnd = nonEmptyHandler(events["onMomentumScrollEnd"]).map { handler in
|
|
2251
|
+
{ [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
|
|
2252
|
+
}
|
|
808
2253
|
|
|
809
2254
|
list.apply(
|
|
810
2255
|
nodes: children,
|
|
@@ -823,15 +2268,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
823
2268
|
path: "\(path).\(identity)",
|
|
824
2269
|
previous: previous
|
|
825
2270
|
)
|
|
2271
|
+
if let previous, previous.view !== next.view {
|
|
2272
|
+
self.forgetHandlers(previous)
|
|
2273
|
+
}
|
|
826
2274
|
self.flatListRows[listKey, default: [:]][identity] = next
|
|
827
2275
|
return next.view
|
|
828
2276
|
},
|
|
829
2277
|
recycleItem: { [weak self] identity in
|
|
830
2278
|
guard let self = self,
|
|
831
2279
|
let row = self.flatListRows[listKey]?.removeValue(forKey: identity) else { return }
|
|
832
|
-
self.forgetHandlers(row)
|
|
2280
|
+
self.forgetHandlers(row, preservingInputDrafts: true)
|
|
833
2281
|
},
|
|
834
|
-
endReached: events["onEndReached"].map { [weak self] handler in
|
|
2282
|
+
endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
|
|
835
2283
|
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
836
2284
|
}
|
|
837
2285
|
)
|
|
@@ -850,40 +2298,119 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
850
2298
|
|
|
851
2299
|
private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
|
|
852
2300
|
forgetHandlers(node)
|
|
2301
|
+
if node.type == "Modal" {
|
|
2302
|
+
node.view.removeFromSuperview()
|
|
2303
|
+
return
|
|
2304
|
+
}
|
|
853
2305
|
stack.removeLayoutStyle(for: node.view)
|
|
854
2306
|
stack.removeArrangedSubview(node.view)
|
|
855
2307
|
node.view.removeFromSuperview()
|
|
856
2308
|
}
|
|
857
2309
|
|
|
858
|
-
private func forgetHandlers(_ node: RenderedNode) {
|
|
2310
|
+
private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
|
|
2311
|
+
(node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
|
|
859
2312
|
if let list = node.view as? CraftNativeFlatList {
|
|
860
2313
|
let listKey = ObjectIdentifier(list)
|
|
2314
|
+
list.setRefreshHandler(nil, refreshing: false)
|
|
861
2315
|
list.discardAll()
|
|
862
2316
|
flatListRows.removeValue(forKey: listKey)
|
|
863
2317
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
864
2318
|
}
|
|
865
2319
|
let id = ObjectIdentifier(node.view)
|
|
866
2320
|
handlers.removeValue(forKey: id)
|
|
2321
|
+
pressInHandlers.removeValue(forKey: id)
|
|
2322
|
+
pressOutHandlers.removeValue(forKey: id)
|
|
2323
|
+
layoutHandlers.removeValue(forKey: id)
|
|
2324
|
+
lastLayoutFrames.removeValue(forKey: id)
|
|
867
2325
|
focusHandlers.removeValue(forKey: id)
|
|
868
2326
|
blurHandlers.removeValue(forKey: id)
|
|
2327
|
+
endEditingHandlers.removeValue(forKey: id)
|
|
869
2328
|
submitHandlers.removeValue(forKey: id)
|
|
2329
|
+
inputIdentities.removeValue(forKey: id)
|
|
2330
|
+
if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
|
|
2331
|
+
longPressHandlers.removeValue(forKey: id)
|
|
2332
|
+
textMaxLengths.removeValue(forKey: id)
|
|
870
2333
|
scrollHandlers.removeValue(forKey: id)
|
|
871
2334
|
scrollBeginHandlers.removeValue(forKey: id)
|
|
872
2335
|
scrollEndHandlers.removeValue(forKey: id)
|
|
2336
|
+
scrollMomentumBeginHandlers.removeValue(forKey: id)
|
|
2337
|
+
scrollMomentumEndHandlers.removeValue(forKey: id)
|
|
2338
|
+
sliderCompleteHandlers.removeValue(forKey: id)
|
|
2339
|
+
sliderSteps.removeValue(forKey: id)
|
|
2340
|
+
pickerOptions.removeValue(forKey: id)
|
|
2341
|
+
modalVisibility.removeValue(forKey: id)
|
|
873
2342
|
if let recognizer = tapRecognizers.removeValue(forKey: id) {
|
|
874
2343
|
node.view.removeGestureRecognizer(recognizer)
|
|
875
2344
|
}
|
|
2345
|
+
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
2346
|
+
node.view.removeGestureRecognizer(recognizer)
|
|
2347
|
+
}
|
|
2348
|
+
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) {
|
|
2349
|
+
node.view.removeGestureRecognizer(recognizer)
|
|
2350
|
+
}
|
|
876
2351
|
imageSources.removeValue(forKey: id)
|
|
877
2352
|
imageTasks.removeValue(forKey: id)?.cancel()
|
|
878
|
-
|
|
2353
|
+
imageErrors.removeValue(forKey: id)
|
|
2354
|
+
tintedImages.remove(id)
|
|
2355
|
+
imageLoadStartHandlers.removeValue(forKey: id)
|
|
2356
|
+
imageLoadHandlers.removeValue(forKey: id)
|
|
2357
|
+
imageLoadEndHandlers.removeValue(forKey: id)
|
|
2358
|
+
imageErrorHandlers.removeValue(forKey: id)
|
|
2359
|
+
for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
|
|
879
2360
|
}
|
|
880
2361
|
|
|
881
2362
|
private func updateHandler(_ handler: String?, for view: UIView) {
|
|
882
2363
|
let id = ObjectIdentifier(view)
|
|
883
|
-
if let handler
|
|
2364
|
+
if let handler, !handler.isEmpty { handlers[id] = handler }
|
|
884
2365
|
else { handlers.removeValue(forKey: id) }
|
|
885
2366
|
}
|
|
886
2367
|
|
|
2368
|
+
private func snappedSliderValue(_ value: Float, for slider: UISlider) -> Float {
|
|
2369
|
+
let bounded = min(slider.maximumValue, max(slider.minimumValue, value))
|
|
2370
|
+
guard let step = sliderSteps[ObjectIdentifier(slider)], step > 0 else { return bounded }
|
|
2371
|
+
let steps = ((bounded - slider.minimumValue) / step).rounded()
|
|
2372
|
+
return min(slider.maximumValue, max(slider.minimumValue, slider.minimumValue + steps * step))
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
private func updateLayoutHandler(_ handler: String?, for view: UIView) {
|
|
2376
|
+
let id = ObjectIdentifier(view)
|
|
2377
|
+
guard let handler, !handler.isEmpty else {
|
|
2378
|
+
layoutHandlers.removeValue(forKey: id)
|
|
2379
|
+
lastLayoutFrames.removeValue(forKey: id)
|
|
2380
|
+
return
|
|
2381
|
+
}
|
|
2382
|
+
if layoutHandlers[id] != handler { lastLayoutFrames.removeValue(forKey: id) }
|
|
2383
|
+
layoutHandlers[id] = handler
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
private func emitLayoutEvents() {
|
|
2387
|
+
guard let renderedRoot else { return }
|
|
2388
|
+
emitLayoutEvents(for: renderedRoot)
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
private func emitLayoutEvents(for node: RenderedNode) {
|
|
2392
|
+
let id = ObjectIdentifier(node.view)
|
|
2393
|
+
if let handler = layoutHandlers[id], node.view.window != nil {
|
|
2394
|
+
let frame = node.view.frame
|
|
2395
|
+
if lastLayoutFrames[id] != frame {
|
|
2396
|
+
lastLayoutFrames[id] = frame
|
|
2397
|
+
send(type: "EVENT", payload: [
|
|
2398
|
+
"handlerName": handler,
|
|
2399
|
+
"nativeEvent": ["layout": [
|
|
2400
|
+
"x": frame.minX,
|
|
2401
|
+
"y": frame.minY,
|
|
2402
|
+
"width": frame.width,
|
|
2403
|
+
"height": frame.height,
|
|
2404
|
+
]],
|
|
2405
|
+
])
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
for child in node.children { emitLayoutEvents(for: child) }
|
|
2409
|
+
if let list = node.view as? CraftNativeFlatList {
|
|
2410
|
+
flatListRows[ObjectIdentifier(list)]?.values.forEach { emitLayoutEvents(for: $0) }
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
|
|
887
2414
|
private func updateAuxiliaryHandler(
|
|
888
2415
|
_ handler: String?,
|
|
889
2416
|
in handlers: inout [ObjectIdentifier: String],
|
|
@@ -894,8 +2421,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
894
2421
|
else { handlers.removeValue(forKey: id) }
|
|
895
2422
|
}
|
|
896
2423
|
|
|
2424
|
+
private func nonEmptyHandler(_ handler: String?) -> String? {
|
|
2425
|
+
guard let handler, !handler.isEmpty else { return nil }
|
|
2426
|
+
return handler
|
|
2427
|
+
}
|
|
2428
|
+
|
|
897
2429
|
private func updatePressHandler(_ handler: String?, for view: UIView) {
|
|
898
2430
|
let id = ObjectIdentifier(view)
|
|
2431
|
+
let handler = handler?.isEmpty == false ? handler : nil
|
|
899
2432
|
updateHandler(handler, for: view)
|
|
900
2433
|
if handler != nil, tapRecognizers[id] == nil {
|
|
901
2434
|
let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
|
|
@@ -904,7 +2437,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
904
2437
|
} else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
|
|
905
2438
|
view.removeGestureRecognizer(recognizer)
|
|
906
2439
|
}
|
|
907
|
-
|
|
2440
|
+
// A container keeps receiving touches without a handler of its own,
|
|
2441
|
+
// or every control inside it (and the screen root) would be dead.
|
|
2442
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
|
|
2446
|
+
let id = ObjectIdentifier(view)
|
|
2447
|
+
if let pressIn, !pressIn.isEmpty { pressInHandlers[id] = pressIn } else { pressInHandlers.removeValue(forKey: id) }
|
|
2448
|
+
if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
|
|
2449
|
+
guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
|
|
2450
|
+
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
|
|
2451
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
2452
|
+
return
|
|
2453
|
+
}
|
|
2454
|
+
if pressFeedbackRecognizers[id] == nil {
|
|
2455
|
+
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewPressFeedback(_:)))
|
|
2456
|
+
recognizer.minimumPressDuration = 0
|
|
2457
|
+
recognizer.allowableMovement = 10
|
|
2458
|
+
recognizer.cancelsTouchesInView = false
|
|
2459
|
+
pressFeedbackRecognizers[id] = recognizer
|
|
2460
|
+
view.addGestureRecognizer(recognizer)
|
|
2461
|
+
}
|
|
2462
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
private func updateLongPressHandler(_ handler: String?, for view: UIView) {
|
|
2466
|
+
let id = ObjectIdentifier(view)
|
|
2467
|
+
if let handler, !handler.isEmpty {
|
|
2468
|
+
longPressHandlers[id] = handler
|
|
2469
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
|
|
2470
|
+
if longPressRecognizers[id] == nil {
|
|
2471
|
+
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewLongPressed(_:)))
|
|
2472
|
+
longPressRecognizers[id] = recognizer
|
|
2473
|
+
view.addGestureRecognizer(recognizer)
|
|
2474
|
+
}
|
|
2475
|
+
} else {
|
|
2476
|
+
longPressHandlers.removeValue(forKey: id)
|
|
2477
|
+
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
2478
|
+
view.removeGestureRecognizer(recognizer)
|
|
2479
|
+
}
|
|
2480
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
|
|
2481
|
+
}
|
|
908
2482
|
}
|
|
909
2483
|
|
|
910
2484
|
private func updateField(_ field: UITextField, value: String?) {
|
|
@@ -920,24 +2494,126 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
920
2494
|
}
|
|
921
2495
|
}
|
|
922
2496
|
|
|
2497
|
+
private func updateTextView(_ textView: UITextView, value: String?) {
|
|
2498
|
+
guard let value = value, textView.text != value, textView.markedTextRange == nil else { return }
|
|
2499
|
+
let selection = textView.selectedTextRange
|
|
2500
|
+
let start = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.start) }
|
|
2501
|
+
let end = selection.map { textView.offset(from: textView.beginningOfDocument, to: $0.end) }
|
|
2502
|
+
textView.text = value
|
|
2503
|
+
if let start = start, let end = end,
|
|
2504
|
+
let from = textView.position(from: textView.beginningOfDocument, offset: min(start, value.utf16.count)),
|
|
2505
|
+
let to = textView.position(from: textView.beginningOfDocument, offset: min(end, value.utf16.count)) {
|
|
2506
|
+
textView.selectedTextRange = textView.textRange(from: from, to: to)
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
private func configureTextView(
|
|
2511
|
+
_ textView: UITextView,
|
|
2512
|
+
props: [String: Any],
|
|
2513
|
+
style: [String: Any],
|
|
2514
|
+
events: [String: String],
|
|
2515
|
+
defaultValue: String?
|
|
2516
|
+
) {
|
|
2517
|
+
let id = ObjectIdentifier(textView)
|
|
2518
|
+
textView.delegate = self
|
|
2519
|
+
textView.textColor = color(style["color"]) ?? .label
|
|
2520
|
+
textView.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
2521
|
+
textView.textAlignment = textAlignment(style["textAlign"])
|
|
2522
|
+
textView.tintColor = color(props["selectionColor"])
|
|
2523
|
+
textView.keyboardType = keyboardType(props["keyboardType"])
|
|
2524
|
+
textView.returnKeyType = returnKeyType(props["returnKeyType"])
|
|
2525
|
+
textView.autocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
|
|
2526
|
+
textView.autocapitalizationType = capitalizationType(props["autoCapitalize"])
|
|
2527
|
+
textView.isSecureTextEntry = props["secureTextEntry"] as? Bool == true
|
|
2528
|
+
textView.isEditable = props["editable"] as? Bool != false
|
|
2529
|
+
textView.isScrollEnabled = false
|
|
2530
|
+
if let numberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue, numberOfLines > 0 {
|
|
2531
|
+
textView.textContainer.maximumNumberOfLines = numberOfLines
|
|
2532
|
+
} else {
|
|
2533
|
+
textView.textContainer.maximumNumberOfLines = 0
|
|
2534
|
+
}
|
|
2535
|
+
if let maxLength = (props["maxLength"] as? NSNumber)?.intValue, maxLength > 0 {
|
|
2536
|
+
textMaxLengths[id] = maxLength
|
|
2537
|
+
} else {
|
|
2538
|
+
textMaxLengths.removeValue(forKey: id)
|
|
2539
|
+
}
|
|
2540
|
+
if let craftTextView = textView as? CraftNativeTextView {
|
|
2541
|
+
craftTextView.placeholder = props["placeholder"] as? String
|
|
2542
|
+
craftTextView.placeholderColor = color(props["placeholderTextColor"])
|
|
2543
|
+
craftTextView.placeholderFont = textView.font
|
|
2544
|
+
}
|
|
2545
|
+
updateTextView(textView, value: (props["value"] as? String) ?? defaultValue)
|
|
2546
|
+
let selectedRange = textView.selectedRange
|
|
2547
|
+
let attributes = inputTextAttributes(style, font: textView.font ?? .systemFont(ofSize: 16), alignment: textView.textAlignment)
|
|
2548
|
+
textView.typingAttributes = attributes
|
|
2549
|
+
if !textView.text.isEmpty {
|
|
2550
|
+
textView.attributedText = NSAttributedString(string: textView.text, attributes: attributes)
|
|
2551
|
+
textView.selectedRange = selectedRange
|
|
2552
|
+
}
|
|
2553
|
+
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
2554
|
+
updateHandler(nonEmptyHandler(events["onChange"]) ?? nonEmptyHandler(events["onChangeText"]), for: textView)
|
|
2555
|
+
updateAuxiliaryHandler(events["onFocus"], in: &focusHandlers, for: textView)
|
|
2556
|
+
updateAuxiliaryHandler(events["onBlur"], in: &blurHandlers, for: textView)
|
|
2557
|
+
updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
|
|
2558
|
+
updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
|
|
2559
|
+
if props["autoFocus"] as? Bool == true, !textView.isFirstResponder {
|
|
2560
|
+
DispatchQueue.main.async { _ = textView.becomeFirstResponder() }
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/// `<Icon symbol="sun.max">`: an SF Symbol, tinted by `color`, sized by
|
|
2565
|
+
/// `fontSize` or else the icon's box, weighted by `fontWeight`. A name
|
|
2566
|
+
/// the system does not have draws `circle`, so a typo shows rather than
|
|
2567
|
+
/// leaving a hole.
|
|
2568
|
+
private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
|
|
2569
|
+
let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
|
|
2570
|
+
let box = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
|
|
2571
|
+
let fontSize = number(style["fontSize"])
|
|
2572
|
+
// Without a size of its own an icon is a 20 pt square (contract A2).
|
|
2573
|
+
(icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
|
|
2574
|
+
let pointSize = fontSize ?? (box ?? 20) * 0.84
|
|
2575
|
+
let weight: UIImage.SymbolWeight
|
|
2576
|
+
switch style["fontWeight"] as? String {
|
|
2577
|
+
case "100": weight = .ultraLight
|
|
2578
|
+
case "200": weight = .thin
|
|
2579
|
+
case "300": weight = .light
|
|
2580
|
+
case "500": weight = .medium
|
|
2581
|
+
case "600": weight = .semibold
|
|
2582
|
+
case "bold", "700": weight = .bold
|
|
2583
|
+
case "800": weight = .heavy
|
|
2584
|
+
case "900": weight = .black
|
|
2585
|
+
default: weight = .regular
|
|
2586
|
+
}
|
|
2587
|
+
let scale: UIImage.SymbolScale
|
|
2588
|
+
switch props["scale"] as? String {
|
|
2589
|
+
case "small": scale = .small
|
|
2590
|
+
case "large": scale = .large
|
|
2591
|
+
default: scale = .medium
|
|
2592
|
+
}
|
|
2593
|
+
let configuration = UIImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: weight, scale: scale)
|
|
2594
|
+
let image = UIImage(systemName: name, withConfiguration: configuration)
|
|
2595
|
+
?? UIImage(systemName: "circle", withConfiguration: configuration)
|
|
2596
|
+
if icon.image != image { icon.image = image }
|
|
2597
|
+
icon.preferredSymbolConfiguration = configuration
|
|
2598
|
+
icon.tintColor = color(style["color"]) ?? color(props["color"]) ?? .label
|
|
2599
|
+
icon.contentMode = .scaleAspectFit
|
|
2600
|
+
}
|
|
2601
|
+
|
|
923
2602
|
private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
|
|
924
2603
|
let direction = style["flexDirection"] as? String
|
|
925
2604
|
stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
|
|
926
2605
|
stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
|
|
927
2606
|
stack.alignItems = style["alignItems"] as? String ?? "stretch"
|
|
928
2607
|
stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
|
|
929
|
-
|
|
930
|
-
stack.
|
|
931
|
-
stack.
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
stack.
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
bottom: number(style["paddingBottom"]) ?? vertical,
|
|
939
|
-
right: number(style["paddingRight"]) ?? horizontal
|
|
940
|
-
)
|
|
2608
|
+
let gap = number(style["gap"]) ?? 0
|
|
2609
|
+
stack.gap = gap
|
|
2610
|
+
stack.rowGap = number(style["rowGap"]) ?? gap
|
|
2611
|
+
stack.columnGap = number(style["columnGap"]) ?? gap
|
|
2612
|
+
stack.grid = style["display"] as? String == "grid"
|
|
2613
|
+
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
2614
|
+
stack.gridAutoRows = number(style["gridAutoRows"])
|
|
2615
|
+
stack.padding = paddingInsets(style)
|
|
2616
|
+
stack.invalidateMeasurements()
|
|
941
2617
|
}
|
|
942
2618
|
|
|
943
2619
|
private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
|
|
@@ -950,25 +2626,51 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
950
2626
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
951
2627
|
view.alpha = number(style["opacity"]) ?? 1
|
|
952
2628
|
view.isHidden = style["display"] as? String == "none"
|
|
953
|
-
view.
|
|
2629
|
+
view.craftRequestedCornerRadius = number(style["borderRadius"])
|
|
954
2630
|
view.layer.borderWidth = number(style["borderWidth"]) ?? 0
|
|
955
2631
|
view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
|
|
2632
|
+
let elevation = max(0, number(style["elevation"]) ?? 0)
|
|
2633
|
+
view.layer.shadowColor = UIColor.black.cgColor
|
|
2634
|
+
view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
|
|
2635
|
+
view.layer.shadowRadius = elevation * 0.5
|
|
2636
|
+
view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
|
|
2637
|
+
// A label paints its background inside its own bounds, so a rounded
|
|
2638
|
+
// chip of text clips to its corners.
|
|
956
2639
|
view.clipsToBounds = style["overflow"] as? String == "hidden"
|
|
2640
|
+
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
2641
|
+
view.layer.masksToBounds = view.clipsToBounds
|
|
2642
|
+
view.craftLayoutStyle = CraftNativeLayoutStyle(style)
|
|
2643
|
+
(view as? CraftNativeFlowView)?.invalidateMeasurements()
|
|
2644
|
+
(view as? CraftNativeScrollView)?.setContentNeedsLayout()
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
|
|
2648
|
+
let padding = number(style["padding"]) ?? 0
|
|
2649
|
+
let horizontal = number(style["paddingHorizontal"]) ?? padding
|
|
2650
|
+
let vertical = number(style["paddingVertical"]) ?? padding
|
|
2651
|
+
return UIEdgeInsets(
|
|
2652
|
+
top: number(style["paddingTop"]) ?? vertical,
|
|
2653
|
+
left: number(style["paddingLeft"]) ?? horizontal,
|
|
2654
|
+
bottom: number(style["paddingBottom"]) ?? vertical,
|
|
2655
|
+
right: number(style["paddingRight"]) ?? horizontal
|
|
2656
|
+
)
|
|
957
2657
|
}
|
|
958
2658
|
|
|
959
2659
|
private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
|
|
960
|
-
let transformed:
|
|
961
|
-
switch style["textTransform"] as? String {
|
|
962
|
-
case "uppercase": transformed = text.uppercased()
|
|
963
|
-
case "lowercase": transformed = text.lowercased()
|
|
964
|
-
case "capitalize": transformed = text.capitalized
|
|
965
|
-
default: transformed = text
|
|
966
|
-
}
|
|
2660
|
+
let transformed = transformedText(text, style: style)
|
|
967
2661
|
label.textColor = color(style["color"]) ?? .label
|
|
968
|
-
label.font = textFont(style, default: .systemFont(ofSize:
|
|
2662
|
+
label.font = textFont(style, default: .systemFont(ofSize: 16))
|
|
969
2663
|
label.textAlignment = textAlignment(style["textAlign"])
|
|
970
2664
|
var attributes: [NSAttributedString.Key: Any] = [:]
|
|
971
2665
|
if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
2666
|
+
switch style["textDecorationLine"] as? String {
|
|
2667
|
+
case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
2668
|
+
case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
2669
|
+
case "underline line-through":
|
|
2670
|
+
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
2671
|
+
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
2672
|
+
default: break
|
|
2673
|
+
}
|
|
972
2674
|
if let lineHeight = number(style["lineHeight"]) {
|
|
973
2675
|
let paragraph = NSMutableParagraphStyle()
|
|
974
2676
|
paragraph.minimumLineHeight = lineHeight
|
|
@@ -976,6 +2678,43 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
976
2678
|
paragraph.alignment = label.textAlignment
|
|
977
2679
|
attributes[.paragraphStyle] = paragraph
|
|
978
2680
|
}
|
|
2681
|
+
let alignment = label.textAlignment
|
|
2682
|
+
let lineBreakMode = label.lineBreakMode
|
|
2683
|
+
label.attributedText = attributes.isEmpty ? nil : NSAttributedString(string: transformed, attributes: attributes)
|
|
2684
|
+
if attributes.isEmpty { label.text = transformed }
|
|
2685
|
+
// Attributed text brings its own paragraph style, which resets the
|
|
2686
|
+
// label's alignment and truncation; set them again over the whole text.
|
|
2687
|
+
label.textAlignment = alignment
|
|
2688
|
+
label.lineBreakMode = lineBreakMode
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
|
|
2692
|
+
switch value as? String {
|
|
2693
|
+
case "head": return .byTruncatingHead
|
|
2694
|
+
case "middle": return .byTruncatingMiddle
|
|
2695
|
+
case "tail": return .byTruncatingTail
|
|
2696
|
+
case "clip": return .byClipping
|
|
2697
|
+
default: return .byTruncatingTail
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
private func buttonTitleAttributes(
|
|
2702
|
+
_ style: [String: Any],
|
|
2703
|
+
color: UIColor,
|
|
2704
|
+
font: UIFont,
|
|
2705
|
+
forceUnderline: Bool
|
|
2706
|
+
) -> [NSAttributedString.Key: Any] {
|
|
2707
|
+
var attributes: [NSAttributedString.Key: Any] = [
|
|
2708
|
+
.foregroundColor: color,
|
|
2709
|
+
.font: font,
|
|
2710
|
+
]
|
|
2711
|
+
if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
2712
|
+
if let lineHeight = number(style["lineHeight"]) {
|
|
2713
|
+
let paragraph = NSMutableParagraphStyle()
|
|
2714
|
+
paragraph.minimumLineHeight = lineHeight
|
|
2715
|
+
paragraph.maximumLineHeight = lineHeight
|
|
2716
|
+
attributes[.paragraphStyle] = paragraph
|
|
2717
|
+
}
|
|
979
2718
|
switch style["textDecorationLine"] as? String {
|
|
980
2719
|
case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
981
2720
|
case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
@@ -984,8 +2723,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
984
2723
|
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
985
2724
|
default: break
|
|
986
2725
|
}
|
|
987
|
-
|
|
988
|
-
|
|
2726
|
+
if forceUnderline { attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue }
|
|
2727
|
+
return attributes
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
private func inputTextAttributes(
|
|
2731
|
+
_ style: [String: Any],
|
|
2732
|
+
font: UIFont,
|
|
2733
|
+
alignment: NSTextAlignment
|
|
2734
|
+
) -> [NSAttributedString.Key: Any] {
|
|
2735
|
+
var attributes: [NSAttributedString.Key: Any] = [
|
|
2736
|
+
.foregroundColor: color(style["color"]) ?? .label,
|
|
2737
|
+
.font: font,
|
|
2738
|
+
]
|
|
2739
|
+
if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
2740
|
+
if let lineHeight = number(style["lineHeight"]) {
|
|
2741
|
+
let paragraph = NSMutableParagraphStyle()
|
|
2742
|
+
paragraph.minimumLineHeight = lineHeight
|
|
2743
|
+
paragraph.maximumLineHeight = lineHeight
|
|
2744
|
+
paragraph.alignment = alignment
|
|
2745
|
+
attributes[.paragraphStyle] = paragraph
|
|
2746
|
+
}
|
|
2747
|
+
switch style["textDecorationLine"] as? String {
|
|
2748
|
+
case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
2749
|
+
case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
2750
|
+
case "underline line-through":
|
|
2751
|
+
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
2752
|
+
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
2753
|
+
default: break
|
|
2754
|
+
}
|
|
2755
|
+
return attributes
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
private func transformedText(_ text: String, style: [String: Any]) -> String {
|
|
2759
|
+
switch style["textTransform"] as? String {
|
|
2760
|
+
case "uppercase": return text.uppercased()
|
|
2761
|
+
case "lowercase": return text.lowercased()
|
|
2762
|
+
case "capitalize": return text.split(separator: " ", omittingEmptySubsequences: false).map { word in
|
|
2763
|
+
guard let first = word.first else { return String(word) }
|
|
2764
|
+
return String(first).uppercased() + String(word.dropFirst())
|
|
2765
|
+
}.joined(separator: " ")
|
|
2766
|
+
default: return text
|
|
2767
|
+
}
|
|
989
2768
|
}
|
|
990
2769
|
|
|
991
2770
|
private func textFont(_ style: [String: Any], default fallback: UIFont) -> UIFont {
|
|
@@ -1021,6 +2800,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1021
2800
|
}
|
|
1022
2801
|
}
|
|
1023
2802
|
|
|
2803
|
+
private func buttonAlignment(_ value: Any?) -> UIControl.ContentHorizontalAlignment {
|
|
2804
|
+
switch value as? String {
|
|
2805
|
+
case "left": return .left
|
|
2806
|
+
case "right": return .right
|
|
2807
|
+
case "center": return .center
|
|
2808
|
+
default: return .center
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
|
|
1024
2812
|
private func imageContentMode(_ value: Any?) -> UIView.ContentMode {
|
|
1025
2813
|
switch value as? String {
|
|
1026
2814
|
case "cover": return .scaleAspectFill
|
|
@@ -1030,6 +2818,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1030
2818
|
}
|
|
1031
2819
|
}
|
|
1032
2820
|
|
|
2821
|
+
private func keyboardDismissMode(_ value: Any?) -> UIScrollView.KeyboardDismissMode {
|
|
2822
|
+
switch value as? String {
|
|
2823
|
+
case "on-drag": return .onDrag
|
|
2824
|
+
case "interactive": return .interactive
|
|
2825
|
+
default: return .none
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
|
|
1033
2829
|
private func updateImage(_ view: UIImageView, source: Any?) {
|
|
1034
2830
|
let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
|
|
1035
2831
|
let id = ObjectIdentifier(view)
|
|
@@ -1042,8 +2838,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1042
2838
|
guard imageSources[id] != uri else { return }
|
|
1043
2839
|
imageTasks.removeValue(forKey: id)?.cancel()
|
|
1044
2840
|
imageSources[id] = uri
|
|
2841
|
+
imageErrors.removeValue(forKey: id)
|
|
1045
2842
|
view.image = nil
|
|
1046
2843
|
view.accessibilityValue = nil
|
|
2844
|
+
emitImageEvent(view, handler: imageLoadStartHandlers[id])
|
|
1047
2845
|
|
|
1048
2846
|
if uri.hasPrefix("data:image/") {
|
|
1049
2847
|
guard let comma = uri.firstIndex(of: ","),
|
|
@@ -1052,7 +2850,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1052
2850
|
imageFailure(view, uri: uri, message: "Image data is invalid")
|
|
1053
2851
|
return
|
|
1054
2852
|
}
|
|
1055
|
-
view.image = image
|
|
2853
|
+
view.image = imageForDisplay(image, view: view)
|
|
2854
|
+
emitImageEvent(view, handler: imageLoadHandlers[id])
|
|
2855
|
+
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
1056
2856
|
return
|
|
1057
2857
|
}
|
|
1058
2858
|
if let url = URL(string: uri), url.scheme == "https" {
|
|
@@ -1065,7 +2865,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1065
2865
|
self.imageFailure(view, uri: uri, message: "Image download failed")
|
|
1066
2866
|
return
|
|
1067
2867
|
}
|
|
1068
|
-
view.image = image
|
|
2868
|
+
view.image = self.imageForDisplay(image, view: view)
|
|
2869
|
+
self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
|
|
2870
|
+
self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
|
|
1069
2871
|
}
|
|
1070
2872
|
}
|
|
1071
2873
|
imageTasks[id] = task
|
|
@@ -1077,26 +2879,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1077
2879
|
imageFailure(view, uri: uri, message: "Bundled image was not found")
|
|
1078
2880
|
return
|
|
1079
2881
|
}
|
|
1080
|
-
view.image = image
|
|
2882
|
+
view.image = imageForDisplay(image, view: view)
|
|
2883
|
+
emitImageEvent(view, handler: imageLoadHandlers[id])
|
|
2884
|
+
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
1081
2885
|
return
|
|
1082
2886
|
}
|
|
1083
2887
|
imageFailure(view, uri: uri, message: "Unsupported image source")
|
|
1084
2888
|
}
|
|
1085
2889
|
|
|
1086
2890
|
private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
|
|
2891
|
+
imageErrors[ObjectIdentifier(view)] = message
|
|
1087
2892
|
view.image = nil
|
|
1088
2893
|
view.accessibilityValue = message
|
|
2894
|
+
let id = ObjectIdentifier(view)
|
|
2895
|
+
emitImageEvent(view, handler: imageErrorHandlers[id], nativeEvent: ["error": ["message": message]])
|
|
2896
|
+
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
1089
2897
|
NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
|
|
1090
2898
|
}
|
|
1091
2899
|
|
|
2900
|
+
private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
|
|
2901
|
+
image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
private func emitImageEvent(
|
|
2905
|
+
_ view: UIImageView,
|
|
2906
|
+
handler: String?,
|
|
2907
|
+
nativeEvent: [String: Any] = [:]
|
|
2908
|
+
) {
|
|
2909
|
+
guard let handler, !handler.isEmpty else { return }
|
|
2910
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
|
|
2911
|
+
}
|
|
2912
|
+
|
|
1092
2913
|
private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
|
|
1093
|
-
view.accessibilityLabel = props["accessibilityLabel"] as? String
|
|
2914
|
+
view.accessibilityLabel = (props["accessibilityLabel"] as? String)
|
|
2915
|
+
?? (type == "Image" ? props["alt"] as? String : nil)
|
|
1094
2916
|
view.accessibilityHint = props["accessibilityHint"] as? String
|
|
1095
2917
|
if let value = props["accessibilityValue"] {
|
|
1096
2918
|
view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
|
|
2919
|
+
} else if type != "TextInput" {
|
|
2920
|
+
view.accessibilityValue = nil
|
|
1097
2921
|
}
|
|
1098
2922
|
let role = props["accessibilityRole"] as? String
|
|
1099
|
-
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Image", "TextInput"].contains(type))
|
|
2923
|
+
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
1100
2924
|
var traits: UIAccessibilityTraits = []
|
|
1101
2925
|
switch role ?? type.lowercased() {
|
|
1102
2926
|
case "button": traits.insert(.button)
|
|
@@ -1107,11 +2931,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1107
2931
|
default: break
|
|
1108
2932
|
}
|
|
1109
2933
|
if let state = props["accessibilityState"] as? [String: Any] {
|
|
1110
|
-
if state["disabled"] as? Bool == true {
|
|
2934
|
+
if state["disabled"] as? Bool == true {
|
|
2935
|
+
traits.insert(.notEnabled)
|
|
2936
|
+
if let control = view as? UIControl {
|
|
2937
|
+
control.isEnabled = false
|
|
2938
|
+
} else if handlers[ObjectIdentifier(view)] != nil || longPressHandlers[ObjectIdentifier(view)] != nil {
|
|
2939
|
+
view.isUserInteractionEnabled = false
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
1111
2942
|
if state["selected"] as? Bool == true { traits.insert(.selected) }
|
|
1112
2943
|
if state["checked"] as? Bool == true { traits.insert(.selected) }
|
|
1113
2944
|
}
|
|
1114
2945
|
if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
|
|
2946
|
+
if type == "Image", let message = imageErrors[ObjectIdentifier(view)] {
|
|
2947
|
+
let current = view.accessibilityValue
|
|
2948
|
+
if current?.contains(message) != true {
|
|
2949
|
+
view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
1115
2952
|
view.accessibilityTraits = traits
|
|
1116
2953
|
}
|
|
1117
2954
|
|
|
@@ -1119,7 +2956,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1119
2956
|
if let value = value {
|
|
1120
2957
|
if let constraint = constraint { constraint.constant = value }
|
|
1121
2958
|
else {
|
|
2959
|
+
// Below required: the flex layout owns frames, and a width
|
|
2960
|
+
// the layout stretches or shrinks must not log a conflict.
|
|
1122
2961
|
constraint = anchor.constraint(equalToConstant: value)
|
|
2962
|
+
constraint?.priority = UILayoutPriority(999)
|
|
1123
2963
|
constraint?.isActive = true
|
|
1124
2964
|
}
|
|
1125
2965
|
} else {
|
|
@@ -1138,10 +2978,55 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1138
2978
|
}
|
|
1139
2979
|
}
|
|
1140
2980
|
|
|
2981
|
+
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
|
2982
|
+
guard let maxLength = textMaxLengths[ObjectIdentifier(textField)],
|
|
2983
|
+
let current = textField.text,
|
|
2984
|
+
let stringRange = Range(range, in: current) else { return true }
|
|
2985
|
+
return current.replacingCharacters(in: stringRange, with: string).utf16.count <= maxLength
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
|
2989
|
+
if text == "\n", let handler = submitHandlers[ObjectIdentifier(textView)] {
|
|
2990
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
2991
|
+
}
|
|
2992
|
+
guard let maxLength = textMaxLengths[ObjectIdentifier(textView)],
|
|
2993
|
+
let current = textView.text,
|
|
2994
|
+
let stringRange = Range(range, in: current) else { return true }
|
|
2995
|
+
return current.replacingCharacters(in: stringRange, with: text).utf16.count <= maxLength
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
func textViewDidChange(_ textView: UITextView) {
|
|
2999
|
+
lastFocusedInput = textView
|
|
3000
|
+
let id = ObjectIdentifier(textView)
|
|
3001
|
+
inputIdentities[id].map { inputDrafts[$0] = textView.text ?? "" }
|
|
3002
|
+
(textView as? CraftNativeTextView)?.refreshPlaceholder()
|
|
3003
|
+
guard let handler = handlers[id] else { return }
|
|
3004
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
3008
|
+
lastFocusedInput = textView
|
|
3009
|
+
guard let handler = focusHandlers[ObjectIdentifier(textView)] else { return }
|
|
3010
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
func textViewDidEndEditing(_ textView: UITextView) {
|
|
3014
|
+
let id = ObjectIdentifier(textView)
|
|
3015
|
+
let nativeEvent: [String: Any] = ["text": textView.text ?? ""]
|
|
3016
|
+
if let handler = blurHandlers[id] {
|
|
3017
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
|
|
3018
|
+
}
|
|
3019
|
+
if let handler = endEditingHandlers[id] {
|
|
3020
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
|
|
1141
3024
|
@objc private func textChanged(_ sender: UITextField) {
|
|
1142
3025
|
lastFocusedInput = sender
|
|
3026
|
+
let id = ObjectIdentifier(sender)
|
|
3027
|
+
inputIdentities[id].map { inputDrafts[$0] = sender.text ?? "" }
|
|
1143
3028
|
let focused = sender.isFirstResponder ? sender : firstResponder(in: rootStack)
|
|
1144
|
-
guard let handler = handlers[
|
|
3029
|
+
guard let handler = handlers[id] else { return }
|
|
1145
3030
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
1146
3031
|
restoreFocus(focused)
|
|
1147
3032
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
|
|
@@ -1149,15 +3034,59 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1149
3034
|
}
|
|
1150
3035
|
}
|
|
1151
3036
|
|
|
3037
|
+
@objc private func switchChanged(_ sender: UISwitch) {
|
|
3038
|
+
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
3039
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
@objc private func sliderChanged(_ sender: UISlider) {
|
|
3043
|
+
let value = snappedSliderValue(sender.value, for: sender)
|
|
3044
|
+
if sender.value != value { sender.value = value }
|
|
3045
|
+
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
3046
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value]])
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
@objc private func sliderFinished(_ sender: UISlider) {
|
|
3050
|
+
guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
|
|
3051
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
|
|
3055
|
+
|
|
3056
|
+
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
|
|
3057
|
+
pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
|
|
3061
|
+
guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
|
|
3062
|
+
return options[row].label
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
|
|
3066
|
+
guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
|
|
3067
|
+
let handler = handlers[ObjectIdentifier(pickerView)] else { return }
|
|
3068
|
+
let option = options[row]
|
|
3069
|
+
send(type: "EVENT", payload: [
|
|
3070
|
+
"handlerName": handler,
|
|
3071
|
+
"nativeEvent": ["value": option.value, "index": row],
|
|
3072
|
+
])
|
|
3073
|
+
}
|
|
3074
|
+
|
|
1152
3075
|
@objc private func textFocused(_ sender: UITextField) {
|
|
1153
3076
|
lastFocusedInput = sender
|
|
1154
3077
|
guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
|
|
1155
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
3078
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
1156
3079
|
}
|
|
1157
3080
|
|
|
1158
3081
|
@objc private func textBlurred(_ sender: UITextField) {
|
|
1159
|
-
|
|
1160
|
-
|
|
3082
|
+
let id = ObjectIdentifier(sender)
|
|
3083
|
+
let nativeEvent: [String: Any] = ["text": sender.text ?? ""]
|
|
3084
|
+
if let handler = blurHandlers[id] {
|
|
3085
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
|
|
3086
|
+
}
|
|
3087
|
+
if let handler = endEditingHandlers[id] {
|
|
3088
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
|
|
3089
|
+
}
|
|
1161
3090
|
}
|
|
1162
3091
|
|
|
1163
3092
|
@objc private func textSubmitted(_ sender: UITextField) {
|
|
@@ -1167,6 +3096,37 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1167
3096
|
|
|
1168
3097
|
@objc private func viewPressed(_ sender: UITapGestureRecognizer) {
|
|
1169
3098
|
guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
|
|
3099
|
+
let id = ObjectIdentifier(view)
|
|
3100
|
+
if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
|
|
3101
|
+
let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
|
|
3102
|
+
UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
|
|
3103
|
+
UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity }
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
@objc private func viewPressFeedback(_ sender: UILongPressGestureRecognizer) {
|
|
3110
|
+
guard let view = sender.view else { return }
|
|
3111
|
+
let id = ObjectIdentifier(view)
|
|
3112
|
+
switch sender.state {
|
|
3113
|
+
case .began:
|
|
3114
|
+
if let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
|
|
3115
|
+
UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
|
|
3116
|
+
}
|
|
3117
|
+
if let handler = pressInHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
3118
|
+
case .ended, .cancelled, .failed:
|
|
3119
|
+
if let pressable = view as? CraftNativeFlowView { UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity } }
|
|
3120
|
+
if let handler = pressOutHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
3121
|
+
default:
|
|
3122
|
+
break
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
@objc private func viewLongPressed(_ sender: UILongPressGestureRecognizer) {
|
|
3127
|
+
guard sender.state == .began,
|
|
3128
|
+
let view = sender.view,
|
|
3129
|
+
let handler = longPressHandlers[ObjectIdentifier(view)] else { return }
|
|
1170
3130
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
1171
3131
|
}
|
|
1172
3132
|
|
|
@@ -1185,6 +3145,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1185
3145
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
1186
3146
|
}
|
|
1187
3147
|
|
|
3148
|
+
func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
|
|
3149
|
+
guard let handler = scrollMomentumBeginHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
3150
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
3154
|
+
guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
|
|
3155
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
|
|
3156
|
+
}
|
|
3157
|
+
|
|
1188
3158
|
private func scrollEvent(_ scrollView: UIScrollView) -> [String: Any] {
|
|
1189
3159
|
[
|
|
1190
3160
|
"contentOffset": ["x": scrollView.contentOffset.x, "y": scrollView.contentOffset.y],
|
|
@@ -1208,10 +3178,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1208
3178
|
private func keyboardType(_ value: Any?) -> UIKeyboardType {
|
|
1209
3179
|
switch value as? String {
|
|
1210
3180
|
case "email-address": return .emailAddress
|
|
1211
|
-
case "numeric": return .numberPad
|
|
3181
|
+
case "numeric", "number-pad": return .numberPad
|
|
1212
3182
|
case "phone-pad": return .phonePad
|
|
1213
3183
|
case "decimal-pad": return .decimalPad
|
|
1214
3184
|
case "url": return .URL
|
|
3185
|
+
case "web-search": return .webSearch
|
|
3186
|
+
case "visible-password": return .asciiCapable
|
|
1215
3187
|
default: return .default
|
|
1216
3188
|
}
|
|
1217
3189
|
}
|
|
@@ -1236,8 +3208,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate
|
|
|
1236
3208
|
}
|
|
1237
3209
|
}
|
|
1238
3210
|
|
|
3211
|
+
/// A style colour: CSS hex with or without alpha (`#10b9811a` is
|
|
3212
|
+
/// emerald at 10%), `rgb()`/`rgba()` and the few names; `UIColor(hex:)`
|
|
3213
|
+
/// for anything else it has always read.
|
|
1239
3214
|
private func color(_ value: Any?) -> UIColor? {
|
|
1240
|
-
guard let
|
|
1241
|
-
return UIColor(hex:
|
|
3215
|
+
guard let text = value as? String else { return nil }
|
|
3216
|
+
return UIColor(css: text) ?? UIColor(hex: text)
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
/// `console` for native screens: the unified log, subsystem `craft.native`,
|
|
3221
|
+
/// category the screen's name. Read it with
|
|
3222
|
+
/// `log stream --predicate 'subsystem == "craft.native"'`.
|
|
3223
|
+
enum CraftNativeConsole {
|
|
3224
|
+
private static var loggers: [String: Logger] = [:]
|
|
3225
|
+
private static let lock = NSLock()
|
|
3226
|
+
|
|
3227
|
+
static func write(_ level: String, category: String, message: String) {
|
|
3228
|
+
lock.lock()
|
|
3229
|
+
let logger = loggers[category] ?? Logger(subsystem: "craft.native", category: category)
|
|
3230
|
+
loggers[category] = logger
|
|
3231
|
+
lock.unlock()
|
|
3232
|
+
switch level {
|
|
3233
|
+
case "debug": logger.debug("\(message, privacy: .public)")
|
|
3234
|
+
case "info": logger.info("\(message, privacy: .public)")
|
|
3235
|
+
case "warn": logger.warning("\(message, privacy: .public)")
|
|
3236
|
+
case "error": logger.error("\(message, privacy: .public)")
|
|
3237
|
+
default: logger.notice("\(message, privacy: .public)")
|
|
3238
|
+
}
|
|
1242
3239
|
}
|
|
1243
3240
|
}
|