Skip to content

Commit cde8f63

Browse files
Andrew Wangfacebook-github-bot
authored andcommitted
Introduce CollectionPreparationManager
Summary: Decouple sliding window preparation from RecyclerBinder. Reviewed By: adityasharat Differential Revision: D76723972 fbshipit-source-id: cc29fdb358be0cadde9120632d24c09b3aad89b9
1 parent 7e10fb5 commit cde8f63

File tree

4 files changed

+278
-12
lines changed

4 files changed

+278
-12
lines changed

litho-widget/src/main/java/com/facebook/litho/widget/CollectionItem.kt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ abstract class CollectionItem<V : View>(
3737
@Volatile var size: Size? = null,
3838
) {
3939

40+
/**
41+
* Measures the item according to the given size constraints.
42+
*
43+
* @param sizeConstraints The constraints that define the minimum and maximum dimensions for
44+
* measurement
45+
* @param result Optional array to store the measured width and height values (index 0 for width,
46+
* index 1 for height)
47+
*/
48+
abstract fun measure(sizeConstraints: SizeConstraints, result: IntArray?)
49+
4050
/**
4151
* To prepare the item for rendering in async way. This is going to be used in the range
4252
* preparation.
@@ -50,13 +60,8 @@ abstract class CollectionItem<V : View>(
5060
*
5161
* @param sizeConstraints The size constraints of the item.
5262
* @param result The output of the rendering.
53-
* @param shouldCommit Whether the result should be committed.
5463
*/
55-
abstract fun prepareSync(
56-
sizeConstraints: SizeConstraints,
57-
result: IntArray?,
58-
shouldCommit: Boolean = true
59-
)
64+
abstract fun prepareSync(sizeConstraints: SizeConstraints, result: IntArray?)
6065

6166
/** To bind properties for the underline view. */
6267
abstract fun onBindView(view: V)

litho-widget/src/main/java/com/facebook/litho/widget/CollectionLayoutManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ object CollectionLayoutManager {
187187
while (filler.wantsMore() && index < items.size) {
188188
val item = items[index]
189189
val output = IntArray(2)
190-
item.prepareSync(
190+
item.measure(
191191
getChildSizeConstraints(
192192
layoutInfo = layoutInfo,
193193
collectionSizeConstraints = collectionSizeConstraints,
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.facebook.litho.widget
18+
19+
import androidx.annotation.UiThread
20+
import androidx.core.view.isGone
21+
import androidx.recyclerview.widget.LinearLayoutManager
22+
import androidx.recyclerview.widget.RecyclerView
23+
import com.facebook.litho.ThreadUtils
24+
import com.facebook.litho.annotations.ExperimentalLithoApi
25+
import com.facebook.litho.widget.ViewportInfo.ViewportChanged
26+
import com.facebook.rendercore.Size
27+
import com.facebook.rendercore.SizeConstraints
28+
import kotlin.math.max
29+
30+
/**
31+
* This class is responsible for preparing the items in the collection. It will prepare the items
32+
* that are in the viewport and the items that are in the range of the viewport.
33+
*/
34+
@ExperimentalLithoApi
35+
class CollectionPreparationManager(private val layoutInfo: LayoutInfo) {
36+
37+
/**
38+
* The estimated item count in the viewport, which is used to determine the number of items that
39+
* should be rendered.
40+
*/
41+
private var estimatedItemsInViewPort: Int = UNSET
42+
private var mountedView: RecyclerView? = null
43+
private var collectionSizeProvider: (() -> Size?)? = null
44+
private var rangeRatio: Float? = null
45+
private var onEnterRange: ((Int) -> Unit)? = null
46+
private var onExitRange: ((Int) -> Unit)? = null
47+
private var postUpdateViewportAttempts = 0
48+
49+
private val viewportManager: ViewportManager =
50+
ViewportManager(
51+
currentFirstVisiblePosition = RecyclerView.NO_POSITION,
52+
currentLastVisiblePosition = RecyclerView.NO_POSITION,
53+
layoutInfo = layoutInfo)
54+
private val updateViewportRunnable =
55+
object : Runnable {
56+
override fun run() {
57+
val mountedView = mountedView
58+
if (mountedView == null || !mountedView.hasPendingAdapterUpdates()) {
59+
if (viewportManager.shouldUpdate()) {
60+
viewportManager.onViewportChanged(ViewportInfo.State.DATA_CHANGES)
61+
}
62+
postUpdateViewportAttempts = 0
63+
return
64+
}
65+
66+
// If the view gets detached, we might still have pending updates.
67+
// If the view's visibility is GONE, layout won't happen until it becomes visible. We
68+
// have to exit here, otherwise we keep posting this runnable to the next frame until it
69+
// becomes visible.
70+
if (!mountedView.isAttachedToWindow || mountedView.isGone) {
71+
postUpdateViewportAttempts = 0
72+
return
73+
}
74+
75+
if (postUpdateViewportAttempts >= POST_UPDATE_VIEWPORT_AND_COMPUTE_RANGE_MAX_ATTEMPTS) {
76+
postUpdateViewportAttempts = 0
77+
if (viewportManager.shouldUpdate()) {
78+
viewportManager.onViewportChanged(ViewportInfo.State.DATA_CHANGES)
79+
}
80+
return
81+
}
82+
83+
// If we have pending updates, wait until the sync operations are finished and try again
84+
// in the next frame.
85+
postUpdateViewportAttempts++
86+
mountedView.postOnAnimation(this)
87+
}
88+
}
89+
private val viewportChangedListener: ViewportChanged =
90+
object : ViewportChanged {
91+
override fun viewportChanged(
92+
firstVisibleIndex: Int,
93+
lastVisibleIndex: Int,
94+
firstFullyVisibleIndex: Int,
95+
lastFullyVisibleIndex: Int,
96+
state: Int
97+
) {
98+
viewportManager.resetShouldUpdate()
99+
maybePostUpdateViewportAndComputeRange(firstVisibleIndex, lastVisibleIndex)
100+
}
101+
}
102+
private val rangeTraverser: RecyclerRangeTraverser
103+
private val isBound
104+
get() =
105+
rangeRatio != null &&
106+
collectionSizeProvider != null &&
107+
onEnterRange != null &&
108+
onExitRange != null
109+
110+
init {
111+
val layoutManager = layoutInfo.getLayoutManager()
112+
val stackFromEnd =
113+
if (layoutManager is LinearLayoutManager) {
114+
layoutManager.stackFromEnd
115+
} else {
116+
false
117+
}
118+
rangeTraverser =
119+
if (stackFromEnd) {
120+
RecyclerRangeTraverser.BACKWARD_TRAVERSER
121+
} else {
122+
RecyclerRangeTraverser.FORWARD_TRAVERSER
123+
}
124+
}
125+
126+
@UiThread
127+
fun bind(
128+
view: RecyclerView,
129+
rangeRatio: Float,
130+
collectionSizeProvider: (() -> Size?),
131+
onEnterRange: (Int) -> Unit,
132+
onExitRange: (Int) -> Unit
133+
) {
134+
ThreadUtils.assertMainThread()
135+
this.mountedView = view
136+
this.rangeRatio = rangeRatio
137+
this.collectionSizeProvider = collectionSizeProvider
138+
this.onEnterRange = onEnterRange
139+
this.onExitRange = onExitRange
140+
141+
view.addOnScrollListener(viewportManager.scrollListener)
142+
viewportManager.addViewportChangedListener(viewportChangedListener)
143+
}
144+
145+
@UiThread
146+
fun unbind(view: RecyclerView) {
147+
ThreadUtils.assertMainThread()
148+
view.removeOnScrollListener(viewportManager.scrollListener)
149+
viewportManager.removeViewportChangedListener(viewportChangedListener)
150+
mountedView = null
151+
collectionSizeProvider = null
152+
rangeRatio = null
153+
onEnterRange = null
154+
onExitRange = null
155+
postUpdateViewportAttempts = 0
156+
}
157+
158+
fun addViewportChangedListener(viewportChangedListener: ViewportChanged?) {
159+
viewportManager.addViewportChangedListener(viewportChangedListener)
160+
}
161+
162+
/**
163+
* Attempts to update the viewport and compute the range of items that should be prepared. This
164+
* method checks if the viewport needs updating and posts a runnable to handle the update. It also
165+
* triggers computation of which items should enter or exit the preparation range.
166+
*
167+
* @param firstVisibleIndex The index of the first visible item in the viewport
168+
* @param lastVisibleIndex The index of the last visible item in the viewport
169+
*/
170+
@UiThread
171+
fun maybePostUpdateViewportAndComputeRange(
172+
firstVisibleIndex: Int = layoutInfo.findFirstVisibleItemPosition(),
173+
lastVisibleIndex: Int = layoutInfo.findLastVisibleItemPosition(),
174+
) {
175+
mountedView?.let { recyclerView ->
176+
if (viewportManager.shouldUpdate()) {
177+
recyclerView.removeCallbacks(updateViewportRunnable)
178+
recyclerView.postOnAnimation(updateViewportRunnable)
179+
}
180+
}
181+
computeRange(firstVisibleIndex, lastVisibleIndex)
182+
}
183+
184+
/**
185+
* Computes the range of items that should be prepared for rendering based on the currently
186+
* visible items. This method determines which items should enter or exit the preparation range
187+
* based on their position relative to the visible viewport.
188+
*
189+
* @param firstVisibleIndex The index of the first visible item in the viewport
190+
* @param lastVisibleIndex The index of the last visible item in the viewport
191+
* @param traverser The traverser that defines the order in which items are processed
192+
*/
193+
@UiThread
194+
private fun computeRange(
195+
firstVisibleIndex: Int,
196+
lastVisibleIndex: Int,
197+
traverser: RecyclerRangeTraverser = rangeTraverser,
198+
) {
199+
if (!isBound) return
200+
201+
val collectionSize: Size? = requireNotNull(collectionSizeProvider).invoke()
202+
if (collectionSize == null || estimatedItemsInViewPort == UNSET) {
203+
return
204+
}
205+
206+
val firstVisibleToUse: Int = max(firstVisibleIndex, 0)
207+
val lastVisibleToUse: Int = max(lastVisibleIndex, 0)
208+
val rangeSize: Int = max(estimatedItemsInViewPort, lastVisibleToUse - firstVisibleToUse)
209+
val rangeStart: Int = firstVisibleToUse - (rangeSize * requireNotNull(rangeRatio)).toInt()
210+
val rangeEnd: Int =
211+
firstVisibleToUse + rangeSize + (rangeSize * requireNotNull(rangeRatio)).toInt()
212+
val processor =
213+
object : RecyclerRangeTraverser.Processor {
214+
override fun process(index: Int): Boolean = computeAt(index, rangeStart, rangeEnd)
215+
}
216+
traverser.traverse(0, layoutInfo.getItemCount(), firstVisibleToUse, lastVisibleToUse, processor)
217+
}
218+
219+
private fun computeAt(index: Int, rangeStart: Int, rangeEnd: Int): Boolean {
220+
if (!isBound) return false
221+
222+
if (index >= rangeStart && index <= rangeEnd) {
223+
requireNotNull(onEnterRange).invoke(index)
224+
} else {
225+
requireNotNull(onExitRange).invoke(index)
226+
}
227+
return true
228+
}
229+
230+
/**
231+
* Estimates the number of items that can fit in the viewport based on measuring a sample item.
232+
* This calculation is performed only once when estimatedItemsInViewPort is unset and helps
233+
* determine how many items should be prepared for rendering to optimize performance.
234+
*
235+
* @param item A sample CollectionItem used to estimate the size of items in the collection
236+
* @param sizeConstraintsProvider A function that provides size constraints for measuring the
237+
* given item
238+
*/
239+
fun estimateItemsInViewPort(
240+
item: CollectionItem<*>,
241+
sizeConstraintsProvider: (CollectionItem<*>) -> SizeConstraints,
242+
) {
243+
if (!isBound) return
244+
245+
val collectionSize: Size? = requireNotNull(collectionSizeProvider).invoke()
246+
if (estimatedItemsInViewPort == UNSET && collectionSize != null) {
247+
val output = IntArray(2)
248+
item.measure(sizeConstraintsProvider(item), output)
249+
estimatedItemsInViewPort =
250+
max(
251+
layoutInfo.approximateRangeSize(
252+
output[0], output[1], collectionSize.width, collectionSize.height),
253+
1)
254+
}
255+
}
256+
257+
companion object {
258+
private const val UNSET: Int = -1
259+
private const val POST_UPDATE_VIEWPORT_AND_COMPUTE_RANGE_MAX_ATTEMPTS: Int = 3
260+
}
261+
}

litho-widget/src/main/java/com/facebook/litho/widget/LithoCollectionItem.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ class LithoCollectionItem(
3939
treePropContainer = componentContext.treePropContainerCopy,
4040
visibilityController = componentContext.lithoVisibilityEventsController)
4141

42+
override fun measure(sizeConstraints: SizeConstraints, result: IntArray?) {
43+
prepareSync(sizeConstraints, result)
44+
}
45+
4246
override fun prepare(sizeConstraints: SizeConstraints) {
4347
renderer.render(renderInfo.component, sizeConstraints)
4448
}
4549

46-
override fun prepareSync(
47-
sizeConstraints: SizeConstraints,
48-
result: IntArray?,
49-
shouldCommit: Boolean
50-
) {
50+
override fun prepareSync(sizeConstraints: SizeConstraints, result: IntArray?) {
5151
val layoutState = renderer.renderSync(renderInfo.component, sizeConstraints)
5252
result?.let {
5353
result[0] = layoutState.width

0 commit comments

Comments
 (0)