craft-native 0.0.124 → 0.0.125
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +213 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +150 -21
- package/dist/ios/templates/CraftNativeScreen.swift +1492 -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,146 @@ 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 frame snapped to the screen's pixels edge by edge, so neighbours
|
|
217
|
+
/// neither overlap nor leave a hairline between them.
|
|
218
|
+
static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
|
|
219
|
+
let scale = max(1, scale)
|
|
220
|
+
func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
|
|
221
|
+
let minX = snap(rect.minX), minY = snap(rect.minY)
|
|
222
|
+
return CGRect(x: minX, y: minY, width: max(0, snap(rect.maxX) - minX), height: max(0, snap(rect.maxY) - minY))
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// A flexbox container: rows and columns with grow, shrink and basis, wrap,
|
|
227
|
+
/// gaps, alignment, margins, percentages, absolute children and a simple
|
|
228
|
+
/// grid. It is a UIStackView in name only, so existing callers and tests keep
|
|
229
|
+
/// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
|
|
230
|
+
/// constraints are never created, and every frame comes from `layout`.
|
|
87
231
|
private final class CraftNativeFlowView: UIStackView {
|
|
88
|
-
var
|
|
89
|
-
var
|
|
90
|
-
var
|
|
91
|
-
var
|
|
92
|
-
var
|
|
93
|
-
var
|
|
94
|
-
var
|
|
95
|
-
var
|
|
96
|
-
var
|
|
232
|
+
var pressActiveOpacity: CGFloat?
|
|
233
|
+
var pressBaseOpacity: CGFloat = 1
|
|
234
|
+
var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
|
|
235
|
+
var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
|
|
236
|
+
var justifyContent = "flex-start" { didSet { distribution = distributionValue; if justifyContent != oldValue { invalidateMeasurements() } } }
|
|
237
|
+
var gap: CGFloat = 0 { didSet { spacing = gap; if gap != oldValue { invalidateMeasurements() } } }
|
|
238
|
+
var rowGap: CGFloat? { didSet { if rowGap != oldValue { invalidateMeasurements() } } }
|
|
239
|
+
var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
|
|
240
|
+
var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
|
|
241
|
+
var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
|
|
242
|
+
var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
|
|
97
243
|
var padding = UIEdgeInsets.zero {
|
|
98
244
|
didSet {
|
|
99
245
|
layoutMargins = padding
|
|
100
246
|
isLayoutMarginsRelativeArrangement = true
|
|
101
|
-
|
|
102
|
-
setNeedsLayout()
|
|
247
|
+
if padding != oldValue { invalidateMeasurements() }
|
|
103
248
|
}
|
|
104
249
|
}
|
|
105
|
-
|
|
250
|
+
override var axis: NSLayoutConstraint.Axis {
|
|
251
|
+
didSet { if axis != oldValue { invalidateMeasurements() } }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private var flowChildren: [UIView] = []
|
|
255
|
+
private var measurements: [MeasureKey: CGSize] = [:]
|
|
256
|
+
|
|
257
|
+
private struct MeasureKey: Hashable {
|
|
258
|
+
let width: CGFloat?
|
|
259
|
+
let height: CGFloat?
|
|
260
|
+
let maxWidth: CGFloat
|
|
261
|
+
let maxHeight: CGFloat
|
|
262
|
+
}
|
|
106
263
|
|
|
107
264
|
override init(frame: CGRect) {
|
|
108
265
|
super.init(frame: frame)
|
|
109
266
|
alignment = .fill
|
|
110
267
|
distribution = .fill
|
|
111
268
|
isLayoutMarginsRelativeArrangement = true
|
|
269
|
+
insetsLayoutMarginsFromSafeArea = false
|
|
112
270
|
}
|
|
113
271
|
|
|
114
272
|
required init(coder: NSCoder) { super.init(coder: coder) }
|
|
@@ -131,304 +289,677 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
131
289
|
}
|
|
132
290
|
}
|
|
133
291
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
292
|
+
// MARK: Children
|
|
293
|
+
|
|
294
|
+
override var arrangedSubviews: [UIView] { flowChildren }
|
|
295
|
+
|
|
296
|
+
override func addArrangedSubview(_ view: UIView) {
|
|
297
|
+
insertArrangedSubview(view, at: flowChildren.count)
|
|
138
298
|
}
|
|
139
299
|
|
|
140
|
-
func
|
|
300
|
+
override func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
|
|
301
|
+
flowChildren.removeAll { $0 === view }
|
|
302
|
+
flowChildren.insert(view, at: min(max(0, stackIndex), flowChildren.count))
|
|
303
|
+
if view.superview !== self {
|
|
304
|
+
view.removeFromSuperview()
|
|
305
|
+
addSubview(view)
|
|
306
|
+
}
|
|
307
|
+
view.translatesAutoresizingMaskIntoConstraints = true
|
|
308
|
+
invalidateMeasurements()
|
|
309
|
+
}
|
|
141
310
|
|
|
142
|
-
override func
|
|
143
|
-
|
|
144
|
-
|
|
311
|
+
override func removeArrangedSubview(_ view: UIView) {
|
|
312
|
+
flowChildren.removeAll { $0 === view }
|
|
313
|
+
invalidateMeasurements()
|
|
145
314
|
}
|
|
146
315
|
|
|
147
316
|
override func willRemoveSubview(_ subview: UIView) {
|
|
148
|
-
|
|
317
|
+
flowChildren.removeAll { $0 === subview }
|
|
149
318
|
super.willRemoveSubview(subview)
|
|
319
|
+
invalidateMeasurements()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func setLayoutStyle(_ raw: [String: Any], for view: UIView) {
|
|
323
|
+
view.craftLayoutStyle = CraftNativeLayoutStyle(raw)
|
|
324
|
+
invalidateMeasurements()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
func removeLayoutStyle(for view: UIView) {
|
|
328
|
+
view.craftLayoutStyle = .empty
|
|
150
329
|
}
|
|
151
330
|
|
|
331
|
+
/// Forgets measured sizes and lays out again on the next pass.
|
|
332
|
+
func invalidateMeasurements() {
|
|
333
|
+
measurements.removeAll(keepingCapacity: true)
|
|
334
|
+
setNeedsLayout()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// MARK: Measuring and layout
|
|
338
|
+
|
|
152
339
|
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)
|
|
340
|
+
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
344
|
+
measure(width: nil, height: nil, maxWidth: size.width, maxHeight: size.height)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
348
|
+
let key = MeasureKey(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight)
|
|
349
|
+
if let cached = measurements[key] { return cached }
|
|
350
|
+
let size = layout(width: width, height: height, maxWidth: maxWidth, maxHeight: maxHeight, place: false).size
|
|
351
|
+
measurements[key] = size
|
|
352
|
+
return size
|
|
172
353
|
}
|
|
173
354
|
|
|
174
355
|
override func layoutSubviews() {
|
|
175
356
|
super.layoutSubviews()
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
357
|
+
craftApplyCornerRadius()
|
|
358
|
+
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
359
|
+
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
360
|
+
for (child, frame) in result.frames {
|
|
361
|
+
let snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
|
|
362
|
+
if child.frame != snapped { child.frame = snapped }
|
|
363
|
+
child.craftApplyCornerRadius()
|
|
180
364
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private struct Item {
|
|
368
|
+
let view: UIView
|
|
369
|
+
let style: CraftNativeLayoutStyle
|
|
370
|
+
let align: String
|
|
371
|
+
let mainLeading: CGFloat
|
|
372
|
+
let mainTrailing: CGFloat
|
|
373
|
+
let crossLeading: CGFloat
|
|
374
|
+
let crossTrailing: CGFloat
|
|
375
|
+
let minMain: CGFloat?
|
|
376
|
+
let maxMain: CGFloat?
|
|
377
|
+
let minCross: CGFloat?
|
|
378
|
+
let maxCross: CGFloat?
|
|
379
|
+
let fixedCross: CGFloat?
|
|
380
|
+
let grow: CGFloat
|
|
381
|
+
let shrink: CGFloat
|
|
382
|
+
var basis: CGFloat
|
|
185
383
|
var main: CGFloat = 0
|
|
186
384
|
var cross: CGFloat = 0
|
|
187
385
|
|
|
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
|
-
|
|
386
|
+
var mainMargins: CGFloat { mainLeading + mainTrailing }
|
|
387
|
+
var crossMargins: CGFloat { crossLeading + crossTrailing }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private func clamp(_ value: CGFloat, _ lower: CGFloat?, _ upper: CGFloat?) -> CGFloat {
|
|
391
|
+
var result = value
|
|
392
|
+
if let upper { result = min(result, upper) }
|
|
393
|
+
if let lower { result = max(result, lower) }
|
|
394
|
+
return max(0, result)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/// Children that take part in the flow: shown, not a modal overlay.
|
|
398
|
+
private var visibleChildren: [UIView] {
|
|
399
|
+
flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// The flexbox algorithm, single pass. `width`/`height` are this view's
|
|
403
|
+
/// outer size when its parent fixes it, nil when it sizes to its content
|
|
404
|
+
/// within `maxWidth`/`maxHeight`. Returns its outer size and, when
|
|
405
|
+
/// placing, every child's frame in its own coordinates.
|
|
406
|
+
private func layout(
|
|
407
|
+
width: CGFloat?,
|
|
408
|
+
height: CGFloat?,
|
|
409
|
+
maxWidth: CGFloat,
|
|
410
|
+
maxHeight: CGFloat,
|
|
411
|
+
place: Bool
|
|
412
|
+
) -> (size: CGSize, frames: [(UIView, CGRect)]) {
|
|
413
|
+
let children = visibleChildren
|
|
414
|
+
let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
|
|
415
|
+
let horizontal = axis == .horizontal
|
|
416
|
+
let paddingWidth = padding.left + padding.right
|
|
417
|
+
let paddingHeight = padding.top + padding.bottom
|
|
418
|
+
let innerWidth = width.map { max(0, $0 - paddingWidth) }
|
|
419
|
+
let innerHeight = height.map { max(0, $0 - paddingHeight) }
|
|
420
|
+
let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
|
|
421
|
+
let roomHeight = max(0, (height ?? maxHeight) - paddingHeight)
|
|
422
|
+
|
|
423
|
+
var frames: [(UIView, CGRect)] = []
|
|
424
|
+
var contentSize: CGSize
|
|
425
|
+
if grid {
|
|
426
|
+
contentSize = layoutGrid(flow, innerWidth: innerWidth, roomWidth: roomWidth, innerHeight: innerHeight, place: place, frames: &frames)
|
|
427
|
+
} else {
|
|
428
|
+
contentSize = layoutFlex(
|
|
429
|
+
flow,
|
|
430
|
+
horizontal: horizontal,
|
|
431
|
+
innerWidth: innerWidth,
|
|
432
|
+
innerHeight: innerHeight,
|
|
433
|
+
roomWidth: roomWidth,
|
|
434
|
+
roomHeight: roomHeight,
|
|
435
|
+
place: place,
|
|
436
|
+
frames: &frames
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
|
|
440
|
+
|
|
441
|
+
if place {
|
|
442
|
+
let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
|
|
443
|
+
for child in children where child.craftLayoutStyle.position == "absolute" {
|
|
444
|
+
let style = child.craftLayoutStyle
|
|
445
|
+
let fixedWidth = style.left != nil && style.right != nil
|
|
446
|
+
? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
|
|
447
|
+
: style.width?.resolve(content.width)
|
|
448
|
+
let fixedHeight = style.top != nil && style.bottom != nil
|
|
449
|
+
? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
|
|
450
|
+
: style.height?.resolve(content.height)
|
|
451
|
+
let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
|
|
452
|
+
let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
|
|
453
|
+
let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
|
|
454
|
+
frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
|
|
243
455
|
}
|
|
244
|
-
crossOffset += lineCross + crossGapValue
|
|
245
456
|
}
|
|
457
|
+
return (size, frames)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private func layoutFlex(
|
|
461
|
+
_ children: [UIView],
|
|
462
|
+
horizontal: Bool,
|
|
463
|
+
innerWidth: CGFloat?,
|
|
464
|
+
innerHeight: CGFloat?,
|
|
465
|
+
roomWidth: CGFloat,
|
|
466
|
+
roomHeight: CGFloat,
|
|
467
|
+
place: Bool,
|
|
468
|
+
frames: inout [(UIView, CGRect)]
|
|
469
|
+
) -> CGSize {
|
|
470
|
+
let definiteMain = horizontal ? innerWidth : innerHeight
|
|
471
|
+
let definiteCross = horizontal ? innerHeight : innerWidth
|
|
472
|
+
let roomMain = horizontal ? roomWidth : roomHeight
|
|
473
|
+
let roomCross = horizontal ? roomHeight : roomWidth
|
|
474
|
+
let mainGap = horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
|
|
475
|
+
let crossGap = horizontal ? (rowGap ?? 0) : (columnGap ?? 0)
|
|
476
|
+
|
|
477
|
+
func measure(_ view: UIView, main: CGFloat?, cross: CGFloat?, mainLimit: CGFloat, crossLimit: CGFloat) -> (main: CGFloat, cross: CGFloat) {
|
|
478
|
+
let size = horizontal
|
|
479
|
+
? CraftNativeFlexLayout.measure(view, width: main, height: cross, maxWidth: mainLimit, maxHeight: crossLimit)
|
|
480
|
+
: CraftNativeFlexLayout.measure(view, width: cross, height: main, maxWidth: crossLimit, maxHeight: mainLimit)
|
|
481
|
+
return horizontal ? (size.width, size.height) : (size.height, size.width)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
var items: [Item] = children.map { view in
|
|
485
|
+
let style = view.craftLayoutStyle
|
|
486
|
+
let mainLeading = horizontal ? style.marginLeft : style.marginTop
|
|
487
|
+
let mainTrailing = horizontal ? style.marginRight : style.marginBottom
|
|
488
|
+
let crossLeading = horizontal ? style.marginTop : style.marginLeft
|
|
489
|
+
let crossTrailing = horizontal ? style.marginBottom : style.marginRight
|
|
490
|
+
let align = style.alignSelf ?? alignItems
|
|
491
|
+
let explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
|
|
492
|
+
let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
|
|
493
|
+
let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
|
|
494
|
+
let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
|
|
495
|
+
let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
|
|
496
|
+
let maxCross = (horizontal ? style.maxHeight : style.maxWidth)?.resolve(definiteCross)
|
|
497
|
+
var fixedCross = explicitCross
|
|
498
|
+
if fixedCross == nil, align == "stretch", !wrap, let definiteCross {
|
|
499
|
+
fixedCross = definiteCross - crossLeading - crossTrailing
|
|
500
|
+
}
|
|
501
|
+
fixedCross = fixedCross.map { clamp($0, minCross, maxCross) }
|
|
502
|
+
let defaultShrink: CGFloat
|
|
503
|
+
if view is UIScrollView {
|
|
504
|
+
defaultShrink = 1
|
|
505
|
+
} else if view is UILabel {
|
|
506
|
+
defaultShrink = horizontal ? 1 : 0
|
|
507
|
+
} else {
|
|
508
|
+
defaultShrink = 0
|
|
509
|
+
}
|
|
246
510
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
let
|
|
252
|
-
let
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
511
|
+
// The basis: a flex-basis, else the explicit size, else the
|
|
512
|
+
// content's size. A zero basis (`flex-1`) means "share the free
|
|
513
|
+
// space" only when there is a free space to share; in a container
|
|
514
|
+
// sized by its content, the content is the basis, as on the web.
|
|
515
|
+
let basis: CGFloat
|
|
516
|
+
if let flexBasis = style.flexBasis?.resolve(definiteMain), definiteMain != nil || flexBasis > 0 {
|
|
517
|
+
basis = flexBasis
|
|
518
|
+
} else if let explicitMain {
|
|
519
|
+
basis = explicitMain
|
|
520
|
+
} else {
|
|
521
|
+
basis = measure(
|
|
522
|
+
view,
|
|
523
|
+
main: nil,
|
|
524
|
+
cross: fixedCross,
|
|
525
|
+
mainLimit: max(0, (maxMain ?? roomMain) - mainLeading - mainTrailing),
|
|
526
|
+
crossLimit: max(0, (fixedCross ?? (maxCross ?? roomCross - crossLeading - crossTrailing)))
|
|
527
|
+
).main
|
|
528
|
+
}
|
|
529
|
+
return Item(
|
|
530
|
+
view: view,
|
|
531
|
+
style: style,
|
|
532
|
+
align: align,
|
|
533
|
+
mainLeading: mainLeading,
|
|
534
|
+
mainTrailing: mainTrailing,
|
|
535
|
+
crossLeading: crossLeading,
|
|
536
|
+
crossTrailing: crossTrailing,
|
|
537
|
+
minMain: minMain,
|
|
538
|
+
maxMain: maxMain,
|
|
539
|
+
minCross: minCross,
|
|
540
|
+
maxCross: maxCross,
|
|
541
|
+
fixedCross: fixedCross,
|
|
542
|
+
grow: style.flexGrow,
|
|
543
|
+
shrink: style.flexShrink ?? defaultShrink,
|
|
544
|
+
basis: clamp(basis, minMain, maxMain)
|
|
545
|
+
)
|
|
256
546
|
}
|
|
257
|
-
}
|
|
258
547
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
let
|
|
262
|
-
|
|
263
|
-
var
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
548
|
+
// Lines: one, or as many as wrapping needs.
|
|
549
|
+
var lines: [Range<Int>] = []
|
|
550
|
+
let lineLimit = definiteMain ?? roomMain
|
|
551
|
+
var start = 0
|
|
552
|
+
var used: CGFloat = 0
|
|
553
|
+
for index in items.indices {
|
|
554
|
+
let size = items[index].basis + items[index].mainMargins
|
|
555
|
+
let next = index == start ? size : used + mainGap + size
|
|
556
|
+
if wrap, index > start, next > lineLimit {
|
|
557
|
+
lines.append(start..<index)
|
|
558
|
+
start = index
|
|
559
|
+
used = size
|
|
560
|
+
} else {
|
|
561
|
+
used = next
|
|
562
|
+
}
|
|
267
563
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
564
|
+
if start < items.count { lines.append(start..<items.count) }
|
|
565
|
+
|
|
566
|
+
var lineMains: [CGFloat] = []
|
|
567
|
+
var lineCrosses: [CGFloat] = []
|
|
568
|
+
for line in lines {
|
|
569
|
+
let gaps = CGFloat(max(0, line.count - 1)) * mainGap
|
|
570
|
+
let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + gaps
|
|
571
|
+
// A row sized by its content is as wide as that content, up to the
|
|
572
|
+
// room it has; a column sized by its content is as tall as it is.
|
|
573
|
+
let lineMain = definiteMain ?? (horizontal ? min(hypothetical, roomMain) : hypothetical)
|
|
574
|
+
let free = lineMain - hypothetical
|
|
575
|
+
if free > 0 {
|
|
576
|
+
let total = line.reduce(0) { $0 + items[$1].grow }
|
|
577
|
+
for index in line {
|
|
578
|
+
let share = total > 0 ? free * items[index].grow / total : 0
|
|
579
|
+
items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
|
|
580
|
+
}
|
|
581
|
+
} else if free < 0 {
|
|
582
|
+
let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
|
|
583
|
+
for index in line {
|
|
584
|
+
let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
|
|
585
|
+
items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
|
|
586
|
+
}
|
|
587
|
+
} else {
|
|
588
|
+
for index in line { items[index].main = items[index].basis }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
var lineCross: CGFloat = 0
|
|
592
|
+
for index in line {
|
|
593
|
+
let item = items[index]
|
|
594
|
+
if let fixedCross = item.fixedCross {
|
|
595
|
+
items[index].cross = fixedCross
|
|
596
|
+
} else {
|
|
597
|
+
let measured = measure(
|
|
598
|
+
item.view,
|
|
599
|
+
main: item.main,
|
|
600
|
+
cross: nil,
|
|
601
|
+
mainLimit: item.main,
|
|
602
|
+
crossLimit: max(0, (item.maxCross ?? roomCross) - item.crossMargins)
|
|
603
|
+
)
|
|
604
|
+
items[index].cross = clamp(measured.cross, item.minCross, item.maxCross)
|
|
605
|
+
}
|
|
606
|
+
lineCross = max(lineCross, items[index].cross + item.crossMargins)
|
|
607
|
+
}
|
|
608
|
+
// A single line is as thick as the container when the container's
|
|
609
|
+
// size is known: stretched children fill it, others align in it.
|
|
610
|
+
if !wrap, let definiteCross { lineCross = definiteCross }
|
|
611
|
+
for index in line where items[index].fixedCross == nil && items[index].align == "stretch" {
|
|
612
|
+
let item = items[index]
|
|
613
|
+
if case .some = (horizontal ? item.style.height : item.style.width) { continue }
|
|
614
|
+
items[index].cross = clamp(lineCross - item.crossMargins, item.minCross, item.maxCross)
|
|
615
|
+
}
|
|
616
|
+
lineMains.append(line.reduce(0) { $0 + items[$1].main + items[$1].mainMargins } + gaps)
|
|
617
|
+
lineCrosses.append(lineCross)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let contentMain = definiteMain ?? (lineMains.max() ?? 0)
|
|
621
|
+
let contentCross = definiteCross
|
|
622
|
+
?? (lineCrosses.reduce(0, +) + CGFloat(max(0, lineCrosses.count - 1)) * crossGap)
|
|
623
|
+
|
|
624
|
+
if place {
|
|
625
|
+
var crossOffset: CGFloat = 0
|
|
626
|
+
for (lineIndex, line) in lines.enumerated() {
|
|
627
|
+
let free = max(0, contentMain - lineMains[lineIndex])
|
|
628
|
+
let count = line.count
|
|
629
|
+
let leading: CGFloat
|
|
630
|
+
let between: CGFloat
|
|
631
|
+
switch justifyContent {
|
|
632
|
+
case "center": leading = free / 2; between = 0
|
|
633
|
+
case "flex-end": leading = free; between = 0
|
|
634
|
+
case "space-between": leading = 0; between = count > 1 ? free / CGFloat(count - 1) : 0
|
|
635
|
+
case "space-around": leading = free / CGFloat(count * 2); between = free / CGFloat(count)
|
|
636
|
+
case "space-evenly": leading = free / CGFloat(count + 1); between = free / CGFloat(count + 1)
|
|
637
|
+
default: leading = 0; between = 0
|
|
638
|
+
}
|
|
639
|
+
let lineCross = lineCrosses[lineIndex]
|
|
640
|
+
var mainOffset = leading
|
|
641
|
+
for index in line {
|
|
642
|
+
let item = items[index]
|
|
643
|
+
let crossPosition: CGFloat
|
|
644
|
+
switch item.align {
|
|
645
|
+
case "center": crossPosition = item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
|
|
646
|
+
case "flex-end": crossPosition = lineCross - item.crossTrailing - item.cross
|
|
647
|
+
default: crossPosition = item.crossLeading
|
|
648
|
+
}
|
|
649
|
+
let mainPosition = mainOffset + item.mainLeading
|
|
650
|
+
let frame = horizontal
|
|
651
|
+
? CGRect(x: padding.left + mainPosition, y: padding.top + crossOffset + crossPosition, width: item.main, height: item.cross)
|
|
652
|
+
: CGRect(x: padding.left + crossOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
|
|
653
|
+
frames.append((item.view, frame))
|
|
654
|
+
mainOffset += item.mainMargins + item.main + mainGap + between
|
|
655
|
+
}
|
|
656
|
+
crossOffset += lineCross + crossGap
|
|
657
|
+
}
|
|
275
658
|
}
|
|
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)
|
|
659
|
+
return horizontal ? CGSize(width: contentMain, height: contentCross) : CGSize(width: contentCross, height: contentMain)
|
|
279
660
|
}
|
|
280
661
|
|
|
281
|
-
private func layoutGrid(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
662
|
+
private func layoutGrid(
|
|
663
|
+
_ children: [UIView],
|
|
664
|
+
innerWidth: CGFloat?,
|
|
665
|
+
roomWidth: CGFloat,
|
|
666
|
+
innerHeight: CGFloat?,
|
|
667
|
+
place: Bool,
|
|
668
|
+
frames: inout [(UIView, CGRect)]
|
|
669
|
+
) -> CGSize {
|
|
286
670
|
let columns = max(1, gridColumns)
|
|
287
671
|
let horizontalGap = columnGap ?? gap
|
|
288
672
|
let verticalGap = rowGap ?? gap
|
|
289
|
-
let
|
|
673
|
+
let gaps = CGFloat(columns - 1) * horizontalGap
|
|
674
|
+
var cellWidth: CGFloat
|
|
675
|
+
if let innerWidth {
|
|
676
|
+
cellWidth = max(0, (innerWidth - gaps) / CGFloat(columns))
|
|
677
|
+
} else {
|
|
678
|
+
// Sized by content: the widest cell, as long as the row fits.
|
|
679
|
+
let widest = children.map { child -> CGFloat in
|
|
680
|
+
let style = child.craftLayoutStyle
|
|
681
|
+
return CraftNativeFlexLayout.measure(child, width: style.width?.resolve(nil), height: nil, maxWidth: roomWidth, maxHeight: CraftNativeFlexLayout.unbounded).width
|
|
682
|
+
+ style.marginLeft + style.marginRight
|
|
683
|
+
}.max() ?? 0
|
|
684
|
+
cellWidth = min(widest, max(0, (roomWidth - gaps) / CGFloat(columns)))
|
|
685
|
+
}
|
|
290
686
|
var rowHeights: [CGFloat] = []
|
|
291
687
|
var sizes: [CGSize] = []
|
|
292
|
-
for (index, child) in
|
|
293
|
-
let style =
|
|
294
|
-
let
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
688
|
+
for (index, child) in children.enumerated() {
|
|
689
|
+
let style = child.craftLayoutStyle
|
|
690
|
+
let available = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
691
|
+
let align = style.alignSelf ?? alignItems
|
|
692
|
+
let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
|
|
693
|
+
let size = CraftNativeFlexLayout.measure(
|
|
694
|
+
child,
|
|
695
|
+
width: fixedWidth.map { min($0, available) },
|
|
696
|
+
height: style.height?.resolve(nil),
|
|
697
|
+
maxWidth: available,
|
|
698
|
+
maxHeight: CraftNativeFlexLayout.unbounded
|
|
298
699
|
)
|
|
299
700
|
sizes.append(size)
|
|
300
701
|
let row = index / columns
|
|
301
702
|
while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
|
|
302
703
|
rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
|
|
303
704
|
}
|
|
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)
|
|
705
|
+
let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
|
|
706
|
+
let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
707
|
+
if place {
|
|
708
|
+
var rowTop = padding.top
|
|
709
|
+
for (index, child) in children.enumerated() {
|
|
710
|
+
let style = child.craftLayoutStyle
|
|
711
|
+
let row = index / columns
|
|
712
|
+
let column = index % columns
|
|
713
|
+
let rowHeight = rowHeights[row]
|
|
714
|
+
let size = sizes[index]
|
|
715
|
+
let align = style.alignSelf ?? alignItems
|
|
716
|
+
let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
717
|
+
let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
|
|
718
|
+
let childWidth = min(availableWidth, size.width)
|
|
719
|
+
let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
|
|
720
|
+
let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
|
|
721
|
+
+ (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
|
|
722
|
+
let y = rowTop + style.marginTop
|
|
723
|
+
+ (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
|
|
724
|
+
frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
|
|
725
|
+
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + verticalGap }
|
|
374
726
|
}
|
|
375
|
-
return CGSize(width: size.width, height: clamp(max(0, size.height + delta), min: style.minHeight, max: style.maxHeight))
|
|
376
727
|
}
|
|
728
|
+
return CGSize(width: width, height: innerHeight ?? height)
|
|
377
729
|
}
|
|
730
|
+
}
|
|
378
731
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
732
|
+
private final class CraftNativeModalView: UIView {
|
|
733
|
+
let contentStack = CraftNativeFlowView()
|
|
734
|
+
private let blocker = UIView()
|
|
735
|
+
private var renderedVisible = false
|
|
736
|
+
var onRequestClose: (() -> Void)?
|
|
737
|
+
|
|
738
|
+
var transparent = false {
|
|
739
|
+
didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
|
|
384
740
|
}
|
|
385
741
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
742
|
+
override init(frame: CGRect) {
|
|
743
|
+
super.init(frame: frame)
|
|
744
|
+
isUserInteractionEnabled = true
|
|
745
|
+
blocker.isUserInteractionEnabled = true
|
|
746
|
+
blocker.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
|
747
|
+
blocker.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blockerTapped)))
|
|
748
|
+
addSubview(blocker)
|
|
749
|
+
contentStack.backgroundColor = .clear
|
|
750
|
+
addSubview(contentStack)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
required init?(coder: NSCoder) { nil }
|
|
754
|
+
|
|
755
|
+
func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
|
|
756
|
+
guard renderedVisible != visible || isHidden != !visible else { return }
|
|
757
|
+
renderedVisible = visible
|
|
758
|
+
guard visible else {
|
|
759
|
+
isHidden = true
|
|
760
|
+
alpha = 0
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
isHidden = false
|
|
764
|
+
if animationType == "none" {
|
|
765
|
+
alpha = opacity
|
|
766
|
+
} else {
|
|
767
|
+
alpha = 0
|
|
768
|
+
UIView.animate(withDuration: animationType == "slide" ? 0.24 : 0.18) { self.alpha = opacity }
|
|
395
769
|
}
|
|
396
770
|
}
|
|
771
|
+
|
|
772
|
+
@objc private func blockerTapped() { onRequestClose?() }
|
|
773
|
+
|
|
774
|
+
override func layoutSubviews() {
|
|
775
|
+
super.layoutSubviews()
|
|
776
|
+
blocker.frame = bounds
|
|
777
|
+
contentStack.frame = bounds
|
|
778
|
+
}
|
|
397
779
|
}
|
|
398
780
|
|
|
399
|
-
private final class CraftNativeScrollView: UIScrollView {
|
|
781
|
+
private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
|
|
400
782
|
let contentStack = CraftNativeFlowView()
|
|
401
|
-
private
|
|
783
|
+
private let keyboardTapGesture = UITapGestureRecognizer()
|
|
784
|
+
private var keyboardShouldPersistTaps = "never"
|
|
785
|
+
private var contentNeedsLayout = true
|
|
786
|
+
private var laidOutSize: CGSize = .zero
|
|
787
|
+
private let pullToRefresh = UIRefreshControl()
|
|
788
|
+
private var refreshHandler: (() -> Void)?
|
|
402
789
|
|
|
403
790
|
override init(frame: CGRect) {
|
|
404
791
|
super.init(frame: frame)
|
|
405
|
-
|
|
792
|
+
keyboardTapGesture.cancelsTouchesInView = false
|
|
793
|
+
keyboardTapGesture.delegate = self
|
|
794
|
+
keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
|
|
795
|
+
addGestureRecognizer(keyboardTapGesture)
|
|
796
|
+
pullToRefresh.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
|
406
797
|
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
798
|
setAxis(.vertical)
|
|
414
799
|
}
|
|
415
800
|
|
|
416
801
|
required init?(coder: NSCoder) { nil }
|
|
417
802
|
|
|
803
|
+
var isVertical: Bool { contentStack.axis == .vertical }
|
|
804
|
+
|
|
805
|
+
/// Lays the content out again on the next pass. Scrolling alone does not:
|
|
806
|
+
/// a scroll view lays out on every frame of a scroll.
|
|
807
|
+
func setContentNeedsLayout() {
|
|
808
|
+
contentNeedsLayout = true
|
|
809
|
+
setNeedsLayout()
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/// The size of the content laid out across the visible width (or, sideways,
|
|
813
|
+
/// height), which is also the scroll view's own size when nothing fixes it.
|
|
814
|
+
func contentFitting(width: CGFloat?, height: CGFloat?) -> CGSize {
|
|
815
|
+
if isVertical {
|
|
816
|
+
let contentWidth = max(0, (width ?? bounds.width) - adjustedContentInset.left - adjustedContentInset.right)
|
|
817
|
+
return contentStack.measure(width: contentWidth, height: nil, maxWidth: contentWidth, maxHeight: CraftNativeFlexLayout.unbounded)
|
|
818
|
+
}
|
|
819
|
+
let contentHeight = height.map { max(0, $0 - adjustedContentInset.top - adjustedContentInset.bottom) }
|
|
820
|
+
return contentStack.measure(width: nil, height: contentHeight, maxWidth: CraftNativeFlexLayout.unbounded, maxHeight: contentHeight ?? CraftNativeFlexLayout.unbounded)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
func measure(width: CGFloat?, height: CGFloat?, maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
|
|
824
|
+
if isVertical {
|
|
825
|
+
let resolvedWidth = width ?? maxWidth
|
|
826
|
+
return CGSize(width: resolvedWidth, height: height ?? contentFitting(width: resolvedWidth, height: nil).height)
|
|
827
|
+
}
|
|
828
|
+
let content = contentFitting(width: nil, height: height)
|
|
829
|
+
return CGSize(width: width ?? min(content.width, maxWidth), height: height ?? content.height)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
override func layoutSubviews() {
|
|
833
|
+
super.layoutSubviews()
|
|
834
|
+
craftApplyCornerRadius()
|
|
835
|
+
guard contentNeedsLayout || laidOutSize != bounds.size else { return }
|
|
836
|
+
contentNeedsLayout = false
|
|
837
|
+
laidOutSize = bounds.size
|
|
838
|
+
var size = contentFitting(width: bounds.width, height: bounds.height)
|
|
839
|
+
if isVertical {
|
|
840
|
+
size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
|
|
841
|
+
} else {
|
|
842
|
+
size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
|
|
843
|
+
}
|
|
844
|
+
let frame = CGRect(origin: .zero, size: size)
|
|
845
|
+
if contentStack.frame != frame { contentStack.frame = frame }
|
|
846
|
+
if contentSize != size { contentSize = size }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
override func adjustedContentInsetDidChange() {
|
|
850
|
+
super.adjustedContentInsetDidChange()
|
|
851
|
+
setContentNeedsLayout()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private var refreshingProp = false
|
|
855
|
+
|
|
856
|
+
/// `onRefresh` and `refreshing`: the system's pull to refresh, controlled
|
|
857
|
+
/// as in React Native. The spinner shows while `refreshing` is true; a
|
|
858
|
+
/// pull that the screen does not answer with `refreshing` true ends at
|
|
859
|
+
/// once instead of spinning forever.
|
|
860
|
+
func setRefreshHandler(_ handler: (() -> Void)?, refreshing: Bool) {
|
|
861
|
+
refreshHandler = handler
|
|
862
|
+
refreshingProp = refreshing
|
|
863
|
+
if handler == nil {
|
|
864
|
+
if pullToRefresh.isRefreshing { pullToRefresh.endRefreshing() }
|
|
865
|
+
if refreshControl === pullToRefresh { refreshControl = nil }
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
if refreshControl !== pullToRefresh { refreshControl = pullToRefresh }
|
|
869
|
+
if refreshing, !pullToRefresh.isRefreshing {
|
|
870
|
+
pullToRefresh.beginRefreshing()
|
|
871
|
+
} else if !refreshing, pullToRefresh.isRefreshing {
|
|
872
|
+
pullToRefresh.endRefreshing()
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
|
|
877
|
+
|
|
878
|
+
@objc private func refreshPulled() {
|
|
879
|
+
refreshHandler?()
|
|
880
|
+
// The handler's synchronous render has landed by now.
|
|
881
|
+
DispatchQueue.main.async { [weak self] in
|
|
882
|
+
guard let self, !self.refreshingProp, self.pullToRefresh.isRefreshing else { return }
|
|
883
|
+
self.pullToRefresh.endRefreshing()
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
func setKeyboardShouldPersistTaps(_ value: Any?) {
|
|
888
|
+
let requested = value as? String
|
|
889
|
+
keyboardShouldPersistTaps = ["always", "handled", "never"].contains(requested) ? requested! : "never"
|
|
890
|
+
keyboardTapGesture.isEnabled = keyboardShouldPersistTaps != "always"
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
@objc private func keyboardTap() {
|
|
894
|
+
guard keyboardShouldPersistTaps != "always" else { return }
|
|
895
|
+
if keyboardShouldPersistTaps == "handled" {
|
|
896
|
+
let point = keyboardTapGesture.location(in: self)
|
|
897
|
+
var hit = hitTest(point, with: nil)
|
|
898
|
+
while let view = hit, view !== self {
|
|
899
|
+
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
|
|
900
|
+
hit = view.superview
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
window?.endEditing(true)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
907
|
+
true
|
|
908
|
+
}
|
|
909
|
+
|
|
418
910
|
func setAxis(_ axis: NSLayoutConstraint.Axis) {
|
|
911
|
+
if contentStack.axis != axis { setContentNeedsLayout() }
|
|
419
912
|
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
913
|
alwaysBounceVertical = axis == .vertical
|
|
426
914
|
alwaysBounceHorizontal = axis == .horizontal
|
|
427
915
|
}
|
|
428
916
|
}
|
|
429
917
|
|
|
918
|
+
/// Text with padding of its own: the padding is inside the label, around
|
|
919
|
+
/// the text, and counts in its measured size.
|
|
920
|
+
private final class CraftNativeLabel: UILabel {
|
|
921
|
+
var insets = UIEdgeInsets.zero {
|
|
922
|
+
didSet {
|
|
923
|
+
guard insets != oldValue else { return }
|
|
924
|
+
invalidateIntrinsicContentSize()
|
|
925
|
+
setNeedsDisplay()
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
override func drawText(in rect: CGRect) {
|
|
930
|
+
super.drawText(in: rect.inset(by: insets))
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect {
|
|
934
|
+
guard insets != .zero else { return super.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) }
|
|
935
|
+
let inner = super.textRect(forBounds: bounds.inset(by: insets), limitedToNumberOfLines: numberOfLines)
|
|
936
|
+
return CGRect(
|
|
937
|
+
x: inner.minX - insets.left,
|
|
938
|
+
y: inner.minY - insets.top,
|
|
939
|
+
width: inner.width + insets.left + insets.right,
|
|
940
|
+
height: inner.height + insets.top + insets.bottom
|
|
941
|
+
)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
override func layoutSubviews() {
|
|
945
|
+
super.layoutSubviews()
|
|
946
|
+
craftApplyCornerRadius()
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
430
950
|
private final class CraftNativeFlexSpacer: UIView {}
|
|
431
951
|
|
|
952
|
+
/// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
|
|
953
|
+
/// when the style gives it no size at all.
|
|
954
|
+
private final class CraftNativeIconView: UIImageView {
|
|
955
|
+
var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
|
|
956
|
+
|
|
957
|
+
override var intrinsicContentSize: CGSize {
|
|
958
|
+
if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
|
|
959
|
+
return super.intrinsicContentSize
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
432
963
|
private final class CraftNativeTextView: UITextView {
|
|
433
964
|
private let placeholderLabel = UILabel()
|
|
434
965
|
var placeholder: String? {
|
|
@@ -482,7 +1013,7 @@ private final class CraftNativeTextView: UITextView {
|
|
|
482
1013
|
}
|
|
483
1014
|
}
|
|
484
1015
|
|
|
485
|
-
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate {
|
|
1016
|
+
final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
|
|
486
1017
|
private final class RenderedNode {
|
|
487
1018
|
let identity: String
|
|
488
1019
|
let type: String
|
|
@@ -506,6 +1037,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
506
1037
|
private let jsContext = JSContext()!
|
|
507
1038
|
private let rootStack = CraftNativeFlowView()
|
|
508
1039
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
1040
|
+
private var pressInHandlers: [ObjectIdentifier: String] = [:]
|
|
1041
|
+
private var pressOutHandlers: [ObjectIdentifier: String] = [:]
|
|
509
1042
|
private var focusHandlers: [ObjectIdentifier: String] = [:]
|
|
510
1043
|
private var blurHandlers: [ObjectIdentifier: String] = [:]
|
|
511
1044
|
private var endEditingHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -521,10 +1054,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
521
1054
|
private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
|
|
522
1055
|
private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
|
|
523
1056
|
private var sliderSteps: [ObjectIdentifier: Float] = [:]
|
|
1057
|
+
private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
|
|
1058
|
+
private var modalVisibility: [ObjectIdentifier: Bool] = [:]
|
|
524
1059
|
private var layoutHandlers: [ObjectIdentifier: String] = [:]
|
|
525
1060
|
private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
|
|
526
1061
|
private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
|
|
527
1062
|
private var longPressRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
|
|
1063
|
+
private var pressFeedbackRecognizers: [ObjectIdentifier: UILongPressGestureRecognizer] = [:]
|
|
528
1064
|
private var imageSources: [ObjectIdentifier: String] = [:]
|
|
529
1065
|
private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
|
|
530
1066
|
private var imageErrors: [ObjectIdentifier: String] = [:]
|
|
@@ -541,8 +1077,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
541
1077
|
private let capabilityScope = UUID().uuidString
|
|
542
1078
|
private var pendingCapabilityRequests = Set<String>()
|
|
543
1079
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
1080
|
+
private var pendingTimers: [Int: DispatchWorkItem] = [:]
|
|
544
1081
|
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
545
1082
|
private var deepLinkListener: UUID?
|
|
1083
|
+
private var keyboardSafeAreaInset: CGFloat = 0
|
|
1084
|
+
/// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
|
|
1085
|
+
/// app's native screens: told of the first frame, of a first frame that
|
|
1086
|
+
/// could not be drawn, and of `craft.navigation.open(path)`.
|
|
1087
|
+
weak var hybridEvents: CraftHybridScreenEvents?
|
|
1088
|
+
private var hybridRendered = false
|
|
546
1089
|
|
|
547
1090
|
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
|
|
548
1091
|
self.config = config
|
|
@@ -558,6 +1101,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
558
1101
|
deinit {
|
|
559
1102
|
imageTasks.values.forEach { $0.cancel() }
|
|
560
1103
|
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
1104
|
+
pendingTimers.values.forEach { $0.cancel() }
|
|
561
1105
|
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
562
1106
|
lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
|
|
563
1107
|
if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
|
|
@@ -571,8 +1115,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
571
1115
|
rootStack.alignItems = "stretch"
|
|
572
1116
|
rootStack.translatesAutoresizingMaskIntoConstraints = false
|
|
573
1117
|
view.addSubview(rootStack)
|
|
1118
|
+
rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
|
|
1119
|
+
rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
|
|
574
1120
|
NSLayoutConstraint.activate([
|
|
575
|
-
|
|
1121
|
+
rootTopToSafeArea!,
|
|
576
1122
|
rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
577
1123
|
rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
578
1124
|
rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
|
|
@@ -586,10 +1132,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
586
1132
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
587
1133
|
guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
|
|
588
1134
|
refreshTraitDefaults()
|
|
1135
|
+
send(type: "APPEARANCE", payload: ["colorScheme": colorScheme])
|
|
589
1136
|
}
|
|
590
1137
|
|
|
591
1138
|
override func viewWillAppear(_ animated: Bool) {
|
|
592
1139
|
super.viewWillAppear(animated)
|
|
1140
|
+
if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
|
|
1141
|
+
navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
|
|
1142
|
+
} else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
|
|
1143
|
+
navigationController?.viewControllers.first !== self {
|
|
1144
|
+
// A screen that did not ask for a hidden bar shows one, even after
|
|
1145
|
+
// a screen that hid its own.
|
|
1146
|
+
navigationController?.setNavigationBarHidden(false, animated: animated)
|
|
1147
|
+
}
|
|
593
1148
|
refreshTraitDefaults()
|
|
594
1149
|
}
|
|
595
1150
|
|
|
@@ -619,13 +1174,34 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
619
1174
|
jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
620
1175
|
}
|
|
621
1176
|
jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
622
|
-
jsContext.exceptionHandler = {
|
|
623
|
-
|
|
1177
|
+
jsContext.exceptionHandler = { [weak self] context, exception in
|
|
1178
|
+
let message = exception?.toString() ?? "unknown"
|
|
1179
|
+
let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
|
|
1180
|
+
CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
|
|
1181
|
+
self?.hybridFailed(message)
|
|
624
1182
|
}
|
|
625
1183
|
let postMessage: @convention(block) (String) -> Void = { [weak self] json in
|
|
626
1184
|
self?.receive(json)
|
|
627
1185
|
}
|
|
628
1186
|
jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
1187
|
+
// JavaScriptCore has no timers of its own. The callbacks stay in
|
|
1188
|
+
// JavaScript; native only owns the main-queue deadline, so route
|
|
1189
|
+
// teardown cancels every pending timer with the controller.
|
|
1190
|
+
let scheduleTimer: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
|
|
1191
|
+
guard let self = self else { return }
|
|
1192
|
+
let work = DispatchWorkItem { [weak self] in
|
|
1193
|
+
guard let self = self, self.pendingTimers.removeValue(forKey: id) != nil else { return }
|
|
1194
|
+
self.jsContext.objectForKeyedSubscript("__craftNativeFireTimer")?.call(withArguments: [id])
|
|
1195
|
+
}
|
|
1196
|
+
self.pendingTimers[id] = work
|
|
1197
|
+
let milliseconds = delay.isFinite ? Int(min(max(delay, 0), 2_147_483_647)) : 0
|
|
1198
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: work)
|
|
1199
|
+
}
|
|
1200
|
+
let cancelTimer: @convention(block) (Int) -> Void = { [weak self] id in
|
|
1201
|
+
self?.pendingTimers.removeValue(forKey: id)?.cancel()
|
|
1202
|
+
}
|
|
1203
|
+
jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
|
|
1204
|
+
jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
|
|
629
1205
|
let capabilities = CraftNativeActions.capabilities(config: config)
|
|
630
1206
|
let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
|
|
631
1207
|
let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
@@ -638,12 +1214,185 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
638
1214
|
capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
|
|
639
1215
|
capabilities: \(capabilityJSON),
|
|
640
1216
|
initialAppState: "\(CraftNativeActions.currentAppState())",
|
|
1217
|
+
colorScheme: "\(colorScheme)",
|
|
641
1218
|
postMessage: function(message) { craftNativePostMessage(message); },
|
|
642
1219
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
643
1220
|
};
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
1221
|
+
""")
|
|
1222
|
+
installHostAPIs()
|
|
1223
|
+
jsContext.evaluateScript("""
|
|
1224
|
+
if (typeof globalThis.setTimeout !== 'function') {
|
|
1225
|
+
(function() {
|
|
1226
|
+
var timers = new Map();
|
|
1227
|
+
var nextTimer = 0;
|
|
1228
|
+
globalThis.setTimeout = function(callback, delay) {
|
|
1229
|
+
var args = Array.prototype.slice.call(arguments, 2);
|
|
1230
|
+
var id = ++nextTimer;
|
|
1231
|
+
timers.set(id, function() {
|
|
1232
|
+
if (typeof callback === 'function') callback.apply(undefined, args);
|
|
1233
|
+
});
|
|
1234
|
+
craftNativeScheduleTimer(id, Number(delay) || 0);
|
|
1235
|
+
return id;
|
|
1236
|
+
};
|
|
1237
|
+
globalThis.clearTimeout = function(id) {
|
|
1238
|
+
if (timers.delete(id)) craftNativeCancelTimer(id);
|
|
1239
|
+
};
|
|
1240
|
+
globalThis.__craftNativeFireTimer = function(id) {
|
|
1241
|
+
var callback = timers.get(id);
|
|
1242
|
+
if (!callback) return;
|
|
1243
|
+
timers.delete(id);
|
|
1244
|
+
callback();
|
|
1245
|
+
};
|
|
1246
|
+
})();
|
|
1247
|
+
}
|
|
1248
|
+
""")
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
|
|
1252
|
+
/// the window's appearance once there is one, else the app's setting.
|
|
1253
|
+
private var colorScheme: String {
|
|
1254
|
+
let traits = [view.window?.traitCollection, CraftPresenter.keyWindow()?.traitCollection, traitCollection]
|
|
1255
|
+
if let style = traits.compactMap({ $0 }).first(where: { $0.userInterfaceStyle != .unspecified })?.userInterfaceStyle {
|
|
1256
|
+
return style == .dark ? "dark" : "light"
|
|
1257
|
+
}
|
|
1258
|
+
return config.appearance == "dark" ? "dark" : "light"
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/// The screen's name as its script knows it: the route the host opened,
|
|
1262
|
+
/// else the one a route bundle selected for itself.
|
|
1263
|
+
private func screenName(in context: JSContext? = nil) -> String {
|
|
1264
|
+
if let routeName, !routeName.isEmpty { return routeName }
|
|
1265
|
+
if let selected = (context ?? jsContext).objectForKeyedSubscript("__stxNativeRoute"), selected.isString {
|
|
1266
|
+
return selected.toString()
|
|
1267
|
+
}
|
|
1268
|
+
return "screen"
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/// Host functions every screen script can call synchronously, installed
|
|
1272
|
+
/// before the script runs (contract sections 3, 4 and 5):
|
|
1273
|
+
///
|
|
1274
|
+
/// - `console.*` writes to the unified log, subsystem `craft.native`,
|
|
1275
|
+
/// category the screen's name.
|
|
1276
|
+
/// - `craft.storage.getSync(key)` / `setSync(key, value)`: the store the
|
|
1277
|
+
/// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
|
|
1278
|
+
/// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
|
|
1279
|
+
/// promise): `Application Support/craft-snapshots/<name>.json`.
|
|
1280
|
+
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
1281
|
+
/// `craft.secureStorage.set` wrote.
|
|
1282
|
+
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
1283
|
+
/// rightButtons })`; a right button's tap is the event `navButton`.
|
|
1284
|
+
///
|
|
1285
|
+
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
1286
|
+
/// these: each namespace is a property whose setter adds them back to the
|
|
1287
|
+
/// object it is given.
|
|
1288
|
+
private func installHostAPIs() {
|
|
1289
|
+
let log: @convention(block) (String, String, String) -> Void = { level, category, message in
|
|
1290
|
+
CraftNativeConsole.write(level, category: category, message: message)
|
|
1291
|
+
}
|
|
1292
|
+
let storageGet: @convention(block) (String) -> String? = { key in
|
|
1293
|
+
CraftNativeActions.storageJSON(forKey: key)
|
|
1294
|
+
}
|
|
1295
|
+
let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
|
|
1296
|
+
CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
|
|
1297
|
+
}
|
|
1298
|
+
let snapshotGet: @convention(block) (String) -> String? = { name in
|
|
1299
|
+
CraftSnapshots.read(name)
|
|
1300
|
+
}
|
|
1301
|
+
let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
|
|
1302
|
+
CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
|
|
1303
|
+
done.call(withArguments: [ok])
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
let config = self.config
|
|
1307
|
+
let secureGet: @convention(block) (String) -> String? = { key in
|
|
1308
|
+
config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
|
|
1309
|
+
}
|
|
1310
|
+
let setOptions: @convention(block) (String) -> Void = { [weak self] json in
|
|
1311
|
+
guard let data = json.data(using: .utf8),
|
|
1312
|
+
let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
|
1313
|
+
self?.applyNavigationOptions(options)
|
|
1314
|
+
}
|
|
1315
|
+
jsContext.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
|
|
1316
|
+
jsContext.setObject(storageGet, forKeyedSubscript: "craftNativeStorageGet" as NSString)
|
|
1317
|
+
jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
|
|
1318
|
+
jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
|
|
1319
|
+
jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
|
|
1320
|
+
jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
|
|
1321
|
+
jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
|
|
1322
|
+
jsContext.evaluateScript("""
|
|
1323
|
+
(function() {
|
|
1324
|
+
function text(value) {
|
|
1325
|
+
if (typeof value === 'string') return value;
|
|
1326
|
+
if (value === undefined) return 'undefined';
|
|
1327
|
+
if (value instanceof Error) return value.stack ? value.message + '\\n' + value.stack : String(value);
|
|
1328
|
+
try { var json = JSON.stringify(value); return json === undefined ? String(value) : json; }
|
|
1329
|
+
catch (error) { return String(value); }
|
|
1330
|
+
}
|
|
1331
|
+
function screen() {
|
|
1332
|
+
var route = globalThis.__stxNativeRoute;
|
|
1333
|
+
return typeof route === 'string' && route ? route : 'screen';
|
|
1334
|
+
}
|
|
1335
|
+
function writer(level) {
|
|
1336
|
+
return function() {
|
|
1337
|
+
craftNativeLog(level, screen(), Array.prototype.map.call(arguments, text).join(' '));
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
globalThis.console = {
|
|
1341
|
+
log: writer('log'), info: writer('info'), debug: writer('debug'), trace: writer('debug'),
|
|
1342
|
+
warn: writer('warn'), error: writer('error')
|
|
1343
|
+
};
|
|
1344
|
+
|
|
1345
|
+
function parsed(json) {
|
|
1346
|
+
if (json === null || json === undefined) return null;
|
|
1347
|
+
try { return JSON.parse(json); } catch (error) { return null; }
|
|
1348
|
+
}
|
|
1349
|
+
function encoded(value) {
|
|
1350
|
+
return value === null || value === undefined ? null : JSON.stringify(value);
|
|
1351
|
+
}
|
|
1352
|
+
var craft = globalThis.craft = globalThis.craft || {};
|
|
1353
|
+
function keep(name, extras) {
|
|
1354
|
+
var current = {};
|
|
1355
|
+
function adopt(next) {
|
|
1356
|
+
current = next && typeof next === 'object' ? next : {};
|
|
1357
|
+
Object.keys(extras).forEach(function(key) {
|
|
1358
|
+
if (!(key in current)) current[key] = extras[key];
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
adopt(craft[name]);
|
|
1362
|
+
Object.defineProperty(craft, name, {
|
|
1363
|
+
configurable: true,
|
|
1364
|
+
enumerable: true,
|
|
1365
|
+
get: function() { return current; },
|
|
1366
|
+
set: adopt
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
keep('storage', {
|
|
1370
|
+
getSync: function(key) { return parsed(craftNativeStorageGet(String(key))); },
|
|
1371
|
+
setSync: function(key, value) {
|
|
1372
|
+
if (!craftNativeStorageSet(String(key), encoded(value))) throw new Error('craft.storage.setSync could not store ' + key);
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
keep('snapshots', {
|
|
1376
|
+
get: function(name) { return parsed(craftNativeSnapshotGet(String(name))); },
|
|
1377
|
+
set: function(name, value) {
|
|
1378
|
+
return new Promise(function(resolve, reject) {
|
|
1379
|
+
craftNativeSnapshotSet(String(name), encoded(value), function(ok) {
|
|
1380
|
+
if (ok) resolve();
|
|
1381
|
+
else reject(new Error('craft.snapshots.set could not write ' + name));
|
|
1382
|
+
});
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
keep('secureStorage', {
|
|
1387
|
+
getSync: function(key) {
|
|
1388
|
+
var value = craftNativeSecureGet(String(key));
|
|
1389
|
+
return value === undefined ? null : value;
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
keep('navigation', {
|
|
1393
|
+
setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
|
|
1394
|
+
});
|
|
1395
|
+
})();
|
|
647
1396
|
""")
|
|
648
1397
|
}
|
|
649
1398
|
|
|
@@ -651,13 +1400,108 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
651
1400
|
guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
|
|
652
1401
|
?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
|
|
653
1402
|
let script = try? String(contentsOf: url, encoding: .utf8) else {
|
|
654
|
-
showError("Missing dist/native-screen.js. Compile a .stx screen with stx
|
|
1403
|
+
showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
|
|
655
1404
|
return
|
|
656
1405
|
}
|
|
657
1406
|
jsContext.evaluateScript(script)
|
|
658
|
-
|
|
659
|
-
|
|
1407
|
+
// A bundle that names no route leaves `__stxNativeRoute` undefined;
|
|
1408
|
+
// its title is the app's, not the word "undefined". A title the
|
|
1409
|
+
// script set with `navigation.setOptions` wins over both.
|
|
1410
|
+
if routeName == nil, !titleFromOptions,
|
|
1411
|
+
let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute"), selected.isString,
|
|
1412
|
+
let name = selected.toString(), !name.isEmpty {
|
|
1413
|
+
navigationItem.title = name
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// MARK: Navigation bar options
|
|
1418
|
+
|
|
1419
|
+
private var titleFromOptions = false
|
|
1420
|
+
private var rootTopToSafeArea: NSLayoutConstraint?
|
|
1421
|
+
private var rootTopToView: NSLayoutConstraint?
|
|
1422
|
+
private var navigationBarHidden: Bool?
|
|
1423
|
+
private var prefersLargeTitle: Bool?
|
|
1424
|
+
private weak var trackedScrollView: UIScrollView?
|
|
1425
|
+
|
|
1426
|
+
/// `craft.navigation.setOptions`: this screen's bar. Every key is
|
|
1427
|
+
/// optional and only the keys given change.
|
|
1428
|
+
private func applyNavigationOptions(_ options: [String: Any]) {
|
|
1429
|
+
if options.keys.contains("title") {
|
|
1430
|
+
navigationItem.title = options["title"] as? String
|
|
1431
|
+
titleFromOptions = true
|
|
1432
|
+
}
|
|
1433
|
+
if options.keys.contains("backTitle") {
|
|
1434
|
+
navigationItem.backButtonTitle = options["backTitle"] as? String
|
|
1435
|
+
}
|
|
1436
|
+
if let largeTitle = options["largeTitle"] as? Bool {
|
|
1437
|
+
prefersLargeTitle = largeTitle
|
|
1438
|
+
navigationItem.largeTitleDisplayMode = largeTitle ? .always : .never
|
|
1439
|
+
if largeTitle { navigationController?.navigationBar.prefersLargeTitles = true }
|
|
1440
|
+
trackContentScrollView()
|
|
1441
|
+
}
|
|
1442
|
+
if let hidden = options["hidden"] as? Bool {
|
|
1443
|
+
navigationBarHidden = hidden
|
|
1444
|
+
if navigationController?.topViewController === self {
|
|
1445
|
+
navigationController?.setNavigationBarHidden(hidden, animated: view.window != nil)
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
if options.keys.contains("rightButtons") {
|
|
1449
|
+
let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
|
|
1450
|
+
navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/// A bar button from `{ id, symbol | title }`. Its tap is the event
|
|
1455
|
+
/// `navButton` with `{ id }`.
|
|
1456
|
+
private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
|
|
1457
|
+
guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
|
|
1458
|
+
let action = UIAction { [weak self] _ in
|
|
1459
|
+
self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": id]])
|
|
1460
|
+
}
|
|
1461
|
+
let item: UIBarButtonItem
|
|
1462
|
+
if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
|
|
1463
|
+
item = UIBarButtonItem(image: image, primaryAction: action)
|
|
1464
|
+
} else if let title = spec["title"] as? String {
|
|
1465
|
+
item = UIBarButtonItem(title: title, primaryAction: action)
|
|
1466
|
+
} else {
|
|
1467
|
+
return nil
|
|
660
1468
|
}
|
|
1469
|
+
item.accessibilityIdentifier = "nav-\(id)"
|
|
1470
|
+
if let label = (spec["accessibilityLabel"] as? String) ?? (spec["title"] as? String) {
|
|
1471
|
+
item.accessibilityLabel = label
|
|
1472
|
+
} else {
|
|
1473
|
+
item.accessibilityLabel = id
|
|
1474
|
+
}
|
|
1475
|
+
if let tint = (spec["color"] as? String).flatMap({ UIColor(hex: $0) }) { item.tintColor = tint }
|
|
1476
|
+
return item
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/// The first vertical scroll view of the screen drives the bar: a large
|
|
1480
|
+
/// title collapses as it scrolls, and the bar's background follows it.
|
|
1481
|
+
private func trackContentScrollView() {
|
|
1482
|
+
guard let renderedRoot else { return }
|
|
1483
|
+
func find(_ node: RenderedNode) -> UIScrollView? {
|
|
1484
|
+
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical, scroll.isScrollEnabled { return scroll }
|
|
1485
|
+
if let list = node.view as? CraftNativeFlatList, list.isScrollEnabled,
|
|
1486
|
+
(list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { return list }
|
|
1487
|
+
for child in node.children {
|
|
1488
|
+
if let match = find(child) { return match }
|
|
1489
|
+
}
|
|
1490
|
+
return nil
|
|
1491
|
+
}
|
|
1492
|
+
let scroll = find(renderedRoot)
|
|
1493
|
+
// Under a large title the screen runs edge to edge, as UIKit's own
|
|
1494
|
+
// screens do: the scroll view starts under the bar and insets its
|
|
1495
|
+
// content by it, so the title collapses and pull to refresh draws
|
|
1496
|
+
// in the bar's space. Other screens stay inside the safe area.
|
|
1497
|
+
let edgeToEdge = scroll != nil && prefersLargeTitle == true
|
|
1498
|
+
if rootTopToView?.isActive != edgeToEdge {
|
|
1499
|
+
rootTopToSafeArea?.isActive = !edgeToEdge
|
|
1500
|
+
rootTopToView?.isActive = edgeToEdge
|
|
1501
|
+
}
|
|
1502
|
+
guard scroll !== trackedScrollView else { return }
|
|
1503
|
+
trackedScrollView = scroll
|
|
1504
|
+
setContentScrollView(scroll, for: .top)
|
|
661
1505
|
}
|
|
662
1506
|
|
|
663
1507
|
private func observeNativeEvents() {
|
|
@@ -671,6 +1515,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
671
1515
|
observe(UIApplication.didBecomeActiveNotification, state: "active")
|
|
672
1516
|
observe(UIApplication.willResignActiveNotification, state: "inactive")
|
|
673
1517
|
observe(UIApplication.didEnterBackgroundNotification, state: "background")
|
|
1518
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
1519
|
+
forName: UIResponder.keyboardWillChangeFrameNotification,
|
|
1520
|
+
object: nil,
|
|
1521
|
+
queue: .main
|
|
1522
|
+
) { [weak self] notification in
|
|
1523
|
+
self?.applyKeyboardSafeArea(notification)
|
|
1524
|
+
})
|
|
1525
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
1526
|
+
forName: UIResponder.keyboardWillHideNotification,
|
|
1527
|
+
object: nil,
|
|
1528
|
+
queue: .main
|
|
1529
|
+
) { [weak self] notification in
|
|
1530
|
+
self?.applyKeyboardSafeArea(notification, hidden: true)
|
|
1531
|
+
})
|
|
674
1532
|
sendAppState(CraftNativeActions.currentAppState())
|
|
675
1533
|
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
|
|
676
1534
|
guard let self = self, self.navigationController?.topViewController === self else { return }
|
|
@@ -680,6 +1538,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
680
1538
|
}
|
|
681
1539
|
}
|
|
682
1540
|
|
|
1541
|
+
private func applyKeyboardSafeArea(_ notification: Notification, hidden: Bool = false) {
|
|
1542
|
+
let inset: CGFloat
|
|
1543
|
+
if hidden {
|
|
1544
|
+
inset = 0
|
|
1545
|
+
} else if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
|
|
1546
|
+
let keyboardFrame = view.convert(frame.cgRectValue, from: nil)
|
|
1547
|
+
inset = max(0, view.bounds.intersection(keyboardFrame).height - view.safeAreaInsets.bottom)
|
|
1548
|
+
} else {
|
|
1549
|
+
return
|
|
1550
|
+
}
|
|
1551
|
+
guard abs(inset - keyboardSafeAreaInset) > 0.5 else { return }
|
|
1552
|
+
keyboardSafeAreaInset = inset
|
|
1553
|
+
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
|
1554
|
+
let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
|
|
1555
|
+
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
|
|
1556
|
+
self.additionalSafeAreaInsets.bottom = inset
|
|
1557
|
+
self.view.layoutIfNeeded()
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
683
1561
|
private func sendAppState(_ state: String) {
|
|
684
1562
|
guard navigationController?.topViewController === self else { return }
|
|
685
1563
|
send(type: "APP_STATE", payload: ["state": state])
|
|
@@ -694,6 +1572,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
694
1572
|
rootStack.addArrangedSubview(label)
|
|
695
1573
|
rootStack.setLayoutStyle(["alignSelf": "stretch"], for: label)
|
|
696
1574
|
NSLog("[craft native] %@", message)
|
|
1575
|
+
hybridFailed(message)
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/// Only before the first frame: later exceptions belong to a screen that
|
|
1579
|
+
/// is up, and are logged.
|
|
1580
|
+
private func hybridFailed(_ message: String) {
|
|
1581
|
+
guard !hybridRendered else { return }
|
|
1582
|
+
hybridEvents?.nativeScreen(self, didFail: message)
|
|
697
1583
|
}
|
|
698
1584
|
|
|
699
1585
|
private func receive(_ raw: String) {
|
|
@@ -737,11 +1623,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
737
1623
|
let navigation = navigationController else { return }
|
|
738
1624
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
739
1625
|
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
1626
|
+
next.hybridEvents = hybridEvents
|
|
740
1627
|
if type == "NAVIGATE" {
|
|
741
1628
|
navigation.pushViewController(next, animated: true)
|
|
742
1629
|
} else {
|
|
743
1630
|
navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
|
|
744
1631
|
}
|
|
1632
|
+
case "NAVIGATION_SET_OPTIONS":
|
|
1633
|
+
applyNavigationOptions(payload)
|
|
1634
|
+
case "NAVIGATE_OPEN":
|
|
1635
|
+
// A path rather than a screen: the hybrid shell opens it, natively
|
|
1636
|
+
// or in the web view.
|
|
1637
|
+
guard let path = payload["path"] as? String, !path.isEmpty else { return }
|
|
1638
|
+
hybridEvents?.nativeScreen(self, open: path)
|
|
745
1639
|
case "NAVIGATE_BACK":
|
|
746
1640
|
if navigationController?.topViewController === self {
|
|
747
1641
|
navigationController?.popViewController(animated: true)
|
|
@@ -804,10 +1698,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
804
1698
|
}
|
|
805
1699
|
}
|
|
806
1700
|
|
|
1701
|
+
/// Runs script in the screen's JavaScript context. Internal for
|
|
1702
|
+
/// simulator-hosted XCTest of the host APIs.
|
|
1703
|
+
@discardableResult
|
|
1704
|
+
func evaluateScript(_ script: String) -> JSValue? {
|
|
1705
|
+
jsContext.evaluateScript(script)
|
|
1706
|
+
}
|
|
1707
|
+
|
|
807
1708
|
// Internal so simulator-hosted XCTest can assert actual UIView identity.
|
|
808
1709
|
func render(_ document: [String: Any]) {
|
|
809
1710
|
mutationDocument.replace(with: document)
|
|
810
1711
|
renderCommitted(document)
|
|
1712
|
+
if !hybridRendered {
|
|
1713
|
+
hybridRendered = true
|
|
1714
|
+
hybridEvents?.nativeScreenDidRender(self)
|
|
1715
|
+
}
|
|
811
1716
|
}
|
|
812
1717
|
|
|
813
1718
|
@discardableResult
|
|
@@ -825,7 +1730,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
825
1730
|
let target = flatListOwners[id] ?? id
|
|
826
1731
|
guard applied.insert(target).inserted else { continue }
|
|
827
1732
|
guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
|
|
828
|
-
|
|
1733
|
+
let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
|
|
1734
|
+
// A style patch is applied in place, layout included: the parent
|
|
1735
|
+
// reads the node's new layout style on its next pass.
|
|
1736
|
+
CraftNativeFlexLayout.invalidateAncestors(of: next.view)
|
|
829
1737
|
}
|
|
830
1738
|
restoreFocus(focused)
|
|
831
1739
|
return true
|
|
@@ -846,10 +1754,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
846
1754
|
let next = reconcile(document, identity: "root", path: "root", previous: previous)
|
|
847
1755
|
if previous?.view !== next.view {
|
|
848
1756
|
if let old = previous { detach(old, from: rootStack) }
|
|
849
|
-
rootStack.addArrangedSubview(next.view)
|
|
850
1757
|
}
|
|
1758
|
+
// A failed or interrupted reconciliation can leave an older root
|
|
1759
|
+
// attached even though `renderedRoot` points at the latest tree. Keep
|
|
1760
|
+
// the host single-rooted so accessibility queries and event routing
|
|
1761
|
+
// never see stale copies of the native screen.
|
|
1762
|
+
for child in rootStack.arrangedSubviews where child !== next.view {
|
|
1763
|
+
rootStack.removeArrangedSubview(child)
|
|
1764
|
+
child.removeFromSuperview()
|
|
1765
|
+
}
|
|
1766
|
+
if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
|
|
851
1767
|
rootStack.setLayoutStyle(next.style, for: next.view)
|
|
1768
|
+
rootStack.invalidateMeasurements()
|
|
852
1769
|
renderedRoot = next
|
|
1770
|
+
trackContentScrollView()
|
|
853
1771
|
restoreFocus(focused)
|
|
854
1772
|
}
|
|
855
1773
|
|
|
@@ -890,7 +1808,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
890
1808
|
current.protocolId = node["id"] as? String
|
|
891
1809
|
current.style = style
|
|
892
1810
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
893
|
-
if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" {
|
|
1811
|
+
if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
|
|
894
1812
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
895
1813
|
}
|
|
896
1814
|
applyViewStyle(style, to: result, node: current)
|
|
@@ -901,6 +1819,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
901
1819
|
let label = result as! UILabel
|
|
902
1820
|
label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
|
|
903
1821
|
label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
|
|
1822
|
+
(label as? CraftNativeLabel)?.insets = paddingInsets(style)
|
|
904
1823
|
configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
|
|
905
1824
|
case "Button", "Link":
|
|
906
1825
|
let button = result as! UIButton
|
|
@@ -911,6 +1830,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
911
1830
|
style,
|
|
912
1831
|
default: .systemFont(ofSize: 16)
|
|
913
1832
|
)
|
|
1833
|
+
// The plain title too, so `title(for:)` and accessibility read it.
|
|
1834
|
+
if button.title(for: .normal) != transformedTitle { button.setTitle(transformedTitle, for: .normal) }
|
|
914
1835
|
button.setAttributedTitle(NSAttributedString(
|
|
915
1836
|
string: transformedTitle,
|
|
916
1837
|
attributes: buttonTitleAttributes(style, color: titleColor, font: font, forceUnderline: type == "Link")
|
|
@@ -962,10 +1883,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
962
1883
|
if field.returnKeyType != desiredReturnKeyType { field.returnKeyType = desiredReturnKeyType }
|
|
963
1884
|
let desiredAutocorrection: UITextAutocorrectionType = props["autoCorrect"] as? Bool == false ? .no : .default
|
|
964
1885
|
if field.autocorrectionType != desiredAutocorrection { field.autocorrectionType = desiredAutocorrection }
|
|
965
|
-
|
|
966
|
-
if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
|
|
1886
|
+
// Secure entry first: turning it on resets the capitalization.
|
|
967
1887
|
let desiredSecureEntry = props["secureTextEntry"] as? Bool == true
|
|
968
1888
|
if field.isSecureTextEntry != desiredSecureEntry { field.isSecureTextEntry = desiredSecureEntry }
|
|
1889
|
+
let desiredCapitalization = capitalizationType(props["autoCapitalize"])
|
|
1890
|
+
if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
|
|
969
1891
|
let desiredEnabled = props["editable"] as? Bool != false
|
|
970
1892
|
if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
|
|
971
1893
|
if let value = props["value"] as? String {
|
|
@@ -981,6 +1903,52 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
981
1903
|
if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
|
|
982
1904
|
DispatchQueue.main.async { _ = field.becomeFirstResponder() }
|
|
983
1905
|
}
|
|
1906
|
+
case "Picker":
|
|
1907
|
+
let picker = result as! UIPickerView
|
|
1908
|
+
let options = children.compactMap { child -> (value: String, label: String)? in
|
|
1909
|
+
guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
|
|
1910
|
+
let optionProps = node["props"] as? [String: Any] ?? [:]
|
|
1911
|
+
let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
|
|
1912
|
+
guard !label.isEmpty else { return nil }
|
|
1913
|
+
return (value: optionProps["value"] as? String ?? label, label: label)
|
|
1914
|
+
}
|
|
1915
|
+
let pickerID = ObjectIdentifier(picker)
|
|
1916
|
+
pickerOptions[pickerID] = options
|
|
1917
|
+
picker.reloadAllComponents()
|
|
1918
|
+
if let selected = (props["selectedValue"] as? String) ?? (props["value"] as? String),
|
|
1919
|
+
let row = options.firstIndex(where: { $0.value == selected }) {
|
|
1920
|
+
picker.selectRow(row, inComponent: 0, animated: false)
|
|
1921
|
+
} else if !options.isEmpty {
|
|
1922
|
+
picker.selectRow(0, inComponent: 0, animated: false)
|
|
1923
|
+
}
|
|
1924
|
+
picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
|
|
1925
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
|
|
1926
|
+
case "Modal":
|
|
1927
|
+
let modal = result as! CraftNativeModalView
|
|
1928
|
+
modal.transparent = props["transparent"] as? Bool == true
|
|
1929
|
+
let visible = props["visible"] as? Bool == true
|
|
1930
|
+
let modalID = ObjectIdentifier(modal)
|
|
1931
|
+
let wasVisible = modalVisibility[modalID]
|
|
1932
|
+
modalVisibility[modalID] = visible
|
|
1933
|
+
modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
1934
|
+
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
1935
|
+
{ [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
1936
|
+
}
|
|
1937
|
+
if wasVisible != visible, visible || wasVisible == true {
|
|
1938
|
+
let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
|
|
1939
|
+
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
|
|
1940
|
+
send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
configureStack(modal.contentStack, style: style)
|
|
1944
|
+
reconcileChildren(children, in: modal.contentStack, parent: current, path: path, style: style)
|
|
1945
|
+
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
1946
|
+
let pressable = result as! CraftNativeFlowView
|
|
1947
|
+
configureStack(pressable, style: style)
|
|
1948
|
+
reconcileChildren(children, in: pressable, parent: current, path: path, style: style)
|
|
1949
|
+
pressable.pressActiveOpacity = props["disabled"] as? Bool == true
|
|
1950
|
+
? nil : number(props["activeOpacity"]) ?? 0.5
|
|
1951
|
+
pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
|
|
984
1952
|
case "Switch":
|
|
985
1953
|
let toggle = result as! UISwitch
|
|
986
1954
|
toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
|
|
@@ -1029,6 +1997,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1029
1997
|
} else {
|
|
1030
1998
|
indicator.startAnimating()
|
|
1031
1999
|
}
|
|
2000
|
+
case "Icon":
|
|
2001
|
+
configureIcon(result as! UIImageView, props: props, style: style)
|
|
1032
2002
|
case "Image":
|
|
1033
2003
|
let image = result as! UIImageView
|
|
1034
2004
|
let imageID = ObjectIdentifier(image)
|
|
@@ -1061,6 +2031,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1061
2031
|
scroll.bounces = props["bounces"] as? Bool ?? true
|
|
1062
2032
|
scroll.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
|
|
1063
2033
|
scroll.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
|
|
2034
|
+
scroll.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
|
|
1064
2035
|
scroll.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool != false
|
|
1065
2036
|
scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
|
|
1066
2037
|
scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
|
|
@@ -1070,8 +2041,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1070
2041
|
updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
|
|
1071
2042
|
updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
|
|
1072
2043
|
updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
|
|
1073
|
-
|
|
1074
|
-
|
|
2044
|
+
scroll.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
|
|
2045
|
+
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
2046
|
+
}, refreshing: props["refreshing"] as? Bool == true)
|
|
2047
|
+
var contentStyle = style
|
|
2048
|
+
if let override = props["contentContainerStyle"] as? [String: Any] {
|
|
2049
|
+
contentStyle.merge(override) { _, next in next }
|
|
2050
|
+
}
|
|
2051
|
+
configureStack(scroll.contentStack, style: contentStyle)
|
|
2052
|
+
reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
|
|
1075
2053
|
case "FlatList":
|
|
1076
2054
|
let list = result as! CraftNativeFlatList
|
|
1077
2055
|
reconcileFlatList(
|
|
@@ -1087,9 +2065,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1087
2065
|
configureStack(stack, style: style)
|
|
1088
2066
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
1089
2067
|
}
|
|
1090
|
-
updateLongPressHandler(events["onLongPress"], for: result)
|
|
1091
|
-
if type != "Button" && type != "Link" && type != "TextInput" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
1092
|
-
|
|
2068
|
+
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
2069
|
+
if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
2070
|
+
let disabled = props["disabled"] as? Bool == true
|
|
2071
|
+
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
2072
|
+
updatePressFeedback(
|
|
2073
|
+
disabled ? nil : nonEmptyHandler(events["onPressIn"]),
|
|
2074
|
+
disabled ? nil : nonEmptyHandler(events["onPressOut"]),
|
|
2075
|
+
for: result
|
|
2076
|
+
)
|
|
2077
|
+
if disabled { result.isUserInteractionEnabled = false }
|
|
1093
2078
|
}
|
|
1094
2079
|
applyAccessibility(props, type: type, to: result)
|
|
1095
2080
|
return current
|
|
@@ -1098,7 +2083,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1098
2083
|
private func makeView(_ type: String, props: [String: Any]) -> UIView {
|
|
1099
2084
|
switch type {
|
|
1100
2085
|
case "Text":
|
|
1101
|
-
let label =
|
|
2086
|
+
let label = CraftNativeLabel()
|
|
1102
2087
|
label.numberOfLines = 0
|
|
1103
2088
|
return label
|
|
1104
2089
|
case "Button", "Link":
|
|
@@ -1118,6 +2103,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1118
2103
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
1119
2104
|
field.addTarget(self, action: #selector(textSubmitted(_:)), for: .editingDidEndOnExit)
|
|
1120
2105
|
return field
|
|
2106
|
+
case "Picker":
|
|
2107
|
+
let picker = UIPickerView()
|
|
2108
|
+
picker.dataSource = self
|
|
2109
|
+
picker.delegate = self
|
|
2110
|
+
return picker
|
|
2111
|
+
case "Modal":
|
|
2112
|
+
return CraftNativeModalView()
|
|
1121
2113
|
case "Switch":
|
|
1122
2114
|
let toggle = UISwitch()
|
|
1123
2115
|
toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
|
|
@@ -1131,19 +2123,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1131
2123
|
return UIActivityIndicatorView(style: .medium)
|
|
1132
2124
|
case "Image":
|
|
1133
2125
|
return UIImageView()
|
|
2126
|
+
case "Icon":
|
|
2127
|
+
let icon = CraftNativeIconView()
|
|
2128
|
+
icon.contentMode = .scaleAspectFit
|
|
2129
|
+
return icon
|
|
1134
2130
|
case "ScrollView":
|
|
1135
2131
|
return CraftNativeScrollView()
|
|
1136
2132
|
case "FlatList":
|
|
1137
2133
|
return CraftNativeFlatList()
|
|
1138
2134
|
case "View", "SafeAreaView":
|
|
1139
2135
|
return CraftNativeFlowView()
|
|
2136
|
+
case "TouchableOpacity", "TouchableHighlight", "Pressable":
|
|
2137
|
+
return CraftNativeFlowView()
|
|
1140
2138
|
default:
|
|
1141
2139
|
return CraftNativeFlowView()
|
|
1142
2140
|
}
|
|
1143
2141
|
}
|
|
1144
2142
|
|
|
1145
2143
|
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
1146
|
-
// The current stx
|
|
2144
|
+
// The current stx native compiler emits key in props; accept the IR
|
|
1147
2145
|
// field too so keyed children keep working when the compiler adopts it.
|
|
1148
2146
|
[node["id"], node["key"], props["key"], props["testID"]]
|
|
1149
2147
|
.compactMap { $0 as? String }
|
|
@@ -1186,7 +2184,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1186
2184
|
for child in parent.children where !next.contains(where: { $0 === child }) {
|
|
1187
2185
|
detach(child, from: stack)
|
|
1188
2186
|
}
|
|
1189
|
-
for
|
|
2187
|
+
for modal in stack.arrangedSubviews where modal is CraftNativeModalView {
|
|
2188
|
+
stack.removeArrangedSubview(modal)
|
|
2189
|
+
}
|
|
2190
|
+
let flowChildren = next.filter { $0.type != "Modal" }
|
|
2191
|
+
for (index, child) in flowChildren.enumerated() {
|
|
1190
2192
|
if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view {
|
|
1191
2193
|
stack.setLayoutStyle(child.style, for: child.view)
|
|
1192
2194
|
continue
|
|
@@ -1195,6 +2197,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1195
2197
|
stack.insertArrangedSubview(child.view, at: min(index, stack.arrangedSubviews.count))
|
|
1196
2198
|
stack.setLayoutStyle(child.style, for: child.view)
|
|
1197
2199
|
}
|
|
2200
|
+
for child in next where child.type == "Modal" {
|
|
2201
|
+
guard let modal = child.view as? CraftNativeModalView else { continue }
|
|
2202
|
+
if modal.superview !== stack {
|
|
2203
|
+
modal.removeFromSuperview()
|
|
2204
|
+
stack.addSubview(modal)
|
|
2205
|
+
}
|
|
2206
|
+
modal.frame = stack.bounds
|
|
2207
|
+
modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
2208
|
+
stack.bringSubviewToFront(modal)
|
|
2209
|
+
}
|
|
1198
2210
|
parent.children = next
|
|
1199
2211
|
addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
|
|
1200
2212
|
}
|
|
@@ -1212,6 +2224,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1212
2224
|
flatListOwners = flatListOwners.filter { $0.value != owner }
|
|
1213
2225
|
for child in children { registerFlatListOwnership(child, owner: owner) }
|
|
1214
2226
|
list.bounces = props["bounces"] as? Bool ?? true
|
|
2227
|
+
list.isScrollEnabled = props["scrollEnabled"] as? Bool != false
|
|
2228
|
+
list.onFittingHeightChanged = { [weak list] in
|
|
2229
|
+
guard let list else { return }
|
|
2230
|
+
CraftNativeFlexLayout.invalidateAncestors(of: list)
|
|
2231
|
+
}
|
|
2232
|
+
list.setKeyboardShouldPersistTaps(props["keyboardShouldPersistTaps"])
|
|
2233
|
+
list.setContentContainerStyle(props["contentContainerStyle"] as? [String: Any])
|
|
2234
|
+
list.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
|
|
2235
|
+
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
2236
|
+
}, refreshing: props["refreshing"] as? Bool == true)
|
|
1215
2237
|
list.keyboardDismissMode = keyboardDismissMode(props["keyboardDismissMode"])
|
|
1216
2238
|
list.onScrollEvent = nonEmptyHandler(events["onScroll"]).map { handler in
|
|
1217
2239
|
{ [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
|
|
@@ -1276,20 +2298,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1276
2298
|
|
|
1277
2299
|
private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
|
|
1278
2300
|
forgetHandlers(node)
|
|
2301
|
+
if node.type == "Modal" {
|
|
2302
|
+
node.view.removeFromSuperview()
|
|
2303
|
+
return
|
|
2304
|
+
}
|
|
1279
2305
|
stack.removeLayoutStyle(for: node.view)
|
|
1280
2306
|
stack.removeArrangedSubview(node.view)
|
|
1281
2307
|
node.view.removeFromSuperview()
|
|
1282
2308
|
}
|
|
1283
2309
|
|
|
1284
2310
|
private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
|
|
2311
|
+
(node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
|
|
1285
2312
|
if let list = node.view as? CraftNativeFlatList {
|
|
1286
2313
|
let listKey = ObjectIdentifier(list)
|
|
2314
|
+
list.setRefreshHandler(nil, refreshing: false)
|
|
1287
2315
|
list.discardAll()
|
|
1288
2316
|
flatListRows.removeValue(forKey: listKey)
|
|
1289
2317
|
if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
|
|
1290
2318
|
}
|
|
1291
2319
|
let id = ObjectIdentifier(node.view)
|
|
1292
2320
|
handlers.removeValue(forKey: id)
|
|
2321
|
+
pressInHandlers.removeValue(forKey: id)
|
|
2322
|
+
pressOutHandlers.removeValue(forKey: id)
|
|
1293
2323
|
layoutHandlers.removeValue(forKey: id)
|
|
1294
2324
|
lastLayoutFrames.removeValue(forKey: id)
|
|
1295
2325
|
focusHandlers.removeValue(forKey: id)
|
|
@@ -1307,12 +2337,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1307
2337
|
scrollMomentumEndHandlers.removeValue(forKey: id)
|
|
1308
2338
|
sliderCompleteHandlers.removeValue(forKey: id)
|
|
1309
2339
|
sliderSteps.removeValue(forKey: id)
|
|
2340
|
+
pickerOptions.removeValue(forKey: id)
|
|
2341
|
+
modalVisibility.removeValue(forKey: id)
|
|
1310
2342
|
if let recognizer = tapRecognizers.removeValue(forKey: id) {
|
|
1311
2343
|
node.view.removeGestureRecognizer(recognizer)
|
|
1312
2344
|
}
|
|
1313
2345
|
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
1314
2346
|
node.view.removeGestureRecognizer(recognizer)
|
|
1315
2347
|
}
|
|
2348
|
+
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) {
|
|
2349
|
+
node.view.removeGestureRecognizer(recognizer)
|
|
2350
|
+
}
|
|
1316
2351
|
imageSources.removeValue(forKey: id)
|
|
1317
2352
|
imageTasks.removeValue(forKey: id)?.cancel()
|
|
1318
2353
|
imageErrors.removeValue(forKey: id)
|
|
@@ -1402,7 +2437,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1402
2437
|
} else if handler == nil, let recognizer = tapRecognizers.removeValue(forKey: id) {
|
|
1403
2438
|
view.removeGestureRecognizer(recognizer)
|
|
1404
2439
|
}
|
|
1405
|
-
|
|
2440
|
+
// A container keeps receiving touches without a handler of its own,
|
|
2441
|
+
// or every control inside it (and the screen root) would be dead.
|
|
2442
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
|
|
2446
|
+
let id = ObjectIdentifier(view)
|
|
2447
|
+
if let pressIn, !pressIn.isEmpty { pressInHandlers[id] = pressIn } else { pressInHandlers.removeValue(forKey: id) }
|
|
2448
|
+
if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
|
|
2449
|
+
guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
|
|
2450
|
+
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
|
|
2451
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
2452
|
+
return
|
|
2453
|
+
}
|
|
2454
|
+
if pressFeedbackRecognizers[id] == nil {
|
|
2455
|
+
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewPressFeedback(_:)))
|
|
2456
|
+
recognizer.minimumPressDuration = 0
|
|
2457
|
+
recognizer.allowableMovement = 10
|
|
2458
|
+
recognizer.cancelsTouchesInView = false
|
|
2459
|
+
pressFeedbackRecognizers[id] = recognizer
|
|
2460
|
+
view.addGestureRecognizer(recognizer)
|
|
2461
|
+
}
|
|
2462
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
|
|
1406
2463
|
}
|
|
1407
2464
|
|
|
1408
2465
|
private func updateLongPressHandler(_ handler: String?, for view: UIView) {
|
|
@@ -1420,7 +2477,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1420
2477
|
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
1421
2478
|
view.removeGestureRecognizer(recognizer)
|
|
1422
2479
|
}
|
|
1423
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil }
|
|
2480
|
+
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
|
|
1424
2481
|
}
|
|
1425
2482
|
}
|
|
1426
2483
|
|
|
@@ -1504,6 +2561,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1504
2561
|
}
|
|
1505
2562
|
}
|
|
1506
2563
|
|
|
2564
|
+
/// `<Icon symbol="sun.max">`: an SF Symbol, tinted by `color`, sized by
|
|
2565
|
+
/// `fontSize` or else the icon's box, weighted by `fontWeight`. A name
|
|
2566
|
+
/// the system does not have draws `circle`, so a typo shows rather than
|
|
2567
|
+
/// leaving a hole.
|
|
2568
|
+
private func configureIcon(_ icon: UIImageView, props: [String: Any], style: [String: Any]) {
|
|
2569
|
+
let name = (props["symbol"] as? String) ?? (props["name"] as? String) ?? ""
|
|
2570
|
+
let box = [number(style["width"]), number(style["height"])].compactMap { $0 }.min()
|
|
2571
|
+
let fontSize = number(style["fontSize"])
|
|
2572
|
+
// Without a size of its own an icon is a 20 pt square (contract A2).
|
|
2573
|
+
(icon as? CraftNativeIconView)?.defaultBox = box == nil && fontSize == nil ? 20 : nil
|
|
2574
|
+
let pointSize = fontSize ?? (box ?? 20) * 0.84
|
|
2575
|
+
let weight: UIImage.SymbolWeight
|
|
2576
|
+
switch style["fontWeight"] as? String {
|
|
2577
|
+
case "100": weight = .ultraLight
|
|
2578
|
+
case "200": weight = .thin
|
|
2579
|
+
case "300": weight = .light
|
|
2580
|
+
case "500": weight = .medium
|
|
2581
|
+
case "600": weight = .semibold
|
|
2582
|
+
case "bold", "700": weight = .bold
|
|
2583
|
+
case "800": weight = .heavy
|
|
2584
|
+
case "900": weight = .black
|
|
2585
|
+
default: weight = .regular
|
|
2586
|
+
}
|
|
2587
|
+
let scale: UIImage.SymbolScale
|
|
2588
|
+
switch props["scale"] as? String {
|
|
2589
|
+
case "small": scale = .small
|
|
2590
|
+
case "large": scale = .large
|
|
2591
|
+
default: scale = .medium
|
|
2592
|
+
}
|
|
2593
|
+
let configuration = UIImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: weight, scale: scale)
|
|
2594
|
+
let image = UIImage(systemName: name, withConfiguration: configuration)
|
|
2595
|
+
?? UIImage(systemName: "circle", withConfiguration: configuration)
|
|
2596
|
+
if icon.image != image { icon.image = image }
|
|
2597
|
+
icon.preferredSymbolConfiguration = configuration
|
|
2598
|
+
icon.tintColor = color(style["color"]) ?? color(props["color"]) ?? .label
|
|
2599
|
+
icon.contentMode = .scaleAspectFit
|
|
2600
|
+
}
|
|
2601
|
+
|
|
1507
2602
|
private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
|
|
1508
2603
|
let direction = style["flexDirection"] as? String
|
|
1509
2604
|
stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
|
|
@@ -1517,15 +2612,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1517
2612
|
stack.grid = style["display"] as? String == "grid"
|
|
1518
2613
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
1519
2614
|
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
|
-
)
|
|
2615
|
+
stack.padding = paddingInsets(style)
|
|
2616
|
+
stack.invalidateMeasurements()
|
|
1529
2617
|
}
|
|
1530
2618
|
|
|
1531
2619
|
private func addJustificationSpacers(to stack: CraftNativeFlowView, value: String?) { }
|
|
@@ -1538,7 +2626,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1538
2626
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
1539
2627
|
view.alpha = number(style["opacity"]) ?? 1
|
|
1540
2628
|
view.isHidden = style["display"] as? String == "none"
|
|
1541
|
-
view.
|
|
2629
|
+
view.craftRequestedCornerRadius = number(style["borderRadius"])
|
|
1542
2630
|
view.layer.borderWidth = number(style["borderWidth"]) ?? 0
|
|
1543
2631
|
view.layer.borderColor = (color(style["borderColor"]) ?? .clear).cgColor
|
|
1544
2632
|
let elevation = max(0, number(style["elevation"]) ?? 0)
|
|
@@ -1546,8 +2634,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1546
2634
|
view.layer.shadowOpacity = elevation > 0 ? Float(min(0.28, 0.12 + elevation * 0.02)) : 0
|
|
1547
2635
|
view.layer.shadowRadius = elevation * 0.5
|
|
1548
2636
|
view.layer.shadowOffset = CGSize(width: 0, height: elevation * 0.25)
|
|
2637
|
+
// A label paints its background inside its own bounds, so a rounded
|
|
2638
|
+
// chip of text clips to its corners.
|
|
1549
2639
|
view.clipsToBounds = style["overflow"] as? String == "hidden"
|
|
2640
|
+
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
1550
2641
|
view.layer.masksToBounds = view.clipsToBounds
|
|
2642
|
+
view.craftLayoutStyle = CraftNativeLayoutStyle(style)
|
|
2643
|
+
(view as? CraftNativeFlowView)?.invalidateMeasurements()
|
|
2644
|
+
(view as? CraftNativeScrollView)?.setContentNeedsLayout()
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
|
|
2648
|
+
let padding = number(style["padding"]) ?? 0
|
|
2649
|
+
let horizontal = number(style["paddingHorizontal"]) ?? padding
|
|
2650
|
+
let vertical = number(style["paddingVertical"]) ?? padding
|
|
2651
|
+
return UIEdgeInsets(
|
|
2652
|
+
top: number(style["paddingTop"]) ?? vertical,
|
|
2653
|
+
left: number(style["paddingLeft"]) ?? horizontal,
|
|
2654
|
+
bottom: number(style["paddingBottom"]) ?? vertical,
|
|
2655
|
+
right: number(style["paddingRight"]) ?? horizontal
|
|
2656
|
+
)
|
|
1551
2657
|
}
|
|
1552
2658
|
|
|
1553
2659
|
private func configureText(_ label: UILabel, text: String, style: [String: Any]) {
|
|
@@ -1557,13 +2663,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1557
2663
|
label.textAlignment = textAlignment(style["textAlign"])
|
|
1558
2664
|
var attributes: [NSAttributedString.Key: Any] = [:]
|
|
1559
2665
|
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
2666
|
switch style["textDecorationLine"] as? String {
|
|
1568
2667
|
case "underline": attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
|
1569
2668
|
case "line-through": attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
@@ -1572,8 +2671,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1572
2671
|
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
|
1573
2672
|
default: break
|
|
1574
2673
|
}
|
|
2674
|
+
if let lineHeight = number(style["lineHeight"]) {
|
|
2675
|
+
let paragraph = NSMutableParagraphStyle()
|
|
2676
|
+
paragraph.minimumLineHeight = lineHeight
|
|
2677
|
+
paragraph.maximumLineHeight = lineHeight
|
|
2678
|
+
paragraph.alignment = label.textAlignment
|
|
2679
|
+
attributes[.paragraphStyle] = paragraph
|
|
2680
|
+
}
|
|
2681
|
+
let alignment = label.textAlignment
|
|
2682
|
+
let lineBreakMode = label.lineBreakMode
|
|
1575
2683
|
label.attributedText = attributes.isEmpty ? nil : NSAttributedString(string: transformed, attributes: attributes)
|
|
1576
2684
|
if attributes.isEmpty { label.text = transformed }
|
|
2685
|
+
// Attributed text brings its own paragraph style, which resets the
|
|
2686
|
+
// label's alignment and truncation; set them again over the whole text.
|
|
2687
|
+
label.textAlignment = alignment
|
|
2688
|
+
label.lineBreakMode = lineBreakMode
|
|
1577
2689
|
}
|
|
1578
2690
|
|
|
1579
2691
|
private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
|
|
@@ -1808,7 +2920,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1808
2920
|
view.accessibilityValue = nil
|
|
1809
2921
|
}
|
|
1810
2922
|
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))
|
|
2923
|
+
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
1812
2924
|
var traits: UIAccessibilityTraits = []
|
|
1813
2925
|
switch role ?? type.lowercased() {
|
|
1814
2926
|
case "button": traits.insert(.button)
|
|
@@ -1844,7 +2956,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1844
2956
|
if let value = value {
|
|
1845
2957
|
if let constraint = constraint { constraint.constant = value }
|
|
1846
2958
|
else {
|
|
2959
|
+
// Below required: the flex layout owns frames, and a width
|
|
2960
|
+
// the layout stretches or shrinks must not log a conflict.
|
|
1847
2961
|
constraint = anchor.constraint(equalToConstant: value)
|
|
2962
|
+
constraint?.priority = UILayoutPriority(999)
|
|
1848
2963
|
constraint?.isActive = true
|
|
1849
2964
|
}
|
|
1850
2965
|
} else {
|
|
@@ -1936,6 +3051,27 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1936
3051
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
|
|
1937
3052
|
}
|
|
1938
3053
|
|
|
3054
|
+
func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
|
|
3055
|
+
|
|
3056
|
+
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
|
|
3057
|
+
pickerOptions[ObjectIdentifier(pickerView)]?.count ?? 0
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
|
|
3061
|
+
guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row) else { return nil }
|
|
3062
|
+
return options[row].label
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
|
|
3066
|
+
guard let options = pickerOptions[ObjectIdentifier(pickerView)], options.indices.contains(row),
|
|
3067
|
+
let handler = handlers[ObjectIdentifier(pickerView)] else { return }
|
|
3068
|
+
let option = options[row]
|
|
3069
|
+
send(type: "EVENT", payload: [
|
|
3070
|
+
"handlerName": handler,
|
|
3071
|
+
"nativeEvent": ["value": option.value, "index": row],
|
|
3072
|
+
])
|
|
3073
|
+
}
|
|
3074
|
+
|
|
1939
3075
|
@objc private func textFocused(_ sender: UITextField) {
|
|
1940
3076
|
lastFocusedInput = sender
|
|
1941
3077
|
guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
|
|
@@ -1960,9 +3096,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1960
3096
|
|
|
1961
3097
|
@objc private func viewPressed(_ sender: UITapGestureRecognizer) {
|
|
1962
3098
|
guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
|
|
3099
|
+
let id = ObjectIdentifier(view)
|
|
3100
|
+
if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
|
|
3101
|
+
let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
|
|
3102
|
+
UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
|
|
3103
|
+
UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity }
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
1963
3106
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
1964
3107
|
}
|
|
1965
3108
|
|
|
3109
|
+
@objc private func viewPressFeedback(_ sender: UILongPressGestureRecognizer) {
|
|
3110
|
+
guard let view = sender.view else { return }
|
|
3111
|
+
let id = ObjectIdentifier(view)
|
|
3112
|
+
switch sender.state {
|
|
3113
|
+
case .began:
|
|
3114
|
+
if let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
|
|
3115
|
+
UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
|
|
3116
|
+
}
|
|
3117
|
+
if let handler = pressInHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
3118
|
+
case .ended, .cancelled, .failed:
|
|
3119
|
+
if let pressable = view as? CraftNativeFlowView { UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity } }
|
|
3120
|
+
if let handler = pressOutHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
3121
|
+
default:
|
|
3122
|
+
break
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
|
|
1966
3126
|
@objc private func viewLongPressed(_ sender: UILongPressGestureRecognizer) {
|
|
1967
3127
|
guard sender.state == .began,
|
|
1968
3128
|
let view = sender.view,
|
|
@@ -2048,8 +3208,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2048
3208
|
}
|
|
2049
3209
|
}
|
|
2050
3210
|
|
|
3211
|
+
/// A style colour: CSS hex with or without alpha (`#10b9811a` is
|
|
3212
|
+
/// emerald at 10%), `rgb()`/`rgba()` and the few names; `UIColor(hex:)`
|
|
3213
|
+
/// for anything else it has always read.
|
|
2051
3214
|
private func color(_ value: Any?) -> UIColor? {
|
|
2052
|
-
guard let
|
|
2053
|
-
return UIColor(hex:
|
|
3215
|
+
guard let text = value as? String else { return nil }
|
|
3216
|
+
return UIColor(css: text) ?? UIColor(hex: text)
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
/// `console` for native screens: the unified log, subsystem `craft.native`,
|
|
3221
|
+
/// category the screen's name. Read it with
|
|
3222
|
+
/// `log stream --predicate 'subsystem == "craft.native"'`.
|
|
3223
|
+
enum CraftNativeConsole {
|
|
3224
|
+
private static var loggers: [String: Logger] = [:]
|
|
3225
|
+
private static let lock = NSLock()
|
|
3226
|
+
|
|
3227
|
+
static func write(_ level: String, category: String, message: String) {
|
|
3228
|
+
lock.lock()
|
|
3229
|
+
let logger = loggers[category] ?? Logger(subsystem: "craft.native", category: category)
|
|
3230
|
+
loggers[category] = logger
|
|
3231
|
+
lock.unlock()
|
|
3232
|
+
switch level {
|
|
3233
|
+
case "debug": logger.debug("\(message, privacy: .public)")
|
|
3234
|
+
case "info": logger.info("\(message, privacy: .public)")
|
|
3235
|
+
case "warn": logger.warning("\(message, privacy: .public)")
|
|
3236
|
+
case "error": logger.error("\(message, privacy: .public)")
|
|
3237
|
+
default: logger.notice("\(message, privacy: .public)")
|
|
3238
|
+
}
|
|
2054
3239
|
}
|
|
2055
3240
|
}
|