diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt index c1b28f737080e..e7a93bf2be183 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt @@ -18,6 +18,7 @@ package androidx.compose.ui.platform import androidx.collection.MutableIntSet import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.ui.animation.durationScale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.node.HitTestResult @@ -78,6 +79,7 @@ import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -1253,7 +1255,8 @@ internal class AccessibilityMediator( // Will exit on CancellationException from within await on `invalidationChannel.receive()` // when [job] is cancelled while (true) { - hasPendingInvalidations = false + @OptIn(ExperimentalCoroutinesApi::class) + hasPendingInvalidations = !invalidationChannel.isEmpty invalidationChannel.receive() hasPendingInvalidations = true @@ -1269,24 +1272,24 @@ internal class AccessibilityMediator( sync() } accessibilityDebugLogger?.log("AccessibilityMediator.sync took $time") + + if (keyboardFocusedElementKey != null) { + // Do nothing. + // When full keyboard access is enabled, the selection rectangle can be updated + // on every frame. To improve the user experience, we should update the + // accessibility tree as quickly as possible. + } else { + // Estimated delay between the iOS Accessibility Engine sync intervals. + // There is no reason to post change notifications more frequently because the + // iOS Accessibility Engine will ignore them. + delay((100.0 * coroutineContext.durationScale()).milliseconds) + } } } else if (root.element != null) { refocusKeyboardElementIfNeeded() root.element = null AccessibilityNotification(UIAccessibilityLayoutChangedNotification).postNotification() } - - if (keyboardFocusedElementKey != null) { - // Do nothing. - // When full keyboard access is enabled, the selection rectangle can be updated - // on every frame. To improve the user experience, we should update the - // accessibility tree as quickly as possible. - } else { - // Estimated delay between the iOS Accessibility Engine sync intervals. - // There is no reason to post change notifications more frequently because the - // iOS Accessibility Engine will ignore them. - delay(100.milliseconds) - } } } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index f29f5fe8fb8cf..5f32d5bce5bed 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -206,7 +206,9 @@ internal class ComposeContainer( } fun hasInvalidations(): Boolean { - return mediator?.hasInvalidations == true || layersHolder?.layersViewController?.hasInvalidations == true + return mediator?.hasInvalidations == true || + layersHolder?.layersViewController?.hasInvalidations == true || + focusedViewsList.hasScheduledTasks } private val currentInterfaceOrientation: InterfaceOrientation? diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 7b727bb295c52..64dd0075e302e 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -488,7 +488,8 @@ internal class ComposeSceneMediator( keyboardManager.hasPendingWork || isLayoutTransitionAnimating || semanticsOwnerListener.hasInvalidations || - textInputService.hasInvalidations + textInputService.hasInvalidations || + interopContainer.hasPendingUpdates } init { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/viewinterop/InteropTransaction.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/viewinterop/InteropTransaction.ios.kt index b62f43bfa1a28..dca0fc9931335 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/viewinterop/InteropTransaction.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/viewinterop/InteropTransaction.ios.kt @@ -101,9 +101,12 @@ internal class InteropMutableTransaction( /** * Schedules a user-provided `UIKitView.update` or `UIKitViewController.update` callback. */ - fun scheduleViewUpdate(holder: InteropViewHolder) { + fun scheduleViewUpdate(holder: InteropViewHolder, completion: () -> Unit) { if (holdersWithPendingViewUpdates.add(holder)) { - actions.add { holder.update() } + actions.add { + holder.update() + completion() + } } } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/viewinterop/IosInteropContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/viewinterop/IosInteropContainer.ios.kt index 5993cce186f60..7848f4d9c0169 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/viewinterop/IosInteropContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/viewinterop/IosInteropContainer.ios.kt @@ -38,8 +38,10 @@ internal class IosInteropContainer( private val interopViews = mutableMapOf() private var transaction = InteropMutableTransaction(isInteropActive = false) + private var scheduledUpdatesCount = 0 val hasInteropViews: Boolean get() = interopViews.isNotEmpty() + val hasPendingUpdates: Boolean get() = transaction.hasPendingActions || scheduledUpdatesCount > 0 val hasPendingViewUpdatesOnly: Boolean get() = transaction.hasPendingViewUpdatesOnly // TODO: Android reuses `owner.snapshotObserver`. We should probably do the same with RootNodeOwner. @@ -140,14 +142,21 @@ internal class IosInteropContainer( } override fun scheduleUpdate(action: () -> Unit) { + scheduledUpdatesCount++ // Add lambda to a list of commands which will be executed later // in the same [CATransaction], when the next rendered Compose frame is presented. - transaction.scheduleFrameSynchronizedAction(action) + transaction.scheduleFrameSynchronizedAction { + action() + scheduledUpdatesCount-- + } requestRedraw() } override fun scheduleUpdate(holder: InteropViewHolder) { - transaction.scheduleViewUpdate(holder) + scheduledUpdatesCount++ + transaction.scheduleViewUpdate(holder) { + scheduledUpdatesCount-- + } requestRedraw() } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt index ba7fcc776509a..1ac11acb33447 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/FocusedViewsList.ios.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.window import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEachReversed import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.MainScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -38,6 +39,12 @@ internal class FocusedViewsList { private var parent: FocusedViewsList? = null private val children = mutableListOf() + private var scheduledTasksCount = 0 + + val hasScheduledTasks: Boolean get() { + return scheduledTasksCount > 0 || children.any { it.hasScheduledTasks } + } + /** * Add new view to list and focus on it. */ @@ -87,8 +94,10 @@ internal class FocusedViewsList { resignedViews += activeViews activeViews = emptyList() + scheduledTasksCount++ mainScope.launch { resignScheduledViews() + scheduledTasksCount-- } } @@ -96,8 +105,12 @@ internal class FocusedViewsList { fun refocusOnLastViewInHierarchy() { val viewToFocus = lastViewToFocus() if (viewToFocus != null) { + scheduledTasksCount++ viewToFocus.becomeFirstResponder() viewToFocus.window?.makeKeyWindow() + mainScope.launch { + scheduledTasksCount-- + } } else { resignScheduledViews() } @@ -105,9 +118,15 @@ internal class FocusedViewsList { if (delay == null) { refocusOnLastViewInHierarchy() } else { + scheduledTasksCount++ mainScope.launch { delay(delay) refocusOnLastViewInHierarchy() + + // With lots of show/hide keyboard requests, iOS postpones next operations for a while, + // which makes some tests fail. Adding a small delay to fix this issue. + delay(50.milliseconds) + scheduledTasksCount-- } } } diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt index 8dfe1ea2da9db..dd93e32b59205 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt @@ -17,6 +17,8 @@ package androidx.compose.ui.animation import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.MotionDurationScale +import kotlin.coroutines.CoroutineContext import kotlin.math.min import kotlin.time.Duration import kotlin.time.Duration.Companion.nanoseconds @@ -53,3 +55,7 @@ internal suspend fun withAnimationProgress( } } } + +internal fun CoroutineContext.durationScale(): Float { + return this[MotionDurationScale]?.scaleFactor ?: 1f +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt index 24bb7ceabbeb3..e0bef1aeeb5dc 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt @@ -34,7 +34,7 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.ComposeUiFlags import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.MotionDurationScale +import androidx.compose.ui.animation.durationScale import androidx.compose.ui.animation.easeOutTimingFunction import androidx.compose.ui.animation.withAnimationProgress import androidx.compose.ui.draw.drawBehind @@ -64,7 +64,6 @@ import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.center import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner -import kotlin.coroutines.CoroutineContext import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.currentCoroutineContext @@ -416,7 +415,3 @@ internal fun getDialogScrimBlendMode(isWindowTransparent: Boolean) = } else { BlendMode.SrcOver } - -private fun CoroutineContext.durationScale(): Float { - return this[MotionDurationScale]?.scaleFactor ?: 1f -} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt index 2864edaf60a12..79cb34bec0eb9 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/integrations/ComposeSceneMediatorTest.kt @@ -32,6 +32,7 @@ class ComposeSceneMediatorTest { tap(screenSize.center) waitForIdle() + // Should not crash } @OptIn(ExperimentalForeignApi::class) @@ -45,5 +46,6 @@ class ComposeSceneMediatorTest { viewController.view.layoutIfNeeded() waitForIdle() + // Should not crash } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt index 3403630edfb80..07a382cd38522 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.test.assertVisibleInContainer import androidx.compose.ui.test.findNodeWithLabel import androidx.compose.ui.test.findNodeWithLabelOrNull import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.isContextMenuVisible import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.tapContextMenuButton import androidx.compose.ui.test.utils.BasicTextFieldType @@ -751,13 +752,16 @@ class TextFieldEditMenuTest { private fun UIKitInstrumentedTest.longPressNodeWithTagAndAwaitContextMenu(textFieldTag: String) { val touch = findNodeWithTag(textFieldTag).touchDown() - waitUntil { - findFirstDescendant { it.isLoupeView } != null + waitUntil("Awaiting context menu or loupe") { + isContextMenuVisible || isLoupeVisible } touch.up() waitForContextMenu() } + private val UIKitInstrumentedTest.isLoupeVisible: Boolean get() = + findFirstDescendant { it.isLoupeView } != null + private fun UIKitInstrumentedTest.setTextFieldContent( textFieldKind: BasicTextFieldType, initialValue: TextFieldValue, diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/KeyboardInsetsTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/KeyboardInsetsTest.kt index 6c965861df0eb..aeb9e03c07bdb 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/KeyboardInsetsTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/keyboard/KeyboardInsetsTest.kt @@ -98,6 +98,8 @@ internal abstract class KeyboardInsetsTest( var focusManager: FocusManager? = null val focusRequester = FocusRequester() + animationSpeed = UIKitInstrumentedTest.DefaultAnimationSpeed + setContent({ onFocusBehavior = OnFocusBehavior.FocusableAboveKeyboard }) { @@ -188,6 +190,8 @@ internal abstract class KeyboardInsetsTest( var focusManager: FocusManager? = null val focusRequester = FocusRequester() + animationSpeed = UIKitInstrumentedTest.DefaultAnimationSpeed + setContent({ onFocusBehavior = OnFocusBehavior.DoNothing }) { @@ -590,6 +594,8 @@ internal abstract class KeyboardInsetsTest( val drawnTextFieldFrames = mutableListOf>() val focusRequester = FocusRequester() + animationSpeed = UIKitInstrumentedTest.DefaultAnimationSpeed + setContent({ onFocusBehavior = OnFocusBehavior.FocusableAboveKeyboard }) { diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/modifiers/FrameRateTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/modifiers/FrameRateTest.kt index 7b17114f08c8b..6b61cda5aa9eb 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/modifiers/FrameRateTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/modifiers/FrameRateTest.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.preferredFrameRate +import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.runUIKitInstrumentedTest import kotlin.test.Test @@ -43,6 +44,8 @@ internal class FrameRateTest { fun testLowFrameRates() = runUIKitInstrumentedTest { val frameRates = listOf(5f, 10f, 30f, 60f) + animationSpeed = UIKitInstrumentedTest.DefaultAnimationSpeed + setContent { LazyColumn(modifier = Modifier.fillMaxSize()) { items(frameRates.size) { index -> @@ -67,6 +70,8 @@ internal class FrameRateTest { fun testPreferredFrameRates() = runUIKitInstrumentedTest { val frameRates = listOf(5f, 10f, 30f, 60f, 80f, 120f) + animationSpeed = UIKitInstrumentedTest.DefaultAnimationSpeed + setContent { LazyColumn(modifier = Modifier.fillMaxSize()) { items(frameRates.size) { index -> 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 0a3811e79f96e..10c3a412fc454 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 @@ -75,7 +75,9 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.useContents import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -84,7 +86,10 @@ import org.jetbrains.skiko.OSVersion import org.jetbrains.skiko.available import platform.CoreGraphics.CGSizeMake import platform.Foundation.NSDate +import platform.Foundation.NSNotification +import platform.Foundation.NSNotificationCenter import platform.Foundation.NSRunLoop +import platform.Foundation.NSSelectorFromString import platform.Foundation.dateWithTimeIntervalSinceNow import platform.Foundation.runUntilDate import platform.UIKit.UIApplication @@ -117,11 +122,11 @@ import platform.UIKit.UITouch import platform.UIKit.UITraitCollection import platform.UIKit.UITraitEnvironmentLayoutDirection import platform.UIKit.UITraitEnvironmentLayoutDirectionLeftToRight -import platform.UIKit.UITraitPreferredContentSizeCategory import platform.UIKit.UIUserInterfaceIdiomPad import platform.UIKit.UIView import platform.UIKit.UIViewController import platform.UIKit.UIWindow +import platform.UIKit.UIWindowDidBecomeVisibleNotification import platform.UIKit.UIWindowScene import platform.UIKit.endEditing import platform.UIKit.setOverrideTraitCollection @@ -217,6 +222,16 @@ internal class UIKitInstrumentedTest( val useHostingView: Boolean ) { companion object { + /** + * The factor by which animations are sped up in instrumented tests. + */ + internal const val TestsAnimationSpeed = 100f + + /** + * Default animation speed for tests that require one-to-one animations. + */ + internal const val DefaultAnimationSpeed = 1f + fun delay(timeoutMillis: Long) { val runLoop = NSRunLoop.currentRunLoop() runLoop.runUntilDate(NSDate.dateWithTimeIntervalSinceNow(timeoutMillis.toDouble() / 1000.0)) @@ -248,6 +263,8 @@ internal class UIKitInstrumentedTest( private val screen = UIScreen.mainScreen() val density = Density(density = screen.scale.toFloat()) val appDelegate = MockAppDelegate() + var animationSpeed: Float by appDelegate::animationSpeed + val keyboardHeight: Dp get() = KeyboardVisibilityListener.keyboardFrame.useContents { size.height.dp } val screenBounds: DpRect get() = screen.bounds().toDpRect() @@ -731,7 +748,7 @@ internal class UIKitInstrumentedTest( fun AccessibilityTestNode.dragSelectionHandle( handle: TestHandle, toOffset: Int, - duration: Duration = 0.5.seconds, + duration: Duration = 0.1.seconds, ) = dragSelectionHandleImpl(this, handle, toOffset, duration) /** @@ -739,10 +756,10 @@ internal class UIKitInstrumentedTest( * over a given duration. * * @param location The target position of the drag in DpOffset. - * @param duration The duration of the drag gesture, defaulting to 0.5 seconds. + * @param duration The duration of the drag gesture, defaulting to 0.1 seconds. * @return The same UITouch instance after completing the drag gesture. */ - private fun UITouch.dragTo(location: DpOffset, duration: Duration = 0.5.seconds): UITouch { + private fun UITouch.dragTo(location: DpOffset, duration: Duration = 0.1.seconds): UITouch { val startLocation = locationInView(null).toDpOffset() val startTime = TimeSource.Monotonic.markNow() @@ -764,10 +781,10 @@ internal class UIKitInstrumentedTest( * over a given duration. * * @param offset The offset by which the touch is moved, specified as a DpOffset. - * @param duration The duration of the drag gesture, defaulting to 0.5 seconds. + * @param duration The duration of the drag gesture, defaulting to 0.1 seconds. * @return The same UITouch instance after completing the drag gesture. */ - fun UITouch.dragBy(offset: DpOffset, duration: Duration = 0.5.seconds): UITouch { + fun UITouch.dragBy(offset: DpOffset, duration: Duration = 0.1.seconds): UITouch { return dragTo(locationInView(null).toDpOffset() + offset, duration) } @@ -777,10 +794,10 @@ internal class UIKitInstrumentedTest( * * @param dx The horizontal offset by which the touch is moved, specified as a Dp. Defaults to 0.dp. * @param dy The vertical offset by which the touch is moved, specified as a Dp. Defaults to 0.dp. - * @param duration The duration of the drag gesture, specified as a Duration. Defaults to 0.5 seconds. + * @param duration The duration of the drag gesture, specified as a Duration. Defaults to 0.1 seconds. * @return The same UITouch instance after completing the drag gesture. */ - fun UITouch.dragBy(dx: Dp = 0.dp, dy: Dp = 0.dp, duration: Duration = 0.5.seconds): UITouch { + fun UITouch.dragBy(dx: Dp = 0.dp, dy: Dp = 0.dp, duration: Duration = 0.1.seconds): UITouch { return dragBy(DpOffset(dx, dy), duration) } @@ -790,10 +807,10 @@ internal class UIKitInstrumentedTest( * * @param x The horizontal destination point. The default value does not change the current horizontal offset. * @param y The vertical destination point. The default value does not change the current vertical offset. - * @param duration The duration of the drag gesture, specified as a Duration. Defaults to 0.5 seconds. + * @param duration The duration of the drag gesture, specified as a Duration. Defaults to 0.1 seconds. * @return The same UITouch instance after completing the drag gesture. */ - fun UITouch.dragTo(x: Dp? = null, y: Dp? = null, duration: Duration = 0.5.seconds): UITouch { + fun UITouch.dragTo(x: Dp? = null, y: Dp? = null, duration: Duration = 0.1.seconds): UITouch { val location = locationInView(null).toDpOffset() return dragTo(DpOffset(x ?: location.x, y ?: location.y), duration) } @@ -825,6 +842,13 @@ internal class MockAppDelegate: NSObject(), UIApplicationDelegateProtocol { private var _window: UIWindow? = UIWindow(frame = UIScreen.mainScreen.bounds) override fun window(): UIWindow? = _window + var animationSpeed: Float = UIKitInstrumentedTest.TestsAnimationSpeed + set(value) { + field = value + applyAnimationSpeed() + } + private var isObservingWindowVisibility = false + private var supportedInterfaceOrientations: UIInterfaceOrientationMask = UIInterfaceOrientationMaskAll private val infiniteAnimationPolicy = object : InfiniteAnimationPolicy { @@ -849,6 +873,11 @@ internal class MockAppDelegate: NSObject(), UIApplicationDelegateProtocol { _window?.backgroundColor = UIColor.systemBackgroundColor _window?.windowScene = scene + // Must be applied before the Compose container is attached to the window: it picks the + // window layer speed up in `onDidMoveToWindow` to derive its `MotionDurationScale`. + startObservingWindowVisibility() + applyAnimationSpeed() + _window?.rootViewController = viewController _window?.makeKeyAndVisible() @@ -857,7 +886,13 @@ internal class MockAppDelegate: NSObject(), UIApplicationDelegateProtocol { } } + private fun applyAnimationSpeed() { + _window?.applyAnimationSpeed() + _window?.windowScene?.windows?.forEach { (it as UIWindow).applyAnimationSpeed() } + } + fun cleanUp() { + stopObservingWindowVisibility() sceneJob.cancel() val scene = UIApplication.sharedApplication().connectedScenes.first() as? UIWindowScene val allWindows = scene?.windows ?: emptyList() @@ -883,6 +918,37 @@ internal class MockAppDelegate: NSObject(), UIApplicationDelegateProtocol { } } + private fun startObservingWindowVisibility() { + if (isObservingWindowVisibility) return + isObservingWindowVisibility = true + NSNotificationCenter.defaultCenter.addObserver( + observer = this, + selector = NSSelectorFromString(::windowDidBecomeVisible.name + ":"), + name = UIWindowDidBecomeVisibleNotification, + `object` = null + ) + } + + private fun stopObservingWindowVisibility() { + if (!isObservingWindowVisibility) return + isObservingWindowVisibility = false + NSNotificationCenter.defaultCenter.removeObserver( + observer = this, + name = UIWindowDidBecomeVisibleNotification, + `object` = null + ) + } + + @OptIn(BetaInteropApi::class) + @ObjCAction + fun windowDidBecomeVisible(arg: NSNotification) { + (arg.`object` as? UIWindow)?.applyAnimationSpeed() + } + + private fun UIWindow.applyAnimationSpeed() { + layer.speed = animationSpeed + } + /** * Applies the requested interface orientation if it differs from the current one. * @@ -1033,18 +1099,22 @@ internal fun UIKitInstrumentedTest.captureScreenshot(): UIImage? { } internal fun UIKitInstrumentedTest.waitForContextMenu() { + waitForIdle() + waitUntil("Waiting for context menu to appear") { isContextMenuVisible } + delay(400) // wait for toolbar animation + waitForIdle() +} + +internal val UIKitInstrumentedTest.isContextMenuVisible: Boolean get() { val menuClassName = if (available(OS.Ios to OSVersion(16))) { "_UIEditMenuContainerView" } else { "UICalloutBar" } - waitForIdle() - waitUntil("Waiting for context menu to appear") { - firstNodeOrNull { node -> - node.element?.let { it::class.simpleName } == menuClassName - } != null - } - delay(500) // wait for toolbar animation + + return firstNodeOrNull { node -> + node.element?.let { it::class.simpleName } == menuClassName + } != null } internal fun UIViewController.setLayoutDirection(