craft-native 0.0.158 → 0.0.161
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 +62 -1
- package/dist/android/templates/CraftNativeCapabilities.kt.template +311 -4
- package/dist/android/templates/MainActivityNative.kt.template +638 -55
- package/dist/api/mobile.d.ts +57 -4
- 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/CraftApp.swift +24 -0
- package/dist/ios/templates/CraftHealth.swift +123 -0
- package/dist/ios/templates/CraftHybrid.swift +19 -0
- package/dist/ios/templates/CraftNativeScreen.swift +501 -58
- package/dist/mobile.js +12 -0
- package/package.json +1 -1
|
@@ -182,11 +182,32 @@ 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
|
|
188
|
+
private typealias AlignedFunction = @convention(c) (
|
|
189
|
+
Float, Float, UInt32, Float, Float, UInt32, UnsafeRawPointer, Int, UnsafeMutableRawPointer
|
|
190
|
+
) -> Int32
|
|
191
|
+
private typealias AutoRowsAlignedFunction = @convention(c) (
|
|
192
|
+
Float, Float, UInt32, Float, Float, UInt32, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
|
|
193
|
+
) -> Int32
|
|
185
194
|
|
|
186
195
|
private static let function: Function? = {
|
|
187
196
|
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid") else { return nil }
|
|
188
197
|
return unsafeBitCast(symbol, to: Function.self)
|
|
189
198
|
}()
|
|
199
|
+
private static let autoRowsFunction: AutoRowsFunction? = {
|
|
200
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_auto_rows") else { return nil }
|
|
201
|
+
return unsafeBitCast(symbol, to: AutoRowsFunction.self)
|
|
202
|
+
}()
|
|
203
|
+
private static let alignedFunction: AlignedFunction? = {
|
|
204
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_aligned") else { return nil }
|
|
205
|
+
return unsafeBitCast(symbol, to: AlignedFunction.self)
|
|
206
|
+
}()
|
|
207
|
+
private static let autoRowsAlignedFunction: AutoRowsAlignedFunction? = {
|
|
208
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_auto_rows_aligned") else { return nil }
|
|
209
|
+
return unsafeBitCast(symbol, to: AutoRowsAlignedFunction.self)
|
|
210
|
+
}()
|
|
190
211
|
|
|
191
212
|
static func frames(
|
|
192
213
|
availableWidth: CGFloat,
|
|
@@ -194,9 +215,13 @@ private enum CraftNativeGridBridge {
|
|
|
194
215
|
columns: Int,
|
|
195
216
|
columnGap: CGFloat,
|
|
196
217
|
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)]
|
|
218
|
+
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)],
|
|
219
|
+
autoRowHeight: CGFloat? = nil,
|
|
220
|
+
alignContent: UInt32 = 0
|
|
198
221
|
) -> [(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat)]? {
|
|
199
|
-
guard
|
|
222
|
+
guard !items.isEmpty else { return nil }
|
|
223
|
+
if autoRowHeight != nil, autoRowsFunction == nil { return nil }
|
|
224
|
+
if autoRowHeight == nil, function == nil { return nil }
|
|
200
225
|
var input = items.map { item in
|
|
201
226
|
Item(
|
|
202
227
|
intrinsicWidth: Float(item.intrinsicWidth), intrinsicHeight: Float(item.intrinsicHeight),
|
|
@@ -211,7 +236,17 @@ private enum CraftNativeGridBridge {
|
|
|
211
236
|
var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: input.count)
|
|
212
237
|
let status = input.withUnsafeBufferPointer { inputBuffer in
|
|
213
238
|
output.withUnsafeMutableBufferPointer { outputBuffer in
|
|
214
|
-
|
|
239
|
+
if let autoRowHeight, let autoRowsFunction {
|
|
240
|
+
if alignContent != 0, let autoRowsAlignedFunction {
|
|
241
|
+
return autoRowsAlignedFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), alignContent, Float(autoRowHeight), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
242
|
+
}
|
|
243
|
+
return autoRowsFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), Float(autoRowHeight), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
244
|
+
}
|
|
245
|
+
if alignContent != 0, let alignedFunction {
|
|
246
|
+
return alignedFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), alignContent, UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
247
|
+
}
|
|
248
|
+
guard let function else { return Int32(-1) }
|
|
249
|
+
return function(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
|
|
215
250
|
}
|
|
216
251
|
}
|
|
217
252
|
guard status == 0, output.allSatisfy({ frame in
|
|
@@ -221,6 +256,17 @@ private enum CraftNativeGridBridge {
|
|
|
221
256
|
}
|
|
222
257
|
}
|
|
223
258
|
|
|
259
|
+
private func craftNativeAlignContentCode(_ value: String) -> UInt32 {
|
|
260
|
+
switch value {
|
|
261
|
+
case "center": return 1
|
|
262
|
+
case "flex-end": return 2
|
|
263
|
+
case "space-between": return 3
|
|
264
|
+
case "space-around": return 4
|
|
265
|
+
case "stretch": return 5
|
|
266
|
+
default: return 0
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
224
270
|
private final class CraftNativeVideoView: UIView {
|
|
225
271
|
private let playerController = AVPlayerViewController()
|
|
226
272
|
private var player: AVPlayer?
|
|
@@ -242,12 +288,20 @@ private final class CraftNativeVideoView: UIView {
|
|
|
242
288
|
host.addChild(playerController)
|
|
243
289
|
addSubview(playerController.view)
|
|
244
290
|
playerController.didMove(toParent: host)
|
|
291
|
+
// Expose the host view as the native Video node. The player controller
|
|
292
|
+
// owns an accessibility subtree of its own, which otherwise masks the
|
|
293
|
+
// host's testID and makes the component look like a WebView-backed view
|
|
294
|
+
// to UI tests and assistive technology.
|
|
295
|
+
playerController.view.isAccessibilityElement = false
|
|
296
|
+
playerController.view.accessibilityElementsHidden = true
|
|
245
297
|
}
|
|
246
298
|
|
|
247
299
|
required init?(coder: NSCoder) {
|
|
248
300
|
super.init(coder: coder)
|
|
249
301
|
clipsToBounds = true
|
|
250
302
|
addSubview(playerController.view)
|
|
303
|
+
playerController.view.isAccessibilityElement = false
|
|
304
|
+
playerController.view.accessibilityElementsHidden = true
|
|
251
305
|
}
|
|
252
306
|
|
|
253
307
|
override func layoutSubviews() {
|
|
@@ -381,7 +435,7 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
|
381
435
|
/// A length as a style gives it: points, or a percentage of the parent's
|
|
382
436
|
/// content box. A percentage against a size that is not known yet (a column
|
|
383
437
|
/// whose height comes from its content) resolves to nothing, as on the web.
|
|
384
|
-
private enum CraftNativeLength {
|
|
438
|
+
private enum CraftNativeLength: Equatable {
|
|
385
439
|
case points(CGFloat)
|
|
386
440
|
case percent(CGFloat)
|
|
387
441
|
|
|
@@ -414,6 +468,7 @@ private enum CraftNativeLength {
|
|
|
414
468
|
private struct CraftNativeLayoutStyle {
|
|
415
469
|
let width: CraftNativeLength?
|
|
416
470
|
let height: CraftNativeLength?
|
|
471
|
+
let aspectRatio: CGFloat?
|
|
417
472
|
let minWidth: CraftNativeLength?
|
|
418
473
|
let maxWidth: CraftNativeLength?
|
|
419
474
|
let minHeight: CraftNativeLength?
|
|
@@ -435,7 +490,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
435
490
|
let marginLeft: CGFloat
|
|
436
491
|
let display: String
|
|
437
492
|
let gridColumns: Int
|
|
438
|
-
let gridAutoRows:
|
|
493
|
+
let gridAutoRows: CraftNativeLength?
|
|
439
494
|
|
|
440
495
|
static let empty = CraftNativeLayoutStyle([:])
|
|
441
496
|
|
|
@@ -443,6 +498,8 @@ private struct CraftNativeLayoutStyle {
|
|
|
443
498
|
func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
|
|
444
499
|
width = CraftNativeLength(raw["width"])
|
|
445
500
|
height = CraftNativeLength(raw["height"])
|
|
501
|
+
let ratio = number(raw["aspectRatio"])
|
|
502
|
+
aspectRatio = ratio.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
|
|
446
503
|
minWidth = CraftNativeLength(raw["minWidth"])
|
|
447
504
|
maxWidth = CraftNativeLength(raw["maxWidth"])
|
|
448
505
|
minHeight = CraftNativeLength(raw["minHeight"])
|
|
@@ -470,7 +527,7 @@ private struct CraftNativeLayoutStyle {
|
|
|
470
527
|
marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
|
|
471
528
|
display = raw["display"] as? String ?? "flex"
|
|
472
529
|
gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
|
|
473
|
-
gridAutoRows =
|
|
530
|
+
gridAutoRows = CraftNativeLength(raw["gridAutoRows"])
|
|
474
531
|
}
|
|
475
532
|
}
|
|
476
533
|
|
|
@@ -531,11 +588,15 @@ enum CraftNativeFlexLayout {
|
|
|
531
588
|
// Text takes the width it needs up to the room there is, and
|
|
532
589
|
// wraps or truncates inside it rather than widening its parent.
|
|
533
590
|
let limit = width ?? maxWidth
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
591
|
+
// Ask for max-content before asking for the wrapped height. A
|
|
592
|
+
// constrained sizeThatFits call can return min-content (the
|
|
593
|
+
// longest word), which collapses shrink-to-fit bubbles and pills.
|
|
594
|
+
let maxContent = label.sizeThatFits(CGSize(width: unbounded, height: unbounded))
|
|
595
|
+
let resolvedWidth = width ?? min(ceil(maxContent.width), limit)
|
|
596
|
+
// Narrower than max-content (it met the limit): the wrapped height.
|
|
597
|
+
let resolvedHeight = height ?? ceil(resolvedWidth < ceil(maxContent.width)
|
|
537
598
|
? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
|
|
538
|
-
:
|
|
599
|
+
: maxContent.height)
|
|
539
600
|
return CGSize(width: resolvedWidth, height: resolvedHeight)
|
|
540
601
|
case let image as UIImageView where image.image == nil:
|
|
541
602
|
return CGSize(width: width ?? 0, height: height ?? 0)
|
|
@@ -583,7 +644,7 @@ enum CraftNativeFlexLayout {
|
|
|
583
644
|
/// grid. It is a UIStackView in name only, so existing callers and tests keep
|
|
584
645
|
/// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
|
|
585
646
|
/// constraints are never created, and every frame comes from `layout`.
|
|
586
|
-
private
|
|
647
|
+
private class CraftNativeFlowView: UIStackView {
|
|
587
648
|
var pressActiveOpacity: CGFloat?
|
|
588
649
|
var pressBaseOpacity: CGFloat = 1
|
|
589
650
|
var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
|
|
@@ -595,7 +656,7 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
595
656
|
var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
|
|
596
657
|
var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
|
|
597
658
|
var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
|
|
598
|
-
var gridAutoRows:
|
|
659
|
+
var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
|
|
599
660
|
var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
|
|
600
661
|
var padding = UIEdgeInsets.zero {
|
|
601
662
|
didSet {
|
|
@@ -628,6 +689,36 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
628
689
|
|
|
629
690
|
required init(coder: NSCoder) { super.init(coder: coder) }
|
|
630
691
|
|
|
692
|
+
/// A view made one accessibility element (a row with `accessibilityRole`)
|
|
693
|
+
/// hides its children from VoiceOver, so with no label of its own it reads
|
|
694
|
+
/// what they say, in order, as React Native's accessible View does. A
|
|
695
|
+
/// label given in props always wins.
|
|
696
|
+
override var accessibilityLabel: String? {
|
|
697
|
+
get {
|
|
698
|
+
if let own = super.accessibilityLabel, !own.isEmpty { return own }
|
|
699
|
+
guard isAccessibilityElement else { return nil }
|
|
700
|
+
// An avatar labelled with the name beside it is said once.
|
|
701
|
+
var spoken: [String] = []
|
|
702
|
+
for part in CraftNativeFlowView.spokenText(in: self) where !spoken.contains(part) { spoken.append(part) }
|
|
703
|
+
return spoken.isEmpty ? nil : spoken.joined(separator: ", ")
|
|
704
|
+
}
|
|
705
|
+
set { super.accessibilityLabel = newValue }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private static func spokenText(in view: UIView) -> [String] {
|
|
709
|
+
var parts: [String] = []
|
|
710
|
+
for child in view.subviews where !child.isHidden && child.alpha > 0.01 {
|
|
711
|
+
if child.isAccessibilityElement {
|
|
712
|
+
// A Text's label is its text (applyAccessibility leaves accessibilityLabel nil).
|
|
713
|
+
let said = child.accessibilityLabel ?? (child as? UILabel)?.text
|
|
714
|
+
if let label = said?.trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty { parts.append(label) }
|
|
715
|
+
} else {
|
|
716
|
+
parts.append(contentsOf: spokenText(in: child))
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return parts
|
|
720
|
+
}
|
|
721
|
+
|
|
631
722
|
private var alignmentValue: UIStackView.Alignment {
|
|
632
723
|
switch alignItems {
|
|
633
724
|
case "flex-start": return .leading
|
|
@@ -814,12 +905,13 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
814
905
|
let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
|
|
815
906
|
for child in children where child.craftLayoutStyle.position == "absolute" {
|
|
816
907
|
let style = child.craftLayoutStyle
|
|
817
|
-
|
|
908
|
+
var fixedWidth = style.left != nil && style.right != nil
|
|
818
909
|
? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
|
|
819
910
|
: style.width?.resolve(content.width)
|
|
820
|
-
|
|
911
|
+
var fixedHeight = style.top != nil && style.bottom != nil
|
|
821
912
|
? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
|
|
822
913
|
: style.height?.resolve(content.height)
|
|
914
|
+
(fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
|
|
823
915
|
let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
|
|
824
916
|
let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
|
|
825
917
|
let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
|
|
@@ -829,6 +921,21 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
829
921
|
return (size, frames)
|
|
830
922
|
}
|
|
831
923
|
|
|
924
|
+
/// Resolves the missing side of an aspect-ratio box after its parent has
|
|
925
|
+
/// supplied any definite width or height. Keeping this at the host edge
|
|
926
|
+
/// lets grid and absolute children follow the same rule as flex items
|
|
927
|
+
/// without adding platform-specific measurement behavior to native views.
|
|
928
|
+
private func aspectDimensions(
|
|
929
|
+
_ style: CraftNativeLayoutStyle,
|
|
930
|
+
width: CGFloat?,
|
|
931
|
+
height: CGFloat?
|
|
932
|
+
) -> (width: CGFloat?, height: CGFloat?) {
|
|
933
|
+
guard let ratio = style.aspectRatio else { return (width, height) }
|
|
934
|
+
if let width, height == nil { return (width, width / ratio) }
|
|
935
|
+
if let height, width == nil { return (height * ratio, height) }
|
|
936
|
+
return (width, height)
|
|
937
|
+
}
|
|
938
|
+
|
|
832
939
|
private func layoutFlex(
|
|
833
940
|
_ children: [UIView],
|
|
834
941
|
horizontal: Bool,
|
|
@@ -853,15 +960,69 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
853
960
|
return horizontal ? (size.width, size.height) : (size.height, size.width)
|
|
854
961
|
}
|
|
855
962
|
|
|
856
|
-
var items: [Item] =
|
|
963
|
+
var items: [Item] = []
|
|
964
|
+
|
|
965
|
+
func fallbackWidths(for line: Range<Int>, available: CGFloat, gap: CGFloat) -> [CGFloat] {
|
|
966
|
+
let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + CGFloat(max(0, line.count - 1)) * gap
|
|
967
|
+
let free = available - hypothetical
|
|
968
|
+
let growTotal = line.reduce(0) { $0 + items[$1].grow }
|
|
969
|
+
let shrinkTotal = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
|
|
970
|
+
let growing = free > 0 && growTotal > 0
|
|
971
|
+
let shrinking = free < 0 && shrinkTotal > 0
|
|
972
|
+
var widths = line.map { items[$0].basis }
|
|
973
|
+
guard growing || shrinking else { return widths }
|
|
974
|
+
|
|
975
|
+
var frozen = Set<Int>()
|
|
976
|
+
for _ in 0...line.count {
|
|
977
|
+
var fixed = CGFloat(max(0, line.count - 1)) * gap
|
|
978
|
+
var activeBasis: CGFloat = 0
|
|
979
|
+
var factor: CGFloat = 0
|
|
980
|
+
for (offset, index) in line.enumerated() {
|
|
981
|
+
let item = items[index]
|
|
982
|
+
fixed += item.mainMargins
|
|
983
|
+
if frozen.contains(offset) {
|
|
984
|
+
fixed += widths[offset]
|
|
985
|
+
} else {
|
|
986
|
+
activeBasis += item.basis
|
|
987
|
+
factor += growing ? item.grow : item.shrink * item.basis
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
guard factor > 0 else { break }
|
|
991
|
+
let remaining = available - fixed - activeBasis
|
|
992
|
+
var froze = false
|
|
993
|
+
for (offset, index) in line.enumerated() where !frozen.contains(offset) {
|
|
994
|
+
let item = items[index]
|
|
995
|
+
let share = growing
|
|
996
|
+
? remaining * item.grow / factor
|
|
997
|
+
: -remaining * item.shrink * item.basis / factor
|
|
998
|
+
let raw = growing ? item.basis + share : item.basis - share
|
|
999
|
+
let clamped = clamp(raw, item.minMain, item.maxMain)
|
|
1000
|
+
widths[offset] = clamped
|
|
1001
|
+
if abs(clamped - raw) > 0.0001 {
|
|
1002
|
+
frozen.insert(offset)
|
|
1003
|
+
froze = true
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if !froze { break }
|
|
1007
|
+
}
|
|
1008
|
+
return widths
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
items = children.map { view in
|
|
857
1012
|
let style = view.craftLayoutStyle
|
|
858
1013
|
let mainLeading = horizontal ? style.marginLeft : style.marginTop
|
|
859
1014
|
let mainTrailing = horizontal ? style.marginRight : style.marginBottom
|
|
860
1015
|
let crossLeading = horizontal ? style.marginTop : style.marginLeft
|
|
861
1016
|
let crossTrailing = horizontal ? style.marginBottom : style.marginRight
|
|
862
1017
|
let align = style.alignSelf ?? alignItems
|
|
863
|
-
let
|
|
864
|
-
let
|
|
1018
|
+
let authoredMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
|
|
1019
|
+
let authoredCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
|
|
1020
|
+
let explicitMain = authoredMain ?? style.aspectRatio.flatMap { ratio in
|
|
1021
|
+
authoredCross.map { horizontal ? $0 * ratio : $0 / ratio }
|
|
1022
|
+
}
|
|
1023
|
+
let explicitCross = authoredCross ?? style.aspectRatio.flatMap { ratio in
|
|
1024
|
+
authoredMain.map { horizontal ? $0 / ratio : $0 * ratio }
|
|
1025
|
+
}
|
|
865
1026
|
let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
|
|
866
1027
|
let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
|
|
867
1028
|
let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
|
|
@@ -971,22 +1132,8 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
971
1132
|
if let sharedWidths {
|
|
972
1133
|
for (offset, index) in line.enumerated() { items[index].main = sharedWidths[offset].width }
|
|
973
1134
|
} 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
|
-
}
|
|
1135
|
+
let fallback = fallbackWidths(for: line, available: lineMain, gap: mainGap)
|
|
1136
|
+
for (offset, index) in line.enumerated() { items[index].main = fallback[offset] }
|
|
990
1137
|
}
|
|
991
1138
|
|
|
992
1139
|
var lineCross: CGFloat = 0
|
|
@@ -1152,21 +1299,26 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
1152
1299
|
let style = child.craftLayoutStyle
|
|
1153
1300
|
let available = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1154
1301
|
let align = style.alignSelf ?? alignItems
|
|
1155
|
-
|
|
1302
|
+
var fixedWidth = style.width?.resolve(cellWidth)
|
|
1303
|
+
var fixedHeight = style.height?.resolve(nil)
|
|
1304
|
+
if fixedWidth == nil && fixedHeight == nil && align == "stretch" { fixedWidth = available }
|
|
1305
|
+
(fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
|
|
1156
1306
|
let size = CraftNativeFlexLayout.measure(
|
|
1157
1307
|
child,
|
|
1158
1308
|
width: fixedWidth.map { min($0, available) },
|
|
1159
|
-
height:
|
|
1309
|
+
height: fixedHeight,
|
|
1160
1310
|
maxWidth: available,
|
|
1161
1311
|
maxHeight: CraftNativeFlexLayout.unbounded
|
|
1162
1312
|
)
|
|
1163
1313
|
sizes.append(size)
|
|
1164
1314
|
let row = index / columns
|
|
1165
|
-
while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
|
|
1315
|
+
while rowHeights.count <= row { rowHeights.append(gridAutoRows?.resolve(innerHeight) ?? 0) }
|
|
1166
1316
|
rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
|
|
1167
1317
|
}
|
|
1168
1318
|
let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
|
|
1169
1319
|
let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
1320
|
+
// Older runtimes return nil for the optional symbol; the local layout
|
|
1321
|
+
// below remains the compatibility path in that case.
|
|
1170
1322
|
let sharedFrames = CraftNativeGridBridge.frames(
|
|
1171
1323
|
availableWidth: innerWidth ?? width,
|
|
1172
1324
|
availableHeight: innerHeight ?? height,
|
|
@@ -1178,15 +1330,23 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
1178
1330
|
let align = style.alignSelf ?? alignItems
|
|
1179
1331
|
let size = sizes[index]
|
|
1180
1332
|
let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
|
|
1333
|
+
var explicitWidth = style.width?.resolve(cellWidth)
|
|
1334
|
+
var explicitHeight = style.height?.resolve(nil)
|
|
1335
|
+
if explicitWidth == nil && explicitHeight == nil && align == "stretch" {
|
|
1336
|
+
explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1337
|
+
}
|
|
1338
|
+
(explicitWidth, explicitHeight) = aspectDimensions(style, width: explicitWidth, height: explicitHeight)
|
|
1181
1339
|
return (
|
|
1182
1340
|
size.width, size.height,
|
|
1183
|
-
|
|
1341
|
+
explicitWidth, explicitHeight,
|
|
1184
1342
|
style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
|
|
1185
1343
|
style.minHeight?.resolve(nil), style.maxHeight?.resolve(nil),
|
|
1186
1344
|
style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
|
|
1187
1345
|
align == "stretch", alignment
|
|
1188
1346
|
)
|
|
1189
|
-
}
|
|
1347
|
+
},
|
|
1348
|
+
autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 },
|
|
1349
|
+
alignContent: craftNativeAlignContentCode(alignContent)
|
|
1190
1350
|
)
|
|
1191
1351
|
if place {
|
|
1192
1352
|
if let sharedFrames {
|
|
@@ -1196,30 +1356,136 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
1196
1356
|
}
|
|
1197
1357
|
return CGSize(width: width, height: innerHeight ?? height)
|
|
1198
1358
|
}
|
|
1199
|
-
|
|
1359
|
+
let gridHeight = innerHeight ?? height
|
|
1360
|
+
let gridGaps = CGFloat(max(0, rowHeights.count - 1)) * verticalGap
|
|
1361
|
+
let gridFree = max(0, gridHeight - rowHeights.reduce(0, +) - gridGaps)
|
|
1362
|
+
var rowLeading: CGFloat = 0
|
|
1363
|
+
var rowBetween = verticalGap
|
|
1364
|
+
var rowExtra: CGFloat = 0
|
|
1365
|
+
switch alignContent {
|
|
1366
|
+
case "center": rowLeading = gridFree / 2
|
|
1367
|
+
case "flex-end": rowLeading = gridFree
|
|
1368
|
+
case "space-between":
|
|
1369
|
+
if rowHeights.count > 1 { rowBetween += gridFree / CGFloat(rowHeights.count - 1) }
|
|
1370
|
+
case "space-around":
|
|
1371
|
+
rowLeading = gridFree / CGFloat(rowHeights.count * 2)
|
|
1372
|
+
rowBetween += gridFree / CGFloat(rowHeights.count)
|
|
1373
|
+
case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
|
|
1374
|
+
default: break
|
|
1375
|
+
}
|
|
1376
|
+
var rowTop = padding.top + rowLeading
|
|
1200
1377
|
for (index, child) in children.enumerated() {
|
|
1201
1378
|
let style = child.craftLayoutStyle
|
|
1202
1379
|
let row = index / columns
|
|
1203
1380
|
let column = index % columns
|
|
1204
|
-
let rowHeight = rowHeights[row]
|
|
1381
|
+
let rowHeight = rowHeights[row] + rowExtra
|
|
1205
1382
|
let size = sizes[index]
|
|
1206
1383
|
let align = style.alignSelf ?? alignItems
|
|
1207
1384
|
let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
|
|
1208
1385
|
let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
|
|
1209
1386
|
let childWidth = min(availableWidth, size.width)
|
|
1210
|
-
let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
|
|
1387
|
+
let childHeight = align == "stretch" && style.height == nil && style.aspectRatio == nil ? availableHeight : min(availableHeight, size.height)
|
|
1211
1388
|
let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
|
|
1212
1389
|
+ (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
|
|
1213
1390
|
let y = rowTop + style.marginTop
|
|
1214
1391
|
+ (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
|
|
1215
1392
|
frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
|
|
1216
|
-
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight +
|
|
1393
|
+
if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + rowBetween }
|
|
1217
1394
|
}
|
|
1218
1395
|
}
|
|
1219
1396
|
return CGSize(width: width, height: innerHeight ?? height)
|
|
1220
1397
|
}
|
|
1221
1398
|
}
|
|
1222
1399
|
|
|
1400
|
+
/// A flex container that responds to the controller's keyboard overlap.
|
|
1401
|
+
/// `padding` keeps the content visible without moving the field; `position`
|
|
1402
|
+
/// lifts the whole container; `height` reduces its viewport.
|
|
1403
|
+
private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
|
|
1404
|
+
private var authoredPadding = UIEdgeInsets.zero
|
|
1405
|
+
private var keyboardInset: CGFloat = 0
|
|
1406
|
+
private var keyboardVerticalOffset: CGFloat = 0
|
|
1407
|
+
private var keyboardBehavior = "padding"
|
|
1408
|
+
private var appliedHeightInset: CGFloat = 0
|
|
1409
|
+
private var adjustingHeight = false
|
|
1410
|
+
|
|
1411
|
+
func configureKeyboard(props: [String: Any]) {
|
|
1412
|
+
let requestedBehavior = (props["behavior"] as? String) ?? "padding"
|
|
1413
|
+
keyboardBehavior = ["height", "position", "padding"].contains(requestedBehavior) ? requestedBehavior : "padding"
|
|
1414
|
+
keyboardVerticalOffset = (props["keyboardVerticalOffset"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 0
|
|
1415
|
+
if keyboardBehavior != "height" { appliedHeightInset = 0 }
|
|
1416
|
+
authoredPadding = padding
|
|
1417
|
+
applyKeyboardAvoidance()
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
func setKeyboardInset(_ inset: CGFloat) {
|
|
1421
|
+
guard abs(keyboardInset - inset) > 0.5 else { return }
|
|
1422
|
+
keyboardInset = max(0, inset)
|
|
1423
|
+
applyKeyboardAvoidance()
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
private var effectiveInset: CGFloat { max(0, keyboardInset - keyboardVerticalOffset) }
|
|
1427
|
+
|
|
1428
|
+
private func applyKeyboardAvoidance() {
|
|
1429
|
+
let inset = effectiveInset
|
|
1430
|
+
switch keyboardBehavior {
|
|
1431
|
+
case "position":
|
|
1432
|
+
transform = CGAffineTransform(translationX: 0, y: -inset)
|
|
1433
|
+
padding = authoredPadding
|
|
1434
|
+
case "height":
|
|
1435
|
+
transform = .identity
|
|
1436
|
+
padding = authoredPadding
|
|
1437
|
+
default:
|
|
1438
|
+
transform = .identity
|
|
1439
|
+
padding = UIEdgeInsets(
|
|
1440
|
+
top: authoredPadding.top,
|
|
1441
|
+
left: authoredPadding.left,
|
|
1442
|
+
bottom: authoredPadding.bottom + inset,
|
|
1443
|
+
right: authoredPadding.right
|
|
1444
|
+
)
|
|
1445
|
+
}
|
|
1446
|
+
invalidateMeasurements()
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
override func layoutSubviews() {
|
|
1450
|
+
if keyboardBehavior == "height", !adjustingHeight {
|
|
1451
|
+
let inset = effectiveInset
|
|
1452
|
+
let target = max(0, bounds.height + appliedHeightInset - inset)
|
|
1453
|
+
if abs(target - bounds.height) > 0.5 {
|
|
1454
|
+
adjustingHeight = true
|
|
1455
|
+
appliedHeightInset = inset
|
|
1456
|
+
bounds.size.height = target
|
|
1457
|
+
super.layoutSubviews()
|
|
1458
|
+
adjustingHeight = false
|
|
1459
|
+
return
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
super.layoutSubviews()
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/// An image-backed flex container. The image stays behind ordinary native
|
|
1467
|
+
/// children so `ImageBackground` keeps the same layout and interaction
|
|
1468
|
+
/// semantics as a `View` while using the shared image loader.
|
|
1469
|
+
private final class CraftNativeImageBackgroundView: UIImageView {
|
|
1470
|
+
let contentStack = CraftNativeFlowView()
|
|
1471
|
+
|
|
1472
|
+
override init(frame: CGRect) {
|
|
1473
|
+
super.init(frame: frame)
|
|
1474
|
+
isUserInteractionEnabled = true
|
|
1475
|
+
contentMode = .scaleAspectFill
|
|
1476
|
+
clipsToBounds = true
|
|
1477
|
+
contentStack.backgroundColor = .clear
|
|
1478
|
+
addSubview(contentStack)
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
1482
|
+
|
|
1483
|
+
override func layoutSubviews() {
|
|
1484
|
+
super.layoutSubviews()
|
|
1485
|
+
contentStack.frame = bounds
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1223
1489
|
private final class CraftNativeModalView: UIView {
|
|
1224
1490
|
let contentStack = CraftNativeFlowView()
|
|
1225
1491
|
private let blocker = UIView()
|
|
@@ -1876,6 +2142,13 @@ private final class CraftNativeButton: UIButton {
|
|
|
1876
2142
|
}
|
|
1877
2143
|
}
|
|
1878
2144
|
|
|
2145
|
+
/// A native checkbox button. UIKit has no checkbox control, so use the system
|
|
2146
|
+
/// square/checkmark symbols while keeping the control's title and accessibility
|
|
2147
|
+
/// state native and keyboard/touch reachable.
|
|
2148
|
+
private final class CraftNativeCheckboxButton: UIButton {
|
|
2149
|
+
var indeterminate = false
|
|
2150
|
+
}
|
|
2151
|
+
|
|
1879
2152
|
private final class CraftNativeLabel: UILabel {
|
|
1880
2153
|
var insets = UIEdgeInsets.zero {
|
|
1881
2154
|
didSet {
|
|
@@ -2052,6 +2325,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2052
2325
|
private var imageSources: [ObjectIdentifier: String] = [:]
|
|
2053
2326
|
private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
|
|
2054
2327
|
private var imageErrors: [ObjectIdentifier: String] = [:]
|
|
2328
|
+
private var checkboxIndeterminate: [ObjectIdentifier: Bool] = [:]
|
|
2055
2329
|
private var tintedImages = Set<ObjectIdentifier>()
|
|
2056
2330
|
private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
|
|
2057
2331
|
private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -2967,12 +3241,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2967
3241
|
}
|
|
2968
3242
|
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
|
|
2969
3243
|
let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
|
|
2970
|
-
let
|
|
3244
|
+
let globalInset = containsKeyboardAvoidingView() ? 0 : inset
|
|
3245
|
+
let safeAreaChanged = abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5
|
|
2971
3246
|
keyboardSafeAreaInset = inset
|
|
2972
3247
|
keyboardScreenFrame = keyboardFrame
|
|
3248
|
+
updateKeyboardAvoidingViews()
|
|
2973
3249
|
UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
|
|
2974
|
-
if
|
|
2975
|
-
self.additionalSafeAreaInsets.bottom =
|
|
3250
|
+
if safeAreaChanged {
|
|
3251
|
+
self.additionalSafeAreaInsets.bottom = globalInset
|
|
2976
3252
|
self.view.layoutIfNeeded()
|
|
2977
3253
|
}
|
|
2978
3254
|
self.insetScrollViewsForKeyboard()
|
|
@@ -2980,6 +3256,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2980
3256
|
}
|
|
2981
3257
|
}
|
|
2982
3258
|
|
|
3259
|
+
private func updateKeyboardAvoidingViews() {
|
|
3260
|
+
func visit(_ node: RenderedNode?) {
|
|
3261
|
+
guard let node else { return }
|
|
3262
|
+
if let keyboard = node.view as? CraftNativeKeyboardAvoidingView {
|
|
3263
|
+
keyboard.setKeyboardInset(keyboardSafeAreaInset)
|
|
3264
|
+
}
|
|
3265
|
+
node.children.forEach(visit)
|
|
3266
|
+
}
|
|
3267
|
+
visit(renderedRoot)
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
private func containsKeyboardAvoidingView() -> Bool {
|
|
3271
|
+
func visit(_ node: RenderedNode?) -> Bool {
|
|
3272
|
+
guard let node else { return false }
|
|
3273
|
+
if node.view is CraftNativeKeyboardAvoidingView { return true }
|
|
3274
|
+
return node.children.contains(where: visit)
|
|
3275
|
+
}
|
|
3276
|
+
return visit(renderedRoot)
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
private func syncGlobalKeyboardSafeArea() {
|
|
3280
|
+
let globalInset = containsKeyboardAvoidingView() ? 0 : keyboardSafeAreaInset
|
|
3281
|
+
guard abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5 else { return }
|
|
3282
|
+
additionalSafeAreaInsets.bottom = globalInset
|
|
3283
|
+
view.setNeedsLayout()
|
|
3284
|
+
}
|
|
3285
|
+
|
|
2983
3286
|
/// A scroll view the keyboard still covers part of (one that does not end
|
|
2984
3287
|
/// at the screen's bottom edge, or a keyboard taller than the safe area
|
|
2985
3288
|
/// took account of) insets its content by that much, so everything in it
|
|
@@ -3318,6 +3621,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3318
3621
|
rootStack.setLayoutStyle(rootStyle, for: next.view)
|
|
3319
3622
|
rootStack.invalidateMeasurements()
|
|
3320
3623
|
renderedRoot = next
|
|
3624
|
+
updateKeyboardAvoidingViews()
|
|
3625
|
+
syncGlobalKeyboardSafeArea()
|
|
3321
3626
|
trackContentScrollView()
|
|
3322
3627
|
restoreFocus(focused)
|
|
3323
3628
|
}
|
|
@@ -3325,6 +3630,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3325
3630
|
private func clearRenderedTree() {
|
|
3326
3631
|
if let root = renderedRoot { detach(root, from: rootStack) }
|
|
3327
3632
|
renderedRoot = nil
|
|
3633
|
+
syncGlobalKeyboardSafeArea()
|
|
3328
3634
|
}
|
|
3329
3635
|
|
|
3330
3636
|
private func firstResponder(in view: UIView) -> UIView? {
|
|
@@ -3341,7 +3647,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3341
3647
|
}
|
|
3342
3648
|
|
|
3343
3649
|
private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
|
|
3344
|
-
let type = node["type"] as? String ?? "View"
|
|
3650
|
+
let type = canonicalType(node["type"] as? String ?? "View")
|
|
3345
3651
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
3346
3652
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
3347
3653
|
let events = node["events"] as? [String: String] ?? [:]
|
|
@@ -3359,7 +3665,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3359
3665
|
current.protocolId = node["id"] as? String
|
|
3360
3666
|
current.style = style
|
|
3361
3667
|
result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
|
|
3362
|
-
if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
|
|
3668
|
+
if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
|
|
3363
3669
|
result.setContentHuggingPriority(.required, for: .vertical)
|
|
3364
3670
|
}
|
|
3365
3671
|
applyViewStyle(style, to: result, node: current)
|
|
@@ -3391,6 +3697,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3391
3697
|
(button as? CraftNativeButton)?.padding = paddingInsets(style)
|
|
3392
3698
|
button.isEnabled = props["disabled"] as? Bool != true
|
|
3393
3699
|
updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
|
|
3700
|
+
case "Checkbox":
|
|
3701
|
+
let checkbox = result as! CraftNativeCheckboxButton
|
|
3702
|
+
let label = props["label"] as? String ?? children.compactMap { $0 as? String }.joined()
|
|
3703
|
+
let transformedLabel = transformedText(label, style: style)
|
|
3704
|
+
let checked = (props["checked"] as? Bool) ?? (props["value"] as? Bool) ?? false
|
|
3705
|
+
let indeterminate = props["indeterminate"] as? Bool == true
|
|
3706
|
+
checkbox.isSelected = checked
|
|
3707
|
+
checkbox.indeterminate = indeterminate
|
|
3708
|
+
checkboxIndeterminate[ObjectIdentifier(checkbox)] = indeterminate
|
|
3709
|
+
checkbox.setTitle(transformedLabel, for: .normal)
|
|
3710
|
+
checkbox.setAttributedTitle(NSAttributedString(
|
|
3711
|
+
string: transformedLabel,
|
|
3712
|
+
attributes: buttonTitleAttributes(style, color: color(props["color"]) ?? color(style["color"]) ?? .label, font: textFont(style, default: .systemFont(ofSize: 16)), forceUnderline: false)
|
|
3713
|
+
), for: .normal)
|
|
3714
|
+
let uncheckedImage = UIImage(systemName: indeterminate ? "minus.square" : "square")
|
|
3715
|
+
checkbox.setImage(uncheckedImage, for: .normal)
|
|
3716
|
+
checkbox.setImage(
|
|
3717
|
+
UIImage(systemName: indeterminate ? "minus.square" : "checkmark.square.fill"),
|
|
3718
|
+
for: .selected
|
|
3719
|
+
)
|
|
3720
|
+
checkbox.tintColor = color(props["color"]) ?? color(style["color"]) ?? .systemBlue
|
|
3721
|
+
checkbox.isEnabled = props["disabled"] as? Bool != true
|
|
3722
|
+
updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: checkbox)
|
|
3394
3723
|
case "TextInput":
|
|
3395
3724
|
if let textView = result as? UITextView {
|
|
3396
3725
|
inputIdentities[ObjectIdentifier(textView)] = current.identity
|
|
@@ -3490,7 +3819,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3490
3819
|
picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
|
|
3491
3820
|
picker.minimumDate = Self.pickerDate(props["minimumDate"])
|
|
3492
3821
|
picker.maximumDate = Self.pickerDate(props["maximumDate"])
|
|
3493
|
-
if
|
|
3822
|
+
if mode == .countDownTimer {
|
|
3823
|
+
if let duration = Self.countdownDuration(props["value"]), abs(picker.countDownDuration - duration) >= 1 {
|
|
3824
|
+
picker.countDownDuration = duration
|
|
3825
|
+
}
|
|
3826
|
+
} else if let date = Self.pickerDate(props["value"]), abs(picker.date.timeIntervalSince(date)) >= 1 {
|
|
3494
3827
|
picker.setDate(date, animated: false)
|
|
3495
3828
|
}
|
|
3496
3829
|
picker.tintColor = color(props["color"]) ?? color(style["color"])
|
|
@@ -3611,6 +3944,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3611
3944
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
3612
3945
|
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
|
|
3613
3946
|
updateImage(image, source: props["source"])
|
|
3947
|
+
case "ImageBackground":
|
|
3948
|
+
let background = result as! CraftNativeImageBackgroundView
|
|
3949
|
+
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
3950
|
+
background.contentMode = imageContentMode(requestedResizeMode)
|
|
3951
|
+
background.clipsToBounds = background.contentMode == .scaleAspectFill
|
|
3952
|
+
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
|
|
3953
|
+
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
|
|
3954
|
+
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
|
|
3955
|
+
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: background)
|
|
3956
|
+
updateImage(background, source: props["source"])
|
|
3957
|
+
configureStack(background.contentStack, style: style)
|
|
3958
|
+
reconcileChildren(children, in: background.contentStack, parent: current, path: path, style: style)
|
|
3959
|
+
case "KeyboardAvoidingView":
|
|
3960
|
+
let keyboard = result as! CraftNativeKeyboardAvoidingView
|
|
3961
|
+
configureStack(keyboard, style: style)
|
|
3962
|
+
keyboard.configureKeyboard(props: props)
|
|
3963
|
+
reconcileChildren(children, in: keyboard, parent: current, path: path, style: style)
|
|
3614
3964
|
case "Video":
|
|
3615
3965
|
let video = result as! CraftNativeVideoView
|
|
3616
3966
|
let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
|
|
@@ -3627,7 +3977,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3627
3977
|
controls: (props["controls"] as? Bool) == true,
|
|
3628
3978
|
resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
3629
3979
|
)
|
|
3630
|
-
case "ScrollView":
|
|
3980
|
+
case "ScrollView", "SectionList":
|
|
3631
3981
|
let scroll = result as! CraftNativeScrollView
|
|
3632
3982
|
let flexDirection = style["flexDirection"] as? String
|
|
3633
3983
|
let direction = (props["horizontal"] as? Bool) == true
|
|
@@ -3677,7 +4027,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3677
4027
|
reconcileChildren(children, in: stack, parent: current, path: path, style: style)
|
|
3678
4028
|
}
|
|
3679
4029
|
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" {
|
|
4030
|
+
if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
|
|
3681
4031
|
let disabled = props["disabled"] as? Bool == true
|
|
3682
4032
|
updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
|
|
3683
4033
|
updatePressFeedback(
|
|
@@ -3701,6 +4051,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3701
4051
|
let button = CraftNativeButton(type: .system)
|
|
3702
4052
|
button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
|
|
3703
4053
|
return button
|
|
4054
|
+
case "Checkbox":
|
|
4055
|
+
let checkbox = CraftNativeCheckboxButton(type: .system)
|
|
4056
|
+
checkbox.semanticContentAttribute = .forceLeftToRight
|
|
4057
|
+
checkbox.contentHorizontalAlignment = .leading
|
|
4058
|
+
checkbox.addTarget(self, action: #selector(checkboxPressed(_:)), for: .touchUpInside)
|
|
4059
|
+
return checkbox
|
|
3704
4060
|
case "TextInput":
|
|
3705
4061
|
if props["multiline"] as? Bool == true {
|
|
3706
4062
|
let textView = CraftNativeTextView()
|
|
@@ -3738,13 +4094,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3738
4094
|
return UIActivityIndicatorView(style: .medium)
|
|
3739
4095
|
case "Image":
|
|
3740
4096
|
return UIImageView()
|
|
4097
|
+
case "ImageBackground":
|
|
4098
|
+
return CraftNativeImageBackgroundView(frame: .zero)
|
|
4099
|
+
case "KeyboardAvoidingView":
|
|
4100
|
+
return CraftNativeKeyboardAvoidingView()
|
|
3741
4101
|
case "Video":
|
|
3742
4102
|
return CraftNativeVideoView(host: self)
|
|
3743
4103
|
case "Icon":
|
|
3744
4104
|
let icon = CraftNativeIconView()
|
|
3745
4105
|
icon.contentMode = .scaleAspectFit
|
|
3746
4106
|
return icon
|
|
3747
|
-
case "ScrollView":
|
|
4107
|
+
case "ScrollView", "SectionList":
|
|
3748
4108
|
return CraftNativeScrollView()
|
|
3749
4109
|
case "FlatList":
|
|
3750
4110
|
return CraftNativeFlatList()
|
|
@@ -3759,12 +4119,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3759
4119
|
}
|
|
3760
4120
|
}
|
|
3761
4121
|
|
|
4122
|
+
private func canonicalType(_ raw: String) -> String {
|
|
4123
|
+
switch raw.lowercased() {
|
|
4124
|
+
case "view": return "View"
|
|
4125
|
+
case "safeareaview": return "SafeAreaView"
|
|
4126
|
+
case "keyboardavoidingview": return "KeyboardAvoidingView"
|
|
4127
|
+
case "text": return "Text"
|
|
4128
|
+
case "button": return "Button"
|
|
4129
|
+
case "link": return "Link"
|
|
4130
|
+
case "checkbox": return "Checkbox"
|
|
4131
|
+
case "textinput", "input": return "TextInput"
|
|
4132
|
+
case "picker", "select": return "Picker"
|
|
4133
|
+
case "modal": return "Modal"
|
|
4134
|
+
case "switch": return "Switch"
|
|
4135
|
+
case "slider": return "Slider"
|
|
4136
|
+
case "activityindicator": return "ActivityIndicator"
|
|
4137
|
+
case "image": return "Image"
|
|
4138
|
+
case "imagebackground": return "ImageBackground"
|
|
4139
|
+
case "video": return "Video"
|
|
4140
|
+
case "icon": return "Icon"
|
|
4141
|
+
case "scrollview": return "ScrollView"
|
|
4142
|
+
case "sectionlist": return "SectionList"
|
|
4143
|
+
case "flatlist": return "FlatList"
|
|
4144
|
+
case "svg": return "Svg"
|
|
4145
|
+
case "touchableopacity": return "TouchableOpacity"
|
|
4146
|
+
case "touchablehighlight": return "TouchableHighlight"
|
|
4147
|
+
case "pressable": return "Pressable"
|
|
4148
|
+
default: return raw
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
|
|
3762
4152
|
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
3763
4153
|
// The current @stacksjs/stx native compiler emits key in props; accept the IR
|
|
3764
4154
|
// field too so keyed children keep working when the compiler adopts it.
|
|
3765
|
-
[node["key"], props["key"], props["testID"]
|
|
4155
|
+
let direct = [node["key"], props["key"], props["testID"]]
|
|
3766
4156
|
.compactMap { $0 as? String }
|
|
3767
4157
|
.first { !$0.isEmpty }
|
|
4158
|
+
if let direct { return direct }
|
|
4159
|
+
for child in node["children"] as? [[String: Any]] ?? [] {
|
|
4160
|
+
let childProps = child["props"] as? [String: Any] ?? [:]
|
|
4161
|
+
if let nested = explicitKey(child, props: childProps) { return nested }
|
|
4162
|
+
}
|
|
4163
|
+
return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
3768
4164
|
}
|
|
3769
4165
|
|
|
3770
4166
|
private func accessibilityIdentifier(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
@@ -3983,6 +4379,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3983
4379
|
imageLoadHandlers.removeValue(forKey: id)
|
|
3984
4380
|
imageLoadEndHandlers.removeValue(forKey: id)
|
|
3985
4381
|
imageErrorHandlers.removeValue(forKey: id)
|
|
4382
|
+
checkboxIndeterminate.removeValue(forKey: id)
|
|
3986
4383
|
if let video = node.view as? CraftNativeVideoView { video.stop() }
|
|
3987
4384
|
for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
|
|
3988
4385
|
}
|
|
@@ -4248,7 +4645,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4248
4645
|
stack.columnGap = number(style["columnGap"]) ?? gap
|
|
4249
4646
|
stack.grid = style["display"] as? String == "grid"
|
|
4250
4647
|
stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
|
|
4251
|
-
stack.gridAutoRows =
|
|
4648
|
+
stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
|
|
4252
4649
|
stack.padding = paddingInsets(style)
|
|
4253
4650
|
stack.invalidateMeasurements()
|
|
4254
4651
|
}
|
|
@@ -4572,7 +4969,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4572
4969
|
|
|
4573
4970
|
private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
|
|
4574
4971
|
view.accessibilityLabel = (props["accessibilityLabel"] as? String)
|
|
4575
|
-
?? (type == "Image" ? props["alt"] as? String : nil)
|
|
4972
|
+
?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
|
|
4576
4973
|
view.accessibilityHint = props["accessibilityHint"] as? String
|
|
4577
4974
|
if let value = props["accessibilityValue"] {
|
|
4578
4975
|
view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
|
|
@@ -4580,10 +4977,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4580
4977
|
view.accessibilityValue = nil
|
|
4581
4978
|
}
|
|
4582
4979
|
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))
|
|
4980
|
+
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
4584
4981
|
var traits: UIAccessibilityTraits = []
|
|
4585
4982
|
switch role ?? type.lowercased() {
|
|
4586
|
-
case "button": traits.insert(.button)
|
|
4983
|
+
case "button", "checkbox": traits.insert(.button)
|
|
4587
4984
|
case "image": traits.insert(.image)
|
|
4588
4985
|
case "header": traits.insert(.header)
|
|
4589
4986
|
case "link": traits.insert(.link)
|
|
@@ -4603,7 +5000,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4603
5000
|
if state["checked"] as? Bool == true { traits.insert(.selected) }
|
|
4604
5001
|
}
|
|
4605
5002
|
if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
|
|
4606
|
-
if type == "
|
|
5003
|
+
if type == "Checkbox", let checkbox = view as? CraftNativeCheckboxButton {
|
|
5004
|
+
if checkbox.isSelected { traits.insert(.selected) }
|
|
5005
|
+
view.accessibilityValue = checkbox.indeterminate ? "mixed" : checkbox.isSelected ? "checked" : "unchecked"
|
|
5006
|
+
}
|
|
5007
|
+
if (type == "Image" || type == "ImageBackground"), let message = imageErrors[ObjectIdentifier(view)] {
|
|
4607
5008
|
let current = view.accessibilityValue
|
|
4608
5009
|
if current?.contains(message) != true {
|
|
4609
5010
|
view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
|
|
@@ -4638,6 +5039,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4638
5039
|
}
|
|
4639
5040
|
}
|
|
4640
5041
|
|
|
5042
|
+
@objc private func checkboxPressed(_ sender: CraftNativeCheckboxButton) {
|
|
5043
|
+
sender.indeterminate = false
|
|
5044
|
+
checkboxIndeterminate[ObjectIdentifier(sender)] = false
|
|
5045
|
+
sender.isSelected.toggle()
|
|
5046
|
+
if let handler = handlers[ObjectIdentifier(sender)] {
|
|
5047
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isSelected]])
|
|
5048
|
+
}
|
|
5049
|
+
applyAccessibility([:], type: "Checkbox", to: sender)
|
|
5050
|
+
}
|
|
5051
|
+
|
|
4641
5052
|
/// Return on a `returnKeyType="next"` field the screen does not handle
|
|
4642
5053
|
/// itself (no `@submit`) goes to the next field down the screen, keeping
|
|
4643
5054
|
/// the keyboard up. Every other Return ends editing and fires `@submit`.
|
|
@@ -4737,7 +5148,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4737
5148
|
/// milliseconds for code that wants those.
|
|
4738
5149
|
@objc private func datePicked(_ sender: UIDatePicker) {
|
|
4739
5150
|
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
4740
|
-
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender
|
|
5151
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender)])
|
|
5152
|
+
}
|
|
5153
|
+
|
|
5154
|
+
static func datePickerEvent(_ picker: UIDatePicker) -> [String: Any] {
|
|
5155
|
+
if picker.datePickerMode == .countDownTimer { return countdownEvent(picker.countDownDuration) }
|
|
5156
|
+
return datePickerEvent(picker.date, mode: picker.datePickerMode)
|
|
5157
|
+
}
|
|
5158
|
+
|
|
5159
|
+
static func countdownEvent(_ duration: TimeInterval) -> [String: Any] {
|
|
5160
|
+
let totalSeconds = max(0, Int(duration.rounded()))
|
|
5161
|
+
let hours = totalSeconds / 3600
|
|
5162
|
+
let minutes = (totalSeconds % 3600) / 60
|
|
5163
|
+
let seconds = totalSeconds % 60
|
|
5164
|
+
return [
|
|
5165
|
+
"value": String(format: "%02d:%02d:%02d", hours, minutes, seconds),
|
|
5166
|
+
"iso": "PT\(totalSeconds)S",
|
|
5167
|
+
"timestamp": Double(totalSeconds * 1000),
|
|
5168
|
+
]
|
|
4741
5169
|
}
|
|
4742
5170
|
|
|
4743
5171
|
static func datePickerEvent(_ date: Date, mode: UIDatePicker.Mode) -> [String: Any] {
|
|
@@ -4764,6 +5192,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4764
5192
|
}
|
|
4765
5193
|
}
|
|
4766
5194
|
|
|
5195
|
+
/// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
|
|
5196
|
+
static func countdownDuration(_ value: Any?) -> TimeInterval? {
|
|
5197
|
+
let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
|
|
5198
|
+
if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
|
|
5199
|
+
guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
|
|
5200
|
+
if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
|
|
5201
|
+
let parts = text.split(separator: ":").compactMap { Double($0) }
|
|
5202
|
+
guard parts.count == 2 || parts.count == 3 else { return nil }
|
|
5203
|
+
let hours = parts[0]
|
|
5204
|
+
let minutes = parts[1]
|
|
5205
|
+
let seconds = parts.count == 3 ? parts[2] : 0
|
|
5206
|
+
guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
|
|
5207
|
+
return min(maximum, hours * 3600 + minutes * 60 + seconds)
|
|
5208
|
+
}
|
|
5209
|
+
|
|
4767
5210
|
/// A DatePicker's value: `2026-10-12` (that day), `07:30` (today at that
|
|
4768
5211
|
/// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
|
|
4769
5212
|
/// milliseconds since 1970. Nil for anything else.
|