diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SwipeBackTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/DialogSwipeBackTest.kt similarity index 59% rename from compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SwipeBackTest.kt rename to compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/DialogSwipeBackTest.kt index 804bda3d3003d..1bf29a7e2d38f 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SwipeBackTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/DialogSwipeBackTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package androidx.compose.ui.interaction +package androidx.compose.ui.interaction.swipeback import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable @@ -28,17 +28,17 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.background +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.findNodeWithTagOrNull import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.utils.hold -import androidx.compose.ui.test.utils.leftCenter -import androidx.compose.ui.test.utils.offsetBy -import androidx.compose.ui.test.utils.rightCenter import androidx.compose.ui.test.utils.up -import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.navigationevent.NavigationEventInfo import androidx.navigationevent.NavigationEventTransitionState import androidx.navigationevent.NavigationEventTransitionState.InProgress @@ -48,116 +48,159 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -internal class SwipeBackInHostingViewTest : SwipeBackTest( +internal class DialogSwipeBackInHostingViewTest : DialogSwipeBackTest( runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = true, it) } ) -internal class SwipeBackInHostingViewControllerTest : SwipeBackTest( +internal class DialogSwipeBackInHostingViewControllerTest : DialogSwipeBackTest( runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = false, it) } ) -internal abstract class SwipeBackTest( +internal abstract class DialogSwipeBackTest( private val runUIKitInstrumentedTest: (UIKitInstrumentedTest.() -> Unit) -> Unit ) { @Test - fun edgeBackSwipeDoesNotDispatchHorizontalDragToCompose() = runUIKitInstrumentedTest { + fun testEdgeBackSwipeOverDialogDoesNotDispatchHorizontalDragToCompose() = runUIKitInstrumentedTest { var dragDistance = Float.NaN var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle var backCompletedCount = -1 setContent { - TestContent( + DialogBackGestureContent( onDragDistanceChanged = { dragDistance = it }, onTransitionStateChanged = { transitionState = it }, onBackCompletedCountChanged = { backCompletedCount = it } ) } - waitUntil("drag surface should be ready") { - findNodeWithTagOrNull(DRAG_SURFACE) != null && - !dragDistance.isNaN() && - backCompletedCount == 0 - } + waitUntilReady { !dragDistance.isNaN() && backCompletedCount == 0 } val backSwipe = swipeRightFromEdge().hold() - waitUntil("back swipe should be in progress") { - transitionState is InProgress - } + waitForIdle() + assertFalse( + transitionState is InProgress, + "Edge swipe over Dialog should not start root back navigation" + ) assertEquals( expected = 0f, actual = dragDistance, absoluteTolerance = 0.01f, - message = "Edge back swipe should not dispatch horizontal drag deltas to Compose" + message = "Edge back swipe over Dialog should not dispatch horizontal drag deltas to Compose" ) assertEquals( expected = 0, actual = backCompletedCount, - message = "Back gesture should not complete before release" + message = "Back gesture over Dialog should not complete before release" ) backSwipe.up() - waitUntil("back swipe should complete after release") { - backCompletedCount == 1 - } + waitForIdle() + + assertFalse( + transitionState is InProgress, + "Releasing edge swipe over Dialog should still not start root back navigation" + ) + assertEquals( + expected = 0, + actual = backCompletedCount, + message = "Edge swipe over Dialog should not complete root back navigation" + ) } @Test - fun innerSwipeDispatchesHorizontalDragWithoutStartingBack() = runUIKitInstrumentedTest { + fun testInnerSwipeOverDialogDispatchesHorizontalDragWithoutStartingBack() = runUIKitInstrumentedTest { var dragDistance = Float.NaN var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle var backCompletedCount = -1 setContent { - TestContent( + DialogBackGestureContent( onDragDistanceChanged = { dragDistance = it }, onTransitionStateChanged = { transitionState = it }, onBackCompletedCountChanged = { backCompletedCount = it } ) } - waitUntil("drag surface should be ready") { - findNodeWithTagOrNull(DRAG_SURFACE) != null && - !dragDistance.isNaN() && - backCompletedCount == 0 - } + waitUntilReady { !dragDistance.isNaN() && backCompletedCount == 0 } - findNodeWithTag(DRAG_SURFACE).swipe( - fromPosition = { leftCenter().offsetBy(dx = 16.dp) }, - toPosition = { rightCenter().offsetBy(dx = (-16).dp) } - ) + findNodeWithTag(OVERLAY_SURFACE).swipeRight() - waitUntil("inner swipe should dispatch drag deltas to Compose") { + waitUntil("Inner swipe should dispatch drag deltas over Dialog") { dragDistance > 0f } + assertFalse( transitionState is InProgress, - "Inner swipe should not start back navigation" + "Inner swipe over Dialog should not start back navigation" ) assertEquals( expected = 0, actual = backCompletedCount, - message = "Inner swipe should not complete back navigation" + message = "Inner swipe over Dialog should not complete back navigation" ) } } @Composable -private fun TestContent( +private fun DialogBackGestureContent( onDragDistanceChanged: (Float) -> Unit, onTransitionStateChanged: (NavigationEventTransitionState) -> Unit, onBackCompletedCountChanged: (Int) -> Unit, +) { + BackGestureHost( + onTransitionStateChanged = onTransitionStateChanged, + onBackCompletedCountChanged = onBackCompletedCountChanged + ) { + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + dismissOnBackPress = false, + usePlatformDefaultWidth = false, + usePlatformInsets = false + ) + ) { + DraggableSurface(onDragDistanceChanged = onDragDistanceChanged) + } + } +} + +@Composable +private fun DraggableSurface( + onDragDistanceChanged: (Float) -> Unit, ) { var dragDistance by remember { mutableFloatStateOf(0f) } + + onDragDistanceChanged(dragDistance) + + Box( + modifier = Modifier + .background(Color.Red) + .fillMaxSize() + .testTag(OVERLAY_SURFACE) + .draggable( + state = rememberDraggableState { delta -> + dragDistance += delta + }, + orientation = Orientation.Horizontal, + ) + ) +} + +@Composable +private fun BackGestureHost( + onTransitionStateChanged: (NavigationEventTransitionState) -> Unit, + onBackCompletedCountChanged: (Int) -> Unit, + content: @Composable () -> Unit, +) { var backCompletedCount by remember { mutableIntStateOf(0) } val navigationEventState = rememberNavigationEventState( currentInfo = NavigationEventInfo.None, - backInfo = listOf(NavigationEventInfo.None) + backInfo = listOf(NavigationEventInfo.None) ) - onDragDistanceChanged(dragDistance) onTransitionStateChanged(navigationEventState.transitionState) onBackCompletedCountChanged(backCompletedCount) @@ -168,17 +211,17 @@ private fun TestContent( } ) - Box( - modifier = Modifier - .fillMaxSize() - .testTag(DRAG_SURFACE) - .draggable( - state = rememberDraggableState { delta -> - dragDistance += delta - }, - orientation = Orientation.Horizontal, - ) - ) + Box(modifier = Modifier.fillMaxSize()) { + content() + } +} + +private fun UIKitInstrumentedTest.waitUntilReady( + otherConditions: () -> Boolean, +) { + waitUntil("$OVERLAY_SURFACE should be ready") { + findNodeWithTagOrNull(OVERLAY_SURFACE) != null && otherConditions() + } } -private const val DRAG_SURFACE = "dragSurface" +private const val OVERLAY_SURFACE = "overlaySurface" diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/HorizontalScrollSwipeBackTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/HorizontalScrollSwipeBackTest.kt new file mode 100644 index 0000000000000..ac0915302aae8 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/HorizontalScrollSwipeBackTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.interaction.swipeback + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.findNodeWithTagOrNull +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.utils.hold +import androidx.compose.ui.test.utils.up +import androidx.compose.ui.unit.dp +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.NavigationEventTransitionState +import androidx.navigationevent.NavigationEventTransitionState.InProgress +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +internal class HorizontalScrollSwipeBackInHostingViewTest : HorizontalScrollSwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = true, it) } +) + +internal class HorizontalScrollSwipeBackInHostingViewControllerTest : HorizontalScrollSwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = false, it) } +) + +internal abstract class HorizontalScrollSwipeBackTest( + private val runUIKitInstrumentedTest: (UIKitInstrumentedTest.() -> Unit) -> Unit +) { + @Test + fun testEdgeBackSwipeOverHorizontalScrollDoesNotScrollComposeContent() = runUIKitInstrumentedTest { + var scrollOffset = Float.NaN + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setContent { + HorizontalScrollBackGestureContent( + onScrollOffsetChanged = { scrollOffset = it }, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + } + + waitUntilReady { !scrollOffset.isNaN() && backCompletedCount == 0 } + + val backSwipe = swipeRightFromEdge().hold() + waitUntil("Back swipe over horizontal scroll content should start") { + transitionState is InProgress + } + + assertEquals( + expected = 0f, + actual = scrollOffset, + absoluteTolerance = 0.01f, + message = "Edge back swipe should not scroll horizontal Compose content" + ) + + backSwipe.up() + + waitUntil("Back swipe over horizontal scroll content should complete") { + backCompletedCount == 1 + } + } + + @Test + fun testInnerSwipeOverHorizontalScrollScrollsComposeContentWithoutStartingBack() = runUIKitInstrumentedTest { + var scrollOffset = Float.NaN + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setContent { + HorizontalScrollBackGestureContent( + onScrollOffsetChanged = { scrollOffset = it }, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + } + + waitUntilReady { !scrollOffset.isNaN() && backCompletedCount == 0 } + + findNodeWithTag(SCROLL_SURFACE).swipeLeft() + + waitUntil("Inner swipe should scroll horizontal Compose content") { + scrollOffset > 0f + } + + assertFalse( + transitionState is InProgress, + "Inner swipe over horizontal scroll content should not start back navigation" + ) + assertEquals( + expected = 0, + actual = backCompletedCount, + message = "Inner swipe over horizontal scroll content should not complete back navigation" + ) + } +} + +@Composable +private fun HorizontalScrollBackGestureContent( + onScrollOffsetChanged: (Float) -> Unit, + onTransitionStateChanged: (NavigationEventTransitionState) -> Unit, + onBackCompletedCountChanged: (Int) -> Unit, +) { + var scrollOffset by remember { mutableFloatStateOf(0f) } + + onScrollOffsetChanged(scrollOffset) + + BackGestureHost( + onTransitionStateChanged = onTransitionStateChanged, + onBackCompletedCountChanged = onBackCompletedCountChanged + ) { + val scrollState = rememberScrollState() + + scrollOffset = scrollState.value.toFloat() + + Row( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .testTag(SCROLL_SURFACE) + .horizontalScroll(scrollState) + ) { + repeat(10) { + Box( + modifier = Modifier + .size(width = 200.dp, height = 160.dp) + .background(if (it % 2 == 0) Color.Red else Color.Blue) + ) + } + } + } +} + +@Composable +private fun BackGestureHost( + onTransitionStateChanged: (NavigationEventTransitionState) -> Unit, + onBackCompletedCountChanged: (Int) -> Unit, + content: @Composable () -> Unit, +) { + var backCompletedCount by remember { mutableIntStateOf(0) } + val navigationEventState = rememberNavigationEventState( + currentInfo = NavigationEventInfo.None, + backInfo = listOf(NavigationEventInfo.None) + ) + + onTransitionStateChanged(navigationEventState.transitionState) + onBackCompletedCountChanged(backCompletedCount) + + NavigationBackHandler( + state = navigationEventState, + onBackCompleted = { + backCompletedCount += 1 + } + ) + + Box(modifier = Modifier.fillMaxSize()) { + content() + } +} + +private fun UIKitInstrumentedTest.waitUntilReady( + otherConditions: () -> Boolean, +) { + waitUntil("$SCROLL_SURFACE should be ready") { + findNodeWithTagOrNull(SCROLL_SURFACE) != null && otherConditions() + } +} + +private const val SCROLL_SURFACE = "scrollSurface" diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/PopupSwipeBackTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/PopupSwipeBackTest.kt new file mode 100644 index 0000000000000..8ae431ac08d26 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/PopupSwipeBackTest.kt @@ -0,0 +1,227 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.interaction.swipeback + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.findNodeWithTagOrNull +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.utils.hold +import androidx.compose.ui.test.utils.up +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.NavigationEventTransitionState +import androidx.navigationevent.NavigationEventTransitionState.InProgress +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +internal class PopupSwipeBackInHostingViewTest : PopupSwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = true, it) } +) + +internal class PopupSwipeBackInHostingViewControllerTest : PopupSwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = false, it) } +) + +internal abstract class PopupSwipeBackTest( + private val runUIKitInstrumentedTest: (UIKitInstrumentedTest.() -> Unit) -> Unit +) { + @Test + fun testEdgeBackSwipeOverPopupDoesNotDispatchHorizontalDragToCompose() = runComposeContainerTest { + var dragDistance = Float.NaN + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setContent { + PopupBackGestureContent( + onDragDistanceChanged = { dragDistance = it }, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + } + + waitUntilReady { !dragDistance.isNaN() && backCompletedCount == 0 } + + val backSwipe = swipeRightFromEdge().hold() + waitForIdle() + + assertFalse( + transitionState is InProgress, + "Edge swipe over Popup should not start root back navigation" + ) + assertEquals( + expected = 0f, + actual = dragDistance, + absoluteTolerance = 0.01f, + message = "Edge back swipe over Popup should not dispatch horizontal drag deltas to Compose" + ) + assertEquals( + expected = 0, + actual = backCompletedCount, + message = "Back gesture over Popup should not complete before release" + ) + + backSwipe.up() + waitForIdle() + + assertFalse( + transitionState is InProgress, + "Releasing edge swipe over Popup should still not start root back navigation" + ) + assertEquals( + expected = 0, + actual = backCompletedCount, + message = "Edge swipe over Popup should not complete root back navigation" + ) + } + + @Test + fun testInnerSwipeOverPopupDispatchesHorizontalDragWithoutStartingBack() = runComposeContainerTest { + var dragDistance = Float.NaN + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setContent { + PopupBackGestureContent( + onDragDistanceChanged = { dragDistance = it }, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + } + + waitUntilReady { !dragDistance.isNaN() && backCompletedCount == 0 } + + findNodeWithTag(OVERLAY_SURFACE).swipeRight() + + waitUntil("Inner swipe should dispatch drag deltas over Popup") { + dragDistance > 0f + } + + assertFalse( + transitionState is InProgress, + "Inner swipe over Popup should not start back navigation" + ) + assertEquals( + expected = 0, + actual = backCompletedCount, + message = "Inner swipe over Popup should not complete back navigation" + ) + } + + private fun runComposeContainerTest(testBlock: UIKitInstrumentedTest.() -> Unit) { + runUIKitInstrumentedTest(testBlock) + } +} + +@Composable +private fun PopupBackGestureContent( + onDragDistanceChanged: (Float) -> Unit, + onTransitionStateChanged: (NavigationEventTransitionState) -> Unit, + onBackCompletedCountChanged: (Int) -> Unit, +) { + BackGestureHost( + onTransitionStateChanged = onTransitionStateChanged, + onBackCompletedCountChanged = onBackCompletedCountChanged + ) { + Popup( + properties = PopupProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false, + focusable = true, + usePlatformDefaultWidth = false, + usePlatformInsets = false + ) + ) { + DraggableSurface(onDragDistanceChanged = onDragDistanceChanged) + } + } +} + +@Composable +private fun DraggableSurface( + onDragDistanceChanged: (Float) -> Unit, +) { + var dragDistance by remember { mutableFloatStateOf(0f) } + + onDragDistanceChanged(dragDistance) + + Box( + modifier = Modifier + .fillMaxSize() + .testTag(OVERLAY_SURFACE) + .draggable( + state = rememberDraggableState { delta -> + dragDistance += delta + }, + orientation = Orientation.Horizontal, + ) + ) +} + +@Composable +private fun BackGestureHost( + onTransitionStateChanged: (NavigationEventTransitionState) -> Unit, + onBackCompletedCountChanged: (Int) -> Unit, + content: @Composable () -> Unit, +) { + var backCompletedCount by remember { mutableIntStateOf(0) } + val navigationEventState = rememberNavigationEventState( + currentInfo = NavigationEventInfo.None, + backInfo = listOf(NavigationEventInfo.None) + ) + + onTransitionStateChanged(navigationEventState.transitionState) + onBackCompletedCountChanged(backCompletedCount) + + NavigationBackHandler( + state = navigationEventState, + onBackCompleted = { + backCompletedCount += 1 + } + ) + + Box(modifier = Modifier.fillMaxSize()) { + content() + } +} + +private fun UIKitInstrumentedTest.waitUntilReady( + otherConditions: () -> Boolean, +) { + waitUntil("$OVERLAY_SURFACE should be ready") { + findNodeWithTagOrNull(OVERLAY_SURFACE) != null && otherConditions() + } +} + +private const val OVERLAY_SURFACE = "overlaySurface" diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/SwipeBackTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/SwipeBackTest.kt new file mode 100644 index 0000000000000..f178733997503 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/swipeback/SwipeBackTest.kt @@ -0,0 +1,392 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.interaction.swipeback + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.findNodeWithTagOrNull +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.utils.hold +import androidx.compose.ui.test.utils.leftCenter +import androidx.compose.ui.test.utils.offsetBy +import androidx.compose.ui.test.utils.rightCenter +import androidx.compose.ui.test.utils.up +import androidx.compose.ui.unit.dp +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.NavigationEventTransitionState +import androidx.navigationevent.NavigationEventTransitionState.InProgress +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import platform.CoreGraphics.CGRectMake +import platform.UIKit.UIModalPresentationFullScreen +import platform.UIKit.UISemanticContentAttributeForceRightToLeft +import platform.UIKit.UIViewController +import platform.UIKit.addChildViewController +import platform.UIKit.didMoveToParentViewController + +internal class SwipeBackInHostingViewTest : SwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = true, it) } +) + +internal class SwipeBackInHostingViewControllerTest : SwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = false, it) } +) + +internal abstract class SwipeBackTest( + private val runUIKitInstrumentedTest: (UIKitInstrumentedTest.() -> Unit) -> Unit +) { + @Test + fun testEdgeBackSwipeInFullscreenContainer() = runUIKitInstrumentedTest { + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setContent { + BackGestureContent( + tag = ROOT_SURFACE, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + } + + waitUntilReady(ROOT_SURFACE) { backCompletedCount == 0 } + + val backSwipe = swipeRightFromEdge().hold() + waitUntil("Back swipe should start in fullscreen container") { + transitionState is InProgress + } + + assertEquals( + expected = NavigationEvent.EDGE_LEFT, + actual = (transitionState as InProgress).latestEvent.swipeEdge + ) + assertEquals( + expected = 0, + actual = backCompletedCount, + message = "Back gesture should not complete before release" + ) + + backSwipe.up() + + waitUntil("Back swipe should complete in fullscreen container") { + backCompletedCount == 1 + } + } + + @Test + fun testEdgeBackSwipeInNonFullscreenContainer() = runUIKitInstrumentedTest { + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setupWindow { + val composeViewController = createViewControllerHostingCompose { + BackGestureContent( + tag = ROOT_SURFACE, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + } + + UIViewController().also { hostViewController -> + hostViewController.addChildViewController(composeViewController) + hostViewController.view.addSubview(composeViewController.view) + composeViewController.view.setFrame(CGRectMake(80.0, 160.0, 220.0, 260.0)) + composeViewController.didMoveToParentViewController(hostViewController) + } + } + + waitUntilReady(ROOT_SURFACE) { backCompletedCount == 0 } + + val backSwipe = swipeRightFromEdge().hold() + waitUntil("Back swipe should start in non-fullscreen container") { + transitionState is InProgress + } + + assertEquals( + expected = NavigationEvent.EDGE_LEFT, + actual = (transitionState as InProgress).latestEvent.swipeEdge + ) + + backSwipe.up() + + waitUntil("Back swipe should complete in non-fullscreen container") { + backCompletedCount == 1 + } + } + + @Test + fun testEdgeBackSwipeInModalContainer() = runUIKitInstrumentedTest { + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + val rootViewController = UIViewController() + setupWindow { rootViewController } + + waitUntil("Root view controller should be attached before presenting modal") { + rootViewController.view.window != null + } + + var presented = false + rootViewController.presentViewController( + viewControllerToPresent = createViewControllerHostingCompose { + BackGestureContent( + tag = ROOT_SURFACE, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + }.apply { + modalPresentationStyle = UIModalPresentationFullScreen + }, + animated = false + ) { + presented = true + } + + waitUntil("Modal container should be presented") { presented } + waitUntilReady(ROOT_SURFACE) { backCompletedCount == 0 } + + val backSwipe = swipeRightFromEdge().hold() + waitUntil("Back swipe should start in modal container") { + transitionState is InProgress + } + + assertEquals( + expected = NavigationEvent.EDGE_LEFT, + actual = (transitionState as InProgress).latestEvent.swipeEdge + ) + + backSwipe.up() + + waitUntil("Back swipe should complete in modal container") { + backCompletedCount == 1 + } + } + + @Test + fun testRtlBackSwipeFromRightEdge() = runUIKitInstrumentedTest { + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setupWindow { + createViewControllerHostingCompose { + BackGestureContent( + tag = ROOT_SURFACE, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + }.apply { + view.semanticContentAttribute = UISemanticContentAttributeForceRightToLeft + } + } + + waitUntilReady(ROOT_SURFACE) { backCompletedCount == 0 } + + val backSwipe = swipeLeftFromEdge().hold() + waitUntil("RTL back swipe should start from the right edge") { + transitionState is InProgress + } + + assertEquals( + expected = NavigationEvent.EDGE_RIGHT, + actual = (transitionState as InProgress).latestEvent.swipeEdge + ) + + backSwipe.up() + + waitUntil("RTL back swipe should complete after release") { + backCompletedCount == 1 + } + } + + @Test + fun testEdgeBackSwipeDoesNotDispatchHorizontalDragToCompose() = runUIKitInstrumentedTest { + var dragDistance = Float.NaN + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setContent { + DragTestContent( + onDragDistanceChanged = { dragDistance = it }, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + } + + waitUntil("drag surface should be ready") { + findNodeWithTagOrNull(DRAG_SURFACE) != null && + !dragDistance.isNaN() && + backCompletedCount == 0 + } + + val backSwipe = swipeRightFromEdge().hold() + + waitUntil("back swipe should be in progress") { + transitionState is InProgress + } + + assertEquals( + expected = 0f, + actual = dragDistance, + absoluteTolerance = 0.01f, + message = "Edge back swipe should not dispatch horizontal drag deltas to Compose" + ) + assertEquals( + expected = 0, + actual = backCompletedCount, + message = "Back gesture should not complete before release" + ) + + backSwipe.up() + + waitUntil("back swipe should complete after release") { + backCompletedCount == 1 + } + } + + @Test + fun testInnerSwipeDispatchesHorizontalDragWithoutStartingBack() = runUIKitInstrumentedTest { + var dragDistance = Float.NaN + var transitionState: NavigationEventTransitionState = NavigationEventTransitionState.Idle + var backCompletedCount = -1 + + setContent { + DragTestContent( + onDragDistanceChanged = { dragDistance = it }, + onTransitionStateChanged = { transitionState = it }, + onBackCompletedCountChanged = { backCompletedCount = it } + ) + } + + waitUntil("drag surface should be ready") { + findNodeWithTagOrNull(DRAG_SURFACE) != null && + !dragDistance.isNaN() && + backCompletedCount == 0 + } + + findNodeWithTag(DRAG_SURFACE).swipe( + fromPosition = { leftCenter().offsetBy(dx = 16.dp) }, + toPosition = { rightCenter().offsetBy(dx = (-16).dp) } + ) + + waitUntil("inner swipe should dispatch drag deltas to Compose") { + dragDistance > 0f + } + assertFalse( + transitionState is InProgress, + "Inner swipe should not start back navigation" + ) + assertEquals( + expected = 0, + actual = backCompletedCount, + message = "Inner swipe should not complete back navigation" + ) + } +} + +@Composable +private fun DragTestContent( + onDragDistanceChanged: (Float) -> Unit, + onTransitionStateChanged: (NavigationEventTransitionState) -> Unit, + onBackCompletedCountChanged: (Int) -> Unit, +) { + var dragDistance by remember { mutableFloatStateOf(0f) } + var backCompletedCount by remember { mutableIntStateOf(0) } + val navigationEventState = rememberNavigationEventState( + currentInfo = NavigationEventInfo.None, + backInfo = listOf(NavigationEventInfo.None) + ) + + onDragDistanceChanged(dragDistance) + onTransitionStateChanged(navigationEventState.transitionState) + onBackCompletedCountChanged(backCompletedCount) + + NavigationBackHandler( + state = navigationEventState, + onBackCompleted = { + backCompletedCount += 1 + } + ) + + Box( + modifier = Modifier + .fillMaxSize() + .testTag(DRAG_SURFACE) + .draggable( + state = rememberDraggableState { delta -> + dragDistance += delta + }, + orientation = Orientation.Horizontal, + ) + ) +} + +@Composable +private fun BackGestureContent( + tag: String, + onTransitionStateChanged: (NavigationEventTransitionState) -> Unit, + onBackCompletedCountChanged: (Int) -> Unit, +) { + var backCompletedCount by remember { mutableIntStateOf(0) } + val navigationEventState = rememberNavigationEventState( + currentInfo = NavigationEventInfo.None, + backInfo = listOf(NavigationEventInfo.None) + ) + + onTransitionStateChanged(navigationEventState.transitionState) + onBackCompletedCountChanged(backCompletedCount) + + NavigationBackHandler( + state = navigationEventState, + onBackCompleted = { + backCompletedCount += 1 + } + ) + + Box( + modifier = Modifier + .fillMaxSize() + .testTag(tag) + ) +} + +private fun UIKitInstrumentedTest.waitUntilReady( + tag: String, + otherConditions: () -> Boolean, +) { + waitUntil("$tag should be ready") { + findNodeWithTagOrNull(tag) != null && otherConditions() + } +} + +private const val ROOT_SURFACE = "rootSurface" +private const val DRAG_SURFACE = "dragSurface"