craft-native 0.0.124 → 0.0.126
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/dist/android/src/index.js +5 -3
- package/dist/android/templates/CraftNativeFlatList.kt.template +39 -2
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivityNative.kt.template +269 -26
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +43 -0
- package/dist/cli.js +3 -3
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +49 -1
- package/dist/ios/src/index.js +150 -4
- package/dist/ios/templates/CraftApp.swift +111 -13
- package/dist/ios/templates/CraftHybrid.swift +1199 -0
- package/dist/ios/templates/CraftNativeActions.swift +213 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +150 -21
- package/dist/ios/templates/CraftNativeScreen.swift +1683 -307
- package/dist/mobile.js +66 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import JavaScriptCore
|
|
3
|
+
import OSLog
|
|
3
4
|
import SwiftUI
|
|
4
5
|
import UIKit
|
|
5
6
|
|
|
@@ -17,7 +18,7 @@ private func craftNativeGridColumnCount(_ value: Any?) -> Int {
|
|
|
17
18
|
return max(1, tracks.count)
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/// A WebView-free host for the first stx
|
|
21
|
+
/// A WebView-free host for the first stx native vertical slice. The bundled
|
|
21
22
|
/// JavaScript sends whole, compiled view trees to UIKit and receives control
|
|
22
23
|
/// events and Craft API replies through JavaScriptCore.
|
|
23
24
|
struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
@@ -30,13 +31,46 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
|
30
31
|
func updateUIViewController(_ controller: UINavigationController, context: Context) {}
|
|
31
32
|
}
|
|
32
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.
|
|
33
67
|
private struct CraftNativeLayoutStyle {
|
|
34
|
-
let width:
|
|
35
|
-
let height:
|
|
36
|
-
let minWidth:
|
|
37
|
-
let maxWidth:
|
|
38
|
-
let minHeight:
|
|
39
|
-
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?
|
|
40
74
|
let position: String
|
|
41
75
|
let top: CGFloat?
|
|
42
76
|
let right: CGFloat?
|
|
@@ -44,8 +78,10 @@ private struct CraftNativeLayoutStyle {
|
|
|
44
78
|
let left: CGFloat?
|
|
45
79
|
let alignSelf: String?
|
|
46
80
|
let flexGrow: CGFloat
|
|
47
|
-
|
|
48
|
-
|
|
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?
|
|
49
85
|
let marginTop: CGFloat
|
|
50
86
|
let marginRight: CGFloat
|
|
51
87
|
let marginBottom: CGFloat
|
|
@@ -54,23 +90,30 @@ private struct CraftNativeLayoutStyle {
|
|
|
54
90
|
let gridColumns: Int
|
|
55
91
|
let gridAutoRows: CGFloat?
|
|
56
92
|
|
|
93
|
+
static let empty = CraftNativeLayoutStyle([:])
|
|
94
|
+
|
|
57
95
|
init(_ raw: [String: Any]) {
|
|
58
96
|
func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
59
|
-
width =
|
|
60
|
-
height =
|
|
61
|
-
minWidth =
|
|
62
|
-
maxWidth =
|
|
63
|
-
minHeight =
|
|
64
|
-
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"])
|
|
65
103
|
position = raw["position"] as? String ?? "relative"
|
|
66
104
|
top = number(raw["top"])
|
|
67
105
|
right = number(raw["right"])
|
|
68
106
|
bottom = number(raw["bottom"])
|
|
69
107
|
left = number(raw["left"])
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 }
|
|
74
117
|
let margin = number(raw["margin"]) ?? 0
|
|
75
118
|
let horizontalMargin = number(raw["marginHorizontal"]) ?? margin
|
|
76
119
|
let verticalMargin = number(raw["marginVertical"]) ?? margin
|
|
@@ -84,31 +127,154 @@ private struct CraftNativeLayoutStyle {
|
|
|
84
127
|
}
|
|
85
128
|
}
|
|
86
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 size rounded up to whole pixels, never below `atLeast`.
|
|
217
|
+
static func ceiled(_ size: CGSize, scale: CGFloat, atLeast floor: CGSize) -> CGSize {
|
|
218
|
+
let scale = max(1, scale)
|
|
219
|
+
// Less a hair, so a size already on the pixel grid stays put.
|
|
220
|
+
func up(_ value: CGFloat) -> CGFloat { (value * scale - 0.001).rounded(.up) / scale }
|
|
221
|
+
return CGSize(width: max(floor.width, up(size.width)), height: max(floor.height, up(size.height)))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// A frame snapped to the screen's pixels edge by edge, so neighbours
|
|
225
|
+
/// neither overlap nor leave a hairline between them.
|
|
226
|
+
static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
|
|
227
|
+
let scale = max(1, scale)
|
|
228
|
+
func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
|
|
229
|
+
let minX = snap(rect.minX), minY = snap(rect.minY)
|
|
230
|
+
return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// A flexbox container: rows and columns with grow, shrink and basis, wrap,
|
|
235
|
+
/// gaps, alignment, margins, percentages, absolute children and a simple
|
|
236
|
+
/// grid. It is a UIStackView in name only, so existing callers and tests keep
|
|
237
|
+
/// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
|
|
238
|
+
/// constraints are never created, and every frame comes from `layout`.
|
|
87
239
|
private final class CraftNativeFlowView: UIStackView {
|
|
88
|
-
var
|
|
89
|
-
var
|
|
90
|
-
var
|
|
91
|
-
var
|
|
92
|
-
var
|
|
93
|
-
var
|
|
94
|
-
var
|
|
95
|
-
var
|
|
96
|
-
var
|
|
240
|
+
var pressActiveOpacity: CGFloat?
|
|
241
|
+
var pressBaseOpacity: CGFloat = 1
|
|
242
|
+
var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
|
|
243
|
+
var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
|
|
244
|
+
var justifyContent = "flex-start" { didSet { distribution = distributionValue; if justifyContent != oldValue { invalidateMeasurements() } } }
|
|
245
|
+
var gap: CGFloat = 0 { didSet { spacing = gap; if gap != oldValue { invalidateMeasurements() } } }
|
|
246
|
+
var rowGap: CGFloat? { didSet { if rowGap != oldValue { invalidateMeasurements() } } }
|
|
247
|
+
var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
|
|
248
|
+
var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
|
|
249
|
+
var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
|
|
250
|
+
var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
|
|
97
251
|
var padding = UIEdgeInsets.zero {
|
|
98
252
|
didSet {
|
|
99
253
|
layoutMargins = padding
|
|
100
254
|
isLayoutMarginsRelativeArrangement = true
|
|
101
|
-
|
|
102
|
-
setNeedsLayout()
|
|
255
|
+
if padding != oldValue { invalidateMeasurements() }
|
|
103
256
|
}
|
|
104
257
|
}
|
|
105
|
-
|
|
258
|
+
override var axis: NSLayoutConstraint.Axis {
|
|
259
|
+
didSet { if axis != oldValue { invalidateMeasurements() } }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private var flowChildren: [UIView] = []
|
|
263
|
+
private var measurements: [MeasureKey: CGSize] = [:]
|
|
264
|
+
|
|
265
|
+
private struct MeasureKey: Hashable {
|
|
266
|
+
let width: CGFloat?
|
|
267
|
+
let height: CGFloat?
|
|
268
|
+
let maxWidth: CGFloat
|
|
269
|
+
let maxHeight: CGFloat
|
|
270
|
+
}
|
|
106
271
|
|
|
107
272
|
override init(frame: CGRect) {
|
|
108
273
|
super.init(frame: frame)
|
|
109
274
|
alignment = .fill
|
|
110
275
|
distribution = .fill
|
|
111
276
|
isLayoutMarginsRelativeArrangement = true
|
|
277
|
+
insetsLayoutMarginsFromSafeArea = false
|
|
112
278
|
}
|
|
113
279
|
|
|
114
280
|
required init(coder: NSCoder) { super.init(coder: coder) }
|
|
@@ -131,304 +297,818 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
131
297
|
}
|
|
132
298
|
}
|
|
133
299
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
300
|
+
// MARK: Children
|
|
301
|
+
|
|
302
|
+
override var arrangedSubviews: [UIView] { flowChildren }
|
|
303
|
+
|
|
304
|
+
override func addArrangedSubview(_ view: UIView) {
|
|
305
|
+
insertArrangedSubview(view, at: flowChildren.count)
|
|
138
306
|
}
|
|
139
307
|
|
|
140
|
-
func
|
|
308
|
+
override func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
|
|
309
|
+
flowChildren.removeAll { $0 === view }
|
|
310
|
+
flowChildren.insert(view, at: min(max(0, stackIndex), flowChildren.count))
|
|
311
|
+
if view.superview !== self {
|
|
312
|
+
view.removeFromSuperview()
|
|
313
|
+
addSubview(view)
|
|
314
|
+
}
|
|
315
|
+
view.translatesAutoresizingMaskIntoConstraints = true
|
|
316
|
+
invalidateMeasurements()
|
|
317
|
+
}
|
|
141
318
|
|
|
142
|
-
override func
|
|
143
|
-
|
|
144
|
-
|
|
319
|
+
override func removeArrangedSubview(_ view: UIView) {
|
|
320
|
+
flowChildren.removeAll { $0 === view }
|
|
321
|
+
invalidateMeasurements()
|
|
145
322
|
}
|
|
146
323
|
|
|
147
324
|
override func willRemoveSubview(_ subview: UIView) {
|
|
148
|
-
|
|
325
|
+
flowChildren.removeAll { $0 === subview }
|
|
149
326
|
super.willRemoveSubview(subview)
|
|
327
|
+
invalidateMeasurements()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
|
|
331
|
+
view.craftLayoutStyle = CraftNativeLayoutStyle(raw)
|
|
332
|
+
invalidateMeasurements()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
func removeLayoutStyle(for view: UIView) {
|
|
336
|
+
view.craftLayoutStyle = .empty
|
|
150
337
|
}
|
|
151
338
|
|
|
339
|
+
/// Forgets measured sizes and lays out again on the next pass.
|
|
340
|
+
func invalidateMeasurements() {
|
|
341
|
+
measurements.removeAll(keepingCapacity: true)
|
|
342
|
+
setNeedsLayout()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// MARK: Measuring and layout
|
|
346
|
+
|
|
152
347
|
override var intrinsicContentSize: CGSize {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
let
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}.max() ?? 0
|
|
167
|
-
let mainGap = max(0, CGFloat(max(0, children.count - 1))) * mainGapValue
|
|
168
|
-
if axis == .horizontal {
|
|
169
|
-
return CGSize(width: padding.left + padding.right + main + mainGap, height: padding.top + padding.bottom + cross)
|
|
170
|
-
}
|
|
171
|
-
return CGSize(width: padding.left + padding.right + cross, height: padding.top + padding.bottom + main + mainGap)
|
|
348
|
+
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
352
|
+
measure(width: nil, height: nil, maxWidth: size.width, maxHeight: size.height)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
356
|
+
let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
357
|
+
if let cached = measurements[key] { return cached }
|
|
358
|
+
let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
|
|
359
|
+
measurements[key] = size
|
|
360
|
+
return size
|
|
172
361
|
}
|
|
173
362
|
|
|
174
363
|
override func layoutSubviews() {
|
|
175
364
|
super.layoutSubviews()
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
365
|
+
craftApplyCornerRadius()
|
|
366
|
+
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
367
|
+
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
368
|
+
for (child, frame) in result.frames {
|
|
369
|
+
var snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
|
|
370
|
+
// Snapping each edge can take a pixel off a label (15.83…33.83
|
|
371
|
+
// becomes 16…33.67), and a label a pixel narrower than its text
|
|
372
|
+
// truncates it ("13" drew as "…"). Text keeps its whole size.
|
|
373
|
+
if child is UILabel {
|
|
374
|
+
snapped.size = CraftNativeFlexLayout.ceiled(frame.size, scale: scale, atLeast: snapped.size)
|
|
375
|
+
}
|
|
376
|
+
if child.transform.isIdentity {
|
|
377
|
+
if child.frame != snapped { child.frame = snapped }
|
|
378
|
+
} else {
|
|
379
|
+
// A transformed child (a sticky header, a scaled spinner) is
|
|
380
|
+
// placed by bounds and center: its frame is undefined then.
|
|
381
|
+
let size = CGRect(origin: child.bounds.origin, size: snapped.size)
|
|
382
|
+
let center = CGPoint(x: snapped.midX, y: snapped.midY)
|
|
383
|
+
if child.bounds != size { child.bounds = size }
|
|
384
|
+
if child.center != center { child.center = center }
|
|
385
|
+
}
|
|
386
|
+
child.craftApplyCornerRadius()
|
|
180
387
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private struct Item {
|
|
391
|
+
let view: UIView
|
|
392
|
+
let style: CraftNativeLayoutStyle
|
|
393
|
+
let align: String
|
|
394
|
+
let mainLeading: CGFloat
|
|
395
|
+
let mainTrailing: CGFloat
|
|
396
|
+
let crossLeading: CGFloat
|
|
397
|
+
let crossTrailing: CGFloat
|
|
398
|
+
let minMain: CGFloat?
|
|
399
|
+
let maxMain: CGFloat?
|
|
400
|
+
let minCross: CGFloat?
|
|
401
|
+
let maxCross: CGFloat?
|
|
402
|
+
let fixedCross: CGFloat?
|
|
403
|
+
let grow: CGFloat
|
|
404
|
+
let shrink: CGFloat
|
|
405
|
+
var basis: CGFloat
|
|
185
406
|
var main: CGFloat = 0
|
|
186
407
|
var cross: CGFloat = 0
|
|
187
408
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
409
|
+
var mainMargins: CGFloat { mainLeading + mainTrailing }
|
|
410
|
+
var crossMargins: CGFloat { crossLeading + crossTrailing }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private func clamp(_ value: CGFloat, _ lower: CGFloat?, _ upper: CGFloat?) -> CGFloat {
|
|
414
|
+
var result = value
|
|
415
|
+
if let upper { result = min(result, upper) }
|
|
416
|
+
if let lower { result = max(result, lower) }
|
|
417
|
+
return max(0, result)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// Children that take part in the flow: shown, not a modal overlay.
|
|
421
|
+
private var visibleChildren: [UIView] {
|
|
422
|
+
flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// The flexbox algorithm, single pass. `width`/`height` are this view's
|
|
426
|
+
/// outer size when its parent fixes it, nil when it sizes to its content
|
|
427
|
+
/// within `maxWidth`/`maxHeight`. Returns its outer size and, when
|
|
428
|
+
/// placing, every child's frame in its own coordinates.
|
|
429
|
+
private func layout(
|
|
430
|
+
width: CGFloat?,
|
|
431
|
+
height: CGFloat?,
|
|
432
|
+
maxWidth: CGFloat,
|
|
433
|
+
maxHeight: CGFloat,
|
|
434
|
+
place: Bool
|
|
435
|
+
) -> (size: CGSize, frames: [(UIView, CGRect)]) {
|
|
436
|
+
let children = visibleChildren
|
|
437
|
+
let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
|
|
438
|
+
let horizontal = axis == .horizontal
|
|
439
|
+
let paddingWidth = padding.left + padding.right
|
|
440
|
+
let paddingHeight = padding.top + padding.bottom
|
|
441
|
+
let innerWidth = width.map { max(0, $0 - paddingWidth) }
|
|
442
|
+
let innerHeight = height.map { max(0, $0 - paddingHeight) }
|
|
443
|
+
let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
|
|
444
|
+
let roomHeight = max(0, (height ?? maxHeight) - paddingHeight)
|
|
445
|
+
|
|
446
|
+
var frames: [(UIView, CGRect)] = []
|
|
447
|
+
var contentSize: CGSize
|
|
448
|
+
if grid {
|
|
449
|
+
contentSize = layoutGrid(flow, innerWidth: innerWidth, roomWidth: roomWidth, innerHeight: innerHeight, place: place, frames: &frames)
|
|
450
|
+
} else {
|
|
451
|
+
contentSize = layoutFlex(
|
|
452
|
+
flow,
|
|
453
|
+
horizontal: horizontal,
|
|
454
|
+
innerWidth: innerWidth,
|
|
455
|
+
innerHeight: innerHeight,
|
|
456
|
+
roomWidth: roomWidth,
|
|
457
|
+
roomHeight: roomHeight,
|
|
458
|
+
place: place,
|
|
459
|
+
frames: &frames
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
|
|
463
|
+
|
|
464
|
+
if place {
|
|
465
|
+
let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
|
|
466
|
+
for child in children where child.craftLayoutStyle.position == "absolute" {
|
|
467
|
+
let style = child.craftLayoutStyle
|
|
468
|
+
let fixedWidth = style.left != nil && style.right != nil
|
|
469
|
+
? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
|
|
470
|
+
: style.width?.resolve(content.width)
|
|
471
|
+
let fixedHeight = style.top != nil && style.bottom != nil
|
|
472
|
+
? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
|
|
473
|
+
: style.height?.resolve(content.height)
|
|
474
|
+
let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
|
|
475
|
+
let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
|
|
476
|
+
let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
|
|
477
|
+
frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
|
|
243
478
|
}
|
|
244
|
-
crossOffset += lineCross + crossGapValue
|
|
245
479
|
}
|
|
480
|
+
return (size, frames)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private func layoutFlex(
|
|
484
|
+
_ children: [UIView],
|
|
485
|
+
horizontal: Bool,
|
|
486
|
+
innerWidth: CGFloat?,
|
|
487
|
+
innerHeight: CGFloat?,
|
|
488
|
+
roomWidth: CGFloat,
|
|
489
|
+
roomHeight: CGFloat,
|
|
490
|
+
place: Bool,
|
|
491
|
+
frames: inout [(UIView, CGRect)]
|
|
492
|
+
) -> CGSize {
|
|
493
|
+
let definiteMain = horizontal ? innerWidth : innerHeight
|
|
494
|
+
let definiteCross = horizontal ? innerHeight : innerWidth
|
|
495
|
+
let roomMain = horizontal ? roomWidth : roomHeight
|
|
496
|
+
let roomCross = horizontal ? roomHeight : roomWidth
|
|
497
|
+
let mainGap = horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
|
|
498
|
+
let crossGap = horizontal ? (rowGap ?? 0) : (columnGap ?? 0)
|
|
499
|
+
|
|
500
|
+
func measure(_ view: UIView, main: CGFloat?, cross: CGFloat?, mainLimit: CGFloat, crossLimit: CGFloat) -> (main: CGFloat, cross: CGFloat) {
|
|
501
|
+
let size = horizontal
|
|
502
|
+
? CraftNativeFlexLayout.measure(view, width: main, height: cross, maxWidth: mainLimit, maxHeight: crossLimit)
|
|
503
|
+
: CraftNativeFlexLayout.measure(view, width: cross, height: main, maxWidth: crossLimit, maxHeight: mainLimit)
|
|
504
|
+
return horizontal ? (size.width, size.height) : (size.height, size.width)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
var items: [Item] = children.map { view in
|
|
508
|
+
let style = view.craftLayoutStyle
|
|
509
|
+
let mainLeading = horizontal ? style.marginLeft : style.marginTop
|
|
510
|
+
let mainTrailing = horizontal ? style.marginRight : style.marginBottom
|
|
511
|
+
let crossLeading = horizontal ? style.marginTop : style.marginLeft
|
|
512
|
+
let crossTrailing = horizontal ? style.marginBottom : style.marginRight
|
|
513
|
+
let align = style.alignSelf ?? alignItems
|
|
514
|
+
let explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
|
|
515
|
+
let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
|
|
516
|
+
let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
|
|
517
|
+
let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
|
|
518
|
+
let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
|
|
519
|
+
let maxCross = (horizontal ? style.maxHeight : style.maxWidth)?.resolve(definiteCross)
|
|
520
|
+
var fixedCross = explicitCross
|
|
521
|
+
if fixedCross == nil, align == "stretch", !wrap, let definiteCross {
|
|
522
|
+
fixedCross = definiteCross - crossLeading - crossTrailing
|
|
523
|
+
}
|
|
524
|
+
fixedCross = fixedCross.map { clamp($0, minCross, maxCross) }
|
|
525
|
+
let defaultShrink: CGFloat
|
|
526
|
+
if view is UIScrollView {
|
|
527
|
+
defaultShrink = 1
|
|
528
|
+
} else if view is UILabel {
|
|
529
|
+
defaultShrink = horizontal ? 1 : 0
|
|
530
|
+
} else {
|
|
531
|
+
defaultShrink = 0
|
|
532
|
+
}
|
|
246
533
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
let
|
|
252
|
-
let
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
534
|
+
// The basis: a flex-basis, else the explicit size, else the
|
|
535
|
+
// content's size. A zero basis (`flex-1`) means "share the free
|
|
536
|
+
// space" only when there is a free space to share; in a container
|
|
537
|
+
// sized by its content, the content is the basis, as on the web.
|
|
538
|
+
let basis: CGFloat
|
|
539
|
+
if let flexBasis = style.flexBasis?.resolve(definiteMain), definiteMain != nil || flexBasis > 0 {
|
|
540
|
+
basis = flexBasis
|
|
541
|
+
} else if let explicitMain {
|
|
542
|
+
basis = explicitMain
|
|
543
|
+
} else {
|
|
544
|
+
basis = measure(
|
|
545
|
+
view,
|
|
546
|
+
main: nil,
|
|
547
|
+
cross: fixedCross,
|
|
548
|
+
mainLimit: max(0, (maxMain ?? roomMain) - mainLeading - mainTrailing),
|
|
549
|
+
crossLimit: max(0, (fixedCross ?? (maxCross ?? roomCross - crossLeading - crossTrailing)))
|
|
550
|
+
).main
|
|
551
|
+
}
|
|
552
|
+
return Item(
|
|
553
|
+
view: view,
|
|
554
|
+
style: style,
|
|
555
|
+
align: align,
|
|
556
|
+
mainLeading: mainLeading,
|
|
557
|
+
mainTrailing: mainTrailing,
|
|
558
|
+
crossLeading: crossLeading,
|
|
559
|
+
crossTrailing: crossTrailing,
|
|
560
|
+
minMain: minMain,
|
|
561
|
+
maxMain: maxMain,
|
|
562
|
+
minCross: minCross,
|
|
563
|
+
maxCross: maxCross,
|
|
564
|
+
fixedCross: fixedCross,
|
|
565
|
+
grow: style.flexGrow,
|
|
566
|
+
shrink: style.flexShrink ?? defaultShrink,
|
|
567
|
+
basis: clamp(basis, minMain, maxMain)
|
|
568
|
+
)
|
|
256
569
|
}
|
|
257
|
-
}
|
|
258
570
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
let
|
|
262
|
-
|
|
263
|
-
var
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
571
|
+
// Lines: one, or as many as wrapping needs.
|
|
572
|
+
var lines: [Range<Int>] = []
|
|
573
|
+
let lineLimit = definiteMain ?? roomMain
|
|
574
|
+
var start = 0
|
|
575
|
+
var used: CGFloat = 0
|
|
576
|
+
for index in items.indices {
|
|
577
|
+
let size = items[index].basis + items[index].mainMargins
|
|
578
|
+
let next = index == start ? size : used + mainGap + size
|
|
579
|
+
if wrap, index > start, next > lineLimit {
|
|
580
|
+
lines.append(start..<index)
|
|
581
|
+
start = index
|
|
582
|
+
used = size
|
|
583
|
+
} else {
|
|
584
|
+
used = next
|
|
585
|
+
}
|
|
267
586
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
587
|
+
if start < items.count { lines.append(start..<items.count) }
|
|
588
|
+
|
|
589
|
+
var lineMains: [CGFloat] = []
|
|
590
|
+
var lineCrosses: [CGFloat] = []
|
|
591
|
+
for line in lines {
|
|
592
|
+
let gaps = CGFloat(max(0, line.count - 1)) * mainGap
|
|
593
|
+
let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + gaps
|
|
594
|
+
// A row sized by its content is as wide as that content, up to the
|
|
595
|
+
// room it has; a column sized by its content is as tall as it is.
|
|
596
|
+
let lineMain = definiteMain ?? (horizontal ? min(hypothetical, roomMain) : hypothetical)
|
|
597
|
+
let free = lineMain - hypothetical
|
|
598
|
+
if free > 0 {
|
|
599
|
+
let total = line.reduce(0) { $0 + items[$1].grow }
|
|
600
|
+
for index in line {
|
|
601
|
+
let share = total > 0 ? free * items[index].grow / total : 0
|
|
602
|
+
items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
|
|
603
|
+
}
|
|
604
|
+
} else if free < 0 {
|
|
605
|
+
let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
|
|
606
|
+
for index in line {
|
|
607
|
+
let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
|
|
608
|
+
items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
|
|
609
|
+
}
|
|
610
|
+
} else {
|
|
611
|
+
for index in line { items[index].main = items[index].basis }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
var lineCross: CGFloat = 0
|
|
615
|
+
for index in line {
|
|
616
|
+
let item = items[index]
|
|
617
|
+
if let fixedCross = item.fixedCross {
|
|
618
|
+
items[index].cross = fixedCross
|
|
619
|
+
} else {
|
|
620
|
+
let measured = measure(
|
|
621
|
+
item.view,
|
|
622
|
+
main: item.main,
|
|
623
|
+
cross: nil,
|
|
624
|
+
mainLimit: item.main,
|
|
625
|
+
crossLimit: max(0, (item.maxCross ?? roomCross) - item.crossMargins)
|
|
626
|
+
)
|
|
627
|
+
items[index].cross = clamp(measured.cross, item.minCross, item.maxCross)
|
|
628
|
+
}
|
|
629
|
+
lineCross = max(lineCross, items[index].cross + item.crossMargins)
|
|
630
|
+
}
|
|
631
|
+
// A single line is as thick as the container when the container's
|
|
632
|
+
// size is known: stretched children fill it, others align in it.
|
|
633
|
+
if !wrap, let definiteCross { lineCross = definiteCross }
|
|
634
|
+
for index in line where items[index].fixedCross == nil && items[index].align == "stretch" {
|
|
635
|
+
let item = items[index]
|
|
636
|
+
if case .some = (horizontal ? item.style.height : item.style.width) { continue }
|
|
637
|
+
items[index].cross = clamp(lineCross - item.crossMargins, item.minCross, item.maxCross)
|
|
638
|
+
}
|
|
639
|
+
lineMains.append(line.reduce(0) { $0 + items[$1].main + items[$1].mainMargins } + gaps)
|
|
640
|
+
lineCrosses.append(lineCross)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
let contentMain = definiteMain ?? (lineMains.max() ?? 0)
|
|
644
|
+
let contentCross = definiteCross
|
|
645
|
+
?? (lineCrosses.reduce(0, +) + CGFloat(max(0, lineCrosses.count - 1)) * crossGap)
|
|
646
|
+
|
|
647
|
+
if place {
|
|
648
|
+
var crossOffset: CGFloat = 0
|
|
649
|
+
for (lineIndex, line) in lines.enumerated() {
|
|
650
|
+
let free = max(0, contentMain - lineMains[lineIndex])
|
|
651
|
+
let count = line.count
|
|
652
|
+
let leading: CGFloat
|
|
653
|
+
let between: CGFloat
|
|
654
|
+
switch justifyContent {
|
|
655
|
+
case "center": leading = free / 2; between = 0
|
|
656
|
+
case "flex-end": leading = free; between = 0
|
|
657
|
+
case "space-between": leading = 0; between = count > 1 ? free / CGFloat(count - 1) : 0
|
|
658
|
+
case "space-around": leading = free / CGFloat(count * 2); between = free / CGFloat(count)
|
|
659
|
+
case "space-evenly": leading = free / CGFloat(count + 1); between = free / CGFloat(count + 1)
|
|
660
|
+
default: leading = 0; between = 0
|
|
661
|
+
}
|
|
662
|
+
let lineCross = lineCrosses[lineIndex]
|
|
663
|
+
var mainOffset = leading
|
|
664
|
+
for index in line {
|
|
665
|
+
let item = items[index]
|
|
666
|
+
let crossPosition: CGFloat
|
|
667
|
+
switch item.align {
|
|
668
|
+
case "center": crossPosition = item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
|
|
669
|
+
case "flex-end": crossPosition = lineCross - item.crossTrailing - item.cross
|
|
670
|
+
default: crossPosition = item.crossLeading
|
|
671
|
+
}
|
|
672
|
+
let mainPosition = mainOffset + item.mainLeading
|
|
673
|
+
let frame = horizontal
|
|
674
|
+
? CGRect(x: padding.left + mainPosition, y: padding.top + crossOffset + crossPosition, width: item.main, height: item.cross)
|
|
675
|
+
: CGRect(x: padding.left + crossOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
|
|
676
|
+
frames.append((item.view, frame))
|
|
677
|
+
mainOffset += item.mainMargins + item.main + mainGap + between
|
|
678
|
+
}
|
|
679
|
+
crossOffset += lineCross + crossGap
|
|
680
|
+
}
|
|
275
681
|
}
|
|
276
|
-
|
|
277
|
-
let height = padding.top + padding.bottom + rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
278
|
-
return CGSize(width: width, height: height)
|
|
682
|
+
return horizontal ? CGSize(width: contentMain, height: contentCross) : CGSize(width: contentCross, height: contentMain)
|
|
279
683
|
}
|
|
280
684
|
|
|
281
|
-
private func layoutGrid(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
685
|
+
private func layoutGrid(
|
|
686
|
+
_ children: [UIView],
|
|
687
|
+
innerWidth: CGFloat?,
|
|
688
|
+
roomWidth: CGFloat,
|
|
689
|
+
innerHeight: CGFloat?,
|
|
690
|
+
place: Bool,
|
|
691
|
+
frames: inout [(UIView, CGRect)]
|
|
692
|
+
) -> CGSize {
|
|
286
693
|
let columns = max(1, gridColumns)
|
|
287
694
|
let horizontalGap = columnGap ?? gap
|
|
288
695
|
let verticalGap = rowGap ?? gap
|
|
289
|
-
let
|
|
696
|
+
let gaps = CGFloat(columns - 1) * horizontalGap
|
|
697
|
+
var cellWidth: CGFloat
|
|
698
|
+
if let innerWidth {
|
|
699
|
+
cellWidth = max(0, (innerWidth - gaps) / CGFloat(columns))
|
|
700
|
+
} else {
|
|
701
|
+
// Sized by content: the widest cell, as long as the row fits.
|
|
702
|
+
let widest = children.map { child -> CGFloat in
|
|
703
|
+
let style = child.craftLayoutStyle
|
|
704
|
+
return CraftNativeFlexLayout.measure(child, width: style.width?.resolve(nil), height: nil, maxWidth: roomWidth, maxHeight: CraftNativeFlexLayout.unbounded).width
|
|
705
|
+
+ style.marginLeft + style.marginRight
|
|
706
|
+
}.max() ?? 0
|
|
707
|
+
cellWidth = min(widest, max(0, (roomWidth - gaps) / CGFloat(columns)))
|
|
708
|
+
}
|
|
290
709
|
var rowHeights: [CGFloat] = []
|
|
291
710
|
var sizes: [CGSize] = []
|
|
292
|
-
for (index, child) in
|
|
293
|
-
let style =
|
|
294
|
-
let
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
711
|
+
for (index, child) in children.enumerated() {
|
|
712
|
+
let style = child.craftLayoutStyle
|
|
713
|
+
let available = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
714
|
+
let align = style.alignSelf ?? alignItems
|
|
715
|
+
let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
|
|
716
|
+
let size = CraftNativeFlexLayout.measure(
|
|
717
|
+
child,
|
|
718
|
+
width: fixedWidth.map { min($0, available) },
|
|
719
|
+
height: style.height?.resolve(nil),
|
|
720
|
+
maxWidth: available,
|
|
721
|
+
maxHeight: CraftNativeFlexLayout.unbounded
|
|
298
722
|
)
|
|
299
723
|
sizes.append(size)
|
|
300
724
|
let row = index / columns
|
|
301
725
|
while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
|
|
302
726
|
rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
|
|
303
727
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
guard style.position == "absolute" else { continue }
|
|
326
|
-
let size = measuredSize(for: child, available: content.size, style: style)
|
|
327
|
-
let width = style.left != nil && style.right != nil ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0)) : size.width
|
|
328
|
-
let height = style.top != nil && style.bottom != nil ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0)) : size.height
|
|
329
|
-
let x = style.left ?? (style.right.map { content.width - $0 - width } ?? 0)
|
|
330
|
-
let y = style.top ?? (style.bottom.map { content.height - $0 - height } ?? 0)
|
|
331
|
-
child.frame = CGRect(x: content.minX + x, y: content.minY + y, width: width, height: height).integral
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
private var mainGapValue: CGFloat { axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap) }
|
|
336
|
-
private var crossGapValue: CGFloat { axis == .horizontal ? (rowGap ?? 0) : (columnGap ?? 0) }
|
|
337
|
-
|
|
338
|
-
private func mainMargins(_ style: CraftNativeLayoutStyle) -> CGFloat {
|
|
339
|
-
axis == .horizontal ? style.marginLeft + style.marginRight : style.marginTop + style.marginBottom
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
private func crossMargins(_ style: CraftNativeLayoutStyle) -> CGFloat {
|
|
343
|
-
axis == .horizontal ? style.marginTop + style.marginBottom : style.marginLeft + style.marginRight
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
private func styleCrossDimension(_ style: CraftNativeLayoutStyle) -> CGFloat? {
|
|
347
|
-
axis == .horizontal ? style.height : style.width
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
private func measuredSize(for view: UIView, available: CGSize, style: CraftNativeLayoutStyle? = nil) -> CGSize {
|
|
351
|
-
let style = style ?? childStyles[ObjectIdentifier(view)] ?? CraftNativeLayoutStyle([:])
|
|
352
|
-
let intrinsic = view.intrinsicContentSize
|
|
353
|
-
let fitted = view.sizeThatFits(available)
|
|
354
|
-
let width = (axis == .horizontal ? style.flexBasis : nil) ?? style.width ?? (intrinsic.width > 0 && intrinsic.width != UIView.noIntrinsicMetric ? intrinsic.width : max(0, fitted.width))
|
|
355
|
-
let height = (axis == .vertical ? style.flexBasis : nil) ?? style.height ?? (intrinsic.height > 0 && intrinsic.height != UIView.noIntrinsicMetric ? intrinsic.height : max(0, fitted.height))
|
|
356
|
-
return CGSize(width: clamp(width, min: style.minWidth, max: style.maxWidth), height: clamp(height, min: style.minHeight, max: style.maxHeight))
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
private func distributeMainAxisSizes(
|
|
360
|
-
_ sizes: [CGSize],
|
|
361
|
-
styles: [CraftNativeLayoutStyle],
|
|
362
|
-
available: CGFloat,
|
|
363
|
-
lineMain: CGFloat
|
|
364
|
-
) -> [CGSize] {
|
|
365
|
-
let free = available - lineMain
|
|
366
|
-
let factors = free >= 0 ? styles.map(\.flexGrow) : styles.map(\.flexShrink)
|
|
367
|
-
let total = factors.reduce(0, +)
|
|
368
|
-
guard total > 0, free != 0 else { return sizes }
|
|
369
|
-
return sizes.enumerated().map { index, size in
|
|
370
|
-
let delta = free * factors[index] / total
|
|
371
|
-
let style = styles[index]
|
|
372
|
-
if axis == .horizontal {
|
|
373
|
-
return CGSize(width: clamp(max(0, size.width + delta), min: style.minWidth, max: style.maxWidth), height: size.height)
|
|
728
|
+
let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
|
|
729
|
+
let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
730
|
+
if place {
|
|
731
|
+
var rowTop = padding.top
|
|
732
|
+
for (index, child) in children.enumerated() {
|
|
733
|
+
let style = child.craftLayoutStyle
|
|
734
|
+
let row = index / columns
|
|
735
|
+
let column = index % columns
|
|
736
|
+
let rowHeight = rowHeights[row]
|
|
737
|
+
let size = sizes[index]
|
|
738
|
+
let align = style.alignSelf ?? alignItems
|
|
739
|
+
let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
740
|
+
let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
|
|
741
|
+
let childWidth = min(availableWidth, size.width)
|
|
742
|
+
let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
|
|
743
|
+
let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
|
|
744
|
+
+ (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
|
|
745
|
+
let y = rowTop + style.marginTop
|
|
746
|
+
+ (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
|
|
747
|
+
frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
|
|
748
|
+
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + verticalGap }
|
|
374
749
|
}
|
|
375
|
-
return CGSize(width: size.width, height: clamp(max(0, size.height + delta), min: style.minHeight, max: style.maxHeight))
|
|
376
750
|
}
|
|
751
|
+
return CGSize(width: width, height: innerHeight ?? height)
|
|
377
752
|
}
|
|
753
|
+
}
|
|
378
754
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
755
|
+
private final class CraftNativeModalView: UIView {
|
|
756
|
+
let contentStack = CraftNativeFlowView()
|
|
757
|
+
private let blocker = UIView()
|
|
758
|
+
private var renderedVisible = false
|
|
759
|
+
var onRequestClose: (() -> Void)?
|
|
760
|
+
|
|
761
|
+
var transparent = false {
|
|
762
|
+
didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
|
|
384
763
|
}
|
|
385
764
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
765
|
+
override init(frame: CGRect) {
|
|
766
|
+
super.init(frame: frame)
|
|
767
|
+
isUserInteractionEnabled = true
|
|
768
|
+
blocker.isUserInteractionEnabled = true
|
|
769
|
+
blocker.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
|
770
|
+
blocker.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blockerTapped)))
|
|
771
|
+
addSubview(blocker)
|
|
772
|
+
contentStack.backgroundColor = .clear
|
|
773
|
+
addSubview(contentStack)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
required init?(coder: NSCoder) { nil }
|
|
777
|
+
|
|
778
|
+
func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
|
|
779
|
+
guard renderedVisible != visible || isHidden != !visible else { return }
|
|
780
|
+
renderedVisible = visible
|
|
781
|
+
guard visible else {
|
|
782
|
+
isHidden = true
|
|
783
|
+
alpha = 0
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
isHidden = false
|
|
787
|
+
if animationType == "none" {
|
|
788
|
+
alpha = opacity
|
|
789
|
+
} else {
|
|
790
|
+
alpha = 0
|
|
791
|
+
UIView.animate(withDuration: animationType == "slide" ? 0.24 : 0.18) { self.alpha = opacity }
|
|
395
792
|
}
|
|
396
793
|
}
|
|
794
|
+
|
|
795
|
+
@objc private func blockerTapped() { onRequestClose?() }
|
|
796
|
+
|
|
797
|
+
override func layoutSubviews() {
|
|
798
|
+
super.layoutSubviews()
|
|
799
|
+
blocker.frame = bounds
|
|
800
|
+
contentStack.frame = bounds
|
|
801
|
+
}
|
|
397
802
|
}
|
|
398
803
|
|
|
399
|
-
private final class CraftNativeScrollView: UIScrollView {
|
|
804
|
+
private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
|
|
400
805
|
let contentStack = CraftNativeFlowView()
|
|
401
|
-
private
|
|
806
|
+
private let keyboardTapGesture = UITapGestureRecognizer()
|
|
807
|
+
private var keyboardShouldPersistTaps = "never"
|
|
808
|
+
private var contentNeedsLayout = true
|
|
809
|
+
private var laidOutSize: CGSize = .zero
|
|
810
|
+
private let pullToRefresh = UIRefreshControl()
|
|
811
|
+
private var refreshHandler: (() -> Void)?
|
|
402
812
|
|
|
403
813
|
override init(frame: CGRect) {
|
|
404
814
|
super.init(frame: frame)
|
|
405
|
-
|
|
815
|
+
keyboardTapGesture.cancelsTouchesInView = false
|
|
816
|
+
keyboardTapGesture.delegate = self
|
|
817
|
+
keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
|
|
818
|
+
addGestureRecognizer(keyboardTapGesture)
|
|
819
|
+
pullToRefresh.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
|
406
820
|
addSubview(contentStack)
|
|
407
|
-
NSLayoutConstraint.activate([
|
|
408
|
-
contentStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
|
|
409
|
-
contentStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
|
|
410
|
-
contentStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
|
|
411
|
-
contentStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor)
|
|
412
|
-
])
|
|
413
821
|
setAxis(.vertical)
|
|
414
822
|
}
|
|
415
823
|
|
|
416
824
|
required init?(coder: NSCoder) { nil }
|
|
417
825
|
|
|
826
|
+
var isVertical: Bool { contentStack.axis == .vertical }
|
|
827
|
+
|
|
828
|
+
/// Lays the content out again on the next pass. Scrolling alone does not:
|
|
829
|
+
/// a scroll view lays out on every frame of a scroll.
|
|
830
|
+
func setContentNeedsLayout() {
|
|
831
|
+
contentNeedsLayout = true
|
|
832
|
+
setNeedsLayout()
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/// The size of the content laid out across the visible width (or, sideways,
|
|
836
|
+
/// height), which is also the scroll view's own size when nothing fixes it.
|
|
837
|
+
func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
|
|
838
|
+
if isVertical {
|
|
839
|
+
let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
|
|
840
|
+
return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
|
|
841
|
+
}
|
|
842
|
+
let contentHeight = height.map { max(0, $0 - adjustedContentInset.top - adjustedContentInset.bottom) }
|
|
843
|
+
return contentStack.measure(width: nil, height: contentHeight, maxWidth: CraftNativeFlexLayout.unbounded, maxHeight: contentHeight ?? CraftNativeFlexLayout.unbounded)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
847
|
+
if isVertical {
|
|
848
|
+
let resolvedWidth = width ?? maxWidth
|
|
849
|
+
return CGSize(width: resolvedWidth, height: height ?? contentFitting(width: resolvedWidth, height: nil).height)
|
|
850
|
+
}
|
|
851
|
+
let content = contentFitting(width: nil, height: height)
|
|
852
|
+
return CGSize(width: width ?? min(content.width, maxWidth), height: height ?? content.height)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
override func layoutSubviews() {
|
|
856
|
+
super.layoutSubviews()
|
|
857
|
+
craftApplyCornerRadius()
|
|
858
|
+
if contentNeedsLayout || laidOutSize != bounds.size {
|
|
859
|
+
contentNeedsLayout = false
|
|
860
|
+
laidOutSize = bounds.size
|
|
861
|
+
var size = contentFitting(width: bounds.width, height: bounds.height)
|
|
862
|
+
if isVertical {
|
|
863
|
+
size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
|
|
864
|
+
} else {
|
|
865
|
+
size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
|
|
866
|
+
}
|
|
867
|
+
let frame = CGRect(origin: .zero, size: size)
|
|
868
|
+
if contentStack.frame != frame { contentStack.frame = frame }
|
|
869
|
+
if contentSize != size { contentSize = size }
|
|
870
|
+
}
|
|
871
|
+
// A scroll view lays out on every frame of a scroll: the sticky
|
|
872
|
+
// headers follow it here, over content placed by this same pass.
|
|
873
|
+
if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
|
|
874
|
+
updateStickyHeaders()
|
|
875
|
+
scrollToPendingTarget()
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// MARK: Sticky headers
|
|
879
|
+
|
|
880
|
+
private var stickyIndices: [Int] = []
|
|
881
|
+
private var stickyViews: [UIView] = []
|
|
882
|
+
|
|
883
|
+
/// `stickyHeaderIndices`, as in React Native: the content's children at
|
|
884
|
+
/// these indices stay at the top of the visible area once scrolled to,
|
|
885
|
+
/// each pushed up by the next. Vertical scroll views only.
|
|
886
|
+
func setStickyHeaderIndices(_ value: Any?) {
|
|
887
|
+
let next = Array(Set(((value as? [Any]) ?? []).compactMap { ($0 as? NSNumber)?.intValue }.filter { $0 >= 0 })).sorted()
|
|
888
|
+
guard next != stickyIndices else { return }
|
|
889
|
+
stickyIndices = next
|
|
890
|
+
setNeedsLayout()
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/// Where a header sits in the content when it is not stuck.
|
|
894
|
+
private func naturalTop(_ view: UIView) -> CGFloat {
|
|
895
|
+
view.center.y - view.bounds.height / 2
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private func updateStickyHeaders() {
|
|
899
|
+
let children = contentStack.arrangedSubviews
|
|
900
|
+
let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
|
|
901
|
+
for view in stickyViews where !headers.contains(where: { $0 === view }) {
|
|
902
|
+
view.transform = .identity
|
|
903
|
+
view.layer.zPosition = 0
|
|
904
|
+
}
|
|
905
|
+
stickyViews = headers
|
|
906
|
+
guard !headers.isEmpty else { return }
|
|
907
|
+
let visibleTop = contentOffset.y + adjustedContentInset.top
|
|
908
|
+
for (index, header) in headers.enumerated() {
|
|
909
|
+
let natural = naturalTop(header)
|
|
910
|
+
var top = max(natural, visibleTop)
|
|
911
|
+
if index + 1 < headers.count {
|
|
912
|
+
top = max(natural, min(top, naturalTop(headers[index + 1]) - header.bounds.height))
|
|
913
|
+
}
|
|
914
|
+
let shift = CGAffineTransform(translationX: 0, y: top - natural)
|
|
915
|
+
if header.transform != shift { header.transform = shift }
|
|
916
|
+
// Above the content it covers, for drawing and for touches.
|
|
917
|
+
header.layer.zPosition = 1
|
|
918
|
+
if contentStack.subviews.last !== header { contentStack.bringSubviewToFront(header) }
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/// The height a stuck header covers above a point of the content.
|
|
923
|
+
private func stickyHeight(above y: CGFloat) -> CGFloat {
|
|
924
|
+
stickyViews.last(where: { naturalTop($0) <= y })?.bounds.height ?? 0
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// MARK: Scroll targets
|
|
928
|
+
|
|
929
|
+
private var scrollTargetKey: String?
|
|
930
|
+
private var pendingScrollTarget: (id: String, animated: Bool, attempts: Int)?
|
|
931
|
+
|
|
932
|
+
/// `scrollTarget`: `"<testID>"` or `{ id, animated?, key? }`. When it
|
|
933
|
+
/// changes, the content scrolls so the view with that `testID` sits at
|
|
934
|
+
/// the top of the visible area, below any sticky header. A new `key`
|
|
935
|
+
/// scrolls to the same view again; `null` forgets the last target.
|
|
936
|
+
func setScrollTarget(_ value: Any?) {
|
|
937
|
+
var id: String?
|
|
938
|
+
var animated = true
|
|
939
|
+
if let text = value as? String {
|
|
940
|
+
id = text
|
|
941
|
+
} else if let target = value as? [String: Any] {
|
|
942
|
+
id = target["id"] as? String
|
|
943
|
+
animated = target["animated"] as? Bool ?? true
|
|
944
|
+
}
|
|
945
|
+
guard let id, !id.isEmpty else {
|
|
946
|
+
scrollTargetKey = nil
|
|
947
|
+
pendingScrollTarget = nil
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
let key: String
|
|
951
|
+
if let object = value as? [String: Any],
|
|
952
|
+
let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) {
|
|
953
|
+
key = String(decoding: data, as: UTF8.self)
|
|
954
|
+
} else {
|
|
955
|
+
key = id
|
|
956
|
+
}
|
|
957
|
+
guard key != scrollTargetKey else { return }
|
|
958
|
+
scrollTargetKey = key
|
|
959
|
+
pendingScrollTarget = (id, animated, 0)
|
|
960
|
+
setNeedsLayout()
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private func descendant(withIdentifier id: String, in view: UIView) -> UIView? {
|
|
964
|
+
for child in view.subviews {
|
|
965
|
+
if child.accessibilityIdentifier == id { return child }
|
|
966
|
+
if let match = descendant(withIdentifier: id, in: child) { return match }
|
|
967
|
+
}
|
|
968
|
+
return nil
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private func scrollToPendingTarget() {
|
|
972
|
+
guard let target = pendingScrollTarget, bounds.height > 0 else { return }
|
|
973
|
+
pendingScrollTarget = nil
|
|
974
|
+
let view = descendant(withIdentifier: target.id, in: contentStack)
|
|
975
|
+
// A target the same render created may not be in place yet (this pass
|
|
976
|
+
// can run while the render is applied): try again on the next passes.
|
|
977
|
+
guard let view, view.bounds.size != .zero else {
|
|
978
|
+
if target.attempts < 3 {
|
|
979
|
+
pendingScrollTarget = (target.id, target.animated, target.attempts + 1)
|
|
980
|
+
DispatchQueue.main.async { [weak self] in self?.setNeedsLayout() }
|
|
981
|
+
}
|
|
982
|
+
return
|
|
983
|
+
}
|
|
984
|
+
let rect = view.convert(view.bounds, to: contentStack)
|
|
985
|
+
if isVertical {
|
|
986
|
+
let lowest = -adjustedContentInset.top
|
|
987
|
+
let highest = max(lowest, contentSize.height + adjustedContentInset.bottom - bounds.height)
|
|
988
|
+
let y = min(highest, max(lowest, rect.minY - adjustedContentInset.top - stickyHeight(above: rect.minY)))
|
|
989
|
+
setContentOffset(CGPoint(x: contentOffset.x, y: y), animated: target.animated)
|
|
990
|
+
} else {
|
|
991
|
+
let lowest = -adjustedContentInset.left
|
|
992
|
+
let highest = max(lowest, contentSize.width + adjustedContentInset.right - bounds.width)
|
|
993
|
+
let x = min(highest, max(lowest, rect.minX - adjustedContentInset.left))
|
|
994
|
+
setContentOffset(CGPoint(x: x, y: contentOffset.y), animated: target.animated)
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
override func adjustedContentInsetDidChange() {
|
|
999
|
+
super.adjustedContentInsetDidChange()
|
|
1000
|
+
setContentNeedsLayout()
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private var refreshingProp = false
|
|
1004
|
+
|
|
1005
|
+
/// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
|
|
1006
|
+
/// as in React Native. The spinner shows while `refreshing` is true; a
|
|
1007
|
+
/// pull that the screen does not answer with `refreshing` true ends at
|
|
1008
|
+
/// once instead of spinning forever.
|
|
1009
|
+
func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
|
|
1010
|
+
refreshHandler = handler
|
|
1011
|
+
refreshingProp = refreshing
|
|
1012
|
+
if handler == nil {
|
|
1013
|
+
if pullToRefresh.isRefreshing { pullToRefresh.endRefreshing() }
|
|
1014
|
+
if refreshControl === pullToRefresh { refreshControl = nil }
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
if refreshControl !== pullToRefresh { refreshControl = pullToRefresh }
|
|
1018
|
+
if refreshing, !pullToRefresh.isRefreshing {
|
|
1019
|
+
pullToRefresh.beginRefreshing()
|
|
1020
|
+
} else if !refreshing, pullToRefresh.isRefreshing {
|
|
1021
|
+
pullToRefresh.endRefreshing()
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
|
|
1026
|
+
|
|
1027
|
+
@objc private func refreshPulled() {
|
|
1028
|
+
refreshHandler?()
|
|
1029
|
+
// The handler's synchronous render has landed by now.
|
|
1030
|
+
DispatchQueue.main.async { [weak self] in
|
|
1031
|
+
guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
|
|
1032
|
+
self.pullToRefresh.endRefreshing()
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
func setKeyboardShouldPersistTaps(_ value: Any?) {
|
|
1037
|
+
let requested = value as? String
|
|
1038
|
+
keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
|
|
1039
|
+
keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
@objc private func keyboardTap() {
|
|
1043
|
+
guard keyboardShouldPersistTaps != "always" else { return }
|
|
1044
|
+
if keyboardShouldPersistTaps == "handled" {
|
|
1045
|
+
let point = keyboardTapGesture.location(in: self)
|
|
1046
|
+
var hit = hitTest(point, with: nil)
|
|
1047
|
+
while let view = hit, view !== self {
|
|
1048
|
+
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
|
|
1049
|
+
hit = view.superview
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
window?.endEditing(true)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1056
|
+
true
|
|
1057
|
+
}
|
|
1058
|
+
|
|
418
1059
|
func setAxis(_ axis: NSLayoutConstraint.Axis) {
|
|
1060
|
+
if contentStack.axis != axis { setContentNeedsLayout() }
|
|
419
1061
|
contentStack.axis = axis
|
|
420
|
-
crossAxisConstraint?.isActive = false
|
|
421
|
-
crossAxisConstraint = axis == .vertical
|
|
422
|
-
? contentStack.widthAnchor.constraint(equalTo: frameLayoutGuide.widthAnchor)
|
|
423
|
-
: contentStack.heightAnchor.constraint(equalTo: frameLayoutGuide.heightAnchor)
|
|
424
|
-
crossAxisConstraint?.isActive = true
|
|
425
1062
|
alwaysBounceVertical = axis == .vertical
|
|
426
1063
|
alwaysBounceHorizontal = axis == .horizontal
|
|
427
1064
|
}
|
|
428
1065
|
}
|
|
429
1066
|
|
|
1067
|
+
/// Text with padding of its own: the padding is inside the label, around
|
|
1068
|
+
/// the text, and counts in its measured size.
|
|
1069
|
+
private final class CraftNativeLabel: UILabel {
|
|
1070
|
+
var insets = UIEdgeInsets.zero {
|
|
1071
|
+
didSet {
|
|
1072
|
+
guard insets != oldValue else { return }
|
|
1073
|
+
invalidateIntrinsicContentSize()
|
|
1074
|
+
setNeedsDisplay()
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
override func drawText(in rect: CGRect) {
|
|
1079
|
+
super.drawText(in: rect.inset(by: insets))
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect {
|
|
1083
|
+
guard insets != .zero else { return super.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) }
|
|
1084
|
+
let inner = super.textRect(forBounds: bounds.inset(by: insets), limitedToNumberOfLines: numberOfLines)
|
|
1085
|
+
return CGRect(
|
|
1086
|
+
x: inner.minX - insets.left,
|
|
1087
|
+
y: inner.minY - insets.top,
|
|
1088
|
+
width: inner.width + insets.left + insets.right,
|
|
1089
|
+
height: inner.height + insets.top + insets.bottom
|
|
1090
|
+
)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
override func layoutSubviews() {
|
|
1094
|
+
super.layoutSubviews()
|
|
1095
|
+
craftApplyCornerRadius()
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
430
1099
|
private final class CraftNativeFlexSpacer: UIView {}
|
|
431
1100
|
|
|
1101
|
+
/// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
|
|
1102
|
+
/// when the style gives it no size at all.
|
|
1103
|
+
private final class CraftNativeIconView: UIImageView {
|
|
1104
|
+
var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
|
|
1105
|
+
|
|
1106
|
+
override var intrinsicContentSize: CGSize {
|
|
1107
|
+
if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
|
|
1108
|
+
return super.intrinsicContentSize
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
432
1112
|
private final class CraftNativeTextView: UITextView {
|
|
433
1113
|
private let placeholderLabel = UILabel()
|
|
434
1114
|
var placeholder: String? {
|
|
@@ -482,7 +1162,7 @@ private final class CraftNativeTextView: UITextView {
|
|
|
482
1162
|
}
|
|
483
1163
|
}
|
|
484
1164
|
|
|
485
|
-
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
|
|
1165
|
+
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
|
|
486
1166
|
private final class RenderedNode {
|
|
487
1167
|
let identity: String
|
|
488
1168
|
let type: String
|
|
@@ -506,6 +1186,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
506
1186
|
private let jsContext = JSContext()!
|
|
507
1187
|
private let rootStack = CraftNativeFlowView()
|
|
508
1188
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
1189
|
+
private var pressInHandlers: [ObjectIdentifier: String] = [:]
|
|
1190
|
+
private var pressOutHandlers: [ObjectIdentifier: String] = [:]
|
|
509
1191
|
private var focusHandlers: [ObjectIdentifier: String] = [:]
|
|
510
1192
|
private var blurHandlers: [ObjectIdentifier: String] = [:]
|
|
511
1193
|
private var endEditingHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -521,10 +1203,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
521
1203
|
private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
|
|
522
1204
|
private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
|
|
523
1205
|
private var sliderSteps: [ObjectIdentifier: Float] = [:]
|
|
1206
|
+
private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
|
|
1207
|
+
private var modalVisibility: [ObjectIdentifier: Bool] = [:]
|
|
524
1208
|
private var layoutHandlers: [ObjectIdentifier: String] = [:]
|
|
525
1209
|
private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
|
|
526
1210
|
private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
|
|
527
1211
|
private var longPressRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
|
|
1212
|
+
private var pressFeedbackRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
|
|
528
1213
|
private var imageSources: [ObjectIdentifier: String] = [:]
|
|
529
1214
|
private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
|
|
530
1215
|
private var imageErrors: [ObjectIdentifier: String] = [:]
|
|
@@ -541,8 +1226,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
541
1226
|
private let capabilityScope = UUID().uuidString
|
|
542
1227
|
private var pendingCapabilityRequests = Set<String>()
|
|
543
1228
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
1229
|
+
private var pendingTimers: [Int: DispatchWorkItem] = [:]
|
|
544
1230
|
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
545
1231
|
private var deepLinkListener: UUID?
|
|
1232
|
+
private var keyboardSafeAreaInset: CGFloat = 0
|
|
1233
|
+
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
1234
|
+
/// app's native screens: told of the first frame, of a first frame that
|
|
1235
|
+
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
1236
|
+
weak var hybridEvents: CraftHybridScreenEvents?
|
|
1237
|
+
private var hybridRendered = false
|
|
546
1238
|
|
|
547
1239
|
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
|
|
548
1240
|
self.config = config
|
|
@@ -558,6 +1250,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
558
1250
|
deinit {
|
|
559
1251
|
imageTasks.values.forEach { $0.cancel() }
|
|
560
1252
|
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
1253
|
+
pendingTimers.values.forEach { $0.cancel() }
|
|
561
1254
|
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
562
1255
|
lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
|
|
563
1256
|
if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
|
|
@@ -571,8 +1264,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
571
1264
|
rootStack.alignItems = "stretch"
|
|
572
1265
|
rootStack.translatesAutoresizingMaskIntoConstraints = false
|
|
573
1266
|
view.addSubview(rootStack)
|
|
1267
|
+
rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
|
|
1268
|
+
rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
|
|
574
1269
|
NSLayoutConstraint.activate([
|
|
575
|
-
|
|
1270
|
+
rootTopToSafeArea!,
|
|
576
1271
|
rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
577
1272
|
rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
578
1273
|
rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
|
|
@@ -586,10 +1281,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
586
1281
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
587
1282
|
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
|
|
588
1283
|
refreshTraitDefaults()
|
|
1284
|
+
send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
|
|
589
1285
|
}
|
|
590
1286
|
|
|
591
1287
|
override func viewWillAppear(_ animated: Bool) {
|
|
592
1288
|
super.viewWillAppear(animated)
|
|
1289
|
+
if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
|
|
1290
|
+
navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
|
|
1291
|
+
} else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
|
|
1292
|
+
navigationController?.viewControllers.first !== self || wantsNavigationBar {
|
|
1293
|
+
// A screen that did not ask for a hidden bar shows one, even after
|
|
1294
|
+
// a screen that hid its own.
|
|
1295
|
+
navigationController?.setNavigationBarHidden(false, animated: animated)
|
|
1296
|
+
}
|
|
593
1297
|
refreshTraitDefaults()
|
|
594
1298
|
}
|
|
595
1299
|
|
|
@@ -619,13 +1323,34 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
619
1323
|
jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
620
1324
|
}
|
|
621
1325
|
jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
622
|
-
jsContext.exceptionHandler = {
|
|
623
|
-
|
|
1326
|
+
jsContext.exceptionHandler = { [weak self] context, exception in
|
|
1327
|
+
let message = exception?.toString() ?? "unknown"
|
|
1328
|
+
let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
|
|
1329
|
+
CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
|
|
1330
|
+
self?.hybridFailed(message)
|
|
624
1331
|
}
|
|
625
1332
|
let postMessage: @convention(block) (String) -> Void = { [weak self] json in
|
|
626
1333
|
self?.receive(json)
|
|
627
1334
|
}
|
|
628
1335
|
jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
1336
|
+
// JavaScriptCore has no timers of its own. The callbacks stay in
|
|
1337
|
+
// JavaScript; native only owns the main-queue deadline, so route
|
|
1338
|
+
// teardown cancels every pending timer with the controller.
|
|
1339
|
+
let scheduleTimer: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
|
|
1340
|
+
guard let self = self else { return }
|
|
1341
|
+
let work = DispatchWorkItem { [weak self] in
|
|
1342
|
+
guard let self = self, self.pendingTimers.removeValue(forKey: id) != nil else { return }
|
|
1343
|
+
self.jsContext.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
|
|
1344
|
+
}
|
|
1345
|
+
self.pendingTimers[id] = work
|
|
1346
|
+
let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
|
|
1347
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
|
|
1348
|
+
}
|
|
1349
|
+
let cancelTimer: @convention(block) (Int) -> Void = { [weak self] id in
|
|
1350
|
+
self?.pendingTimers.removeValue(forKey: id)?.cancel()
|
|
1351
|
+
}
|
|
1352
|
+
jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
|
|
1353
|
+
jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
|
|
629
1354
|
let capabilities = CraftNativeActions.capabilities(config: config)
|
|
630
1355
|
let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
|
|
631
1356
|
let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
@@ -638,12 +1363,212 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
638
1363
|
capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
|
|
639
1364
|
capabilities: \(capabilityJSON),
|
|
640
1365
|
initialAppState: "\(CraftNativeActions.currentAppState())",
|
|
1366
|
+
colorScheme: "\(colorScheme)",
|
|
641
1367
|
postMessage: function(message) { craftNativePostMessage(message); },
|
|
642
1368
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
643
1369
|
};
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
1370
|
+
""")
|
|
1371
|
+
installHostAPIs()
|
|
1372
|
+
jsContext.evaluateScript("""
|
|
1373
|
+
if (typeof globalThis.setTimeout !== 'function') {
|
|
1374
|
+
(function() {
|
|
1375
|
+
var timers = new Map();
|
|
1376
|
+
var nextTimer = 0;
|
|
1377
|
+
globalThis.setTimeout = function(callback, delay) {
|
|
1378
|
+
var args = Array.prototype.slice.call(arguments, 2);
|
|
1379
|
+
var id = ++nextTimer;
|
|
1380
|
+
timers.set(id, function() {
|
|
1381
|
+
if (typeof callback === 'function') callback.apply(undefined, args);
|
|
1382
|
+
});
|
|
1383
|
+
craftNativeScheduleTimer(id, Number(delay) || 0);
|
|
1384
|
+
return id;
|
|
1385
|
+
};
|
|
1386
|
+
globalThis.clearTimeout = function(id) {
|
|
1387
|
+
if (timers.delete(id)) craftNativeCancelTimer(id);
|
|
1388
|
+
};
|
|
1389
|
+
globalThis.__craftNativeFireTimer = function(id) {
|
|
1390
|
+
var callback = timers.get(id);
|
|
1391
|
+
if (!callback) return;
|
|
1392
|
+
timers.delete(id);
|
|
1393
|
+
callback();
|
|
1394
|
+
};
|
|
1395
|
+
})();
|
|
1396
|
+
}
|
|
1397
|
+
// Repeating timers, on the one-shot ones: each tick schedules the
|
|
1398
|
+
// next before running, so a callback that throws or clears itself
|
|
1399
|
+
// still behaves, and a screen that closes cancels them with the rest.
|
|
1400
|
+
if (typeof globalThis.setInterval !== 'function') {
|
|
1401
|
+
(function() {
|
|
1402
|
+
var intervals = new Map();
|
|
1403
|
+
var nextInterval = 0;
|
|
1404
|
+
globalThis.setInterval = function(callback, delay) {
|
|
1405
|
+
var args = Array.prototype.slice.call(arguments, 2);
|
|
1406
|
+
var id = ++nextInterval;
|
|
1407
|
+
var ms = Math.max(Number(delay) || 0, 1);
|
|
1408
|
+
var tick = function() {
|
|
1409
|
+
if (!intervals.has(id)) return;
|
|
1410
|
+
intervals.set(id, globalThis.setTimeout(tick, ms));
|
|
1411
|
+
if (typeof callback === 'function') callback.apply(undefined, args);
|
|
1412
|
+
};
|
|
1413
|
+
intervals.set(id, globalThis.setTimeout(tick, ms));
|
|
1414
|
+
return id;
|
|
1415
|
+
};
|
|
1416
|
+
globalThis.clearInterval = function(id) {
|
|
1417
|
+
var timer = intervals.get(id);
|
|
1418
|
+
if (timer === undefined) return;
|
|
1419
|
+
intervals.delete(id);
|
|
1420
|
+
globalThis.clearTimeout(timer);
|
|
1421
|
+
};
|
|
1422
|
+
})();
|
|
1423
|
+
}
|
|
1424
|
+
""")
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
|
|
1428
|
+
/// the window's appearance once there is one, else the app's setting.
|
|
1429
|
+
private var colorScheme: String {
|
|
1430
|
+
let traits = [view.window?.traitCollection, CraftPresenter.keyWindow()?.traitCollection, traitCollection]
|
|
1431
|
+
if let style = traits.compactMap({ $0 }).first(where: { $0.userInterfaceStyle != .unspecified })?.userInterfaceStyle {
|
|
1432
|
+
return style == .dark ? "dark" : "light"
|
|
1433
|
+
}
|
|
1434
|
+
return config.appearance == "dark" ? "dark" : "light"
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/// The screen's name as its script knows it: the route the host opened,
|
|
1438
|
+
/// else the one a route bundle selected for itself.
|
|
1439
|
+
private func screenName(in context: JSContext? = nil) -> String {
|
|
1440
|
+
if let routeName, !routeName.isEmpty { return routeName }
|
|
1441
|
+
if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
|
|
1442
|
+
return selected.toString()
|
|
1443
|
+
}
|
|
1444
|
+
return "screen"
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/// Host functions every screen script can call synchronously, installed
|
|
1448
|
+
/// before the script runs (contract sections 3, 4 and 5):
|
|
1449
|
+
///
|
|
1450
|
+
/// - `console.*` writes to the unified log, subsystem `craft.native`,
|
|
1451
|
+
/// category the screen's name.
|
|
1452
|
+
/// - `craft.storage.getSync(key)` / `setSync(key, value)`: the store the
|
|
1453
|
+
/// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
|
|
1454
|
+
/// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
|
|
1455
|
+
/// promise): `Application Support/craft-snapshots/<name>.json`.
|
|
1456
|
+
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
1457
|
+
/// `craft.secureStorage.set` wrote.
|
|
1458
|
+
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
1459
|
+
/// rightButtons })`; a right button's tap is the event `navButton`.
|
|
1460
|
+
///
|
|
1461
|
+
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
1462
|
+
/// these: each namespace is a property whose setter adds them back to the
|
|
1463
|
+
/// object it is given.
|
|
1464
|
+
private func installHostAPIs() {
|
|
1465
|
+
let log: @convention(block) (String, String, String) -> Void = { level, category, message in
|
|
1466
|
+
CraftNativeConsole.write(level, category: category, message: message)
|
|
1467
|
+
}
|
|
1468
|
+
let storageGet: @convention(block) (String) -> String? = { key in
|
|
1469
|
+
CraftNativeActions.storageJSON(forKey: key)
|
|
1470
|
+
}
|
|
1471
|
+
let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
|
|
1472
|
+
CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
|
|
1473
|
+
}
|
|
1474
|
+
let snapshotGet: @convention(block) (String) -> String? = { name in
|
|
1475
|
+
CraftSnapshots.read(name)
|
|
1476
|
+
}
|
|
1477
|
+
let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
|
|
1478
|
+
CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
|
|
1479
|
+
done.call(withArguments: [ok])
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
let config = self.config
|
|
1483
|
+
let secureGet: @convention(block) (String) -> String? = { key in
|
|
1484
|
+
config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
|
|
1485
|
+
}
|
|
1486
|
+
let setOptions: @convention(block) (String) -> Void = { [weak self] json in
|
|
1487
|
+
guard let data = json.data(using: .utf8),
|
|
1488
|
+
let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
1489
|
+
self?.applyNavigationOptions(options)
|
|
1490
|
+
}
|
|
1491
|
+
jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
|
|
1492
|
+
jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
|
|
1493
|
+
jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
|
|
1494
|
+
jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
|
|
1495
|
+
jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
|
|
1496
|
+
jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
1497
|
+
jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
|
|
1498
|
+
jsContext.evaluateScript("""
|
|
1499
|
+
(function() {
|
|
1500
|
+
function text(value) {
|
|
1501
|
+
if (typeof value === 'string') return value;
|
|
1502
|
+
if (value === undefined) return 'undefined';
|
|
1503
|
+
if (value instanceof Error) return value.stack ? value.message + '\\n' + value.stack : String(value);
|
|
1504
|
+
try { var json = JSON.stringify(value); return json === undefined ? String(value) : json; }
|
|
1505
|
+
catch (error) { return String(value); }
|
|
1506
|
+
}
|
|
1507
|
+
function screen() {
|
|
1508
|
+
var route = globalThis.__stxNativeRoute;
|
|
1509
|
+
return typeof route === 'string' && route ? route : 'screen';
|
|
1510
|
+
}
|
|
1511
|
+
function writer(level) {
|
|
1512
|
+
return function() {
|
|
1513
|
+
craftNativeLog(level, screen(), Array.prototype.map.call(arguments, text).join(' '));
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
globalThis.console = {
|
|
1517
|
+
log: writer('log'), info: writer('info'), debug: writer('debug'), trace: writer('debug'),
|
|
1518
|
+
warn: writer('warn'), error: writer('error')
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
function parsed(json) {
|
|
1522
|
+
if (json === null || json === undefined) return null;
|
|
1523
|
+
try { return JSON.parse(json); } catch (error) { return null; }
|
|
1524
|
+
}
|
|
1525
|
+
function encoded(value) {
|
|
1526
|
+
return value === null || value === undefined ? null : JSON.stringify(value);
|
|
1527
|
+
}
|
|
1528
|
+
var craft = globalThis.craft = globalThis.craft || {};
|
|
1529
|
+
function keep(name, extras) {
|
|
1530
|
+
var current = {};
|
|
1531
|
+
function adopt(next) {
|
|
1532
|
+
current = next && typeof next === 'object' ? next : {};
|
|
1533
|
+
Object.keys(extras).forEach(function(key) {
|
|
1534
|
+
if (!(key in current)) current[key] = extras[key];
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
adopt(craft[name]);
|
|
1538
|
+
Object.defineProperty(craft, name, {
|
|
1539
|
+
configurable: true,
|
|
1540
|
+
enumerable: true,
|
|
1541
|
+
get: function() { return current; },
|
|
1542
|
+
set: adopt
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
keep('storage', {
|
|
1546
|
+
getSync: function(key) { return parsed(craftNativeStorageGet(String(key))); },
|
|
1547
|
+
setSync: function(key, value) {
|
|
1548
|
+
if (!craftNativeStorageSet(String(key), encoded(value))) throw new Error('craft.storage.setSync could not store ' + key);
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
keep('snapshots', {
|
|
1552
|
+
get: function(name) { return parsed(craftNativeSnapshotGet(String(name))); },
|
|
1553
|
+
set: function(name, value) {
|
|
1554
|
+
return new Promise(function(resolve, reject) {
|
|
1555
|
+
craftNativeSnapshotSet(String(name), encoded(value), function(ok) {
|
|
1556
|
+
if (ok) resolve();
|
|
1557
|
+
else reject(new Error('craft.snapshots.set could not write ' + name));
|
|
1558
|
+
});
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
keep('secureStorage', {
|
|
1563
|
+
getSync: function(key) {
|
|
1564
|
+
var value = craftNativeSecureGet(String(key));
|
|
1565
|
+
return value === undefined ? null : value;
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
keep('navigation', {
|
|
1569
|
+
setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
|
|
1570
|
+
});
|
|
1571
|
+
})();
|
|
647
1572
|
""")
|
|
648
1573
|
}
|
|
649
1574
|
|
|
@@ -651,15 +1576,123 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
651
1576
|
guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
|
|
652
1577
|
?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
|
|
653
1578
|
let script = try? String(contentsOf: url, encoding: .utf8) else {
|
|
654
|
-
showError("Missing dist/native-screen.js. Compile a .stx screen with stx
|
|
1579
|
+
showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
|
|
655
1580
|
return
|
|
656
1581
|
}
|
|
657
1582
|
jsContext.evaluateScript(script)
|
|
658
|
-
|
|
659
|
-
|
|
1583
|
+
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
1584
|
+
// its title is the app's, not the word "undefined". A title the
|
|
1585
|
+
// script set with `navigation.setOptions` wins over both.
|
|
1586
|
+
if routeName == nil, !titleFromOptions,
|
|
1587
|
+
let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
|
|
1588
|
+
let name = selected.toString(), !name.isEmpty {
|
|
1589
|
+
navigationItem.title = name
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// MARK: Navigation bar options
|
|
1594
|
+
|
|
1595
|
+
private var titleFromOptions = false
|
|
1596
|
+
private var rootTopToSafeArea: NSLayoutConstraint?
|
|
1597
|
+
private var rootTopToView: NSLayoutConstraint?
|
|
1598
|
+
private var navigationBarHidden: Bool?
|
|
1599
|
+
private var prefersLargeTitle: Bool?
|
|
1600
|
+
|
|
1601
|
+
/// Whether this screen asked for a bar of its own through `setOptions`: a
|
|
1602
|
+
/// title, a large title or buttons, and not `hidden`. A tab's root screen
|
|
1603
|
+
/// in a hybrid app is shown bare unless it did.
|
|
1604
|
+
var wantsNavigationBar: Bool {
|
|
1605
|
+
if let navigationBarHidden { return !navigationBarHidden }
|
|
1606
|
+
return titleFromOptions || prefersLargeTitle == true || !(navigationItem.rightBarButtonItems ?? []).isEmpty
|
|
1607
|
+
}
|
|
1608
|
+
private weak var trackedScrollView: UIScrollView?
|
|
1609
|
+
|
|
1610
|
+
/// `craft.navigation.setOptions`: this screen's bar. Every key is
|
|
1611
|
+
/// optional and only the keys given change.
|
|
1612
|
+
private func applyNavigationOptions(_ options: [String: Any]) {
|
|
1613
|
+
if options.keys.contains("title") {
|
|
1614
|
+
navigationItem.title = options["title"] as? String
|
|
1615
|
+
titleFromOptions = true
|
|
1616
|
+
}
|
|
1617
|
+
if options.keys.contains("backTitle") {
|
|
1618
|
+
navigationItem.backButtonTitle = options["backTitle"] as? String
|
|
1619
|
+
}
|
|
1620
|
+
if let largeTitle = options["largeTitle"] as? Bool {
|
|
1621
|
+
prefersLargeTitle = largeTitle
|
|
1622
|
+
navigationItem.largeTitleDisplayMode = largeTitle ? .always : .never
|
|
1623
|
+
if largeTitle { navigationController?.navigationBar.prefersLargeTitles = true }
|
|
1624
|
+
trackContentScrollView()
|
|
1625
|
+
}
|
|
1626
|
+
if let hidden = options["hidden"] as? Bool {
|
|
1627
|
+
navigationBarHidden = hidden
|
|
1628
|
+
if navigationController?.topViewController === self {
|
|
1629
|
+
navigationController?.setNavigationBarHidden(hidden, animated: view.window != nil)
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
if options.keys.contains("rightButtons") {
|
|
1633
|
+
let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
|
|
1634
|
+
navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
|
|
1635
|
+
}
|
|
1636
|
+
// Asked for after the bar was hidden for a bare root screen.
|
|
1637
|
+
if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
|
|
1638
|
+
navigationController?.isNavigationBarHidden == true {
|
|
1639
|
+
navigationController?.setNavigationBarHidden(false, animated: view.window != nil)
|
|
660
1640
|
}
|
|
661
1641
|
}
|
|
662
1642
|
|
|
1643
|
+
/// A bar button from `{ id, symbol | title }`. Its tap is the event
|
|
1644
|
+
/// `navButton` with `{ id }`.
|
|
1645
|
+
private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
|
|
1646
|
+
guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
|
|
1647
|
+
let action = UIAction { [weak self] _ in
|
|
1648
|
+
self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": id]])
|
|
1649
|
+
}
|
|
1650
|
+
let item: UIBarButtonItem
|
|
1651
|
+
if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
|
|
1652
|
+
item = UIBarButtonItem(image: image, primaryAction: action)
|
|
1653
|
+
} else if let title = spec["title"] as? String {
|
|
1654
|
+
item = UIBarButtonItem(title: title, primaryAction: action)
|
|
1655
|
+
} else {
|
|
1656
|
+
return nil
|
|
1657
|
+
}
|
|
1658
|
+
item.accessibilityIdentifier = "nav-\(id)"
|
|
1659
|
+
if let label = (spec["accessibilityLabel"] as? String) ?? (spec["title"] as? String) {
|
|
1660
|
+
item.accessibilityLabel = label
|
|
1661
|
+
} else {
|
|
1662
|
+
item.accessibilityLabel = id
|
|
1663
|
+
}
|
|
1664
|
+
if let tint = (spec["color"] as? String).flatMap({ UIColor(hex: $0) }) { item.tintColor = tint }
|
|
1665
|
+
return item
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/// The first vertical scroll view of the screen drives the bar: a large
|
|
1669
|
+
/// title collapses as it scrolls, and the bar's background follows it.
|
|
1670
|
+
private func trackContentScrollView() {
|
|
1671
|
+
guard let renderedRoot else { return }
|
|
1672
|
+
func find(_ node: RenderedNode) -> UIScrollView? {
|
|
1673
|
+
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical, scroll.isScrollEnabled { return scroll }
|
|
1674
|
+
if let list = node.view as? CraftNativeFlatList, list.isScrollEnabled,
|
|
1675
|
+
(list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { return list }
|
|
1676
|
+
for child in node.children {
|
|
1677
|
+
if let match = find(child) { return match }
|
|
1678
|
+
}
|
|
1679
|
+
return nil
|
|
1680
|
+
}
|
|
1681
|
+
let scroll = find(renderedRoot)
|
|
1682
|
+
// Under a large title the screen runs edge to edge, as UIKit's own
|
|
1683
|
+
// screens do: the scroll view starts under the bar and insets its
|
|
1684
|
+
// content by it, so the title collapses and pull to refresh draws
|
|
1685
|
+
// in the bar's space. Other screens stay inside the safe area.
|
|
1686
|
+
let edgeToEdge = scroll != nil && prefersLargeTitle == true
|
|
1687
|
+
if rootTopToView?.isActive != edgeToEdge {
|
|
1688
|
+
rootTopToSafeArea?.isActive = !edgeToEdge
|
|
1689
|
+
rootTopToView?.isActive = edgeToEdge
|
|
1690
|
+
}
|
|
1691
|
+
guard scroll !== trackedScrollView else { return }
|
|
1692
|
+
trackedScrollView = scroll
|
|
1693
|
+
setContentScrollView(scroll, for: .top)
|
|
1694
|
+
}
|
|
1695
|
+
|
|
663
1696
|
private func observeNativeEvents() {
|
|
664
1697
|
func observe(_ name: Notification.Name, state: String) {
|
|
665
1698
|
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
@@ -671,6 +1704,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
671
1704
|
observe(UIApplication.didBecomeActiveNotification, state: "active")
|
|
672
1705
|
observe(UIApplication.willResignActiveNotification, state: "inactive")
|
|
673
1706
|
observe(UIApplication.didEnterBackgroundNotification, state: "background")
|
|
1707
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
1708
|
+
forName: UIResponder.keyboardWillChangeFrameNotification,
|
|
1709
|
+
object: nil,
|
|
1710
|
+
queue: .main
|
|
1711
|
+
) { [weak self] notification in
|
|
1712
|
+
self?.applyKeyboardSafeArea(notification)
|
|
1713
|
+
})
|
|
1714
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
1715
|
+
forName: UIResponder.keyboardWillHideNotification,
|
|
1716
|
+
object: nil,
|
|
1717
|
+
queue: .main
|
|
1718
|
+
) { [weak self] notification in
|
|
1719
|
+
self?.applyKeyboardSafeArea(notification, hidden: true)
|
|
1720
|
+
})
|
|
674
1721
|
sendAppState(CraftNativeActions.currentAppState())
|
|
675
1722
|
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
|
|
676
1723
|
guard let self = self, self.navigationController?.topViewController === self else { return }
|
|
@@ -680,6 +1727,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
680
1727
|
}
|
|
681
1728
|
}
|
|
682
1729
|
|
|
1730
|
+
private func applyKeyboardSafeArea(_ notification: Notification, hidden: Bool = false) {
|
|
1731
|
+
let inset: CGFloat
|
|
1732
|
+
if hidden {
|
|
1733
|
+
inset = 0
|
|
1734
|
+
} else if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
|
1735
|
+
let keyboardFrame = view.convert(frame.cgRectValue, from: nil)
|
|
1736
|
+
inset = max(0, view.bounds.intersection(keyboardFrame).height - view.safeAreaInsets.bottom)
|
|
1737
|
+
} else {
|
|
1738
|
+
return
|
|
1739
|
+
}
|
|
1740
|
+
guard abs(inset - keyboardSafeAreaInset) > 0.5 else { return }
|
|
1741
|
+
keyboardSafeAreaInset = inset
|
|
1742
|
+
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
|
1743
|
+
let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
|
|
1744
|
+
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
|
|
1745
|
+
self.additionalSafeAreaInsets.bottom = inset
|
|
1746
|
+
self.view.layoutIfNeeded()
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
683
1750
|
private func sendAppState(_ state: String) {
|
|
684
1751
|
guard navigationController?.topViewController === self else { return }
|
|
685
1752
|
send(type: "APP_STATE", payload: ["state": state])
|
|
@@ -694,6 +1761,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
694
1761
|
rootStack.addArrangedSubview(label)
|
|
695
1762
|
rootStack.setLayoutStyle(["alignSelf": "stretch"], for: label)
|
|
696
1763
|
NSLog("[craft native] %@", message)
|
|
1764
|
+
hybridFailed(message)
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/// Only before the first frame: later exceptions belong to a screen that
|
|
1768
|
+
/// is up, and are logged.
|
|
1769
|
+
private func hybridFailed(_ message: String) {
|
|
1770
|
+
guard !hybridRendered else { return }
|
|
1771
|
+
hybridEvents?.nativeScreen(self, didFail: message)
|
|
697
1772
|
}
|
|
698
1773
|
|
|
699
1774
|
private func receive(_ raw: String) {
|
|
@@ -737,11 +1812,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
737
1812
|
let navigation = navigationController else { return }
|
|
738
1813
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
739
1814
|
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
1815
|
+
next.hybridEvents = hybridEvents
|
|
740
1816
|
if type == "NAVIGATE" {
|
|
741
1817
|
navigation.pushViewController(next, animated: true)
|
|
742
1818
|
} else {
|
|
743
1819
|
navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
|
|
744
1820
|
}
|
|
1821
|
+
case "NAVIGATION_SET_OPTIONS":
|
|
1822
|
+
applyNavigationOptions(payload)
|
|
1823
|
+
case "NAVIGATE_OPEN":
|
|
1824
|
+
// A path rather than a screen: the hybrid shell opens it, natively
|
|
1825
|
+
// or in the web view.
|
|
1826
|
+
guard let path = payload["path"] as? String, !path.isEmpty else { return }
|
|
1827
|
+
hybridEvents?.nativeScreen(self, open: path)
|
|
745
1828
|
case "NAVIGATE_BACK":
|
|
746
1829
|
if navigationController?.topViewController === self {
|
|
747
1830
|
navigationController?.popViewController(animated: true)
|
|
@@ -804,10 +1887,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
804
1887
|
}
|
|
805
1888
|
}
|
|
806
1889
|
|
|
1890
|
+
/// Runs script in the screen's JavaScript context. Internal for
|
|
1891
|
+
/// simulator-hosted XCTest of the host APIs.
|
|
1892
|
+
@discardableResult
|
|
1893
|
+
func evaluateScript(_ script: String) -> JSValue? {
|
|
1894
|
+
jsContext.evaluateScript(script)
|
|
1895
|
+
}
|
|
1896
|
+
|
|
807
1897
|
// Internal so simulator-hosted XCTest can assert actual UIView identity.
|
|
808
1898
|
func render(_ document: [String: Any]) {
|
|
809
1899
|
mutationDocument.replace(with: document)
|
|
810
1900
|
renderCommitted(document)
|
|
1901
|
+
if !hybridRendered {
|
|
1902
|
+
hybridRendered = true
|
|
1903
|
+
hybridEvents?.nativeScreenDidRender(self)
|
|
1904
|
+
}
|
|
811
1905
|
}
|
|
812
1906
|
|
|
813
1907
|
@discardableResult
|
|
@@ -825,7 +1919,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
825
1919
|
let target = flatListOwners[id] ?? id
|
|
826
1920
|
guard applied.insert(target).inserted else { continue }
|
|
827
1921
|
guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
|
|
828
|
-
|
|
1922
|
+
let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
|
|
1923
|
+
// A style patch is applied in place, layout included: the parent
|
|
1924
|
+
// reads the node's new layout style on its next pass.
|
|
1925
|
+
CraftNativeFlexLayout.invalidateAncestors(of: next.view)
|
|
829
1926
|
}
|
|
830
1927
|
restoreFocus(focused)
|
|
831
1928
|
return true
|
|
@@ -846,10 +1943,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
846
1943
|
let next = reconcile(document, identity: "root", path: "root", previous: previous)
|
|
847
1944
|
if previous?.view !== next.view {
|
|
848
1945
|
if let old = previous { detach(old, from: rootStack) }
|
|
849
|
-
rootStack.addArrangedSubview(next.view)
|
|
850
1946
|
}
|
|
1947
|
+
// A failed or interrupted reconciliation can leave an older root
|
|
1948
|
+
// attached even though `renderedRoot` points at the latest tree. Keep
|
|
1949
|
+
// the host single-rooted so accessibility queries and event routing
|
|
1950
|
+
// never see stale copies of the native screen.
|
|
1951
|
+
for child in rootStack.arrangedSubviews where child !== next.view {
|
|
1952
|
+
rootStack.removeArrangedSubview(child)
|
|
1953
|
+
child.removeFromSuperview()
|
|
1954
|
+
}
|
|
1955
|
+
if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
|
|
851
1956
|
rootStack.setLayoutStyle(next.style, for: next.view)
|
|
1957
|
+
rootStack.invalidateMeasurements()
|
|
852
1958
|
renderedRoot = next
|
|
1959
|
+
trackContentScrollView()
|
|
853
1960
|
restoreFocus(focused)
|
|
854
1961
|
}
|
|
855
1962
|
|
|
@@ -890,7 +1997,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
890
1997
|
current.protocolId = node["id"] as? String
|
|
891
1998
|
current.style = style
|
|
892
1999
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
893
|
-
if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" {
|
|
2000
|
+
if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
|
|
894
2001
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
895
2002
|
}
|
|
896
2003
|
applyViewStyle(style, to: result, node: current)
|
|
@@ -901,6 +2008,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
901
2008
|
let label = result as! UILabel
|
|
902
2009
|
label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
|
|
903
2010
|
label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
2011
|
+
(label as? CraftNativeLabel)?.insets = paddingInsets(style)
|
|
904
2012
|
configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
|
|
905
2013
|
case "Button", "Link":
|
|
906
2014
|
let button = result as! UIButton
|
|
@@ -911,6 +2019,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
911
2019
|
style,
|
|
912
2020
|
default: .systemFont(ofSize: 16)
|
|
913
2021
|
)
|
|
2022
|
+
// The plain title too, so `title(for:)` and accessibility read it.
|
|
2023
|
+
if button.title(for: .normal) != transformedTitle { button.setTitle(transformedTitle, for: .normal) }
|
|
914
2024
|
button.setAttributedTitle(NSAttributedString(
|
|
915
2025
|
string: transformedTitle,
|
|
916
2026
|
attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
|
|
@@ -962,10 +2072,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
962
2072
|
if field.returnKeyType != desiredReturnKeyType { field.returnKeyType = desiredReturnKeyType }
|
|
963
2073
|
let desiredAutocorrection: UITextAutocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
|
|
964
2074
|
if field.autocorrectionType != desiredAutocorrection { field.autocorrectionType = desiredAutocorrection }
|
|
965
|
-
|
|
966
|
-
if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
|
|
2075
|
+
// Secure entry first: turning it on resets the capitalization.
|
|
967
2076
|
let desiredSecureEntry = props["secureTextEntry"] as? Bool == true
|
|
968
2077
|
if field.isSecureTextEntry != desiredSecureEntry { field.isSecureTextEntry = desiredSecureEntry }
|
|
2078
|
+
let desiredCapitalization = capitalizationType(props["autoCapitalize"])
|
|
2079
|
+
if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
|
|
969
2080
|
let desiredEnabled = props["editable"] as? Bool != false
|
|
970
2081
|
if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
|
|
971
2082
|
if let value = props["value"] as? String {
|
|
@@ -981,6 +2092,52 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
981
2092
|
if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
|
|
982
2093
|
DispatchQueue.main.async { _ = field.becomeFirstResponder() }
|
|
983
2094
|
}
|
|
2095
|
+
case "Picker":
|
|
2096
|
+
let picker = result as! UIPickerView
|
|
2097
|
+
let options = children.compactMap { child -> (value: String, label: String)? in
|
|
2098
|
+
guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
|
|
2099
|
+
let optionProps = node["props"] as? [String: Any] ?? [:]
|
|
2100
|
+
let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
|
|
2101
|
+
guard !label.isEmpty else { return nil }
|
|
2102
|
+
return (value: optionProps["value"] as? String ?? label, label: label)
|
|
2103
|
+
}
|
|
2104
|
+
let pickerID = ObjectIdentifier(picker)
|
|
2105
|
+
pickerOptions[pickerID] = options
|
|
2106
|
+
picker.reloadAllComponents()
|
|
2107
|
+
if let selected = (props["selectedValue"] as? String) ?? (props["value"] as? String),
|
|
2108
|
+
let row = options.firstIndex(where: { $0.value == selected }) {
|
|
2109
|
+
picker.selectRow(row, inComponent: 0, animated: false)
|
|
2110
|
+
} else if !options.isEmpty {
|
|
2111
|
+
picker.selectRow(0, inComponent: 0, animated: false)
|
|
2112
|
+
}
|
|
2113
|
+
picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
|
|
2114
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
|
|
2115
|
+
case "Modal":
|
|
2116
|
+
let modal = result as! CraftNativeModalView
|
|
2117
|
+
modal.transparent = props["transparent"] as? Bool == true
|
|
2118
|
+
let visible = props["visible"] as? Bool == true
|
|
2119
|
+
let modalID = ObjectIdentifier(modal)
|
|
2120
|
+
let wasVisible = modalVisibility[modalID]
|
|
2121
|
+
modalVisibility[modalID] = visible
|
|
2122
|
+
modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
2123
|
+
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
2124
|
+
{ [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
2125
|
+
}
|
|
2126
|
+
if wasVisible != visible, visible || wasVisible == true {
|
|
2127
|
+
let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
|
|
2128
|
+
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
|
|
2129
|
+
send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
configureStack(modal.contentStack, style: style)
|
|
2133
|
+
reconcileChildren(children, in: modal.contentStack, parent: current, path: path, style: style)
|
|
2134
|
+
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
2135
|
+
let pressable = result as! CraftNativeFlowView
|
|
2136
|
+
configureStack(pressable, style: style)
|
|
2137
|
+
reconcileChildren(children, in: pressable, parent: current, path: path, style: style)
|
|
2138
|
+
pressable.pressActiveOpacity = props["disabled"] as? Bool == true
|
|
2139
|
+
? nil : number(props["activeOpacity"]) ?? 0.5
|
|
2140
|
+
pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
|
|
984
2141
|
case "Switch":
|
|
985
2142
|
let toggle = result as! UISwitch
|
|
986
2143
|
toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
|
|
@@ -1029,6 +2186,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1029
2186
|
} else {
|
|
1030
2187
|
indicator.startAnimating()
|
|
1031
2188
|
}
|
|
2189
|
+
case "Icon":
|
|
2190
|
+
configureIcon(result as! UIImageView, props: props, style: style)
|
|
1032
2191
|
case "Image":
|
|
1033
2192
|
let image = result as! UIImageView
|
|
1034
2193
|
let imageID = ObjectIdentifier(image)
|
|
@@ -1061,17 +2220,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1061
2220
|
scroll.bounces = props["bounces"] as? Bool ?? true
|
|
1062
2221
|
scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
|
|
1063
2222
|
scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
|
|
2223
|
+
scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
|
|
1064
2224
|
scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
|
|
1065
2225
|
scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
|
|
1066
2226
|
scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
|
|
1067
2227
|
scroll.alwaysBounceHorizontal = props["alwaysBounceHorizontal"] as? Bool ?? (direction == .horizontal)
|
|
2228
|
+
scroll.setStickyHeaderIndices(props["stickyHeaderIndices"])
|
|
2229
|
+
scroll.setScrollTarget(props["scrollTarget"])
|
|
1068
2230
|
updateAuxiliaryHandler(events["onScroll"], in: &scrollHandlers, for: scroll)
|
|
1069
2231
|
updateAuxiliaryHandler(events["onScrollBeginDrag"], in: &scrollBeginHandlers, for: scroll)
|
|
1070
2232
|
updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
|
|
1071
2233
|
updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
|
|
1072
2234
|
updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
|
|
1073
|
-
|
|
1074
|
-
|
|
2235
|
+
scroll.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
|
|
2236
|
+
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
2237
|
+
}, refreshing: props["refreshing"] as? Bool == true)
|
|
2238
|
+
var contentStyle = style
|
|
2239
|
+
if let override = props["contentContainerStyle"] as? [String: Any] {
|
|
2240
|
+
contentStyle.merge(override) { _, next in next }
|
|
2241
|
+
}
|
|
2242
|
+
configureStack(scroll.contentStack, style: contentStyle)
|
|
2243
|
+
reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
|
|
1075
2244
|
case "FlatList":
|
|
1076
2245
|
let list = result as! CraftNativeFlatList
|
|
1077
2246
|
reconcileFlatList(
|
|
@@ -1087,9 +2256,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1087
2256
|
configureStack(stack, style: style)
|
|
1088
2257
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
1089
2258
|
}
|
|
1090
|
-
updateLongPressHandler(events["onLongPress"], for: result)
|
|
1091
|
-
if type != "Button" && type != "Link" && type != "TextInput" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
1092
|
-
|
|
2259
|
+
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
2260
|
+
if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
2261
|
+
let disabled = props["disabled"] as? Bool == true
|
|
2262
|
+
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
2263
|
+
updatePressFeedback(
|
|
2264
|
+
disabled ? nil : nonEmptyHandler(events["onPressIn"]),
|
|
2265
|
+
disabled ? nil : nonEmptyHandler(events["onPressOut"]),
|
|
2266
|
+
for: result
|
|
2267
|
+
)
|
|
2268
|
+
if disabled { result.isUserInteractionEnabled = false }
|
|
1093
2269
|
}
|
|
1094
2270
|
applyAccessibility(props, type: type, to: result)
|
|
1095
2271
|
return current
|
|
@@ -1098,7 +2274,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1098
2274
|
private func makeView(_ type: String, props: [String: Any]) -> UIView {
|
|
1099
2275
|
switch type {
|
|
1100
2276
|
case "Text":
|
|
1101
|
-
let label =
|
|
2277
|
+
let label = CraftNativeLabel()
|
|
1102
2278
|
label.numberOfLines = 0
|
|
1103
2279
|
return label
|
|
1104
2280
|
case "Button", "Link":
|
|
@@ -1118,6 +2294,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1118
2294
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
1119
2295
|
field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
|
|
1120
2296
|
return field
|
|
2297
|
+
case "Picker":
|
|
2298
|
+
let picker = UIPickerView()
|
|
2299
|
+
picker.dataSource = self
|
|
2300
|
+
picker.delegate = self
|
|
2301
|
+
return picker
|
|
2302
|
+
case "Modal":
|
|
2303
|
+
return CraftNativeModalView()
|
|
1121
2304
|
case "Switch":
|
|
1122
2305
|
let toggle = UISwitch()
|
|
1123
2306
|
toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
|
|
@@ -1131,19 +2314,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1131
2314
|
return UIActivityIndicatorView(style: .medium)
|
|
1132
2315
|
case "Image":
|
|
1133
2316
|
return UIImageView()
|
|
2317
|
+
case "Icon":
|
|
2318
|
+
let icon = CraftNativeIconView()
|
|
2319
|
+
icon.contentMode = .scaleAspectFit
|
|
2320
|
+
return icon
|
|
1134
2321
|
case "ScrollView":
|
|
1135
2322
|
return CraftNativeScrollView()
|
|
1136
2323
|
case "FlatList":
|
|
1137
2324
|
return CraftNativeFlatList()
|
|
1138
2325
|
case "View", "SafeAreaView":
|
|
1139
2326
|
return CraftNativeFlowView()
|
|
2327
|
+
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
2328
|
+
return CraftNativeFlowView()
|
|
1140
2329
|
default:
|
|
1141
2330
|
return CraftNativeFlowView()
|
|
1142
2331
|
}
|
|
1143
2332
|
}
|
|
1144
2333
|
|
|
1145
2334
|
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
1146
|
-
// The current stx
|
|
2335
|
+
// The current stx native compiler emits key in props; accept the IR
|
|
1147
2336
|
// field too so keyed children keep working when the compiler adopts it.
|
|
1148
2337
|
[node["id"], node["key"], props["key"], props["testID"]]
|
|
1149
2338
|
.compactMap { $0 as? String }
|
|
@@ -1186,7 +2375,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1186
2375
|
for child in parent.children where !next.contains(where: { $0 === child }) {
|
|
1187
2376
|
detach(child, from: stack)
|
|
1188
2377
|
}
|
|
1189
|
-
for
|
|
2378
|
+
for modal in stack.arrangedSubviews where modal is CraftNativeModalView {
|
|
2379
|
+
stack.removeArrangedSubview(modal)
|
|
2380
|
+
}
|
|
2381
|
+
let flowChildren = next.filter { $0.type != "Modal" }
|
|
2382
|
+
for (index, child) in flowChildren.enumerated() {
|
|
1190
2383
|
if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view {
|
|
1191
2384
|
stack.setLayoutStyle(child.style, for: child.view)
|
|
1192
2385
|
continue
|
|
@@ -1195,6 +2388,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1195
2388
|
stack.insertArrangedSubview(child.view, at: min(index, stack.arrangedSubviews.count))
|
|
1196
2389
|
stack.setLayoutStyle(child.style, for: child.view)
|
|
1197
2390
|
}
|
|
2391
|
+
for child in next where child.type == "Modal" {
|
|
2392
|
+
guard let modal = child.view as? CraftNativeModalView else { continue }
|
|
2393
|
+
if modal.superview !== stack {
|
|
2394
|
+
modal.removeFromSuperview()
|
|
2395
|
+
stack.addSubview(modal)
|
|
2396
|
+
}
|
|
2397
|
+
modal.frame = stack.bounds
|
|
2398
|
+
modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
2399
|
+
stack.bringSubviewToFront(modal)
|
|
2400
|
+
}
|
|
1198
2401
|
parent.children = next
|
|
1199
2402
|
addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
|
|
1200
2403
|
}
|
|
@@ -1212,6 +2415,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1212
2415
|
flatListOwners = flatListOwners.filter { $0.value != owner }
|
|
1213
2416
|
for child in children { registerFlatListOwnership(child, owner: owner) }
|
|
1214
2417
|
list.bounces = props["bounces"] as? Bool ?? true
|
|
2418
|
+
list.isScrollEnabled = props["scrollEnabled"] as? Bool != false
|
|
2419
|
+
list.onFittingHeightChanged = { [weak list] in
|
|
2420
|
+
guard let list else { return }
|
|
2421
|
+
CraftNativeFlexLayout.invalidateAncestors(of: list)
|
|
2422
|
+
}
|
|
2423
|
+
list.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
|
|
2424
|
+
list.setContentContainerStyle(props["contentContainerStyle"] as? [String: Any])
|
|
2425
|
+
list.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
|
|
2426
|
+
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
2427
|
+
}, refreshing: props["refreshing"] as? Bool == true)
|
|
1215
2428
|
list.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
|
|
1216
2429
|
list.onScrollEvent = nonEmptyHandler(events["onScroll"]).map { handler in
|
|
1217
2430
|
{ [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
|
|
@@ -1276,20 +2489,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1276
2489
|
|
|
1277
2490
|
private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
|
|
1278
2491
|
forgetHandlers(node)
|
|
2492
|
+
if node.type == "Modal" {
|
|
2493
|
+
node.view.removeFromSuperview()
|
|
2494
|
+
return
|
|
2495
|
+
}
|
|
1279
2496
|
stack.removeLayoutStyle(for: node.view)
|
|
1280
2497
|
stack.removeArrangedSubview(node.view)
|
|
1281
2498
|
node.view.removeFromSuperview()
|
|
1282
2499
|
}
|
|
1283
2500
|
|
|
1284
2501
|
private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
|
|
2502
|
+
(node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
|
|
1285
2503
|
if let list = node.view as? CraftNativeFlatList {
|
|
1286
2504
|
let listKey = ObjectIdentifier(list)
|
|
2505
|
+
list.setRefreshHandler(nil, refreshing: false)
|
|
1287
2506
|
list.discardAll()
|
|
1288
2507
|
flatListRows.removeValue(forKey: listKey)
|
|
1289
2508
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
1290
2509
|
}
|
|
1291
2510
|
let id = ObjectIdentifier(node.view)
|
|
1292
2511
|
handlers.removeValue(forKey: id)
|
|
2512
|
+
pressInHandlers.removeValue(forKey: id)
|
|
2513
|
+
pressOutHandlers.removeValue(forKey: id)
|
|
1293
2514
|
layoutHandlers.removeValue(forKey: id)
|
|
1294
2515
|
lastLayoutFrames.removeValue(forKey: id)
|
|
1295
2516
|
focusHandlers.removeValue(forKey: id)
|
|
@@ -1307,12 +2528,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1307
2528
|
scrollMomentumEndHandlers.removeValue(forKey: id)
|
|
1308
2529
|
sliderCompleteHandlers.removeValue(forKey: id)
|
|
1309
2530
|
sliderSteps.removeValue(forKey: id)
|
|
2531
|
+
pickerOptions.removeValue(forKey: id)
|
|
2532
|
+
modalVisibility.removeValue(forKey: id)
|
|
1310
2533
|
if let recognizer = tapRecognizers.removeValue(forKey: id) {
|
|
1311
2534
|
node.view.removeGestureRecognizer(recognizer)
|
|
1312
2535
|
}
|
|
1313
2536
|
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
1314
2537
|
node.view.removeGestureRecognizer(recognizer)
|
|
1315
2538
|
}
|
|
2539
|
+
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) {
|
|
2540
|
+
node.view.removeGestureRecognizer(recognizer)
|
|
2541
|
+
}
|
|
1316
2542
|
imageSources.removeValue(forKey: id)
|
|
1317
2543
|
imageTasks.removeValue(forKey: id)?.cancel()
|
|
1318
2544
|
imageErrors.removeValue(forKey: id)
|
|
@@ -1402,7 +2628,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1402
2628
|
} else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
|
|
1403
2629
|
view.removeGestureRecognizer(recognizer)
|
|
1404
2630
|
}
|
|
1405
|
-
|
|
2631
|
+
// A container keeps receiving touches without a handler of its own,
|
|
2632
|
+
// or every control inside it (and the screen root) would be dead.
|
|
2633
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
|
|
2637
|
+
let id = ObjectIdentifier(view)
|
|
2638
|
+
if let pressIn, !pressIn.isEmpty { pressInHandlers[id] = pressIn } else { pressInHandlers.removeValue(forKey: id) }
|
|
2639
|
+
if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
|
|
2640
|
+
guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
|
|
2641
|
+
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
|
|
2642
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
2643
|
+
return
|
|
2644
|
+
}
|
|
2645
|
+
if pressFeedbackRecognizers[id] == nil {
|
|
2646
|
+
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewPressFeedback(_:)))
|
|
2647
|
+
recognizer.minimumPressDuration = 0
|
|
2648
|
+
recognizer.allowableMovement = 10
|
|
2649
|
+
recognizer.cancelsTouchesInView = false
|
|
2650
|
+
pressFeedbackRecognizers[id] = recognizer
|
|
2651
|
+
view.addGestureRecognizer(recognizer)
|
|
2652
|
+
}
|
|
2653
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
|
|
1406
2654
|
}
|
|
1407
2655
|
|
|
1408
2656
|
private func updateLongPressHandler(_ handler: String?, for view: UIView) {
|
|
@@ -1420,7 +2668,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1420
2668
|
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
1421
2669
|
view.removeGestureRecognizer(recognizer)
|
|
1422
2670
|
}
|
|
1423
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil }
|
|
2671
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
|
|
1424
2672
|
}
|
|
1425
2673
|
}
|
|
1426
2674
|
|
|
@@ -1504,6 +2752,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1504
2752
|
}
|
|
1505
2753
|
}
|
|
1506
2754
|
|
|
2755
|
+
/// `<Icon symbol="sun.max">`: an SF Symbol, tinted by `color`, sized by
|
|
2756
|
+
/// `fontSize` or else the icon's box, weighted by `fontWeight`. A name
|
|
2757
|
+
/// the system does not have draws `circle`, so a typo shows rather than
|
|
2758
|
+
/// leaving a hole.
|
|
2759
|
+
private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
|
|
2760
|
+
let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
|
|
2761
|
+
let box = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
|
|
2762
|
+
let fontSize = number(style["fontSize"])
|
|
2763
|
+
// Without a size of its own an icon is a 20 pt square (contract A2).
|
|
2764
|
+
(icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
|
|
2765
|
+
let pointSize = fontSize ?? (box ?? 20) * 0.84
|
|
2766
|
+
let weight: UIImage.SymbolWeight
|
|
2767
|
+
switch style["fontWeight"] as? String {
|
|
2768
|
+
case "100": weight = .ultraLight
|
|
2769
|
+
case "200": weight = .thin
|
|
2770
|
+
case "300": weight = .light
|
|
2771
|
+
case "500": weight = .medium
|
|
2772
|
+
case "600": weight = .semibold
|
|
2773
|
+
case "bold", "700": weight = .bold
|
|
2774
|
+
case "800": weight = .heavy
|
|
2775
|
+
case "900": weight = .black
|
|
2776
|
+
default: weight = .regular
|
|
2777
|
+
}
|
|
2778
|
+
let scale: UIImage.SymbolScale
|
|
2779
|
+
switch props["scale"] as? String {
|
|
2780
|
+
case "small": scale = .small
|
|
2781
|
+
case "large": scale = .large
|
|
2782
|
+
default: scale = .medium
|
|
2783
|
+
}
|
|
2784
|
+
let configuration = UIImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: weight, scale: scale)
|
|
2785
|
+
let image = UIImage(systemName: name, withConfiguration: configuration)
|
|
2786
|
+
?? UIImage(systemName: "circle", withConfiguration: configuration)
|
|
2787
|
+
if icon.image != image { icon.image = image }
|
|
2788
|
+
icon.preferredSymbolConfiguration = configuration
|
|
2789
|
+
icon.tintColor = color(style["color"]) ?? color(props["color"]) ?? .label
|
|
2790
|
+
icon.contentMode = .scaleAspectFit
|
|
2791
|
+
}
|
|
2792
|
+
|
|
1507
2793
|
private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
|
|
1508
2794
|
let direction = style["flexDirection"] as? String
|
|
1509
2795
|
stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
|
|
@@ -1517,15 +2803,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1517
2803
|
stack.grid = style["display"] as? String == "grid"
|
|
1518
2804
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
1519
2805
|
stack.gridAutoRows = number(style["gridAutoRows"])
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
let vertical = number(style["paddingVertical"]) ?? padding
|
|
1523
|
-
stack.padding = UIEdgeInsets(
|
|
1524
|
-
top: number(style["paddingTop"]) ?? vertical,
|
|
1525
|
-
left: number(style["paddingLeft"]) ?? horizontal,
|
|
1526
|
-
bottom: number(style["paddingBottom"]) ?? vertical,
|
|
1527
|
-
right: number(style["paddingRight"]) ?? horizontal
|
|
1528
|
-
)
|
|
2806
|
+
stack.padding = paddingInsets(style)
|
|
2807
|
+
stack.invalidateMeasurements()
|
|
1529
2808
|
}
|
|
1530
2809
|
|
|
1531
2810
|
private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
|
|
@@ -1538,7 +2817,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1538
2817
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
1539
2818
|
view.alpha = number(style["opacity"]) ?? 1
|
|
1540
2819
|
view.isHidden = style["display"] as? String == "none"
|
|
1541
|
-
view.
|
|
2820
|
+
view.craftRequestedCornerRadius = number(style["borderRadius"])
|
|
1542
2821
|
view.layer.borderWidth = number(style["borderWidth"]) ?? 0
|
|
1543
2822
|
view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
|
|
1544
2823
|
let elevation = max(0, number(style["elevation"]) ?? 0)
|
|
@@ -1546,8 +2825,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1546
2825
|
view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
|
|
1547
2826
|
view.layer.shadowRadius = elevation * 0.5
|
|
1548
2827
|
view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
|
|
2828
|
+
// A label paints its background inside its own bounds, so a rounded
|
|
2829
|
+
// chip of text clips to its corners.
|
|
1549
2830
|
view.clipsToBounds = style["overflow"] as? String == "hidden"
|
|
2831
|
+
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
1550
2832
|
view.layer.masksToBounds = view.clipsToBounds
|
|
2833
|
+
view.craftLayoutStyle = CraftNativeLayoutStyle(style)
|
|
2834
|
+
(view as? CraftNativeFlowView)?.invalidateMeasurements()
|
|
2835
|
+
(view as? CraftNativeScrollView)?.setContentNeedsLayout()
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
|
|
2839
|
+
let padding = number(style["padding"]) ?? 0
|
|
2840
|
+
let horizontal = number(style["paddingHorizontal"]) ?? padding
|
|
2841
|
+
let vertical = number(style["paddingVertical"]) ?? padding
|
|
2842
|
+
return UIEdgeInsets(
|
|
2843
|
+
top: number(style["paddingTop"]) ?? vertical,
|
|
2844
|
+
left: number(style["paddingLeft"]) ?? horizontal,
|
|
2845
|
+
bottom: number(style["paddingBottom"]) ?? vertical,
|
|
2846
|
+
right: number(style["paddingRight"]) ?? horizontal
|
|
2847
|
+
)
|
|
1551
2848
|
}
|
|
1552
2849
|
|
|
1553
2850
|
private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
|
|
@@ -1557,13 +2854,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1557
2854
|
label.textAlignment = textAlignment(style["textAlign"])
|
|
1558
2855
|
var attributes: [NSAttributedString.Key: Any] = [:]
|
|
1559
2856
|
if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
|
|
1560
|
-
if let lineHeight = number(style["lineHeight"]) {
|
|
1561
|
-
let paragraph = NSMutableParagraphStyle()
|
|
1562
|
-
paragraph.minimumLineHeight = lineHeight
|
|
1563
|
-
paragraph.maximumLineHeight = lineHeight
|
|
1564
|
-
paragraph.alignment = label.textAlignment
|
|
1565
|
-
attributes[.paragraphStyle] = paragraph
|
|
1566
|
-
}
|
|
1567
2857
|
switch style["textDecorationLine"] as? String {
|
|
1568
2858
|
case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
1569
2859
|
case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
@@ -1572,8 +2862,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1572
2862
|
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
1573
2863
|
default: break
|
|
1574
2864
|
}
|
|
2865
|
+
if let lineHeight = number(style["lineHeight"]) {
|
|
2866
|
+
let paragraph = NSMutableParagraphStyle()
|
|
2867
|
+
paragraph.minimumLineHeight = lineHeight
|
|
2868
|
+
paragraph.maximumLineHeight = lineHeight
|
|
2869
|
+
paragraph.alignment = label.textAlignment
|
|
2870
|
+
attributes[.paragraphStyle] = paragraph
|
|
2871
|
+
}
|
|
2872
|
+
let alignment = label.textAlignment
|
|
2873
|
+
let lineBreakMode = label.lineBreakMode
|
|
1575
2874
|
label.attributedText = attributes.isEmpty ? nil : NSAttributedString(string: transformed, attributes: attributes)
|
|
1576
2875
|
if attributes.isEmpty { label.text = transformed }
|
|
2876
|
+
// Attributed text brings its own paragraph style, which resets the
|
|
2877
|
+
// label's alignment and truncation; set them again over the whole text.
|
|
2878
|
+
label.textAlignment = alignment
|
|
2879
|
+
label.lineBreakMode = lineBreakMode
|
|
1577
2880
|
}
|
|
1578
2881
|
|
|
1579
2882
|
private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
|
|
@@ -1808,7 +3111,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1808
3111
|
view.accessibilityValue = nil
|
|
1809
3112
|
}
|
|
1810
3113
|
let role = props["accessibilityRole"] as? String
|
|
1811
|
-
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Switch", "Slider"].contains(type))
|
|
3114
|
+
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
1812
3115
|
var traits: UIAccessibilityTraits = []
|
|
1813
3116
|
switch role ?? type.lowercased() {
|
|
1814
3117
|
case "button": traits.insert(.button)
|
|
@@ -1844,7 +3147,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1844
3147
|
if let value = value {
|
|
1845
3148
|
if let constraint = constraint { constraint.constant = value }
|
|
1846
3149
|
else {
|
|
3150
|
+
// Below required: the flex layout owns frames, and a width
|
|
3151
|
+
// the layout stretches or shrinks must not log a conflict.
|
|
1847
3152
|
constraint = anchor.constraint(equalToConstant: value)
|
|
3153
|
+
constraint?.priority = UILayoutPriority(999)
|
|
1848
3154
|
constraint?.isActive = true
|
|
1849
3155
|
}
|
|
1850
3156
|
} else {
|
|
@@ -1936,6 +3242,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1936
3242
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
|
|
1937
3243
|
}
|
|
1938
3244
|
|
|
3245
|
+
func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
|
|
3246
|
+
|
|
3247
|
+
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
|
|
3248
|
+
pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
|
|
3252
|
+
guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
|
|
3253
|
+
return options[row].label
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
|
|
3257
|
+
guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
|
|
3258
|
+
let handler = handlers[ObjectIdentifier(pickerView)] else { return }
|
|
3259
|
+
let option = options[row]
|
|
3260
|
+
send(type: "EVENT", payload: [
|
|
3261
|
+
"handlerName": handler,
|
|
3262
|
+
"nativeEvent": ["value": option.value, "index": row],
|
|
3263
|
+
])
|
|
3264
|
+
}
|
|
3265
|
+
|
|
1939
3266
|
@objc private func textFocused(_ sender: UITextField) {
|
|
1940
3267
|
lastFocusedInput = sender
|
|
1941
3268
|
guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
|
|
@@ -1960,9 +3287,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1960
3287
|
|
|
1961
3288
|
@objc private func viewPressed(_ sender: UITapGestureRecognizer) {
|
|
1962
3289
|
guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
|
|
3290
|
+
let id = ObjectIdentifier(view)
|
|
3291
|
+
if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
|
|
3292
|
+
let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
|
|
3293
|
+
UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
|
|
3294
|
+
UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity }
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
1963
3297
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
1964
3298
|
}
|
|
1965
3299
|
|
|
3300
|
+
@objc private func viewPressFeedback(_ sender: UILongPressGestureRecognizer) {
|
|
3301
|
+
guard let view = sender.view else { return }
|
|
3302
|
+
let id = ObjectIdentifier(view)
|
|
3303
|
+
switch sender.state {
|
|
3304
|
+
case .began:
|
|
3305
|
+
if let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
|
|
3306
|
+
UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
|
|
3307
|
+
}
|
|
3308
|
+
if let handler = pressInHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
3309
|
+
case .ended, .cancelled, .failed:
|
|
3310
|
+
if let pressable = view as? CraftNativeFlowView { UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity } }
|
|
3311
|
+
if let handler = pressOutHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
3312
|
+
default:
|
|
3313
|
+
break
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
|
|
1966
3317
|
@objc private func viewLongPressed(_ sender: UILongPressGestureRecognizer) {
|
|
1967
3318
|
guard sender.state == .began,
|
|
1968
3319
|
let view = sender.view,
|
|
@@ -2048,8 +3399,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2048
3399
|
}
|
|
2049
3400
|
}
|
|
2050
3401
|
|
|
3402
|
+
/// A style colour: CSS hex with or without alpha (`#10b9811a` is
|
|
3403
|
+
/// emerald at 10%), `rgb()`/`rgba()` and the few names; `UIColor(hex:)`
|
|
3404
|
+
/// for anything else it has always read.
|
|
2051
3405
|
private func color(_ value: Any?) -> UIColor? {
|
|
2052
|
-
guard let
|
|
2053
|
-
return UIColor(hex:
|
|
3406
|
+
guard let text = value as? String else { return nil }
|
|
3407
|
+
return UIColor(css: text) ?? UIColor(hex: text)
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
/// `console` for native screens: the unified log, subsystem `craft.native`,
|
|
3412
|
+
/// category the screen's name. Read it with
|
|
3413
|
+
/// `log stream --predicate 'subsystem == "craft.native"'`.
|
|
3414
|
+
enum CraftNativeConsole {
|
|
3415
|
+
private static var loggers: [String: Logger] = [:]
|
|
3416
|
+
private static let lock = NSLock()
|
|
3417
|
+
|
|
3418
|
+
static func write(_ level: String, category: String, message: String) {
|
|
3419
|
+
lock.lock()
|
|
3420
|
+
let logger = loggers[category] ?? Logger(subsystem: "craft.native", category: category)
|
|
3421
|
+
loggers[category] = logger
|
|
3422
|
+
lock.unlock()
|
|
3423
|
+
switch level {
|
|
3424
|
+
case "debug": logger.debug("\(message, privacy: .public)")
|
|
3425
|
+
case "info": logger.info("\(message, privacy: .public)")
|
|
3426
|
+
case "warn": logger.warning("\(message, privacy: .public)")
|
|
3427
|
+
case "error": logger.error("\(message, privacy: .public)")
|
|
3428
|
+
default: logger.notice("\(message, privacy: .public)")
|
|
3429
|
+
}
|
|
2054
3430
|
}
|
|
2055
3431
|
}
|