Merge "Updated docs-public/build.gradle for the following artifacts:" into androidx-main
diff --git a/.github/workflows/validate_gradle_wrapper.yml b/.github/workflows/validate_gradle_wrapper.yml
new file mode 100644
index 0000000..2e9a731
--- /dev/null
+++ b/.github/workflows/validate_gradle_wrapper.yml
@@ -0,0 +1,16 @@
+name: "Validate Gradle Wrapper"
+
+on:
+ pull_request:
+ paths:
+ - 'gradlew'
+ - 'gradlew.bat'
+ - 'gradle/wrapper/'
+
+jobs:
+ validation:
+ name: "Validation"
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: gradle/wrapper-validation-action@v1
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt
index 434a88d..9350274 100644
--- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt
@@ -44,6 +44,7 @@
import androidx.compose.animation.demos.lookahead.LookaheadWithDisappearingMovableContentDemo
import androidx.compose.animation.demos.lookahead.LookaheadWithFlowRowDemo
import androidx.compose.animation.demos.lookahead.LookaheadWithIntrinsicsDemo
+import androidx.compose.animation.demos.lookahead.LookaheadWithLazyColumn
import androidx.compose.animation.demos.lookahead.LookaheadWithMovableContentDemo
import androidx.compose.animation.demos.lookahead.LookaheadWithScaffold
import androidx.compose.animation.demos.lookahead.LookaheadWithSubcompose
@@ -126,6 +127,7 @@
LookaheadWithBoxWithConstraints()
},
ComposableDemo("Lookahead With Subcompose") { LookaheadWithSubcompose() },
+ ComposableDemo("Lookahead With LazyColumn") { LookaheadWithLazyColumn() },
ComposableDemo("Lookahead With Flow Row") { LookaheadWithFlowRowDemo() },
ComposableDemo("Lookahead With Intrinsics") {
LookaheadWithIntrinsicsDemo()
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithAnimateItemPlacement.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithAnimateItemPlacement.kt
new file mode 100644
index 0000000..493556a
--- /dev/null
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithAnimateItemPlacement.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2023 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.animation.demos.lookahead
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.demos.layoutanimation.turquoiseColors
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.LookaheadScope
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
+@Preview
+@Composable
+fun LookaheadWithLAnimateItemPlacement() {
+ val visible by produceState(true) {
+ while (true) {
+ delay(2000)
+ value = !value
+ }
+ }
+ LookaheadScope {
+ LazyColumn(Modifier.padding(20.dp)) {
+ items(3, key = { it }) {
+ Column(
+ Modifier
+ .animateItemPlacement()
+ .clip(RoundedCornerShape(15.dp))
+ .background(turquoiseColors[it])
+
+ ) {
+ Box(
+ Modifier
+ .requiredHeight(ItemSize.dp)
+ .fillMaxWidth()
+ )
+ AnimatedVisibility(visible = visible) {
+ Box(
+ Modifier
+ .requiredHeight(ItemSize.dp)
+ .fillMaxWidth()
+ .background(Color.White)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Suppress("ConstPropertyName")
+private const val ItemSize = 100
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyColumn.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyColumn.kt
new file mode 100644
index 0000000..8fdbd52
--- /dev/null
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/lookahead/LookaheadWithLazyColumn.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2023 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.
+ */
+
+@file:OptIn(ExperimentalComposeUiApi::class)
+
+package androidx.compose.animation.demos.lookahead
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.demos.R
+import androidx.compose.animation.demos.gesture.pastelColors
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.LookaheadScope
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Preview
+@Composable
+fun LookaheadWithLazyColumn() {
+ LookaheadScope {
+ LazyColumn {
+ items(10, key = { it }) {
+ val index = it % 4
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ AnimatedVisibility(
+ remember { MutableTransitionState(false) }
+ .apply { targetState = true },
+ enter = slideInHorizontally { 20 } + fadeIn()
+ ) {
+ Surface(shape = RoundedCornerShape(10.dp),
+ color = pastelColors[index],
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ expanded = !expanded
+ }) {
+ LookaheadScope {
+ val title = remember {
+ movableContentOf {
+ Text(
+ names[index],
+ Modifier
+ .padding(20.dp)
+ .animateBounds(Modifier)
+ )
+ }
+ }
+ val image = remember {
+ if (index < 3) {
+ movableContentOf {
+ Image(
+ painter = painterResource(res[index]),
+ contentDescription = null,
+ modifier = Modifier
+ .padding(10.dp)
+ .animateBounds(
+ if (expanded)
+ Modifier.fillMaxWidth()
+ else
+ Modifier.size(80.dp),
+ spring(stiffness = Spring.StiffnessLow)
+ )
+ .clip(RoundedCornerShape(5.dp)),
+ contentScale = if (expanded) {
+ ContentScale.FillWidth
+ } else {
+ ContentScale.Crop
+ }
+ )
+ }
+ } else {
+ movableContentOf {
+ Box(
+ modifier = Modifier
+ .padding(10.dp)
+ .animateBounds(
+ if (expanded) Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ else Modifier.size(80.dp),
+ spring(stiffness = Spring.StiffnessLow)
+ )
+ .background(
+ Color.LightGray, RoundedCornerShape(5.dp)
+ ),
+ )
+ }
+ }
+ }
+ if (expanded) {
+ Column {
+ title()
+ image()
+ }
+ } else {
+ Row {
+ image()
+ title()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+val names = listOf("YT", "Pepper", "Waffle", "Who?")
+val res = listOf(
+ R.drawable.yt_profile,
+ R.drawable.pepper,
+ R.drawable.waffle,
+)
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/res/drawable/pepper.jpg b/compose/animation/animation/integration-tests/animation-demos/src/main/res/drawable/pepper.jpg
new file mode 100644
index 0000000..1aa03c5
--- /dev/null
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/res/drawable/pepper.jpg
Binary files differ
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/res/drawable/waffle.jpeg b/compose/animation/animation/integration-tests/animation-demos/src/main/res/drawable/waffle.jpeg
new file mode 100644
index 0000000..194b4e5
--- /dev/null
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/res/drawable/waffle.jpeg
Binary files differ
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/EnterExitTransition.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/EnterExitTransition.kt
index 035d8aa..e0d2b40 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/EnterExitTransition.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/EnterExitTransition.kt
@@ -40,9 +40,9 @@
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.GraphicsLayerScope
import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
@@ -864,7 +864,8 @@
val graphicsLayerBlock = createGraphicsLayerBlock(enter, exit, label)
- return (if (disableClip) Modifier else Modifier.clipToBounds())
+ return Modifier
+ .graphicsLayer(clip = !disableClip)
.then(
EnterExitTransitionElement(
this, sizeAnimation, offsetAnimation, slideAnimation,
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt
index 2763637..416fc19 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListAnimateItemPlacementTest.kt
@@ -49,7 +49,9 @@
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsProperties
@@ -86,6 +88,7 @@
private val isVertical: Boolean get() = config.isVertical
private val reverseLayout: Boolean get() = config.reverseLayout
+ private val isInLookaheadScope: Boolean get() = config.isInLookaheadScope
@get:Rule
val rule = createComposeRule()
@@ -1842,6 +1845,7 @@
}
}
+ @OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun LazyList(
arrangement: Arrangement.HorizontalOrVertical? = null,
@@ -1854,63 +1858,70 @@
endPadding: Dp = 0.dp,
content: LazyListScope.() -> Unit
) {
- state = rememberLazyListState(startIndex)
- if (isVertical) {
- val verticalArrangement =
- arrangement ?: if (!reverseLayout) Arrangement.Top else Arrangement.Bottom
- val horizontalAlignment = if (crossAxisAlignment == CrossAxisAlignment.Start) {
- Alignment.Start
- } else if (crossAxisAlignment == CrossAxisAlignment.Center) {
- Alignment.CenterHorizontally
- } else {
- Alignment.End
- }
- LazyColumn(
- state = state,
- modifier = Modifier
- .requiredHeightIn(min = minSize, max = maxSize)
- .then(
- if (crossAxisSize != Dp.Unspecified) {
- Modifier.requiredWidth(crossAxisSize)
- } else {
- Modifier.fillMaxWidth()
- }
- )
- .testTag(ContainerTag),
- verticalArrangement = verticalArrangement,
- horizontalAlignment = horizontalAlignment,
- reverseLayout = reverseLayout,
- contentPadding = PaddingValues(top = startPadding, bottom = endPadding),
- content = content
- )
+ val container: @Composable (@Composable () -> Unit) -> Unit = if (isInLookaheadScope) {
+ { LookaheadScope { it() } }
} else {
- val horizontalArrangement =
- arrangement ?: if (!reverseLayout) Arrangement.Start else Arrangement.End
- val verticalAlignment = if (crossAxisAlignment == CrossAxisAlignment.Start) {
- Alignment.Top
- } else if (crossAxisAlignment == CrossAxisAlignment.Center) {
- Alignment.CenterVertically
+ { it() }
+ }
+ container {
+ state = rememberLazyListState(startIndex)
+ if (isVertical) {
+ val verticalArrangement =
+ arrangement ?: if (!reverseLayout) Arrangement.Top else Arrangement.Bottom
+ val horizontalAlignment = if (crossAxisAlignment == CrossAxisAlignment.Start) {
+ Alignment.Start
+ } else if (crossAxisAlignment == CrossAxisAlignment.Center) {
+ Alignment.CenterHorizontally
+ } else {
+ Alignment.End
+ }
+ LazyColumn(
+ state = state,
+ modifier = Modifier
+ .requiredHeightIn(min = minSize, max = maxSize)
+ .then(
+ if (crossAxisSize != Dp.Unspecified) {
+ Modifier.requiredWidth(crossAxisSize)
+ } else {
+ Modifier.fillMaxWidth()
+ }
+ )
+ .testTag(ContainerTag),
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment,
+ reverseLayout = reverseLayout,
+ contentPadding = PaddingValues(top = startPadding, bottom = endPadding),
+ content = content
+ )
} else {
- Alignment.Bottom
+ val horizontalArrangement =
+ arrangement ?: if (!reverseLayout) Arrangement.Start else Arrangement.End
+ val verticalAlignment = if (crossAxisAlignment == CrossAxisAlignment.Start) {
+ Alignment.Top
+ } else if (crossAxisAlignment == CrossAxisAlignment.Center) {
+ Alignment.CenterVertically
+ } else {
+ Alignment.Bottom
+ }
+ LazyRow(
+ state = state,
+ modifier = Modifier
+ .requiredWidthIn(min = minSize, max = maxSize)
+ .then(
+ if (crossAxisSize != Dp.Unspecified) {
+ Modifier.requiredHeight(crossAxisSize)
+ } else {
+ Modifier.fillMaxHeight()
+ }
+ )
+ .testTag(ContainerTag),
+ horizontalArrangement = horizontalArrangement,
+ verticalAlignment = verticalAlignment,
+ reverseLayout = reverseLayout,
+ contentPadding = PaddingValues(start = startPadding, end = endPadding),
+ content = content
+ )
}
- LazyRow(
- state = state,
- modifier = Modifier
- .requiredWidthIn(min = minSize, max = maxSize)
- .then(
- if (crossAxisSize != Dp.Unspecified) {
- Modifier.requiredHeight(crossAxisSize)
- } else {
- Modifier.fillMaxHeight()
- }
- )
- .testTag(ContainerTag),
- horizontalArrangement = horizontalArrangement,
- verticalAlignment = verticalAlignment,
- reverseLayout = reverseLayout,
- contentPadding = PaddingValues(start = startPadding, end = endPadding),
- content = content
- )
}
}
@@ -1952,19 +1963,22 @@
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun params() = arrayOf(
- Config(isVertical = true, reverseLayout = false),
- Config(isVertical = false, reverseLayout = false),
- Config(isVertical = true, reverseLayout = true),
- Config(isVertical = false, reverseLayout = true),
+ Config(isVertical = true, reverseLayout = false, isInLookaheadScope = false),
+ Config(isVertical = false, reverseLayout = false, isInLookaheadScope = false),
+ Config(isVertical = true, reverseLayout = true, isInLookaheadScope = false),
+ Config(isVertical = false, reverseLayout = true, isInLookaheadScope = false),
+ Config(isVertical = true, reverseLayout = false, isInLookaheadScope = true)
)
class Config(
val isVertical: Boolean,
- val reverseLayout: Boolean
+ val reverseLayout: Boolean,
+ val isInLookaheadScope: Boolean
) {
override fun toString() =
(if (isVertical) "LazyColumn" else "LazyRow") +
- (if (reverseLayout) "(reverse)" else "")
+ (if (reverseLayout) "(reverse)" else "") +
+ (if (isInLookaheadScope) "(in LookaheadScope)" else "")
}
}
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
index 2b0b778..19191a5 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt
@@ -17,7 +17,12 @@
package androidx.compose.foundation.lazy.list
import android.os.Build
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.AutoTestFrameClock
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.VelocityTrackerCalculationThreshold
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
@@ -25,6 +30,7 @@
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -44,6 +50,7 @@
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.matchers.isZero
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
@@ -54,6 +61,7 @@
import androidx.compose.testutils.WithTouchSlop
import androidx.compose.testutils.assertPixels
import androidx.compose.testutils.assertShape
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
@@ -67,7 +75,11 @@
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.LookaheadScope
+import androidx.compose.ui.layout.findRootCoordinates
import androidx.compose.ui.layout.layout
+import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
@@ -106,6 +118,7 @@
import java.util.concurrent.CountDownLatch
import kotlin.math.abs
import kotlin.math.roundToInt
+import kotlin.test.assertEquals
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -2171,6 +2184,640 @@
}
@Test
+ fun testLookaheadPositionWithOnlyInBoundChanges() {
+ testLookaheadPositionWithPlacementAnimator(
+ initialList = listOf(0, 1, 2, 3),
+ targetList = listOf(3, 2, 1, 0),
+ initialExpectedLookaheadPositions = listOf(0, 100, 200, 300),
+ targetExpectedLookaheadPositions = listOf(300, 200, 100, 0)
+ )
+ }
+
+ @Test
+ fun testLookaheadPositionWithCustomStartingIndex() {
+ testLookaheadPositionWithPlacementAnimator(
+ initialList = listOf(0, 1, 2, 3, 4),
+ targetList = listOf(4, 3, 2, 1, 0),
+ initialExpectedLookaheadPositions = listOf(null, 0, 100, 200, 300),
+ targetExpectedLookaheadPositions = listOf(300, 200, 100, 0, -100),
+ startingIndex = 1
+ )
+ }
+
+ @Test
+ fun testLookaheadPositionWithTwoInBoundTwoOutBound() {
+ testLookaheadPositionWithPlacementAnimator(
+ initialList = listOf(0, 1, 2, 3, 4, 5),
+ targetList = listOf(5, 4, 2, 1, 3, 0),
+ initialExpectedLookaheadPositions = listOf(null, null, 0, 100, 200, 300),
+ targetExpectedLookaheadPositions = listOf(300, 100, 0, 200, -100, -200),
+ startingIndex = 2
+ )
+ }
+
+ private fun testLookaheadPositionWithPlacementAnimator(
+ initialList: List<Int>,
+ targetList: List<Int>,
+ initialExpectedLookaheadPositions: List<Int?>,
+ targetExpectedLookaheadPositions: List<Int?>,
+ startingIndex: Int = 0
+ ) {
+ var list by mutableStateOf(initialList)
+ val lookaheadPosition = mutableMapOf<Int, Int>()
+ val postLookaheadPosition = mutableMapOf<Int, Int>()
+ rule.mainClock.autoAdvance = false
+ rule.setContent {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ LazyListInLookaheadScope(
+ list = list,
+ startingIndex = startingIndex,
+ lookaheadPosition = lookaheadPosition,
+ postLookaheadPosition = postLookaheadPosition
+ )
+ }
+ }
+ rule.runOnIdle {
+ repeat(list.size) {
+ assertEquals(initialExpectedLookaheadPositions[it], lookaheadPosition[it])
+ assertEquals(initialExpectedLookaheadPositions[it], postLookaheadPosition[it])
+ }
+ lookaheadPosition.clear()
+ postLookaheadPosition.clear()
+ list = targetList
+ }
+ rule.waitForIdle()
+ repeat(20) {
+ rule.mainClock.advanceTimeByFrame()
+ repeat(list.size) {
+ assertEquals(targetExpectedLookaheadPositions[it], lookaheadPosition[it])
+ }
+ }
+ repeat(list.size) {
+ if (lookaheadPosition[it]?.let { offset -> offset + ItemSize >= 0 } != false) {
+ assertEquals(lookaheadPosition[it], postLookaheadPosition[it])
+ }
+ }
+ }
+
+ @OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
+ @Composable
+ private fun LazyListInLookaheadScope(
+ list: List<Int>,
+ startingIndex: Int,
+ lookaheadPosition: MutableMap<Int, Int>,
+ postLookaheadPosition: MutableMap<Int, Int>
+ ) {
+ LookaheadScope {
+ LazyColumnOrRow(
+ if (vertical) {
+ Modifier.requiredHeight(ItemSize.dp * (list.size - startingIndex))
+ } else {
+ Modifier.requiredWidth(ItemSize.dp * (list.size - startingIndex))
+ },
+ state = rememberLazyListState(
+ initialFirstVisibleItemIndex = startingIndex
+ ),
+
+ ) {
+ items(list, key = { it }) { item ->
+ Box(
+ Modifier
+ .animateItemPlacement(tween(160))
+ .trackPositions(
+ lookaheadPosition,
+ postLookaheadPosition,
+ this@LookaheadScope,
+ item
+ )
+ .requiredSize(ItemSize.dp)
+ )
+ }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ private fun Modifier.trackPositions(
+ lookaheadPosition: MutableMap<Int, Int>,
+ postLookaheadPosition: MutableMap<Int, Int>,
+ lookaheadScope: LookaheadScope,
+ item: Int
+ ): Modifier = this.layout { measurable, constraints ->
+ measurable
+ .measure(constraints)
+ .run {
+ layout(width, height) {
+ if (isLookingAhead) {
+ lookaheadPosition[item] =
+ with(lookaheadScope) {
+ coordinates!!
+ .findRootCoordinates()
+ .localLookaheadPositionOf(
+ coordinates!!
+ )
+ .let {
+ if (vertical) {
+ it.y
+ } else {
+ it.x
+ }.roundToInt()
+ }
+ }
+ } else {
+ postLookaheadPosition[item] =
+ coordinates!!
+ .positionInRoot()
+ .let {
+ if (vertical) it.y else it.x
+ }
+ .roundToInt()
+ }
+ place(0, 0)
+ }
+ }
+ }
+
+ @OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
+ @Test
+ fun animContentSizeWithPlacementAnimator() {
+ val lookaheadPosition = mutableMapOf<Int, Int>()
+ val postLookaheadPosition = mutableMapOf<Int, Int>()
+ var large by mutableStateOf(false)
+ var animateSizeChange by mutableStateOf(false)
+ rule.setContent {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ LookaheadScope {
+ LazyColumnOrRow {
+ items(4, key = { it }) {
+ Box(
+ Modifier
+ .animateItemPlacement(tween(160, easing = LinearEasing))
+ .trackPositions(
+ lookaheadPosition,
+ postLookaheadPosition,
+ this@LookaheadScope,
+ it
+ )
+ .then(
+ if (animateSizeChange) Modifier.animateContentSize(
+ tween(160)
+ ) else Modifier
+ )
+ .requiredSize(if (large) ItemSize.dp * 2 else ItemSize.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+ rule.waitForIdle()
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPosition[it])
+ assertEquals(it * ItemSize, postLookaheadPosition[it])
+ }
+
+ rule.mainClock.autoAdvance = false
+ large = true
+ rule.waitForIdle()
+ rule.mainClock.advanceTimeByFrame()
+ rule.mainClock.advanceTimeByFrame()
+
+ repeat(20) { frame ->
+ val fraction = (frame * 16 / 160f).coerceAtMost(1f)
+ repeat(4) {
+ assertEquals(it * ItemSize * 2, lookaheadPosition[it])
+ assertEquals(
+ (it * ItemSize * (1 + fraction)).roundToInt(),
+ postLookaheadPosition[it]
+ )
+ }
+ rule.mainClock.advanceTimeByFrame()
+ }
+
+ // Enable animateContentSize
+ animateSizeChange = true
+ large = false
+ rule.waitForIdle()
+ rule.mainClock.advanceTimeByFrame()
+ rule.mainClock.advanceTimeByFrame()
+
+ repeat(20) { frame ->
+ val fraction = (frame * 16 / 160f).coerceAtMost(1f)
+ repeat(4) {
+ // Verify that item target offsets are not affected by animateContentSize
+ assertEquals(it * ItemSize, lookaheadPosition[it])
+ assertEquals(
+ (it * (2 - fraction) * ItemSize).roundToInt(),
+ postLookaheadPosition[it]
+ )
+ }
+ rule.mainClock.advanceTimeByFrame()
+ }
+ }
+
+ @OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
+ @Test
+ fun animVisibilityWithPlacementAnimator() {
+ val lookaheadPosition = mutableMapOf<Int, Int>()
+ val postLookaheadPosition = mutableMapOf<Int, Int>()
+ var visible by mutableStateOf(false)
+ rule.setContent {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ LookaheadScope {
+ LazyColumnOrRow {
+ items(4, key = { it }) {
+ if (vertical) {
+ Column(
+ Modifier
+ .animateItemPlacement(tween(160, easing = LinearEasing))
+ .trackPositions(
+ lookaheadPosition,
+ postLookaheadPosition,
+ this@LookaheadScope,
+ it
+ )
+ ) {
+ Box(Modifier.requiredSize(ItemSize.dp))
+ AnimatedVisibility(visible = visible) {
+ Box(Modifier.requiredSize(ItemSize.dp))
+ }
+ }
+ } else {
+ Row(
+ Modifier
+ .animateItemPlacement(tween(160, easing = LinearEasing))
+ .trackPositions(
+ lookaheadPosition,
+ postLookaheadPosition,
+ this@LookaheadScope,
+ it
+ )
+ ) {
+ Box(Modifier.requiredSize(ItemSize.dp))
+ AnimatedVisibility(visible = visible) {
+ Box(Modifier.requiredSize(ItemSize.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ rule.waitForIdle()
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPosition[it])
+ assertEquals(it * ItemSize, postLookaheadPosition[it])
+ }
+
+ rule.mainClock.autoAdvance = false
+ visible = true
+ rule.waitForIdle()
+ rule.mainClock.advanceTimeByFrame()
+ rule.mainClock.advanceTimeByFrame()
+
+ repeat(20) { frame ->
+ val fraction = (frame * 16 / 160f).coerceAtMost(1f)
+ repeat(4) {
+ assertEquals(it * ItemSize * 2, lookaheadPosition[it])
+ assertEquals(
+ (it * ItemSize * (1 + fraction)).roundToInt(),
+ postLookaheadPosition[it]
+ )
+ }
+ rule.mainClock.advanceTimeByFrame()
+ }
+ }
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ @Test
+ fun resizeLazyList() {
+ val lookaheadPositions = mutableMapOf<Int, Offset>()
+ val postLookaheadPositions = mutableMapOf<Int, Offset>()
+ var postLookaheadSize by mutableStateOf(ItemSize * 2)
+ rule.setContent {
+ LookaheadScope {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ LazyColumnOrRow(Modifier.layout { measurable, _ ->
+ val constraints = if (isLookingAhead) {
+ Constraints.fixed(4 * ItemSize, 4 * ItemSize)
+ } else {
+ Constraints.fixed(postLookaheadSize, postLookaheadSize)
+ }
+ measurable.measure(constraints).run {
+ layout(width, height) {
+ place(0, 0)
+ }
+ }
+ }) {
+ items(4) {
+ Box(
+ Modifier
+ .requiredSize(ItemSize.dp)
+ .layout { measurable, constraints ->
+ measurable
+ .measure(constraints)
+ .run {
+ layout(width, height) {
+ if (isLookingAhead) {
+ lookaheadPositions[it] = coordinates!!
+ .findRootCoordinates()
+ .localLookaheadPositionOf(coordinates!!)
+ } else {
+ postLookaheadPositions[it] =
+ coordinates!!.positionInRoot()
+ }
+ }
+ }
+ })
+ }
+ }
+ }
+ }
+ }
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+ postLookaheadSize = (2.9f * ItemSize).toInt()
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(ItemSize * 2, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+ postLookaheadSize = (3.4f * ItemSize).toInt()
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(ItemSize * 2, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(ItemSize * 3, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+
+ // Shrinking post-lookahead size
+ postLookaheadSize = (2.7f * ItemSize).toInt()
+ postLookaheadPositions.clear()
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(ItemSize * 2, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+
+ // Shrinking post-lookahead size
+ postLookaheadSize = (1.2f * ItemSize).toInt()
+ postLookaheadPositions.clear()
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+ }
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ @Test
+ fun lookaheadSizeSmallerThanPostLookahead() {
+ val lookaheadPositions = mutableMapOf<Int, Offset>()
+ val postLookaheadPositions = mutableMapOf<Int, Offset>()
+ var lookaheadSize by mutableStateOf(ItemSize * 2)
+ var postLookaheadSize by mutableStateOf(ItemSize * 4)
+ rule.setContent {
+ LookaheadScope {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ LazyColumnOrRow(Modifier.layout { measurable, _ ->
+ val constraints = if (isLookingAhead) {
+ Constraints.fixed(lookaheadSize, lookaheadSize)
+ } else {
+ Constraints.fixed(postLookaheadSize, postLookaheadSize)
+ }
+ measurable.measure(constraints).run {
+ layout(width, height) {
+ place(0, 0)
+ }
+ }
+ }) {
+ items(4) {
+ Box(
+ Modifier
+ .requiredSize(ItemSize.dp)
+ .layout { measurable, constraints ->
+ measurable
+ .measure(constraints)
+ .run {
+ layout(width, height) {
+ if (isLookingAhead) {
+ lookaheadPositions[it] = coordinates!!
+ .findRootCoordinates()
+ .localLookaheadPositionOf(coordinates!!)
+ } else {
+ postLookaheadPositions[it] =
+ coordinates!!.positionInRoot()
+ }
+ }
+ }
+ })
+ }
+ }
+ }
+ }
+ }
+ // postLookaheadSize was initialized to 4 * ItemSize
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(ItemSize * 2, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(ItemSize * 3, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+ postLookaheadSize = (2.9f * ItemSize).toInt()
+ postLookaheadPositions.clear()
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(ItemSize * 2, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+ postLookaheadSize = 2 * ItemSize
+ postLookaheadPositions.clear()
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+
+ // Growing post-lookahead size
+ postLookaheadSize = (2.7f * ItemSize).toInt()
+ postLookaheadPositions.clear()
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(ItemSize * 2, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+
+ // Shrinking post-lookahead size
+ postLookaheadSize = (1.2f * ItemSize).toInt()
+ postLookaheadPositions.clear()
+ rule.runOnIdle {
+ repeat(4) {
+ assertEquals(it * ItemSize, lookaheadPositions[it]?.mainAxisPosition)
+ }
+ assertEquals(0, postLookaheadPositions[0]?.mainAxisPosition)
+ assertEquals(ItemSize, postLookaheadPositions[1]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[2]?.mainAxisPosition)
+ assertEquals(null, postLookaheadPositions[3]?.mainAxisPosition)
+ }
+ }
+ private val Offset.mainAxisPosition get() = (if (vertical) y else x).roundToInt()
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ @Test
+ fun postLookaheadItemsComposed() {
+ lateinit var state: LazyListState
+ rule.setContent {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ LookaheadScope {
+ state = rememberLazyListState()
+ LazyColumnOrRow(Modifier.requiredSize(300.dp), state) {
+ items(12, key = { it }) {
+ Box(
+ Modifier
+ .testTag("$it")
+ .then(
+ if (it == 0) {
+ Modifier.layout { measurable, constraints ->
+ val p = measurable.measure(constraints)
+ val size = if (isLookingAhead) 300 else 30
+ layout(size, size) {
+ p.place(0, 0)
+ }
+ }
+ } else
+ Modifier.size(30.dp)
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ rule.waitForIdle()
+
+ // Based on lookahead item 0 would be the only item needed, but post-lookahead calculation
+ // indicates 10 items will be needed to fill the viewport.
+ for (i in 0 until 10) {
+ rule.onNodeWithTag("$i")
+ .assertIsPlaced()
+ }
+ for (i in 10 until 12) {
+ rule.onNodeWithTag("$i")
+ .assertDoesNotExist()
+ }
+ }
+
+ @OptIn(ExperimentalComposeUiApi::class)
+ @Test
+ fun postLookaheadItemsComposedBasedOnScrollDelta() {
+ var lookaheadSize by mutableStateOf(30)
+ var postLookaheadSize by mutableStateOf(lookaheadSize)
+ lateinit var state: LazyListState
+ rule.setContent {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ LookaheadScope {
+ state = rememberLazyListState()
+ LazyColumnOrRow(Modifier.requiredSize(300.dp), state) {
+ items(12, key = { it }) {
+ Box(
+ Modifier
+ .testTag("$it")
+ .then(
+ if (it == 2) {
+ Modifier.layout { measurable, constraints ->
+ val p = measurable.measure(constraints)
+ val size = if (isLookingAhead)
+ lookaheadSize
+ else
+ postLookaheadSize
+ layout(size, size) {
+ p.place(0, 0)
+ }
+ }
+ } else
+ Modifier.size(30.dp)
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+ rule.waitForIdle()
+
+ for (i in 0 until 12) {
+ if (i < 10) {
+ rule.onNodeWithTag("$i")
+ .assertIsPlaced()
+ } else {
+ rule.onNodeWithTag("$i")
+ .assertDoesNotExist()
+ }
+ }
+
+ lookaheadSize = 300
+ rule.runOnIdle {
+ runBlocking {
+ state.scrollBy(60f)
+ }
+ }
+ rule.waitForIdle()
+
+ rule.onNodeWithTag("0").assertIsNotPlaced()
+ rule.onNodeWithTag("1").assertIsNotPlaced()
+ for (i in 2 until 12) {
+ rule.onNodeWithTag("$i").assertIsPlaced()
+ }
+
+ postLookaheadSize = 300
+ for (i in 0 until 12) {
+ if (i == 2) {
+ rule.onNodeWithTag("$i").assertIsPlaced()
+ } else {
+ rule.onNodeWithTag("$i").assertIsNotPlaced()
+ }
+ }
+ }
+
+ @Test
fun usingFillParentMaxSizeOnInfinityConstraintsIsIgnored() {
rule.setContentWithTestViewConfiguration {
Layout(content = {
@@ -2311,4 +2958,6 @@
endVelocity = 0f
)
}
- }
\ No newline at end of file
+ }
+
+private const val ItemSize = 100
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
index 6f41836..99f6a33 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyList.kt
@@ -34,6 +34,7 @@
import androidx.compose.foundation.overscroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -79,6 +80,8 @@
val itemProviderLambda = rememberLazyListItemProviderLambda(state, content)
val semanticState = rememberLazyListSemanticState(state, isVertical)
+ val scope = rememberCoroutineScope()
+ state.coroutineScope = scope
val measurePolicy = rememberLazyListMeasurePolicy(
itemProviderLambda,
@@ -182,6 +185,8 @@
verticalArrangement
) {
{ containerConstraints ->
+ // Tracks if the lookahead pass has occurred
+ val hasLookaheadPassOccurred = state.hasLookaheadPassOccurred || isLookingAhead
checkScrollableContainerConstraints(
containerConstraints,
if (isVertical) Orientation.Vertical else Orientation.Horizontal
@@ -307,6 +312,12 @@
beyondBoundsInfo = state.beyondBoundsInfo
)
+ val scrollToBeConsumed = if (isLookingAhead || !hasLookaheadPassOccurred) {
+ state.scrollToBeConsumed
+ } else {
+ state.scrollDeltaBetweenPasses
+ }
+
measureLazyList(
itemsCount = itemsCount,
measuredItemProvider = measuredItemProvider,
@@ -316,7 +327,7 @@
spaceBetweenItems = spaceBetweenItems,
firstVisibleItemIndex = firstVisibleItemIndex,
firstVisibleItemScrollOffset = firstVisibleScrollOffset,
- scrollToBeConsumed = state.scrollToBeConsumed,
+ scrollToBeConsumed = scrollToBeConsumed,
constraints = contentConstraints,
isVertical = isVertical,
headerIndexes = itemProvider.headerIndexes,
@@ -327,6 +338,9 @@
placementAnimator = state.placementAnimator,
beyondBoundsItemCount = beyondBoundsItemCount,
pinnedItems = pinnedItems,
+ hasLookaheadPassOccurred = hasLookaheadPassOccurred,
+ isLookingAhead = isLookingAhead,
+ postLookaheadLayoutInfo = state.postLookaheadLayoutInfo,
layout = { width, height, placement ->
layout(
containerConstraints.constrainWidth(width + totalHorizontalPadding),
@@ -336,7 +350,7 @@
)
}
).also {
- state.applyMeasureResult(it)
+ state.applyMeasureResult(it, isLookingAhead)
}
}
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt
index ec0fb82..7ad2893 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListItemPlacementAnimator.kt
@@ -56,7 +56,9 @@
layoutHeight: Int,
positionedItems: MutableList<LazyListMeasuredItem>,
itemProvider: LazyListMeasuredItemProvider,
- isVertical: Boolean
+ isVertical: Boolean,
+ isLookingAhead: Boolean,
+ hasLookaheadOccurred: Boolean
) {
if (!positionedItems.fastAny { it.hasAnimations } && activeKeys.isEmpty()) {
// no animations specified - no work needed
@@ -66,6 +68,7 @@
val previousFirstVisibleIndex = firstVisibleIndex
firstVisibleIndex = positionedItems.firstOrNull()?.index ?: 0
+
val previousKeyToIndexMap = keyIndexMap
keyIndexMap = itemProvider.keyIndexMap
@@ -78,6 +81,9 @@
IntOffset(consumedScroll, 0)
}
+ // Only setup animations when we have access to target value in the current pass, which
+ // means lookahead pass, or regular pass when not in a lookahead scope.
+ val shouldSetupAnimation = isLookingAhead || !hasLookaheadOccurred
// first add all items we had in the previous run
movingAwayKeys.addAll(activeKeys)
// iterate through the items which are visible (without animated offsets)
@@ -102,12 +108,15 @@
)
}
} else {
- item.forEachNode { _, node ->
- if (node.rawOffset != LazyLayoutAnimateItemModifierNode.NotInitialized) {
- node.rawOffset += scrollOffset
+ if (shouldSetupAnimation) {
+ item.forEachNode { _, node ->
+ if (node.rawOffset != LazyLayoutAnimateItemModifierNode.NotInitialized
+ ) {
+ node.rawOffset += scrollOffset
+ }
}
+ startAnimationsIfNeeded(item)
}
- startAnimationsIfNeeded(item)
}
} else {
// no animation, clean up if needed
@@ -116,20 +125,22 @@
}
var accumulatedOffset = 0
- movingInFromStartBound.sortByDescending { previousKeyToIndexMap.getIndex(it.key) }
- movingInFromStartBound.fastForEach { item ->
- accumulatedOffset += item.size
- val mainAxisOffset = 0 - accumulatedOffset
- initializeNode(item, mainAxisOffset)
- startAnimationsIfNeeded(item)
- }
- accumulatedOffset = 0
- movingInFromEndBound.sortBy { previousKeyToIndexMap.getIndex(it.key) }
- movingInFromEndBound.fastForEach { item ->
- val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset
- accumulatedOffset += item.size
- initializeNode(item, mainAxisOffset)
- startAnimationsIfNeeded(item)
+ if (shouldSetupAnimation) {
+ movingInFromStartBound.sortByDescending { previousKeyToIndexMap.getIndex(it.key) }
+ movingInFromStartBound.fastForEach { item ->
+ accumulatedOffset += item.size
+ val mainAxisOffset = 0 - accumulatedOffset
+ initializeNode(item, mainAxisOffset)
+ startAnimationsIfNeeded(item)
+ }
+ accumulatedOffset = 0
+ movingInFromEndBound.sortBy { previousKeyToIndexMap.getIndex(it.key) }
+ movingInFromEndBound.fastForEach { item ->
+ val mainAxisOffset = mainAxisLayoutSize + accumulatedOffset
+ accumulatedOffset += item.size
+ initializeNode(item, mainAxisOffset)
+ startAnimationsIfNeeded(item)
+ }
}
movingAwayKeys.forEach { key ->
@@ -168,9 +179,11 @@
val mainAxisOffset = 0 - accumulatedOffset
item.position(mainAxisOffset, layoutWidth, layoutHeight)
- positionedItems.add(item)
- startAnimationsIfNeeded(item)
+ if (shouldSetupAnimation) {
+ startAnimationsIfNeeded(item)
+ }
}
+
accumulatedOffset = 0
movingAwayToEndBound.sortBy { keyIndexMap.getIndex(it.key) }
movingAwayToEndBound.fastForEach { item ->
@@ -178,10 +191,16 @@
accumulatedOffset += item.size
item.position(mainAxisOffset, layoutWidth, layoutHeight)
- positionedItems.add(item)
- startAnimationsIfNeeded(item)
+ if (shouldSetupAnimation) {
+ startAnimationsIfNeeded(item)
+ }
}
+ // This adds the new items to the list of positioned items while keeping the index of
+ // the positioned items sorted in ascending order.
+ positionedItems.addAll(0, movingAwayToStartBound.apply { reverse() })
+ positionedItems.addAll(movingAwayToEndBound)
+
movingInFromStartBound.clear()
movingInFromEndBound.clear()
movingAwayToStartBound.clear()
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
index a352409..3a7dce6 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasure.kt
@@ -27,6 +27,8 @@
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.util.fastAny
+import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.fastForEach
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -57,6 +59,9 @@
placementAnimator: LazyListItemPlacementAnimator,
beyondBoundsItemCount: Int,
pinnedItems: List<Int>,
+ hasLookaheadPassOccurred: Boolean,
+ isLookingAhead: Boolean,
+ postLookaheadLayoutInfo: LazyListLayoutInfo?,
@Suppress("PrimitiveInLambda")
layout: (Int, Int, Placeable.PlacementScope.() -> Unit) -> MeasureResult
): LazyListMeasureResult {
@@ -70,6 +75,7 @@
canScrollForward = false,
consumedScroll = 0f,
measureResult = layout(constraints.minWidth, constraints.minHeight) {},
+ scrollBackAmount = 0f,
visibleItemsInfo = emptyList(),
viewportStartOffset = -beforeContentPadding,
viewportEndOffset = mainAxisAvailableSize + afterContentPadding,
@@ -77,7 +83,7 @@
reverseLayout = reverseLayout,
orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal,
afterContentPadding = afterContentPadding,
- mainAxisItemSpacing = spaceBetweenItems
+ mainAxisItemSpacing = spaceBetweenItems,
)
} else {
var currentFirstItemIndex = firstVisibleItemIndex
@@ -172,6 +178,7 @@
index++
}
+ val preScrollBackScrollDelta = scrollDelta
// we didn't fill the whole viewport with items starting from firstVisibleItemIndex.
// lets try to scroll back if we have enough items before firstVisibleItemIndex.
if (currentMainAxisOffset < maxOffset) {
@@ -208,6 +215,16 @@
scrollToBeConsumed
}
+ val unconsumedScroll = scrollToBeConsumed - consumedScroll
+ // When scrolling to the bottom via gesture, there could be scrollback due to
+ // not being able to consume the whole scroll. In that case, the amount of
+ // scrollBack is the inverse of unconsumed scroll.
+ val scrollBackAmount: Float =
+ if (isLookingAhead && scrollDelta > preScrollBackScrollDelta && unconsumedScroll <= 0) {
+ scrollDelta - preScrollBackScrollDelta + unconsumedScroll
+ } else
+ 0f
+
// the initial offset for items from visibleItems list
require(currentFirstItemScrollOffset >= 0) { "negative currentFirstItemScrollOffset" }
val visibleItemsScrollOffset = -currentFirstItemScrollOffset
@@ -248,7 +265,10 @@
measuredItemProvider = measuredItemProvider,
itemsCount = itemsCount,
beyondBoundsItemCount = beyondBoundsItemCount,
- pinnedItems = pinnedItems
+ pinnedItems = pinnedItems,
+ consumedScroll = consumedScroll,
+ isLookingAhead = isLookingAhead,
+ lastPostLookaheadLayoutInfo = postLookaheadLayoutInfo
)
// Update maxCrossAxis with extra items
@@ -287,7 +307,9 @@
layoutHeight = layoutHeight,
positionedItems = positionedItems,
itemProvider = measuredItemProvider,
- isVertical = isVertical
+ isVertical = isVertical,
+ isLookingAhead = isLookingAhead,
+ hasLookaheadOccurred = hasLookaheadPassOccurred
)
val headerItem = if (headerIndexes.isNotEmpty()) {
@@ -311,18 +333,19 @@
measureResult = layout(layoutWidth, layoutHeight) {
positionedItems.fastForEach {
if (it !== headerItem) {
- it.place(this)
+ it.place(this, isLookingAhead)
}
}
// the header item should be placed (drawn) after all other items
- headerItem?.place(this)
+ headerItem?.place(this, isLookingAhead)
},
- viewportStartOffset = -beforeContentPadding,
- viewportEndOffset = maxOffset + afterContentPadding,
+ scrollBackAmount = scrollBackAmount,
visibleItemsInfo = if (noExtraItems) positionedItems else positionedItems.fastFilter {
(it.index >= visibleItems.first().index && it.index <= visibleItems.last().index) ||
it === headerItem
},
+ viewportStartOffset = -beforeContentPadding,
+ viewportEndOffset = maxOffset + afterContentPadding,
totalItemsCount = itemsCount,
reverseLayout = reverseLayout,
orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal,
@@ -337,7 +360,10 @@
measuredItemProvider: LazyListMeasuredItemProvider,
itemsCount: Int,
beyondBoundsItemCount: Int,
- pinnedItems: List<Int>
+ pinnedItems: List<Int>,
+ consumedScroll: Float,
+ isLookingAhead: Boolean,
+ lastPostLookaheadLayoutInfo: LazyListLayoutInfo?
): List<LazyListMeasuredItem> {
var list: MutableList<LazyListMeasuredItem>? = null
@@ -357,6 +383,60 @@
}
}
+ if (isLookingAhead) {
+ // Check if there's any item that needs to be composed based on last postLookaheadLayoutInfo
+ if (lastPostLookaheadLayoutInfo != null &&
+ lastPostLookaheadLayoutInfo.visibleItemsInfo.isNotEmpty()
+ ) {
+ // Find first item with index > end. Note that `visibleItemsInfo.last()` may not have
+ // the largest index as the last few items could be added to animate item placement.
+ val firstItem = lastPostLookaheadLayoutInfo.visibleItemsInfo.run {
+ var found: LazyListItemInfo? = null
+ for (i in size - 1 downTo 0) {
+ if (this[i].index > end && (i == 0 || this[i - 1].index <= end)) {
+ found = this[i]
+ break
+ }
+ }
+ found
+ }
+ val lastVisibleItem = lastPostLookaheadLayoutInfo.visibleItemsInfo.last()
+ if (firstItem != null) {
+ for (i in firstItem.index..lastVisibleItem.index) {
+ if (list?.fastAny { it.index == i } != null) {
+ if (list == null) list = mutableListOf()
+ list?.add(measuredItemProvider.getAndMeasure(i))
+ }
+ }
+ }
+
+ // Calculate the additional offset to subcompose based on what was shown in the
+ // previous post-loookahead pass and the scroll consumed.
+ val additionalOffset =
+ lastPostLookaheadLayoutInfo.viewportEndOffset - lastVisibleItem.offset -
+ lastVisibleItem.size - consumedScroll
+ if (additionalOffset > 0) {
+ var index = lastVisibleItem.index + 1
+ var totalOffset = 0
+ while (index < itemsCount && totalOffset < additionalOffset) {
+ val item = if (index <= end) {
+ visibleItems.fastFirstOrNull { it.index == index }
+ } else null
+ ?: list?.fastFirstOrNull { it.index == index }
+ if (item != null) {
+ index++
+ totalOffset += item.sizeWithSpacings
+ } else {
+ if (list == null) list = mutableListOf()
+ list?.add(measuredItemProvider.getAndMeasure(index))
+ index++
+ totalOffset += list!!.last().sizeWithSpacings
+ }
+ }
+ }
+ }
+ }
+
return list ?: emptyList()
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt
index d291659..9a7c9999 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasureResult.kt
@@ -35,6 +35,8 @@
val consumedScroll: Float,
/** MeasureResult defining the layout.*/
measureResult: MeasureResult,
+ /** The amount of scroll-back that happened due to reaching the end of the list. **/
+ val scrollBackAmount: Float,
// properties representing the info needed for LazyListLayoutInfo:
/** see [LazyListLayoutInfo.visibleItemsInfo] */
override val visibleItemsInfo: List<LazyListItemInfo>,
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
index b7993d7..70f38cc 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListMeasuredItem.kt
@@ -18,6 +18,7 @@
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.layout.LazyLayoutAnimateItemModifierNode
+import androidx.compose.foundation.lazy.layout.LazyLayoutAnimateItemModifierNode.Companion.NotInitialized
import androidx.compose.ui.Alignment
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.IntOffset
@@ -134,6 +135,7 @@
fun place(
scope: Placeable.PlacementScope,
+ isLookingAhead: Boolean
) = with(scope) {
require(mainAxisLayoutSize != Unset) { "position() should be called first" }
repeat(placeablesCount) { index ->
@@ -143,14 +145,26 @@
var offset = getOffset(index)
val animateNode = getParentData(index) as? LazyLayoutAnimateItemModifierNode
if (animateNode != null) {
- val animatedOffset = offset + animateNode.placementDelta
- // cancel the animation if current and target offsets are both out of the bounds.
- if ((offset.mainAxis <= minOffset && animatedOffset.mainAxis <= minOffset) ||
- (offset.mainAxis >= maxOffset && animatedOffset.mainAxis >= maxOffset)
- ) {
- animateNode.cancelAnimation()
+ if (isLookingAhead) {
+ // Skip animation in lookahead pass
+ animateNode.lookaheadOffset = offset
+ } else {
+ val targetOffset = if (animateNode.lookaheadOffset != NotInitialized) {
+ animateNode.lookaheadOffset
+ } else {
+ offset
+ }
+ val animatedOffset = targetOffset + animateNode.placementDelta
+ // cancel the animation if current and target offsets are both out of the bounds
+ if ((targetOffset.mainAxis <= minOffset &&
+ animatedOffset.mainAxis <= minOffset) ||
+ (targetOffset.mainAxis >= maxOffset &&
+ animatedOffset.mainAxis >= maxOffset)
+ ) {
+ animateNode.cancelAnimation()
+ }
+ offset = animatedOffset
}
- offset = animatedOffset
}
if (reverseLayout) {
offset = offset.copy { mainAxisOffset ->
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
index 1d2cf7a..fb2fe0a 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyListState.kt
@@ -16,6 +16,13 @@
package androidx.compose.foundation.lazy
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.animateTo
+import androidx.compose.animation.core.copy
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.Orientation
@@ -42,7 +49,10 @@
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
import kotlin.math.abs
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
/**
* Creates a [LazyListState] that is remembered across compositions.
@@ -82,6 +92,12 @@
firstVisibleItemIndex: Int = 0,
firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
+
+ internal var hasLookaheadPassOccurred: Boolean = false
+ private set
+ internal var postLookaheadLayoutInfo: LazyListLayoutInfo? = null
+ private set
+
/**
* The holder class for the current scroll position.
*/
@@ -385,18 +401,69 @@
/**
* Updates the state with the new calculated scroll position and consumed scroll.
*/
- internal fun applyMeasureResult(result: LazyListMeasureResult) {
- scrollPosition.updateFromMeasureResult(result)
- scrollToBeConsumed -= result.consumedScroll
- layoutInfoState.value = result
+ internal fun applyMeasureResult(result: LazyListMeasureResult, isLookingAhead: Boolean) {
+ if (!isLookingAhead && hasLookaheadPassOccurred) {
+ // If there was already a lookahead pass, record this result as postLookahead result
+ postLookaheadLayoutInfo = result
+ } else {
+ if (isLookingAhead) {
+ hasLookaheadPassOccurred = true
+ }
+ scrollPosition.updateFromMeasureResult(result)
+ scrollToBeConsumed -= result.consumedScroll
+ layoutInfoState.value = result
- canScrollForward = result.canScrollForward
- canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 ||
- result.firstVisibleItemScrollOffset != 0
+ canScrollForward = result.canScrollForward
+ canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 ||
+ result.firstVisibleItemScrollOffset != 0
- numMeasurePasses++
+ if (isLookingAhead) updateScrollDeltaForPostLookahead(result.scrollBackAmount)
+ numMeasurePasses++
- cancelPrefetchIfVisibleItemsChanged(result)
+ cancelPrefetchIfVisibleItemsChanged(result)
+ }
+ }
+
+ internal var coroutineScope: CoroutineScope? = null
+
+ internal val scrollDeltaBetweenPasses: Float
+ get() = _scrollDeltaBetweenPasses.value
+
+ private var _scrollDeltaBetweenPasses: AnimationState<Float, AnimationVector1D> =
+ AnimationState(Float.VectorConverter, 0f, 0f)
+
+ // Updates the scroll delta between lookahead & post-lookahead pass
+ private fun updateScrollDeltaForPostLookahead(delta: Float) {
+ if (delta <= with(density) { DeltaThresholdForScrollAnimation.toPx() }) {
+ // If the delta is within the threshold, scroll by the delta amount instead of animating
+ return
+ }
+
+ // Scroll delta is updated during lookahead, we don't need to trigger lookahead when
+ // the delta changes.
+ Snapshot.withoutReadObservation {
+ val currentDelta = _scrollDeltaBetweenPasses.value
+
+ if (_scrollDeltaBetweenPasses.isRunning) {
+ _scrollDeltaBetweenPasses = _scrollDeltaBetweenPasses.copy(currentDelta - delta)
+ coroutineScope?.launch {
+ _scrollDeltaBetweenPasses.animateTo(
+ 0f,
+ spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f),
+ true
+ )
+ }
+ } else {
+ _scrollDeltaBetweenPasses = AnimationState(Float.VectorConverter, -delta)
+ coroutineScope?.launch {
+ _scrollDeltaBetweenPasses.animateTo(
+ 0f,
+ spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 0.5f),
+ true
+ )
+ }
+ }
+ }
}
/**
@@ -425,6 +492,8 @@
}
}
+private val DeltaThresholdForScrollAnimation = 1.dp
+
private object EmptyLazyListLayoutInfo : LazyListLayoutInfo {
override val visibleItemsInfo = emptyList<LazyListItemInfo>()
override val viewportStartOffset = 0
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt
index dc2161f..8a81ca6 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/layout/LazyLayoutAnimateItemModifierNode.kt
@@ -74,6 +74,12 @@
}
/**
+ * Tracks the offset of the item in the lookahead pass. When set, this is the animation target
+ * that placementDelta should be applied to.
+ */
+ var lookaheadOffset: IntOffset = NotInitialized
+
+ /**
* Animate the placement by the given [delta] offset.
*/
fun animatePlacementDelta(delta: IntOffset) {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
index 51ef3b9..0ff47af 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/staggeredgrid/LazyStaggeredGridMeasure.kt
@@ -1003,11 +1003,7 @@
private val measureScope: LazyLayoutMeasureScope,
private val resolvedSlots: LazyStaggeredGridSlots
) {
- private fun childConstraints(requestedSlot: Int, requestedSpan: Int): Constraints {
- val slotCount = resolvedSlots.sizes.size
- val slot = requestedSlot.coerceAtMost(slotCount - 1)
- val span = requestedSpan.coerceAtMost(slotCount - slot)
-
+ private fun childConstraints(slot: Int, span: Int): Constraints {
// resolved slots contain [offset, size] pair per each slot.
val crossAxisSize = if (span == 1) {
resolvedSlots.sizes[slot]
@@ -1028,11 +1024,16 @@
fun getAndMeasure(index: Int, span: SpanRange): LazyStaggeredGridMeasuredItem {
val key = itemProvider.getKey(index)
val contentType = itemProvider.getContentType(index)
- val placeables = measureScope.measure(index, childConstraints(span.start, span.size))
+
+ val slotCount = resolvedSlots.sizes.size
+ val spanStart = span.start.coerceAtMost(slotCount - 1)
+ val spanSize = span.size.coerceAtMost(slotCount - spanStart)
+
+ val placeables = measureScope.measure(index, childConstraints(spanStart, spanSize))
return createItem(
index,
- span.start,
- span.size,
+ spanStart,
+ spanSize,
key,
contentType,
placeables
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
index f179e9e..85527e5 100644
--- a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
@@ -814,7 +814,7 @@
}
if (!state.is24hour) {
- Box(modifier.padding(start = PeriodToggleMargin)) {
+ Box(Modifier.padding(start = PeriodToggleMargin)) {
VerticalPeriodToggle(
modifier = Modifier.size(
PeriodSelectorContainerWidth,
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LookaheadScopeTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LookaheadScopeTest.kt
index a1e73ac..6e3afac 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LookaheadScopeTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/LookaheadScopeTest.kt
@@ -62,6 +62,7 @@
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
+import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Matrix
@@ -2145,6 +2146,85 @@
}
}
+ @OptIn(ExperimentalComposeUiApi::class)
+ @Test
+ fun lookaheadSizeTrackedWhenModifierChanges() {
+ var expanded by mutableStateOf(true)
+ val lookaheadHeight = mutableListOf(0, 0, 0)
+ rule.setContent {
+ CompositionLocalProvider(LocalDensity provides Density(1f)) {
+ LookaheadScope {
+ Layout(content = {
+ repeat(3) {
+ Column(Modifier.layout { measurable, constraints ->
+ measurable.measure(constraints).run {
+ layout(width, height) { place(0, 0) }
+ }
+ }) {
+ Box(
+ Modifier
+ .requiredHeight(100.dp)
+ .fillMaxWidth()
+ ) {
+ Text("$it")
+ }
+ // Bring in a new modifier while setting the size to 0.
+ Box(
+ (if (!expanded) Modifier.clipToBounds() else Modifier)
+ .then(Modifier.layout { measurable, constraints ->
+ measurable.measure(constraints).run {
+ val (w, h) = if (isLookingAhead) {
+ if (!expanded) IntSize.Zero else IntSize(
+ width,
+ height
+ )
+ } else {
+ IntSize(width, height)
+ }
+ layout(w, h) {
+ place(0, 0)
+ }
+ }
+ })
+ ) {
+ Box(
+ Modifier
+ .requiredHeight(100.dp)
+ .fillMaxWidth()
+ )
+ }
+ }
+ }
+ }) { measurables, constraints ->
+ measurables.map { it.measure(constraints) }.run {
+ layout(this[0].width, this[0].height * 3) {
+ var h = 0
+ forEachIndexed { id, placeable ->
+ if (isLookingAhead) {
+ lookaheadHeight[id] = placeable.height
+ }
+ placeable.place(0, h)
+ h += placeable.height
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ repeat(3) {
+ assertEquals(200, lookaheadHeight[it])
+ }
+ rule.runOnIdle {
+ expanded = false
+ }
+ rule.waitForIdle()
+ repeat(3) {
+ assertEquals(100, lookaheadHeight[it])
+ }
+ }
+
@Test
fun forceMeasureSubtreeWhileLookaheadMeasureRequestedFromSubtree() {
var iterations by mutableStateOf(0)
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
index 263068d..7d9003a3 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt
@@ -24,6 +24,7 @@
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.ReusableContentHost
import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.collection.mutableVectorOf
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.currentCompositeKeyHash
import androidx.compose.runtime.getValue
@@ -48,6 +49,7 @@
import androidx.compose.ui.platform.createSubcomposition
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.util.fastForEach
/**
* Analogue of [Layout] which allows to subcompose the actual content during the measuring stage
@@ -358,7 +360,6 @@
* when a new SubcomposeLayoutState is applied to SubcomposeLayout and even when the
* SubcomposeLayout's LayoutNode is reused via the ReusableComposeNode mechanism.
*/
-@OptIn(ExperimentalComposeUiApi::class)
internal class LayoutNodeSubcompositionsState(
private val root: LayoutNode,
slotReusePolicy: SubcomposeSlotReusePolicy
@@ -374,10 +375,8 @@
}
}
- val isInLookaheadScope: Boolean
- get() = root.lookaheadRoot != null
-
private var currentIndex = 0
+ private var currentPostLookaheadIndex = 0
private val nodeToNodeState = mutableMapOf<LayoutNode, NodeState>()
// this map contains active slotIds (without precomposed or reusable nodes)
@@ -387,6 +386,11 @@
private val precomposeMap = mutableMapOf<Any?, LayoutNode>()
private val reusableSlotIdsSet = SubcomposeSlotReusePolicy.SlotIdsSet()
+ // SlotHandles precomposed in the post-lookahead pass.
+ private val postLookaheadPrecomposeSlotHandleMap = mutableMapOf<Any?, PrecomposedSlotHandle>()
+ // Slot ids _composed_ in post-lookahead. The valid slot ids are stored between 0 and
+ // currentPostLookaheadIndex - 1, beyond index currentPostLookaheadIndex are obsolete ids.
+ private val postLookaheadComposedSlotIds = mutableVectorOf<Any?>()
/**
* `root.foldedChildren` list consist of:
@@ -622,32 +626,51 @@
scope.density = density
scope.fontScale = fontScale
if (!isLookingAhead && root.lookaheadRoot != null) {
- return with(postLookaheadMeasureScope) {
- block(constraints)
+ currentPostLookaheadIndex = 0
+ val result = postLookaheadMeasureScope.block(constraints)
+ val indexAfterMeasure = currentPostLookaheadIndex
+ return createMeasureResult(result) {
+ currentPostLookaheadIndex = indexAfterMeasure
+ result.placeChildren()
+ // dispose
+ disposeUnusedSlotsInPostLookahead()
}
} else {
currentIndex = 0
val result = scope.block(constraints)
val indexAfterMeasure = currentIndex
- return object : MeasureResult {
- override val width: Int
- get() = result.width
- override val height: Int
- get() = result.height
- override val alignmentLines: Map<AlignmentLine, Int>
- get() = result.alignmentLines
-
- override fun placeChildren() {
- currentIndex = indexAfterMeasure
- result.placeChildren()
- disposeOrReuseStartingFromIndex(currentIndex)
- }
+ return createMeasureResult(result) {
+ currentIndex = indexAfterMeasure
+ result.placeChildren()
+ disposeOrReuseStartingFromIndex(currentIndex)
}
}
}
}
}
+ private fun disposeUnusedSlotsInPostLookahead() {
+ postLookaheadPrecomposeSlotHandleMap.entries.removeAll { (slotId, handle) ->
+ val id = postLookaheadComposedSlotIds.indexOf(slotId)
+ if (id < 0 || id >= currentPostLookaheadIndex) {
+ // Slot was not used in the latest pass of post-lookahead.
+ handle.dispose()
+ true
+ } else {
+ false
+ }
+ }
+ }
+
+ private inline fun createMeasureResult(
+ result: MeasureResult,
+ crossinline placeChildrenBlock: () -> Unit
+ ) = object : MeasureResult by result {
+ override fun placeChildren() {
+ placeChildrenBlock()
+ }
+ }
+
private val NoIntrinsicsMessage = "Asking for intrinsic measurements of SubcomposeLayout " +
"layouts is not supported. This includes components that are built on top of " +
"SubcomposeLayout, such as lazy lists, BoxWithConstraints, TabRow, etc. To mitigate " +
@@ -661,6 +684,8 @@
fun precompose(slotId: Any?, content: @Composable () -> Unit): PrecomposedSlotHandle {
makeSureStateIsConsistent()
if (!slotIdToNode.containsKey(slotId)) {
+ // Yield ownership of PrecomposedHandle from postLookahead to the caller of precompose
+ postLookaheadPrecomposeSlotHandleMap.remove(slotId)
val node = precomposeMap.getOrPut(slotId) {
val reusedNode = takeNodeFromReusables(slotId)
if (reusedNode != null) {
@@ -785,8 +810,46 @@
* the subcomposition that happened in the lookahead pass. If [slotId] was not subcomposed
* in the lookahead pass, [subcompose] will return an [emptyList].
*/
- override fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable> =
- slotIdToNode[slotId]?.childMeasurables ?: emptyList()
+ override fun subcompose(slotId: Any?, content: @Composable () -> Unit): List<Measurable> {
+ val measurables = slotIdToNode[slotId]?.childMeasurables
+ if (measurables != null) {
+ return measurables
+ }
+ return postLookaheadSubcompose(slotId, content)
+ }
+ }
+
+ private fun postLookaheadSubcompose(
+ slotId: Any?,
+ content: @Composable () -> Unit
+ ): List<Measurable> {
+ require(postLookaheadComposedSlotIds.size >= currentPostLookaheadIndex) {
+ "Error: currentPostLookaheadIndex cannot be greater than the size of the" +
+ "postLookaheadComposedSlotIds list."
+ }
+ if (postLookaheadComposedSlotIds.size == currentPostLookaheadIndex) {
+ postLookaheadComposedSlotIds.add(slotId)
+ } else {
+ postLookaheadComposedSlotIds[currentPostLookaheadIndex] = slotId
+ }
+ currentPostLookaheadIndex++
+ if (!precomposeMap.contains(slotId)) {
+ // Not composed yet
+ precompose(slotId, content).also {
+ postLookaheadPrecomposeSlotHandleMap[slotId] = it
+ }
+ if (root.layoutState == LayoutState.LayingOut) {
+ root.requestLookaheadRelayout(true)
+ } else {
+ root.requestLookaheadRemeasure(true)
+ }
+ }
+
+ return precomposeMap[slotId]?.run {
+ measurePassDelegate.childDelegates.also {
+ it.fastForEach { delegate -> delegate.markDetachedFromParentLookaheadPass() }
+ }
+ } ?: emptyList()
}
}
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
index d58eef5..75aaace 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutNodeLayoutDelegate.kt
@@ -47,6 +47,13 @@
get() = measurePassDelegate.width
/**
+ * This gets set to true via [MeasurePassDelegate.markDetachedFromParentLookaheadPass] and
+ * automatically gets unset in `measure` when the measure call comes from parent with
+ * layoutState being LookaheadMeasuring or LookaheadLayingOut.
+ */
+ private var detachedFromParentLookaheadPass: Boolean = false
+
+ /**
* The layout state the node is currently in.
*
* The mutation of [layoutState] is confined to [LayoutNodeLayoutDelegate], and is therefore
@@ -332,6 +339,10 @@
return _childDelegates.asMutableList()
}
+ internal fun markDetachedFromParentLookaheadPass() {
+ detachedFromParentLookaheadPass = true
+ }
+
var layingOutChildren = false
private set
@@ -1135,6 +1146,10 @@
}
override fun measure(constraints: Constraints): Placeable {
+ if (layoutNode.parent?.layoutState == LayoutState.LookaheadMeasuring ||
+ layoutNode.parent?.layoutState == LayoutState.LookaheadLayingOut) {
+ detachedFromParentLookaheadPass = false
+ }
trackLookaheadMeasurementByParent(layoutNode)
if (layoutNode.intrinsicsUsageByParent == LayoutNode.UsageByParent.NotUsed) {
// This LayoutNode may have asked children for intrinsics. If so, we should
@@ -1191,14 +1206,19 @@
forEachChildAlignmentLinesOwner {
it.alignmentLines.usedDuringParentMeasurement = false
}
+ // Copy out the previous size before performing lookahead measure. If never
+ // measured, set the last size to negative instead of Zero in anticipation for zero
+ // being a valid lookahead size.
+ val lastLookaheadSize = if (measuredOnce)
+ measuredSize
+ else
+ IntSize(Int.MIN_VALUE, Int.MIN_VALUE)
measuredOnce = true
val lookaheadDelegate = outerCoordinator.lookaheadDelegate
check(lookaheadDelegate != null) {
"Lookahead result from lookaheadRemeasure cannot be null"
}
- // Copy out the previous size before perform lookahead measure
- val lastLookaheadSize = IntSize(lookaheadDelegate.width, lookaheadDelegate.height)
performLookaheadMeasure(constraints)
measuredSize = IntSize(lookaheadDelegate.width, lookaheadDelegate.height)
val sizeChanged = lastLookaheadSize.width != lookaheadDelegate.width ||
@@ -1482,7 +1502,7 @@
* has a lookahead root.
*/
private fun LayoutNode.isOutMostLookaheadRoot(): Boolean =
- lookaheadRoot != null && parent?.lookaheadRoot == null
+ lookaheadRoot != null && (parent?.lookaheadRoot == null || detachedFromParentLookaheadPass)
/**
* Performs measure with the given constraints and perform necessary state mutations before
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutTreeConsistencyChecker.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutTreeConsistencyChecker.kt
index 4ebfe68..4e2ee0f 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutTreeConsistencyChecker.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/node/LayoutTreeConsistencyChecker.kt
@@ -65,6 +65,7 @@
// remeasure or relayout is scheduled
if (measurePending) {
return relayoutNodes.contains(this) ||
+ layoutState == LayoutNode.LayoutState.LookaheadMeasuring ||
parent?.measurePending == true ||
parent?.lookaheadMeasurePending == true ||
parentLayoutState == LayoutNode.LayoutState.Measuring
diff --git a/privacysandbox/plugins/plugins-privacysandbox-library/src/main/java/androidx/privacysandboxlibraryplugin/PrivacySandboxLibraryPlugin.kt b/privacysandbox/plugins/plugins-privacysandbox-library/src/main/java/androidx/privacysandboxlibraryplugin/PrivacySandboxLibraryPlugin.kt
index eaf2d0a..a7ecb4f 100644
--- a/privacysandbox/plugins/plugins-privacysandbox-library/src/main/java/androidx/privacysandboxlibraryplugin/PrivacySandboxLibraryPlugin.kt
+++ b/privacysandbox/plugins/plugins-privacysandbox-library/src/main/java/androidx/privacysandboxlibraryplugin/PrivacySandboxLibraryPlugin.kt
@@ -77,7 +77,7 @@
// Add additional dependencies required for KSP outputs
- val toolsVersion = "1.0.0-alpha03"
+ val toolsVersion = "1.0.0-alpha04"
project.dependencies {
add(
"ksp",
diff --git a/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/AssetLoaderAjaxActivityTestAppTest.java b/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/AssetLoaderAjaxActivityTestAppTest.java
index 7325510..a84374d 100644
--- a/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/AssetLoaderAjaxActivityTestAppTest.java
+++ b/webkit/integration-tests/testapp/src/androidTest/java/com/example/androidx/webkit/AssetLoaderAjaxActivityTestAppTest.java
@@ -20,10 +20,10 @@
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
+import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.After;
import org.junit.Before;
-import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -52,10 +52,10 @@
activity.getUriIdlingResource()));
}
- @Ignore("b/283485965")
@Test
public void testAssetLoaderAjaxActivity() {
- mRule.getScenario().onActivity(activity -> activity.loadUrl());
+ mRule.getScenario().onActivity(AssetLoaderAjaxActivity::loadUrl);
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
WebkitTestHelpers.assertHtmlElementContainsText(R.id.webview_asset_loader_webview,
"title", "Loaded HTML should appear below on success");
WebkitTestHelpers.assertHtmlElementContainsText(R.id.webview_asset_loader_webview,