What happens
During an interactive tab swipe, the outgoing screen follows the finger correctly, but the incoming tab never appears. The drag reveals the app's background instead, and the tab bar's selection has already moved — so it reads as the screen peeling away to nothing.
Verified on iPhone 17 Pro (iOS 26), craft-native@0.0.187, an all-native app (renderer: 'native') with five tabs. Captured mid-drag at ~150pt of travel: the outgoing screen is offset, the right third of the window is empty.
The back swipe is unaffected and looks correct, which is the giveaway — that one is UIKit's own interactivePopGestureRecognizer, while the tab swipe is hand-rolled in CraftHybrid.swift.
Why
beginInteractiveTabSwipe(to:direction:):
target.view.frame = view.bounds.offsetBy(dx: direction * width, dy: 0)direction is the direction the finger is travelling. The tab being pulled in has to start on the opposite edge. As written it is placed on the same side — directly behind the screen that is leaving — so it is never on screen at any point in the gesture.
Symmetric: a finger moving left selects index + 1 and places it at -width; a finger moving right selects index - 1 and places it at +width. Both wrong.
There is a second, latent one three lines down in updateInteractiveTabSwipe(translation:):
let start = target.view.frame.minX >= width * 0.5 ? width : -widthThis re-derives the starting edge from the frame it has just moved. Once the drag passes half the screen width the comparison flips and the incoming tab jumps across. It is hidden today by the first bug, but it bites as soon as that one is fixed alone.
Patch
Remember the edge for the gesture instead of re-deriving it, and start on the opposite side:
+ /// Which edge the tab being pulled in started from, kept for the length of
+ /// one gesture: re-derived from the live frame, it drifts as the frame moves.
+ private var tabSwipeFrom: CGFloat = 0
+
/// Prepare a mounted target beside the current stack. Native roots render
@@
target.view.isHidden = false
let width = view.bounds.width
- target.view.frame = view.bounds.offsetBy(dx: direction * width, dy: 0)
+ tabSwipeFrom = -direction * width
+ target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom, dy: 0)
@@
let width = max(1, view.bounds.width)
- let start = target.view.frame.minX >= width * 0.5 ? width : -width
let offset = min(width, max(-width, translation))
- target.view.frame.origin.x = start + offset
+ target.view.frame.origin.x = tabSwipeFrom + offsetfinishInteractiveTabSwipe(completed:completion:) needs no change — its direction is derived from minX's sign, which is correct once the target starts on the right edge, in both the completed and the sprung-back cases.
Applied and rebuilt, the incoming tab follows the finger in from the correct edge and the two screens move together like a pager. Happy to open a PR if that is useful.