Skip to content

Commit a09b419

Browse files
Luna Weifacebook-github-bot
authored andcommitted
Move VirtualView shadow nodes (facebook#52058)
Summary: Pull Request resolved: facebook#52058 Changelog: [Internal] - Add experimental VirtualView shadow nodes to open source Differential Revision: D76471250
1 parent 331fab0 commit a09b419

File tree

16 files changed

+1364
-0
lines changed

16 files changed

+1364
-0
lines changed

packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9348,6 +9348,11 @@ export type {
93489348
PublicRootInstance,
93499349
PublicTextInstance,
93509350
} from \\"./Libraries/ReactPrivate/ReactNativePrivateInterface\\";
9351+
export {
9352+
default as unstable_VirtualView,
9353+
VirtualViewMode,
9354+
} from \\"./src/private/components/virtualview/VirtualView\\";
9355+
export type { ModeChangeEvent } from \\"./src/private/components/virtualview/VirtualView\\";
93519356
"
93529357
`;
93539358

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.virtualview
9+
10+
import android.content.Context
11+
import android.graphics.Rect
12+
import android.view.View
13+
import android.view.ViewGroup
14+
import android.view.ViewParent
15+
import androidx.annotation.VisibleForTesting
16+
import com.facebook.common.logging.FLog
17+
import com.facebook.react.R
18+
import com.facebook.react.common.build.ReactBuildConfig
19+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
20+
import com.facebook.react.uimanager.ReactRoot
21+
import com.facebook.react.views.scroll.ReactHorizontalScrollView
22+
import com.facebook.react.views.scroll.ReactScrollView
23+
import com.facebook.react.views.scroll.ReactScrollViewHelper
24+
import com.facebook.react.views.scroll.ScrollEventType
25+
import com.facebook.react.views.view.ReactViewGroup
26+
import com.facebook.systrace.Systrace
27+
28+
public class ReactVirtualView(context: Context) :
29+
ReactViewGroup(context),
30+
ReactScrollViewHelper.ScrollListener,
31+
ReactScrollViewHelper.LayoutChangeListener,
32+
View.OnLayoutChangeListener {
33+
34+
internal var mode: VirtualViewMode? = null
35+
internal var modeChangeEmitter: ModeChangeEmitter? = null
36+
internal var prerenderRatio: Double = ReactNativeFeatureFlags.virtualViewPrerenderRatio()
37+
internal val debugLogEnabled: Boolean = ReactNativeFeatureFlags.enableVirtualViewDebugFeatures()
38+
39+
private var parentScrollView: View? = null
40+
41+
// preallocate Rects to avoid allocation during layout
42+
private val lastRect: Rect = Rect()
43+
private val targetRect: Rect = Rect()
44+
private val thresholdRect: Rect = Rect()
45+
46+
/** Cumulative offset of parents' `left` values within the scroll view */
47+
private var offsetX: Int = 0
48+
/** Cumulative offset of parents' `top` values within the scroll view */
49+
private var offsetY: Int = 0
50+
private var offsetChanged: Boolean = false
51+
52+
internal val nativeId: String?
53+
get() = getTag(R.id.view_tag_native_id) as? String
54+
55+
internal fun recycleView() {
56+
ReactScrollViewHelper.removeScrollListener(this)
57+
ReactScrollViewHelper.removeLayoutChangeListener(this)
58+
cleanupLayoutListeners()
59+
mode = null
60+
modeChangeEmitter = null
61+
lastRect.setEmpty()
62+
parentScrollView = null
63+
offsetX = 0
64+
offsetY = 0
65+
offsetChanged = false
66+
}
67+
68+
override fun onAttachedToWindow() {
69+
super.onAttachedToWindow()
70+
doAttachedToWindow()
71+
}
72+
73+
@VisibleForTesting
74+
internal fun doAttachedToWindow() {
75+
parentScrollView =
76+
getParentScrollView()?.also {
77+
offsetChanged = true
78+
ReactScrollViewHelper.addScrollListener(this)
79+
ReactScrollViewHelper.addLayoutChangeListener(this)
80+
}
81+
debugLog("onAttachedToWindow")
82+
dispatchOnModeChangeIfNeeded(checkRectChange = false)
83+
}
84+
85+
override fun onDetachedFromWindow() {
86+
super.onDetachedFromWindow()
87+
ReactScrollViewHelper.removeScrollListener(this)
88+
ReactScrollViewHelper.removeLayoutChangeListener(this)
89+
cleanupLayoutListeners()
90+
}
91+
92+
/** From [View#onLayout] */
93+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
94+
super.onLayout(changed, left, top, right, bottom)
95+
if (changed) {
96+
offsetChanged = true
97+
dispatchOnModeChangeIfNeeded(checkRectChange = false)
98+
}
99+
}
100+
101+
override fun onLayoutChange(
102+
v: View?,
103+
left: Int,
104+
top: Int,
105+
right: Int,
106+
bottom: Int,
107+
oldLeft: Int,
108+
oldTop: Int,
109+
oldRight: Int,
110+
oldBottom: Int
111+
) {
112+
offsetChanged = offsetChanged || oldLeft != left || oldTop != top
113+
dispatchOnModeChangeIfNeeded(true)
114+
}
115+
116+
/**
117+
* From ReactScrollViewHelper#onScroll, triggered by [ReactScrollView] and
118+
* [ReactHorizontalScrollView]
119+
*/
120+
override fun onScroll(
121+
scrollView: ViewGroup?,
122+
scrollEventType: ScrollEventType?,
123+
xVelocity: Float,
124+
yVelocity: Float
125+
) {
126+
if (scrollView == parentScrollView) {
127+
dispatchOnModeChangeIfNeeded(checkRectChange = false)
128+
}
129+
}
130+
131+
/**
132+
* From ReactScrollViewHelper#onLayout, triggered by [ReactScrollView] and
133+
* [ReactHorizontalScrollView]
134+
*/
135+
override fun onLayout(scrollView: ViewGroup?) {
136+
if (scrollView == parentScrollView) {
137+
dispatchOnModeChangeIfNeeded(checkRectChange = false)
138+
}
139+
}
140+
141+
/**
142+
* From ReactScrollViewHelper#onLayoutChange, triggered by [ReactScrollView] and
143+
* [ReactHorizontalScrollView]
144+
*/
145+
override fun onLayoutChange(scrollView: ViewGroup) {
146+
if (scrollView == parentScrollView) {
147+
offsetChanged = true
148+
dispatchOnModeChangeIfNeeded(false)
149+
}
150+
}
151+
152+
private fun dispatchOnModeChangeIfNeeded(checkRectChange: Boolean) {
153+
modeChangeEmitter ?: return
154+
val scrollView = parentScrollView ?: return
155+
156+
if (offsetChanged) {
157+
updateParentOffset()
158+
}
159+
targetRect.set(
160+
left + offsetX,
161+
top + offsetY,
162+
right + offsetX,
163+
bottom + offsetY,
164+
)
165+
scrollView.getDrawingRect(thresholdRect)
166+
167+
// TODO: Validate whether this is still the case and whether these checks are still needed.
168+
// updateRects will initially get called before the targetRect has any dimensions set, so if
169+
// it's both zero width and height, we need to skip dispatching an incorrect mode change.
170+
// The correct mode change will be dispatched later. We can't use targetRect.isEmpty because it
171+
// will return true if either there's a zero width or height, but that case is valid.
172+
if ((targetRect.width() == 0 && targetRect.height() == 0) || thresholdRect.isEmpty) {
173+
debugLog("dispatchOnModeChangeIfNeeded") {
174+
"empty rects target=${targetRect.toShortString()} threshold=${thresholdRect.toShortString()}"
175+
}
176+
return
177+
}
178+
if (checkRectChange) {
179+
if (!lastRect.isEmpty && lastRect == targetRect) {
180+
debugLog("dispatchOnModeChangeIfNeeded") { "no rect change" }
181+
return
182+
}
183+
lastRect.set(targetRect)
184+
}
185+
186+
val newMode: VirtualViewMode
187+
if (rectsOverlap(targetRect, thresholdRect)) {
188+
newMode = VirtualViewMode.Visible
189+
} else {
190+
var prerender = false
191+
if (prerenderRatio > 0.0) {
192+
thresholdRect.inset(
193+
(-thresholdRect.width() * prerenderRatio).toInt(),
194+
(-thresholdRect.height() * prerenderRatio).toInt())
195+
prerender = rectsOverlap(targetRect, thresholdRect)
196+
}
197+
if (prerender) {
198+
newMode = VirtualViewMode.Prerender
199+
} else {
200+
newMode = VirtualViewMode.Hidden
201+
thresholdRect.setEmpty()
202+
}
203+
}
204+
debugLog("dispatchOnModeChangeIfNeeded") {
205+
"mode=$mode target=${targetRect.toShortString()} threshold=${thresholdRect.toShortString()}"
206+
}
207+
208+
if (newMode == mode) {
209+
return
210+
}
211+
val oldMode = mode
212+
mode = newMode
213+
maybeEmitModeChanges(oldMode, newMode)
214+
}
215+
216+
/**
217+
* Checks whether one Rect overlaps with another Rect.
218+
*
219+
* This is different from [Rect.intersects] because a Rect representing a line or a point is
220+
* considered to overlap with another Rect if the line or point is within the rect bounds.
221+
* However, two Rects are not considered to overlap if they only share a boundary.
222+
*/
223+
private fun rectsOverlap(rect1: Rect, rect2: Rect): Boolean {
224+
if (rect1.top >= rect2.bottom || rect2.top >= rect1.bottom) {
225+
// No overlap on the y-axis.
226+
return false
227+
}
228+
if (rect1.left >= rect2.right || rect2.left >= rect1.right) {
229+
// No overlap on the x-axis.
230+
return false
231+
}
232+
return true
233+
}
234+
235+
/**
236+
* Evaluate the mode change and emit 0, 1, or 2 mode change events depending on the type of
237+
* transition, [noActivity], and [asyncPrerender]
238+
*/
239+
private fun maybeEmitModeChanges(
240+
oldMode: VirtualViewMode?,
241+
newMode: VirtualViewMode,
242+
) {
243+
debugLog("Mode change") { "$oldMode->$newMode" }
244+
Systrace.beginSection(
245+
Systrace.TRACE_TAG_REACT,
246+
"VirtualView::mode change $oldMode -> $newMode, nativeID=$nativeId")
247+
when (newMode) {
248+
VirtualViewMode.Visible -> {
249+
emitSyncModeChange(VirtualViewMode.Visible)
250+
}
251+
VirtualViewMode.Prerender -> {
252+
if (oldMode != VirtualViewMode.Visible) {
253+
emitAsyncModeChange(VirtualViewMode.Prerender)
254+
}
255+
}
256+
VirtualViewMode.Hidden -> {
257+
emitAsyncModeChange(VirtualViewMode.Hidden)
258+
}
259+
}
260+
Systrace.endSection(Systrace.TRACE_TAG_REACT)
261+
}
262+
263+
private fun emitAsyncModeChange(mode: VirtualViewMode) {
264+
modeChangeEmitter?.emitModeChange(mode, targetRect, thresholdRect, synchronous = false)
265+
}
266+
267+
private fun emitSyncModeChange(mode: VirtualViewMode) {
268+
modeChangeEmitter?.emitModeChange(mode, targetRect, thresholdRect, synchronous = true)
269+
}
270+
271+
private fun getParentScrollView(): ViewGroup? = traverseParentStack(true)
272+
273+
private fun cleanupLayoutListeners() {
274+
traverseParentStack(false)
275+
}
276+
277+
/**
278+
* Navigate up through the view hierarchy until we reach the scroll view or root view, and
279+
* maintain layout change listeners on any intermediate views.
280+
*
281+
* @param addListeners Whether to call [View.addOnLayoutChangeListener] to views in the hierarchy.
282+
* If false, existing listeners will be removed.
283+
*/
284+
private fun traverseParentStack(addListeners: Boolean): ViewGroup? {
285+
var parent: ViewParent? = parent
286+
while (parent != null) {
287+
if (parent is ReactScrollView) {
288+
return parent
289+
}
290+
if (parent is ReactHorizontalScrollView) {
291+
return parent
292+
}
293+
if (parent is ReactRoot) {
294+
// don't look past the root - it could traverse into a separate hierarchy
295+
return null
296+
}
297+
if (parent is View) {
298+
// always remove, to ensure listeners aren't added more than once
299+
parent.removeOnLayoutChangeListener(this)
300+
if (addListeners) {
301+
parent.addOnLayoutChangeListener(this)
302+
}
303+
}
304+
parent = parent.parent
305+
}
306+
return null
307+
}
308+
309+
/** Navigate up the view hierarchy to record parents' offsets within the scroll view */
310+
private fun updateParentOffset() {
311+
val scrollView = parentScrollView ?: return
312+
offsetX = 0
313+
offsetY = 0
314+
offsetChanged = false
315+
var parent: ViewParent? = parent
316+
while (parent != null && parent != scrollView) {
317+
if (parent is View) {
318+
offsetX += parent.left
319+
offsetY += parent.top
320+
}
321+
parent = parent.parent
322+
}
323+
}
324+
325+
internal inline fun debugLog(subtag: String, block: () -> String = { "" }) {
326+
if (debugLogEnabled) {
327+
if (IS_DEBUG_BUILD) {
328+
FLog.d("$DEBUG_TAG:$subtag", "${block()} [$id][$nativeId]")
329+
} else {
330+
// production builds only log warnings/errors
331+
FLog.w("$DEBUG_TAG:$subtag", "${block()} [$id][$nativeId]")
332+
}
333+
}
334+
}
335+
}
336+
337+
internal fun interface ModeChangeEmitter {
338+
fun emitModeChange(
339+
mode: VirtualViewMode,
340+
targetRect: Rect,
341+
thresholdRect: Rect,
342+
synchronous: Boolean,
343+
)
344+
}
345+
346+
private const val DEBUG_TAG: String = "ReactVirtualView"
347+
348+
private val IS_DEBUG_BUILD =
349+
ReactBuildConfig.DEBUG || ReactBuildConfig.IS_INTERNAL_BUILD || ReactBuildConfig.ENABLE_PERFETTO

0 commit comments

Comments
 (0)