craft-native 0.0.158 → 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 +16 -6
- package/dist/ios/templates/CraftHealth.swift +11 -0
- package/dist/ios/templates/CraftHybrid.swift +19 -0
- package/dist/ios/templates/CraftNativeScreen.swift +403 -45
- 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] = [:]
|
|
@@ -2967,12 +3157,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2967
3157
|
}
|
|
2968
3158
|
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
|
2969
3159
|
let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
|
|
2970
|
-
let
|
|
3160
|
+
let globalInset = containsKeyboardAvoidingView() ? 0 : inset
|
|
3161
|
+
let safeAreaChanged = abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5
|
|
2971
3162
|
keyboardSafeAreaInset = inset
|
|
2972
3163
|
keyboardScreenFrame = keyboardFrame
|
|
3164
|
+
updateKeyboardAvoidingViews()
|
|
2973
3165
|
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
|
|
2974
|
-
if
|
|
2975
|
-
self.additionalSafeAreaInsets.bottom =
|
|
3166
|
+
if safeAreaChanged {
|
|
3167
|
+
self.additionalSafeAreaInsets.bottom = globalInset
|
|
2976
3168
|
self.view.layoutIfNeeded()
|
|
2977
3169
|
}
|
|
2978
3170
|
self.insetScrollViewsForKeyboard()
|
|
@@ -2980,6 +3172,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2980
3172
|
}
|
|
2981
3173
|
}
|
|
2982
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
|
+
|
|
2983
3202
|
/// A scroll view the keyboard still covers part of (one that does not end
|
|
2984
3203
|
/// at the screen's bottom edge, or a keyboard taller than the safe area
|
|
2985
3204
|
/// took account of) insets its content by that much, so everything in it
|
|
@@ -3318,6 +3537,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3318
3537
|
rootStack.setLayoutStyle(rootStyle, for: next.view)
|
|
3319
3538
|
rootStack.invalidateMeasurements()
|
|
3320
3539
|
renderedRoot = next
|
|
3540
|
+
updateKeyboardAvoidingViews()
|
|
3541
|
+
syncGlobalKeyboardSafeArea()
|
|
3321
3542
|
trackContentScrollView()
|
|
3322
3543
|
restoreFocus(focused)
|
|
3323
3544
|
}
|
|
@@ -3325,6 +3546,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3325
3546
|
private func clearRenderedTree() {
|
|
3326
3547
|
if let root = renderedRoot { detach(root, from: rootStack) }
|
|
3327
3548
|
renderedRoot = nil
|
|
3549
|
+
syncGlobalKeyboardSafeArea()
|
|
3328
3550
|
}
|
|
3329
3551
|
|
|
3330
3552
|
private func firstResponder(in view: UIView) -> UIView? {
|
|
@@ -3341,7 +3563,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3341
3563
|
}
|
|
3342
3564
|
|
|
3343
3565
|
private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
|
|
3344
|
-
let type = node["type"] as? String ?? "View"
|
|
3566
|
+
let type = canonicalType(node["type"] as? String ?? "View")
|
|
3345
3567
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
3346
3568
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
3347
3569
|
let events = node["events"] as? [String: String] ?? [:]
|
|
@@ -3359,7 +3581,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3359
3581
|
current.protocolId = node["id"] as? String
|
|
3360
3582
|
current.style = style
|
|
3361
3583
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
3362
|
-
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" {
|
|
3363
3585
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
3364
3586
|
}
|
|
3365
3587
|
applyViewStyle(style, to: result, node: current)
|
|
@@ -3391,6 +3613,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3391
3613
|
(button as? CraftNativeButton)?.padding = paddingInsets(style)
|
|
3392
3614
|
button.isEnabled = props["disabled"] as? Bool != true
|
|
3393
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)
|
|
3394
3639
|
case "TextInput":
|
|
3395
3640
|
if let textView = result as? UITextView {
|
|
3396
3641
|
inputIdentities[ObjectIdentifier(textView)] = current.identity
|
|
@@ -3490,7 +3735,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3490
3735
|
picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
|
|
3491
3736
|
picker.minimumDate = Self.pickerDate(props["minimumDate"])
|
|
3492
3737
|
picker.maximumDate = Self.pickerDate(props["maximumDate"])
|
|
3493
|
-
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 {
|
|
3494
3743
|
picker.setDate(date, animated: false)
|
|
3495
3744
|
}
|
|
3496
3745
|
picker.tintColor = color(props["color"]) ?? color(style["color"])
|
|
@@ -3611,6 +3860,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3611
3860
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
3612
3861
|
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
|
|
3613
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)
|
|
3614
3880
|
case "Video":
|
|
3615
3881
|
let video = result as! CraftNativeVideoView
|
|
3616
3882
|
let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
|
|
@@ -3677,7 +3943,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3677
3943
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
3678
3944
|
}
|
|
3679
3945
|
updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
|
|
3680
|
-
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" {
|
|
3681
3947
|
let disabled = props["disabled"] as? Bool == true
|
|
3682
3948
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
3683
3949
|
updatePressFeedback(
|
|
@@ -3701,6 +3967,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3701
3967
|
let button = CraftNativeButton(type: .system)
|
|
3702
3968
|
button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
|
|
3703
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
|
|
3704
3976
|
case "TextInput":
|
|
3705
3977
|
if props["multiline"] as? Bool == true {
|
|
3706
3978
|
let textView = CraftNativeTextView()
|
|
@@ -3738,6 +4010,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3738
4010
|
return UIActivityIndicatorView(style: .medium)
|
|
3739
4011
|
case "Image":
|
|
3740
4012
|
return UIImageView()
|
|
4013
|
+
case "ImageBackground":
|
|
4014
|
+
return CraftNativeImageBackgroundView(frame: .zero)
|
|
4015
|
+
case "KeyboardAvoidingView":
|
|
4016
|
+
return CraftNativeKeyboardAvoidingView()
|
|
3741
4017
|
case "Video":
|
|
3742
4018
|
return CraftNativeVideoView(host: self)
|
|
3743
4019
|
case "Icon":
|
|
@@ -3759,12 +4035,47 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3759
4035
|
}
|
|
3760
4036
|
}
|
|
3761
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
|
+
|
|
3762
4067
|
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
3763
4068
|
// The current @stacksjs/stx native compiler emits key in props; accept the IR
|
|
3764
4069
|
// field too so keyed children keep working when the compiler adopts it.
|
|
3765
|
-
[node["key"], props["key"], props["testID"]
|
|
4070
|
+
let direct = [node["key"], props["key"], props["testID"]]
|
|
3766
4071
|
.compactMap { $0 as? String }
|
|
3767
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 }
|
|
3768
4079
|
}
|
|
3769
4080
|
|
|
3770
4081
|
private func accessibilityIdentifier(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
@@ -3983,6 +4294,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3983
4294
|
imageLoadHandlers.removeValue(forKey: id)
|
|
3984
4295
|
imageLoadEndHandlers.removeValue(forKey: id)
|
|
3985
4296
|
imageErrorHandlers.removeValue(forKey: id)
|
|
4297
|
+
checkboxIndeterminate.removeValue(forKey: id)
|
|
3986
4298
|
if let video = node.view as? CraftNativeVideoView { video.stop() }
|
|
3987
4299
|
for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
|
|
3988
4300
|
}
|
|
@@ -4248,7 +4560,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4248
4560
|
stack.columnGap = number(style["columnGap"]) ?? gap
|
|
4249
4561
|
stack.grid = style["display"] as? String == "grid"
|
|
4250
4562
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
4251
|
-
stack.gridAutoRows =
|
|
4563
|
+
stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
|
|
4252
4564
|
stack.padding = paddingInsets(style)
|
|
4253
4565
|
stack.invalidateMeasurements()
|
|
4254
4566
|
}
|
|
@@ -4572,7 +4884,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4572
4884
|
|
|
4573
4885
|
private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
|
|
4574
4886
|
view.accessibilityLabel = (props["accessibilityLabel"] as? String)
|
|
4575
|
-
?? (type == "Image" ? props["alt"] as? String : nil)
|
|
4887
|
+
?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
|
|
4576
4888
|
view.accessibilityHint = props["accessibilityHint"] as? String
|
|
4577
4889
|
if let value = props["accessibilityValue"] {
|
|
4578
4890
|
view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
|
|
@@ -4580,10 +4892,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4580
4892
|
view.accessibilityValue = nil
|
|
4581
4893
|
}
|
|
4582
4894
|
let role = props["accessibilityRole"] as? String
|
|
4583
|
-
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))
|
|
4584
4896
|
var traits: UIAccessibilityTraits = []
|
|
4585
4897
|
switch role ?? type.lowercased() {
|
|
4586
|
-
case "button": traits.insert(.button)
|
|
4898
|
+
case "button", "checkbox": traits.insert(.button)
|
|
4587
4899
|
case "image": traits.insert(.image)
|
|
4588
4900
|
case "header": traits.insert(.header)
|
|
4589
4901
|
case "link": traits.insert(.link)
|
|
@@ -4603,7 +4915,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4603
4915
|
if state["checked"] as? Bool == true { traits.insert(.selected) }
|
|
4604
4916
|
}
|
|
4605
4917
|
if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
|
|
4606
|
-
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)] {
|
|
4607
4923
|
let current = view.accessibilityValue
|
|
4608
4924
|
if current?.contains(message) != true {
|
|
4609
4925
|
view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
|
|
@@ -4638,6 +4954,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4638
4954
|
}
|
|
4639
4955
|
}
|
|
4640
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
|
+
|
|
4641
4967
|
/// Return on a `returnKeyType="next"` field the screen does not handle
|
|
4642
4968
|
/// itself (no `@submit`) goes to the next field down the screen, keeping
|
|
4643
4969
|
/// the keyboard up. Every other Return ends editing and fires `@submit`.
|
|
@@ -4737,7 +5063,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4737
5063
|
/// milliseconds for code that wants those.
|
|
4738
5064
|
@objc private func datePicked(_ sender: UIDatePicker) {
|
|
4739
5065
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
4740
|
-
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
|
+
]
|
|
4741
5084
|
}
|
|
4742
5085
|
|
|
4743
5086
|
static func datePickerEvent(_ date: Date, mode: UIDatePicker.Mode) -> [String: Any] {
|
|
@@ -4764,6 +5107,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4764
5107
|
}
|
|
4765
5108
|
}
|
|
4766
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
|
+
|
|
4767
5125
|
/// A DatePicker's value: `2026-10-12` (that day), `07:30` (today at that
|
|
4768
5126
|
/// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
|
|
4769
5127
|
/// milliseconds since 1970. Nil for anything else.
|