craft-native 0.0.164 → 0.0.166

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.
@@ -1004,7 +1004,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1004
1004
  private var openingWeb = false
1005
1005
  private var firstFrameLogged = false
1006
1006
  private var tabBarHeight: CGFloat = 0
1007
- private var tabSwipeRecognizers: [UISwipeGestureRecognizer] = []
1007
+ private var tabSwipeRecognizers: [UIGestureRecognizer] = []
1008
+ private var activeTabSwipeTarget: String?
1008
1009
 
1009
1010
  init(config: CraftConfig) {
1010
1011
  self.config = config
@@ -1047,28 +1048,137 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1047
1048
 
1048
1049
  private func installTabSwipeGestures() {
1049
1050
  guard tabSwipeRecognizers.isEmpty else { return }
1050
- for direction in [UISwipeGestureRecognizer.Direction.left, .right] {
1051
- let recognizer = UISwipeGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
1052
- recognizer.direction = direction
1053
- recognizer.delegate = self
1054
- recognizer.cancelsTouchesInView = false
1055
- recognizer.delaysTouchesBegan = false
1056
- view.addGestureRecognizer(recognizer)
1057
- tabSwipeRecognizers.append(recognizer)
1058
- }
1051
+ let recognizer = UIPanGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
1052
+ recognizer.delegate = self
1053
+ recognizer.cancelsTouchesInView = false
1054
+ recognizer.delaysTouchesBegan = false
1055
+ view.addGestureRecognizer(recognizer)
1056
+ tabSwipeRecognizers.append(recognizer)
1059
1057
  }
1060
1058
 
1061
- @objc private func tabSwipeRecognized(_ recognizer: UISwipeGestureRecognizer) {
1059
+ @objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
1062
1060
  let tabs = hybrid.tabIds
1063
1061
  guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
1064
- let next = recognizer.direction == .left ? index + 1 : index - 1
1065
- guard tabs.indices.contains(next) else { return }
1066
- CraftChrome.shared.swipe(to: tabs[next])
1062
+ let translation = recognizer.translation(in: view).x
1063
+ let velocity = recognizer.velocity(in: view).x
1064
+ switch recognizer.state {
1065
+ case .changed:
1066
+ if activeTabSwipeTarget == nil {
1067
+ let direction = abs(translation) > 1 ? translation : velocity
1068
+ guard abs(direction) > 1 else { return }
1069
+ let next = direction < 0 ? index + 1 : index - 1
1070
+ guard tabs.indices.contains(next), beginInteractiveTabSwipe(to: tabs[next], direction: direction < 0 ? -1 : 1) else { return }
1071
+ activeTabSwipeTarget = tabs[next]
1072
+ }
1073
+ updateInteractiveTabSwipe(translation: translation)
1074
+ case .ended, .cancelled, .failed:
1075
+ guard let target = activeTabSwipeTarget else {
1076
+ guard recognizer.state == .ended else { return }
1077
+ let delta = abs(translation) >= 32 ? translation : velocity
1078
+ guard abs(delta) >= 32 else { return }
1079
+ let next = delta < 0 ? index + 1 : index - 1
1080
+ guard tabs.indices.contains(next) else { return }
1081
+ CraftChrome.shared.swipe(to: tabs[next])
1082
+ return
1083
+ }
1084
+ activeTabSwipeTarget = nil
1085
+ let complete = recognizer.state == .ended && (abs(translation) >= max(32, view.bounds.width * 0.28) || abs(velocity) > 700)
1086
+ finishInteractiveTabSwipe(completed: complete) { [weak self] in
1087
+ guard complete else {
1088
+ CraftChrome.shared.cancelTabSwipe()
1089
+ return
1090
+ }
1091
+ CraftChrome.shared.commitTabSwipe(to: target)
1092
+ self?.view.layoutIfNeeded()
1093
+ }
1094
+ default:
1095
+ break
1096
+ }
1097
+ }
1098
+
1099
+ /// Prepare a mounted target beside the current stack. Native roots render
1100
+ /// immediately; a web root borrows the shared WebView and stays
1101
+ /// transparent until its page route catches up. Two web roots still use
1102
+ /// the route-aware fallback because one WebView cannot render both pages.
1103
+ private func beginInteractiveTabSwipe(to id: String, direction: CGFloat) -> Bool {
1104
+ guard let current = visibleStack, let target = stacks[id],
1105
+ current.view !== target.view,
1106
+ !(current.topViewController is CraftHybridWebSlot && target.topViewController is CraftHybridWebSlot),
1107
+ view.bounds.width > 0 else { return false }
1108
+ if let slot = target.topViewController as? CraftHybridWebSlot {
1109
+ // There is one shared WebView, so a web-to-web gesture cannot
1110
+ // render two pages at once. A native-to-web gesture can: move the
1111
+ // WebView into the hidden target slot and let its route catch up
1112
+ // while the slot follows the finger. It stays transparent until
1113
+ // the page reports that it reached the target path.
1114
+ slot.awaitingPage = true
1115
+ adoptWeb(into: slot, allowHiddenStack: true)
1116
+ webHost?.view.alpha = 0
1117
+ hybrid.navigatePage(slot.path) { [weak self, weak slot] depth in
1118
+ guard let self, let slot else { return }
1119
+ slot.depth = depth
1120
+ guard self.activeTabSwipeTarget == id,
1121
+ CraftHybridRoutes.normalize(self.hybrid.pagePath ?? "") == CraftHybridRoutes.normalize(slot.path) else { return }
1122
+ self.reveal(slot)
1123
+ }
1124
+ }
1125
+ target.view.isHidden = false
1126
+ let width = view.bounds.width
1127
+ target.view.frame = view.bounds.offsetBy(dx: direction * width, dy: 0)
1128
+ view.bringSubviewToFront(target.view)
1129
+ CraftChrome.shared.beginTabSwipe(to: id)
1130
+ return true
1131
+ }
1132
+
1133
+ private func updateInteractiveTabSwipe(translation: CGFloat) {
1134
+ guard let targetId = activeTabSwipeTarget, let current = visibleStack,
1135
+ let target = stacks[targetId] else { return }
1136
+ let width = max(1, view.bounds.width)
1137
+ let start = target.view.frame.minX >= width * 0.5 ? width : -width
1138
+ let offset = min(width, max(-width, translation))
1139
+ target.view.frame.origin.x = start + offset
1140
+ current.view.frame.origin.x = offset
1141
+ }
1142
+
1143
+ private func finishInteractiveTabSwipe(completed: Bool, completion: @escaping () -> Void) {
1144
+ guard let targetId = activeTabSwipeTarget, let current = visibleStack,
1145
+ let target = stacks[targetId] else { completion(); return }
1146
+ let width = view.bounds.width
1147
+ let direction: CGFloat = target.view.frame.minX >= 0 ? 1 : -1
1148
+ let end = completed ? 0 : direction * width
1149
+ let duration = CraftChrome.motion(.easeOut(duration: completed ? 0.22 : 0.18)) == nil ? 0 : 0.22
1150
+ UIView.animate(withDuration: duration, animations: {
1151
+ target.view.frame.origin.x = end
1152
+ current.view.frame.origin.x = completed ? -direction * width : 0
1153
+ }, completion: { _ in
1154
+ if !completed {
1155
+ target.view.isHidden = true
1156
+ target.view.frame = self.view.bounds
1157
+ current.view.frame = self.view.bounds
1158
+ if target.topViewController is CraftHybridWebSlot,
1159
+ current.topViewController is CraftNativeScreenController {
1160
+ self.parkWeb()
1161
+ }
1162
+ }
1163
+ completion()
1164
+ })
1067
1165
  }
1068
1166
 
1069
1167
  private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
1070
1168
  let local = root.convert(point, from: view)
1071
1169
  guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
1170
+ // WKWebView keeps its scrolling UIScrollView below the hosting view
1171
+ // that owns the native slot, so it is not necessarily on the hit
1172
+ // view's superview chain. Treat an overflowing page as its own
1173
+ // horizontal gesture surface before the shell claims the swipe.
1174
+ if let webView = CraftChrome.shared.webView,
1175
+ hit === webView || hit.isDescendant(of: webView) || webView.isDescendant(of: hit) {
1176
+ let scroll = webView.scrollView
1177
+ if scroll.isScrollEnabled,
1178
+ scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
1179
+ return true
1180
+ }
1181
+ }
1072
1182
  var candidate: UIView? = hit
1073
1183
  while let current = candidate {
1074
1184
  if let scroll = current as? UIScrollView,
@@ -1193,6 +1303,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1193
1303
  }
1194
1304
  currentTab = id
1195
1305
  for (key, other) in stacks { other.view.isHidden = key != id }
1306
+ nav.view.frame = view.bounds
1196
1307
  view.bringSubviewToFront(nav.view)
1197
1308
  if let slot = nav.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
1198
1309
  adoptWeb(into: slot)
@@ -1206,8 +1317,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1206
1317
  pendingTab = nil
1207
1318
  }
1208
1319
 
1209
- func adoptWeb(into slot: CraftHybridWebSlot) {
1210
- guard let webHost, slot.navigationController === visibleStack || visibleStack == nil else { return }
1320
+ func adoptWeb(into slot: CraftHybridWebSlot, allowHiddenStack: Bool = false) {
1321
+ guard let webHost,
1322
+ allowHiddenStack || slot.navigationController === visibleStack || visibleStack == nil else { return }
1211
1323
  attachWeb()
1212
1324
  moveWeb(to: slot, in: slot.view)
1213
1325
  webHost.view.alpha = slot.awaitingPage ? 0 : 1
@@ -1333,6 +1445,13 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1333
1445
 
1334
1446
  func pageNavigated(path: String, direction: String, depth: Int, byHost: Bool) {
1335
1447
  let owner = CraftHybridRoutes.tab(owning: path, among: hybrid.tabIds)
1448
+ if let targetId = activeTabSwipeTarget,
1449
+ let target = stacks[targetId]?.topViewController as? CraftHybridWebSlot,
1450
+ owner == targetId || CraftHybridRoutes.normalize(path) == CraftHybridRoutes.normalize(target.path) {
1451
+ target.depth = depth
1452
+ target.awaitingPage = false
1453
+ reveal(target)
1454
+ }
1336
1455
  if let pending = pendingTab, owner == pending.id || direction == "tab" {
1337
1456
  show(tab: pending.id)
1338
1457
  }
@@ -1521,6 +1640,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1521
1640
  func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
1522
1641
  if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
1523
1642
  guard hybrid.tabIds.count > 1, let top = visibleStack?.topViewController else { return false }
1643
+ guard CraftChrome.shared.tabSwipeEnabled else { return false }
1644
+ if let pan = gestureRecognizer as? UIPanGestureRecognizer {
1645
+ let velocity = pan.velocity(in: view)
1646
+ guard abs(velocity.x) > abs(velocity.y) else { return false }
1647
+ }
1524
1648
  if let native = top as? CraftNativeScreenController,
1525
1649
  !native.tabSwipeEnabled || !native.contentSwipeBack { return false }
1526
1650
  return !horizontalScrollAt(gestureRecognizer.location(in: view), in: top.view)
@@ -1533,7 +1657,6 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1533
1657
  return false
1534
1658
  }
1535
1659
  if let native = nav.topViewController as? CraftNativeScreenController,
1536
- gestureRecognizer !== nav.interactivePopGestureRecognizer,
1537
1660
  nativeScreenContainsHorizontalScroll(native) {
1538
1661
  let moved = (gestureRecognizer as? UIPanGestureRecognizer)?.translation(in: nav.view).x ?? 0
1539
1662
  if gestureRecognizer.location(in: nav.view).x - moved > 32 { return false }