Skip to content

Commit 52e6edf

Browse files
authored
Fix onFirstVisible modifier (#2233)
[CMP-8183](https://youtrack.jetbrains.com/issue/CMP-8183) Support calling of RectManager.updateOffsets [CMP-8544](https://youtrack.jetbrains.com/issue/CMP-8544) onFirstVisible Modifier doesn't work on non-Android platforms ## Release Notes ### Fixes - Multiple Platforms - _(prerelease fix)_ Fix trigger of `Modifier.onFirstVisible` modifier (added in Jetpack Compose `1.9.0-alpha03`)
1 parent 7b162e6 commit 52e6edf

File tree

3 files changed

+145
-41
lines changed

3 files changed

+145
-41
lines changed

compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt

Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import androidx.compose.ui.geometry.Offset
4141
import androidx.compose.ui.geometry.Rect
4242
import androidx.compose.ui.geometry.isUnspecified
4343
import androidx.compose.ui.graphics.Canvas
44+
import androidx.compose.ui.graphics.Matrix
4445
import androidx.compose.ui.graphics.SkiaGraphicsContext
4546
import androidx.compose.ui.graphics.layer.GraphicsLayer
4647
import androidx.compose.ui.input.InputMode
@@ -89,8 +90,10 @@ import androidx.compose.ui.text.font.createFontFamilyResolver
8990
import androidx.compose.ui.text.input.TextInputService
9091
import androidx.compose.ui.unit.Constraints
9192
import androidx.compose.ui.unit.Density
93+
import androidx.compose.ui.unit.IntOffset
9294
import androidx.compose.ui.unit.IntSize
9395
import androidx.compose.ui.unit.LayoutDirection
96+
import androidx.compose.ui.unit.round
9497
import androidx.compose.ui.unit.toIntRect
9598
import androidx.compose.ui.unit.toRect
9699
import androidx.compose.ui.util.fastAll
@@ -159,19 +162,23 @@ internal class RootNodeOwner(
159162
private val pointerInputEventProcessor = PointerInputEventProcessor(owner.root)
160163
private val measureAndLayoutDelegate = MeasureAndLayoutDelegate(owner.root)
161164
private var isDisposed = false
162-
private var currentCornersOnScreen: List<Offset>? = cornersOnScreen()
165+
166+
private var windowPosition: Offset? = null
167+
private var globalPosition: Offset? = null
168+
169+
// TODO: Android assumes matrix for some APIs, so we need store something to avoid extra
170+
// allocations. Clean up this APIs and remove it.
171+
private val identityMatrix = Matrix()
163172

164173
init {
165174
snapshotObserver.startObserving()
166175
owner.root.attach(owner)
167176
platformContext.rootForTestListener?.onRootForTestCreated(rootForTest)
168177
onRootConstrainsChanged(size?.toConstraints())
169-
onLightingInfoChanged()
178+
updatePositionCacheAndDispatch()
170179
coroutineScope.launch {
171180
snapshotFlow { platformContext.windowInfo.containerSize }
172-
.collect {
173-
onLightingInfoChanged()
174-
}
181+
.collect { updatePositionCacheAndDispatch() }
175182
}
176183
}
177184

@@ -216,43 +223,65 @@ internal class RootNodeOwner(
216223

217224
fun measureAndLayout() {
218225
owner.measureAndLayout(sendPointerUpdate = true)
226+
updatePositionCacheAndDispatch()
219227
}
220228

221-
fun invalidatePositionInWindow() {
222-
owner.root.layoutDelegate.measurePassDelegate.notifyChildrenUsingCoordinatesWhilePlacing()
223-
measureAndLayoutDelegate.dispatchOnPositionedCallbacks(forceDispatch = true)
224-
onLightingInfoChanged()
229+
private fun updatePositionCacheAndDispatch() {
230+
val globalPosition = platformContext.convertLocalToScreenPosition(Offset.Zero)
231+
val hasGlobalPositionChanged = if (platformContext.hasNonTranslationComponents) {
232+
this.globalPosition = null
233+
true // Always invalidate in case of rotation, skew, etc.
234+
} else if (globalPosition != this.globalPosition) {
235+
this.globalPosition = globalPosition
236+
true
237+
} else false
238+
239+
val windowPosition = platformContext.convertLocalToWindowPosition(Offset.Zero)
240+
val hasWindowPositionChanged = if (platformContext.hasNonTranslationComponents) {
241+
this.windowPosition = null
242+
true // Always invalidate in case of rotation, skew, etc.
243+
} else if (windowPosition != this.windowPosition) {
244+
this.windowPosition = windowPosition
245+
true
246+
} else false
247+
248+
if (hasGlobalPositionChanged || hasWindowPositionChanged) {
249+
owner.root.layoutDelegate.measurePassDelegate.notifyChildrenUsingCoordinatesWhilePlacing()
250+
}
251+
val containerSize = platformContext.windowInfo.containerSize
252+
owner.rectManager.updateOffsets(
253+
screenOffset = globalPosition.round(),
254+
windowOffset = windowPosition.round(),
255+
viewToWindowMatrix = identityMatrix, // TODO: Replace viewToWindowMatrix to delegates
256+
windowWidth = containerSize.width,
257+
windowHeight = containerSize.height,
258+
)
259+
measureAndLayoutDelegate.dispatchOnPositionedCallbacks(
260+
forceDispatch = hasGlobalPositionChanged || hasWindowPositionChanged
261+
)
262+
if (ComposeUiFlags.isRectTrackingEnabled) {
263+
owner.rectManager.dispatchCallbacks()
264+
}
265+
if (hasWindowPositionChanged) {
266+
graphicsContext.setLightingInfo(
267+
canvasOffset = windowPosition,
268+
density = density,
269+
containerSize = containerSize
270+
)
271+
}
225272
}
226273

227-
private fun cornersOnScreen() = size?.let { size ->
228-
val width = size.width.toFloat()
229-
val height = size.height.toFloat()
230-
val corners =
231-
listOf(
232-
Offset.Zero,
233-
Offset(x = width, y = 0f),
234-
Offset(x = 0f, y = height),
235-
Offset(x = width, y = height)
236-
).fastMap {
237-
platformContext.convertLocalToScreenPosition(it)
238-
}
239-
if (corners.fastAny { it.isUnspecified }) null else corners
274+
fun invalidatePositionInWindow() {
275+
updatePositionCacheAndDispatch()
240276
}
241277

242278
fun invalidatePositionOnScreen() {
243-
// Look at all corners, because platformContext.convertLocalToScreenPosition can also
244-
// rotate, skew etc.
245-
val cornersOnScreen = cornersOnScreen()
246-
if (cornersOnScreen != currentCornersOnScreen) {
247-
measureAndLayoutDelegate.dispatchOnPositionedCallbacks(forceDispatch = true)
248-
currentCornersOnScreen = cornersOnScreen
249-
}
279+
updatePositionCacheAndDispatch()
250280
}
251281

252282
fun draw(canvas: Canvas) = trace("RootNodeOwner:draw") {
253283
ownedLayerManager.draw(canvas)
254284
clearInvalidObservations()
255-
@OptIn(ExperimentalComposeUiApi::class)
256285
if (ComposeUiFlags.isRectTrackingEnabled) {
257286
owner.rectManager.dispatchCallbacks()
258287
}
@@ -269,14 +298,6 @@ internal class RootNodeOwner(
269298
}
270299
}
271300

272-
private fun onLightingInfoChanged() {
273-
graphicsContext.setLightingInfo(
274-
canvasOffset = platformContext.convertLocalToWindowPosition(Offset.Zero),
275-
density = density,
276-
containerSize = platformContext.windowInfo.containerSize
277-
)
278-
}
279-
280301
fun onCancelPointerInput() {
281302
pointerInputEventProcessor.processCancel()
282303
}
@@ -522,7 +543,6 @@ internal class RootNodeOwner(
522543
snapshotInvalidationTracker.requestDraw()
523544
}
524545
measureAndLayoutDelegate.dispatchOnPositionedCallbacks()
525-
@OptIn(ExperimentalComposeUiApi::class)
526546
if (ComposeUiFlags.isRectTrackingEnabled) {
527547
rectManager.dispatchCallbacks()
528548
}
@@ -540,7 +560,6 @@ internal class RootNodeOwner(
540560
if (!measureAndLayoutDelegate.hasPendingMeasureOrLayout) {
541561
measureAndLayoutDelegate.dispatchOnPositionedCallbacks()
542562
}
543-
@OptIn(ExperimentalComposeUiApi::class)
544563
if (ComposeUiFlags.isRectTrackingEnabled) {
545564
rectManager.dispatchCallbacks()
546565
}

compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,22 @@ interface PlatformContext {
6868
*/
6969
val isWindowTransparent: Boolean get() = false
7070

71+
/**
72+
* Indicates whether the transformation between local and window/screen coordinate spaces
73+
* includes components other than simple translation (such as rotation, scaling, or skewing).
74+
*
75+
* When this property returns `true`:
76+
* - Position calculations require full matrix transformations rather than simple offsets
77+
* - Certain optimizations for translation-only transformations cannot be applied
78+
*
79+
* @return `true` if the transformation includes rotation, scaling, skewing, or other
80+
* non-translation components; `false` if only translation is used.
81+
*
82+
* @see convertLocalToWindowPosition
83+
* @see convertWindowToLocalPosition
84+
*/
85+
val hasNonTranslationComponents: Boolean get() = false
86+
7187
/**
7288
* Converts [localPosition] relative to the [ComposeScene] into an [Offset] relative to
7389
* the containing window.
@@ -293,4 +309,3 @@ internal class DelegateRootForTestListener : PlatformContext.RootForTestListener
293309
}
294310
}
295311
}
296-
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.layout
18+
19+
import androidx.compose.foundation.layout.Box
20+
import androidx.compose.foundation.layout.fillMaxSize
21+
import androidx.compose.foundation.layout.size
22+
import androidx.compose.foundation.lazy.LazyColumn
23+
import androidx.compose.foundation.lazy.LazyListState
24+
import androidx.compose.foundation.lazy.items
25+
import androidx.compose.foundation.lazy.rememberLazyListState
26+
import androidx.compose.runtime.rememberCoroutineScope
27+
import androidx.compose.ui.Modifier
28+
import androidx.compose.ui.assertThat
29+
import androidx.compose.ui.geometry.Size
30+
import androidx.compose.ui.isEqualTo
31+
import androidx.compose.ui.test.ExperimentalTestApi
32+
import androidx.compose.ui.test.runSkikoComposeUiTest
33+
import androidx.compose.ui.unit.dp
34+
import kotlin.test.Test
35+
import kotlinx.coroutines.CoroutineScope
36+
import kotlinx.coroutines.launch
37+
38+
@OptIn(ExperimentalTestApi::class)
39+
class OnFirstVisibleTest {
40+
41+
@Test
42+
fun callOnFirstVisible() = runSkikoComposeUiTest(Size(100f, 200f)) {
43+
class ItemState(
44+
var value: Int = 0
45+
)
46+
val data = List(50) { ItemState(0) }
47+
lateinit var lazyListState: LazyListState
48+
lateinit var scope: CoroutineScope
49+
setContent {
50+
lazyListState = rememberLazyListState()
51+
scope = rememberCoroutineScope()
52+
LazyColumn(Modifier.fillMaxSize(), lazyListState) {
53+
items(items = data, key = { it }) {
54+
Box(Modifier.size(100.dp).onFirstVisible {
55+
it.value++
56+
})
57+
}
58+
}
59+
}
60+
61+
scope.launch {
62+
repeat(50) {
63+
lazyListState.animateScrollToItem(it)
64+
}
65+
}
66+
67+
waitForIdle()
68+
assertThat(data.map { it.value }).isEqualTo(List(50) { 1 })
69+
}
70+
}

0 commit comments

Comments
 (0)