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) } 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 new file mode 100644 index 0000000000000..ab4b2a921d856 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/UIKitNavigationSwipeBackTest.kt @@ -0,0 +1,281 @@ +/* + * 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.LaunchedEffect +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.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.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 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 UIKitNavigationSwipeBackTest( + private val runUIKitInstrumentedTest: (UIKitInstrumentedTest.() -> Unit) -> Unit +) { + @Test + fun testSwipeRightOnPagerDoesNotPopController() = runUIKitInstrumentedTest { + val initialPage = 0 + val currentPage = mutableIntStateOf(initialPage) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + findNodeWithTag("pager").swipeRight() + + waitForIdle() + + assertEquals(2, navigationController.viewControllers.size) + assertNotNull(findNodeWithTagOrNull("pager")) + assertEquals(initialPage, currentPage.value) + } + + @Test + fun testSwipeLeftOnPagerChangesPage() = runUIKitInstrumentedTest { + val initialPage = 0 + val currentPage = mutableIntStateOf(initialPage) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + findNodeWithTag("pager").swipeLeft() + + waitForIdle() + + assertEquals(initialPage + 1, currentPage.value) + assertEquals(2, navigationController.viewControllers.size) + } + + @Test + fun testSwipeLeftOutsidePagerNoChanges() = runUIKitInstrumentedTest { + val initialPage = 1 + val currentPage = mutableIntStateOf(initialPage) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + findNodeWithTag("outsideBox").swipeLeft() + + assertEquals(initialPage, currentPage.value) + assertEquals(2, navigationController.viewControllers.size) + } + + @Test + fun testSwipeRightFromEdgePopsController() = runUIKitInstrumentedTest { + val viewControllerHostingCompose = setNavigationControllerContent { + TestContent(currentPage = mutableIntStateOf(1)) + } + + swipeRightFromEdge() + + waitForPopped(viewControllerHostingCompose) + } + + @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) + + setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + findNodeWithTag("outsideBox").swipe( + fromPosition = { center() }, + toPosition = { rightCenter() }, + ) + + waitForIdle() + + 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) + + val viewControllerHostingCompose = setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + findNodeWithTag("outsideBox").swipeRight() + + waitForPopped(viewControllerHostingCompose) + } + + @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) + + val viewControllerHostingCompose = setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + swipeRightFromEdge() + + waitForPopped(viewControllerHostingCompose) + } + + @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) + + val viewControllerHostingCompose = setNavigationControllerContent { + TestContent(currentPage = currentPage) + } + + findNodeWithTag("outsideBox").swipe( + fromPosition = { center() }, + toPosition = { rightCenter() }, + ) + + waitForPopped(viewControllerHostingCompose) + } + + private val UIKitInstrumentedTest.navigationController: UINavigationController get() { + return assertNotNull(appDelegate.window?.rootViewController as? UINavigationController) + } + + private fun UIKitInstrumentedTest.setNavigationControllerContent( + content: @Composable () -> Unit = {} + ): UIViewController { + val viewControllerHostingCompose = createViewControllerHostingCompose(content = content) + + setupWindow { + UINavigationController().also { + it.setViewControllers(listOf(UIViewController(), viewControllerHostingCompose), false) + } + } + + waitUntil { + 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 + } + } + + private fun runUIKitInstrumentedTest( + ignoreIf: Boolean, + ignoreNotes: String, + testBlock: UIKitInstrumentedTest.() -> Unit + ) = if (ignoreIf) { + println("Debug: Ignored test: $ignoreNotes") + } else { + runUIKitInstrumentedTest(testBlock) + } +} + +@Composable +private fun TestContent( + currentPage: MutableIntState +) { + 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() + .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 UIKitNavigationSwipeBackInHostingViewTest : UIKitNavigationSwipeBackTest( + runUIKitInstrumentedTest = { runUIKitInstrumentedTest(useHostingView = true, it) } +) + +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 0646421cf2e17..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 @@ -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 @@ -116,17 +119,13 @@ import platform.darwin.dispatch_get_main_queue * @param [testBlock] The test function. */ internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Unit) { - println("Debug: Running test with ComposeHostingView") - with(UIKitInstrumentedTest(useHostingView = true)) { - try { - testBlock() - } finally { - tearDown() - } - } + runUIKitInstrumentedTest(useHostingView = true, testBlock) + runUIKitInstrumentedTest(useHostingView = false, testBlock) +} - 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 { @@ -158,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) }) } } @@ -278,36 +263,29 @@ internal class UIKitInstrumentedTest( configure: ComposeContainerConfiguration.() -> Unit = {}, interfaceOrientation: UIInterfaceOrientation = UIInterfaceOrientationPortrait, content: @Composable () -> Unit + ) = setupWindow( + interfaceOrientation = interfaceOrientation, + 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, ) { accessibilityNotifications.clear() 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(rootViewController()) - appDelegate.setUpWindow(rootViewController) waitForIdle() if (appDelegate.requestInterfaceOrientationChangeIfNeeded(interfaceOrientation)) { @@ -315,7 +293,66 @@ internal class UIKitInstrumentedTest( } } + /** + * 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(createComposeHostingView(configure, content)) + } + } else { + createComposeHostingViewController(configure, content) + } + + /** + * 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 { + val configuration = ComposeUIViewConfiguration() + .apply({ enforceStrictPlistSanityCheck = false }) + .apply(configure) + + return ComposeHostingView( + configuration = configuration, + content = content, + coroutineContext = coroutineContext + ).also { + hostingView = it + } + } + + /** + * 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 { + val configuration = ComposeUIViewControllerConfiguration() + .apply({ enforceStrictPlistSanityCheck = false }) + .apply(configure) + + return ComposeHostingViewController( + configuration = configuration, + content = content, + coroutineContext = coroutineContext + ).also { + this.hostingViewController = it + } + } + fun tearDown() { + clearComposeContainerReferencesIfDetached() + // Stop text editing and hide keyboard if any viewController.view.endEditing(force = true) waitForIdle() @@ -329,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() @@ -344,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) @@ -365,8 +414,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 = 200.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() } /** @@ -581,6 +648,28 @@ internal class UIKitInstrumentedTest( val location = locationInView(null).toDpOffset() return dragTo(DpOffset(x ?: location.x, y ?: location.y), duration) } + + private val SwipeDuration = 200.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) @@ -756,4 +845,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() }