craft-native 0.0.226 → 0.0.228
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/CraftNativeShell.kt.template +49 -5
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +76 -16
- package/dist/ios/templates/CraftHybrid.swift +41 -3
- package/dist/ios/templates/CraftNativeActions.swift +27 -5
- package/dist/ios/templates/CraftNativeScreen.swift +7 -0
- package/dist/ios/templates/Info.plist.template +2 -0
- package/package.json +1 -1
|
@@ -643,6 +643,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
643
643
|
private var accessorySpec: JSONObject? = null
|
|
644
644
|
private val tabViews = mutableListOf<Pair<TextView, TextView>>()
|
|
645
645
|
private val tabIconText = mutableMapOf<String, String>()
|
|
646
|
+
private val tabTitles = mutableMapOf<String, String>()
|
|
647
|
+
private val tabAccessibilityLabels = mutableMapOf<String, String>()
|
|
648
|
+
private val tabIconTints = mutableMapOf<String, Int>()
|
|
649
|
+
private val tabIconPulses = mutableSetOf<String>()
|
|
646
650
|
private val tabImageURLs = mutableMapOf<String, String>()
|
|
647
651
|
private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
|
|
648
652
|
private val navButtonSpecs = mutableMapOf<TextView, JSONObject>()
|
|
@@ -706,6 +710,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
706
710
|
cell.gravity = Gravity.CENTER
|
|
707
711
|
cell.background = rippleBackground()
|
|
708
712
|
cell.contentDescription = tab.title
|
|
713
|
+
cell.tag = "craft-tab-${tab.id}"
|
|
709
714
|
val icon = TextView(context)
|
|
710
715
|
icon.text = CraftNativeSymbols.glyph(tab.symbol)
|
|
711
716
|
icon.textSize = 22f
|
|
@@ -716,6 +721,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
716
721
|
val label = TextView(context)
|
|
717
722
|
label.text = tab.title
|
|
718
723
|
label.textSize = 12f
|
|
724
|
+
label.fontFeatureSettings = "tnum"
|
|
719
725
|
label.gravity = Gravity.CENTER
|
|
720
726
|
label.isSingleLine = true
|
|
721
727
|
cell.addView(icon, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
|
@@ -844,12 +850,25 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
844
850
|
val id = item.optString("id")
|
|
845
851
|
val index = tabs.indexOfFirst { it.id == id }
|
|
846
852
|
if (index < 0) return
|
|
847
|
-
|
|
848
|
-
|
|
853
|
+
if (item.has("accessibilityLabel")) {
|
|
854
|
+
tabAccessibilityLabels.remove(id)
|
|
855
|
+
item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() }?.let { tabAccessibilityLabels[id] = it.take(160) }
|
|
856
|
+
}
|
|
849
857
|
if (item.has("iconText")) {
|
|
850
858
|
tabIconText.remove(id)
|
|
851
859
|
item.optString("iconText").takeUnless { item.isNull("iconText") || it.isBlank() }?.let { tabIconText[id] = it.take(8) }
|
|
852
860
|
}
|
|
861
|
+
if (item.has("title")) {
|
|
862
|
+
tabTitles.remove(id)
|
|
863
|
+
item.optString("title").takeUnless { item.isNull("title") || it.isBlank() }?.let { tabTitles[id] = it.take(40) }
|
|
864
|
+
}
|
|
865
|
+
if (item.has("iconTint")) {
|
|
866
|
+
tabIconTints.remove(id)
|
|
867
|
+
if (!item.isNull("iconTint")) runCatching { Color.parseColor(item.optString("iconTint")) }.getOrNull()?.let { tabIconTints[id] = it }
|
|
868
|
+
}
|
|
869
|
+
if (item.has("iconPulse")) {
|
|
870
|
+
if (item.optBoolean("iconPulse", false)) tabIconPulses.add(id) else tabIconPulses.remove(id)
|
|
871
|
+
}
|
|
853
872
|
if (item.has("image")) {
|
|
854
873
|
val address = item.optString("image").takeIf { !item.isNull("image") && it.startsWith("https://") }
|
|
855
874
|
if (address != tabImageURLs[id]) {
|
|
@@ -896,16 +915,41 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
896
915
|
tabViews.forEachIndexed { index, (icon, label) ->
|
|
897
916
|
tabBar.getChildAt(index).isSelected = index == selectedTab
|
|
898
917
|
val color = if (index == selectedTab) accent else muted
|
|
899
|
-
val
|
|
900
|
-
|
|
918
|
+
val id = tabs[index].id
|
|
919
|
+
val number = tabIconText[id]
|
|
920
|
+
val iconColor = tabIconTints[id] ?: if (number != null) Color.parseColor("#FF2D55") else color
|
|
921
|
+
icon.setTextColor(iconColor)
|
|
922
|
+
label.text = tabTitles[id] ?: tabs[index].title
|
|
923
|
+
tabBar.getChildAt(index).contentDescription = tabAccessibilityLabels[id] ?: label.text
|
|
901
924
|
label.setTextColor(color)
|
|
902
|
-
val vector = tabImages[
|
|
925
|
+
val vector = tabImages[id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, iconColor) else null
|
|
926
|
+
val pulse = tabIconPulses.contains(id) && number == null && tabImages[id] == null && hasWindowFocus() && android.animation.ValueAnimator.areAnimatorsEnabled()
|
|
927
|
+
if (pulse && icon.animation == null) {
|
|
928
|
+
icon.startAnimation(android.view.animation.AlphaAnimation(1f, 0.55f).apply {
|
|
929
|
+
duration = 700
|
|
930
|
+
repeatCount = android.view.animation.Animation.INFINITE
|
|
931
|
+
repeatMode = android.view.animation.Animation.REVERSE
|
|
932
|
+
})
|
|
933
|
+
} else if (!pulse) {
|
|
934
|
+
icon.clearAnimation()
|
|
935
|
+
icon.alpha = 1f
|
|
936
|
+
}
|
|
903
937
|
icon.foreground = vector
|
|
904
938
|
icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
|
|
905
939
|
}
|
|
906
940
|
navButtonSpecs.forEach { (view, spec) -> applyNavButtonTint(view, spec, dark) }
|
|
907
941
|
}
|
|
908
942
|
|
|
943
|
+
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
|
944
|
+
super.onWindowFocusChanged(hasWindowFocus)
|
|
945
|
+
if (hasWindowFocus) refreshColors() else tabViews.forEach { (icon, _) -> icon.clearAnimation() }
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
override fun onDetachedFromWindow() {
|
|
949
|
+
tabViews.forEach { (icon, _) -> icon.clearAnimation() }
|
|
950
|
+
super.onDetachedFromWindow()
|
|
951
|
+
}
|
|
952
|
+
|
|
909
953
|
private fun navButton(button: JSONObject): View {
|
|
910
954
|
val id = button.optString("id")
|
|
911
955
|
val title = button.optString("title")
|
package/dist/cli.js
CHANGED
|
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3424
3424
|
`);
|
|
3425
3425
|
}
|
|
3426
3426
|
// package.json
|
|
3427
|
-
var version = "0.0.
|
|
3427
|
+
var version = "0.0.228";
|
|
3428
3428
|
|
|
3429
3429
|
// bin/cli.ts
|
|
3430
3430
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -616,6 +616,10 @@ final class CraftChrome: ObservableObject {
|
|
|
616
616
|
let symbol: String
|
|
617
617
|
var badge: String?
|
|
618
618
|
var iconText: String? = nil
|
|
619
|
+
var titleOverride: String? = nil
|
|
620
|
+
var iconTint: String? = nil
|
|
621
|
+
var iconPulse = false
|
|
622
|
+
var displayTitle: String { titleOverride ?? title }
|
|
619
623
|
var accessibilityLabel: String? = nil
|
|
620
624
|
var image: String? = nil
|
|
621
625
|
}
|
|
@@ -623,6 +627,7 @@ final class CraftChrome: ObservableObject {
|
|
|
623
627
|
@Published var tabs: [Tab] = []
|
|
624
628
|
@Published var tabAccessory: Tab?
|
|
625
629
|
@Published var selected: String?
|
|
630
|
+
@Published private(set) var tabSwipePosition: CGFloat?
|
|
626
631
|
@Published var tabBarVisible = false
|
|
627
632
|
@Published var splashVisible = true
|
|
628
633
|
@Published var tint: UIColor?
|
|
@@ -636,6 +641,7 @@ final class CraftChrome: ObservableObject {
|
|
|
636
641
|
private var pendingHide: DispatchWorkItem?
|
|
637
642
|
private var splashDeadline: DispatchWorkItem?
|
|
638
643
|
private var tabSwipePrevious: String?
|
|
644
|
+
private var tabSwipeTarget: String?
|
|
639
645
|
/// The keyboard is up and covers the tab bar, so the page need not leave
|
|
640
646
|
/// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
|
|
641
647
|
private var keyboardUp = false
|
|
@@ -673,12 +679,21 @@ final class CraftChrome: ObservableObject {
|
|
|
673
679
|
/// the next screen shown says again. The page's own hideTabBar is apart.
|
|
674
680
|
private(set) var hiddenByScreen = false
|
|
675
681
|
|
|
676
|
-
/// Change one tab's icon without replacing
|
|
682
|
+
/// Change one tab's caption or icon without replacing stacks or selection.
|
|
677
683
|
func updateTab(_ item: [String: Any]) {
|
|
678
684
|
guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
|
|
679
685
|
if item.keys.contains("iconText") {
|
|
680
686
|
tabs[index].iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
|
|
681
687
|
}
|
|
688
|
+
if item.keys.contains("title") {
|
|
689
|
+
tabs[index].titleOverride = (item["title"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(40)) }
|
|
690
|
+
}
|
|
691
|
+
if item.keys.contains("iconTint") {
|
|
692
|
+
tabs[index].iconTint = (item["iconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
|
|
693
|
+
}
|
|
694
|
+
if item.keys.contains("iconPulse") {
|
|
695
|
+
tabs[index].iconPulse = item["iconPulse"] as? Bool ?? false
|
|
696
|
+
}
|
|
682
697
|
if item.keys.contains("accessibilityLabel") {
|
|
683
698
|
tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
|
|
684
699
|
}
|
|
@@ -808,12 +823,34 @@ final class CraftChrome: ObservableObject {
|
|
|
808
823
|
// A page's pending selection report may disagree with the native
|
|
809
824
|
// stack. Cancellation belongs to the stack the finger left.
|
|
810
825
|
tabSwipePrevious = previous
|
|
811
|
-
|
|
826
|
+
tabSwipeTarget = id
|
|
827
|
+
withAnimation(nil) {
|
|
828
|
+
tabSwipePosition = tabs.firstIndex(where: { $0.id == previous }).map { CGFloat($0) }
|
|
829
|
+
selected = id
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/// The capsule shares the gesture's progress, rather than jumping to the
|
|
834
|
+
/// next item as soon as the pan recognizer begins.
|
|
835
|
+
func updateTabSwipe(progress: CGFloat) {
|
|
836
|
+
guard let previous = tabSwipePrevious, let target = tabSwipeTarget,
|
|
837
|
+
let from = tabs.firstIndex(where: { $0.id == previous }),
|
|
838
|
+
let to = tabs.firstIndex(where: { $0.id == target }) else { return }
|
|
839
|
+
let position = CGFloat(from) + CGFloat(to - from) * min(1, max(0, progress))
|
|
840
|
+
if tabSwipePosition != position { withAnimation(nil) { tabSwipePosition = position } }
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
func settleTabSwipe(completed: Bool, duration: TimeInterval) {
|
|
844
|
+
guard let id = completed ? tabSwipeTarget : tabSwipePrevious,
|
|
845
|
+
let index = tabs.firstIndex(where: { $0.id == id }) else { return }
|
|
846
|
+
withAnimation(Self.motion(.easeOut(duration: duration))) { tabSwipePosition = CGFloat(index) }
|
|
812
847
|
}
|
|
813
848
|
|
|
814
849
|
func cancelTabSwipe(restoringSelection: Bool = true) {
|
|
815
850
|
guard let previous = tabSwipePrevious else { return }
|
|
816
851
|
tabSwipePrevious = nil
|
|
852
|
+
tabSwipeTarget = nil
|
|
853
|
+
withAnimation(nil) { tabSwipePosition = nil }
|
|
817
854
|
if restoringSelection {
|
|
818
855
|
withAnimation(nil) { selected = previous }
|
|
819
856
|
}
|
|
@@ -821,6 +858,8 @@ final class CraftChrome: ObservableObject {
|
|
|
821
858
|
|
|
822
859
|
func commitTabSwipe(to id: String) {
|
|
823
860
|
tabSwipePrevious = nil
|
|
861
|
+
tabSwipeTarget = nil
|
|
862
|
+
withAnimation(nil) { tabSwipePosition = nil }
|
|
824
863
|
guard tabs.contains(where: { $0.id == id }) else { return }
|
|
825
864
|
withAnimation(nil) { selected = id }
|
|
826
865
|
UISelectionFeedbackGenerator().selectionChanged()
|
|
@@ -921,8 +960,8 @@ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
|
|
|
921
960
|
/// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
|
|
922
961
|
struct CraftTabBarView: View {
|
|
923
962
|
@ObservedObject private var chrome = CraftChrome.shared
|
|
924
|
-
@Namespace private var selection
|
|
925
963
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
964
|
+
@Environment(\.scenePhase) private var scenePhase
|
|
926
965
|
// Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
|
|
927
966
|
// capped at the largest standard size (below), as UITabBar is; past that
|
|
928
967
|
// a long press shows the large content viewer instead.
|
|
@@ -1030,9 +1069,7 @@ struct CraftTabBarView: View {
|
|
|
1030
1069
|
.minimumScaleFactor(0.7)
|
|
1031
1070
|
.frame(height: iconSize + 6)
|
|
1032
1071
|
} else {
|
|
1033
|
-
|
|
1034
|
-
.font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
|
|
1035
|
-
.frame(height: iconSize + 6)
|
|
1072
|
+
tabSymbol(tab, selected: isSelected)
|
|
1036
1073
|
}
|
|
1037
1074
|
if let badge = tab.badge {
|
|
1038
1075
|
Text(badge)
|
|
@@ -1044,37 +1081,60 @@ struct CraftTabBarView: View {
|
|
|
1044
1081
|
.offset(x: 10, y: -4)
|
|
1045
1082
|
}
|
|
1046
1083
|
}
|
|
1047
|
-
Text(tab.
|
|
1084
|
+
Text(tab.displayTitle)
|
|
1048
1085
|
.font(.system(size: labelSize, weight: isSelected ? .semibold : .medium))
|
|
1086
|
+
.monospacedDigit()
|
|
1049
1087
|
.lineLimit(1)
|
|
1050
1088
|
.minimumScaleFactor(0.8)
|
|
1051
1089
|
}
|
|
1052
1090
|
.foregroundStyle(isSelected ? selectedColor : Color.primary)
|
|
1053
1091
|
.frame(maxWidth: .infinity, minHeight: 54)
|
|
1054
|
-
.background {
|
|
1055
|
-
if isSelected {
|
|
1056
|
-
Capsule()
|
|
1057
|
-
.fill(Color.primary.opacity(0.09))
|
|
1058
|
-
.matchedGeometryEffect(id: "selection", in: selection)
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
1092
|
.contentShape(Capsule())
|
|
1062
1093
|
}
|
|
1063
1094
|
.buttonStyle(.plain)
|
|
1064
1095
|
.accessibilityShowsLargeContentViewer {
|
|
1065
1096
|
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
1066
|
-
Text(tab.
|
|
1097
|
+
Text(tab.displayTitle)
|
|
1067
1098
|
}
|
|
1068
|
-
.
|
|
1099
|
+
.accessibilityIdentifier("craft-tab-\(tab.id)")
|
|
1100
|
+
.accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
|
|
1069
1101
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
1070
1102
|
}
|
|
1071
1103
|
}
|
|
1104
|
+
.background {
|
|
1105
|
+
GeometryReader { geometry in
|
|
1106
|
+
if let position = chrome.tabSwipePosition
|
|
1107
|
+
?? chrome.tabs.firstIndex(where: { $0.id == chrome.selected }).map({ CGFloat($0) }) {
|
|
1108
|
+
let width = geometry.size.width / CGFloat(max(1, chrome.tabs.count))
|
|
1109
|
+
Capsule()
|
|
1110
|
+
.fill(Color.primary.opacity(0.09))
|
|
1111
|
+
.frame(width: width, height: geometry.size.height)
|
|
1112
|
+
.offset(x: position * width)
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
.allowsHitTesting(false)
|
|
1116
|
+
.accessibilityHidden(true)
|
|
1117
|
+
}
|
|
1072
1118
|
}
|
|
1073
1119
|
|
|
1074
1120
|
private var selectedColor: Color {
|
|
1075
1121
|
chrome.tint.map { Color($0) } ?? Color.primary
|
|
1076
1122
|
}
|
|
1077
1123
|
|
|
1124
|
+
/// Live styling belongs to the icon, leaving selection and the caption clear.
|
|
1125
|
+
@ViewBuilder private func tabSymbol(_ tab: CraftChrome.Tab, selected: Bool) -> some View {
|
|
1126
|
+
let color = tab.iconTint.flatMap { UIColor(hex: $0) }.map { Color($0) } ?? (selected ? selectedColor : Color.primary)
|
|
1127
|
+
let icon = Image(systemName: symbol(tab.symbol, selected: selected))
|
|
1128
|
+
.font(.system(size: iconSize, weight: selected ? .semibold : .regular))
|
|
1129
|
+
.foregroundStyle(color)
|
|
1130
|
+
.frame(height: iconSize + 6)
|
|
1131
|
+
if #available(iOS 17.0, *) {
|
|
1132
|
+
icon.symbolEffect(.pulse, options: .repeating, isActive: tab.iconPulse && !reduceMotion && scenePhase == .active)
|
|
1133
|
+
} else {
|
|
1134
|
+
icon
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1078
1138
|
/// The filled variant for the current tab, when the symbol has one.
|
|
1079
1139
|
private func symbol(_ name: String, selected: Bool) -> String {
|
|
1080
1140
|
guard selected, !name.hasSuffix(".fill"), UIImage(systemName: "\(name).fill") != nil else { return name }
|
|
@@ -1104,6 +1104,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1104
1104
|
DispatchQueue.main.async { [weak self] in self?.hybrid.shellReady() }
|
|
1105
1105
|
}
|
|
1106
1106
|
|
|
1107
|
+
override func viewDidAppear(_ animated: Bool) {
|
|
1108
|
+
super.viewDidAppear(animated)
|
|
1109
|
+
prepareAdjacentNativeTabs()
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1107
1112
|
private var webAttached = false
|
|
1108
1113
|
|
|
1109
1114
|
private func installTabSwipeGestures() {
|
|
@@ -1152,9 +1157,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1152
1157
|
let speed = velocity * direction
|
|
1153
1158
|
let complete = recognizer.state == .ended && speed > -700
|
|
1154
1159
|
&& (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
|
|
1155
|
-
finishInteractiveTabSwipe(completed: complete) { [weak self] in
|
|
1160
|
+
finishInteractiveTabSwipe(completed: complete, velocity: velocity) { [weak self] in
|
|
1156
1161
|
self?.activeTabSwipeTarget = nil
|
|
1157
1162
|
self?.tabSwipeSettling = false
|
|
1163
|
+
self?.prepareAdjacentNativeTabs()
|
|
1158
1164
|
guard complete else {
|
|
1159
1165
|
CraftChrome.shared.cancelTabSwipe()
|
|
1160
1166
|
return
|
|
@@ -1224,9 +1230,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1224
1230
|
: max(0, min(width, translation))
|
|
1225
1231
|
target.view.frame.origin.x = tabSwipeFrom + offset
|
|
1226
1232
|
current.view.frame.origin.x = offset
|
|
1233
|
+
CraftChrome.shared.updateTabSwipe(progress: abs(offset) / width)
|
|
1227
1234
|
}
|
|
1228
1235
|
|
|
1229
|
-
private func finishInteractiveTabSwipe(completed: Bool, completion: @escaping () -> Void) {
|
|
1236
|
+
private func finishInteractiveTabSwipe(completed: Bool, velocity: CGFloat, completion: @escaping () -> Void) {
|
|
1230
1237
|
guard let targetId = activeTabSwipeTarget, let current = visibleStack,
|
|
1231
1238
|
let target = stacks[targetId] else { completion(); return }
|
|
1232
1239
|
let width = view.bounds.width
|
|
@@ -1235,7 +1242,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1235
1242
|
let settlingDuration = completed ? 0.22 : 0.18
|
|
1236
1243
|
let duration = CraftChrome.motion(.easeOut(duration: settlingDuration)) == nil ? 0 : settlingDuration
|
|
1237
1244
|
let generation = tabSwipeGeneration
|
|
1238
|
-
|
|
1245
|
+
CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
|
|
1246
|
+
let remaining = end - target.view.frame.minX
|
|
1247
|
+
let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
|
|
1248
|
+
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
|
|
1249
|
+
initialSpringVelocity: initialVelocity,
|
|
1250
|
+
options: [.beginFromCurrentState, .allowUserInteraction], animations: {
|
|
1239
1251
|
target.view.frame.origin.x = end
|
|
1240
1252
|
current.view.frame.origin.x = completed ? -direction * width : 0
|
|
1241
1253
|
}, completion: { _ in
|
|
@@ -1449,6 +1461,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1449
1461
|
for (key, other) in stacks { other.view.isHidden = key != id }
|
|
1450
1462
|
nav.view.frame = view.bounds
|
|
1451
1463
|
view.bringSubviewToFront(nav.view)
|
|
1464
|
+
prepareAdjacentNativeTabs()
|
|
1452
1465
|
if let slot = nav.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
|
|
1453
1466
|
adoptWeb(into: slot)
|
|
1454
1467
|
} else if !(nav.topViewController is CraftHybridWebSlot) {
|
|
@@ -1456,6 +1469,31 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1456
1469
|
}
|
|
1457
1470
|
}
|
|
1458
1471
|
|
|
1472
|
+
/// Prepare one neighbour after the first frame, never inside the finger's
|
|
1473
|
+
/// tracking run-loop mode. A first swipe then uses a laid-out native root
|
|
1474
|
+
/// rather than compiling, measuring and fetching at gesture start.
|
|
1475
|
+
private func prepareAdjacentNativeTabs() {
|
|
1476
|
+
let owner = currentTab
|
|
1477
|
+
let timer = Timer(timeInterval: 0.15, repeats: false) { [weak self] _ in
|
|
1478
|
+
guard let self, self.currentTab == owner, self.activeTabSwipeTarget == nil,
|
|
1479
|
+
self.view.window != nil,
|
|
1480
|
+
let index = self.hybrid.tabIds.firstIndex(of: owner) else { return }
|
|
1481
|
+
let tabs = self.hybrid.tabIds
|
|
1482
|
+
for neighbour in [index + 1, index - 1] where tabs.indices.contains(neighbour) {
|
|
1483
|
+
let id = tabs[neighbour]
|
|
1484
|
+
guard self.stacks[id] == nil, self.hybrid.routes.match(id) != nil else { continue }
|
|
1485
|
+
let nav = self.makeStack(tab: id, rootPath: id)
|
|
1486
|
+
nav.view.isHidden = true
|
|
1487
|
+
nav.view.layoutIfNeeded()
|
|
1488
|
+
(nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
|
|
1489
|
+
(self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
|
|
1490
|
+
self.prepareAdjacentNativeTabs()
|
|
1491
|
+
break
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
RunLoop.main.add(timer, forMode: .default)
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1459
1497
|
private func cancelPendingTab() {
|
|
1460
1498
|
pendingTab?.deadline.cancel()
|
|
1461
1499
|
pendingTab = nil
|
|
@@ -1436,6 +1436,16 @@ enum CraftSnapshots {
|
|
|
1436
1436
|
private static let lock = NSLock()
|
|
1437
1437
|
/// Writes not on disk yet, so a read right after a write sees it.
|
|
1438
1438
|
private static var pending: [String: String?] = [:]
|
|
1439
|
+
private static var revisions: [String: UInt64] = [:]
|
|
1440
|
+
private static var revision: UInt64 = 0
|
|
1441
|
+
// Screens read shared headers many times while evaluating bindings. Keep
|
|
1442
|
+
// settled JSON in memory instead of reopening the same file on the UI thread.
|
|
1443
|
+
private static let cache: NSCache<NSString, NSString> = {
|
|
1444
|
+
let cache = NSCache<NSString, NSString>()
|
|
1445
|
+
cache.countLimit = 32
|
|
1446
|
+
cache.totalCostLimit = 8 * 1024 * 1024
|
|
1447
|
+
return cache
|
|
1448
|
+
}()
|
|
1439
1449
|
|
|
1440
1450
|
static var directory: URL? {
|
|
1441
1451
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
|
|
@@ -1458,11 +1468,13 @@ enum CraftSnapshots {
|
|
|
1458
1468
|
static func read(_ name: String) -> String? {
|
|
1459
1469
|
guard let url = url(for: name) else { return nil }
|
|
1460
1470
|
lock.lock()
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
if let
|
|
1471
|
+
defer { lock.unlock() }
|
|
1472
|
+
if let waiting = pending[name] { return waiting }
|
|
1473
|
+
if let cached = cache.object(forKey: name as NSString) { return cached as String }
|
|
1464
1474
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
1465
|
-
|
|
1475
|
+
let json = String(decoding: data, as: UTF8.self)
|
|
1476
|
+
cache.setObject(json as NSString, forKey: name as NSString, cost: data.count)
|
|
1477
|
+
return json
|
|
1466
1478
|
}
|
|
1467
1479
|
|
|
1468
1480
|
/// Replaces the snapshot with JSON text (nil deletes it) off the main
|
|
@@ -1477,7 +1489,11 @@ enum CraftSnapshots {
|
|
|
1477
1489
|
return
|
|
1478
1490
|
}
|
|
1479
1491
|
lock.lock()
|
|
1492
|
+
revision &+= 1
|
|
1493
|
+
let mine = revision
|
|
1494
|
+
revisions[name] = mine
|
|
1480
1495
|
pending[name] = .some(json)
|
|
1496
|
+
cache.removeObject(forKey: name as NSString)
|
|
1481
1497
|
lock.unlock()
|
|
1482
1498
|
queue.async {
|
|
1483
1499
|
var ok = true
|
|
@@ -1492,7 +1508,13 @@ enum CraftSnapshots {
|
|
|
1492
1508
|
ok = false
|
|
1493
1509
|
}
|
|
1494
1510
|
lock.lock()
|
|
1495
|
-
if
|
|
1511
|
+
if revisions[name] == mine {
|
|
1512
|
+
revisions.removeValue(forKey: name)
|
|
1513
|
+
pending.removeValue(forKey: name)
|
|
1514
|
+
if ok, let json {
|
|
1515
|
+
cache.setObject(json as NSString, forKey: name as NSString, cost: json.utf8.count)
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1496
1518
|
lock.unlock()
|
|
1497
1519
|
DispatchQueue.main.async {
|
|
1498
1520
|
if ok { NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name]) }
|
|
@@ -4224,7 +4224,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4224
4224
|
screenRequestCancellers.removeAll()
|
|
4225
4225
|
}
|
|
4226
4226
|
|
|
4227
|
+
private var appliedTraitDefaults: String?
|
|
4228
|
+
|
|
4227
4229
|
private func refreshTraitDefaults() {
|
|
4230
|
+
// Tab navigation is not a theme change. Reapplying the entire tree
|
|
4231
|
+
// discards every measurement just as an interactive transition starts.
|
|
4232
|
+
let traits = "\(colorScheme):\(traitCollection.preferredContentSizeCategory.rawValue):\(UIAccessibility.isReduceTransparencyEnabled)"
|
|
4233
|
+
guard traits != appliedTraitDefaults else { return }
|
|
4234
|
+
appliedTraitDefaults = traits
|
|
4228
4235
|
view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
|
|
4229
4236
|
applyNavigationBarAppearance()
|
|
4230
4237
|
if let document = mutationDocument.node("root") {
|