blob: 30c436c2ae06b1eb2968489c035bdfd943d427a2 [file] [log] [blame]
/*
* Copyright 2022 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.foundation.lazy.list
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LocalPinnableContainer
import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.layout.PinnableContainer
import androidx.compose.ui.layout.PinnableContainer.PinnedHandle
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Dp
import androidx.test.filters.MediumTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@MediumTest
@RunWith(Parameterized::class)
class LazyListPinnableContainerTest(val useLookaheadScope: Boolean) {
companion object {
@JvmStatic
@Parameterized.Parameters(name = "useLookahead = {0}")
fun params() = arrayOf(true, false)
}
@get:Rule
val rule = createComposeRule()
private var pinnableContainer: PinnableContainer? = null
private val itemSizePx = 10
private var itemSize = Dp.Unspecified
private val composed = mutableSetOf<Int>()
@Before
fun setup() {
itemSize = with(rule.density) { itemSizePx.toDp() }
}
private inline fun ComposeContentTestRule.setContentParameterized(
crossinline content: @Composable () -> Unit
) {
setContent {
if (useLookaheadScope) {
LookaheadScope {
content()
}
} else {
content()
}
}
}
@Composable
fun Item(index: Int) {
Box(
Modifier
.size(itemSize)
.testTag("$index")
)
DisposableEffect(index) {
composed.add(index)
onDispose {
composed.remove(index)
}
}
}
@Test
fun pinnedItemIsComposedAndPlacedWhenScrolledOut() {
val state = LazyListState()
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(100) { index ->
if (index == 1) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
assertThat(composed).contains(1)
runBlocking {
state.scrollToItem(3)
}
}
rule.waitUntil {
// not visible items were disposed
!composed.contains(0)
}
rule.runOnIdle {
// item 1 is still pinned
assertThat(composed).contains(1)
}
rule.onNodeWithTag("1")
.assertExists()
.assertIsNotDisplayed()
.assertIsPlaced()
}
@Test
fun itemsBetweenPinnedAndCurrentVisibleAreNotComposed() {
val state = LazyListState()
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(100) { index ->
if (index == 1) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
runBlocking {
state.scrollToItem(4)
}
}
rule.waitUntil {
// not visible items were disposed
!composed.contains(0)
}
rule.runOnIdle {
assertThat(composed).doesNotContain(0)
assertThat(composed).contains(1)
assertThat(composed).doesNotContain(2)
assertThat(composed).doesNotContain(3)
assertThat(composed).contains(4)
}
}
@Test
fun pinnedItemAfterVisibleOnesIsComposedAndPlacedWhenScrolledOut() {
val state = LazyListState()
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(100) { index ->
if (index == 4) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
rule.runOnIdle {
runBlocking {
state.scrollToItem(4)
}
}
rule.waitUntil {
// wait for not visible items to be disposed
!composed.contains(1)
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
assertThat(composed).contains(5)
}
rule.runOnIdle {
runBlocking {
state.scrollToItem(0)
}
if (useLookaheadScope) {
// Force another lookahead measure pass, because lookahead pass by design keeps
// content from last measure pass until it's no longer needed in either pass.
runBlocking {
state.scrollToItem(0)
}
}
}
rule.waitUntil {
// wait for not visible items to be disposed
!composed.contains(5)
}
rule.runOnIdle {
assertThat(composed).contains(0)
assertThat(composed).contains(1)
assertThat(composed).doesNotContain(2)
assertThat(composed).doesNotContain(3)
assertThat(composed).contains(4)
assertThat(composed).doesNotContain(5)
}
}
@Test
fun pinnedItemCanBeUnpinned() {
val state = LazyListState()
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(100) { index ->
if (index == 1) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
val handle = rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
runBlocking {
state.scrollToItem(3)
}
}
rule.waitUntil {
// wait for not visible items to be disposed
!composed.contains(0)
}
rule.runOnIdle {
handle.release()
}
rule.waitUntil {
// wait for unpinned item to be disposed
!composed.contains(1)
}
rule.onNodeWithTag("1")
.assertIsNotPlaced()
}
@Test
fun pinnedItemIsStillPinnedWhenReorderedAndNotVisibleAnymore() {
val state = LazyListState()
var list by mutableStateOf(listOf(0, 1, 2, 3, 4))
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 3), state = state) {
items(list, key = { it }) { index ->
if (index == 2) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
rule.runOnIdle {
assertThat(composed).containsExactly(0, 1, 2)
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
list = listOf(0, 3, 4, 1, 2)
}
rule.waitUntil {
// wait for not visible item to be disposed
!composed.contains(1)
}
rule.runOnIdle {
assertThat(composed).containsExactly(0, 3, 4, 2) // 2 is pinned
}
rule.onNodeWithTag("2")
.assertIsPlaced()
}
@Test
fun unpinnedWhenLazyListStateChanges() {
var state by mutableStateOf(LazyListState(firstVisibleItemIndex = 2))
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(100) { index ->
if (index == 2) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
assertThat(composed).contains(3)
runBlocking {
state.scrollToItem(0)
}
if (useLookaheadScope) {
// Force another lookahead measure pass, because lookahead pass by design keeps
// content from last measure pass until it's no longer needed in either pass.
runBlocking {
state.scrollToItem(0)
}
}
}
rule.waitUntil {
// wait for not visible item to be disposed
!composed.contains(3)
}
rule.runOnIdle {
assertThat(composed).contains(2)
state = LazyListState()
}
rule.waitUntil {
// wait for pinned item to be disposed
!composed.contains(2)
}
rule.onNodeWithTag("2")
.assertIsNotPlaced()
}
@Test
fun pinAfterLazyListStateChange() {
var state by mutableStateOf(LazyListState())
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(100) { index ->
if (index == 0) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
rule.runOnIdle {
state = LazyListState()
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
assertThat(composed).contains(1)
runBlocking {
state.scrollToItem(2)
}
}
rule.waitUntil {
// wait for not visible item to be disposed
!composed.contains(1)
}
rule.runOnIdle {
assertThat(composed).contains(0)
}
}
@Test
fun itemsArePinnedBasedOnGlobalIndexes() {
val state = LazyListState(firstVisibleItemIndex = 3)
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
repeat(100) { index ->
item {
if (index == 3) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
assertThat(composed).contains(4)
runBlocking {
state.scrollToItem(6)
}
}
rule.waitUntil {
// wait for not visible item to be disposed
!composed.contains(4)
}
rule.runOnIdle {
assertThat(composed).contains(3)
}
rule.onNodeWithTag("3")
.assertExists()
.assertIsNotDisplayed()
.assertIsPlaced()
}
@Test
fun pinnedItemIsRemovedWhenNotVisible() {
val state = LazyListState(3)
var itemCount by mutableStateOf(10)
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(itemCount) { index ->
if (index == 3) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
assertThat(composed).contains(4)
runBlocking {
state.scrollToItem(0)
}
if (useLookaheadScope) {
// Force another lookahead measure pass, because lookahead pass by design keeps
// content from last measure pass until it's no longer needed in either pass.
runBlocking {
state.scrollToItem(0)
}
}
}
rule.waitUntil {
// wait for not visible item to be disposed
!composed.contains(4)
}
rule.runOnIdle {
itemCount = 3
}
rule.waitUntil {
// wait for pinned item to be disposed
!composed.contains(3)
}
rule.onNodeWithTag("3")
.assertIsNotPlaced()
}
@Test
fun pinnedItemIsRemovedWhenVisible() {
val state = LazyListState(0)
var items by mutableStateOf(listOf(0, 1, 2))
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(items) { index ->
if (index == 1) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
items = listOf(0, 2)
}
rule.waitUntil {
// wait for pinned item to be disposed
!composed.contains(1)
}
rule.onNodeWithTag("1")
.assertIsNotPlaced()
}
@Test
fun pinnedMultipleTimes() {
val state = LazyListState(0)
// Arrange.
rule.setContentParameterized {
LazyColumn(Modifier.size(itemSize * 2), state = state) {
items(100) { index ->
if (index == 1) {
pinnableContainer = LocalPinnableContainer.current
}
Item(index)
}
}
}
val handles = mutableListOf<PinnedHandle>()
rule.runOnIdle {
handles.add(requireNotNull(pinnableContainer).pin())
handles.add(requireNotNull(pinnableContainer).pin())
}
rule.runOnIdle {
// pinned 3 times in total
handles.add(requireNotNull(pinnableContainer).pin())
assertThat(composed).contains(0)
runBlocking {
state.scrollToItem(3)
}
}
rule.waitUntil {
// wait for not visible item to be disposed
!composed.contains(0)
}
while (handles.isNotEmpty()) {
rule.runOnIdle {
assertThat(composed).contains(1)
handles.removeFirst().release()
}
}
rule.waitUntil {
// wait for pinned item to be disposed
!composed.contains(1)
}
}
@Test
fun pinningIsPropagatedToParentContainer() {
var parentPinned = false
val parentContainer = object : PinnableContainer {
override fun pin(): PinnedHandle {
parentPinned = true
return PinnedHandle { parentPinned = false }
}
}
// Arrange.
rule.setContentParameterized {
CompositionLocalProvider(LocalPinnableContainer provides parentContainer) {
LazyColumn {
item {
pinnableContainer = LocalPinnableContainer.current
Box(Modifier.size(itemSize))
}
}
}
}
val handle = rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
assertThat(parentPinned).isTrue()
handle.release()
}
rule.runOnIdle {
assertThat(parentPinned).isFalse()
}
}
@Test
fun parentContainerChange_pinningIsMaintained() {
var parent1Pinned = false
val parent1Container = object : PinnableContainer {
override fun pin(): PinnedHandle {
parent1Pinned = true
return PinnedHandle { parent1Pinned = false }
}
}
var parent2Pinned = false
val parent2Container = object : PinnableContainer {
override fun pin(): PinnedHandle {
parent2Pinned = true
return PinnedHandle { parent2Pinned = false }
}
}
var parentContainer by mutableStateOf<PinnableContainer>(parent1Container)
// Arrange.
rule.setContentParameterized {
CompositionLocalProvider(LocalPinnableContainer provides parentContainer) {
LazyColumn {
item {
pinnableContainer = LocalPinnableContainer.current
Box(Modifier.size(itemSize))
}
}
}
}
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
assertThat(parent1Pinned).isTrue()
assertThat(parent2Pinned).isFalse()
parentContainer = parent2Container
}
rule.runOnIdle {
assertThat(parent1Pinned).isFalse()
assertThat(parent2Pinned).isTrue()
}
}
@Test
fun pinnedItemIsRemovedAfterContainerExitsComposition() {
var active by mutableStateOf(true)
// Arrange.
rule.setContentParameterized {
if (active) {
LazyColumn(Modifier.size(itemSize * 2)) {
items(3) { colIndex ->
LazyRow {
items(3) { rowIndex ->
if (colIndex == 1 && rowIndex == 1) {
pinnableContainer = LocalPinnableContainer.current
}
Box(Modifier.size(itemSize).testTag("$colIndex:$rowIndex"))
}
}
}
}
}
}
rule.onNodeWithTag("1:1")
.assertIsPlaced()
rule.runOnIdle {
requireNotNull(pinnableContainer).pin()
}
rule.runOnIdle {
active = !active
}
rule.onNodeWithTag("1:1")
.assertIsNotPlaced()
}
}
/**
* Asserts that the current semantics node is not placed.
*
* Throws [AssertionError] if the node is placed.
*/
internal fun SemanticsNodeInteraction.assertIsNotPlaced() {
// TODO(b/187188981): We don't have a non-throwing API to check whether an item exists.
// So until this bug is fixed, we are going to catch the assertion error and then check
// whether the node is placed or not.
try {
// If the node does not exist, it implies that it is also not placed.
assertDoesNotExist()
} catch (e: AssertionError) {
// If the node exists, we need to assert that it is not placed.
val errorMessageOnFail = "Assert failed: The component is placed!"
if (fetchSemanticsNode().layoutInfo.isPlaced) {
throw AssertionError(errorMessageOnFail)
}
}
}