From b163bf71d62899c0ae589baa3cf963018a12220a Mon Sep 17 00:00:00 2001 From: Andrei Salavei Date: Fri, 29 Nov 2024 10:48:59 +0100 Subject: [PATCH] Animate compose content size transitions (#1691) Animate the size transition of Compose scenes and platform windows when the size of the ComposeUIViewController changes. Example: https://github.com/user-attachments/assets/0054014c-fc8e-419d-8ffb-eaf6497dfb5e Fixes https://youtrack.jetbrains.com/issue/CMP-1491/Fix-interop-views-animation-while-rotating-screen ## Testing ### Features - iOS - Animate the size transition of Compose content when the screen is rotated or other ComposeUIViewController size changes --- .../compose/ui/animation/Animation.skiko.kt | 51 ++++++ .../ui/layout/OffsetToFocusedRect.skiko.kt | 28 +-- .../ui/platform/PlatformInsets.skiko.kt | 9 + .../platform/PlatformWindowContext.uikit.kt | 60 +++++-- .../ComposeHostingViewController.uikit.kt | 165 +++++++++--------- .../ui/scene/ComposeSceneMediator.uikit.kt | 139 ++++++--------- .../ui/scene/UIKitComposeSceneLayer.uikit.kt | 18 +- .../UIKitComposeSceneLayersHolder.uikit.kt | 40 ++++- ...UIKitComposeSceneLayersHolderView.uikit.kt | 42 ----- .../compose/ui/uikit/Extensions.uikit.kt | 14 ++ ...ComposeSceneKeyboardOffsetManager.uikit.kt | 33 +--- .../compose/ui/window/ComposeView.uikit.kt | 70 +++++++- .../compose/ui/window/DisplayLinkListener.kt | 58 ++++++ .../IntermediateTextInputUIView.uikit.kt | 2 +- .../compose/ui/window/MetalView.uikit.kt | 15 +- 15 files changed, 424 insertions(+), 320 deletions(-) create mode 100644 compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt delete mode 100644 compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolderView.uikit.kt create mode 100644 compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/DisplayLinkListener.kt 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 new file mode 100644 index 0000000000000..15ed32f8a53a3 --- /dev/null +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/animation/Animation.skiko.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 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.animation + +import androidx.compose.runtime.withFrameNanos +import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.Duration.Companion.nanoseconds + +internal fun easeInOutTimingFunction(progress: Float): Float = if (progress < 0.5f) { + 2f * progress * progress +} else { + (-2f * progress * progress) + (4f * progress) - 1f +} + +internal suspend fun withAnimationProgress( + duration: Duration, + timingFunction: (Float) -> Float = ::easeInOutTimingFunction, + update: (Float) -> Unit +) { + update(0f) + + var firstFrameTime = 0L + var progressDuration = Duration.ZERO + while (progressDuration < duration) { + withFrameNanos { frameTime -> + if (firstFrameTime == 0L) { + firstFrameTime = frameTime + } + progressDuration = (frameTime - firstFrameTime).nanoseconds + val progress = timingFunction( + min(1.0, progressDuration / duration).toFloat() + ) + update(progress) + } + } +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/OffsetToFocusedRect.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/OffsetToFocusedRect.skiko.kt index cc7894bae12a3..cf41234263669 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/OffsetToFocusedRect.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/layout/OffsetToFocusedRect.skiko.kt @@ -22,7 +22,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.animation.withAnimationProgress import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.LocalDensity @@ -36,7 +36,6 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import kotlin.time.Duration -import kotlin.time.Duration.Companion.nanoseconds @Composable internal fun OffsetToFocusedRect( @@ -156,28 +155,3 @@ private fun directionalFocusOffset( min(0f, max(hiddenFromPart, -hiddenToPart)).roundToInt() } } - -private suspend fun withAnimationProgress(duration: Duration, update: (Float) -> Unit) { - fun easeInOutProgress(progress: Float) = if (progress < 0.5) { - 2 * progress * progress - } else { - (-2 * progress * progress) + (4 * progress) - 1 - } - - update(0f) - - var firstFrameTime = 0L - var progressDuration = Duration.ZERO - while (progressDuration < duration) { - withFrameNanos { frameTime -> - if (firstFrameTime == 0L) { - firstFrameTime = frameTime - } - progressDuration = (frameTime - firstFrameTime).nanoseconds - val progress = easeInOutProgress( - min(1.0, progressDuration / duration).toFloat() - ) - update(progress) - } - } -} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformInsets.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformInsets.skiko.kt index e6f12e75c7e0e..768fa364f3936 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformInsets.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformInsets.skiko.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp /** * This class represents platform insets. @@ -81,6 +82,14 @@ internal fun PlatformInsets.exclude(insets: PlatformInsets) = PlatformInsets( bottom = (bottom - insets.bottom).coerceAtLeast(0.dp) ) +internal fun lerp(start: PlatformInsets, stop: PlatformInsets, fraction: Float) = + PlatformInsets( + left = lerp(start.left, stop.left, fraction), + right = lerp(start.right, stop.right, fraction), + top = lerp(start.top, stop.top, fraction), + bottom = lerp(start.bottom, stop.bottom, fraction) + ) + internal interface InsetsConfig { // TODO: Add more granular control. Look at Android's [WindowInsetsCompat] diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/PlatformWindowContext.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/PlatformWindowContext.uikit.kt index 5adb085603409..2e5263b33d789 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/PlatformWindowContext.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/PlatformWindowContext.uikit.kt @@ -16,29 +16,34 @@ package androidx.compose.ui.platform +import androidx.compose.ui.animation.withAnimationProgress import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.lerp import androidx.compose.ui.uikit.density -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.asCGPoint import androidx.compose.ui.unit.asCGRect import androidx.compose.ui.unit.asDpOffset import androidx.compose.ui.unit.asDpRect +import androidx.compose.ui.unit.asDpSize +import androidx.compose.ui.unit.roundToIntSize import androidx.compose.ui.unit.toDpOffset import androidx.compose.ui.unit.toDpRect import androidx.compose.ui.unit.toOffset import androidx.compose.ui.unit.toRect -import kotlin.math.roundToInt +import androidx.compose.ui.unit.toSize +import kotlin.time.Duration import kotlinx.cinterop.useContents import platform.UIKit.UIView -private const val LayerFrameKeyPath = "layer.frame" - /** * Tracking a state of window. */ internal class PlatformWindowContext { - private val _windowInfo = WindowInfoImpl() + private val _windowInfo = WindowInfoImpl().apply { + isWindowFocused = true + } val windowInfo: WindowInfo get() = _windowInfo @@ -47,26 +52,47 @@ internal class PlatformWindowContext { */ private var windowContainer: UIView? = null - var isWindowFocused by _windowInfo::isWindowFocused - fun setWindowContainer(windowContainer: UIView) { this.windowContainer = windowContainer updateWindowContainerSize() } - fun updateWindowContainerSize() { - val windowContainer = windowContainer ?: return - - val scale = windowContainer.density.density - val size = windowContainer.frame.useContents { - IntSize( - width = (size.width * scale).roundToInt(), - height = (size.height * scale).roundToInt() - ) + private var isAnimating = false + fun prepareAndGetSizeTransitionAnimation(): suspend (Duration) -> Unit { + isAnimating = true + val initialSize = _windowInfo.containerSize.toSize() + + return { duration -> + try { + if (initialSize != currentWindowContainerSize) { + withAnimationProgress(duration) { progress -> + val size = currentWindowContainerSize ?: initialSize + _windowInfo.containerSize = + lerp(initialSize, size, progress).roundToIntSize() + } + } + } finally { + isAnimating = false + updateWindowContainerSize() + } } + } - _windowInfo.containerSize = size + fun updateWindowContainerSize() { + if (isAnimating) return + + _windowInfo.containerSize = currentWindowContainerSize?.roundToIntSize() ?: return + } + + private val currentWindowContainerSize: Size? get() { + val windowContainer = windowContainer ?: return null + + return windowContainer.bounds.useContents { + with(windowContainer.density) { + size.asDpSize().toSize() + } + } } fun convertLocalToWindowPosition(container: UIView, localPosition: Offset): Offset { diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt index 0c67e36cd5fdf..816f53e5d1b88 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.uikit.LocalInterfaceOrientation import androidx.compose.ui.uikit.LocalUIViewController import androidx.compose.ui.uikit.PlistSanityCheck import androidx.compose.ui.uikit.density -import androidx.compose.ui.uikit.embedSubview import androidx.compose.ui.uikit.utils.CMPViewController import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection @@ -45,6 +44,7 @@ import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.viewinterop.UIKitInteropAction import androidx.compose.ui.viewinterop.UIKitInteropTransaction import androidx.compose.ui.window.ComposeView +import androidx.compose.ui.window.DisplayLinkListener import androidx.compose.ui.window.FocusStack import androidx.compose.ui.window.GestureEvent import androidx.compose.ui.window.MetalView @@ -53,18 +53,19 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import kotlin.coroutines.CoroutineContext import kotlin.native.runtime.GC import kotlin.native.runtime.NativeRuntimeApi +import kotlin.time.DurationUnit +import kotlin.time.toDuration import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue import kotlinx.cinterop.ExportObjCClass -import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import org.jetbrains.skiko.OS import org.jetbrains.skiko.OSVersion import org.jetbrains.skiko.available import platform.CoreGraphics.CGSize -import platform.CoreGraphics.CGSizeEqualToSize -import platform.Foundation.NSStringFromClass import platform.UIKit.UIApplication import platform.UIKit.UIEvent import platform.UIKit.UIStatusBarAnimation @@ -72,7 +73,6 @@ import platform.UIKit.UIStatusBarStyle import platform.UIKit.UITraitCollection import platform.UIKit.UIUserInterfaceLayoutDirection import platform.UIKit.UIUserInterfaceStyle -import platform.UIKit.UIViewController import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol import platform.UIKit.UIWindow import platform.darwin.dispatch_async @@ -88,14 +88,30 @@ internal class ComposeHostingViewController( private val lifecycleOwner = ViewControllerBasedLifecycleOwner() private val hapticFeedback = CupertinoHapticFeedback() + private val rootMetalView = MetalView( + retrieveInteropTransaction = { + mediator?.retrieveInteropTransaction() ?: object : UIKitInteropTransaction { + override val actions = emptyList() + override val isInteropActive = false + } + }, + useSeparateRenderThreadWhenPossible = configuration.parallelRendering, + render = { canvas, nanoTime -> + mediator?.render(canvas.asComposeCanvas(), nanoTime) + } + ).apply { + canBeOpaque = configuration.opaque + } private val rootView = ComposeView( onDidMoveToWindow = ::onDidMoveToWindow, - onLayoutSubviews = ::onLayoutSubviews, - useOpaqueConfiguration = configuration.opaque + onLayoutSubviews = {}, + metalView = rootMetalView, + transparentForTouches = false, + useOpaqueConfiguration = configuration.opaque, ) - private var isInsideSwiftUI = false private var mediator: ComposeSceneMediator? = null - private val layers = UIKitComposeSceneLayersHolder(configuration.parallelRendering) + private val windowContext = PlatformWindowContext() + private val layers = UIKitComposeSceneLayersHolder(windowContext, configuration.parallelRendering) private val layoutDirection get() = getLayoutDirection() private var hasViewAppeared: Boolean = false @@ -113,9 +129,6 @@ internal class ComposeHostingViewController( private val systemThemeState: MutableState = mutableStateOf(SystemTheme.Unknown) var focusStack: FocusStack? = FocusStack() - private val windowContext = PlatformWindowContext().apply { - isWindowFocused = true - } /* * On iOS >= 13.0 interfaceOrientation will be deduced from [UIWindowScene] of [UIWindow] @@ -160,8 +173,6 @@ internal class ComposeHostingViewController( override fun viewDidLoad() { super.viewDidLoad() - view.embedSubview(metalView) - if (configuration.enforceStrictPlistSanityCheck) { PlistSanityCheck.performIfNeeded() } @@ -170,16 +181,18 @@ internal class ComposeHostingViewController( systemThemeState.value = traitCollection.userInterfaceStyle.asComposeSystemTheme() } - override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) + override fun viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() - systemThemeState.value = traitCollection.userInterfaceStyle.asComposeSystemTheme() - } + mediator?.updateInteractionRect() - private fun onLayoutSubviews() { windowContext.updateWindowContainerSize() + } - mediator?.updateInteractionRect() + override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + systemThemeState.value = traitCollection.userInterfaceStyle.asComposeSystemTheme() } private fun onDidMoveToWindow(window: UIWindow?) { @@ -203,35 +216,13 @@ internal class ComposeHostingViewController( super.viewWillTransitionToSize(size, withTransitionCoordinator) updateInterfaceOrientationState() - - if (isInsideSwiftUI || presentingViewController != null) { - // SwiftUI will do full layout and scene constraints update on each frame of orientation change animation - // This logic is not needed - - // When presented modally, UIKit performs non-trivial hierarchy update during orientation change, - // its logic is not feasible to integrate into - return - } - - // Happens during orientation change from LandscapeLeft to LandscapeRight, for example - val isSameSizeTransition = view.frame.useContents { - CGSizeEqualToSize(size, this.size.readValue()) - } - if (isSameSizeTransition) { - return - } - - mediator?.performOrientationChangeAnimation( - targetSize = size, - coordinator = withTransitionCoordinator, - ) + animateSizeTransition(withTransitionCoordinator) } @Suppress("DEPRECATION") override fun viewWillAppear(animated: Boolean) { super.viewWillAppear(animated) - isInsideSwiftUI = checkIfInsideSwiftUI() createMediatorIfNeeded() lifecycleOwner.handleViewWillAppear() @@ -282,6 +273,45 @@ internal class ComposeHostingViewController( super.didReceiveMemoryWarning() } + /** + * Animates the layout transition of root view as well as all layers. + * The animation consists of the following steps + * - Before the actual animation starts, all initial parameters should be stored in the + * corresponding lambdas. See [ComposeSceneMediator.prepareAndGetSizeTransitionAnimation]. + * - At the time of the animation phase, the drawing canvas expands to fit the animated scene + * throughout the animation cycle. See [ComposeView.animateSizeTransition]. + * - The animation phase consists of changing scene and window sizes frame by frame. + * See [ComposeSceneMediator.prepareAndGetSizeTransitionAnimation] and + * [PlatformWindowContext.prepareAndGetSizeTransitionAnimation]. + * + * Known issue: Because per-frame updates between UIKit and Compose are not synchronised, + * native views can be misaligned with Compose content during animation. + * + * @param transitionCoordinator The coordinator that mediates the transition animations. + */ + private fun animateSizeTransition( + transitionCoordinator: UIViewControllerTransitionCoordinatorProtocol + ) { + val displayLinkListener = DisplayLinkListener() + val sizeTransitionScope = CoroutineScope(coroutineContext + displayLinkListener.frameClock) + val duration = transitionCoordinator.transitionDuration.toDuration(DurationUnit.SECONDS) + displayLinkListener.start() + + val animations = mediator?.prepareAndGetSizeTransitionAnimation() + layers.animateSizeTransition(sizeTransitionScope, duration) + rootView.animateSizeTransition(sizeTransitionScope) { + animations?.invoke(duration) + } + + transitionCoordinator.animateAlongsideTransition( + animation = {}, + completion = { + sizeTransitionScope.cancel() + displayLinkListener.invalidate() + } + ) + } + private fun createComposeSceneContext(platformContext: PlatformContext): ComposeSceneContext { return object : ComposeSceneContext { override val platformContext: PlatformContext = platformContext @@ -334,12 +364,12 @@ internal class ComposeHostingViewController( } private fun createMediator() = ComposeSceneMediator( - parentView = view, + parentView = rootView, configuration = configuration, focusStack = focusStack, windowContext = windowContext, coroutineContext = coroutineContext, - redrawer = metalView.redrawer, + redrawer = rootMetalView.redrawer, onGestureEvent = ::onGestureEvent, composeSceneFactory = ::createComposeScene, ).also { mediator -> @@ -348,24 +378,7 @@ internal class ComposeHostingViewController( ProvideContainerCompositionLocals(content) } - view.bringSubviewToFront(metalView) - } - - private val metalView by lazy { - MetalView( - retrieveInteropTransaction = { - mediator?.retrieveInteropTransaction() ?: object : UIKitInteropTransaction { - override val actions = emptyList() - override val isInteropActive = false - } - }, - useSeparateRenderThreadWhenPossible = configuration.parallelRendering, - render = { canvas, nanoTime -> - mediator?.render(canvas.asComposeCanvas(), nanoTime) - } - ).apply { - canBeOpaque = configuration.opaque - } + rootView.bringSubviewToFront(rootMetalView) } /** @@ -376,14 +389,14 @@ internal class ComposeHostingViewController( * Otherwise [UIEvent]s will be dispatched with the 60hz frequency. */ private fun onGestureEvent(gestureEvent: GestureEvent) { - metalView.needsProactiveDisplayLink = when (gestureEvent) { + rootMetalView.needsProactiveDisplayLink = when (gestureEvent) { GestureEvent.BEGAN -> true GestureEvent.ENDED -> false } } private fun dispose() { - metalView.dispose() + rootMetalView.dispose() lifecycleOwner.dispose() mediator?.dispose() rootView.dispose() @@ -423,28 +436,6 @@ internal class ComposeHostingViewController( } } -@OptIn(BetaInteropApi::class) -private fun UIViewController.checkIfInsideSwiftUI(): Boolean { - var parent = parentViewController - - while (parent != null) { - val isUIHostingController = parent.`class`()?.let { - val className = NSStringFromClass(it) - // SwiftUI UIHostingController has mangled name depending on generic instantiation type, - // It always contains UIHostingController substring though - return className.contains("UIHostingController") - } ?: false - - if (isUIHostingController) { - return true - } - - parent = parent.parentViewController - } - - return false -} - private fun UIUserInterfaceStyle.asComposeSystemTheme(): SystemTheme { return when (this) { UIUserInterfaceStyle.UIUserInterfaceStyleLight -> SystemTheme.Light diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt index 19d6b1834bc58..d7c99617c98b3 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt @@ -24,9 +24,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.SessionMutex +import androidx.compose.ui.animation.withAnimationProgress import androidx.compose.ui.draganddrop.UIKitDragAndDropManager import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.lerp import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.key.Key @@ -53,28 +56,27 @@ import androidx.compose.ui.platform.PlatformWindowContext import androidx.compose.ui.platform.UIKitTextInputService import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.platform.lerp import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration -import androidx.compose.ui.uikit.ExclusiveLayoutConstraints import androidx.compose.ui.uikit.LocalKeyboardOverlapHeight import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.embedSubview -import androidx.compose.ui.uikit.layoutConstraintsToCenterInParent -import androidx.compose.ui.uikit.layoutConstraintsToMatch import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.asCGRect import androidx.compose.ui.unit.asDpOffset -import androidx.compose.ui.unit.asDpRect +import androidx.compose.ui.unit.asDpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.roundToIntSize import androidx.compose.ui.unit.toDpRect import androidx.compose.ui.unit.toOffset import androidx.compose.ui.unit.toPlatformInsets +import androidx.compose.ui.unit.toSize import androidx.compose.ui.viewinterop.LocalInteropContainer import androidx.compose.ui.viewinterop.TrackInteropPlacementContainer import androidx.compose.ui.viewinterop.UIKitInteropContainer @@ -88,27 +90,21 @@ import androidx.compose.ui.window.MetalRedrawer import androidx.compose.ui.window.TouchesEventKind import androidx.compose.ui.window.UserInputView import kotlin.coroutines.CoroutineContext -import kotlin.math.roundToInt +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlinx.cinterop.CValue -import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.suspendCancellableCoroutine -import platform.CoreGraphics.CGAffineTransformIdentity -import platform.CoreGraphics.CGAffineTransformInvert import platform.CoreGraphics.CGPoint import platform.CoreGraphics.CGRect -import platform.CoreGraphics.CGSize import platform.QuartzCore.CACurrentMediaTime import platform.QuartzCore.CATransaction -import platform.UIKit.NSLayoutConstraint import platform.UIKit.UIEvent import platform.UIKit.UIPress import platform.UIKit.UITouch import platform.UIKit.UITouchPhase import platform.UIKit.UIView -import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol import platform.UIKit.UIWindow /** @@ -167,13 +163,8 @@ private class SemanticsOwnerListenerImpl( } } -internal sealed interface ComposeSceneMediatorLayout { - data object Fill : ComposeSceneMediatorLayout - class Center(val size: CValue) : ComposeSceneMediatorLayout -} - internal class ComposeSceneMediator( - private val parentView: UIView, + parentView: UIView, private val configuration: ComposeUIViewControllerConfiguration, private val focusStack: FocusStack?, private val windowContext: PlatformWindowContext, @@ -228,8 +219,6 @@ internal class ComposeSceneMediator( scene.compositionLocalContext = value } - private val userInputViewConstraints = ExclusiveLayoutConstraints() - private val applicationForegroundStateListener = ApplicationForegroundStateListener { _ -> // Sometimes the application can trigger animation and go background before the animation is @@ -333,8 +322,8 @@ internal class ComposeSceneMediator( } } - val hasInvalidations: Boolean - get() = scene.hasInvalidations() || keyboardManager.isAnimating + val hasInvalidations: Boolean get() = + scene.hasInvalidations() || keyboardManager.isAnimating || isLayoutTransitionAnimating private fun hitTestInteropView(point: CValue, event: UIEvent?): UIView? = point.useContents { @@ -399,10 +388,7 @@ internal class ComposeSceneMediator( } init { - view.translatesAutoresizingMaskIntoConstraints = false - parentView.addSubview(view) - setLayout(ComposeSceneMediatorLayout.Fill) - + parentView.embedSubview(view) view.embedSubview(userInputView) } @@ -427,42 +413,40 @@ internal class ComposeSceneMediator( } } - fun performOrientationChangeAnimation( - targetSize: CValue, - coordinator: UIViewControllerTransitionCoordinatorProtocol - ) { - val startSnapshotView = view.snapshotViewAfterScreenUpdates(false) ?: return - startSnapshotView.translatesAutoresizingMaskIntoConstraints = false - parentView.addSubview(startSnapshotView) - targetSize.useContents { - NSLayoutConstraint.activateConstraints( - listOf( - startSnapshotView.widthAnchor.constraintEqualToConstant(height), - startSnapshotView.heightAnchor.constraintEqualToConstant(width), - startSnapshotView.centerXAnchor.constraintEqualToAnchor(parentView.centerXAnchor), - startSnapshotView.centerYAnchor.constraintEqualToAnchor(parentView.centerYAnchor) - ) - ) - } - redrawer.isForcedToPresentWithTransactionEveryFrame = true - setLayout(ComposeSceneMediatorLayout.Center(targetSize)) - userInputView.transform = coordinator.targetTransform - - coordinator.animateAlongsideTransition( - animation = { - startSnapshotView.alpha = 0.0 - startSnapshotView.transform = CGAffineTransformInvert(coordinator.targetTransform) - userInputView.transform = CGAffineTransformIdentity.readValue() - }, - completion = { - startSnapshotView.removeFromSuperview() - setLayout(ComposeSceneMediatorLayout.Fill) - redrawer.isForcedToPresentWithTransactionEveryFrame = false + private var isLayoutTransitionAnimating = false + fun prepareAndGetSizeTransitionAnimation(): suspend (Duration) -> Unit { + isLayoutTransitionAnimating = true + + val initialLayoutMargins = layoutMargins + val initialSafeArea = safeArea + val initialSize = scene.size?.toSize() ?: return {} + + return { duration -> + try { + if (initialSize != currentViewSize) { + withAnimationProgress(duration) { progress -> + layoutMargins = lerp( + start = initialLayoutMargins, + stop = view.layoutMargins.toPlatformInsets(), + fraction = progress + ) + safeArea = lerp( + start = initialSafeArea, + stop = view.safeAreaInsets.toPlatformInsets(), + fraction = progress + ) + scene.size = lerp( + start = initialSize, + stop = currentViewSize, + fraction = progress + ).roundToIntSize() + } + } + } finally { + isLayoutTransitionAnimating = false + updateLayout() } - ) - - userInputView.setNeedsLayout() - view.layoutIfNeeded() + } } fun render(canvas: Canvas, nanoTime: Long) { @@ -472,22 +456,6 @@ internal class ComposeSceneMediator( fun retrieveInteropTransaction(): UIKitInteropTransaction = interopContainer.retrieveTransaction() - private fun setLayout(value: ComposeSceneMediatorLayout) { - when (value) { - ComposeSceneMediatorLayout.Fill -> { - userInputViewConstraints.set( - view.layoutConstraintsToMatch(parentView) - ) - } - - is ComposeSceneMediatorLayout.Center -> { - userInputViewConstraints.set( - view.layoutConstraintsToCenterInParent(parentView, value.size) - ) - } - } - } - private var safeArea by mutableStateOf(PlatformInsets.Zero) private var layoutMargins by mutableStateOf(PlatformInsets.Zero) @@ -552,18 +520,19 @@ internal class ComposeSceneMediator( private fun updateLayout() { density = view.density + if (isLayoutTransitionAnimating) { + return + } layoutMargins = view.layoutMargins.toPlatformInsets() safeArea = view.safeAreaInsets.toPlatformInsets() - val boundsInPx = view.bounds.useContents { - with(density) { - asDpRect().toRect() - } + scene.size = currentViewSize.roundToIntSize() + } + + private val currentViewSize: Size get() { + return with(density) { + view.frame.useContents { size.asDpSize() }.toSize() } - scene.size = IntSize( - width = boundsInPx.width.roundToInt(), - height = boundsInPx.height.roundToInt() - ) } fun sceneDidAppear() { diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt index d5bdcf5525fe6..3d0922effcae2 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.asDpOffset -import androidx.compose.ui.unit.asDpSize +import androidx.compose.ui.unit.asDpRect import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toOffset import androidx.compose.ui.window.FocusStack @@ -47,7 +47,7 @@ internal class UIKitComposeSceneLayer( private val onClosed: (UIKitComposeSceneLayer) -> Unit, private val createComposeSceneContext: (PlatformContext) -> ComposeSceneContext, private val providingCompositionLocals: @Composable (@Composable () -> Unit) -> Unit, - metalView: MetalView, + private val metalView: MetalView, onGestureEvent: (GestureEvent) -> Unit, private val initDensity: Density, private val initLayoutDirection: LayoutDirection, @@ -115,15 +115,9 @@ internal class UIKitComposeSceneLayer( fun render(canvas: Canvas, nanoTime: Long) { if (scrimColor != null) { - val size = view.bounds.useContents { with(density) { size.asDpSize().toSize() } } - - canvas.drawRect( - left = 0f, - top = 0f, - right = size.width, - bottom = size.height, - paint = scrimPaint - ) + val rect = metalView.bounds.useContents { with(density) { asDpRect().toRect() } } + + canvas.drawRect(rect, scrimPaint) } mediator.render(canvas, nanoTime) @@ -131,6 +125,8 @@ internal class UIKitComposeSceneLayer( fun retrieveInteropTransaction() = mediator.retrieveInteropTransaction() + fun prepareAndGetSizeTransitionAnimation() = mediator.prepareAndGetSizeTransitionAnimation() + override fun close() { onClosed(this) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt index 5e376b0ca5005..bddbad8e93baa 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt @@ -17,20 +17,26 @@ package androidx.compose.ui.scene import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.platform.PlatformWindowContext import androidx.compose.ui.uikit.embedSubview import androidx.compose.ui.util.fastForEach import androidx.compose.ui.viewinterop.UIKitInteropTransaction +import androidx.compose.ui.window.ComposeView import androidx.compose.ui.window.GestureEvent import androidx.compose.ui.window.MetalView +import kotlin.time.Duration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch import org.jetbrains.skia.Canvas import platform.UIKit.UIEvent import platform.UIKit.UIWindow -// TODO: add cross-fade orientation transition like in `ComposeHostingViewController` /** * A class responsible for managing and rendering [UIKitComposeSceneLayer]s. */ internal class UIKitComposeSceneLayersHolder( + private val windowContext: PlatformWindowContext, useSeparateRenderThreadWhenPossible: Boolean ) { val hasInvalidations: Boolean @@ -47,8 +53,6 @@ internal class UIKitComposeSceneLayersHolder( */ private var removedLayersTransactions = mutableListOf() - private val view = UIKitComposeSceneLayersHolderView() - val metalView: MetalView = MetalView( ::retrieveAndMergeInteropTransactions, useSeparateRenderThreadWhenPossible, @@ -57,8 +61,29 @@ internal class UIKitComposeSceneLayersHolder( canBeOpaque = false } - init { - view.embedSubview(metalView) + private val view = ComposeView( + onDidMoveToWindow = {}, + onLayoutSubviews = { windowContext.updateWindowContainerSize() }, + useOpaqueConfiguration = false, + transparentForTouches = true, + metalView = metalView + ) + + fun animateSizeTransition(scope: CoroutineScope, duration: Duration) { + if (layers.isEmpty()) { + return + } + val animations = listOf( + windowContext.prepareAndGetSizeTransitionAnimation() + ) + layers.map { + it.prepareAndGetSizeTransitionAnimation() + } + + view.animateSizeTransition(scope) { + animations.map { + scope.launch { it.invoke(duration) } + }.joinAll() + } } /** @@ -104,6 +129,7 @@ internal class UIKitComposeSceneLayersHolder( layer.dispose() } + view.dispose() view.removeFromSuperview() } @@ -176,7 +202,9 @@ internal class UIKitComposeSceneLayersHolder( val removedLayersTransactionsCopy = removedLayersTransactions.toList() removedLayersTransactions.clear() - val transactions = layers.map { it.retrieveInteropTransaction() } + removedLayersTransactionsCopy + val transactions = layers.map { + it.retrieveInteropTransaction() + } + removedLayersTransactionsCopy return UIKitInteropTransaction.merge( transactions = transactions ) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolderView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolderView.uikit.kt deleted file mode 100644 index a06037bca5a87..0000000000000 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolderView.uikit.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2024 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.scene - -import kotlinx.cinterop.CValue -import kotlinx.cinterop.readValue -import platform.CoreGraphics.CGPoint -import platform.CoreGraphics.CGRectZero -import platform.UIKit.UIEvent -import platform.UIKit.UIView - -/** - * A view that hosts the [ComposeScene] layers and a metal view shared by all of them. - */ -internal class UIKitComposeSceneLayersHolderView: UIView(frame = CGRectZero.readValue()) { - /** - * This view is transparent for touches, unless a child view is hit-tested. - */ - override fun hitTest(point: CValue, withEvent: UIEvent?): UIView? { - val result = super.hitTest(point, withEvent) - - return if (result == this) { - null - } else { - result - } - } -} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt index 86b910f19bc62..cba5d8c71a5ac 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt @@ -20,9 +20,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.dp +import kotlin.math.floor +import kotlin.math.roundToLong import kotlinx.cinterop.CValue import kotlinx.cinterop.useContents import platform.CoreGraphics.CGRect +import platform.Foundation.NSTimeInterval import platform.UIKit.UIColor import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraExtraLarge import platform.UIKit.UIContentSizeCategoryAccessibilityExtraExtraLarge @@ -99,3 +102,14 @@ internal fun CValue.toDpRect() = useContents { bottom = origin.y.dp + size.height.dp, ) } + +internal fun NSTimeInterval.toNanoSeconds(): Long { + // The calculation is split in two instead of + // `(targetTimestamp * 1e9).toLong()` + // to avoid losing precision for fractional part + val integral = floor(this) + val fractional = this - integral + val secondsToNanos = 1_000_000_000L + val nanos = integral.roundToLong() * secondsToNanos + (fractional * 1e9).roundToLong() + return nanos +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeSceneKeyboardOffsetManager.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeSceneKeyboardOffsetManager.uikit.kt index c94dcc68abeed..7f3168b6fedaf 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeSceneKeyboardOffsetManager.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeSceneKeyboardOffsetManager.uikit.kt @@ -19,9 +19,7 @@ package androidx.compose.ui.window import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.math.max -import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue -import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents import platform.CoreGraphics.CGPointMake @@ -30,16 +28,9 @@ import platform.CoreGraphics.CGRectGetMinY import platform.CoreGraphics.CGRectIsEmpty import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectZero -import platform.Foundation.NSRunLoop -import platform.Foundation.NSRunLoopCommonModes -import platform.QuartzCore.CADisplayLink import platform.UIKit.UIView import platform.UIKit.UIViewAnimationOptionCurveEaseInOut import platform.UIKit.UIViewAnimationOptions -import platform.darwin.NSObject -import platform.darwin.dispatch_async -import platform.darwin.dispatch_get_main_queue -import platform.darwin.sel_registerName internal class ComposeSceneKeyboardOffsetManager( private val view: UIView, @@ -70,7 +61,7 @@ internal class ComposeSceneKeyboardOffsetManager( private val animationViews = mutableListOf() - private var keyboardAnimationListener: CADisplayLink? = null + private var keyboardAnimationListener: DisplayLinkListener? = null val isAnimating get() = keyboardAnimationListener != null @@ -176,18 +167,9 @@ internal class ComposeSceneKeyboardOffsetManager( return layer.frame.useContents { size.height / animationTargetSize } } - //animation listener - val keyboardDisplayLink = CADisplayLink.displayLinkWithTarget( - target = object : NSObject() { - @OptIn(BetaInteropApi::class) - @Suppress("unused") - @ObjCAction - fun animationDidUpdate() { - updateAnimationValues(getCurrentAnimationProgress()) - } - }, - selector = sel_registerName("animationDidUpdate") - ) + val keyboardDisplayLink = DisplayLinkListener { + updateAnimationValues(getCurrentAnimationProgress()) + } keyboardAnimationListener = keyboardDisplayLink UIView.animateWithDuration( @@ -208,11 +190,6 @@ internal class ComposeSceneKeyboardOffsetManager( } } ) - // HACK: Add display link observer to run loop in the next run loop cycle to fix issue - // where view's presentationLayer sometimes gets end bounds on the first animation frame - // instead of the initial one. - dispatch_async(dispatch_get_main_queue()) { - keyboardDisplayLink.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoopCommonModes) - } + keyboardDisplayLink.start() } } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeView.uikit.kt index 32143aafc0df6..857d133b550f1 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeView.uikit.kt @@ -16,20 +16,34 @@ package androidx.compose.ui.window +import androidx.compose.ui.unit.asDpSize +import kotlin.math.max +import kotlinx.cinterop.CValue import kotlinx.cinterop.readValue +import kotlinx.cinterop.useContents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import platform.CoreGraphics.CGPoint +import platform.CoreGraphics.CGRectEqualToRect +import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectZero import platform.UIKit.UIColor +import platform.UIKit.UIEvent import platform.UIKit.UIView import platform.UIKit.UIWindow internal class ComposeView( private var onDidMoveToWindow: (UIWindow?) -> Unit, private var onLayoutSubviews: () -> Unit, - useOpaqueConfiguration: Boolean + useOpaqueConfiguration: Boolean, + private val transparentForTouches: Boolean, + private val metalView: MetalView, ): UIView(frame = CGRectZero.readValue()) { init { setClipsToBounds(true) setOpaque(useOpaqueConfiguration) + addSubview(metalView) backgroundColor = if (useOpaqueConfiguration) UIColor.whiteColor else UIColor.clearColor } @@ -44,10 +58,13 @@ internal class ComposeView( setNeedsLayout() } + private var isAnimating: Boolean = false + override fun layoutSubviews() { super.layoutSubviews() onLayoutSubviews() + updateLayout() } override fun safeAreaInsetsDidChange() { @@ -56,8 +73,57 @@ internal class ComposeView( setNeedsLayout() } + private fun updateLayout() { + if (isAnimating) { + val oldSize = metalView.frame.useContents { size.asDpSize() } + val newSize = bounds.useContents { size.asDpSize() } + val targetRect = CGRectMake( + 0.0, + 0.0, + max(oldSize.width.value, newSize.width.value).toDouble(), + max(oldSize.height.value, newSize.height.value).toDouble() + ) + if (!CGRectEqualToRect(metalView.frame, targetRect)) { + UIView.performWithoutAnimation { + metalView.setFrame(targetRect) + metalView.setNeedsSynchronousDrawOnNextLayout() + } + } + } else { + if (!CGRectEqualToRect(metalView.frame, bounds)) { + UIView.performWithoutAnimation { + metalView.setFrame(bounds) + metalView.setNeedsSynchronousDrawOnNextLayout() + } + } + } + } + + fun animateSizeTransition(scope: CoroutineScope, animations: suspend () -> Unit) { + isAnimating = true + updateLayout() + metalView.redrawer.isForcedToPresentWithTransactionEveryFrame = true + metalView.needsProactiveDisplayLink = true + scope.launch { + try { + animations() + } finally { + // Delay mitigates rendering glitches that can occur at the end of the animation. + delay(50) + isAnimating = false + updateLayout() + metalView.redrawer.isForcedToPresentWithTransactionEveryFrame = true + metalView.needsProactiveDisplayLink = true + } + } + } + + override fun hitTest(point: CValue, withEvent: UIEvent?): UIView? { + return super.hitTest(point, withEvent).takeUnless { transparentForTouches && it == this } + } + fun dispose() { onDidMoveToWindow = {} onLayoutSubviews = {} } -} \ No newline at end of file +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/DisplayLinkListener.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/DisplayLinkListener.kt new file mode 100644 index 0000000000000..33a7f2fe50012 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/DisplayLinkListener.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 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.window + +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.MonotonicFrameClock +import androidx.compose.ui.uikit.toNanoSeconds +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ObjCAction +import platform.Foundation.NSRunLoop +import platform.Foundation.NSRunLoopCommonModes +import platform.QuartzCore.CADisplayLink +import platform.darwin.NSObject +import platform.darwin.sel_registerName + +internal class DisplayLinkListener(private val trigger: () -> Unit = {}) { + private var keyboardAnimationListener: CADisplayLink? = null + + private val _frameClock = BroadcastFrameClock() + val frameClock: MonotonicFrameClock = _frameClock + + fun start() { + keyboardAnimationListener = CADisplayLink.displayLinkWithTarget( + target = object : NSObject() { + @OptIn(BetaInteropApi::class) + @Suppress("unused") + @ObjCAction + fun animationDidUpdate() { + _frameClock.sendFrame( + timeNanos = keyboardAnimationListener?.targetTimestamp?.toNanoSeconds() ?: 0 + ) + trigger() + } + }, + selector = sel_registerName("animationDidUpdate") + ) + keyboardAnimationListener?.addToRunLoop(NSRunLoop.mainRunLoop, NSRunLoopCommonModes) + } + + fun invalidate() { + keyboardAnimationListener?.invalidate() + keyboardAnimationListener = null + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt index 694ada5602e0c..dabd6617f8c87 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/IntermediateTextInputUIView.uikit.kt @@ -47,6 +47,7 @@ import platform.UIKit.UIEvent import platform.UIKit.UIKeyInputProtocol import platform.UIKit.UIKeyboardAppearance import platform.UIKit.UIKeyboardType +import platform.UIKit.UIPress import platform.UIKit.UIPressesEvent import platform.UIKit.UIReturnKeyType import platform.UIKit.UITextAutocapitalizationType @@ -66,7 +67,6 @@ import platform.UIKit.UITextRange import platform.UIKit.UITextSelectionRect import platform.UIKit.UITextStorageDirection import platform.UIKit.UIView -import platform.UIKit.UIPress import platform.darwin.NSInteger private val NoOpOnKeyboardPresses: (Set<*>) -> Unit = {} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalView.uikit.kt index 40d8c4573ecaf..f41c8c95af73e 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalView.uikit.kt @@ -16,9 +16,8 @@ package androidx.compose.ui.window +import androidx.compose.ui.uikit.toNanoSeconds import androidx.compose.ui.viewinterop.UIKitInteropTransaction -import kotlin.math.floor -import kotlin.math.roundToLong import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents @@ -26,7 +25,6 @@ import org.jetbrains.skia.Canvas import platform.CoreGraphics.CGRectIsEmpty import platform.CoreGraphics.CGRectZero import platform.CoreGraphics.CGSizeMake -import platform.Foundation.NSTimeInterval import platform.Metal.MTLCreateSystemDefaultDevice import platform.Metal.MTLDeviceProtocol import platform.Metal.MTLPixelFormatBGRA8Unorm @@ -139,14 +137,3 @@ internal class MetalView( override fun canBecomeFirstResponder() = false } - -private fun NSTimeInterval.toNanoSeconds(): Long { - // The calculation is split in two instead of - // `(targetTimestamp * 1e9).toLong()` - // to avoid losing precision for fractional part - val integral = floor(this) - val fractional = this - integral - val secondsToNanos = 1_000_000_000L - val nanos = integral.roundToLong() * secondsToNanos + (fractional * 1e9).roundToLong() - return nanos -}