craft-native 0.0.175 → 0.0.177
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.d.ts +19 -0
- package/dist/android/src/index.js +19 -2
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftBridge.kt.template +51 -0
- package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
- package/dist/android/templates/CraftNative.kt.template +17 -0
- package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
- package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
- package/dist/android/templates/CraftNativeServices.kt.template +31 -1
- package/dist/android/templates/MainActivityNative.kt.template +986 -304
- package/dist/android/templates/build.gradle.kts.app.template +4 -2
- package/dist/api/mobile.d.ts +31 -0
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +203 -2
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +19 -0
- package/dist/ios/src/index.js +7 -1
- package/dist/ios/src/testing.d.ts +5 -0
- package/dist/ios/templates/CraftApp.swift +51 -1
- package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
- package/dist/ios/templates/CraftHybrid.swift +10 -0
- package/dist/ios/templates/CraftNativeActions.swift +49 -1
- package/dist/ios/templates/CraftNativeFlatList.swift +30 -28
- package/dist/ios/templates/CraftNativeMutation.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +559 -86
- package/dist/ios/templates/project.yml.template +1 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +8 -0
- package/dist/macos/templates/CraftNativeMutation.swift +1 -1
- package/dist/macos/templates/native_layout.zig +389 -17
- package/dist/mobile.js +66 -0
- package/package.json +1 -1
|
@@ -12,6 +12,15 @@ import UIKit
|
|
|
12
12
|
|
|
13
13
|
private let craftNativeCapabilityTimeoutMilliseconds = 30_000
|
|
14
14
|
|
|
15
|
+
/// A finite number from a prop, for the views outside the screen controller,
|
|
16
|
+
/// which has its own: the keyboard-avoiding view and SVG text read theirs
|
|
17
|
+
/// with it, and without it the template did not compile.
|
|
18
|
+
private func number(_ value: Any?) -> CGFloat? {
|
|
19
|
+
guard let number = value as? NSNumber else { return nil }
|
|
20
|
+
let value = CGFloat(truncating: number)
|
|
21
|
+
return value.isFinite ? value : nil
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
private func craftNativeGridColumnCount(_ value: Any?) -> Int {
|
|
16
25
|
if let number = value as? NSNumber { return max(1, number.intValue) }
|
|
17
26
|
guard let text = value as? String else { return 1 }
|
|
@@ -81,6 +90,178 @@ private func craftNativeJustifyCode(_ value: String) -> UInt32 {
|
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/// Optional bridge to the host-neutral tree layout ABI. The host still owns
|
|
94
|
+
/// intrinsic measurement and keeps its local implementation for unsupported
|
|
95
|
+
/// trees or runtimes built before this symbol was exported.
|
|
96
|
+
private enum CraftNativeTreeLayoutBridge {
|
|
97
|
+
private struct StyleInput {
|
|
98
|
+
var direction: UInt32 = 0
|
|
99
|
+
var width: Float = .nan
|
|
100
|
+
var height: Float = .nan
|
|
101
|
+
var intrinsicWidth: Float = .nan
|
|
102
|
+
var intrinsicHeight: Float = .nan
|
|
103
|
+
var baseline: Float = .nan
|
|
104
|
+
var aspectRatio: Float = .nan
|
|
105
|
+
var flexBasis: Float = .nan
|
|
106
|
+
var flexGrow: Float = 0
|
|
107
|
+
var flexShrink: Float = 0
|
|
108
|
+
var minWidth: Float = .nan
|
|
109
|
+
var maxWidth: Float = .nan
|
|
110
|
+
var minHeight: Float = .nan
|
|
111
|
+
var maxHeight: Float = .nan
|
|
112
|
+
var gap: Float = 0
|
|
113
|
+
var justifyContent: UInt32 = 0
|
|
114
|
+
var alignItems: UInt32 = 3
|
|
115
|
+
var alignSelf: UInt32 = 0
|
|
116
|
+
var flexWrap: UInt32 = 0
|
|
117
|
+
var alignContent: UInt32 = 6
|
|
118
|
+
var marginTop: Float = 0
|
|
119
|
+
var marginRight: Float = 0
|
|
120
|
+
var marginBottom: Float = 0
|
|
121
|
+
var marginLeft: Float = 0
|
|
122
|
+
var paddingTop: Float = 0
|
|
123
|
+
var paddingRight: Float = 0
|
|
124
|
+
var paddingBottom: Float = 0
|
|
125
|
+
var paddingLeft: Float = 0
|
|
126
|
+
var grid: UInt32 = 0
|
|
127
|
+
var gridColumns: UInt32 = 1
|
|
128
|
+
var rowGap: Float = 0
|
|
129
|
+
var columnGap: Float = 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private struct NodeInput {
|
|
133
|
+
var style: StyleInput
|
|
134
|
+
var firstChild: UInt32
|
|
135
|
+
var childCount: UInt32
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private struct Frame {
|
|
139
|
+
var x: Float
|
|
140
|
+
var y: Float
|
|
141
|
+
var width: Float
|
|
142
|
+
var height: Float
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private typealias Function = @convention(c) (
|
|
146
|
+
Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
|
|
147
|
+
) -> Int32
|
|
148
|
+
|
|
149
|
+
private static let function: Function? = {
|
|
150
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree") else { return nil }
|
|
151
|
+
return unsafeBitCast(symbol, to: Function.self)
|
|
152
|
+
}()
|
|
153
|
+
|
|
154
|
+
private static func finite(_ value: CGFloat?) -> Float {
|
|
155
|
+
guard let value, value.isFinite else { return .nan }
|
|
156
|
+
return Float(value)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private static func length(_ value: CraftNativeLength?, base: CGFloat?) -> Float {
|
|
160
|
+
guard let value else { return .nan }
|
|
161
|
+
return finite(value.resolve(base))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private static func alignment(_ value: String?) -> UInt32 {
|
|
165
|
+
switch value {
|
|
166
|
+
case "center": return 1
|
|
167
|
+
case "flex-end": return 2
|
|
168
|
+
case "stretch": return 3
|
|
169
|
+
case "baseline": return 4
|
|
170
|
+
default: return 0
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private static func alignSelf(_ value: String?) -> UInt32 {
|
|
175
|
+
switch value {
|
|
176
|
+
case "flex-start": return 1
|
|
177
|
+
case "center": return 2
|
|
178
|
+
case "flex-end": return 3
|
|
179
|
+
case "stretch": return 4
|
|
180
|
+
case "baseline": return 5
|
|
181
|
+
default: return 0
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private static func justify(_ value: String) -> UInt32 {
|
|
186
|
+
craftNativeJustifyCode(value)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
static func frames(
|
|
190
|
+
width: CGFloat,
|
|
191
|
+
height: CGFloat,
|
|
192
|
+
direction: NSLayoutConstraint.Axis,
|
|
193
|
+
gap: CGFloat,
|
|
194
|
+
rowGap: CGFloat?,
|
|
195
|
+
columnGap: CGFloat?,
|
|
196
|
+
wrap: Bool,
|
|
197
|
+
justifyContent: String,
|
|
198
|
+
alignItems: String,
|
|
199
|
+
padding: UIEdgeInsets,
|
|
200
|
+
children: [(UIView, CraftNativeLayoutStyle, CGSize, CGFloat)]
|
|
201
|
+
) -> [(UIView, CGRect)]? {
|
|
202
|
+
guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !children.isEmpty else { return nil }
|
|
203
|
+
let mainGap = direction == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
|
|
204
|
+
let crossGap = direction == .horizontal ? (rowGap ?? gap) : (columnGap ?? gap)
|
|
205
|
+
var root = StyleInput(
|
|
206
|
+
direction: direction == .horizontal ? 0 : 1,
|
|
207
|
+
width: Float(width), height: Float(height), gap: Float(max(0, mainGap)),
|
|
208
|
+
justifyContent: justify(justifyContent), alignItems: alignment(alignItems), flexWrap: wrap ? 1 : 0,
|
|
209
|
+
paddingTop: Float(max(0, padding.top)), paddingRight: Float(max(0, padding.right)),
|
|
210
|
+
paddingBottom: Float(max(0, padding.bottom)), paddingLeft: Float(max(0, padding.left))
|
|
211
|
+
)
|
|
212
|
+
root.rowGap = Float(max(0, rowGap ?? 0))
|
|
213
|
+
root.columnGap = Float(max(0, columnGap ?? 0))
|
|
214
|
+
let contentWidth = max(0, width - padding.left - padding.right)
|
|
215
|
+
let contentHeight = max(0, height - padding.top - padding.bottom)
|
|
216
|
+
let mainSize = direction == .horizontal ? contentWidth : contentHeight
|
|
217
|
+
var nodes = [NodeInput(style: root, firstChild: 1, childCount: UInt32(children.count))]
|
|
218
|
+
for (view, style, intrinsic, baseline) in children {
|
|
219
|
+
let shrink: CGFloat
|
|
220
|
+
if let authored = style.flexShrink {
|
|
221
|
+
shrink = authored
|
|
222
|
+
} else if view is UIScrollView {
|
|
223
|
+
shrink = 1
|
|
224
|
+
} else if view is UILabel, direction == .horizontal {
|
|
225
|
+
shrink = 1
|
|
226
|
+
} else {
|
|
227
|
+
shrink = 0
|
|
228
|
+
}
|
|
229
|
+
var input = StyleInput(
|
|
230
|
+
width: length(style.width, base: contentWidth), height: length(style.height, base: contentHeight),
|
|
231
|
+
intrinsicWidth: Float(max(0, intrinsic.width)), intrinsicHeight: Float(max(0, intrinsic.height)),
|
|
232
|
+
baseline: Float(max(0, baseline)), aspectRatio: finite(style.aspectRatio),
|
|
233
|
+
flexBasis: length(style.flexBasis, base: mainSize), flexGrow: Float(max(0, style.flexGrow)), flexShrink: Float(max(0, shrink)),
|
|
234
|
+
minWidth: length(style.minWidth, base: contentWidth), maxWidth: length(style.maxWidth, base: contentWidth),
|
|
235
|
+
minHeight: length(style.minHeight, base: contentHeight), maxHeight: length(style.maxHeight, base: contentHeight),
|
|
236
|
+
alignSelf: alignSelf(style.alignSelf),
|
|
237
|
+
marginTop: Float(max(0, style.marginTop)), marginRight: Float(max(0, style.marginRight)),
|
|
238
|
+
marginBottom: Float(max(0, style.marginBottom)), marginLeft: Float(max(0, style.marginLeft))
|
|
239
|
+
)
|
|
240
|
+
input.direction = 0
|
|
241
|
+
nodes.append(NodeInput(style: input, firstChild: 0, childCount: 0))
|
|
242
|
+
}
|
|
243
|
+
var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
|
|
244
|
+
let frameCount = output.count
|
|
245
|
+
let status = nodes.withUnsafeBufferPointer { nodeBuffer in
|
|
246
|
+
output.withUnsafeMutableBufferPointer { frameBuffer in
|
|
247
|
+
function(
|
|
248
|
+
Float(width), Float(height), UnsafeRawPointer(nodeBuffer.baseAddress!), nodes.count,
|
|
249
|
+
UnsafeMutableRawPointer(frameBuffer.baseAddress!), frameCount
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
guard status == 0 else { return nil }
|
|
254
|
+
let childFrames = output.dropFirst()
|
|
255
|
+
guard childFrames.allSatisfy({ frame in
|
|
256
|
+
frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
|
|
257
|
+
frame.width >= 0 && frame.height >= 0
|
|
258
|
+
}) else { return nil }
|
|
259
|
+
return zip(children, childFrames).map { child, frame in
|
|
260
|
+
(child.0, CGRect(x: CGFloat(frame.x), y: CGFloat(frame.y), width: CGFloat(frame.width), height: CGFloat(frame.height)))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
84
265
|
private enum CraftNativeFlexLineBridge {
|
|
85
266
|
private struct Item {
|
|
86
267
|
var basis: Float
|
|
@@ -430,6 +611,7 @@ private final class CraftNativeVideoView: UIView {
|
|
|
430
611
|
private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
431
612
|
private var annotationsByID: [String: MKPointAnnotation] = [:]
|
|
432
613
|
private var overlaysByID: [String: MKPolyline] = [:]
|
|
614
|
+
private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
|
|
433
615
|
private var overlayColors: [ObjectIdentifier: UIColor] = [:]
|
|
434
616
|
private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
|
|
435
617
|
private(set) var markerHandlers: [String: String] = [:]
|
|
@@ -479,7 +661,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
479
661
|
}
|
|
480
662
|
} else if type == "mappolyline" || type == "polyline" {
|
|
481
663
|
guard points.count > 1 else { continue }
|
|
482
|
-
|
|
664
|
+
let authoredWidth = (childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3
|
|
665
|
+
let width = CGFloat(authoredWidth)
|
|
666
|
+
guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
|
|
667
|
+
nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
|
|
483
668
|
}
|
|
484
669
|
}
|
|
485
670
|
|
|
@@ -499,13 +684,31 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
499
684
|
}
|
|
500
685
|
markerHandlers = nextHandlers
|
|
501
686
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
687
|
+
let staleOverlays = overlaysByID.keys.filter { nextPolylines[$0] == nil }
|
|
688
|
+
for id in staleOverlays {
|
|
689
|
+
if let overlay = overlaysByID.removeValue(forKey: id) {
|
|
690
|
+
removeOverlay(overlay)
|
|
691
|
+
overlayColors.removeValue(forKey: ObjectIdentifier(overlay))
|
|
692
|
+
overlayWidths.removeValue(forKey: ObjectIdentifier(overlay))
|
|
693
|
+
}
|
|
694
|
+
overlayCoordinatesByID.removeValue(forKey: id)
|
|
695
|
+
}
|
|
506
696
|
for (id, value) in nextPolylines {
|
|
697
|
+
if let overlay = overlaysByID[id],
|
|
698
|
+
let coordinates = overlayCoordinatesByID[id],
|
|
699
|
+
Self.coordinatesEqual(coordinates, value.coordinates),
|
|
700
|
+
overlayColors[ObjectIdentifier(overlay)]?.isEqual(value.color) == true,
|
|
701
|
+
overlayWidths[ObjectIdentifier(overlay)] == value.width {
|
|
702
|
+
continue
|
|
703
|
+
}
|
|
704
|
+
if let previous = overlaysByID.removeValue(forKey: id) {
|
|
705
|
+
removeOverlay(previous)
|
|
706
|
+
overlayColors.removeValue(forKey: ObjectIdentifier(previous))
|
|
707
|
+
overlayWidths.removeValue(forKey: ObjectIdentifier(previous))
|
|
708
|
+
}
|
|
507
709
|
let overlay = MKPolyline(coordinates: value.coordinates, count: value.coordinates.count)
|
|
508
710
|
overlaysByID[id] = overlay
|
|
711
|
+
overlayCoordinatesByID[id] = value.coordinates
|
|
509
712
|
overlayColors[ObjectIdentifier(overlay)] = value.color
|
|
510
713
|
overlayWidths[ObjectIdentifier(overlay)] = value.width
|
|
511
714
|
addOverlay(overlay)
|
|
@@ -557,8 +760,19 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
557
760
|
let numbers = (value as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue }
|
|
558
761
|
?? (value as? [NSNumber])?.map(\.doubleValue)
|
|
559
762
|
?? []
|
|
560
|
-
return stride(from: 0, to: numbers.count - 1, by: 2).
|
|
561
|
-
|
|
763
|
+
return stride(from: 0, to: numbers.count - 1, by: 2).compactMap {
|
|
764
|
+
let latitude = numbers[$0]
|
|
765
|
+
let longitude = numbers[$0 + 1]
|
|
766
|
+
guard latitude.isFinite, longitude.isFinite,
|
|
767
|
+
(-90...90).contains(latitude), (-180...180).contains(longitude) else { return nil }
|
|
768
|
+
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private static func coordinatesEqual(_ lhs: [CLLocationCoordinate2D], _ rhs: [CLLocationCoordinate2D]) -> Bool {
|
|
773
|
+
guard lhs.count == rhs.count else { return false }
|
|
774
|
+
return zip(lhs, rhs).allSatisfy { left, right in
|
|
775
|
+
left.latitude == right.latitude && left.longitude == right.longitude
|
|
562
776
|
}
|
|
563
777
|
}
|
|
564
778
|
|
|
@@ -569,7 +783,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
569
783
|
let latitude = (center["latitude"] as? NSNumber)?.doubleValue,
|
|
570
784
|
let longitude = (center["longitude"] as? NSNumber)?.doubleValue,
|
|
571
785
|
let latitudeDelta = (span["latitudeDelta"] as? NSNumber)?.doubleValue ?? (span["latitude_delta"] as? NSNumber)?.doubleValue,
|
|
572
|
-
let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue
|
|
786
|
+
let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue,
|
|
787
|
+
latitude.isFinite, longitude.isFinite, latitudeDelta.isFinite, longitudeDelta.isFinite,
|
|
788
|
+
(-90...90).contains(latitude), (-180...180).contains(longitude),
|
|
789
|
+
latitudeDelta > 0, latitudeDelta <= 180, longitudeDelta > 0, longitudeDelta <= 360,
|
|
790
|
+
latitude - latitudeDelta / 2 >= -90, latitude + latitudeDelta / 2 <= 90 else { return nil }
|
|
573
791
|
return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
|
|
574
792
|
}
|
|
575
793
|
|
|
@@ -631,15 +849,17 @@ private enum CraftNativeLength: Equatable {
|
|
|
631
849
|
|
|
632
850
|
init?(_ value: Any?) {
|
|
633
851
|
if let number = value as? NSNumber {
|
|
634
|
-
|
|
852
|
+
let points = CGFloat(truncating: number)
|
|
853
|
+
guard points.isFinite else { return nil }
|
|
854
|
+
self = .points(points)
|
|
635
855
|
return
|
|
636
856
|
}
|
|
637
857
|
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
638
|
-
if text.hasSuffix("%"), let fraction = Double(text.dropLast()) {
|
|
858
|
+
if text.hasSuffix("%"), let fraction = Double(text.dropLast()), fraction.isFinite {
|
|
639
859
|
self = .percent(CGFloat(fraction) / 100)
|
|
640
|
-
} else if text.hasSuffix("px"), let points = Double(text.dropLast(2)) {
|
|
860
|
+
} else if text.hasSuffix("px"), let points = Double(text.dropLast(2)), points.isFinite {
|
|
641
861
|
self = .points(CGFloat(points))
|
|
642
|
-
} else if let points = Double(text) {
|
|
862
|
+
} else if let points = Double(text), points.isFinite {
|
|
643
863
|
self = .points(CGFloat(points))
|
|
644
864
|
} else {
|
|
645
865
|
return nil
|
|
@@ -648,7 +868,7 @@ private enum CraftNativeLength: Equatable {
|
|
|
648
868
|
|
|
649
869
|
func resolve(_ base: CGFloat?) -> CGFloat? {
|
|
650
870
|
switch self {
|
|
651
|
-
case .points(let value): return value
|
|
871
|
+
case .points(let value): return max(0, value)
|
|
652
872
|
case .percent(let fraction): return base.map { max(0, $0 * fraction) }
|
|
653
873
|
}
|
|
654
874
|
}
|
|
@@ -685,7 +905,11 @@ private struct CraftNativeLayoutStyle {
|
|
|
685
905
|
static let empty = CraftNativeLayoutStyle([:])
|
|
686
906
|
|
|
687
907
|
init(_ raw: [String: Any]) {
|
|
688
|
-
func number(_ value: Any?) -> CGFloat? {
|
|
908
|
+
func number(_ value: Any?) -> CGFloat? {
|
|
909
|
+
guard let number = value as? NSNumber else { return nil }
|
|
910
|
+
let value = CGFloat(truncating: number)
|
|
911
|
+
return value.isFinite ? value : nil
|
|
912
|
+
}
|
|
689
913
|
width = CraftNativeLength(raw["width"])
|
|
690
914
|
height = CraftNativeLength(raw["height"])
|
|
691
915
|
let ratio = number(raw["aspectRatio"])
|
|
@@ -942,6 +1166,18 @@ enum CraftNativeFlexLayout {
|
|
|
942
1166
|
/// grid. It is a UIStackView in name only, so existing callers and tests keep
|
|
943
1167
|
/// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
|
|
944
1168
|
/// constraints are never created, and every frame comes from `layout`.
|
|
1169
|
+
extension UIView {
|
|
1170
|
+
/// The native screen this view is drawn on, through the responder chain.
|
|
1171
|
+
var craftScreenController: CraftNativeScreenController? {
|
|
1172
|
+
var responder: UIResponder? = next
|
|
1173
|
+
while let current = responder {
|
|
1174
|
+
if let screen = current as? CraftNativeScreenController { return screen }
|
|
1175
|
+
responder = current.next
|
|
1176
|
+
}
|
|
1177
|
+
return nil
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
945
1181
|
private class CraftNativeFlowView: UIStackView {
|
|
946
1182
|
var pressActiveOpacity: CGFloat?
|
|
947
1183
|
var pressBaseOpacity: CGFloat = 1
|
|
@@ -987,6 +1223,13 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
987
1223
|
|
|
988
1224
|
private var flowChildren: [UIView] = []
|
|
989
1225
|
private var measurements: [MeasureKey: CGSize] = [:]
|
|
1226
|
+
/// The screen's rendered root gets a definite viewport from the host. A
|
|
1227
|
+
/// root with `flex: 1` must keep that height even when its first child is
|
|
1228
|
+
/// a ScrollView whose content is taller than the screen; otherwise an
|
|
1229
|
+
/// absolute sibling is positioned against the scroll content and moves
|
|
1230
|
+
/// with it.
|
|
1231
|
+
private weak var viewportChild: UIView?
|
|
1232
|
+
private var viewportChildFillsHeight = false
|
|
990
1233
|
|
|
991
1234
|
private struct MeasureKey: Hashable {
|
|
992
1235
|
let width: CGFloat?
|
|
@@ -1088,6 +1331,12 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1088
1331
|
invalidateMeasurements()
|
|
1089
1332
|
}
|
|
1090
1333
|
|
|
1334
|
+
func setViewportChild(_ view: UIView?, fillsHeight: Bool) {
|
|
1335
|
+
viewportChild = view
|
|
1336
|
+
viewportChildFillsHeight = fillsHeight
|
|
1337
|
+
invalidateMeasurements()
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1091
1340
|
func removeLayoutStyle(for view: UIView) {
|
|
1092
1341
|
view.craftLayoutStyle = .empty
|
|
1093
1342
|
}
|
|
@@ -1121,8 +1370,20 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1121
1370
|
craftApplyCornerRadius()
|
|
1122
1371
|
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
1123
1372
|
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
1373
|
+
var moved = false
|
|
1374
|
+
defer {
|
|
1375
|
+
// The screen reports onLayout from its own layout pass, which a
|
|
1376
|
+
// change deep in a scroll view's content never runs: a view that
|
|
1377
|
+
// went from 240 to 180 wide kept saying 240.
|
|
1378
|
+
if moved { craftScreenController?.scheduleLayoutEvents() }
|
|
1379
|
+
}
|
|
1124
1380
|
for (child, frame) in result.frames {
|
|
1125
|
-
var
|
|
1381
|
+
var viewportFrame = frame
|
|
1382
|
+
if child === viewportChild, viewportChildFillsHeight {
|
|
1383
|
+
viewportFrame.origin.y = 0
|
|
1384
|
+
viewportFrame.size.height = bounds.height
|
|
1385
|
+
}
|
|
1386
|
+
var snapped = CraftNativeFlexLayout.snapped(viewportFrame, scale: scale)
|
|
1126
1387
|
// Snapping each edge can take a pixel off a label (15.83…33.83
|
|
1127
1388
|
// becomes 16…33.67), and a label a pixel narrower than its text
|
|
1128
1389
|
// truncates it ("13" drew as "…"). Text keeps its whole size.
|
|
@@ -1130,14 +1391,14 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1130
1391
|
snapped.size = CraftNativeFlexLayout.ceiled(frame.size, scale: scale, atLeast: snapped.size)
|
|
1131
1392
|
}
|
|
1132
1393
|
if child.transform.isIdentity {
|
|
1133
|
-
if child.frame != snapped { child.frame = snapped }
|
|
1394
|
+
if child.frame != snapped { child.frame = snapped; moved = true }
|
|
1134
1395
|
} else {
|
|
1135
1396
|
// A transformed child (a sticky header, a scaled spinner) is
|
|
1136
1397
|
// placed by bounds and center: its frame is undefined then.
|
|
1137
1398
|
let size = CGRect(origin: child.bounds.origin, size: snapped.size)
|
|
1138
1399
|
let center = CGPoint(x: snapped.midX, y: snapped.midY)
|
|
1139
|
-
if child.bounds != size { child.bounds = size }
|
|
1140
|
-
if child.center != center { child.center = center }
|
|
1400
|
+
if child.bounds != size { child.bounds = size; moved = true }
|
|
1401
|
+
if child.center != center { child.center = center; moved = true }
|
|
1141
1402
|
}
|
|
1142
1403
|
child.craftApplyCornerRadius()
|
|
1143
1404
|
}
|
|
@@ -1205,6 +1466,29 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1205
1466
|
/// outer size when its parent fixes it, nil when it sizes to its content
|
|
1206
1467
|
/// within `maxWidth`/`maxHeight`. Returns its outer size and, when
|
|
1207
1468
|
/// placing, every child's frame in its own coordinates.
|
|
1469
|
+
private func sharedDirectFrames(_ children: [UIView], width: CGFloat?, height: CGFloat?) -> [(UIView, CGRect)]? {
|
|
1470
|
+
guard let width, let height, !grid, !reverse, !wrapReverse,
|
|
1471
|
+
width.isFinite, height.isFinite, width >= 0, height >= 0,
|
|
1472
|
+
children.allSatisfy({ child in
|
|
1473
|
+
let style = child.craftLayoutStyle
|
|
1474
|
+
return style.position != "absolute" && style.display != "none"
|
|
1475
|
+
}) else { return nil }
|
|
1476
|
+
|
|
1477
|
+
let intrinsic = children.map { child in
|
|
1478
|
+
CraftNativeFlexLayout.measure(child, width: nil, height: nil, maxWidth: width, maxHeight: height)
|
|
1479
|
+
}
|
|
1480
|
+
let baselines = children.enumerated().map { index, child in
|
|
1481
|
+
CraftNativeFlowView.firstBaseline(child, cross: intrinsic[index].height)
|
|
1482
|
+
}
|
|
1483
|
+
let payload = zip(children, zip(intrinsic, baselines)).map { child, measured in
|
|
1484
|
+
(child, child.craftLayoutStyle, measured.0, measured.1)
|
|
1485
|
+
}
|
|
1486
|
+
return CraftNativeTreeLayoutBridge.frames(
|
|
1487
|
+
width: width, height: height, direction: axis, gap: gap, rowGap: rowGap, columnGap: columnGap, wrap: wrap,
|
|
1488
|
+
justifyContent: justifyContent, alignItems: alignItems, padding: effectivePadding, children: payload
|
|
1489
|
+
)
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1208
1492
|
private func layout(
|
|
1209
1493
|
width: CGFloat?,
|
|
1210
1494
|
height: CGFloat?,
|
|
@@ -1239,6 +1523,9 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1239
1523
|
frames: &frames
|
|
1240
1524
|
)
|
|
1241
1525
|
}
|
|
1526
|
+
if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
|
|
1527
|
+
frames = shared
|
|
1528
|
+
}
|
|
1242
1529
|
let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
|
|
1243
1530
|
|
|
1244
1531
|
if place {
|
|
@@ -1286,6 +1573,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1286
1573
|
place: Bool,
|
|
1287
1574
|
frames: inout [(UIView, CGRect)]
|
|
1288
1575
|
) -> CGSize {
|
|
1576
|
+
let contentPadding = effectivePadding
|
|
1289
1577
|
let definiteMain = horizontal ? innerWidth : innerHeight
|
|
1290
1578
|
let definiteCross = horizontal ? innerHeight : innerWidth
|
|
1291
1579
|
let roomMain = horizontal ? roomWidth : roomHeight
|
|
@@ -1647,6 +1935,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1647
1935
|
place: Bool,
|
|
1648
1936
|
frames: inout [(UIView, CGRect)]
|
|
1649
1937
|
) -> CGSize {
|
|
1938
|
+
let contentPadding = effectivePadding
|
|
1650
1939
|
let columns = max(1, gridColumns)
|
|
1651
1940
|
let horizontalGap = columnGap ?? gap
|
|
1652
1941
|
let verticalGap = rowGap ?? gap
|
|
@@ -1670,7 +1959,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1670
1959
|
let available = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1671
1960
|
let align = style.alignSelf ?? alignItems
|
|
1672
1961
|
var fixedWidth = style.width?.resolve(cellWidth)
|
|
1673
|
-
var fixedHeight = style.height?.resolve(
|
|
1962
|
+
var fixedHeight = style.height?.resolve(innerHeight)
|
|
1674
1963
|
// A cell's item is as wide as its column unless it sets a width
|
|
1675
1964
|
// (or an aspect ratio derives one from its height), whatever its
|
|
1676
1965
|
// height: a fixed-height swatch with no content was drawn 0 wide.
|
|
@@ -1704,7 +1993,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1704
1993
|
let size = sizes[index]
|
|
1705
1994
|
let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
|
|
1706
1995
|
var explicitWidth = style.width?.resolve(cellWidth)
|
|
1707
|
-
var explicitHeight = style.height?.resolve(
|
|
1996
|
+
var explicitHeight = style.height?.resolve(innerHeight)
|
|
1708
1997
|
if explicitWidth == nil && align == "stretch" && (explicitHeight == nil || style.aspectRatio == nil) {
|
|
1709
1998
|
explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1710
1999
|
}
|
|
@@ -1713,7 +2002,7 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1713
2002
|
size.width, size.height,
|
|
1714
2003
|
explicitWidth, explicitHeight,
|
|
1715
2004
|
style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
|
|
1716
|
-
style.minHeight?.resolve(
|
|
2005
|
+
style.minHeight?.resolve(innerHeight), style.maxHeight?.resolve(innerHeight),
|
|
1717
2006
|
style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
|
|
1718
2007
|
align == "stretch", alignment
|
|
1719
2008
|
)
|
|
@@ -1822,7 +2111,7 @@ private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
|
|
|
1822
2111
|
func configureKeyboard(props: [String: Any]) {
|
|
1823
2112
|
let requestedBehavior = (props["behavior"] as? String) ?? "padding"
|
|
1824
2113
|
keyboardBehavior = ["height", "position", "padding"].contains(requestedBehavior) ? requestedBehavior : "padding"
|
|
1825
|
-
keyboardVerticalOffset = (props["keyboardVerticalOffset"]
|
|
2114
|
+
keyboardVerticalOffset = number(props["keyboardVerticalOffset"]) ?? 0
|
|
1826
2115
|
if keyboardBehavior != "height" { appliedHeightInset = 0 }
|
|
1827
2116
|
authoredPadding = padding
|
|
1828
2117
|
applyKeyboardAvoidance()
|
|
@@ -1922,6 +2211,10 @@ private final class CraftNativeModalView: UIView {
|
|
|
1922
2211
|
|
|
1923
2212
|
required init?(coder: NSCoder) { nil }
|
|
1924
2213
|
|
|
2214
|
+
/// Whether the modal takes touches is its own to say: shown, it takes
|
|
2215
|
+
/// every one (the scrim and its controls); the screen's press and long
|
|
2216
|
+
/// press bookkeeping once turned that off for a view with no handler of
|
|
2217
|
+
/// its own, and a shown modal let every tap through to the screen behind.
|
|
1925
2218
|
func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
|
|
1926
2219
|
if visible { isUserInteractionEnabled = true }
|
|
1927
2220
|
if renderedVisible == visible && isHidden == !visible {
|
|
@@ -2003,7 +2296,27 @@ private final class CraftNativeModalView: UIView {
|
|
|
2003
2296
|
override func layoutSubviews() {
|
|
2004
2297
|
super.layoutSubviews()
|
|
2005
2298
|
blocker.frame = bounds
|
|
2006
|
-
|
|
2299
|
+
// The scrim covers the whole screen; what the modal holds stays
|
|
2300
|
+
// clear of the bars and the home indicator it is drawn over.
|
|
2301
|
+
contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/// Where the screen's own content starts and ends: the scrim covers the
|
|
2305
|
+
/// whole screen, what the modal holds stays clear of the bars.
|
|
2306
|
+
var contentInsets: UIEdgeInsets = .zero {
|
|
2307
|
+
didSet { if contentInsets != oldValue { setNeedsLayout() } }
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/// The delegate of a Pressable's press-in/out feedback. That recognizer is
|
|
2312
|
+
/// a long press of no duration, so it begins the moment a finger lands; on its
|
|
2313
|
+
/// own it would claim the touch, and the tap that is the Pressable's onPress
|
|
2314
|
+
/// failed whenever onPressIn or onPressOut were set too. It only watches.
|
|
2315
|
+
final class CraftPressFeedbackDelegate: NSObject, UIGestureRecognizerDelegate {
|
|
2316
|
+
static let shared = CraftPressFeedbackDelegate()
|
|
2317
|
+
|
|
2318
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
2319
|
+
true
|
|
2007
2320
|
}
|
|
2008
2321
|
}
|
|
2009
2322
|
|
|
@@ -2275,14 +2588,24 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
2275
2588
|
|
|
2276
2589
|
@objc private func keyboardTap() {
|
|
2277
2590
|
guard keyboardShouldPersistTaps != "always" else { return }
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2591
|
+
let point = keyboardTapGesture.location(in: self)
|
|
2592
|
+
var hit = hitTest(point, with: nil)
|
|
2593
|
+
var onControl = false
|
|
2594
|
+
var onButton = false
|
|
2595
|
+
while let view = hit, view !== self {
|
|
2596
|
+
if view is UIButton { onButton = true }
|
|
2597
|
+
if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
|
|
2598
|
+
hit = view.superview
|
|
2599
|
+
}
|
|
2600
|
+
if keyboardShouldPersistTaps == "handled", onControl { return }
|
|
2601
|
+
// A button keeps the keyboard: its press gives the focus straight
|
|
2602
|
+
// back. Ending editing first only let the keyboard's inset go while
|
|
2603
|
+
// the finger was down, the button moved out from under it, and the
|
|
2604
|
+
// press was lost.
|
|
2605
|
+
if onButton { return }
|
|
2606
|
+
// Otherwise the keyboard goes; after a tap on nothing, for good (the
|
|
2607
|
+
// next render must not bring the last input back).
|
|
2608
|
+
if !onControl { craftScreenController?.forgetFocusedInput() }
|
|
2286
2609
|
window?.endEditing(true)
|
|
2287
2610
|
}
|
|
2288
2611
|
|
|
@@ -2323,7 +2646,7 @@ private final class CraftNativeSvgView: UIView {
|
|
|
2323
2646
|
let props = drawing["props"] as? [String: Any] ?? [:]
|
|
2324
2647
|
guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
|
|
2325
2648
|
let n = box.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
2326
|
-
guard n.count == 4, n[2] > 0, n[3] > 0 else { return nil }
|
|
2649
|
+
guard n.count == 4, n.allSatisfy(\.isFinite), n[2] > 0, n[3] > 0 else { return nil }
|
|
2327
2650
|
return CGRect(x: n[0], y: n[1], width: n[2], height: n[3])
|
|
2328
2651
|
}
|
|
2329
2652
|
|
|
@@ -2344,21 +2667,29 @@ private final class CraftNativeSvgView: UIView {
|
|
|
2344
2667
|
var linecap: String?
|
|
2345
2668
|
var linejoin: String?
|
|
2346
2669
|
var dashes: [CGFloat]?
|
|
2670
|
+
var vectorEffect: String?
|
|
2347
2671
|
|
|
2348
2672
|
func merged(_ node: [String: Any]) -> Paint {
|
|
2349
2673
|
var next = self
|
|
2350
2674
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
2351
2675
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
2676
|
+
func value(_ key: String) -> Any? { style[key] ?? props[key] }
|
|
2677
|
+
func finite(_ raw: Any?) -> CGFloat? {
|
|
2678
|
+
guard let number = raw as? NSNumber else { return nil }
|
|
2679
|
+
let value = CGFloat(truncating: number)
|
|
2680
|
+
return value.isFinite ? value : nil
|
|
2681
|
+
}
|
|
2352
2682
|
if let color = style["color"] as? String { next.color = color }
|
|
2353
2683
|
if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
|
|
2354
2684
|
if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
|
|
2355
|
-
if let width = (style["strokeWidth"]
|
|
2685
|
+
if let width = finite(style["strokeWidth"] ?? props["strokeWidth"]) { next.strokeWidth = width }
|
|
2356
2686
|
// Computed style wins over the component prop, matching Android
|
|
2357
2687
|
// and the browser's cascade when both provide opacity.
|
|
2358
|
-
if let opacity = style["opacity"]
|
|
2359
|
-
if let cap =
|
|
2360
|
-
if let join =
|
|
2361
|
-
if let dashes =
|
|
2688
|
+
if let opacity = finite(style["opacity"] ?? props["opacity"]) { next.opacity *= opacity }
|
|
2689
|
+
if let cap = value("strokeLinecap") as? String { next.linecap = cap }
|
|
2690
|
+
if let join = value("strokeLinejoin") as? String { next.linejoin = join }
|
|
2691
|
+
if let dashes = value("strokeDasharray") as? [Any] { next.dashes = dashes.compactMap(finite).filter { $0 >= 0 } }
|
|
2692
|
+
if let vectorEffect = value("vectorEffect") as? String { next.vectorEffect = vectorEffect }
|
|
2362
2693
|
return next
|
|
2363
2694
|
}
|
|
2364
2695
|
|
|
@@ -2425,7 +2756,7 @@ private final class CraftNativeSvgView: UIView {
|
|
|
2425
2756
|
// `vector-effect="non-scaling-stroke"`: the stroke is in the
|
|
2426
2757
|
// view's points, so a stretched chart's line keeps its width
|
|
2427
2758
|
// and a round cap stays a round dot.
|
|
2428
|
-
let nonScaling =
|
|
2759
|
+
let nonScaling = own.vectorEffect == "non-scaling-stroke"
|
|
2429
2760
|
let line: UIBezierPath
|
|
2430
2761
|
if nonScaling, let copy = path.copy() as? UIBezierPath {
|
|
2431
2762
|
copy.apply(viewTransform)
|
|
@@ -2452,14 +2783,14 @@ private final class CraftNativeSvgView: UIView {
|
|
|
2452
2783
|
guard !text.isEmpty else { return }
|
|
2453
2784
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
2454
2785
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
2455
|
-
let size =
|
|
2786
|
+
let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
|
|
2456
2787
|
let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
|
|
2457
2788
|
let string = NSAttributedString(string: text, attributes: [
|
|
2458
2789
|
.font: UIFont.systemFont(ofSize: size, weight: weight),
|
|
2459
2790
|
.foregroundColor: paint.resolve(paint.fill) ?? UIColor.label,
|
|
2460
2791
|
])
|
|
2461
|
-
var x =
|
|
2462
|
-
let y =
|
|
2792
|
+
var x = number(props["x"]) ?? 0
|
|
2793
|
+
let y = number(props["y"]) ?? 0
|
|
2463
2794
|
switch props["textAnchor"] as? String {
|
|
2464
2795
|
case "middle": x -= string.size().width / 2
|
|
2465
2796
|
case "end": x -= string.size().width
|
|
@@ -2472,7 +2803,7 @@ private final class CraftNativeSvgView: UIView {
|
|
|
2472
2803
|
static func path(_ value: Any?) -> UIBezierPath? {
|
|
2473
2804
|
guard let list = value as? [Any] else { return nil }
|
|
2474
2805
|
let n = list.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
2475
|
-
guard !n.isEmpty, n.count == list.count else { return nil }
|
|
2806
|
+
guard !n.isEmpty, n.count == list.count, n.allSatisfy(\.isFinite) else { return nil }
|
|
2476
2807
|
let path = UIBezierPath()
|
|
2477
2808
|
var i = 0
|
|
2478
2809
|
func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
|
|
@@ -2778,6 +3109,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2778
3109
|
private var linkTargets: [ObjectIdentifier: String] = [:]
|
|
2779
3110
|
private var renderedRoot: RenderedNode?
|
|
2780
3111
|
private weak var lastFocusedInput: UIView?
|
|
3112
|
+
|
|
3113
|
+
/// The person put the keyboard away (Return, or a tap on nothing in a
|
|
3114
|
+
/// scroll view): the last input is let go. Kept, the next render or
|
|
3115
|
+
/// button press focused it again and the keyboard came straight back.
|
|
3116
|
+
func forgetFocusedInput(_ input: UIView? = nil) {
|
|
3117
|
+
if input == nil || lastFocusedInput === input { lastFocusedInput = nil }
|
|
3118
|
+
}
|
|
2781
3119
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
2782
3120
|
private var flatListOwners: [String: String] = [:]
|
|
2783
3121
|
/// A drawing's shapes are not views: a change to one redraws its `Svg`.
|
|
@@ -2909,6 +3247,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2909
3247
|
override func viewDidAppear(_ animated: Bool) {
|
|
2910
3248
|
super.viewDidAppear(animated)
|
|
2911
3249
|
screenVisibilityChanged(true)
|
|
3250
|
+
// A navigation breadcrumb for the crash reporter: the trail a crash
|
|
3251
|
+
// report carries is the screens that led to it.
|
|
3252
|
+
if !isAppScript, let routeName, !routeName.isEmpty {
|
|
3253
|
+
CraftCrashReporter.shared.screenAppeared(routeName)
|
|
3254
|
+
}
|
|
3255
|
+
// onLayout reports views that are on screen: a pushed screen lays
|
|
3256
|
+
// out before it has a window, and nothing moved again once it had
|
|
3257
|
+
// one, so its rows never reported.
|
|
3258
|
+
emitLayoutEvents()
|
|
2912
3259
|
}
|
|
2913
3260
|
|
|
2914
3261
|
override func viewDidDisappear(_ animated: Bool) {
|
|
@@ -2983,9 +3330,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2983
3330
|
|
|
2984
3331
|
override func viewDidLayoutSubviews() {
|
|
2985
3332
|
super.viewDidLayoutSubviews()
|
|
3333
|
+
positionModals()
|
|
2986
3334
|
emitLayoutEvents()
|
|
2987
3335
|
}
|
|
2988
3336
|
|
|
3337
|
+
/// Modals cover the screen; their content starts below its bars (the
|
|
3338
|
+
/// status bar is all the safe area counts on a native screen, and a
|
|
3339
|
+
/// modal's first control sat under the navigation bar).
|
|
3340
|
+
private func positionModals() {
|
|
3341
|
+
let modals = view.subviews.compactMap { $0 as? CraftNativeModalView }
|
|
3342
|
+
guard !modals.isEmpty else { return }
|
|
3343
|
+
var insets = view.safeAreaInsets
|
|
3344
|
+
// The navigation bar floats over the screen without counting in its
|
|
3345
|
+
// safe area, and it is drawn above anything the screen adds.
|
|
3346
|
+
if let nav = navigationController, !nav.isNavigationBarHidden, nav.navigationBar.window != nil {
|
|
3347
|
+
let bar = nav.navigationBar.convert(nav.navigationBar.bounds, to: view)
|
|
3348
|
+
if bar.intersects(view.bounds) { insets.top = max(insets.top, bar.maxY) }
|
|
3349
|
+
}
|
|
3350
|
+
for modal in modals {
|
|
3351
|
+
if modal.frame != view.bounds { modal.frame = view.bounds }
|
|
3352
|
+
modal.contentInsets = insets
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
|
|
2989
3356
|
private static func safeJavaScriptString(_ value: JSValue?) -> String? {
|
|
2990
3357
|
guard let value, !value.isUndefined, !value.isNull, value.isString else { return nil }
|
|
2991
3358
|
return value.toString()
|
|
@@ -3004,7 +3371,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3004
3371
|
?? Self.safeJavaScriptString(exception)
|
|
3005
3372
|
?? "unknown"
|
|
3006
3373
|
let stack = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("stack")) ?? ""
|
|
3007
|
-
|
|
3374
|
+
let screen = self?.screenName(in: context) ?? "screen"
|
|
3375
|
+
CraftNativeConsole.write("error", category: screen, message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
|
|
3376
|
+
// To the app's crash reporter too (bughq, when configured): an
|
|
3377
|
+
// uncaught error in a shipped screen is otherwise only in a log
|
|
3378
|
+
// nobody reads on someone else's phone.
|
|
3379
|
+
CraftCrashReporter.shared.captureJavaScript(
|
|
3380
|
+
name: Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name")),
|
|
3381
|
+
message: message,
|
|
3382
|
+
stack: stack,
|
|
3383
|
+
route: screen,
|
|
3384
|
+
isAppScript: self?.isAppScript ?? false
|
|
3385
|
+
)
|
|
3008
3386
|
self?.hybridFailed(message)
|
|
3009
3387
|
}
|
|
3010
3388
|
let postMessage: @convention(block) (String) -> Void = { [weak self] json in
|
|
@@ -4175,6 +4553,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4175
4553
|
guard let target = drawnTarget(id) else { return false }
|
|
4176
4554
|
guard applied.insert(target).inserted else { continue }
|
|
4177
4555
|
guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
|
|
4556
|
+
// A node that needs a new view (a TextInput turned multiline) is
|
|
4557
|
+
// its parent's to swap in: a targeted update only redraws in
|
|
4558
|
+
// place, and the new view was made and never attached.
|
|
4559
|
+
guard canReuseView(document, previous: previous) else { return false }
|
|
4178
4560
|
let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
|
|
4179
4561
|
// A style patch is applied in place, layout included: the parent
|
|
4180
4562
|
// reads the node's new layout style on its next pass.
|
|
@@ -4232,6 +4614,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4232
4614
|
var rootStyle = next.style
|
|
4233
4615
|
if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }
|
|
4234
4616
|
rootStack.setLayoutStyle(rootStyle, for: next.view)
|
|
4617
|
+
rootStack.setViewportChild(next.view, fillsHeight: rootStyle["height"] == nil)
|
|
4235
4618
|
rootStack.invalidateMeasurements()
|
|
4236
4619
|
renderedRoot = next
|
|
4237
4620
|
rootIsSafeAreaView = next.type == "SafeAreaView"
|
|
@@ -4246,6 +4629,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4246
4629
|
if let root = renderedRoot { detach(root, from: rootStack) }
|
|
4247
4630
|
renderedRoot = nil
|
|
4248
4631
|
rootIsSafeAreaView = false
|
|
4632
|
+
rootStack.setViewportChild(nil, fillsHeight: false)
|
|
4249
4633
|
rootTopToSafeArea?.isActive = true
|
|
4250
4634
|
rootTopToView?.isActive = false
|
|
4251
4635
|
rootBottomToSafeArea?.isActive = true
|
|
@@ -4268,6 +4652,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4268
4652
|
return nil
|
|
4269
4653
|
}
|
|
4270
4654
|
|
|
4655
|
+
/// Whether `previous`'s view can draw `node`: the same type, and for a
|
|
4656
|
+
/// TextInput the same kind of field (multiline is a UITextView).
|
|
4657
|
+
private func canReuseView(_ node: [String: Any], previous: RenderedNode) -> Bool {
|
|
4658
|
+
let type = canonicalType(node["type"] as? String ?? "View")
|
|
4659
|
+
guard previous.type == type else { return false }
|
|
4660
|
+
guard type == "TextInput" else { return true }
|
|
4661
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
4662
|
+
return (previous.view is UITextView) == (props["multiline"] as? Bool == true)
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4271
4665
|
private func restoreFocus(_ focused: UIView?) {
|
|
4272
4666
|
guard let focused, focused.window != nil, !focused.isFirstResponder else { return }
|
|
4273
4667
|
_ = focused.becomeFirstResponder()
|
|
@@ -4280,9 +4674,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4280
4674
|
let events = node["events"] as? [String: String] ?? [:]
|
|
4281
4675
|
let children = node["children"] as? [Any] ?? []
|
|
4282
4676
|
let current: RenderedNode
|
|
4283
|
-
let
|
|
4284
|
-
let canReuseInput = type != "TextInput" || ((previous?.view is UITextView) == wantsMultilineTextView)
|
|
4285
|
-
if let previous = previous, previous.identity == identity, previous.type == type, canReuseInput {
|
|
4677
|
+
if let previous = previous, previous.identity == identity, canReuseView(node, previous: previous) {
|
|
4286
4678
|
current = previous
|
|
4287
4679
|
} else {
|
|
4288
4680
|
current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
|
|
@@ -4500,6 +4892,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4500
4892
|
let modalID = ObjectIdentifier(modal)
|
|
4501
4893
|
let wasVisible = modalVisibility[modalID]
|
|
4502
4894
|
modalVisibility[modalID] = visible
|
|
4895
|
+
if visible, wasVisible != true { positionModals() }
|
|
4503
4896
|
modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
|
|
4504
4897
|
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
4505
4898
|
{ [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
@@ -4537,10 +4930,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4537
4930
|
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
|
|
4538
4931
|
case "Slider":
|
|
4539
4932
|
let slider = result as! UISlider
|
|
4540
|
-
|
|
4541
|
-
slider.
|
|
4542
|
-
let
|
|
4543
|
-
|
|
4933
|
+
let minimum = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
|
|
4934
|
+
slider.minimumValue = minimum.isFinite ? minimum : 0
|
|
4935
|
+
let maximum = (props["maximumValue"] as? NSNumber)?.floatValue ?? 1
|
|
4936
|
+
slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, maximum.isFinite ? maximum : 1)
|
|
4937
|
+
let authoredValue = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
|
|
4938
|
+
let value = authoredValue.isFinite ? authoredValue : slider.minimumValue
|
|
4939
|
+
if let step = (props["step"] as? NSNumber)?.floatValue, step.isFinite, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
|
|
4544
4940
|
else { sliderSteps.removeValue(forKey: ObjectIdentifier(slider)) }
|
|
4545
4941
|
slider.value = snappedSliderValue(value, for: slider)
|
|
4546
4942
|
slider.minimumTrackTintColor = color(props["minimumTrackTintColor"])
|
|
@@ -4555,7 +4951,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4555
4951
|
indicator.style = (requestedSize as? String) == "large" ? .large : .medium
|
|
4556
4952
|
let scale: CGFloat
|
|
4557
4953
|
if let numericSize = requestedSize as? NSNumber {
|
|
4558
|
-
|
|
4954
|
+
let value = CGFloat(numericSize.doubleValue / 20.0)
|
|
4955
|
+
scale = value.isFinite ? max(0.5, value) : 1
|
|
4559
4956
|
} else if (requestedSize as? String) == "small" {
|
|
4560
4957
|
scale = 0.75
|
|
4561
4958
|
} else {
|
|
@@ -4811,6 +5208,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4811
5208
|
case "checkbox": return "Checkbox"
|
|
4812
5209
|
case "textinput", "input": return "TextInput"
|
|
4813
5210
|
case "picker", "select": return "Picker"
|
|
5211
|
+
case "datepicker", "date-picker", "date": return "DatePicker"
|
|
4814
5212
|
case "modal": return "Modal"
|
|
4815
5213
|
case "switch": return "Switch"
|
|
4816
5214
|
case "slider": return "Slider"
|
|
@@ -4898,14 +5296,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4898
5296
|
}
|
|
4899
5297
|
for child in next where child.type == "Modal" {
|
|
4900
5298
|
guard let modal = child.view as? CraftNativeModalView else { continue }
|
|
4901
|
-
|
|
5299
|
+
// A modal covers the screen, wherever it is declared. Laid over
|
|
5300
|
+
// its parent instead, one declared in a scroll view covered the
|
|
5301
|
+
// whole content: its controls sat wherever the content had
|
|
5302
|
+
// scrolled to, under the bar or off screen, out of reach.
|
|
5303
|
+
if modal.superview !== view {
|
|
4902
5304
|
modal.removeFromSuperview()
|
|
4903
|
-
|
|
5305
|
+
view.addSubview(modal)
|
|
4904
5306
|
}
|
|
4905
|
-
modal.frame = stack.bounds
|
|
4906
5307
|
modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
4907
|
-
|
|
5308
|
+
view.bringSubviewToFront(modal)
|
|
4908
5309
|
}
|
|
5310
|
+
positionModals()
|
|
4909
5311
|
parent.children = next
|
|
4910
5312
|
addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
|
|
4911
5313
|
}
|
|
@@ -4950,14 +5352,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4950
5352
|
{ [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
|
|
4951
5353
|
}
|
|
4952
5354
|
|
|
5355
|
+
let threshold = max(
|
|
5356
|
+
0,
|
|
5357
|
+
Double(number(props["onEndReachedThreshold"]) ?? number(props["threshold"]) ?? 0.1)
|
|
5358
|
+
)
|
|
4953
5359
|
list.apply(
|
|
4954
5360
|
nodes: children,
|
|
4955
5361
|
horizontal: props["horizontal"] as? Bool == true,
|
|
4956
5362
|
columns: (props["numColumns"] as? NSNumber)?.intValue ?? 1,
|
|
4957
5363
|
inverted: props["inverted"] as? Bool == true,
|
|
4958
|
-
endReachedThreshold:
|
|
4959
|
-
?? (props["threshold"] as? NSNumber)?.doubleValue
|
|
4960
|
-
?? 0.1,
|
|
5364
|
+
endReachedThreshold: threshold,
|
|
4961
5365
|
renderItem: { [weak self] node, identity, _ in
|
|
4962
5366
|
guard let self = self else { return UIView() }
|
|
4963
5367
|
let previous = self.flatListRows[listKey]?[identity]
|
|
@@ -5023,7 +5427,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5023
5427
|
}
|
|
5024
5428
|
if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
|
|
5025
5429
|
let id = ObjectIdentifier(node.view)
|
|
5026
|
-
if let modal = node.view as? CraftNativeModalView {
|
|
5430
|
+
if let modal = node.view as? CraftNativeModalView {
|
|
5431
|
+
modal.resetForTeardown()
|
|
5432
|
+
// It sits on the screen, not in its parent: leaving with the
|
|
5433
|
+
// parent's subtree does not take it away.
|
|
5434
|
+
modal.removeFromSuperview()
|
|
5435
|
+
}
|
|
5027
5436
|
handlers.removeValue(forKey: id)
|
|
5028
5437
|
linkTargets.removeValue(forKey: id)
|
|
5029
5438
|
pressInHandlers.removeValue(forKey: id)
|
|
@@ -5098,9 +5507,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5098
5507
|
emitLayoutEvents(for: renderedRoot)
|
|
5099
5508
|
}
|
|
5100
5509
|
|
|
5510
|
+
private var layoutEventsScheduled = false
|
|
5511
|
+
|
|
5512
|
+
/// A view inside the screen moved: report onLayout once the pass that
|
|
5513
|
+
/// moved it is over, however deep it was.
|
|
5514
|
+
fileprivate func scheduleLayoutEvents() {
|
|
5515
|
+
guard !layoutEventsScheduled, !layoutHandlers.isEmpty else { return }
|
|
5516
|
+
layoutEventsScheduled = true
|
|
5517
|
+
DispatchQueue.main.async { [weak self] in
|
|
5518
|
+
guard let self else { return }
|
|
5519
|
+
self.layoutEventsScheduled = false
|
|
5520
|
+
self.emitLayoutEvents()
|
|
5521
|
+
}
|
|
5522
|
+
}
|
|
5523
|
+
|
|
5101
5524
|
private func emitLayoutEvents(for node: RenderedNode) {
|
|
5102
5525
|
let id = ObjectIdentifier(node.view)
|
|
5103
|
-
if let handler = layoutHandlers[id], node.view
|
|
5526
|
+
if let handler = layoutHandlers[id], isEffectivelyVisible(node.view) {
|
|
5104
5527
|
let frame = node.view.frame
|
|
5105
5528
|
if lastLayoutFrames[id] != frame {
|
|
5106
5529
|
lastLayoutFrames[id] = frame
|
|
@@ -5121,6 +5544,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5121
5544
|
}
|
|
5122
5545
|
}
|
|
5123
5546
|
|
|
5547
|
+
private func isEffectivelyVisible(_ view: UIView) -> Bool {
|
|
5548
|
+
guard view.window != nil, !view.isHidden else { return false }
|
|
5549
|
+
var ancestor = view.superview
|
|
5550
|
+
while let current = ancestor {
|
|
5551
|
+
if current.isHidden { return false }
|
|
5552
|
+
ancestor = current.superview
|
|
5553
|
+
}
|
|
5554
|
+
return true
|
|
5555
|
+
}
|
|
5556
|
+
|
|
5124
5557
|
private func updateAuxiliaryHandler(
|
|
5125
5558
|
_ handler: String?,
|
|
5126
5559
|
in handlers: inout [ObjectIdentifier: String],
|
|
@@ -5150,7 +5583,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5150
5583
|
}
|
|
5151
5584
|
// A container keeps receiving touches without a handler of its own,
|
|
5152
5585
|
// or every control inside it (and the screen root) would be dead.
|
|
5153
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5586
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5154
5587
|
}
|
|
5155
5588
|
|
|
5156
5589
|
private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
|
|
@@ -5159,7 +5592,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5159
5592
|
if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
|
|
5160
5593
|
guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
|
|
5161
5594
|
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
|
|
5162
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5595
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
5163
5596
|
return
|
|
5164
5597
|
}
|
|
5165
5598
|
if pressFeedbackRecognizers[id] == nil {
|
|
@@ -5167,17 +5600,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5167
5600
|
recognizer.minimumPressDuration = 0
|
|
5168
5601
|
recognizer.allowableMovement = 10
|
|
5169
5602
|
recognizer.cancelsTouchesInView = false
|
|
5603
|
+
recognizer.delegate = CraftPressFeedbackDelegate.shared
|
|
5170
5604
|
pressFeedbackRecognizers[id] = recognizer
|
|
5171
5605
|
view.addGestureRecognizer(recognizer)
|
|
5172
5606
|
}
|
|
5173
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
|
|
5607
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = true }
|
|
5174
5608
|
}
|
|
5175
5609
|
|
|
5176
5610
|
private func updateLongPressHandler(_ handler: String?, for view: UIView) {
|
|
5177
5611
|
let id = ObjectIdentifier(view)
|
|
5178
5612
|
if let handler, !handler.isEmpty {
|
|
5179
5613
|
longPressHandlers[id] = handler
|
|
5180
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
|
|
5614
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = true }
|
|
5181
5615
|
if longPressRecognizers[id] == nil {
|
|
5182
5616
|
let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewLongPressed(_:)))
|
|
5183
5617
|
longPressRecognizers[id] = recognizer
|
|
@@ -5188,7 +5622,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5188
5622
|
if let recognizer = longPressRecognizers.removeValue(forKey: id) {
|
|
5189
5623
|
view.removeGestureRecognizer(recognizer)
|
|
5190
5624
|
}
|
|
5191
|
-
if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
|
|
5625
|
+
if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
|
|
5192
5626
|
}
|
|
5193
5627
|
}
|
|
5194
5628
|
|
|
@@ -5327,10 +5761,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5327
5761
|
stack.alignItems = style["alignItems"] as? String ?? "stretch"
|
|
5328
5762
|
stack.alignContent = style["alignContent"] as? String ?? "flex-start"
|
|
5329
5763
|
stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
|
|
5330
|
-
let gap = number(style["gap"]) ?? 0
|
|
5764
|
+
let gap = max(0, number(style["gap"]) ?? 0)
|
|
5331
5765
|
stack.gap = gap
|
|
5332
|
-
stack.rowGap = number(style["rowGap"]) ?? gap
|
|
5333
|
-
stack.columnGap = number(style["columnGap"]) ?? gap
|
|
5766
|
+
stack.rowGap = max(0, number(style["rowGap"]) ?? gap)
|
|
5767
|
+
stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
|
|
5334
5768
|
stack.grid = style["display"] as? String == "grid"
|
|
5335
5769
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
5336
5770
|
stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
|
|
@@ -5405,14 +5839,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5405
5839
|
}
|
|
5406
5840
|
|
|
5407
5841
|
private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5842
|
+
func nonNegative(_ value: Any?) -> CGFloat? {
|
|
5843
|
+
guard let value = number(value), value >= 0 else { return nil }
|
|
5844
|
+
return value
|
|
5845
|
+
}
|
|
5846
|
+
let padding = nonNegative(style["padding"]) ?? 0
|
|
5847
|
+
let horizontal = nonNegative(style["paddingHorizontal"]) ?? padding
|
|
5848
|
+
let vertical = nonNegative(style["paddingVertical"]) ?? padding
|
|
5411
5849
|
return UIEdgeInsets(
|
|
5412
|
-
top:
|
|
5413
|
-
left:
|
|
5414
|
-
bottom:
|
|
5415
|
-
right:
|
|
5850
|
+
top: nonNegative(style["paddingTop"]) ?? vertical,
|
|
5851
|
+
left: nonNegative(style["paddingLeft"]) ?? horizontal,
|
|
5852
|
+
bottom: nonNegative(style["paddingBottom"]) ?? vertical,
|
|
5853
|
+
right: nonNegative(style["paddingRight"]) ?? horizontal
|
|
5416
5854
|
)
|
|
5417
5855
|
}
|
|
5418
5856
|
|
|
@@ -5672,6 +6110,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5672
6110
|
imageSources[id] = uri
|
|
5673
6111
|
imageErrors.removeValue(forKey: id)
|
|
5674
6112
|
view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
|
|
6113
|
+
imageDidChange(view)
|
|
5675
6114
|
view.accessibilityValue = nil
|
|
5676
6115
|
emitImageEvent(view, handler: imageLoadStartHandlers[id])
|
|
5677
6116
|
|
|
@@ -5683,6 +6122,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5683
6122
|
return
|
|
5684
6123
|
}
|
|
5685
6124
|
view.image = imageForDisplay(image, view: view)
|
|
6125
|
+
imageDidChange(view)
|
|
5686
6126
|
emitImageEvent(view, handler: imageLoadHandlers[id])
|
|
5687
6127
|
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
5688
6128
|
return
|
|
@@ -5698,6 +6138,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5698
6138
|
return
|
|
5699
6139
|
}
|
|
5700
6140
|
view.image = self.imageForDisplay(image, view: view)
|
|
6141
|
+
self.imageDidChange(view)
|
|
5701
6142
|
self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
|
|
5702
6143
|
self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
|
|
5703
6144
|
}
|
|
@@ -5712,6 +6153,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5712
6153
|
return
|
|
5713
6154
|
}
|
|
5714
6155
|
view.image = imageForDisplay(image, view: view)
|
|
6156
|
+
imageDidChange(view)
|
|
5715
6157
|
emitImageEvent(view, handler: imageLoadHandlers[id])
|
|
5716
6158
|
emitImageEvent(view, handler: imageLoadEndHandlers[id])
|
|
5717
6159
|
return
|
|
@@ -5736,6 +6178,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5736
6178
|
private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
|
|
5737
6179
|
imageErrors[ObjectIdentifier(view)] = message
|
|
5738
6180
|
view.image = nil
|
|
6181
|
+
imageDidChange(view)
|
|
5739
6182
|
view.accessibilityValue = message
|
|
5740
6183
|
let id = ObjectIdentifier(view)
|
|
5741
6184
|
emitImageEvent(view, handler: imageErrorHandlers[id], nativeEvent: ["error": ["message": message]])
|
|
@@ -5743,6 +6186,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5743
6186
|
NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
|
|
5744
6187
|
}
|
|
5745
6188
|
|
|
6189
|
+
private func imageDidChange(_ view: UIImageView) {
|
|
6190
|
+
view.invalidateIntrinsicContentSize()
|
|
6191
|
+
CraftNativeFlexLayout.invalidateAncestors(of: view)
|
|
6192
|
+
}
|
|
6193
|
+
|
|
5746
6194
|
private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
|
|
5747
6195
|
image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
|
|
5748
6196
|
}
|
|
@@ -5766,7 +6214,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5766
6214
|
view.accessibilityValue = nil
|
|
5767
6215
|
}
|
|
5768
6216
|
let role = props["accessibilityRole"] as? String
|
|
5769
|
-
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
6217
|
+
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
5770
6218
|
var traits: UIAccessibilityTraits = []
|
|
5771
6219
|
switch role ?? type.lowercased() {
|
|
5772
6220
|
case "button", "checkbox": traits.insert(.button)
|
|
@@ -5789,6 +6237,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5789
6237
|
if state["checked"] as? Bool == true { traits.insert(.selected) }
|
|
5790
6238
|
}
|
|
5791
6239
|
if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
|
|
6240
|
+
// A disabled Pressable or touchable takes no touches; it says so too.
|
|
6241
|
+
if props["disabled"] as? Bool == true { traits.insert(.notEnabled) }
|
|
5792
6242
|
if type == "Checkbox", let checkbox = view as? CraftNativeCheckboxButton {
|
|
5793
6243
|
if checkbox.isSelected { traits.insert(.selected) }
|
|
5794
6244
|
view.accessibilityValue = checkbox.indeterminate ? "mixed" : checkbox.isSelected ? "checked" : "unchecked"
|
|
@@ -5939,13 +6389,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5939
6389
|
let value = snappedSliderValue(sender.value, for: sender)
|
|
5940
6390
|
if sender.value != value { sender.value = value }
|
|
5941
6391
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
5942
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value)]])
|
|
6392
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value, step: sliderSteps[ObjectIdentifier(sender)])]])
|
|
5943
6393
|
}
|
|
5944
6394
|
|
|
5945
|
-
/// A slider's value as the script reads it
|
|
5946
|
-
///
|
|
5947
|
-
|
|
5948
|
-
|
|
6395
|
+
/// A slider's value as the script reads it. UISlider keeps a Float, and a
|
|
6396
|
+
/// step of 0.1 counted up in Floats lands beside the decimal (0.8 read as
|
|
6397
|
+
/// 0.800000011920929, nine steps as 0.90000004), so the value is the
|
|
6398
|
+
/// shortest decimal for that Float, rounded to as many places as the step
|
|
6399
|
+
/// has.
|
|
6400
|
+
static func sliderEventValue(_ value: Float, step: Float? = nil) -> Double {
|
|
6401
|
+
let decimal = Double(value.description) ?? Double(value)
|
|
6402
|
+
guard let step, step > 0, step.isFinite else { return decimal }
|
|
6403
|
+
// "0.1" is one place, "0.25" two, "1.0" none; a step as small as
|
|
6404
|
+
// "1e-07" is held to six.
|
|
6405
|
+
let fraction = step.description.split(separator: ".").dropFirst().first
|
|
6406
|
+
let places = step.description.contains("e") ? 6 : min(6, fraction.map { $0 == "0" ? 0 : $0.count } ?? 0)
|
|
6407
|
+
let scale = pow(10, Double(places))
|
|
6408
|
+
return (decimal * scale).rounded() / scale
|
|
5949
6409
|
}
|
|
5950
6410
|
|
|
5951
6411
|
/// `@change` on a DatePicker: the value as `<input type="date|time|
|
|
@@ -6000,7 +6460,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6000
6460
|
/// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
|
|
6001
6461
|
static func countdownDuration(_ value: Any?) -> TimeInterval? {
|
|
6002
6462
|
let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
|
|
6003
|
-
if let number = value as? NSNumber, !(value is Bool) {
|
|
6463
|
+
if let number = value as? NSNumber, !(value is Bool) {
|
|
6464
|
+
let seconds = number.doubleValue
|
|
6465
|
+
guard seconds.isFinite else { return nil }
|
|
6466
|
+
return min(maximum, max(0, seconds))
|
|
6467
|
+
}
|
|
6004
6468
|
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
6005
6469
|
if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
|
|
6006
6470
|
let parts = text.split(separator: ":")
|
|
@@ -6008,7 +6472,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6008
6472
|
let hours = Double(parts[0]),
|
|
6009
6473
|
let minutes = Double(parts[1]) else { return nil }
|
|
6010
6474
|
let seconds = parts.count == 3 ? (Double(parts[2]) ?? -1) : 0
|
|
6011
|
-
guard hours
|
|
6475
|
+
guard hours.isFinite, minutes.isFinite, seconds.isFinite,
|
|
6476
|
+
hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
|
|
6012
6477
|
return min(maximum, hours * 3600 + minutes * 60 + seconds)
|
|
6013
6478
|
}
|
|
6014
6479
|
|
|
@@ -6016,7 +6481,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6016
6481
|
/// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
|
|
6017
6482
|
/// milliseconds since 1970. Nil for anything else.
|
|
6018
6483
|
static func pickerDate(_ value: Any?) -> Date? {
|
|
6019
|
-
if let number = value as? NSNumber, !(value is Bool) {
|
|
6484
|
+
if let number = value as? NSNumber, !(value is Bool) {
|
|
6485
|
+
let milliseconds = number.doubleValue
|
|
6486
|
+
guard milliseconds.isFinite else { return nil }
|
|
6487
|
+
return Date(timeIntervalSince1970: milliseconds / 1000)
|
|
6488
|
+
}
|
|
6020
6489
|
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
6021
6490
|
let local = DateFormatter()
|
|
6022
6491
|
local.locale = Locale(identifier: "en_US_POSIX")
|
|
@@ -6042,7 +6511,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6042
6511
|
|
|
6043
6512
|
@objc private func sliderFinished(_ sender: UISlider) {
|
|
6044
6513
|
guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
|
|
6045
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender))]])
|
|
6514
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender), step: sliderSteps[ObjectIdentifier(sender)])]])
|
|
6046
6515
|
}
|
|
6047
6516
|
|
|
6048
6517
|
func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
|
|
@@ -6087,6 +6556,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6087
6556
|
}
|
|
6088
6557
|
|
|
6089
6558
|
@objc private func textSubmitted(_ sender: UITextField) {
|
|
6559
|
+
// Return put the keyboard away.
|
|
6560
|
+
forgetFocusedInput(sender)
|
|
6090
6561
|
guard let handler = submitHandlers[ObjectIdentifier(sender)] else { return }
|
|
6091
6562
|
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
6092
6563
|
}
|
|
@@ -6174,7 +6645,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6174
6645
|
}
|
|
6175
6646
|
|
|
6176
6647
|
private func number(_ value: Any?) -> CGFloat? {
|
|
6177
|
-
|
|
6648
|
+
guard let number = value as? NSNumber else { return nil }
|
|
6649
|
+
let value = CGFloat(truncating: number)
|
|
6650
|
+
return value.isFinite ? value : nil
|
|
6178
6651
|
}
|
|
6179
6652
|
|
|
6180
6653
|
private func keyboardType(_ value: Any?) -> UIKeyboardType {
|