From 11767c0fd5275032b5f2f1cb995e7c3ec6b0d0ec Mon Sep 17 00:00:00 2001 From: svastven Date: Thu, 11 Jun 2026 01:48:14 +0200 Subject: [PATCH 01/10] Support new iOS 26 contentSwipe UIScreenEdgePanGestureRecognizer --- .../compose/ui/window/InputViews.ios.kt | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt index 602b876df6363..8ceb64320c826 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/InputViews.ios.kt @@ -46,6 +46,7 @@ import platform.CoreGraphics.CGPoint import platform.CoreGraphics.CGRectIsEmpty import platform.CoreGraphics.CGRectZero import platform.Foundation.NSSelectorFromString +import platform.Foundation.NSStringFromClass import platform.UIKit.UIEvent import platform.UIKit.UIEventTypeTouches import platform.UIKit.UIGestureRecognizer @@ -66,6 +67,7 @@ import platform.UIKit.UIView import platform.UIKit.endEditing import platform.UIKit.setAccessibilityElements import platform.UIKit.setState +import platform.darwin.NSObject /** * A reason for why touches are sent to Compose @@ -295,7 +297,7 @@ private class TouchesGestureRecognizer( return if (isInChildHierarchy(preventedGestureRecognizer.view)) { super.canPreventGestureRecognizer(preventedGestureRecognizer) } else if (preventedGestureRecognizer is UIScreenEdgePanGestureRecognizer) { - false + preventedGestureRecognizer.isUINavigationControllerContentSwipeGestureRecognizer() } else { state == UIGestureRecognizerStatePossible || state.isOngoing } @@ -872,3 +874,21 @@ private fun UIView?.hasTrackingUIScrollView(): Boolean { } return false } + +/** + * Detects the private UIKit recognizer that drives iOS 26 full-width `UINavigationController` + * swipe-back interaction. It is a subclass of `UIScreenEdgePanGestureRecognizer` which can start + * anywhere across the horizontal axis of the screen. + * + * Compose needs to be able to prevent this recognizer after Compose content consumes horizontal + * movement, for example when a `HorizontalPager` handles the drag. Without that, UIKit can start + * the navigation pop transition first and cancel Compose's touch stream. + */ +private fun UIGestureRecognizer.isUINavigationControllerContentSwipeGestureRecognizer(): Boolean = + available(OS.Ios to OSVersion(major = 26)) && + this is UIScreenEdgePanGestureRecognizer && + className() == "_UIParallaxTransitionPanGestureRecognizer" && + name == "UINavigationController.contentSwipe" + +@OptIn(BetaInteropApi::class) +private fun NSObject.className() = this.`class`()?.let { NSStringFromClass(it) } From 12f442bc9d47d02ff3d25562c7cd5d30a7ebad48 Mon Sep 17 00:00:00 2001 From: svastven Date: Thu, 11 Jun 2026 01:51:14 +0200 Subject: [PATCH 02/10] Add UIKitNavigationContentSwipeTest test suite --- .../UIKitNavigationContentSwipeTest.kt | 217 ++++++++++++++++++ .../compose/ui/test/UIKitInstrumentedTest.kt | 88 ++++--- 2 files changed, 278 insertions(+), 27 deletions(-) create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt new file mode 100644 index 0000000000000..e8c92abc20a76 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt @@ -0,0 +1,217 @@ +/* + * 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.interop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.mutableIntStateOf +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.AccessibilityTestNode +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.UIKitInstrumentedTestBlock +import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.findNodeWithTagOrNull +import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingView +import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingViewController +import androidx.compose.ui.test.utils.up +import androidx.compose.ui.uikit.embedSubview +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UINavigationController +import platform.UIKit.UIViewController + +@OptIn(ExperimentalForeignApi::class) +internal abstract class UIKitNavigationContentSwipeTest( + private val runUIKitInstrumentedTest: (UIKitInstrumentedTestBlock) -> Unit +) { + private val SwipeDuration = 100.milliseconds + + @Test + fun testSwipeRightOnPagerDoesNotPopController() = runUIKitInstrumentedTest { + val currentPage = mutableIntStateOf(0) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + delay(10) + + swipeRight(fromNode = findNodeWithTag("pager")) + + delay(500) + + assertEquals(2, navigationController.viewControllers.size) + assertNotNull(findNodeWithTagOrNull("pager")) + assertEquals(0, currentPage.value) + } + + @Test + fun testSwipeLeftOnPagerChangesPage() = runUIKitInstrumentedTest { + val currentPage = mutableIntStateOf(0) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + delay(10) + + swipeLeft(fromNode = findNodeWithTag("pager")) + + assertEquals(1, currentPage.value) + assertEquals(2, navigationController.viewControllers.size) + } + + @Test + fun testSwipeLeftOutsidePagerNoChanges() = runUIKitInstrumentedTest { + val initialPage = 1 + val currentPage = mutableIntStateOf(initialPage) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + delay(10) + + swipeLeft(fromNode = findNodeWithTag("outsideBox")) + + assertEquals(initialPage, currentPage.value) + assertEquals(2, navigationController.viewControllers.size) + } + + @Test + fun testSwipeRightOutsidePagerPopsController() = runUIKitInstrumentedTest { + val initialPage = 1 + val currentPage = mutableIntStateOf(initialPage) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + delay(10) + + swipeRight(fromNode = findNodeWithTag("outsideBox")) + + // wait for pop animation to finish + delay(500) + + assertNull(findNodeWithTagOrNull("pager")) + assertNull(findNodeWithTagOrNull("outsideBox")) + assertEquals(1, navigationController.viewControllers.size) + } + + private fun UIKitInstrumentedTest.swipeRight(fromNode: AccessibilityTestNode) { + fromNode.touchDown() + .dragTo(x = screenSize.width - 16.dp, duration = SwipeDuration) + .up() + + waitForIdle() + } + + private fun UIKitInstrumentedTest.swipeLeft(fromNode: AccessibilityTestNode) { + fromNode.touchDown() + .dragTo(x = 16.dp, duration = SwipeDuration) + .up() + + waitForIdle() + } + + private val UIKitInstrumentedTest.navigationController: UINavigationController get() { + return assertNotNull(appDelegate.window?.rootViewController as? UINavigationController) + } + + private fun UIKitInstrumentedTest.setNavigationControllerContent( + content: @Composable () -> Unit = {} + ) { + val firstViewController = UIViewController() + val secondViewController = + if (useHostingView) { + UIViewController().also { + it.view.embedSubview(createHostingView(content = content)) + } + } else { + createHostingViewController(content = content) + } + val navigationController = UINavigationController() + + navigationController.setViewControllers( + listOf(firstViewController, secondViewController), false + ) + + appDelegate.setUpWindow(navigationController) + + waitForIdle() + } +} + +@Composable +private fun TestContent( + currentPage: MutableIntState +) { + val pagerColors = listOf(Color.Red, Color.Green, Color.Blue) + val pagerState = rememberPagerState(initialPage = currentPage.value) { 3 } + + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .testTag("pager") + ) { page -> + currentPage.value = page + Box(modifier = Modifier + .fillMaxSize() + .background(pagerColors[page]) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .testTag("outsideBox") + ) + } +} + +internal class UIKitNavigationContentSwipeInHostingViewTest : UIKitNavigationContentSwipeTest( + runUIKitInstrumentedTest = ::runUIKitInstrumentedTestInHostingView +) + +internal class UIKitNavigationContentSwipeInHostingViewControllerTest : UIKitNavigationContentSwipeTest( + runUIKitInstrumentedTest = ::runUIKitInstrumentedTestInHostingViewController +) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 0646421cf2e17..035b33ba2279c 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -107,6 +107,8 @@ import platform.darwin.NSObject import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue +internal typealias UIKitInstrumentedTestBlock = UIKitInstrumentedTest.() -> Unit + /** * Sets up the test environment for iOS instrumented tests, runs the given [test][testBlock] against * UIView- and UIViewController-based Compose Container. @@ -115,7 +117,12 @@ import platform.darwin.dispatch_get_main_queue * assertions on it. * @param [testBlock] The test function. */ -internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Unit) { +internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTestBlock) { + runUIKitInstrumentedTestInHostingView(testBlock) + runUIKitInstrumentedTestInHostingViewController(testBlock) +} + +internal fun runUIKitInstrumentedTestInHostingView(testBlock: UIKitInstrumentedTestBlock) { println("Debug: Running test with ComposeHostingView") with(UIKitInstrumentedTest(useHostingView = true)) { try { @@ -124,7 +131,9 @@ internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Uni tearDown() } } +} +internal fun runUIKitInstrumentedTestInHostingViewController(testBlock: UIKitInstrumentedTestBlock) { println("Debug: Running test with ComposeHostingViewController") with(UIKitInstrumentedTest(useHostingView = false)) { try { @@ -187,7 +196,7 @@ internal fun runUIKitInstrumentedTest( internal fun runUIKitInstrumentedTest( ignoreIf: Boolean, ignoreNotes: String, - testBlock: UIKitInstrumentedTest.() -> Unit + testBlock: UIKitInstrumentedTestBlock ) { if (ignoreIf) { println("Debug: Ignored test: $ignoreNotes") @@ -208,7 +217,7 @@ internal fun runUIKitInstrumentedTest( */ @OptIn(ExperimentalForeignApi::class) internal class UIKitInstrumentedTest( - private val useHostingView: Boolean + val useHostingView: Boolean ) { companion object { fun delay(timeoutMillis: Long) { @@ -283,31 +292,11 @@ internal class UIKitInstrumentedTest( AccessibilityNotification.onNotificationPostedForTests = { accessibilityNotifications.add(it) } - val innerConfigure: ComposeContainerConfiguration.() -> Unit = { - enforceStrictPlistSanityCheck = false - configure() - } - val rootViewController: UIViewController = if (useHostingView) { - hostingView = ComposeHostingView( - configuration = ComposeUIViewConfiguration().apply(innerConfigure), - content = content, - coroutineContext = coroutineContext - ) - UIViewController().also { - it.view.embedSubview(hostingView!!) - } - } else { - ComposeHostingViewController( - configuration = ComposeUIViewControllerConfiguration().apply(innerConfigure), - content = content, - coroutineContext = coroutineContext - ).also { - hostingViewController = it - } - } + appDelegate.setUpWindow( + createRootViewController(configure, content) + ) - appDelegate.setUpWindow(rootViewController) waitForIdle() if (appDelegate.requestInterfaceOrientationChangeIfNeeded(interfaceOrientation)) { @@ -315,6 +304,51 @@ internal class UIKitInstrumentedTest( } } + fun createRootViewController( + configure: ComposeContainerConfiguration.() -> Unit = {}, + content: @Composable () -> Unit + ): UIViewController = if (useHostingView) { + UIViewController().also { + it.view.embedSubview(createHostingView(configure, content)) + } + } else { + createHostingViewController(configure, content) + } + + fun createHostingView( + configure: ComposeUIViewConfiguration.() -> Unit = {}, + content: @Composable () -> Unit + ): ComposeHostingView { + val configuration = ComposeUIViewConfiguration() + .apply({ enforceStrictPlistSanityCheck = false }) + .apply(configure) + + return ComposeHostingView( + configuration = configuration, + content = content, + coroutineContext = coroutineContext + ).also { + hostingView = it + } + } + + fun createHostingViewController( + configure: ComposeUIViewControllerConfiguration.() -> Unit = {}, + content: @Composable () -> Unit + ): ComposeHostingViewController { + val configuration = ComposeUIViewControllerConfiguration() + .apply({ enforceStrictPlistSanityCheck = false }) + .apply(configure) + + return ComposeHostingViewController( + configuration = configuration, + content = content, + coroutineContext = coroutineContext + ).also { + this.hostingViewController = it + } + } + fun tearDown() { // Stop text editing and hide keyboard if any viewController.view.endEditing(force = true) @@ -756,4 +790,4 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() { } != null } delay(500) // wait for toolbar animation -} \ No newline at end of file +} From 0c7e377f956ce0c050ed9bbecfae023064ad5779 Mon Sep 17 00:00:00 2001 From: svastven Date: Thu, 11 Jun 2026 19:56:18 +0200 Subject: [PATCH 03/10] Make useHostingView private --- .../ui/interop/UIKitNavigationContentSwipeTest.kt | 9 +-------- .../androidx/compose/ui/test/UIKitInstrumentedTest.kt | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt index e8c92abc20a76..585df9acaa201 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt @@ -153,14 +153,7 @@ internal abstract class UIKitNavigationContentSwipeTest( content: @Composable () -> Unit = {} ) { val firstViewController = UIViewController() - val secondViewController = - if (useHostingView) { - UIViewController().also { - it.view.embedSubview(createHostingView(content = content)) - } - } else { - createHostingViewController(content = content) - } + val secondViewController = createRootViewController(content = content) val navigationController = UINavigationController() navigationController.setViewControllers( diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 035b33ba2279c..82f75289e16ff 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -217,7 +217,7 @@ internal fun runUIKitInstrumentedTest( */ @OptIn(ExperimentalForeignApi::class) internal class UIKitInstrumentedTest( - val useHostingView: Boolean + private val useHostingView: Boolean ) { companion object { fun delay(timeoutMillis: Long) { From de08beedff4b3bc2e9775b3972d897afbb191804 Mon Sep 17 00:00:00 2001 From: svastven Date: Thu, 11 Jun 2026 20:26:32 +0200 Subject: [PATCH 04/10] Remove unused import --- .../compose/ui/interop/UIKitNavigationContentSwipeTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt index 585df9acaa201..17f0c9a66afb2 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.test.findNodeWithTagOrNull import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingView import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingViewController import androidx.compose.ui.test.utils.up -import androidx.compose.ui.uikit.embedSubview import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals From fcec3e024304d0959986a6524d66cb9ac6bf89e0 Mon Sep 17 00:00:00 2001 From: svastven Date: Fri, 12 Jun 2026 01:09:36 +0200 Subject: [PATCH 05/10] Update tests availability --- ...est.kt => UIKitNavigationSwipeBackTest.kt} | 163 ++++++++++++++---- .../compose/ui/test/UIKitInstrumentedTest.kt | 104 ++++++----- .../compose/ui/test/utils/DpRect+Utils.kt | 22 ++- .../compose/ui/test/utils/UITouch+Utils.kt | 4 +- 4 files changed, 211 insertions(+), 82 deletions(-) rename compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/{UIKitNavigationContentSwipeTest.kt => UIKitNavigationSwipeBackTest.kt} (52%) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt similarity index 52% rename from compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt rename to compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt index 17f0c9a66afb2..602fb04bc7351 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationContentSwipeTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt @@ -31,30 +31,28 @@ 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.AccessibilityTestNode import androidx.compose.ui.test.UIKitInstrumentedTest -import androidx.compose.ui.test.UIKitInstrumentedTestBlock import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.findNodeWithTagOrNull -import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingView -import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingViewController -import androidx.compose.ui.test.utils.up +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.utils.center +import androidx.compose.ui.test.utils.rightCenter import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.time.Duration.Companion.milliseconds import kotlinx.cinterop.ExperimentalForeignApi +import org.jetbrains.skiko.OS +import org.jetbrains.skiko.OSVersion +import org.jetbrains.skiko.available import platform.UIKit.UINavigationController import platform.UIKit.UIViewController @OptIn(ExperimentalForeignApi::class) -internal abstract class UIKitNavigationContentSwipeTest( - private val runUIKitInstrumentedTest: (UIKitInstrumentedTestBlock) -> Unit +internal abstract class UIKitNavigationSwipeBackTest( + private val runUIKitInstrumentedTest: (UIKitInstrumentedTest.() -> Unit) -> Unit ) { - private val SwipeDuration = 100.milliseconds - @Test fun testSwipeRightOnPagerDoesNotPopController() = runUIKitInstrumentedTest { val currentPage = mutableIntStateOf(0) @@ -65,7 +63,7 @@ internal abstract class UIKitNavigationContentSwipeTest( delay(10) - swipeRight(fromNode = findNodeWithTag("pager")) + findNodeWithTag("pager").swipeRight() delay(500) @@ -84,7 +82,9 @@ internal abstract class UIKitNavigationContentSwipeTest( delay(10) - swipeLeft(fromNode = findNodeWithTag("pager")) + findNodeWithTag("pager").swipeLeft() + + waitForIdle() assertEquals(1, currentPage.value) assertEquals(2, navigationController.viewControllers.size) @@ -101,14 +101,35 @@ internal abstract class UIKitNavigationContentSwipeTest( delay(10) - swipeLeft(fromNode = findNodeWithTag("outsideBox")) + findNodeWithTag("outsideBox").swipeLeft() assertEquals(initialPage, currentPage.value) assertEquals(2, navigationController.viewControllers.size) } @Test - fun testSwipeRightOutsidePagerPopsController() = runUIKitInstrumentedTest { + fun testSwipeRightFromEdgePopsController() = runUIKitInstrumentedTest { + setNavigationControllerContent { + TestContent(currentPage = mutableIntStateOf(1)) + } + + delay(10) + + swipeRightFromEdge() + + // wait for pop animation to finish + delay(500) + + assertNull(findNodeWithTagOrNull("pager")) + assertNull(findNodeWithTagOrNull("outsideBox")) + assertEquals(1, navigationController.viewControllers.size) + } + + @Test + fun testSwipeRightFromCenterOutsidePagerDoesNotPopController() = runUIKitInstrumentedTest( + ignoreIf = available(OS.Ios to OSVersion(major = 26)), + ignoreNotes = "Full-width swipe gesture is not recognized on iOS < 26" + ){ val initialPage = 1 val currentPage = mutableIntStateOf(initialPage) @@ -118,7 +139,34 @@ internal abstract class UIKitNavigationContentSwipeTest( delay(10) - swipeRight(fromNode = findNodeWithTag("outsideBox")) + findNodeWithTag("outsideBox").swipe( + fromPosition = { center() }, + toPosition = { rightCenter() }, + ) + + // wait for pop animation to finish if any + delay(500) + + assertNotNull(findNodeWithTagOrNull("pager")) + assertNotNull(findNodeWithTagOrNull("outsideBox")) + assertEquals(2, navigationController.viewControllers.size) + } + + @Test + fun testSwipeRightOutsidePagerPopsControllerOnIos26() = runUIKitInstrumentedTest( + ignoreIf = !available(OS.Ios to OSVersion(major = 26)), + ignoreNotes = "Full-width swipe gesture is not recognized on iOS < 26" + ) { + val initialPage = 1 + val currentPage = mutableIntStateOf(initialPage) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + delay(10) + + findNodeWithTag("outsideBox").swipeRight() // wait for pop animation to finish delay(500) @@ -128,20 +176,55 @@ internal abstract class UIKitNavigationContentSwipeTest( assertEquals(1, navigationController.viewControllers.size) } - private fun UIKitInstrumentedTest.swipeRight(fromNode: AccessibilityTestNode) { - fromNode.touchDown() - .dragTo(x = screenSize.width - 16.dp, duration = SwipeDuration) - .up() + @Test + fun testSwipeRightFromEdgeOutsidePagerPopsControllerOnIos26() = runUIKitInstrumentedTest( + ignoreIf = !available(OS.Ios to OSVersion(major = 26)), + ignoreNotes = "Full-width swipe gesture is not recognized on iOS < 26" + ) { + val initialPage = 1 + val currentPage = mutableIntStateOf(initialPage) - waitForIdle() + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + delay(10) + + swipeRightFromEdge() + + // wait for pop animation to finish + delay(500) + + assertNull(findNodeWithTagOrNull("pager")) + assertNull(findNodeWithTagOrNull("outsideBox")) + assertEquals(1, navigationController.viewControllers.size) } - private fun UIKitInstrumentedTest.swipeLeft(fromNode: AccessibilityTestNode) { - fromNode.touchDown() - .dragTo(x = 16.dp, duration = SwipeDuration) - .up() + @Test + fun testSwipeRightFromCenterOutsidePagerPopsControllerOnIos26() = runUIKitInstrumentedTest( + ignoreIf = !available(OS.Ios to OSVersion(major = 26)), + ignoreNotes = "Full-width swipe gesture is not recognized on iOS < 26", + ) { + val initialPage = 1 + val currentPage = mutableIntStateOf(initialPage) - waitForIdle() + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + delay(10) + + findNodeWithTag("outsideBox").swipe( + fromPosition = { center() }, + toPosition = { rightCenter() }, + ) + + // wait for pop animation to finish + delay(500) + + assertNull(findNodeWithTagOrNull("pager")) + assertNull(findNodeWithTagOrNull("outsideBox")) + assertEquals(1, navigationController.viewControllers.size) } private val UIKitInstrumentedTest.navigationController: UINavigationController get() { @@ -150,18 +233,23 @@ internal abstract class UIKitNavigationContentSwipeTest( private fun UIKitInstrumentedTest.setNavigationControllerContent( content: @Composable () -> Unit = {} - ) { + ) = setupWindow { val firstViewController = UIViewController() val secondViewController = createRootViewController(content = content) - val navigationController = UINavigationController() - - navigationController.setViewControllers( - listOf(firstViewController, secondViewController), false - ) - appDelegate.setUpWindow(navigationController) + UINavigationController().also { + it.setViewControllers(listOf(firstViewController, secondViewController), false) + } + } - waitForIdle() + private fun runUIKitInstrumentedTest( + ignoreIf: Boolean, + ignoreNotes: String, + testBlock: UIKitInstrumentedTest.() -> Unit + ) = if (ignoreIf) { + println("Debug: Ignored test: $ignoreNotes") + } else { + runUIKitInstrumentedTest(testBlock) } } @@ -190,7 +278,6 @@ private fun TestContent( .background(pagerColors[page]) ) } - Box( modifier = Modifier .fillMaxWidth() @@ -200,10 +287,10 @@ private fun TestContent( } } -internal class UIKitNavigationContentSwipeInHostingViewTest : UIKitNavigationContentSwipeTest( - runUIKitInstrumentedTest = ::runUIKitInstrumentedTestInHostingView +internal class UIKitNavigationSwipeBackInHostingViewTest : UIKitNavigationSwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = true, it) } ) -internal class UIKitNavigationContentSwipeInHostingViewControllerTest : UIKitNavigationContentSwipeTest( - runUIKitInstrumentedTest = ::runUIKitInstrumentedTestInHostingViewController +internal class UIKitNavigationSwipeBackInHostingViewControllerTest : UIKitNavigationSwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = false, it) } ) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 82f75289e16ff..886e9f264e059 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -29,10 +29,13 @@ import androidx.compose.ui.test.utils.beginPress import androidx.compose.ui.test.utils.center import androidx.compose.ui.test.utils.getTouchesEvent import androidx.compose.ui.test.utils.hold +import androidx.compose.ui.test.utils.leftCenter import androidx.compose.ui.test.utils.mouseDown import androidx.compose.ui.test.utils.moveToLocationOnWindow +import androidx.compose.ui.test.utils.offsetBy import androidx.compose.ui.test.utils.release import androidx.compose.ui.test.utils.resetTouches +import androidx.compose.ui.test.utils.rightCenter import androidx.compose.ui.test.utils.toCGPoint import androidx.compose.ui.test.utils.touchDown import androidx.compose.ui.test.utils.up @@ -107,8 +110,6 @@ import platform.darwin.NSObject import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue -internal typealias UIKitInstrumentedTestBlock = UIKitInstrumentedTest.() -> Unit - /** * Sets up the test environment for iOS instrumented tests, runs the given [test][testBlock] against * UIView- and UIViewController-based Compose Container. @@ -117,25 +118,14 @@ internal typealias UIKitInstrumentedTestBlock = UIKitInstrumentedTest.() -> Unit * assertions on it. * @param [testBlock] The test function. */ -internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTestBlock) { - runUIKitInstrumentedTestInHostingView(testBlock) - runUIKitInstrumentedTestInHostingViewController(testBlock) -} - -internal fun runUIKitInstrumentedTestInHostingView(testBlock: UIKitInstrumentedTestBlock) { - println("Debug: Running test with ComposeHostingView") - with(UIKitInstrumentedTest(useHostingView = true)) { - try { - testBlock() - } finally { - tearDown() - } - } +internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Unit) { + runUIKitInstrumentedTest(useHostingView = true, testBlock) + runUIKitInstrumentedTest(useHostingView = false, testBlock) } -internal fun runUIKitInstrumentedTestInHostingViewController(testBlock: UIKitInstrumentedTestBlock) { - println("Debug: Running test with ComposeHostingViewController") - with(UIKitInstrumentedTest(useHostingView = false)) { +internal fun runUIKitInstrumentedTest(useHostingView: Boolean, testBlock: UIKitInstrumentedTest.() -> Unit) { + println("Debug: Running test with ${if (useHostingView) "ComposeHostingView" else "ComposeHostingViewController"}") + with(UIKitInstrumentedTest(useHostingView = useHostingView)) { try { testBlock() } finally { @@ -167,21 +157,7 @@ internal fun runUIKitInstrumentedTest( } for (param in params) { - with(UIKitInstrumentedTest(useHostingView = true)) { - try { - testBlock(param) - } finally { - tearDown() - } - } - - with(UIKitInstrumentedTest(useHostingView = false)) { - try { - testBlock(param) - } finally { - tearDown() - } - } + runUIKitInstrumentedTest(testBlock = { testBlock(param) }) } } @@ -196,7 +172,7 @@ internal fun runUIKitInstrumentedTest( internal fun runUIKitInstrumentedTest( ignoreIf: Boolean, ignoreNotes: String, - testBlock: UIKitInstrumentedTestBlock + testBlock: UIKitInstrumentedTest.() -> Unit ) { if (ignoreIf) { println("Debug: Ignored test: $ignoreNotes") @@ -287,15 +263,21 @@ internal class UIKitInstrumentedTest( configure: ComposeContainerConfiguration.() -> Unit = {}, interfaceOrientation: UIInterfaceOrientation = UIInterfaceOrientationPortrait, content: @Composable () -> Unit + ) = setupWindow( + interfaceOrientation = interfaceOrientation, + rootViewController = { createRootViewController(configure, content) } + ) + + fun setupWindow( + interfaceOrientation: UIInterfaceOrientation = UIInterfaceOrientationPortrait, + rootViewController: () -> UIViewController, ) { accessibilityNotifications.clear() AccessibilityNotification.onNotificationPostedForTests = { accessibilityNotifications.add(it) } - appDelegate.setUpWindow( - createRootViewController(configure, content) - ) + appDelegate.setUpWindow(rootViewController()) waitForIdle() @@ -399,8 +381,26 @@ internal class UIKitInstrumentedTest( * the window hosting the view will be used. * @return A UITouch object representing the touch interaction. */ - fun touchDown(position: DpOffset, window: UIWindow? = null): UITouch { - return getTargetWindow(position, window).touchDown(position) + fun touchDown(position: DpOffset, window: UIWindow? = null, fromEdge: Boolean = false): UITouch { + return getTargetWindow(position, window).touchDown(position, fromEdge) + } + + private val EdgeSwipeDuration = 100.milliseconds + + fun swipeRightFromEdge() { + val swipeToLocation = screenBounds.rightCenter().offsetBy(dx = (-16).dp) + + touchDown(screenBounds.leftCenter(), fromEdge = true) + .dragTo(swipeToLocation, duration = EdgeSwipeDuration) + .up() + } + + fun swipeLeftFromEdge() { + val swipeToLocation = screenBounds.leftCenter().offsetBy(dx = 16.dp) + + touchDown(screenBounds.rightCenter(), fromEdge = true) + .dragTo(swipeToLocation, duration = EdgeSwipeDuration) + .up() } /** @@ -615,6 +615,28 @@ internal class UIKitInstrumentedTest( val location = locationInView(null).toDpOffset() return dragTo(DpOffset(x ?: location.x, y ?: location.y), duration) } + + private val SwipeDuration = 100.milliseconds + + fun AccessibilityTestNode.swipe( + fromPosition: DpRect.() -> DpOffset = { center() }, + toPosition: DpRect.() -> DpOffset = { center() }, + fromEdge: Boolean = false, + duration: Duration = SwipeDuration + ) { + val frame = frame ?: error("Internal error. Frame is missing.") + touchDown(frame.fromPosition(), fromEdge = fromEdge) + .dragTo(frame.toPosition(), duration) + .up() + } + + fun AccessibilityTestNode.swipeRight(fromEdge: Boolean = false, duration: Duration = SwipeDuration) { + swipe(fromPosition = { center() }, toPosition = { rightCenter() }, fromEdge = fromEdge, duration = duration) + } + + fun AccessibilityTestNode.swipeLeft(fromEdge: Boolean = false, duration: Duration = SwipeDuration) { + swipe(fromPosition = { center() }, toPosition = { leftCenter() }, fromEdge = fromEdge, duration = duration) + } } @OptIn(ExperimentalForeignApi::class) @@ -790,4 +812,4 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() { } != null } delay(500) // wait for toolbar animation -} +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/DpRect+Utils.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/DpRect+Utils.kt index 889c8ffbacd5e..5c5c2eedb53e9 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/DpRect+Utils.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/DpRect+Utils.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.test.utils +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.toDpRect @@ -31,8 +32,27 @@ import platform.UIKit.UIView @OptIn(ExperimentalForeignApi::class) internal fun DpOffset.toCGPoint(): CValue = CGPointMake(x.value.toDouble(), y.value.toDouble()) +/** + * Returns the center of the rectangle. + */ internal fun DpRect.center(): DpOffset = DpOffset((left + right) / 2, (top + bottom) / 2) - +/** + * Returns the center of the left edge. + */ +internal fun DpRect.leftCenter(): DpOffset = DpOffset(left, (top + bottom) / 2) +/** + * Returns the center of the right edge. + */ +internal fun DpRect.rightCenter(): DpOffset = DpOffset(right, (top + bottom) / 2) +/** + * Returns the center of the top edge. + */ +internal fun DpRect.topCenter(): DpOffset = DpOffset((left + right) / 2, top) +/** + * Returns the center of the bottom edge. + */ +internal fun DpRect.bottomCenter(): DpOffset = DpOffset((left + right) / 2, bottom) +internal fun DpOffset.offsetBy(dx: Dp = 0.dp, dy: Dp = 0.dp) = DpOffset(x + dx, y + dy) internal fun DpRectZero() = DpRect(0.dp, 0.dp, 0.dp, 0.dp) internal fun DpRect.intersect(other: DpRect): DpRect { diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UITouch+Utils.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UITouch+Utils.kt index 7f9e82f50a7c9..9b737e14936ce 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UITouch+Utils.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/UITouch+Utils.kt @@ -35,13 +35,13 @@ import platform.UIKit.UITouchTypeIndirect import platform.UIKit.UIWindow @OptIn(ExperimentalForeignApi::class) -internal fun UIWindow.touchDown(location: DpOffset): UITouch { +internal fun UIWindow.touchDown(location: DpOffset, fromEdge: Boolean = false): UITouch { return UITouch.touchAtPoint( point = location.toCGPoint(), withType = UITouchTypeDirect, inWindow = this, tapCount = 1L, - fromEdge = false + fromEdge = fromEdge ).also { it.send() } From d7f1d3032785513da033d0647a9736301b4f2894 Mon Sep 17 00:00:00 2001 From: svastven Date: Fri, 12 Jun 2026 14:54:11 +0200 Subject: [PATCH 06/10] Change EdgeSwipeDuration to 200 milliseconds --- .../kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 886e9f264e059..d3db180e88bb5 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -385,7 +385,7 @@ internal class UIKitInstrumentedTest( return getTargetWindow(position, window).touchDown(position, fromEdge) } - private val EdgeSwipeDuration = 100.milliseconds + private val EdgeSwipeDuration = 200.milliseconds fun swipeRightFromEdge() { val swipeToLocation = screenBounds.rightCenter().offsetBy(dx = (-16).dp) From 0d529f7cf449d4230c9486b884cb1e582575ab81 Mon Sep 17 00:00:00 2001 From: svastven Date: Fri, 12 Jun 2026 16:09:20 +0200 Subject: [PATCH 07/10] User waitUntil instead of delay --- .../interop/UIKitNavigationSwipeBackTest.kt | 88 ++++++++----------- .../compose/ui/test/UIKitInstrumentedTest.kt | 33 +++++-- 2 files changed, 62 insertions(+), 59 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt index 602fb04bc7351..ba9471d72dc3c 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.ui.Modifier @@ -41,7 +42,6 @@ import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertNull import kotlinx.cinterop.ExperimentalForeignApi import org.jetbrains.skiko.OS import org.jetbrains.skiko.OSVersion @@ -55,38 +55,36 @@ internal abstract class UIKitNavigationSwipeBackTest( ) { @Test fun testSwipeRightOnPagerDoesNotPopController() = runUIKitInstrumentedTest { - val currentPage = mutableIntStateOf(0) + val initialPage = 0 + val currentPage = mutableIntStateOf(initialPage) setNavigationControllerContent { TestContent(currentPage = currentPage) } - delay(10) - findNodeWithTag("pager").swipeRight() - delay(500) + waitForIdle() assertEquals(2, navigationController.viewControllers.size) assertNotNull(findNodeWithTagOrNull("pager")) - assertEquals(0, currentPage.value) + assertEquals(initialPage, currentPage.value) } @Test fun testSwipeLeftOnPagerChangesPage() = runUIKitInstrumentedTest { - val currentPage = mutableIntStateOf(0) + val initialPage = 0 + val currentPage = mutableIntStateOf(initialPage) setNavigationControllerContent { TestContent(currentPage = currentPage) } - delay(10) - findNodeWithTag("pager").swipeLeft() waitForIdle() - assertEquals(1, currentPage.value) + assertEquals(initialPage + 1, currentPage.value) assertEquals(2, navigationController.viewControllers.size) } @@ -99,8 +97,6 @@ internal abstract class UIKitNavigationSwipeBackTest( TestContent(currentPage = currentPage) } - delay(10) - findNodeWithTag("outsideBox").swipeLeft() assertEquals(initialPage, currentPage.value) @@ -113,16 +109,11 @@ internal abstract class UIKitNavigationSwipeBackTest( TestContent(currentPage = mutableIntStateOf(1)) } - delay(10) - swipeRightFromEdge() - // wait for pop animation to finish - delay(500) - - assertNull(findNodeWithTagOrNull("pager")) - assertNull(findNodeWithTagOrNull("outsideBox")) - assertEquals(1, navigationController.viewControllers.size) + waitUntil("Waiting for view controller to be popped") { + navigationController.viewControllers.size == 1 + } } @Test @@ -137,15 +128,12 @@ internal abstract class UIKitNavigationSwipeBackTest( TestContent(currentPage = currentPage) } - delay(10) - findNodeWithTag("outsideBox").swipe( fromPosition = { center() }, toPosition = { rightCenter() }, ) - // wait for pop animation to finish if any - delay(500) + waitForIdle() assertNotNull(findNodeWithTagOrNull("pager")) assertNotNull(findNodeWithTagOrNull("outsideBox")) @@ -164,16 +152,11 @@ internal abstract class UIKitNavigationSwipeBackTest( TestContent(currentPage = currentPage) } - delay(10) - findNodeWithTag("outsideBox").swipeRight() - // wait for pop animation to finish - delay(500) - - assertNull(findNodeWithTagOrNull("pager")) - assertNull(findNodeWithTagOrNull("outsideBox")) - assertEquals(1, navigationController.viewControllers.size) + waitUntil("Waiting for view controller to be popped") { + navigationController.viewControllers.size == 1 + } } @Test @@ -188,16 +171,11 @@ internal abstract class UIKitNavigationSwipeBackTest( TestContent(currentPage = currentPage) } - delay(10) - swipeRightFromEdge() - // wait for pop animation to finish - delay(500) - - assertNull(findNodeWithTagOrNull("pager")) - assertNull(findNodeWithTagOrNull("outsideBox")) - assertEquals(1, navigationController.viewControllers.size) + waitUntil("Waiting for view controller to be popped") { + navigationController.viewControllers.size == 1 + } } @Test @@ -212,19 +190,16 @@ internal abstract class UIKitNavigationSwipeBackTest( TestContent(currentPage = currentPage) } - delay(10) - findNodeWithTag("outsideBox").swipe( fromPosition = { center() }, toPosition = { rightCenter() }, ) - // wait for pop animation to finish - delay(500) + waitForIdle() - assertNull(findNodeWithTagOrNull("pager")) - assertNull(findNodeWithTagOrNull("outsideBox")) - assertEquals(1, navigationController.viewControllers.size) + waitUntil("Waiting for view controller to be popped") { + navigationController.viewControllers.size == 1 + } } private val UIKitInstrumentedTest.navigationController: UINavigationController get() { @@ -233,12 +208,17 @@ internal abstract class UIKitNavigationSwipeBackTest( private fun UIKitInstrumentedTest.setNavigationControllerContent( content: @Composable () -> Unit = {} - ) = setupWindow { - val firstViewController = UIViewController() - val secondViewController = createRootViewController(content = content) + ) { + val composeViewController = createViewControllerHostingCompose(content = content) + + setupWindow { + UINavigationController().also { + it.setViewControllers(listOf(UIViewController(), composeViewController), false) + } + } - UINavigationController().also { - it.setViewControllers(listOf(firstViewController, secondViewController), false) + waitUntil { + composeViewController.view.window != null } } @@ -260,6 +240,10 @@ private fun TestContent( val pagerColors = listOf(Color.Red, Color.Green, Color.Blue) val pagerState = rememberPagerState(initialPage = currentPage.value) { 3 } + LaunchedEffect(pagerState.currentPage) { + currentPage.value = pagerState.currentPage + } + Column( modifier = Modifier .fillMaxSize() diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index d3db180e88bb5..f665ce7f8890d 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -265,9 +265,16 @@ internal class UIKitInstrumentedTest( content: @Composable () -> Unit ) = setupWindow( interfaceOrientation = interfaceOrientation, - rootViewController = { createRootViewController(configure, content) } + rootViewController = { createViewControllerHostingCompose(configure, content) } ) + /** + * Installs [rootViewController] into the test window and waits until the Compose scene owned by + * this [UIKitInstrumentedTest] is idle. + * + * Use this when a test needs a custom UIKit hierarchy around Compose, for example a navigation + * controller or a view controller presented by UIKit. + */ fun setupWindow( interfaceOrientation: UIInterfaceOrientation = UIInterfaceOrientationPortrait, rootViewController: () -> UIViewController, @@ -286,18 +293,26 @@ internal class UIKitInstrumentedTest( } } - fun createRootViewController( + /** + * Creates a [UIViewController] that hosts [content] using the container variant selected by + * [useHostingView]. + */ + fun createViewControllerHostingCompose( configure: ComposeContainerConfiguration.() -> Unit = {}, content: @Composable () -> Unit ): UIViewController = if (useHostingView) { UIViewController().also { - it.view.embedSubview(createHostingView(configure, content)) + it.view.embedSubview(createComposeHostingView(configure, content)) } } else { - createHostingViewController(configure, content) + createComposeHostingViewController(configure, content) } - fun createHostingView( + /** + * Creates a [ComposeHostingView] for [content] and records it as the active Compose container + * for idleness and redrawer checks. + */ + fun createComposeHostingView( configure: ComposeUIViewConfiguration.() -> Unit = {}, content: @Composable () -> Unit ): ComposeHostingView { @@ -314,7 +329,11 @@ internal class UIKitInstrumentedTest( } } - fun createHostingViewController( + /** + * Creates a [ComposeHostingViewController] for [content] and records it as the active Compose + * container for idleness and redrawer checks. + */ + fun createComposeHostingViewController( configure: ComposeUIViewControllerConfiguration.() -> Unit = {}, content: @Composable () -> Unit ): ComposeHostingViewController { @@ -812,4 +831,4 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() { } != null } delay(500) // wait for toolbar animation -} \ No newline at end of file +} From ef5002396fcf68bc0b09992878927ac2e6e9eef5 Mon Sep 17 00:00:00 2001 From: svastven Date: Fri, 12 Jun 2026 19:13:34 +0200 Subject: [PATCH 08/10] Clear compose container references in tearDown --- .../androidx/compose/ui/test/UIKitInstrumentedTest.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index f665ce7f8890d..0df435b55e707 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -351,6 +351,8 @@ internal class UIKitInstrumentedTest( } fun tearDown() { + clearComposeContainerReferencesIfDetached() + // Stop text editing and hide keyboard if any viewController.view.endEditing(force = true) waitForIdle() @@ -364,6 +366,15 @@ internal class UIKitInstrumentedTest( hostingViewController?.viewControllerDidLeaveWindowHierarchy() } + private fun clearComposeContainerReferencesIfDetached() { + if (hostingView != null && hostingView?.window == null) { + hostingView = null + } + if (hostingViewController != null && hostingViewController?.view?.window == null) { + hostingViewController = null + } + } + private val isIdle: Boolean get() { val hadSnapshotChanges = Snapshot.current.hasPendingChanges() From 080692ec9dd02ceecf051913f92e903ed5ede4d2 Mon Sep 17 00:00:00 2001 From: svastven Date: Sat, 13 Jun 2026 22:27:30 +0200 Subject: [PATCH 09/10] Increase SwipeDuration to 200 ms --- .../kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 0df435b55e707..6636229d21a15 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -646,7 +646,7 @@ internal class UIKitInstrumentedTest( return dragTo(DpOffset(x ?: location.x, y ?: location.y), duration) } - private val SwipeDuration = 100.milliseconds + private val SwipeDuration = 200.milliseconds fun AccessibilityTestNode.swipe( fromPosition: DpRect.() -> DpOffset = { center() }, From c296f3ccbac42a24e58d2339d53c652808d9cc6b Mon Sep 17 00:00:00 2001 From: svastven Date: Sat, 13 Jun 2026 22:47:45 +0200 Subject: [PATCH 10/10] Fix waitForPopped condition --- .../interop/UIKitNavigationSwipeBackTest.kt | 45 ++++++++++--------- .../compose/ui/test/UIKitInstrumentedTest.kt | 5 ++- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt index ba9471d72dc3c..ab4b2a921d856 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt @@ -105,15 +105,13 @@ internal abstract class UIKitNavigationSwipeBackTest( @Test fun testSwipeRightFromEdgePopsController() = runUIKitInstrumentedTest { - setNavigationControllerContent { + val viewControllerHostingCompose = setNavigationControllerContent { TestContent(currentPage = mutableIntStateOf(1)) } swipeRightFromEdge() - waitUntil("Waiting for view controller to be popped") { - navigationController.viewControllers.size == 1 - } + waitForPopped(viewControllerHostingCompose) } @Test @@ -148,15 +146,13 @@ internal abstract class UIKitNavigationSwipeBackTest( val initialPage = 1 val currentPage = mutableIntStateOf(initialPage) - setNavigationControllerContent { + val viewControllerHostingCompose = setNavigationControllerContent { TestContent(currentPage = currentPage) } findNodeWithTag("outsideBox").swipeRight() - waitUntil("Waiting for view controller to be popped") { - navigationController.viewControllers.size == 1 - } + waitForPopped(viewControllerHostingCompose) } @Test @@ -167,15 +163,13 @@ internal abstract class UIKitNavigationSwipeBackTest( val initialPage = 1 val currentPage = mutableIntStateOf(initialPage) - setNavigationControllerContent { + val viewControllerHostingCompose = setNavigationControllerContent { TestContent(currentPage = currentPage) } swipeRightFromEdge() - waitUntil("Waiting for view controller to be popped") { - navigationController.viewControllers.size == 1 - } + waitForPopped(viewControllerHostingCompose) } @Test @@ -186,7 +180,7 @@ internal abstract class UIKitNavigationSwipeBackTest( val initialPage = 1 val currentPage = mutableIntStateOf(initialPage) - setNavigationControllerContent { + val viewControllerHostingCompose = setNavigationControllerContent { TestContent(currentPage = currentPage) } @@ -195,11 +189,7 @@ internal abstract class UIKitNavigationSwipeBackTest( toPosition = { rightCenter() }, ) - waitForIdle() - - waitUntil("Waiting for view controller to be popped") { - navigationController.viewControllers.size == 1 - } + waitForPopped(viewControllerHostingCompose) } private val UIKitInstrumentedTest.navigationController: UINavigationController get() { @@ -208,17 +198,28 @@ internal abstract class UIKitNavigationSwipeBackTest( private fun UIKitInstrumentedTest.setNavigationControllerContent( content: @Composable () -> Unit = {} - ) { - val composeViewController = createViewControllerHostingCompose(content = content) + ): UIViewController { + val viewControllerHostingCompose = createViewControllerHostingCompose(content = content) setupWindow { UINavigationController().also { - it.setViewControllers(listOf(UIViewController(), composeViewController), false) + it.setViewControllers(listOf(UIViewController(), viewControllerHostingCompose), false) } } waitUntil { - composeViewController.view.window != null + viewControllerHostingCompose.view.window != null + } + + return viewControllerHostingCompose + } + + private fun UIKitInstrumentedTest.waitForPopped( + viewController: UIViewController + ) { + waitUntil("Waiting for view controller to be popped and detached") { + navigationController.viewControllers.size == 1 && + viewController.view.window == null } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 6636229d21a15..0ffb2ba5fdb90 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -390,7 +390,10 @@ internal class UIKitInstrumentedTest( waitUntil( conditionDescription = "waitForIdle: timeout ${timeoutMillis}ms reached.", timeoutMillis = timeoutMillis - ) { isIdle } + ) { + clearComposeContainerReferencesIfDetached() + isIdle + } } fun delay(timeoutMillis: Long) = UIKitInstrumentedTest.delay(timeoutMillis)