From 64a3ee6e9f2965229f07a959eea519b0e858e80a Mon Sep 17 00:00:00 2001 From: tifroz Date: Sun, 31 May 2026 20:45:37 -0700 Subject: [PATCH 1/6] added support for narrow gesture router --- Sources/SkipUI/SkipUI/System/Gesture.swift | 18 ++- .../SkipUI/View/AdditionalViewModifiers.swift | 131 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/Sources/SkipUI/SkipUI/System/Gesture.swift b/Sources/SkipUI/SkipUI/System/Gesture.swift index 1c110970..8fb6b137 100644 --- a/Sources/SkipUI/SkipUI/System/Gesture.swift +++ b/Sources/SkipUI/SkipUI/System/Gesture.swift @@ -445,6 +445,15 @@ extension View { #endif } + // SKIP @bridge + public func bridgedSimultaneousGesture(_ gesture: Any, isEnabled: Bool) -> any View { + #if SKIP + return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled)) + #else + return self + #endif + } + @available(*, unavailable) public func highPriorityGesture(_ gesture: any Gesture, including mask: GestureMask = .all) -> some View { return self @@ -484,9 +493,16 @@ extension View { return onTapGesture(count: count, coordinateSpace: coordinateSpace, perform: { p in action(p.x, p.y) }) } - @available(*, unavailable) public func simultaneousGesture(_ gesture: any Gesture, including mask: GestureMask = .all) -> some View { + return simultaneousGesture(gesture, isEnabled: !mask.isEmpty) + } + + public func simultaneousGesture(_ gesture: any Gesture, isEnabled: Bool) -> some View { + #if SKIP + return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled)) + #else return self + #endif } } diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index 33806ff9..5169c323 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -5,6 +5,7 @@ import Foundation #if SKIP import SkipModel import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.aspectRatio @@ -14,9 +15,11 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable @@ -30,6 +33,7 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size @@ -38,6 +42,9 @@ import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.boundsInWindow @@ -50,10 +57,12 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat +import android.util.Log #elseif canImport(CoreGraphics) import struct CoreGraphics.CGAffineTransform import struct CoreGraphics.CGFloat @@ -1026,6 +1035,52 @@ extension View { #endif } + // SKIP @bridge + public func androidVerticalOverscrollPullDown( + isEnabled: Bool, + onPull: @escaping (CGFloat) -> Void, + onEnd: @escaping () -> Void + ) -> any View { + #if SKIP + return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in + Log.d("ChromeStyleMenuOverscroll", "modifier render enabled=\(isEnabled)") + let enabledState = rememberUpdatedState(isEnabled) + let onPullState = rememberUpdatedState(onPull) + let onEndState = rememberUpdatedState(onEnd) + let density = LocalDensity.current + let connection = remember { + AndroidVerticalOverscrollPullDownConnection( + isEnabled: { + enabledState.value + }, + onPullPx: { pullPx in + let pullDp = with(density) { pullPx.toDp() } + Log.d("ChromeStyleMenuOverscroll", "onPull bridge call pullDp=\(pullDp.value)") + onPullState.value(CGFloat(pullDp.value)) + Log.d("ChromeStyleMenuOverscroll", "onPull bridge return pullDp=\(pullDp.value)") + }, + onEnd: { + Log.d("ChromeStyleMenuOverscroll", "onEnd bridge call") + onEndState.value() + Log.d("ChromeStyleMenuOverscroll", "onEnd bridge return") + } + ) + } + + var context = context + context.modifier = context.modifier.nestedScroll(connection) + // Android's stretch overscroll consumes the edge drag before the + // parent nested-scroll connection can use it for sheet handoff. + // SKIP INSERT: val providedOverscrollFactory = LocalOverscrollFactory provides null + CompositionLocalProvider(providedOverscrollFactory) { + renderable.Render(context: context) + } + }) + #else + return self + #endif + } + @available(*, unavailable) public func onHover(perform action: @escaping (Bool) -> Void) -> some View { return self @@ -1753,5 +1808,81 @@ final class AnimatedBorderModifier: RenderModifier { } } +#if SKIP +final class AndroidVerticalOverscrollPullDownConnection: NestedScrollConnection { + let isEnabled: () -> Bool + let onPullPx: (Float) -> Void + let onEnd: () -> Void + + var accumulatedPullPx = Float(0.0) + var preScrollSample = 0 + var postScrollSample = 0 + + init( + isEnabled: @escaping () -> Bool, + onPullPx: @escaping (Float) -> Void, + onEnd: @escaping () -> Void + ) { + self.isEnabled = isEnabled + self.onPullPx = onPullPx + self.onEnd = onEnd + log("connection init") + } + + override func onPreScroll(available: Offset, source: NestedScrollSource) -> Offset { + preScrollSample += 1 + let enabled = isEnabled() + if source == NestedScrollSource.Drag && (preScrollSample <= 20 || preScrollSample % 20 == 0) { + log("pre sample=\(preScrollSample) enabled=\(enabled) availableY=\(available.y) accumulated=\(accumulatedPullPx)") + } + + guard source == NestedScrollSource.Drag, enabled, accumulatedPullPx > Float(0.0) else { + return Offset.Zero + } + + let previousPullPx = accumulatedPullPx + accumulatedPullPx = max(Float(0.0), accumulatedPullPx + available.y) + onPullPx(accumulatedPullPx) + log("pre consume deltaY=\(accumulatedPullPx - previousPullPx) accumulated=\(accumulatedPullPx)") + return Offset(x: Float(0.0), y: accumulatedPullPx - previousPullPx) + } + + override func onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource) -> Offset { + postScrollSample += 1 + let enabled = isEnabled() + if source == NestedScrollSource.Drag && (postScrollSample <= 40 || postScrollSample % 20 == 0) { + log("post sample=\(postScrollSample) enabled=\(enabled) consumedY=\(consumed.y) availableY=\(available.y) accumulated=\(accumulatedPullPx)") + } + + guard source == NestedScrollSource.Drag, enabled, available.y > Float(0.0) else { + return Offset.Zero + } + + accumulatedPullPx += available.y + onPullPx(accumulatedPullPx) + log("post consume availableY=\(available.y) accumulated=\(accumulatedPullPx)") + return Offset(x: Float(0.0), y: available.y) + } + + override func onPostFling(consumed: Velocity, available: Velocity) async -> Velocity { + log("fling consumedY=\(consumed.y) availableY=\(available.y) accumulated=\(accumulatedPullPx)") + guard accumulatedPullPx > Float(0.0) else { + return Velocity.Zero + } + + accumulatedPullPx = Float(0.0) + onEnd() + return Velocity(x: Float(0.0), y: available.y) + } + + private func log(_ message: String) { + let result = Log.d("ChromeStyleMenuOverscroll", message) + if result == Int.min { + return + } + } +} +#endif + #endif #endif From 37d01c9ce45908331458d0d3f34663289aceb1ca Mon Sep 17 00:00:00 2001 From: tifroz Date: Wed, 3 Jun 2026 11:26:56 -0700 Subject: [PATCH 2/6] Add partial simultaneous gesture support Support simultaneous gesture observers alongside normal gestures on Android, including .none mask behavior for disabled observers. Fix conditional TabView rendering and titleless NavigationStack top-bar spacing. Update Skip dependencies and add Compose-focused regression tests. --- .../SkipUI/SkipUI/Containers/Navigation.swift | 7 +- Sources/SkipUI/SkipUI/System/Gesture.swift | 169 ++++++++++++-- Tests/SkipUITests/SkipUITests.swift | 211 ++++++++++++++++++ 3 files changed, 367 insertions(+), 20 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index 141d6be5..10f79c23 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -316,12 +316,9 @@ public struct NavigationStack : View, Renderable { let defaultTopBarHeight = 112.dp let topBarBottomPx = remember { - // Default our initial value to the expected value, which helps avoid visual artifacts as we measure actual values and - // recompose with adjusted layouts. Only reserve the inset when a bar will actually be - // shown: a title-less root has showTopBar == false and never composes the - // AnimatedVisibility content below, so its onDispose reset never runs — initializing to - // the bar height here would leave a phantom top inset (~safeArea + 112dp). let safeAreaTopPx = arguments.safeArea?.safeBoundsPx.top ?? Float(0.0) + // Use a first-frame estimate only when a top bar will render. The measured value from + // onGloballyPositionedInWindow below is the source of truth after composition. mutableStateOf(showTopBar ? with(density) { safeAreaTopPx + defaultTopBarHeight.toPx() } : Float(0.0)) } let topBarHeightPx = remember { diff --git a/Sources/SkipUI/SkipUI/System/Gesture.swift b/Sources/SkipUI/SkipUI/System/Gesture.swift index 8fb6b137..25eaf3f4 100644 --- a/Sources/SkipUI/SkipUI/System/Gesture.swift +++ b/Sources/SkipUI/SkipUI/System/Gesture.swift @@ -18,6 +18,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.LayoutCoordinates @@ -430,7 +431,7 @@ extension View { public func gesture(_ gesture: any Gesture, isEnabled: Bool) -> any View { #if SKIP - return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled)) + return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled, composition: .normal)) #else return self #endif @@ -439,16 +440,23 @@ extension View { // SKIP @bridge public func bridgedGesture(_ gesture: Any, isEnabled: Bool) -> any View { #if SKIP - return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled)) + return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled, composition: .normal)) #else return self #endif } + /// Adds a SkipUI-supported bridged gesture as a simultaneous observer. + /// + /// Android support is partial. The supplied gesture is collected with + /// other gestures on the same rendered view, which is enough for simple + /// observation such as `DragGesture` callbacks. Gesture arbitration, + /// `GestureMask.gesture`, and `GestureMask.subviews` do not yet fully + /// match SwiftUI/UIKit behavior. // SKIP @bridge public func bridgedSimultaneousGesture(_ gesture: Any, isEnabled: Bool) -> any View { #if SKIP - return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled)) + return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled, composition: .simultaneous)) #else return self #endif @@ -493,13 +501,27 @@ extension View { return onTapGesture(count: count, coordinateSpace: coordinateSpace, perform: { p in action(p.x, p.y) }) } + /// Adds a gesture that may recognize at the same time as the view's other + /// SkipUI-supported gestures. + /// + /// Android support is partial. This currently treats the gesture as an + /// additional observer on the same rendered view. Only `.all` and `.none` + /// have meaningful mask behavior; `.gesture` and `.subviews` are not + /// distinguished. This does not implement high-priority gestures or full + /// SwiftUI/UIKit gesture arbitration. public func simultaneousGesture(_ gesture: any Gesture, including mask: GestureMask = .all) -> some View { return simultaneousGesture(gesture, isEnabled: !mask.isEmpty) } + /// Adds a gesture that may recognize at the same time as the view's other + /// SkipUI-supported gestures when `isEnabled` is true. + /// + /// Android support is partial. This currently treats the gesture as an + /// additional observer on the same rendered view, which is intended as a + /// first step toward fuller `simultaneousGesture` parity. public func simultaneousGesture(_ gesture: any Gesture, isEnabled: Bool) -> some View { #if SKIP - return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled)) + return ModifiedContent(content: self, modifier: GestureModifier(gesture: gesture as! Gesture, isEnabled: isEnabled, composition: .simultaneous)) #else return self #endif @@ -615,11 +637,18 @@ public struct ModifiedGesture : Gesture { /// Modifier view that collects and executes gestures. final class GestureModifier: RenderModifier { + enum Composition { + case normal + case simultaneous + } + let gesture: ModifiedGesture? + let composition: Composition var isConsumed = false - init(gesture: Gesture, isEnabled: Bool) { + init(gesture: Gesture, isEnabled: Bool, composition: Composition) { self.gesture = isEnabled ? gesture.modified : nil + self.composition = composition super.init() self.action = { renderable, context in var context = context @@ -639,21 +668,36 @@ final class GestureModifier: RenderModifier { return modifier } - // Compose wants you to collect all e.g. tap gestures into a single pointerInput modifier, so we collect all our gestures - let gestures: kotlin.collections.MutableList> = mutableListOf() + // Compose wants related gestures, such as taps, collected into a single + // pointerInput modifier. Keep simultaneous drag gestures separate so + // scrollable children can keep consuming movement while observers still + // receive callbacks. + let normalGestures: kotlin.collections.MutableList> = mutableListOf() + let simultaneousGestures: kotlin.collections.MutableList> = mutableListOf() if let gesture { - gestures.add(gesture) + switch composition { + case .normal: + normalGestures.add(gesture) + case .simultaneous: + simultaneousGestures.add(gesture) + } } renderable.forEachModifier { guard let gestureModifier = $0 as? GestureModifier else { return nil } if let gesture = gestureModifier.gesture { - gestures.add(gesture) + switch gestureModifier.composition { + case .normal: + normalGestures.add(gesture) + case .simultaneous: + simultaneousGestures.add(gesture) + } } gestureModifier.isConsumed = true return nil } + let allGestures: kotlin.collections.List> = normalGestures + simultaneousGestures let density = LocalDensity.current var ret = modifier @@ -666,9 +710,9 @@ final class GestureModifier: RenderModifier { let layoutCoordinates = remember { mutableStateOf(nil) } ret = ret.onGloballyPositioned { layoutCoordinates.value = $0 } - let tapGestures = rememberUpdatedState(gestures.filter { $0.isTapGesture }) - let doubleTapGestures = rememberUpdatedState(gestures.filter { $0.isDoubleTapGesture }) - let longPressGestures = rememberUpdatedState(gestures.filter { $0.isLongPressGesture }) + let tapGestures = rememberUpdatedState(allGestures.filter { $0.isTapGesture }) + let doubleTapGestures = rememberUpdatedState(allGestures.filter { $0.isDoubleTapGesture }) + let longPressGestures = rememberUpdatedState(allGestures.filter { $0.isLongPressGesture }) if tapGestures.value.size > 0 || doubleTapGestures.value.size > 0 || longPressGestures.value.size > 0 { ret = ret.pointerInput(true) { let onDoubleTap: ((Offset) -> Void)? @@ -707,7 +751,7 @@ final class GestureModifier: RenderModifier { } } - let dragGestures = rememberUpdatedState(gestures.filter { $0.isDragGesture }) + let dragGestures = rememberUpdatedState(normalGestures.filter { $0.isDragGesture }) if dragGestures.value.size > 0 { let dragOffsetX = remember { mutableStateOf(Float(0.0)) } let dragOffsetY = remember { mutableStateOf(Float(0.0)) } @@ -753,8 +797,53 @@ final class GestureModifier: RenderModifier { } } - let magnifyGestures = rememberUpdatedState(gestures.filter { $0.isMagnifyGesture }) - let rotateGestures = rememberUpdatedState(gestures.filter { $0.isRotateGesture }) + let simultaneousDragGestures = rememberUpdatedState(simultaneousGestures.filter { $0.isDragGesture }) + if simultaneousDragGestures.value.size > 0 { + let dragOffsetX = remember { mutableStateOf(Float(0.0)) } + let dragOffsetY = remember { mutableStateOf(Float(0.0)) } + let dragPositionPx = remember { mutableStateOf(Offset(x: Float(0.0), y: Float(0.0))) } + let noMinimumDistance = simultaneousDragGestures.value.any { $0.minimumDistance <= 0.0 } + ret = ret.pointerInput(true) { + let onDrag: (PointerInputChange, Offset) -> Void = { change, offsetPx in + let offsetX = with(density) { offsetPx.x.toDp() } + let offsetY = with(density) { offsetPx.y.toDp() } + dragOffsetX.value += offsetX.value + dragOffsetY.value += offsetY.value + let translation = CGSize(width: CGFloat(dragOffsetX.value), height: CGFloat(dragOffsetY.value)) + + dragPositionPx.value = change.position + for dragGesture in simultaneousDragGestures.value { + var positionPx = change.position + if dragGesture.coordinateSpace.isGlobal, let layoutCoordinates = layoutCoordinates.value { + positionPx = layoutCoordinates.localToRoot(positionPx) + } + let positionX = (with(density) { positionPx.x.toDp() }).value + let positionY = (with(density) { positionPx.y.toDp() }).value + let location = CGPoint(x: CGFloat(positionX), y: CGFloat(positionY)) + dragGesture.onDragChange(location: location, translation: translation) + } + } + let onDragEnd: () -> Void = { + let translation = CGSize(width: CGFloat(dragOffsetX.value), height: CGFloat(dragOffsetY.value)) + dragOffsetX.value = Float(0.0) + dragOffsetY.value = Float(0.0) + for dragGesture in simultaneousDragGestures.value { + var positionPx = dragPositionPx.value + if dragGesture.coordinateSpace.isGlobal, let layoutCoordinates = layoutCoordinates.value { + positionPx = layoutCoordinates.localToRoot(positionPx) + } + let positionX = (with(density) { positionPx.x.toDp() }).value + let positionY = (with(density) { positionPx.y.toDp() }).value + let location = CGPoint(x: CGFloat(positionX), y: CGFloat(positionY)) + dragGesture.onDragEnd(location: location, translation: translation) + } + } + detectSimultaneousDragGestures(onDrag: onDrag, onDragEnd: onDragEnd, onDragCancel: onDragEnd, shouldAwaitTouchSlop: { !noMinimumDistance }) + } + } + + let magnifyGestures = rememberUpdatedState(allGestures.filter { $0.isMagnifyGesture }) + let rotateGestures = rememberUpdatedState(allGestures.filter { $0.isRotateGesture }) if magnifyGestures.value.size > 0 || rotateGestures.value.size > 0 { let magnification = remember { mutableStateOf(Float(1.0)) } let rotation = remember { mutableStateOf(Float(0.0)) } @@ -876,6 +965,56 @@ func detectDragGesturesWithScrollAxes(onDragEnd: () -> Void, onDragCancel: () -> } } } + +// SKIP DECLARE: suspend fun PointerInputScope.detectSimultaneousDragGestures(onDrag: (PointerInputChange, Offset) -> Unit, onDragEnd: () -> Unit, onDragCancel: () -> Unit, shouldAwaitTouchSlop: () -> Boolean) +func detectSimultaneousDragGestures(onDragEnd: () -> Void, onDragCancel: () -> Void, onDrag: (PointerInputChange, Offset) -> Void, shouldAwaitTouchSlop: () -> Bool) { + awaitEachGesture { + let down = awaitFirstDown(requireUnconsumed: false, pass: PointerEventPass.Initial) + let awaitTouchSlop = shouldAwaitTouchSlop() + let touchSlop = viewConfiguration.touchSlop + var overSlop = Offset.Zero + var accepted = !awaitTouchSlop + if accepted { + onDrag(down, Offset.Zero) + } + + while true { + let event = awaitPointerEvent(pass: PointerEventPass.Initial) + let change = event.changes.firstOrNull { $0.id == down.id } + if change == nil { + if accepted { + onDragEnd() + } else { + onDragCancel() + } + break + } + + if change!.changedToUpIgnoreConsumed() { + if accepted { + onDragEnd() + } else { + onDragCancel() + } + break + } + + let delta = change!.positionChange() + if accepted { + onDrag(change!, delta) + continue + } + + overSlop += delta + let distance = overSlop.getDistance() + if distance >= touchSlop { + let touchSlopOffset = overSlop / distance * touchSlop + onDrag(change!, overSlop - touchSlopOffset) + accepted = true + } + } + } +} #endif /* diff --git a/Tests/SkipUITests/SkipUITests.swift b/Tests/SkipUITests/SkipUITests.swift index 8f1378be..03fcdb89 100644 --- a/Tests/SkipUITests/SkipUITests.swift +++ b/Tests/SkipUITests/SkipUITests.swift @@ -80,6 +80,7 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsDisplayed @@ -89,6 +90,7 @@ import androidx.compose.ui.test.click import androidx.compose.ui.test.down import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasImeAction import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.isFocused @@ -113,6 +115,7 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInputSelection import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performGesture +import androidx.compose.ui.test.swipeUp import androidx.compose.ui.test.up import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi @@ -315,6 +318,205 @@ final class SkipUITests: SkipUITestCase { } } + func testSimultaneousGestureTapObserver() throws { + #if !SKIP + throw XCTSkip("simultaneousGesture partial parity is a Compose behavior") + #else + let gestureTapCount = mutableStateOf(0) + let simultaneousTapCount = mutableStateOf(0) + try testUI(view: { + SimultaneousGestureTapTestView( + gestureAction: { gestureTapCount.value += 1 }, + simultaneousAction: { simultaneousTapCount.value += 1 } + ) + .accessibilityIdentifier("test-view") + }, eval: { rule in + rule.onNodeWithTag("simultaneous.tap.target").performClick() + rule.waitForIdle() + XCTAssertEqual(gestureTapCount.value, 1) + XCTAssertEqual(simultaneousTapCount.value, 1) + }) + #endif + } + + func testSimultaneousGestureNoneMaskDoesNotFire() throws { + #if !SKIP + throw XCTSkip("simultaneousGesture partial parity is a Compose behavior") + #else + let gestureTapCount = mutableStateOf(0) + let simultaneousTapCount = mutableStateOf(0) + try testUI(view: { + SimultaneousGestureNoneMaskTestView( + gestureAction: { gestureTapCount.value += 1 }, + simultaneousAction: { simultaneousTapCount.value += 1 } + ) + .accessibilityIdentifier("test-view") + }, eval: { rule in + rule.onNodeWithTag("simultaneous.none.target").performClick() + rule.waitForIdle() + XCTAssertEqual(gestureTapCount.value, 1) + XCTAssertEqual(simultaneousTapCount.value, 0) + }) + #endif + } + + func testSimultaneousDragGestureObservesScrollViewWithoutBlockingScroll() throws { + #if !SKIP + throw XCTSkip("simultaneousGesture drag observation is a Compose behavior") + #else + let dragChangeCount = mutableStateOf(0) + try testUI(view: { + SimultaneousGestureScrollViewTestView { + dragChangeCount.value += 1 + } + .accessibilityIdentifier("test-view") + }, eval: { rule in + rule.waitForIdle() + + rule.onNodeWithTag("simultaneous.scroll.content").performTouchInput { swipeUp() } + rule.waitForIdle() + XCTAssertGreaterThan(dragChangeCount.value, 0) + + XCTAssertTrue(rule.onRoot().fetchSemanticsNode().containsScrollAction()) + }) + #endif + } + + struct SimultaneousGestureTapTestView: View { + let gestureAction: () -> Void + let simultaneousAction: () -> Void + + var body: some View { + VStack { + Text("Tap") + .accessibilityIdentifier("simultaneous.tap.target") + .gesture( + TapGesture().onEnded { _ in + gestureAction() + } + ) + .simultaneousGesture( + TapGesture().onEnded { _ in + simultaneousAction() + } + ) + } + } + } + + struct SimultaneousGestureNoneMaskTestView: View { + let gestureAction: () -> Void + let simultaneousAction: () -> Void + + var body: some View { + VStack { + Text("Tap") + .accessibilityIdentifier("simultaneous.none.target") + .gesture( + TapGesture().onEnded { _ in + gestureAction() + } + ) + .simultaneousGesture( + TapGesture().onEnded { _ in + simultaneousAction() + }, + including: .none + ) + } + } + } + + struct SimultaneousGestureScrollViewTestView: View { + let simultaneousAction: () -> Void + + var body: some View { + ScrollView { + VStack(spacing: 0) { + ForEach(0..<30) { index in + Text("Row \(index)") + .frame(height: 40) + .accessibilityIdentifier("simultaneous.scroll.row.\(index)") + } + } + .accessibilityIdentifier("simultaneous.scroll.content") + } + .frame(height: 220) + .accessibilityIdentifier("simultaneous.scroll.container") + .simultaneousGesture( + DragGesture().onChanged { _ in + simultaneousAction() + } + ) + } + } + + func testConditionalTabDoesNotRenderBlankNavigationItem() throws { + #if !SKIP + throw XCTSkip("TabView navigation item count is a Compose behavior") + #else + try testUI(view: { + ConditionalTabTestView() + .accessibilityIdentifier("test-view") + }, eval: { rule in + rule.waitForIdle() + rule.onAllNodes(hasClickAction()).assertCountEquals(2) + rule.onNodeWithText("First").assertIsDisplayed() + rule.onNodeWithText("Second").assertIsDisplayed() + }) + #endif + } + + func testTitlelessRootNavigationStackDoesNotReserveTopBarSpace() throws { + #if !SKIP + throw XCTSkip("Navigation top bar measurement is a Compose behavior") + #else + try testUI(view: { + TitlelessNavigationStackTestView() + .accessibilityIdentifier("test-view") + }, eval: { rule in + rule.waitForIdle() + let topBounds = rule.onNodeWithTag("navigation.titleless.top").fetchSemanticsNode().boundsInRoot + let defaultTopBarHeightPx = with(rule.density) { 112.dp.toPx() } + XCTAssertLessThan(topBounds.top, defaultTopBarHeightPx) + }) + #endif + } + + struct TitlelessNavigationStackTestView: View { + var body: some View { + NavigationStack { + VStack(spacing: 0) { + Text("Top") + .accessibilityIdentifier("navigation.titleless.top") + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + } + } + + struct ConditionalTabTestView: View { + var body: some View { + TabView { + Text("First Content") + .tabItem { + Label("First", systemImage: "house") + } + if false { + Text("Hidden Content") + .tabItem { + Label("Hidden", systemImage: "star") + } + } + Text("Second Content") + .tabItem { + Label("Second", systemImage: "gear") + } + } + } + } + func testMenuAccessibilityIdentifier() throws { try testUI(view: { MenuTestView().accessibilityIdentifier("test-view") @@ -766,6 +968,15 @@ final class SkipUITests: SkipUITestCase { #if SKIP extension SemanticsNode { + func containsScrollAction() -> Bool { + if config.getOrNull(SemanticsActions.ScrollBy) != nil { + return true + } + return children.any { child in + child.containsScrollAction() + } + } + /// Returns a description of this node's hierarchy and attributes func treeString(indent: String = "") -> String { let nodeDescription = "\(indent)Node:\(attrList())" From 912c24fd0f91f97bdcd6164b2f23174a24ddcf0a Mon Sep 17 00:00:00 2001 From: tifroz Date: Wed, 3 Jun 2026 12:36:26 -0700 Subject: [PATCH 3/6] Remove overscroll debug logging --- .../SkipUI/View/AdditionalViewModifiers.swift | 35 ++----------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index 5169c323..6899f2e9 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -62,7 +62,6 @@ import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat -import android.util.Log #elseif canImport(CoreGraphics) import struct CoreGraphics.CGAffineTransform import struct CoreGraphics.CGFloat @@ -1043,7 +1042,6 @@ extension View { ) -> any View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in - Log.d("ChromeStyleMenuOverscroll", "modifier render enabled=\(isEnabled)") let enabledState = rememberUpdatedState(isEnabled) let onPullState = rememberUpdatedState(onPull) let onEndState = rememberUpdatedState(onEnd) @@ -1055,14 +1053,10 @@ extension View { }, onPullPx: { pullPx in let pullDp = with(density) { pullPx.toDp() } - Log.d("ChromeStyleMenuOverscroll", "onPull bridge call pullDp=\(pullDp.value)") onPullState.value(CGFloat(pullDp.value)) - Log.d("ChromeStyleMenuOverscroll", "onPull bridge return pullDp=\(pullDp.value)") }, onEnd: { - Log.d("ChromeStyleMenuOverscroll", "onEnd bridge call") onEndState.value() - Log.d("ChromeStyleMenuOverscroll", "onEnd bridge return") } ) } @@ -1815,8 +1809,6 @@ final class AndroidVerticalOverscrollPullDownConnection: NestedScrollConnection let onEnd: () -> Void var accumulatedPullPx = Float(0.0) - var preScrollSample = 0 - var postScrollSample = 0 init( isEnabled: @escaping () -> Bool, @@ -1826,46 +1818,30 @@ final class AndroidVerticalOverscrollPullDownConnection: NestedScrollConnection self.isEnabled = isEnabled self.onPullPx = onPullPx self.onEnd = onEnd - log("connection init") } override func onPreScroll(available: Offset, source: NestedScrollSource) -> Offset { - preScrollSample += 1 - let enabled = isEnabled() - if source == NestedScrollSource.Drag && (preScrollSample <= 20 || preScrollSample % 20 == 0) { - log("pre sample=\(preScrollSample) enabled=\(enabled) availableY=\(available.y) accumulated=\(accumulatedPullPx)") - } - - guard source == NestedScrollSource.Drag, enabled, accumulatedPullPx > Float(0.0) else { + guard source == NestedScrollSource.Drag, isEnabled(), accumulatedPullPx > Float(0.0) else { return Offset.Zero } let previousPullPx = accumulatedPullPx accumulatedPullPx = max(Float(0.0), accumulatedPullPx + available.y) onPullPx(accumulatedPullPx) - log("pre consume deltaY=\(accumulatedPullPx - previousPullPx) accumulated=\(accumulatedPullPx)") return Offset(x: Float(0.0), y: accumulatedPullPx - previousPullPx) } override func onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource) -> Offset { - postScrollSample += 1 - let enabled = isEnabled() - if source == NestedScrollSource.Drag && (postScrollSample <= 40 || postScrollSample % 20 == 0) { - log("post sample=\(postScrollSample) enabled=\(enabled) consumedY=\(consumed.y) availableY=\(available.y) accumulated=\(accumulatedPullPx)") - } - - guard source == NestedScrollSource.Drag, enabled, available.y > Float(0.0) else { + guard source == NestedScrollSource.Drag, isEnabled(), available.y > Float(0.0) else { return Offset.Zero } accumulatedPullPx += available.y onPullPx(accumulatedPullPx) - log("post consume availableY=\(available.y) accumulated=\(accumulatedPullPx)") return Offset(x: Float(0.0), y: available.y) } override func onPostFling(consumed: Velocity, available: Velocity) async -> Velocity { - log("fling consumedY=\(consumed.y) availableY=\(available.y) accumulated=\(accumulatedPullPx)") guard accumulatedPullPx > Float(0.0) else { return Velocity.Zero } @@ -1874,13 +1850,6 @@ final class AndroidVerticalOverscrollPullDownConnection: NestedScrollConnection onEnd() return Velocity(x: Float(0.0), y: available.y) } - - private func log(_ message: String) { - let result = Log.d("ChromeStyleMenuOverscroll", message) - if result == Int.min { - return - } - } } #endif From 79f249ec2a4def0215879ad7721952256fcad8ca Mon Sep 17 00:00:00 2001 From: tifroz Date: Wed, 3 Jun 2026 12:49:14 -0700 Subject: [PATCH 4/6] Document simultaneous gesture support --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f0dc024a..fe30569b 100644 --- a/README.md +++ b/README.md @@ -1660,6 +1660,17 @@ Support levels: + + 🟢 + +
+ .simultaneousGesture (example) + +
+ + ✅ .gradient (example) @@ -2671,13 +2682,14 @@ ForEach([person1, person2, person3], id: \.fullName) { person in ### Gestures -SkipUI currently supports tap, long press, drag, magnify, and rotate gestures. You can use either the general `.gesture` modifier or the specialized modifiers like `.onTapGesture` to add gesture support to your views. The following limitations apply: +SkipUI currently supports tap, long press, drag, magnify, and rotate gestures. You can use the general `.gesture` modifier, `.simultaneousGesture` for supported gesture observers, or specialized modifiers like `.onTapGesture` to add gesture support to your views. The following limitations apply: - `@GestureState` is only supported in Skip Fuse. Use the `Gesture.onEnded` modifier to reset your state. - Tap counts > 2 are not supported. - Gesture velocity and predicted end location are always reported as zero and the current location, respectively. - Only the `onChanged` and `onEnded` gesture modifiers are supported. - Customization of minimum touch duration, distance, etc. is generally not supported. +- `.simultaneousGesture` support is partial. It can observe supported gestures on the same rendered view, including drag observation while scroll views continue scrolling, but only `.all` and `.none` have meaningful mask behavior. `.gesture` and `.subviews` masks are not distinguished. - When applying gestures to an offset view, place any gesture modifiers **before** the `.offset` modifier. There is one exception to the last limitation: you **can** create a `DragGesture(minimumDistance: 0)` in order to detect touch down events immediately. From c269fa3cb7cd1597f3b511d162c8daf2d3ad2aaf Mon Sep 17 00:00:00 2001 From: tifroz Date: Tue, 16 Jun 2026 19:00:38 -0700 Subject: [PATCH 5/6] fixed parameters order in detectSimultaneousDragGestures --- Sources/SkipUI/SkipUI/System/Gesture.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SkipUI/SkipUI/System/Gesture.swift b/Sources/SkipUI/SkipUI/System/Gesture.swift index 25eaf3f4..33a5baaa 100644 --- a/Sources/SkipUI/SkipUI/System/Gesture.swift +++ b/Sources/SkipUI/SkipUI/System/Gesture.swift @@ -967,7 +967,7 @@ func detectDragGesturesWithScrollAxes(onDragEnd: () -> Void, onDragCancel: () -> } // SKIP DECLARE: suspend fun PointerInputScope.detectSimultaneousDragGestures(onDrag: (PointerInputChange, Offset) -> Unit, onDragEnd: () -> Unit, onDragCancel: () -> Unit, shouldAwaitTouchSlop: () -> Boolean) -func detectSimultaneousDragGestures(onDragEnd: () -> Void, onDragCancel: () -> Void, onDrag: (PointerInputChange, Offset) -> Void, shouldAwaitTouchSlop: () -> Bool) { +func detectSimultaneousDragGestures(onDrag: (PointerInputChange, Offset) -> Void, onDragEnd: () -> Void, onDragCancel: () -> Void, shouldAwaitTouchSlop: () -> Bool) { awaitEachGesture { let down = awaitFirstDown(requireUnconsumed: false, pass: PointerEventPass.Initial) let awaitTouchSlop = shouldAwaitTouchSlop() From 58f62ef6a53ae1e7e59ba50590439b13436ee87c Mon Sep 17 00:00:00 2001 From: tifroz Date: Wed, 17 Jun 2026 11:49:32 -0700 Subject: [PATCH 6/6] Fix SkipUI gesture and presentation build issues --- Sources/SkipUI/SkipUI/Layout/Presentation.swift | 3 ++- Sources/SkipUI/SkipUI/System/Gesture.swift | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SkipUI/SkipUI/Layout/Presentation.swift b/Sources/SkipUI/SkipUI/Layout/Presentation.swift index 3a488072..a5b4cd16 100644 --- a/Sources/SkipUI/SkipUI/Layout/Presentation.swift +++ b/Sources/SkipUI/SkipUI/Layout/Presentation.swift @@ -133,7 +133,8 @@ private let AlertDialogMaxWidth: Dp = 560.dp let detentPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: PresentationDetentPreferenceKey.self)) } let detentPreferencesCollector = PreferenceCollector(key: PresentationDetentPreferences.self, state: detentPreferences) let reducedDetentPreferences = detentPreferences.value.reduced - let (dragIndicatorPreferences, dragIndicatorPreferencesCollector) = rememberSaveablePreferenceCollector(key: PresentationDragIndicatorPreferenceKey.self, stateSaver: context.stateSaver as! Saver, Any>, collectorKey: PresentationDragIndicatorPreferences.self) + let dragIndicatorPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: PresentationDragIndicatorPreferenceKey.self)) } + let dragIndicatorPreferencesCollector = PreferenceCollector(key: PresentationDragIndicatorPreferences.self, state: dragIndicatorPreferences) let reducedDragIndicatorVisibility = dragIndicatorPreferences.value.reduced.visibility if !isFullScreen && verticalSizeClass != .compact { diff --git a/Sources/SkipUI/SkipUI/System/Gesture.swift b/Sources/SkipUI/SkipUI/System/Gesture.swift index 33a5baaa..3bb6efda 100644 --- a/Sources/SkipUI/SkipUI/System/Gesture.swift +++ b/Sources/SkipUI/SkipUI/System/Gesture.swift @@ -966,6 +966,8 @@ func detectDragGesturesWithScrollAxes(onDragEnd: () -> Void, onDragCancel: () -> } } +// A pure Swift async function is emitted through Async.run, which moves the detector to Dispatchers.Default. +// Compose pointer input needs this body to stay in the pointerInput coroutine, so declare the direct suspend receiver. // SKIP DECLARE: suspend fun PointerInputScope.detectSimultaneousDragGestures(onDrag: (PointerInputChange, Offset) -> Unit, onDragEnd: () -> Unit, onDragCancel: () -> Unit, shouldAwaitTouchSlop: () -> Boolean) func detectSimultaneousDragGestures(onDrag: (PointerInputChange, Offset) -> Void, onDragEnd: () -> Void, onDragCancel: () -> Void, shouldAwaitTouchSlop: () -> Bool) { awaitEachGesture {