craft-native 0.0.156 → 0.0.160
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/templates/CraftNative.kt.template +24 -1
- package/dist/android/templates/CraftNativeCapabilities.kt.template +309 -4
- package/dist/android/templates/MainActivityNative.kt.template +606 -48
- package/dist/api/mobile.d.ts +3 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +23 -10
- package/dist/ios/src/testing.d.ts +7 -1
- package/dist/ios/templates/CraftHealth.swift +11 -0
- package/dist/ios/templates/CraftHybrid.swift +19 -0
- package/dist/ios/templates/CraftNativeScreen.swift +411 -46
- package/dist/ios/templates/testing/DriverTests.swift.template +161 -145
- package/package.json +1 -1
|
@@ -182,11 +182,18 @@ private enum CraftNativeGridBridge {
|
|
|
182
182
|
private typealias Function = @convention(c) (
|
|
183
183
|
Float, Float, UInt32, Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
|
|
184
184
|
) -> Int32
|
|
185
|
+
private typealias AutoRowsFunction = @convention(c) (
|
|
186
|
+
Float, Float, UInt32, Float, Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
|
|
187
|
+
) -> Int32
|
|
185
188
|
|
|
186
189
|
private static let function: Function? = {
|
|
187
190
|
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid") else { return nil }
|
|
188
191
|
return unsafeBitCast(symbol, to: Function.self)
|
|
189
192
|
}()
|
|
193
|
+
private static let autoRowsFunction: AutoRowsFunction? = {
|
|
194
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_auto_rows") else { return nil }
|
|
195
|
+
return unsafeBitCast(symbol, to: AutoRowsFunction.self)
|
|
196
|
+
}()
|
|
190
197
|
|
|
191
198
|
static func frames(
|
|
192
199
|
availableWidth: CGFloat,
|
|
@@ -194,9 +201,12 @@ private enum CraftNativeGridBridge {
|
|
|
194
201
|
columns: Int,
|
|
195
202
|
columnGap: CGFloat,
|
|
196
203
|
rowGap: CGFloat,
|
|
197
|
-
items: [(intrinsicWidth: CGFloat, intrinsicHeight: CGFloat, explicitWidth: CGFloat?, explicitHeight: CGFloat?, minWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, maxHeight: CGFloat?, marginLeft: CGFloat, marginRight: CGFloat, marginTop: CGFloat, marginBottom: CGFloat, stretch: Bool, alignment: UInt32)]
|
|
204
|
+
items: [(intrinsicWidth: CGFloat, intrinsicHeight: CGFloat, explicitWidth: CGFloat?, explicitHeight: CGFloat?, minWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, maxHeight: CGFloat?, marginLeft: CGFloat, marginRight: CGFloat, marginTop: CGFloat, marginBottom: CGFloat, stretch: Bool, alignment: UInt32)],
|
|
205
|
+
autoRowHeight: CGFloat? = nil
|
|
198
206
|
) -> [(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat)]? {
|
|
199
|
-
guard
|
|
207
|
+
guard !items.isEmpty else { return nil }
|
|
208
|
+
if autoRowHeight != nil, autoRowsFunction == nil { return nil }
|
|
209
|
+
if autoRowHeight == nil, function == nil { return nil }
|
|
200
210
|
var input = items.map { item in
|
|
201
211
|
Item(
|
|
202
212
|
intrinsicWidth: Float(item.intrinsicWidth), intrinsicHeight: Float(item.intrinsicHeight),
|
|
@@ -211,7 +221,11 @@ private enum CraftNativeGridBridge {
|
|
|
211
221
|
var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: input.count)
|
|
212
222
|
let status = input.withUnsafeBufferPointer { inputBuffer in
|
|
213
223
|
output.withUnsafeMutableBufferPointer { outputBuffer in
|
|
214
|
-
|
|
224
|
+
if let autoRowHeight, let autoRowsFunction {
|
|
225
|
+
return autoRowsFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), Float(autoRowHeight), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
226
|
+
}
|
|
227
|
+
guard let function else { return Int32(-1) }
|
|
228
|
+
return function(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
215
229
|
}
|
|
216
230
|
}
|
|
217
231
|
guard status == 0, output.allSatisfy({ frame in
|
|
@@ -242,12 +256,20 @@ private final class CraftNativeVideoView: UIView {
|
|
|
242
256
|
host.addChild(playerController)
|
|
243
257
|
addSubview(playerController.view)
|
|
244
258
|
playerController.didMove(toParent: host)
|
|
259
|
+
// Expose the host view as the native Video node. The player controller
|
|
260
|
+
// owns an accessibility subtree of its own, which otherwise masks the
|
|
261
|
+
// host's testID and makes the component look like a WebView-backed view
|
|
262
|
+
// to UI tests and assistive technology.
|
|
263
|
+
playerController.view.isAccessibilityElement = false
|
|
264
|
+
playerController.view.accessibilityElementsHidden = true
|
|
245
265
|
}
|
|
246
266
|
|
|
247
267
|
required init?(coder: NSCoder) {
|
|
248
268
|
super.init(coder: coder)
|
|
249
269
|
clipsToBounds = true
|
|
250
270
|
addSubview(playerController.view)
|
|
271
|
+
playerController.view.isAccessibilityElement = false
|
|
272
|
+
playerController.view.accessibilityElementsHidden = true
|
|
251
273
|
}
|
|
252
274
|
|
|
253
275
|
override func layoutSubviews() {
|
|
@@ -381,7 +403,7 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
|
381
403
|
/// A length as a style gives it: points, or a percentage of the parent's
|
|
382
404
|
/// content box. A percentage against a size that is not known yet (a column
|
|
383
405
|
/// whose height comes from its content) resolves to nothing, as on the web.
|
|
384
|
-
private enum CraftNativeLength {
|
|
406
|
+
private enum CraftNativeLength: Equatable {
|
|
385
407
|
case points(CGFloat)
|
|
386
408
|
case percent(CGFloat)
|
|
387
409
|
|
|
@@ -435,7 +457,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
435
457
|
let marginLeft: CGFloat
|
|
436
458
|
let display: String
|
|
437
459
|
let gridColumns: Int
|
|
438
|
-
let gridAutoRows:
|
|
460
|
+
let gridAutoRows: CraftNativeLength?
|
|
439
461
|
|
|
440
462
|
static let empty = CraftNativeLayoutStyle([:])
|
|
441
463
|
|
|
@@ -470,7 +492,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
470
492
|
marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
|
|
471
493
|
display = raw["display"] as? String ?? "flex"
|
|
472
494
|
gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
|
|
473
|
-
gridAutoRows =
|
|
495
|
+
gridAutoRows = CraftNativeLength(raw["gridAutoRows"])
|
|
474
496
|
}
|
|
475
497
|
}
|
|
476
498
|
|
|
@@ -531,11 +553,15 @@ enum CraftNativeFlexLayout {
|
|
|
531
553
|
// Text takes the width it needs up to the room there is, and
|
|
532
554
|
// wraps or truncates inside it rather than widening its parent.
|
|
533
555
|
let limit = width ?? maxWidth
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
556
|
+
// Ask for max-content before asking for the wrapped height. A
|
|
557
|
+
// constrained sizeThatFits call can return min-content (the
|
|
558
|
+
// longest word), which collapses shrink-to-fit bubbles and pills.
|
|
559
|
+
let maxContent = label.sizeThatFits(CGSize(width: unbounded, height: unbounded))
|
|
560
|
+
let resolvedWidth = width ?? min(ceil(maxContent.width), limit)
|
|
561
|
+
// Narrower than max-content (it met the limit): the wrapped height.
|
|
562
|
+
let resolvedHeight = height ?? ceil(resolvedWidth < ceil(maxContent.width)
|
|
537
563
|
? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
|
|
538
|
-
:
|
|
564
|
+
: maxContent.height)
|
|
539
565
|
return CGSize(width: resolvedWidth, height: resolvedHeight)
|
|
540
566
|
case let image as UIImageView where image.image == nil:
|
|
541
567
|
return CGSize(width: width ?? 0, height: height ?? 0)
|
|
@@ -583,7 +609,7 @@ enum CraftNativeFlexLayout {
|
|
|
583
609
|
/// grid. It is a UIStackView in name only, so existing callers and tests keep
|
|
584
610
|
/// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
|
|
585
611
|
/// constraints are never created, and every frame comes from `layout`.
|
|
586
|
-
private
|
|
612
|
+
private class CraftNativeFlowView: UIStackView {
|
|
587
613
|
var pressActiveOpacity: CGFloat?
|
|
588
614
|
var pressBaseOpacity: CGFloat = 1
|
|
589
615
|
var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
|
|
@@ -595,7 +621,7 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
595
621
|
var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
|
|
596
622
|
var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
|
|
597
623
|
var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
|
|
598
|
-
var gridAutoRows:
|
|
624
|
+
var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
|
|
599
625
|
var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
|
|
600
626
|
var padding = UIEdgeInsets.zero {
|
|
601
627
|
didSet {
|
|
@@ -628,6 +654,36 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
628
654
|
|
|
629
655
|
required init(coder: NSCoder) { super.init(coder: coder) }
|
|
630
656
|
|
|
657
|
+
/// A view made one accessibility element (a row with `accessibilityRole`)
|
|
658
|
+
/// hides its children from VoiceOver, so with no label of its own it reads
|
|
659
|
+
/// what they say, in order, as React Native's accessible View does. A
|
|
660
|
+
/// label given in props always wins.
|
|
661
|
+
override var accessibilityLabel: String? {
|
|
662
|
+
get {
|
|
663
|
+
if let own = super.accessibilityLabel, !own.isEmpty { return own }
|
|
664
|
+
guard isAccessibilityElement else { return nil }
|
|
665
|
+
// An avatar labelled with the name beside it is said once.
|
|
666
|
+
var spoken: [String] = []
|
|
667
|
+
for part in CraftNativeFlowView.spokenText(in: self) where !spoken.contains(part) { spoken.append(part) }
|
|
668
|
+
return spoken.isEmpty ? nil : spoken.joined(separator: ", ")
|
|
669
|
+
}
|
|
670
|
+
set { super.accessibilityLabel = newValue }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
private static func spokenText(in view: UIView) -> [String] {
|
|
674
|
+
var parts: [String] = []
|
|
675
|
+
for child in view.subviews where !child.isHidden && child.alpha > 0.01 {
|
|
676
|
+
if child.isAccessibilityElement {
|
|
677
|
+
// A Text's label is its text (applyAccessibility leaves accessibilityLabel nil).
|
|
678
|
+
let said = child.accessibilityLabel ?? (child as? UILabel)?.text
|
|
679
|
+
if let label = said?.trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty { parts.append(label) }
|
|
680
|
+
} else {
|
|
681
|
+
parts.append(contentsOf: spokenText(in: child))
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return parts
|
|
685
|
+
}
|
|
686
|
+
|
|
631
687
|
private var alignmentValue: UIStackView.Alignment {
|
|
632
688
|
switch alignItems {
|
|
633
689
|
case "flex-start": return .leading
|
|
@@ -853,7 +909,55 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
853
909
|
return horizontal ? (size.width, size.height) : (size.height, size.width)
|
|
854
910
|
}
|
|
855
911
|
|
|
856
|
-
var items: [Item] =
|
|
912
|
+
var items: [Item] = []
|
|
913
|
+
|
|
914
|
+
func fallbackWidths(for line: Range<Int>, available: CGFloat, gap: CGFloat) -> [CGFloat] {
|
|
915
|
+
let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + CGFloat(max(0, line.count - 1)) * gap
|
|
916
|
+
let free = available - hypothetical
|
|
917
|
+
let growTotal = line.reduce(0) { $0 + items[$1].grow }
|
|
918
|
+
let shrinkTotal = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
|
|
919
|
+
let growing = free > 0 && growTotal > 0
|
|
920
|
+
let shrinking = free < 0 && shrinkTotal > 0
|
|
921
|
+
var widths = line.map { items[$0].basis }
|
|
922
|
+
guard growing || shrinking else { return widths }
|
|
923
|
+
|
|
924
|
+
var frozen = Set<Int>()
|
|
925
|
+
for _ in 0...line.count {
|
|
926
|
+
var fixed = CGFloat(max(0, line.count - 1)) * gap
|
|
927
|
+
var activeBasis: CGFloat = 0
|
|
928
|
+
var factor: CGFloat = 0
|
|
929
|
+
for (offset, index) in line.enumerated() {
|
|
930
|
+
let item = items[index]
|
|
931
|
+
fixed += item.mainMargins
|
|
932
|
+
if frozen.contains(offset) {
|
|
933
|
+
fixed += widths[offset]
|
|
934
|
+
} else {
|
|
935
|
+
activeBasis += item.basis
|
|
936
|
+
factor += growing ? item.grow : item.shrink * item.basis
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
guard factor > 0 else { break }
|
|
940
|
+
let remaining = available - fixed - activeBasis
|
|
941
|
+
var froze = false
|
|
942
|
+
for (offset, index) in line.enumerated() where !frozen.contains(offset) {
|
|
943
|
+
let item = items[index]
|
|
944
|
+
let share = growing
|
|
945
|
+
? remaining * item.grow / factor
|
|
946
|
+
: -remaining * item.shrink * item.basis / factor
|
|
947
|
+
let raw = growing ? item.basis + share : item.basis - share
|
|
948
|
+
let clamped = clamp(raw, item.minMain, item.maxMain)
|
|
949
|
+
widths[offset] = clamped
|
|
950
|
+
if abs(clamped - raw) > 0.0001 {
|
|
951
|
+
frozen.insert(offset)
|
|
952
|
+
froze = true
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if !froze { break }
|
|
956
|
+
}
|
|
957
|
+
return widths
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
items = children.map { view in
|
|
857
961
|
let style = view.craftLayoutStyle
|
|
858
962
|
let mainLeading = horizontal ? style.marginLeft : style.marginTop
|
|
859
963
|
let mainTrailing = horizontal ? style.marginRight : style.marginBottom
|
|
@@ -971,22 +1075,8 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
971
1075
|
if let sharedWidths {
|
|
972
1076
|
for (offset, index) in line.enumerated() { items[index].main = sharedWidths[offset].width }
|
|
973
1077
|
} else {
|
|
974
|
-
let
|
|
975
|
-
|
|
976
|
-
let total = line.reduce(0) { $0 + items[$1].grow }
|
|
977
|
-
for index in line {
|
|
978
|
-
let share = total > 0 ? free * items[index].grow / total : 0
|
|
979
|
-
items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
|
|
980
|
-
}
|
|
981
|
-
} else if free < 0 {
|
|
982
|
-
let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
|
|
983
|
-
for index in line {
|
|
984
|
-
let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
|
|
985
|
-
items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
|
|
986
|
-
}
|
|
987
|
-
} else {
|
|
988
|
-
for index in line { items[index].main = items[index].basis }
|
|
989
|
-
}
|
|
1078
|
+
let fallback = fallbackWidths(for: line, available: lineMain, gap: mainGap)
|
|
1079
|
+
for (offset, index) in line.enumerated() { items[index].main = fallback[offset] }
|
|
990
1080
|
}
|
|
991
1081
|
|
|
992
1082
|
var lineCross: CGFloat = 0
|
|
@@ -1162,11 +1252,13 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
1162
1252
|
)
|
|
1163
1253
|
sizes.append(size)
|
|
1164
1254
|
let row = index / columns
|
|
1165
|
-
while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
|
|
1255
|
+
while rowHeights.count <= row { rowHeights.append(gridAutoRows?.resolve(innerHeight) ?? 0) }
|
|
1166
1256
|
rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
|
|
1167
1257
|
}
|
|
1168
1258
|
let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
|
|
1169
1259
|
let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
1260
|
+
// Older runtimes return nil for the optional symbol; the local layout
|
|
1261
|
+
// below remains the compatibility path in that case.
|
|
1170
1262
|
let sharedFrames = CraftNativeGridBridge.frames(
|
|
1171
1263
|
availableWidth: innerWidth ?? width,
|
|
1172
1264
|
availableHeight: innerHeight ?? height,
|
|
@@ -1186,7 +1278,8 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
1186
1278
|
style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
|
|
1187
1279
|
align == "stretch", alignment
|
|
1188
1280
|
)
|
|
1189
|
-
}
|
|
1281
|
+
},
|
|
1282
|
+
autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 }
|
|
1190
1283
|
)
|
|
1191
1284
|
if place {
|
|
1192
1285
|
if let sharedFrames {
|
|
@@ -1220,6 +1313,95 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
1220
1313
|
}
|
|
1221
1314
|
}
|
|
1222
1315
|
|
|
1316
|
+
/// A flex container that responds to the controller's keyboard overlap.
|
|
1317
|
+
/// `padding` keeps the content visible without moving the field; `position`
|
|
1318
|
+
/// lifts the whole container; `height` reduces its viewport.
|
|
1319
|
+
private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
|
|
1320
|
+
private var authoredPadding = UIEdgeInsets.zero
|
|
1321
|
+
private var keyboardInset: CGFloat = 0
|
|
1322
|
+
private var keyboardVerticalOffset: CGFloat = 0
|
|
1323
|
+
private var keyboardBehavior = "padding"
|
|
1324
|
+
private var appliedHeightInset: CGFloat = 0
|
|
1325
|
+
private var adjustingHeight = false
|
|
1326
|
+
|
|
1327
|
+
func configureKeyboard(props: [String: Any]) {
|
|
1328
|
+
let requestedBehavior = (props["behavior"] as? String) ?? "padding"
|
|
1329
|
+
keyboardBehavior = ["height", "position", "padding"].contains(requestedBehavior) ? requestedBehavior : "padding"
|
|
1330
|
+
keyboardVerticalOffset = (props["keyboardVerticalOffset"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 0
|
|
1331
|
+
if keyboardBehavior != "height" { appliedHeightInset = 0 }
|
|
1332
|
+
authoredPadding = padding
|
|
1333
|
+
applyKeyboardAvoidance()
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
func setKeyboardInset(_ inset: CGFloat) {
|
|
1337
|
+
guard abs(keyboardInset - inset) > 0.5 else { return }
|
|
1338
|
+
keyboardInset = max(0, inset)
|
|
1339
|
+
applyKeyboardAvoidance()
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
private var effectiveInset: CGFloat { max(0, keyboardInset - keyboardVerticalOffset) }
|
|
1343
|
+
|
|
1344
|
+
private func applyKeyboardAvoidance() {
|
|
1345
|
+
let inset = effectiveInset
|
|
1346
|
+
switch keyboardBehavior {
|
|
1347
|
+
case "position":
|
|
1348
|
+
transform = CGAffineTransform(translationX: 0, y: -inset)
|
|
1349
|
+
padding = authoredPadding
|
|
1350
|
+
case "height":
|
|
1351
|
+
transform = .identity
|
|
1352
|
+
padding = authoredPadding
|
|
1353
|
+
default:
|
|
1354
|
+
transform = .identity
|
|
1355
|
+
padding = UIEdgeInsets(
|
|
1356
|
+
top: authoredPadding.top,
|
|
1357
|
+
left: authoredPadding.left,
|
|
1358
|
+
bottom: authoredPadding.bottom + inset,
|
|
1359
|
+
right: authoredPadding.right
|
|
1360
|
+
)
|
|
1361
|
+
}
|
|
1362
|
+
invalidateMeasurements()
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
override func layoutSubviews() {
|
|
1366
|
+
if keyboardBehavior == "height", !adjustingHeight {
|
|
1367
|
+
let inset = effectiveInset
|
|
1368
|
+
let target = max(0, bounds.height + appliedHeightInset - inset)
|
|
1369
|
+
if abs(target - bounds.height) > 0.5 {
|
|
1370
|
+
adjustingHeight = true
|
|
1371
|
+
appliedHeightInset = inset
|
|
1372
|
+
bounds.size.height = target
|
|
1373
|
+
super.layoutSubviews()
|
|
1374
|
+
adjustingHeight = false
|
|
1375
|
+
return
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
super.layoutSubviews()
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/// An image-backed flex container. The image stays behind ordinary native
|
|
1383
|
+
/// children so `ImageBackground` keeps the same layout and interaction
|
|
1384
|
+
/// semantics as a `View` while using the shared image loader.
|
|
1385
|
+
private final class CraftNativeImageBackgroundView: UIImageView {
|
|
1386
|
+
let contentStack = CraftNativeFlowView()
|
|
1387
|
+
|
|
1388
|
+
override init(frame: CGRect) {
|
|
1389
|
+
super.init(frame: frame)
|
|
1390
|
+
isUserInteractionEnabled = true
|
|
1391
|
+
contentMode = .scaleAspectFill
|
|
1392
|
+
clipsToBounds = true
|
|
1393
|
+
contentStack.backgroundColor = .clear
|
|
1394
|
+
addSubview(contentStack)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
1398
|
+
|
|
1399
|
+
override func layoutSubviews() {
|
|
1400
|
+
super.layoutSubviews()
|
|
1401
|
+
contentStack.frame = bounds
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1223
1405
|
private final class CraftNativeModalView: UIView {
|
|
1224
1406
|
let contentStack = CraftNativeFlowView()
|
|
1225
1407
|
private let blocker = UIView()
|
|
@@ -1876,6 +2058,13 @@ private final class CraftNativeButton: UIButton {
|
|
|
1876
2058
|
}
|
|
1877
2059
|
}
|
|
1878
2060
|
|
|
2061
|
+
/// A native checkbox button. UIKit has no checkbox control, so use the system
|
|
2062
|
+
/// square/checkmark symbols while keeping the control's title and accessibility
|
|
2063
|
+
/// state native and keyboard/touch reachable.
|
|
2064
|
+
private final class CraftNativeCheckboxButton: UIButton {
|
|
2065
|
+
var indeterminate = false
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1879
2068
|
private final class CraftNativeLabel: UILabel {
|
|
1880
2069
|
var insets = UIEdgeInsets.zero {
|
|
1881
2070
|
didSet {
|
|
@@ -2052,6 +2241,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2052
2241
|
private var imageSources: [ObjectIdentifier: String] = [:]
|
|
2053
2242
|
private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
|
|
2054
2243
|
private var imageErrors: [ObjectIdentifier: String] = [:]
|
|
2244
|
+
private var checkboxIndeterminate: [ObjectIdentifier: Bool] = [:]
|
|
2055
2245
|
private var tintedImages = Set<ObjectIdentifier>()
|
|
2056
2246
|
private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
|
|
2057
2247
|
private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -2186,7 +2376,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2186
2376
|
|
|
2187
2377
|
override func viewDidDisappear(_ animated: Bool) {
|
|
2188
2378
|
super.viewDidDisappear(animated)
|
|
2189
|
-
|
|
2379
|
+
// Gone for good only when its stack no longer holds it. UIKit's
|
|
2380
|
+
// `isMovingFromParent` is no witness: a tab's first screen covered
|
|
2381
|
+
// by a push while its own first appearance was still settling (an
|
|
2382
|
+
// app script opening the sign-in screen at launch) reports it true
|
|
2383
|
+
// while still the root of its stack, and the screen's script was
|
|
2384
|
+
// torn down under it, frozen on whatever it showed then.
|
|
2385
|
+
let inStack = navigationController?.viewControllers.contains(where: { $0 === self }) ?? false
|
|
2386
|
+
if !inStack {
|
|
2190
2387
|
disposeRuntime()
|
|
2191
2388
|
} else {
|
|
2192
2389
|
screenVisibilityChanged(false)
|
|
@@ -2960,12 +3157,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2960
3157
|
}
|
|
2961
3158
|
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
|
2962
3159
|
let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
|
|
2963
|
-
let
|
|
3160
|
+
let globalInset = containsKeyboardAvoidingView() ? 0 : inset
|
|
3161
|
+
let safeAreaChanged = abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5
|
|
2964
3162
|
keyboardSafeAreaInset = inset
|
|
2965
3163
|
keyboardScreenFrame = keyboardFrame
|
|
3164
|
+
updateKeyboardAvoidingViews()
|
|
2966
3165
|
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
|
|
2967
|
-
if
|
|
2968
|
-
self.additionalSafeAreaInsets.bottom =
|
|
3166
|
+
if safeAreaChanged {
|
|
3167
|
+
self.additionalSafeAreaInsets.bottom = globalInset
|
|
2969
3168
|
self.view.layoutIfNeeded()
|
|
2970
3169
|
}
|
|
2971
3170
|
self.insetScrollViewsForKeyboard()
|
|
@@ -2973,6 +3172,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2973
3172
|
}
|
|
2974
3173
|
}
|
|
2975
3174
|
|
|
3175
|
+
private func updateKeyboardAvoidingViews() {
|
|
3176
|
+
func visit(_ node: RenderedNode?) {
|
|
3177
|
+
guard let node else { return }
|
|
3178
|
+
if let keyboard = node.view as? CraftNativeKeyboardAvoidingView {
|
|
3179
|
+
keyboard.setKeyboardInset(keyboardSafeAreaInset)
|
|
3180
|
+
}
|
|
3181
|
+
node.children.forEach(visit)
|
|
3182
|
+
}
|
|
3183
|
+
visit(renderedRoot)
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
private func containsKeyboardAvoidingView() -> Bool {
|
|
3187
|
+
func visit(_ node: RenderedNode?) -> Bool {
|
|
3188
|
+
guard let node else { return false }
|
|
3189
|
+
if node.view is CraftNativeKeyboardAvoidingView { return true }
|
|
3190
|
+
return node.children.contains(where: visit)
|
|
3191
|
+
}
|
|
3192
|
+
return visit(renderedRoot)
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
private func syncGlobalKeyboardSafeArea() {
|
|
3196
|
+
let globalInset = containsKeyboardAvoidingView() ? 0 : keyboardSafeAreaInset
|
|
3197
|
+
guard abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5 else { return }
|
|
3198
|
+
additionalSafeAreaInsets.bottom = globalInset
|
|
3199
|
+
view.setNeedsLayout()
|
|
3200
|
+
}
|
|
3201
|
+
|
|
2976
3202
|
/// A scroll view the keyboard still covers part of (one that does not end
|
|
2977
3203
|
/// at the screen's bottom edge, or a keyboard taller than the safe area
|
|
2978
3204
|
/// took account of) insets its content by that much, so everything in it
|
|
@@ -3311,6 +3537,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3311
3537
|
rootStack.setLayoutStyle(rootStyle, for: next.view)
|
|
3312
3538
|
rootStack.invalidateMeasurements()
|
|
3313
3539
|
renderedRoot = next
|
|
3540
|
+
updateKeyboardAvoidingViews()
|
|
3541
|
+
syncGlobalKeyboardSafeArea()
|
|
3314
3542
|
trackContentScrollView()
|
|
3315
3543
|
restoreFocus(focused)
|
|
3316
3544
|
}
|
|
@@ -3318,6 +3546,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3318
3546
|
private func clearRenderedTree() {
|
|
3319
3547
|
if let root = renderedRoot { detach(root, from: rootStack) }
|
|
3320
3548
|
renderedRoot = nil
|
|
3549
|
+
syncGlobalKeyboardSafeArea()
|
|
3321
3550
|
}
|
|
3322
3551
|
|
|
3323
3552
|
private func firstResponder(in view: UIView) -> UIView? {
|
|
@@ -3334,7 +3563,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3334
3563
|
}
|
|
3335
3564
|
|
|
3336
3565
|
private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
|
|
3337
|
-
let type = node["type"] as? String ?? "View"
|
|
3566
|
+
let type = canonicalType(node["type"] as? String ?? "View")
|
|
3338
3567
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
3339
3568
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
3340
3569
|
let events = node["events"] as? [String: String] ?? [:]
|
|
@@ -3352,7 +3581,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3352
3581
|
current.protocolId = node["id"] as? String
|
|
3353
3582
|
current.style = style
|
|
3354
3583
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
3355
|
-
if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
|
|
3584
|
+
if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
|
|
3356
3585
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
3357
3586
|
}
|
|
3358
3587
|
applyViewStyle(style, to: result, node: current)
|
|
@@ -3384,6 +3613,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3384
3613
|
(button as? CraftNativeButton)?.padding = paddingInsets(style)
|
|
3385
3614
|
button.isEnabled = props["disabled"] as? Bool != true
|
|
3386
3615
|
updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
|
|
3616
|
+
case "Checkbox":
|
|
3617
|
+
let checkbox = result as! CraftNativeCheckboxButton
|
|
3618
|
+
let label = props["label"] as? String ?? children.compactMap { $0 as? String }.joined()
|
|
3619
|
+
let transformedLabel = transformedText(label, style: style)
|
|
3620
|
+
let checked = (props["checked"] as? Bool) ?? (props["value"] as? Bool) ?? false
|
|
3621
|
+
let indeterminate = props["indeterminate"] as? Bool == true
|
|
3622
|
+
checkbox.isSelected = checked
|
|
3623
|
+
checkbox.indeterminate = indeterminate
|
|
3624
|
+
checkboxIndeterminate[ObjectIdentifier(checkbox)] = indeterminate
|
|
3625
|
+
checkbox.setTitle(transformedLabel, for: .normal)
|
|
3626
|
+
checkbox.setAttributedTitle(NSAttributedString(
|
|
3627
|
+
string: transformedLabel,
|
|
3628
|
+
attributes: buttonTitleAttributes(style, color: color(props["color"]) ?? color(style["color"]) ?? .label, font: textFont(style, default: .systemFont(ofSize: 16)), forceUnderline: false)
|
|
3629
|
+
), for: .normal)
|
|
3630
|
+
let uncheckedImage = UIImage(systemName: indeterminate ? "minus.square" : "square")
|
|
3631
|
+
checkbox.setImage(uncheckedImage, for: .normal)
|
|
3632
|
+
checkbox.setImage(
|
|
3633
|
+
UIImage(systemName: indeterminate ? "minus.square" : "checkmark.square.fill"),
|
|
3634
|
+
for: .selected
|
|
3635
|
+
)
|
|
3636
|
+
checkbox.tintColor = color(props["color"]) ?? color(style["color"]) ?? .systemBlue
|
|
3637
|
+
checkbox.isEnabled = props["disabled"] as? Bool != true
|
|
3638
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: checkbox)
|
|
3387
3639
|
case "TextInput":
|
|
3388
3640
|
if let textView = result as? UITextView {
|
|
3389
3641
|
inputIdentities[ObjectIdentifier(textView)] = current.identity
|
|
@@ -3483,7 +3735,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3483
3735
|
picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
|
|
3484
3736
|
picker.minimumDate = Self.pickerDate(props["minimumDate"])
|
|
3485
3737
|
picker.maximumDate = Self.pickerDate(props["maximumDate"])
|
|
3486
|
-
if
|
|
3738
|
+
if mode == .countDownTimer {
|
|
3739
|
+
if let duration = Self.countdownDuration(props["value"]), abs(picker.countDownDuration - duration) >= 1 {
|
|
3740
|
+
picker.countDownDuration = duration
|
|
3741
|
+
}
|
|
3742
|
+
} else if let date = Self.pickerDate(props["value"]), abs(picker.date.timeIntervalSince(date)) >= 1 {
|
|
3487
3743
|
picker.setDate(date, animated: false)
|
|
3488
3744
|
}
|
|
3489
3745
|
picker.tintColor = color(props["color"]) ?? color(style["color"])
|
|
@@ -3604,6 +3860,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3604
3860
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
3605
3861
|
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
|
|
3606
3862
|
updateImage(image, source: props["source"])
|
|
3863
|
+
case "ImageBackground":
|
|
3864
|
+
let background = result as! CraftNativeImageBackgroundView
|
|
3865
|
+
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
3866
|
+
background.contentMode = imageContentMode(requestedResizeMode)
|
|
3867
|
+
background.clipsToBounds = background.contentMode == .scaleAspectFill
|
|
3868
|
+
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
|
|
3869
|
+
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
|
|
3870
|
+
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
|
|
3871
|
+
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: background)
|
|
3872
|
+
updateImage(background, source: props["source"])
|
|
3873
|
+
configureStack(background.contentStack, style: style)
|
|
3874
|
+
reconcileChildren(children, in: background.contentStack, parent: current, path: path, style: style)
|
|
3875
|
+
case "KeyboardAvoidingView":
|
|
3876
|
+
let keyboard = result as! CraftNativeKeyboardAvoidingView
|
|
3877
|
+
configureStack(keyboard, style: style)
|
|
3878
|
+
keyboard.configureKeyboard(props: props)
|
|
3879
|
+
reconcileChildren(children, in: keyboard, parent: current, path: path, style: style)
|
|
3607
3880
|
case "Video":
|
|
3608
3881
|
let video = result as! CraftNativeVideoView
|
|
3609
3882
|
let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
|
|
@@ -3670,7 +3943,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3670
3943
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
3671
3944
|
}
|
|
3672
3945
|
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
3673
|
-
if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
3946
|
+
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
3674
3947
|
let disabled = props["disabled"] as? Bool == true
|
|
3675
3948
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
3676
3949
|
updatePressFeedback(
|
|
@@ -3694,6 +3967,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3694
3967
|
let button = CraftNativeButton(type: .system)
|
|
3695
3968
|
button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
|
|
3696
3969
|
return button
|
|
3970
|
+
case "Checkbox":
|
|
3971
|
+
let checkbox = CraftNativeCheckboxButton(type: .system)
|
|
3972
|
+
checkbox.semanticContentAttribute = .forceLeftToRight
|
|
3973
|
+
checkbox.contentHorizontalAlignment = .leading
|
|
3974
|
+
checkbox.addTarget(self, action: #selector(checkboxPressed(_:)), for: .touchUpInside)
|
|
3975
|
+
return checkbox
|
|
3697
3976
|
case "TextInput":
|
|
3698
3977
|
if props["multiline"] as? Bool == true {
|
|
3699
3978
|
let textView = CraftNativeTextView()
|
|
@@ -3731,6 +4010,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3731
4010
|
return UIActivityIndicatorView(style: .medium)
|
|
3732
4011
|
case "Image":
|
|
3733
4012
|
return UIImageView()
|
|
4013
|
+
case "ImageBackground":
|
|
4014
|
+
return CraftNativeImageBackgroundView(frame: .zero)
|
|
4015
|
+
case "KeyboardAvoidingView":
|
|
4016
|
+
return CraftNativeKeyboardAvoidingView()
|
|
3734
4017
|
case "Video":
|
|
3735
4018
|
return CraftNativeVideoView(host: self)
|
|
3736
4019
|
case "Icon":
|
|
@@ -3752,12 +4035,47 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3752
4035
|
}
|
|
3753
4036
|
}
|
|
3754
4037
|
|
|
4038
|
+
private func canonicalType(_ raw: String) -> String {
|
|
4039
|
+
switch raw.lowercased() {
|
|
4040
|
+
case "view": return "View"
|
|
4041
|
+
case "safeareaview": return "SafeAreaView"
|
|
4042
|
+
case "keyboardavoidingview": return "KeyboardAvoidingView"
|
|
4043
|
+
case "text": return "Text"
|
|
4044
|
+
case "button": return "Button"
|
|
4045
|
+
case "link": return "Link"
|
|
4046
|
+
case "checkbox": return "Checkbox"
|
|
4047
|
+
case "textinput", "input": return "TextInput"
|
|
4048
|
+
case "picker", "select": return "Picker"
|
|
4049
|
+
case "modal": return "Modal"
|
|
4050
|
+
case "switch": return "Switch"
|
|
4051
|
+
case "slider": return "Slider"
|
|
4052
|
+
case "activityindicator": return "ActivityIndicator"
|
|
4053
|
+
case "image": return "Image"
|
|
4054
|
+
case "imagebackground": return "ImageBackground"
|
|
4055
|
+
case "video": return "Video"
|
|
4056
|
+
case "icon": return "Icon"
|
|
4057
|
+
case "scrollview": return "ScrollView"
|
|
4058
|
+
case "flatlist": return "FlatList"
|
|
4059
|
+
case "svg": return "Svg"
|
|
4060
|
+
case "touchableopacity": return "TouchableOpacity"
|
|
4061
|
+
case "touchablehighlight": return "TouchableHighlight"
|
|
4062
|
+
case "pressable": return "Pressable"
|
|
4063
|
+
default: return raw
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
|
|
3755
4067
|
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
3756
4068
|
// The current @stacksjs/stx native compiler emits key in props; accept the IR
|
|
3757
4069
|
// field too so keyed children keep working when the compiler adopts it.
|
|
3758
|
-
[node["key"], props["key"], props["testID"]
|
|
4070
|
+
let direct = [node["key"], props["key"], props["testID"]]
|
|
3759
4071
|
.compactMap { $0 as? String }
|
|
3760
4072
|
.first { !$0.isEmpty }
|
|
4073
|
+
if let direct { return direct }
|
|
4074
|
+
for child in node["children"] as? [[String: Any]] ?? [] {
|
|
4075
|
+
let childProps = child["props"] as? [String: Any] ?? [:]
|
|
4076
|
+
if let nested = explicitKey(child, props: childProps) { return nested }
|
|
4077
|
+
}
|
|
4078
|
+
return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
3761
4079
|
}
|
|
3762
4080
|
|
|
3763
4081
|
private func accessibilityIdentifier(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
@@ -3976,6 +4294,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3976
4294
|
imageLoadHandlers.removeValue(forKey: id)
|
|
3977
4295
|
imageLoadEndHandlers.removeValue(forKey: id)
|
|
3978
4296
|
imageErrorHandlers.removeValue(forKey: id)
|
|
4297
|
+
checkboxIndeterminate.removeValue(forKey: id)
|
|
3979
4298
|
if let video = node.view as? CraftNativeVideoView { video.stop() }
|
|
3980
4299
|
for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
|
|
3981
4300
|
}
|
|
@@ -4241,7 +4560,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4241
4560
|
stack.columnGap = number(style["columnGap"]) ?? gap
|
|
4242
4561
|
stack.grid = style["display"] as? String == "grid"
|
|
4243
4562
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
4244
|
-
stack.gridAutoRows =
|
|
4563
|
+
stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
|
|
4245
4564
|
stack.padding = paddingInsets(style)
|
|
4246
4565
|
stack.invalidateMeasurements()
|
|
4247
4566
|
}
|
|
@@ -4565,7 +4884,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4565
4884
|
|
|
4566
4885
|
private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
|
|
4567
4886
|
view.accessibilityLabel = (props["accessibilityLabel"] as? String)
|
|
4568
|
-
?? (type == "Image" ? props["alt"] as? String : nil)
|
|
4887
|
+
?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
|
|
4569
4888
|
view.accessibilityHint = props["accessibilityHint"] as? String
|
|
4570
4889
|
if let value = props["accessibilityValue"] {
|
|
4571
4890
|
view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
|
|
@@ -4573,10 +4892,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4573
4892
|
view.accessibilityValue = nil
|
|
4574
4893
|
}
|
|
4575
4894
|
let role = props["accessibilityRole"] as? String
|
|
4576
|
-
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
4895
|
+
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
4577
4896
|
var traits: UIAccessibilityTraits = []
|
|
4578
4897
|
switch role ?? type.lowercased() {
|
|
4579
|
-
case "button": traits.insert(.button)
|
|
4898
|
+
case "button", "checkbox": traits.insert(.button)
|
|
4580
4899
|
case "image": traits.insert(.image)
|
|
4581
4900
|
case "header": traits.insert(.header)
|
|
4582
4901
|
case "link": traits.insert(.link)
|
|
@@ -4596,7 +4915,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4596
4915
|
if state["checked"] as? Bool == true { traits.insert(.selected) }
|
|
4597
4916
|
}
|
|
4598
4917
|
if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
|
|
4599
|
-
if type == "
|
|
4918
|
+
if type == "Checkbox", let checkbox = view as? CraftNativeCheckboxButton {
|
|
4919
|
+
if checkbox.isSelected { traits.insert(.selected) }
|
|
4920
|
+
view.accessibilityValue = checkbox.indeterminate ? "mixed" : checkbox.isSelected ? "checked" : "unchecked"
|
|
4921
|
+
}
|
|
4922
|
+
if (type == "Image" || type == "ImageBackground"), let message = imageErrors[ObjectIdentifier(view)] {
|
|
4600
4923
|
let current = view.accessibilityValue
|
|
4601
4924
|
if current?.contains(message) != true {
|
|
4602
4925
|
view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
|
|
@@ -4631,6 +4954,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4631
4954
|
}
|
|
4632
4955
|
}
|
|
4633
4956
|
|
|
4957
|
+
@objc private func checkboxPressed(_ sender: CraftNativeCheckboxButton) {
|
|
4958
|
+
sender.indeterminate = false
|
|
4959
|
+
checkboxIndeterminate[ObjectIdentifier(sender)] = false
|
|
4960
|
+
sender.isSelected.toggle()
|
|
4961
|
+
if let handler = handlers[ObjectIdentifier(sender)] {
|
|
4962
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isSelected]])
|
|
4963
|
+
}
|
|
4964
|
+
applyAccessibility([:], type: "Checkbox", to: sender)
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4634
4967
|
/// Return on a `returnKeyType="next"` field the screen does not handle
|
|
4635
4968
|
/// itself (no `@submit`) goes to the next field down the screen, keeping
|
|
4636
4969
|
/// the keyboard up. Every other Return ends editing and fires `@submit`.
|
|
@@ -4730,7 +5063,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4730
5063
|
/// milliseconds for code that wants those.
|
|
4731
5064
|
@objc private func datePicked(_ sender: UIDatePicker) {
|
|
4732
5065
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
4733
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender
|
|
5066
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender)])
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
static func datePickerEvent(_ picker: UIDatePicker) -> [String: Any] {
|
|
5070
|
+
if picker.datePickerMode == .countDownTimer { return countdownEvent(picker.countDownDuration) }
|
|
5071
|
+
return datePickerEvent(picker.date, mode: picker.datePickerMode)
|
|
5072
|
+
}
|
|
5073
|
+
|
|
5074
|
+
static func countdownEvent(_ duration: TimeInterval) -> [String: Any] {
|
|
5075
|
+
let totalSeconds = max(0, Int(duration.rounded()))
|
|
5076
|
+
let hours = totalSeconds / 3600
|
|
5077
|
+
let minutes = (totalSeconds % 3600) / 60
|
|
5078
|
+
let seconds = totalSeconds % 60
|
|
5079
|
+
return [
|
|
5080
|
+
"value": String(format: "%02d:%02d:%02d", hours, minutes, seconds),
|
|
5081
|
+
"iso": "PT\(totalSeconds)S",
|
|
5082
|
+
"timestamp": Double(totalSeconds * 1000),
|
|
5083
|
+
]
|
|
4734
5084
|
}
|
|
4735
5085
|
|
|
4736
5086
|
static func datePickerEvent(_ date: Date, mode: UIDatePicker.Mode) -> [String: Any] {
|
|
@@ -4757,6 +5107,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4757
5107
|
}
|
|
4758
5108
|
}
|
|
4759
5109
|
|
|
5110
|
+
/// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
|
|
5111
|
+
static func countdownDuration(_ value: Any?) -> TimeInterval? {
|
|
5112
|
+
let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
|
|
5113
|
+
if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
|
|
5114
|
+
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
5115
|
+
if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
|
|
5116
|
+
let parts = text.split(separator: ":").compactMap { Double($0) }
|
|
5117
|
+
guard parts.count == 2 || parts.count == 3 else { return nil }
|
|
5118
|
+
let hours = parts[0]
|
|
5119
|
+
let minutes = parts[1]
|
|
5120
|
+
let seconds = parts.count == 3 ? parts[2] : 0
|
|
5121
|
+
guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
|
|
5122
|
+
return min(maximum, hours * 3600 + minutes * 60 + seconds)
|
|
5123
|
+
}
|
|
5124
|
+
|
|
4760
5125
|
/// A DatePicker's value: `2026-10-12` (that day), `07:30` (today at that
|
|
4761
5126
|
/// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
|
|
4762
5127
|
/// milliseconds since 1970. Nil for anything else.
|