Skip to content

Simultaneous gesture support#457

Merged
marcprux merged 6 commits into
skiptools:mainfrom
tifroz:simultaneousGesture_support
Jun 17, 2026
Merged

Simultaneous gesture support#457
marcprux merged 6 commits into
skiptools:mainfrom
tifroz:simultaneousGesture_support

Conversation

@tifroz

@tifroz tifroz commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add partial Android support for View.simultaneousGesture so tap gestures and drag observers can run alongside an existing gesture instead of replacing it.
  • Add GestureMask.none handling so a simultaneous gesture can be explicitly disabled.
  • Add an Android nested-scroll helper for pull-down over scroll handoff (i.e. convert a drag-to-scroll into drag-to-pull on over scroll )

API Shape

extension View {
    public func simultaneousGesture<V>(_ gesture: any Gesture<V>, isEnabled: Bool) -> some View
    public func bridgedSimultaneousGesture(_ gesture: Any, isEnabled: Bool) -> any View
    public func androidVerticalOverscrollPullDown(
        isEnabled: Bool,
        onPull: @escaping (CGFloat) -> Void,
        onEnd: @escaping () -> Void
    ) -> any View
}

Usage Example

ScrollView {
    content
}
.simultaneousGesture(
    DragGesture().onChanged { _ in
        observedDragCount += 1
    }
)

Limitations

  • This is partial parity. The branch handles the gesture-observer cases covered by tests; GestureMask.all, .gesture, and .subviews are not yet full SwiftUI parity.
  • Full swift test currently fails in unrelated Android snapshot rendering tests (CanvasTests.testZStackMultiOpacityOverlay, LayoutTests.testRotatedSquareAliased, LayoutTests.testRotatedSquare), so PR validation used focused simultaneous-gesture Robolectric tests.

Testing

  • swift test --filter testSimultaneous
  • Robolectric targeted gesture tests:
    gradle testDebug --project-dir .build/plugins/outputs/skip-ui/SkipUITests/destination/skipstone --tests "skip.ui.SkipUITests.testSimultaneous*"
  • Sandbox app testing on device (see android screen capture, description below)

Active horizontal rail swipe the tile rail sideways, then drag vertically on it. The rail should still scroll horizontally, while the simultaneous drag observer updates the Active changed, Active ended, translation counter, and the small handle position.

Vertical scroll observer drag the vertical row list. The list should keep scrolling normally while the simultaneous drag observer updates the Scroll changed, Scroll ended, and translation counters.

.none gesture mask rail drag the second tile rail. It uses the same drag observer with including: .none, so the rail can still be interacted with, but the .none counters should stay at zero.

video-capture-20260603-133314-stream.mp4

}

// 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function doesn't look like it should need "SKIP DECLARE". Can you explain why it was added? There's also an oddity with it: the ordering of the SKIP DECLARE parameters are onDrag:onDragEnd:onDragCancel:shouldAwaitTouchSlop:, but the order of the actual function is onDragEnd:onDragCancel:onDrag:shouldAwaitTouchSlop:.

@tifroz tifroz Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

//SKIP DECLARE is needed because the generated Kotlin must be a suspend fun PointerInputScope...; without it, Skip emits a plain function, so awaitEachGesture, awaitFirstDown, viewConfiguration, etc. lose their receiver context.

I will resubmit after fixing the parameters order

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't you just declare detectSimultaneousDragGestures async in order to generate the suspend fun?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's cleaner but after I made the change to async, the drag observer Android unit test fails.

I am also investigating a new unrelated issue that I think may have been introduced by a recent merge? Unresolved reference 'rememberSaveablePreferenceCollector'

I need to spend a little more time sorting it out, but I should be posting an update tomorrow

@marcprux marcprux Jun 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am also investigating a new unrelated issue that I think may have been introduced by a recent merge? Unresolved reference 'rememberSaveablePreferenceCollector'

Yes, this got broken from a PR collision. It should be fixed in #464.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Submitting a new version with a fix of the unrelated Unresolved reference 'rememberSaveablePreferenceCollector' (can't build without the fix) + brief comments to explain //SKIP DECLARE Vs async.

I kept the //SKIP DECLARE in place because it is slightly more robust and can be unit-tested on roboelectric while the async version cannot. This is very much a tradeoff decision, I am happy using async and removing the unit test if that's your preference

Here is my current understanding:

....with // SKIP DECLARE we get

suspend fun PointerInputScope.detectSimultaneousDragGestures(...) {
    awaitEachGesture {
        ...
    }
}

kotlin generated from async:

internal suspend fun PointerInputScope.detectSimultaneousDragGestures(...): Unit = Async.run {
    awaitEachGesture {
        ...
    }
}

Notice the Async.run wrapper, the main difference between the 2 flavors

This is the failing unit test (With the async flavor, the test failure is dragChangeCount.value is still 0):

rule.waitForIdle()
rule.onNodeWithTag("simultaneous.scroll.content").performTouchInput { swipeUp() }
rule.waitForIdle()
XCTAssertGreaterThan(dragChangeCount.value, 0)

In a sandbox SkipFuse app running on a real device, dragChangeCount.value does reflect the changed values and everything is working as designed. In the roboelectric unit test, the value is never updated even after I tried inserting more delays to simulate a human interaction.

I am not entirely sure why the behaviors diverge (possible Compose / Bridging ?), but going with async would essentially mean we would lose the ability to detect regressions via unit-tests (if there is a smarter way to unit test, I could not figure it out)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that keeping it testable is worth the "SKIP DECLARE" ugliness. There's probably some way we could get the Robolectric test to work (e.g., with some explicit mechanism to wait for the await call), but I won't hold up this PR for it.

tifroz added 5 commits June 16, 2026 19:01
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.
@tifroz tifroz force-pushed the simultaneousGesture_support branch from f7484f2 to c269fa3 Compare June 17, 2026 02:01
@tifroz tifroz requested a review from marcprux June 17, 2026 20:40
@marcprux marcprux merged commit d24310d into skiptools:main Jun 17, 2026
2 checks passed
@tifroz tifroz deleted the simultaneousGesture_support branch June 17, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants