-
Notifications
You must be signed in to change notification settings - Fork 142
Support sizing of Compose hosting views on iOS #2984
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: jb-main
Are you sure you want to change the base?
Changes from all commits
a534d87
2a31776
bea6ae9
c0b7615
15dc155
2ad93c9
ea1c621
55a2fed
c1f660d
e6fe7ee
9090eb2
0631614
6ffda8d
6c0841b
0073e77
f523ff7
69a4248
1ef163a
0ce3a5a
6eda5ea
779c988
79e016d
e7df1a1
bb9ac23
3fded93
d94f2f5
d0a0393
db61566
b121073
9da605c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,9 +37,14 @@ import androidx.compose.ui.uikit.density | |
| import androidx.compose.ui.uikit.embedSubview | ||
| import androidx.compose.ui.uikit.utils.CMPKeyValueObserver | ||
| import androidx.compose.ui.uikit.utils.CMPUIWindowSceneUtils | ||
| import androidx.compose.ui.unit.Constraints | ||
| import androidx.compose.ui.unit.Density | ||
| import androidx.compose.ui.unit.DpSize | ||
| import androidx.compose.ui.unit.IntOffset | ||
| import androidx.compose.ui.unit.IntSize | ||
| import androidx.compose.ui.unit.LayoutDirection | ||
| import androidx.compose.ui.unit.dp | ||
| import androidx.compose.ui.unit.toIntSize | ||
| import androidx.compose.ui.util.fastForEachReversed | ||
| import androidx.compose.ui.viewinterop.UIKitInteropAction | ||
| import androidx.compose.ui.viewinterop.UIKitInteropTransaction | ||
|
|
@@ -54,8 +59,13 @@ import kotlin.coroutines.CoroutineContext | |
| import kotlin.coroutines.EmptyCoroutineContext | ||
| import kotlinx.cinterop.CPointed | ||
| import kotlinx.cinterop.CPointer | ||
| import kotlinx.cinterop.CValue | ||
| import kotlinx.cinterop.useContents | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Job | ||
| import platform.CoreGraphics.CGFloat | ||
| import platform.CoreGraphics.CGSize | ||
| import platform.CoreGraphics.CGSizeMake | ||
| import platform.Foundation.NSKeyValueObservingOptionNew | ||
| import platform.Foundation.addObserver | ||
| import platform.Foundation.removeObserver | ||
|
|
@@ -65,6 +75,7 @@ import platform.UIKit.UIResponder | |
| import platform.UIKit.UIUserInterfaceLayoutDirection | ||
| import platform.UIKit.UIUserInterfaceStyle | ||
| import platform.UIKit.UIViewController | ||
| import platform.UIKit.UIViewNoIntrinsicMetric | ||
| import platform.UIKit.UIWindow | ||
| import platform.UIKit.UIWindowScene | ||
|
|
||
|
|
@@ -81,7 +92,10 @@ internal class ComposeContainer( | |
| val view = ComposeContainerView( | ||
| transparentForTouches = false, | ||
| useOpaqueConfiguration = configuration.opaque, | ||
| ) | ||
| ).apply { | ||
| onSizeThatFits = { composeSceneSizeSynchronizer.onSizeThatFitsRequest(it) } | ||
| onIntrinsicContentSize = { composeSceneSizeSynchronizer.preferredCGSize } | ||
| } | ||
|
|
||
| private var mediator: ComposeSceneMediator? = null | ||
| private val windowContext = PlatformWindowContext() | ||
|
|
@@ -108,6 +122,14 @@ internal class ComposeContainer( | |
| getTopLeftOffsetInWindow = { IntOffset.Zero }, //full screen | ||
| endEdgePanGestureBehavior = configuration.endEdgePanGestureBehavior | ||
| ) | ||
|
|
||
| private val composeSceneSizeSynchronizer: ComposeSceneSizeSynchronizer = | ||
| ComposeSceneSizeSynchronizer( | ||
| view = view, | ||
| composeSceneSize = { mediator?.measureSceneSize(it) }, | ||
| invalidateComposeSceneContainerSize = view::invalidateIntrinsicContentSize | ||
| ) | ||
|
|
||
| val hasInteropViews: Boolean get() = mediator?.hasInteropViews ?: false | ||
|
|
||
| /* | ||
|
|
@@ -248,6 +270,10 @@ internal class ComposeContainer( | |
| } | ||
| } | ||
|
|
||
| mediator?.setOnLayoutCompletedListener { | ||
| composeSceneSizeSynchronizer.onComposeLayoutCompleted() | ||
| } | ||
|
|
||
| activeStateListener = SceneActiveStateListener( | ||
| getScene = ::windowScene | ||
| ) { isSceneActive -> | ||
|
|
@@ -276,6 +302,7 @@ internal class ComposeContainer( | |
| navigationEventInput.onDidMoveToWindow(null, view) | ||
| architectureComponentsOwner.navigationEventDispatcher.removeInput(navigationEventInput) | ||
|
|
||
| mediator?.setOnLayoutCompletedListener(null) | ||
| mediator = null | ||
|
|
||
| activeStateListener?.dispose() | ||
|
|
@@ -479,3 +506,128 @@ private class SceneGeometryObserver( | |
| onGeometryChanged() | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Synchronizes UIKit/SwiftUI sizing proposals (`sizeThatFits`) with the preferred size produced by | ||
| * the Compose scene under the corresponding constraints. | ||
| * | ||
| * - UIKit/SwiftUI proposes constraints via `sizeThatFits` | ||
| * - Compose produces a preferred size under those constraints after its layout completes | ||
| * - if the preferred size changes, we invalidate the *hosting* view intrinsic size so | ||
| * UIKit/SwiftUI can re-run layout with the updated information | ||
| */ | ||
| internal class ComposeSceneSizeSynchronizer( | ||
| private val view: ComposeContainerView, | ||
| private val composeSceneSize: (Constraints) -> IntSize?, | ||
| private var invalidateComposeSceneContainerSize: () -> Unit = {}, | ||
| ) { | ||
|
|
||
| private var latestSizeThatFitsConstraints: Constraints? = null | ||
| /** | ||
| * Latest constraints proposed by UIKit/SwiftUI through `sizeThatFits`. | ||
| */ | ||
| private var lastMeasuredConstraints: Constraints? = null | ||
| /** | ||
| * Preferred size measured by Compose for [lastMeasuredConstraints]. | ||
| */ | ||
| private var preferredSize: IntSize? = null | ||
| /** | ||
| * Last size returned from `sizeThatFits`. | ||
| * | ||
| * Used as a temporary intrinsic size fallback until Compose measures and caches a real | ||
| * preferred size for the latest external proposal. | ||
| */ | ||
| private var lastSizeThatFitsResult: CValue<CGSize>? = null | ||
|
|
||
| private val hasPreferredSize: Boolean get() = preferredSize != null | ||
|
|
||
| val preferredCGSize: CValue<CGSize>? | ||
| get() = preferredSize?.toCGSize(view.density) ?: lastSizeThatFitsResult | ||
|
|
||
| fun onSizeThatFitsRequest(size: CValue<CGSize>): CValue<CGSize> { | ||
| val constraints = size.useContents { | ||
| Constraints( | ||
| maxWidth = width.toConstraintValue(view.density), | ||
| maxHeight = height.toConstraintValue(view.density) | ||
| ) | ||
| } | ||
|
|
||
| // `sizeThatFits` requests come from UIKit/SwiftUI and should win over stale internal state. | ||
| if (latestSizeThatFitsConstraints != constraints) { | ||
| latestSizeThatFitsConstraints = constraints | ||
| } | ||
|
|
||
| val result = preferredSizeForConstraints(constraints) ?: fallbackSizeThatFits(size) | ||
| lastSizeThatFitsResult = result | ||
|
|
||
| return result | ||
| } | ||
|
|
||
| private fun preferredSizeForConstraints(constraints: Constraints): CValue<CGSize>? { | ||
| if (!hasPreferredSize) return null | ||
|
|
||
| // Fast path: if Compose already measured preferred size for the exact same constraints, | ||
| // return it directly. | ||
| if (lastMeasuredConstraints == constraints) { | ||
| return preferredCGSize | ||
| } | ||
|
|
||
| val didUpdatePreferredSize = measureAndCachePreferredSize(constraints) ?: return null | ||
|
|
||
| if (didUpdatePreferredSize) { | ||
| invalidateComposeSceneContainerSize() | ||
| } | ||
|
|
||
| return preferredCGSize | ||
| } | ||
|
|
||
| fun onComposeLayoutCompleted() { | ||
| val constraints = latestSizeThatFitsConstraints ?: return | ||
| val didUpdatePreferredSize = measureAndCachePreferredSize(constraints) ?: return | ||
|
|
||
| if (didUpdatePreferredSize) { | ||
| invalidateComposeSceneContainerSize() | ||
| } | ||
| } | ||
|
|
||
| private fun measureAndCachePreferredSize(constraints: Constraints): Boolean? { | ||
| val preferredSize = composeSceneSize(constraints) ?: return null | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems that one more unconditional re-measure on each frame might be costly |
||
|
|
||
| lastMeasuredConstraints = constraints | ||
| val preferredSizeUpdated = preferredSize != this.preferredSize | ||
| if (preferredSizeUpdated) { | ||
| this.preferredSize = preferredSize | ||
| } | ||
| return preferredSizeUpdated | ||
| } | ||
|
|
||
| private fun fallbackSizeThatFits(size: CValue<CGSize>): CValue<CGSize> { | ||
| val viewSizeThatFits by lazy { view.superSizeThatFits(size) } | ||
|
|
||
| val width = if (size.useContents { width } == UIViewNoIntrinsicMetric) { | ||
| viewSizeThatFits.useContents { width } | ||
| } else { | ||
| size.useContents { width } | ||
| } | ||
| val height = if (size.useContents { height } == UIViewNoIntrinsicMetric) { | ||
| viewSizeThatFits.useContents { height } | ||
| } else { | ||
| size.useContents { height } | ||
| } | ||
|
|
||
| return with(view.density) { | ||
| DpSize(width.dp, height.dp).toSize().toIntSize().toCGSize(this) | ||
| } | ||
| } | ||
|
|
||
| private fun CGFloat.toConstraintValue(density: Density): Int { | ||
| if (this == UIViewNoIntrinsicMetric) return Constraints.Infinity | ||
| val px = with(density) { dp.roundToPx() } | ||
| if (px >= 0 || px == Constraints.Infinity) return px | ||
| throw IllegalArgumentException("Invalid constraint size: $this") | ||
| } | ||
| } | ||
|
|
||
| private fun IntSize.toCGSize(density: Density) = with(density) { | ||
| CGSizeMake(width.toDp().value.toDouble(), height.toDp().value.toDouble()) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ import androidx.compose.runtime.State | |
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.mutableStateOf | ||
| import androidx.compose.runtime.setValue | ||
| import androidx.compose.runtime.withFrameNanos | ||
| import androidx.compose.ui.InternalComposeUiApi | ||
| import androidx.compose.ui.draganddrop.UIKitDragAndDropManager | ||
| import androidx.compose.ui.geometry.Offset | ||
|
|
@@ -74,6 +75,7 @@ import androidx.compose.ui.input.pointer.isAltPressed | |
| import androidx.compose.ui.input.pointer.isCtrlPressed | ||
| import androidx.compose.ui.input.pointer.isMetaPressed | ||
| import androidx.compose.ui.input.pointer.isShiftPressed | ||
| import androidx.compose.ui.unit.Constraints | ||
| import androidx.compose.ui.unit.Density | ||
| import androidx.compose.ui.unit.DpOffset | ||
| import androidx.compose.ui.unit.IntRect | ||
|
|
@@ -206,8 +208,8 @@ internal class ComposeSceneMediator( | |
| ) -> ComposeScene, | ||
| ) { | ||
| private var onPreviewKeyEvent: (KeyEvent) -> Boolean = { false } | ||
|
|
||
| private var onKeyEvent: (KeyEvent) -> Boolean = { false } | ||
| private var onLayoutCompleted: (() -> Unit) = {} | ||
| private var animateKeyboardOffsetChanges by mutableStateOf(false) | ||
| private var platformScreenReader = object : PlatformScreenReader { | ||
| override var isActive by mutableStateOf(false) | ||
|
|
@@ -276,7 +278,7 @@ internal class ComposeSceneMediator( | |
| * Density of the hosting UIKit screen. | ||
| * | ||
| * This value is intentionally separate from [composeSceneDensity] so we can support setting | ||
| * composeSceneDensity without regressions. | ||
| * [composeSceneDensity] without regressions. | ||
| */ | ||
| val screenDensity: Density get() = _overlayView.density | ||
|
|
||
|
|
@@ -639,8 +641,23 @@ internal class ComposeSceneMediator( | |
| private var lastRenderTime = CACurrentMediaTime().toNanoSeconds() | ||
| fun render(canvas: Canvas, nanoTime: Long) { | ||
| lastRenderTime = nanoTime | ||
| with(sceneRenderingScope) { | ||
| scene.render(frameRecomposer, canvas, nanoTime) | ||
| scene.render(canvas, nanoTime) | ||
| } | ||
|
|
||
| /** | ||
| * Renders the current content on [canvas]: advances the host [frameRecomposer] by one frame, | ||
| * then runs the scene's measure/layout and draw phases. [nanoTime] is the frame time used to | ||
| * drive all animations in the content (and any other code using [withFrameNanos]). | ||
| */ | ||
| private fun ComposeScene.render(canvas: Canvas, nanoTime: Long) { | ||
| sceneRenderingScope.postponingSceneInvalidations { | ||
| frameRecomposer.performFrame(nanoTime) | ||
| measureAndLayout() | ||
| onLayoutCompleted() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is it important to do it before drawing?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we wrap call it only if there was
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought about this and if we only did this, then here we are assuming that
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually it seems that invalidation request to a system should be initiated much earlier, in
which seems that actual resize will happen with one frame delay (see another comment/thread).
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Well, the issue is opposite. The platform does not need to call |
||
| draw(canvas) | ||
| } | ||
| if (hasInvalidations()) { | ||
| redrawer.setNeedsRedraw() | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -759,6 +776,16 @@ internal class ComposeSceneMediator( | |
| this.onKeyEvent = onKeyEvent ?: { false } | ||
| } | ||
|
|
||
| /** | ||
| * Sets the single callback invoked after the scene measure/layout phase during rendering. | ||
| * Passing `null` clears the current callback. | ||
| */ | ||
| fun setOnLayoutCompletedListener(listener: (() -> Unit)?) { | ||
| onLayoutCompleted = listener ?: {} | ||
| } | ||
|
|
||
| fun measureSceneSize(constraints: Constraints): IntSize = scene.measureContent(constraints) | ||
|
|
||
| /** | ||
| * Converts [UIPress] objects to [KeyEvent] and dispatches them to the appropriate handlers. | ||
| * @param presses a [Set] of [UIPress] objects. Erasure happens due to K/N not supporting Obj-C lightweight generics. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems it will request another frame and apply changes only there.
It might work for PoC, but for the long term I believe compose should participate in the native layout phase synchronously