Skip to content

Commit 8400a1f

Browse files
committed
Extract an interface for LoadingStrategy
1 parent 0fbfeb6 commit 8400a1f

File tree

5 files changed

+212
-116
lines changed

5 files changed

+212
-116
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
New:
77
- Source-based schema parser is now the default. Can be disabled in your schema module with `redwood { useFir = false }`.
8+
- Introduce a `LoadingStrategy` interface to manage `LazyList` preloading.
89

910
Changed:
1011
- Nothing yet!

redwood-lazylayout-compose/src/commonMain/kotlin/app/cash/redwood/lazylayout/compose/LazyListState.kt

Lines changed: 12 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -17,33 +17,27 @@ package app.cash.redwood.lazylayout.compose
1717

1818
import androidx.compose.runtime.Composable
1919
import androidx.compose.runtime.getValue
20-
import androidx.compose.runtime.mutableIntStateOf
2120
import androidx.compose.runtime.mutableStateOf
2221
import androidx.compose.runtime.saveable.Saver
2322
import androidx.compose.runtime.saveable.rememberSaveable
2423
import androidx.compose.runtime.setValue
2524
import app.cash.redwood.lazylayout.api.ScrollItemIndex
2625

27-
private const val DEFAULT_PRELOAD_ITEM_COUNT = 15
28-
private const val SCROLL_IN_PROGRESS_PRELOAD_ITEM_COUNT = 5
29-
private const val PRIMARY_PRELOAD_ITEM_COUNT = 20
30-
private const val SECONDARY_PRELOAD_ITEM_COUNT = 10
31-
32-
private const val DEFAULT_SCROLL_INDEX = -1
33-
3426
/**
3527
* Creates a [LazyListState] that is remembered across compositions.
3628
*/
3729
@Composable
38-
public fun rememberLazyListState(): LazyListState {
30+
public fun rememberLazyListState(
31+
strategy: LoadingStrategy = ScrollOptimizedLoadingStrategy(),
32+
): LazyListState {
3933
return rememberSaveable(saver = saver) {
40-
LazyListState()
34+
LazyListState(strategy)
4135
}
4236
}
4337

4438
/** The default [Saver] implementation for [LazyListState]. */
4539
private val saver: Saver<LazyListState, *> = Saver(
46-
save = { it.firstIndex },
40+
save = { it.strategy.firstIndex },
4741
restore = {
4842
LazyListState().apply {
4943
programmaticScroll(firstIndex = it, animated = false, clobberUserScroll = false)
@@ -56,7 +50,9 @@ private val saver: Saver<LazyListState, *> = Saver(
5650
*
5751
* In most cases, this will be created via [rememberLazyListState].
5852
*/
59-
public open class LazyListState {
53+
public open class LazyListState(
54+
public val strategy: LoadingStrategy = ScrollOptimizedLoadingStrategy(),
55+
) {
6056
/**
6157
* Update this to trigger a programmatic scroll. This may be updated multiple times, including
6258
* when the previous scroll state is restored.
@@ -69,26 +65,6 @@ public open class LazyListState {
6965
/** Once we receive a user scroll, we limit which programmatic scrolls we apply. */
7066
private var userScrolled = false
7167

72-
/** Bounds of what the user is looking at. Everything else is placeholders! */
73-
public var firstIndex: Int by mutableIntStateOf(0)
74-
private set
75-
public var lastIndex: Int by mutableIntStateOf(0)
76-
private set
77-
78-
internal var preloadItems: Boolean = true
79-
80-
public var defaultPreloadItemCount: Int = DEFAULT_PRELOAD_ITEM_COUNT
81-
public var scrollInProgressPreloadItemCount: Int = SCROLL_IN_PROGRESS_PRELOAD_ITEM_COUNT
82-
public var primaryPreloadItemCount: Int = PRIMARY_PRELOAD_ITEM_COUNT
83-
public var secondaryPreloadItemCount: Int = SECONDARY_PRELOAD_ITEM_COUNT
84-
85-
private var firstIndexFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
86-
private var firstIndexFromPrevious2: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
87-
private var lastIndexFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
88-
89-
private var beginFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
90-
private var endFromPrevious1: Int by mutableStateOf(DEFAULT_SCROLL_INDEX)
91-
9268
/** Perform a programmatic scroll. */
9369
public fun programmaticScroll(
9470
firstIndex: Int,
@@ -98,16 +74,14 @@ public open class LazyListState {
9874
require(firstIndex >= 0)
9975
if (!clobberUserScroll && userScrolled) return
10076

77+
strategy.scrollTo(firstIndex)
78+
10179
val previous = programmaticScrollIndex
10280
this.programmaticScrollIndex = ScrollItemIndex(
10381
id = previous.id + 1,
10482
index = firstIndex,
10583
animated = animated,
10684
)
107-
108-
val delta = (lastIndex - this.firstIndex)
109-
this.firstIndex = firstIndex
110-
this.lastIndex = firstIndex + delta
11185
}
11286

11387
/** React to a user-initiated scroll. */
@@ -116,85 +90,10 @@ public open class LazyListState {
11690
userScrolled = true
11791
}
11892

119-
this.firstIndex = firstIndex
120-
this.lastIndex = lastIndex
93+
strategy.onUserScroll(firstIndex, lastIndex)
12194
}
12295

12396
public fun loadRange(itemCount: Int): IntRange {
124-
// Ensure that the range includes `firstIndex` through `lastIndex`.
125-
var begin = firstIndex
126-
var end = lastIndex
127-
128-
val isScrollingDown = firstIndexFromPrevious1 != DEFAULT_SCROLL_INDEX && firstIndexFromPrevious1 < firstIndex
129-
val isScrollingUp = firstIndexFromPrevious1 != DEFAULT_SCROLL_INDEX && firstIndexFromPrevious1 > firstIndex
130-
val hasStoppedScrolling = firstIndexFromPrevious2 != DEFAULT_SCROLL_INDEX && firstIndex == firstIndexFromPrevious1
131-
val wasScrollingDown = firstIndexFromPrevious1 > firstIndexFromPrevious2
132-
val wasScrollingUp = firstIndexFromPrevious1 < firstIndexFromPrevious2
133-
134-
// Expand the range depending on scroll direction.
135-
when {
136-
// Ignore preloads.
137-
!preloadItems -> {
138-
// No-op
139-
}
140-
141-
isScrollingDown -> {
142-
begin -= scrollInProgressPreloadItemCount
143-
end += primaryPreloadItemCount
144-
}
145-
146-
isScrollingUp -> {
147-
begin -= primaryPreloadItemCount
148-
end += scrollInProgressPreloadItemCount
149-
}
150-
151-
hasStoppedScrolling && wasScrollingDown -> {
152-
begin -= secondaryPreloadItemCount
153-
end += primaryPreloadItemCount
154-
}
155-
156-
hasStoppedScrolling && wasScrollingUp -> {
157-
begin -= primaryPreloadItemCount
158-
end += secondaryPreloadItemCount
159-
}
160-
161-
// New.
162-
else -> {
163-
end += defaultPreloadItemCount
164-
}
165-
}
166-
167-
// On initial load, set lastIndex to the end of the loaded window.
168-
if (lastIndex == 0) {
169-
lastIndex = end
170-
}
171-
172-
// If we're contiguous with the previous visible window,
173-
// don't rush to remove things from the previous range.
174-
if (beginFromPrevious1 != DEFAULT_SCROLL_INDEX &&
175-
endFromPrevious1 != DEFAULT_SCROLL_INDEX
176-
) {
177-
// Case one: Contiguous scroll down
178-
if (begin in firstIndexFromPrevious1..lastIndexFromPrevious1) {
179-
begin = beginFromPrevious1
180-
}
181-
182-
// Case two: Contiguous scroll up
183-
if (end in firstIndexFromPrevious1..lastIndexFromPrevious1) {
184-
end = endFromPrevious1
185-
}
186-
}
187-
188-
begin = begin.coerceIn(0, itemCount)
189-
end = end.coerceIn(0, itemCount)
190-
191-
this.firstIndexFromPrevious2 = firstIndexFromPrevious1
192-
this.firstIndexFromPrevious1 = firstIndex
193-
this.lastIndexFromPrevious1 = lastIndex
194-
195-
this.beginFromPrevious1 = begin
196-
this.endFromPrevious1 = end
197-
198-
return begin until end
97+
return strategy.loadRange(itemCount)
19998
}
20099
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright (C) 2024 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package app.cash.redwood.lazylayout.compose
17+
18+
public interface LoadingStrategy {
19+
/**
20+
* Returns the most recent first index scrolled to. This is used to save the scroll position when
21+
* the view is unloaded.
22+
*/
23+
public val firstIndex: Int
24+
25+
/** Perform a programmatic scroll to [firstIndex]. */
26+
public fun scrollTo(firstIndex: Int)
27+
28+
/** React to a user-initiated scroll to the target range. */
29+
public fun onUserScroll(firstIndex: Int, lastIndex: Int)
30+
31+
/**
32+
* Returns the range of items to render into the view tree. This should be a slice of
33+
* `0..(itemCount - 1)`. It should cover the most-recently scrolled to `firstIndex..lastIndex`
34+
* range, plus any adjacent indexes to preload.
35+
*/
36+
public fun loadRange(itemCount: Int): IntRange
37+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright (C) 2024 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package app.cash.redwood.lazylayout.compose
17+
18+
import androidx.compose.runtime.getValue
19+
import androidx.compose.runtime.mutableIntStateOf
20+
import androidx.compose.runtime.mutableStateOf
21+
import androidx.compose.runtime.setValue
22+
import app.cash.redwood.lazylayout.api.ScrollItemIndex
23+
24+
private const val DEFAULT_PRELOAD_ITEM_COUNT = 15
25+
private const val SCROLL_IN_PROGRESS_PRELOAD_ITEM_COUNT = 5
26+
private const val PRIMARY_PRELOAD_ITEM_COUNT = 20
27+
private const val SECONDARY_PRELOAD_ITEM_COUNT = 10
28+
29+
private const val DEFAULT_SCROLL_INDEX = -1
30+
31+
/**
32+
* A loading strategy that preloads items above and below the visible range.
33+
*
34+
* When scrolling, this loads more items in the direction the user is scrolling to.
35+
*
36+
* The size of the loading window is kept small while scrolling. It grows when scrolling stops.
37+
*
38+
* This will retain already-loaded items that it wouldn't load otherwise.
39+
*/
40+
public class ScrollOptimizedLoadingStrategy(
41+
private val defaultPreloadItemCount: Int = DEFAULT_PRELOAD_ITEM_COUNT,
42+
private val scrollInProgressPreloadItemCount: Int = SCROLL_IN_PROGRESS_PRELOAD_ITEM_COUNT,
43+
private val primaryPreloadItemCount: Int = PRIMARY_PRELOAD_ITEM_COUNT,
44+
private val secondaryPreloadItemCount: Int = SECONDARY_PRELOAD_ITEM_COUNT,
45+
private val preloadItems: Boolean = true,
46+
) : LoadingStrategy {
47+
/**
48+
* Update this to trigger a programmatic scroll. This may be updated multiple times, including
49+
* when the previous scroll state is restored.
50+
*/
51+
public var programmaticScrollIndex: ScrollItemIndex by mutableStateOf(
52+
ScrollItemIndex(id = 0, index = 0, animated = false),
53+
)
54+
private set
55+
56+
/** Bounds of what the user is looking at. Everything else is placeholders! */
57+
public override var firstIndex: Int by mutableIntStateOf(0)
58+
private set
59+
public var lastIndex: Int by mutableIntStateOf(0)
60+
private set
61+
62+
private var firstIndexFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
63+
private var firstIndexFromPrevious2: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
64+
private var lastIndexFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
65+
66+
private var beginFromPrevious1: Int by mutableIntStateOf(DEFAULT_SCROLL_INDEX)
67+
private var endFromPrevious1: Int by mutableStateOf(DEFAULT_SCROLL_INDEX)
68+
69+
override fun scrollTo(firstIndex: Int) {
70+
require(firstIndex >= 0)
71+
72+
val delta = (lastIndex - this.firstIndex)
73+
this.firstIndex = firstIndex
74+
this.lastIndex = firstIndex + delta
75+
}
76+
77+
override fun onUserScroll(firstIndex: Int, lastIndex: Int) {
78+
this.firstIndex = firstIndex
79+
this.lastIndex = lastIndex
80+
}
81+
82+
public override fun loadRange(itemCount: Int): IntRange {
83+
// Ensure that the range includes `firstIndex` through `lastIndex`.
84+
var begin = firstIndex
85+
var end = lastIndex
86+
87+
val isScrollingDown = firstIndexFromPrevious1 != DEFAULT_SCROLL_INDEX && firstIndexFromPrevious1 < firstIndex
88+
val isScrollingUp = firstIndexFromPrevious1 != DEFAULT_SCROLL_INDEX && firstIndexFromPrevious1 > firstIndex
89+
val hasStoppedScrolling = firstIndexFromPrevious2 != DEFAULT_SCROLL_INDEX && firstIndex == firstIndexFromPrevious1
90+
val wasScrollingDown = firstIndexFromPrevious1 > firstIndexFromPrevious2
91+
val wasScrollingUp = firstIndexFromPrevious1 < firstIndexFromPrevious2
92+
93+
// Expand the range depending on scroll direction.
94+
when {
95+
// Ignore preloads.
96+
!preloadItems -> {
97+
// No-op
98+
}
99+
100+
isScrollingDown -> {
101+
begin -= scrollInProgressPreloadItemCount
102+
end += primaryPreloadItemCount
103+
}
104+
105+
isScrollingUp -> {
106+
begin -= primaryPreloadItemCount
107+
end += scrollInProgressPreloadItemCount
108+
}
109+
110+
hasStoppedScrolling && wasScrollingDown -> {
111+
begin -= secondaryPreloadItemCount
112+
end += primaryPreloadItemCount
113+
}
114+
115+
hasStoppedScrolling && wasScrollingUp -> {
116+
begin -= primaryPreloadItemCount
117+
end += secondaryPreloadItemCount
118+
}
119+
120+
// New.
121+
else -> {
122+
end += defaultPreloadItemCount
123+
}
124+
}
125+
126+
// On initial load, set lastIndex to the end of the loaded window.
127+
if (lastIndex == 0) {
128+
lastIndex = end
129+
}
130+
131+
// If we're contiguous with the previous visible window,
132+
// don't rush to remove things from the previous range.
133+
if (beginFromPrevious1 != DEFAULT_SCROLL_INDEX &&
134+
endFromPrevious1 != DEFAULT_SCROLL_INDEX
135+
) {
136+
// Case one: Contiguous scroll down
137+
if (begin in firstIndexFromPrevious1..lastIndexFromPrevious1) {
138+
begin = beginFromPrevious1
139+
}
140+
141+
// Case two: Contiguous scroll up
142+
if (end in firstIndexFromPrevious1..lastIndexFromPrevious1) {
143+
end = endFromPrevious1
144+
}
145+
}
146+
147+
begin = begin.coerceIn(0, itemCount)
148+
end = end.coerceIn(0, itemCount)
149+
150+
this.firstIndexFromPrevious2 = firstIndexFromPrevious1
151+
this.firstIndexFromPrevious1 = firstIndex
152+
this.lastIndexFromPrevious1 = lastIndex
153+
154+
this.beginFromPrevious1 = begin
155+
this.endFromPrevious1 = end
156+
157+
return begin until end
158+
}
159+
}

0 commit comments

Comments
 (0)