craft-native 0.0.125 → 0.0.127
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/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftHybrid.swift +21 -3
- package/dist/ios/templates/CraftNativeScreen.swift +203 -12
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.127";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -402,12 +402,17 @@ final class CraftHybrid: NSObject {
|
|
|
402
402
|
navigate = function () { return own.apply(router, arguments); };
|
|
403
403
|
// A forward navigation to a native path is the shell's to show; the
|
|
404
404
|
// page draws nothing. Back (pushState false), tab switches and the
|
|
405
|
-
// shell's own navigations go through untouched.
|
|
405
|
+
// shell's own navigations go through untouched. A redirect (replace),
|
|
406
|
+
// as after signing in, is the page leaving where it was: the shell
|
|
407
|
+
// shows the native screen and the page follows it there behind it,
|
|
408
|
+
// or it stayed on the sign-in page and the next screen pushed from it
|
|
409
|
+
// came up blank.
|
|
406
410
|
router.navigate = router.navigateTo = function (url, pushState, force) {
|
|
407
411
|
var path = local(url);
|
|
408
412
|
if (path !== null && pushState !== false && pushState !== 'tab' && hostNav === null && match(path)) {
|
|
409
413
|
var replace = pushState === 'replace' || !!(pushState && typeof pushState === 'object' && pushState.replace);
|
|
410
414
|
post({ type: 'navigateNative', path: path, replace: replace });
|
|
415
|
+
if (replace) return own.apply(router, arguments);
|
|
411
416
|
return Promise.resolve(false);
|
|
412
417
|
}
|
|
413
418
|
return own.apply(router, arguments);
|
|
@@ -441,6 +446,16 @@ final class CraftHybrid: NSObject {
|
|
|
441
446
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
442
447
|
var link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
443
448
|
if (!link) return;
|
|
449
|
+
// The bar's Back (data-native-back). Its href is only where to go
|
|
450
|
+
// without history, often a native path like the calendar; at the
|
|
451
|
+
// entry a native screen opened, Back is that screen.
|
|
452
|
+
if (link.hasAttribute('data-native-back')) {
|
|
453
|
+
if (!atBase()) return;
|
|
454
|
+
event.preventDefault();
|
|
455
|
+
event.stopPropagation();
|
|
456
|
+
post({ type: 'back' });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
444
459
|
if ((link.target && link.target !== '_self') || link.hasAttribute('download') || link.hasAttribute('data-native-tab')
|
|
445
460
|
|| link.getAttribute('data-stx-nav') === 'tab' || link.hasAttribute('data-craft-web')) return;
|
|
446
461
|
var path = local(link.href);
|
|
@@ -1077,8 +1092,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1077
1092
|
|
|
1078
1093
|
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
|
1079
1094
|
// The page draws its own header, and a tab's root screen its own
|
|
1080
|
-
// title; a pushed native screen gets the
|
|
1081
|
-
|
|
1095
|
+
// title unless it asked for the bar; a pushed native screen gets the
|
|
1096
|
+
// bar and its Back.
|
|
1097
|
+
// A root screen that asked for its own bar (setOptions) keeps it.
|
|
1098
|
+
let asksForBar = (viewController as? CraftNativeScreenController)?.wantsNavigationBar ?? false
|
|
1099
|
+
let bare = viewController is CraftHybridWebSlot || (navigationController.viewControllers.first === viewController && !asksForBar)
|
|
1082
1100
|
navigationController.setNavigationBarHidden(bare, animated: animated)
|
|
1083
1101
|
}
|
|
1084
1102
|
|
|
@@ -213,6 +213,14 @@ enum CraftNativeFlexLayout {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/// A size rounded up to whole pixels, never below `atLeast`.
|
|
217
|
+
static func ceiled(_ size: CGSize, scale: CGFloat, atLeast floor: CGSize) -> CGSize {
|
|
218
|
+
let scale = max(1, scale)
|
|
219
|
+
// Less a hair, so a size already on the pixel grid stays put.
|
|
220
|
+
func up(_ value: CGFloat) -> CGFloat { (value * scale - 0.001).rounded(.up) / scale }
|
|
221
|
+
return CGSize(width: max(floor.width, up(size.width)), height: max(floor.height, up(size.height)))
|
|
222
|
+
}
|
|
223
|
+
|
|
216
224
|
/// A frame snapped to the screen's pixels edge by edge, so neighbours
|
|
217
225
|
/// neither overlap nor leave a hairline between them.
|
|
218
226
|
static func snapped(_ rect: CGRect, scale: CGFloat) -> CGRect {
|
|
@@ -358,8 +366,23 @@ private final class CraftNativeFlowView: UIStackView {
|
|
|
358
366
|
let scale = window?.screen.scale ?? traitCollection.displayScale
|
|
359
367
|
let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
|
|
360
368
|
for (child, frame) in result.frames {
|
|
361
|
-
|
|
362
|
-
|
|
369
|
+
var snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
|
|
370
|
+
// Snapping each edge can take a pixel off a label (15.83…33.83
|
|
371
|
+
// becomes 16…33.67), and a label a pixel narrower than its text
|
|
372
|
+
// truncates it ("13" drew as "…"). Text keeps its whole size.
|
|
373
|
+
if child is UILabel {
|
|
374
|
+
snapped.size = CraftNativeFlexLayout.ceiled(frame.size, scale: scale, atLeast: snapped.size)
|
|
375
|
+
}
|
|
376
|
+
if child.transform.isIdentity {
|
|
377
|
+
if child.frame != snapped { child.frame = snapped }
|
|
378
|
+
} else {
|
|
379
|
+
// A transformed child (a sticky header, a scaled spinner) is
|
|
380
|
+
// placed by bounds and center: its frame is undefined then.
|
|
381
|
+
let size = CGRect(origin: child.bounds.origin, size: snapped.size)
|
|
382
|
+
let center = CGPoint(x: snapped.midX, y: snapped.midY)
|
|
383
|
+
if child.bounds != size { child.bounds = size }
|
|
384
|
+
if child.center != center { child.center = center }
|
|
385
|
+
}
|
|
363
386
|
child.craftApplyCornerRadius()
|
|
364
387
|
}
|
|
365
388
|
}
|
|
@@ -832,18 +855,144 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
832
855
|
override func layoutSubviews() {
|
|
833
856
|
super.layoutSubviews()
|
|
834
857
|
craftApplyCornerRadius()
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
858
|
+
if contentNeedsLayout || laidOutSize != bounds.size {
|
|
859
|
+
contentNeedsLayout = false
|
|
860
|
+
laidOutSize = bounds.size
|
|
861
|
+
var size = contentFitting(width: bounds.width, height: bounds.height)
|
|
862
|
+
if isVertical {
|
|
863
|
+
size.width = max(0, bounds.width - adjustedContentInset.left - adjustedContentInset.right)
|
|
864
|
+
} else {
|
|
865
|
+
size.height = max(0, bounds.height - adjustedContentInset.top - adjustedContentInset.bottom)
|
|
866
|
+
}
|
|
867
|
+
let frame = CGRect(origin: .zero, size: size)
|
|
868
|
+
if contentStack.frame != frame { contentStack.frame = frame }
|
|
869
|
+
if contentSize != size { contentSize = size }
|
|
870
|
+
}
|
|
871
|
+
// A scroll view lays out on every frame of a scroll: the sticky
|
|
872
|
+
// headers follow it here, over content placed by this same pass.
|
|
873
|
+
if !stickyIndices.isEmpty || pendingScrollTarget != nil { contentStack.layoutIfNeeded() }
|
|
874
|
+
updateStickyHeaders()
|
|
875
|
+
scrollToPendingTarget()
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// MARK: Sticky headers
|
|
879
|
+
|
|
880
|
+
private var stickyIndices: [Int] = []
|
|
881
|
+
private var stickyViews: [UIView] = []
|
|
882
|
+
|
|
883
|
+
/// `stickyHeaderIndices`, as in React Native: the content's children at
|
|
884
|
+
/// these indices stay at the top of the visible area once scrolled to,
|
|
885
|
+
/// each pushed up by the next. Vertical scroll views only.
|
|
886
|
+
func setStickyHeaderIndices(_ value: Any?) {
|
|
887
|
+
let next = Array(Set(((value as? [Any]) ?? []).compactMap { ($0 as? NSNumber)?.intValue }.filter { $0 >= 0 })).sorted()
|
|
888
|
+
guard next != stickyIndices else { return }
|
|
889
|
+
stickyIndices = next
|
|
890
|
+
setNeedsLayout()
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/// Where a header sits in the content when it is not stuck.
|
|
894
|
+
private func naturalTop(_ view: UIView) -> CGFloat {
|
|
895
|
+
view.center.y - view.bounds.height / 2
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private func updateStickyHeaders() {
|
|
899
|
+
let children = contentStack.arrangedSubviews
|
|
900
|
+
let headers = isVertical ? stickyIndices.compactMap { $0 < children.count ? children[$0] : nil }.filter { !$0.isHidden } : []
|
|
901
|
+
for view in stickyViews where !headers.contains(where: { $0 === view }) {
|
|
902
|
+
view.transform = .identity
|
|
903
|
+
view.layer.zPosition = 0
|
|
904
|
+
}
|
|
905
|
+
stickyViews = headers
|
|
906
|
+
guard !headers.isEmpty else { return }
|
|
907
|
+
let visibleTop = contentOffset.y + adjustedContentInset.top
|
|
908
|
+
for (index, header) in headers.enumerated() {
|
|
909
|
+
let natural = naturalTop(header)
|
|
910
|
+
var top = max(natural, visibleTop)
|
|
911
|
+
if index + 1 < headers.count {
|
|
912
|
+
top = max(natural, min(top, naturalTop(headers[index + 1]) - header.bounds.height))
|
|
913
|
+
}
|
|
914
|
+
let shift = CGAffineTransform(translationX: 0, y: top - natural)
|
|
915
|
+
if header.transform != shift { header.transform = shift }
|
|
916
|
+
// Above the content it covers, for drawing and for touches.
|
|
917
|
+
header.layer.zPosition = 1
|
|
918
|
+
if contentStack.subviews.last !== header { contentStack.bringSubviewToFront(header) }
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/// The height a stuck header covers above a point of the content.
|
|
923
|
+
private func stickyHeight(above y: CGFloat) -> CGFloat {
|
|
924
|
+
stickyViews.last(where: { naturalTop($0) <= y })?.bounds.height ?? 0
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// MARK: Scroll targets
|
|
928
|
+
|
|
929
|
+
private var scrollTargetKey: String?
|
|
930
|
+
private var pendingScrollTarget: (id: String, animated: Bool, attempts: Int)?
|
|
931
|
+
|
|
932
|
+
/// `scrollTarget`: `"<testID>"` or `{ id, animated?, key? }`. When it
|
|
933
|
+
/// changes, the content scrolls so the view with that `testID` sits at
|
|
934
|
+
/// the top of the visible area, below any sticky header. A new `key`
|
|
935
|
+
/// scrolls to the same view again; `null` forgets the last target.
|
|
936
|
+
func setScrollTarget(_ value: Any?) {
|
|
937
|
+
var id: String?
|
|
938
|
+
var animated = true
|
|
939
|
+
if let text = value as? String {
|
|
940
|
+
id = text
|
|
941
|
+
} else if let target = value as? [String: Any] {
|
|
942
|
+
id = target["id"] as? String
|
|
943
|
+
animated = target["animated"] as? Bool ?? true
|
|
944
|
+
}
|
|
945
|
+
guard let id, !id.isEmpty else {
|
|
946
|
+
scrollTargetKey = nil
|
|
947
|
+
pendingScrollTarget = nil
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
let key: String
|
|
951
|
+
if let object = value as? [String: Any],
|
|
952
|
+
let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) {
|
|
953
|
+
key = String(decoding: data, as: UTF8.self)
|
|
954
|
+
} else {
|
|
955
|
+
key = id
|
|
956
|
+
}
|
|
957
|
+
guard key != scrollTargetKey else { return }
|
|
958
|
+
scrollTargetKey = key
|
|
959
|
+
pendingScrollTarget = (id, animated, 0)
|
|
960
|
+
setNeedsLayout()
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private func descendant(withIdentifier id: String, in view: UIView) -> UIView? {
|
|
964
|
+
for child in view.subviews {
|
|
965
|
+
if child.accessibilityIdentifier == id { return child }
|
|
966
|
+
if let match = descendant(withIdentifier: id, in: child) { return match }
|
|
967
|
+
}
|
|
968
|
+
return nil
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private func scrollToPendingTarget() {
|
|
972
|
+
guard let target = pendingScrollTarget, bounds.height > 0 else { return }
|
|
973
|
+
pendingScrollTarget = nil
|
|
974
|
+
let view = descendant(withIdentifier: target.id, in: contentStack)
|
|
975
|
+
// A target the same render created may not be in place yet (this pass
|
|
976
|
+
// can run while the render is applied): try again on the next passes.
|
|
977
|
+
guard let view, view.bounds.size != .zero else {
|
|
978
|
+
if target.attempts < 3 {
|
|
979
|
+
pendingScrollTarget = (target.id, target.animated, target.attempts + 1)
|
|
980
|
+
DispatchQueue.main.async { [weak self] in self?.setNeedsLayout() }
|
|
981
|
+
}
|
|
982
|
+
return
|
|
983
|
+
}
|
|
984
|
+
let rect = view.convert(view.bounds, to: contentStack)
|
|
839
985
|
if isVertical {
|
|
840
|
-
|
|
986
|
+
let lowest = -adjustedContentInset.top
|
|
987
|
+
let highest = max(lowest, contentSize.height + adjustedContentInset.bottom - bounds.height)
|
|
988
|
+
let y = min(highest, max(lowest, rect.minY - adjustedContentInset.top - stickyHeight(above: rect.minY)))
|
|
989
|
+
setContentOffset(CGPoint(x: contentOffset.x, y: y), animated: target.animated)
|
|
841
990
|
} else {
|
|
842
|
-
|
|
991
|
+
let lowest = -adjustedContentInset.left
|
|
992
|
+
let highest = max(lowest, contentSize.width + adjustedContentInset.right - bounds.width)
|
|
993
|
+
let x = min(highest, max(lowest, rect.minX - adjustedContentInset.left))
|
|
994
|
+
setContentOffset(CGPoint(x: x, y: contentOffset.y), animated: target.animated)
|
|
843
995
|
}
|
|
844
|
-
let frame = CGRect(origin: .zero, size: size)
|
|
845
|
-
if contentStack.frame != frame { contentStack.frame = frame }
|
|
846
|
-
if contentSize != size { contentSize = size }
|
|
847
996
|
}
|
|
848
997
|
|
|
849
998
|
override func adjustedContentInsetDidChange() {
|
|
@@ -1140,7 +1289,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1140
1289
|
if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
|
|
1141
1290
|
navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
|
|
1142
1291
|
} else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
|
|
1143
|
-
navigationController?.viewControllers.first !== self {
|
|
1292
|
+
navigationController?.viewControllers.first !== self || wantsNavigationBar {
|
|
1144
1293
|
// A screen that did not ask for a hidden bar shows one, even after
|
|
1145
1294
|
// a screen that hid its own.
|
|
1146
1295
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
|
@@ -1245,6 +1394,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1245
1394
|
};
|
|
1246
1395
|
})();
|
|
1247
1396
|
}
|
|
1397
|
+
// Repeating timers, on the one-shot ones: each tick schedules the
|
|
1398
|
+
// next before running, so a callback that throws or clears itself
|
|
1399
|
+
// still behaves, and a screen that closes cancels them with the rest.
|
|
1400
|
+
if (typeof globalThis.setInterval !== 'function') {
|
|
1401
|
+
(function() {
|
|
1402
|
+
var intervals = new Map();
|
|
1403
|
+
var nextInterval = 0;
|
|
1404
|
+
globalThis.setInterval = function(callback, delay) {
|
|
1405
|
+
var args = Array.prototype.slice.call(arguments, 2);
|
|
1406
|
+
var id = ++nextInterval;
|
|
1407
|
+
var ms = Math.max(Number(delay) || 0, 1);
|
|
1408
|
+
var tick = function() {
|
|
1409
|
+
if (!intervals.has(id)) return;
|
|
1410
|
+
intervals.set(id, globalThis.setTimeout(tick, ms));
|
|
1411
|
+
if (typeof callback === 'function') callback.apply(undefined, args);
|
|
1412
|
+
};
|
|
1413
|
+
intervals.set(id, globalThis.setTimeout(tick, ms));
|
|
1414
|
+
return id;
|
|
1415
|
+
};
|
|
1416
|
+
globalThis.clearInterval = function(id) {
|
|
1417
|
+
var timer = intervals.get(id);
|
|
1418
|
+
if (timer === undefined) return;
|
|
1419
|
+
intervals.delete(id);
|
|
1420
|
+
globalThis.clearTimeout(timer);
|
|
1421
|
+
};
|
|
1422
|
+
})();
|
|
1423
|
+
}
|
|
1248
1424
|
""")
|
|
1249
1425
|
}
|
|
1250
1426
|
|
|
@@ -1421,6 +1597,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1421
1597
|
private var rootTopToView: NSLayoutConstraint?
|
|
1422
1598
|
private var navigationBarHidden: Bool?
|
|
1423
1599
|
private var prefersLargeTitle: Bool?
|
|
1600
|
+
|
|
1601
|
+
/// Whether this screen asked for a bar of its own through `setOptions`: a
|
|
1602
|
+
/// title, a large title or buttons, and not `hidden`. A tab's root screen
|
|
1603
|
+
/// in a hybrid app is shown bare unless it did.
|
|
1604
|
+
var wantsNavigationBar: Bool {
|
|
1605
|
+
if let navigationBarHidden { return !navigationBarHidden }
|
|
1606
|
+
return titleFromOptions || prefersLargeTitle == true || !(navigationItem.rightBarButtonItems ?? []).isEmpty
|
|
1607
|
+
}
|
|
1424
1608
|
private weak var trackedScrollView: UIScrollView?
|
|
1425
1609
|
|
|
1426
1610
|
/// `craft.navigation.setOptions`: this screen's bar. Every key is
|
|
@@ -1449,6 +1633,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1449
1633
|
let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
|
|
1450
1634
|
navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
|
|
1451
1635
|
}
|
|
1636
|
+
// Asked for after the bar was hidden for a bare root screen.
|
|
1637
|
+
if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
|
|
1638
|
+
navigationController?.isNavigationBarHidden == true {
|
|
1639
|
+
navigationController?.setNavigationBarHidden(false, animated: view.window != nil)
|
|
1640
|
+
}
|
|
1452
1641
|
}
|
|
1453
1642
|
|
|
1454
1643
|
/// A bar button from `{ id, symbol | title }`. Its tap is the event
|
|
@@ -2036,6 +2225,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2036
2225
|
scroll.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool != false
|
|
2037
2226
|
scroll.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? (direction == .vertical)
|
|
2038
2227
|
scroll.alwaysBounceHorizontal = props["alwaysBounceHorizontal"] as? Bool ?? (direction == .horizontal)
|
|
2228
|
+
scroll.setStickyHeaderIndices(props["stickyHeaderIndices"])
|
|
2229
|
+
scroll.setScrollTarget(props["scrollTarget"])
|
|
2039
2230
|
updateAuxiliaryHandler(events["onScroll"], in: &scrollHandlers, for: scroll)
|
|
2040
2231
|
updateAuxiliaryHandler(events["onScrollBeginDrag"], in: &scrollBeginHandlers, for: scroll)
|
|
2041
2232
|
updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
|