1 /*
<lambda>null2  * Copyright 2023 The Android Open Source Project
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 androidx.compose.ui.viewinterop
18 
19 import android.content.Context
20 import android.util.AttributeSet
21 import android.view.LayoutInflater
22 import android.view.MotionEvent
23 import android.view.View
24 import android.view.ViewGroup
25 import android.widget.TextView
26 import androidx.activity.ComponentActivity
27 import androidx.annotation.LayoutRes
28 import androidx.compose.foundation.layout.Box
29 import androidx.compose.foundation.layout.fillMaxSize
30 import androidx.compose.foundation.layout.fillMaxWidth
31 import androidx.compose.foundation.layout.height
32 import androidx.compose.foundation.lazy.LazyColumn
33 import androidx.compose.foundation.lazy.LazyListState
34 import androidx.compose.material.Text
35 import androidx.compose.runtime.Composable
36 import androidx.compose.ui.ExperimentalComposeUiApi
37 import androidx.compose.ui.Modifier
38 import androidx.compose.ui.background
39 import androidx.compose.ui.graphics.Color
40 import androidx.compose.ui.input.pointer.util.VelocityTrackerAddPointsFix
41 import androidx.compose.ui.platform.ComposeView
42 import androidx.compose.ui.test.junit4.createAndroidComposeRule
43 import androidx.compose.ui.tests.R
44 import androidx.compose.ui.unit.dp
45 import androidx.lifecycle.Lifecycle
46 import androidx.recyclerview.widget.LinearLayoutManager
47 import androidx.recyclerview.widget.RecyclerView
48 import androidx.recyclerview.widget.RecyclerView.OnScrollListener
49 import androidx.recyclerview.widget.RecyclerView.ViewHolder
50 import androidx.test.core.app.ActivityScenario
51 import androidx.test.ext.junit.runners.AndroidJUnit4
52 import androidx.test.filters.MediumTest
53 import kotlin.coroutines.resume
54 import kotlin.math.absoluteValue
55 import kotlin.test.Ignore
56 import kotlin.test.assertTrue
57 import kotlinx.coroutines.Dispatchers
58 import kotlinx.coroutines.runBlocking
59 import kotlinx.coroutines.suspendCancellableCoroutine
60 import kotlinx.coroutines.withContext
61 import org.junit.Before
62 import org.junit.Rule
63 import org.junit.Test
64 import org.junit.runner.RunWith
65 
66 @MediumTest
67 @RunWith(AndroidJUnit4::class)
68 class VelocityTrackingListParityTest {
69 
70     @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
71 
72     private var layoutManager: LinearLayoutManager? = null
73     private var latestComposeVelocity = 0f
74     private var latestRVState = -1
75 
76     @OptIn(ExperimentalComposeUiApi::class)
77     @Before
78     fun setUp() {
79         layoutManager = null
80         latestComposeVelocity = 0f
81         VelocityTrackerAddPointsFix = true
82     }
83 
84     @Test
85     fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_smallVeryFast() = runBlocking {
86         val state = LazyListState()
87 
88         // starting with view
89         createActivity(state)
90         checkVisibility(composeView(), View.GONE)
91         checkVisibility(recyclerView(), View.VISIBLE)
92 
93         smallGestureVeryFast(R.id.view_list)
94         rule.waitForIdle()
95         recyclerView().awaitScrollIdle()
96 
97         val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
98 
99         // switch visibilities
100         rule.runOnUiThread {
101             rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
102             rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
103         }
104 
105         rule.waitForIdle()
106 
107         checkVisibility(composeView(), View.VISIBLE)
108         checkVisibility(recyclerView(), View.GONE)
109 
110         assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
111 
112         // Inject the same events in compose view
113         rule.runOnUiThread {
114             for (event in recyclerView().motionEvents) {
115                 composeView().dispatchTouchEvent(event)
116             }
117         }
118 
119         rule.runOnIdle {
120             val currentTopInCompose = state.firstVisibleItemIndex
121             val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
122             val message =
123                 "Compose=$currentTopInCompose View=$childAtTheTopOfView " + "Difference was=$diff"
124             assertTrue(message) { diff <= ItemDifferenceThreshold }
125         }
126     }
127 
128     @Test
129     fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_smallFast() = runBlocking {
130         val state = LazyListState()
131 
132         // starting with view
133         createActivity(state)
134         checkVisibility(composeView(), View.GONE)
135         checkVisibility(recyclerView(), View.VISIBLE)
136 
137         smallGestureFast(R.id.view_list)
138         rule.waitForIdle()
139         recyclerView().awaitScrollIdle()
140 
141         val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
142 
143         // switch visibilities
144         rule.runOnUiThread {
145             rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
146             rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
147         }
148 
149         checkVisibility(composeView(), View.VISIBLE)
150         checkVisibility(recyclerView(), View.GONE)
151 
152         assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
153 
154         // Inject the same events in compose view
155         rule.runOnUiThread {
156             for (event in recyclerView().motionEvents) {
157                 composeView().dispatchTouchEvent(event)
158             }
159         }
160 
161         rule.runOnIdle {
162             val currentTopInCompose = state.firstVisibleItemIndex
163             val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
164             val message =
165                 "Compose=$currentTopInCompose View=$childAtTheTopOfView " + "Difference was=$diff"
166             assertTrue(message) { diff <= ItemDifferenceThreshold }
167         }
168     }
169 
170     @Test
171     fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_smallSlow() = runBlocking {
172         val state = LazyListState()
173 
174         // starting with view
175         createActivity(state)
176         checkVisibility(composeView(), View.GONE)
177         checkVisibility(recyclerView(), View.VISIBLE)
178 
179         smallGestureSlow(R.id.view_list)
180         rule.waitForIdle()
181         recyclerView().awaitScrollIdle()
182 
183         val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
184 
185         // switch visibilities
186         rule.runOnUiThread {
187             rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
188             rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
189         }
190 
191         checkVisibility(composeView(), View.VISIBLE)
192         checkVisibility(recyclerView(), View.GONE)
193 
194         assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
195 
196         // Inject the same events in compose view
197         rule.runOnUiThread {
198             for (event in recyclerView().motionEvents) {
199                 composeView().dispatchTouchEvent(event)
200             }
201         }
202 
203         rule.runOnIdle {
204             val currentTopInCompose = state.firstVisibleItemIndex
205             val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
206             val message =
207                 "Compose=$currentTopInCompose View=$childAtTheTopOfView " + "Difference was=$diff"
208             assertTrue(message) { diff <= ItemDifferenceThreshold }
209         }
210     }
211 
212     @Ignore // b/373631123
213     @Test
214     fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_largeFast() = runBlocking {
215         val state = LazyListState()
216 
217         // starting with view
218         createActivity(state)
219         checkVisibility(composeView(), View.GONE)
220         checkVisibility(recyclerView(), View.VISIBLE)
221 
222         largeGestureFast(R.id.view_list)
223         rule.waitForIdle()
224         recyclerView().awaitScrollIdle()
225 
226         val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
227 
228         // switch visibilities
229         rule.runOnUiThread {
230             rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
231             rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
232         }
233 
234         checkVisibility(composeView(), View.VISIBLE)
235         checkVisibility(recyclerView(), View.GONE)
236 
237         assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
238 
239         // Inject the same events in compose view
240         rule.runOnUiThread {
241             for (event in recyclerView().motionEvents) {
242                 composeView().dispatchTouchEvent(event)
243             }
244         }
245 
246         rule.runOnIdle {
247             val currentTopInCompose = state.firstVisibleItemIndex
248             val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
249             val message =
250                 "Compose=$currentTopInCompose View=$childAtTheTopOfView " + "Difference was=$diff"
251             assertTrue(message) { diff <= ItemDifferenceThreshold }
252         }
253     }
254 
255     @Ignore // b/371570954
256     @Test
257     fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_largeVeryFast() = runBlocking {
258         val state = LazyListState()
259 
260         // starting with view
261         createActivity(state)
262         checkVisibility(composeView(), View.GONE)
263         checkVisibility(recyclerView(), View.VISIBLE)
264 
265         largeGestureVeryFast(R.id.view_list)
266         rule.waitForIdle()
267         recyclerView().awaitScrollIdle()
268 
269         val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
270 
271         // switch visibilities
272         rule.runOnUiThread {
273             rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
274             rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
275         }
276 
277         checkVisibility(composeView(), View.VISIBLE)
278         checkVisibility(recyclerView(), View.GONE)
279 
280         assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
281 
282         // Inject the same events in compose view
283         rule.runOnUiThread {
284             for (event in recyclerView().motionEvents) {
285                 composeView().dispatchTouchEvent(event)
286             }
287         }
288 
289         rule.runOnIdle {
290             val currentTopInCompose = state.firstVisibleItemIndex
291             val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
292             val message =
293                 "Compose=$currentTopInCompose View=$childAtTheTopOfView " + "Difference was=$diff"
294             assertTrue(message) { diff <= ItemDifferenceThreshold }
295         }
296     }
297 
298     @Test
299     fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_orthogonal() = runBlocking {
300         val state = LazyListState()
301 
302         // starting with view
303         createActivity(state)
304         checkVisibility(composeView(), View.GONE)
305         checkVisibility(recyclerView(), View.VISIBLE)
306 
307         orthogonalGesture(R.id.view_list)
308         rule.waitForIdle()
309         recyclerView().awaitScrollIdle()
310 
311         val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
312 
313         // switch visibilities
314         rule.runOnUiThread {
315             rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
316             rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
317         }
318 
319         rule.waitForIdle()
320 
321         checkVisibility(composeView(), View.VISIBLE)
322         checkVisibility(recyclerView(), View.GONE)
323 
324         assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
325 
326         // Inject the same events in compose view
327         rule.runOnUiThread {
328             for (event in recyclerView().motionEvents) {
329                 composeView().dispatchTouchEvent(event)
330             }
331         }
332 
333         rule.runOnIdle {
334             val currentTopInCompose = state.firstVisibleItemIndex
335             val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
336             val message =
337                 "Compose=$currentTopInCompose View=$childAtTheTopOfView " + "Difference was=$diff"
338             assertTrue(message) { diff <= ItemDifferenceThreshold }
339         }
340     }
341 
342     @Test
343     fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_regularGestureOne() = runBlocking {
344         val state = LazyListState()
345 
346         // starting with view
347         createActivity(state)
348         checkVisibility(composeView(), View.GONE)
349         checkVisibility(recyclerView(), View.VISIBLE)
350 
351         regularGestureOne(R.id.view_list)
352         rule.waitForIdle()
353         recyclerView().awaitScrollIdle()
354 
355         val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
356 
357         // switch visibilities
358         rule.runOnUiThread {
359             rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
360             rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
361         }
362 
363         rule.waitForIdle()
364 
365         checkVisibility(composeView(), View.VISIBLE)
366         checkVisibility(recyclerView(), View.GONE)
367 
368         assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
369 
370         // Inject the same events in compose view
371         rule.runOnUiThread {
372             for (event in recyclerView().motionEvents) {
373                 composeView().dispatchTouchEvent(event)
374             }
375         }
376 
377         rule.runOnIdle {
378             val currentTopInCompose = state.firstVisibleItemIndex
379             val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
380             val message =
381                 "Compose=$currentTopInCompose View=$childAtTheTopOfView " + "Difference was=$diff"
382             assertTrue(message) { diff <= ItemDifferenceThreshold }
383         }
384     }
385 
386     @Test
387     fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_regularGestureTwo() = runBlocking {
388         val state = LazyListState()
389 
390         // starting with view
391         createActivity(state)
392         checkVisibility(composeView(), View.GONE)
393         checkVisibility(recyclerView(), View.VISIBLE)
394 
395         regularGestureTwo(R.id.view_list)
396         rule.waitForIdle()
397         recyclerView().awaitScrollIdle()
398 
399         val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
400 
401         // switch visibilities
402         rule.runOnUiThread {
403             rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
404             rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
405         }
406 
407         checkVisibility(composeView(), View.VISIBLE)
408         checkVisibility(recyclerView(), View.GONE)
409 
410         assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
411 
412         // Inject the same events in compose view
413         rule.runOnUiThread {
414             for (event in recyclerView().motionEvents) {
415                 composeView().dispatchTouchEvent(event)
416             }
417         }
418 
419         rule.runOnIdle {
420             val currentTopInCompose = state.firstVisibleItemIndex
421             val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
422             val message =
423                 "Compose=$currentTopInCompose View=$childAtTheTopOfView " + "Difference was=$diff"
424             assertTrue(message) { diff <= ItemDifferenceThreshold }
425         }
426     }
427 
428     private fun createActivity(state: LazyListState) {
429         rule.activityRule.scenario.createActivityWithComposeContent(
430             R.layout.android_compose_lists_fling
431         ) {
432             TestComposeList(state)
433         }
434     }
435 
436     private fun ActivityScenario<*>.createActivityWithComposeContent(
437         @LayoutRes layout: Int,
438         content: @Composable () -> Unit,
439     ) {
440         onActivity { activity ->
441             activity.setTheme(R.style.Theme_MaterialComponents_Light)
442             activity.setContentView(layout)
443             with(activity.findViewById<ComposeView>(R.id.compose_view)) { setContent(content) }
444 
445             activity.findViewById<RecyclerView>(R.id.view_list)?.let {
446                 it.adapter = ListAdapter()
447                 it.layoutManager =
448                     LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false).also {
449                         this@VelocityTrackingListParityTest.layoutManager = it
450                     }
451                 it.addOnScrollListener(
452                     object : OnScrollListener() {
453                         override fun onScrollStateChanged(
454                             recyclerView: RecyclerView,
455                             newState: Int
456                         ) {
457                             latestRVState = newState
458                         }
459                     }
460                 )
461             }
462 
463             activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.GONE
464         }
465         moveToState(Lifecycle.State.RESUMED)
466     }
467 
468     private fun recyclerView(): RecyclerViewWithMotionEvents =
469         rule.activity.findViewById(R.id.view_list)
470 
471     private fun composeView(): ComposeView = rule.activity.findViewById(R.id.compose_view)
472 
473     private fun checkVisibility(view: View, visibility: Int) {
474         assertTrue { view.visibility == visibility }
475     }
476 }
477 
478 @Composable
TestComposeListnull479 fun TestComposeList(state: LazyListState) {
480     LazyColumn(Modifier.fillMaxSize(), state = state) {
481         items(2000) {
482             Box(modifier = Modifier.fillMaxWidth().height(64.dp).background(Color.Black)) {
483                 Text(text = it.toString(), color = Color.White)
484             }
485         }
486     }
487 }
488 
489 private class ListAdapter : RecyclerView.Adapter<ListViewHolder>() {
<lambda>null490     val items = (0 until 2000).map { it.toString() }
491 
onCreateViewHoldernull492     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
493         return ListViewHolder(
494             LayoutInflater.from(parent.context)
495                 .inflate(R.layout.android_compose_lists_fling_item, parent, false)
496         )
497     }
498 
onBindViewHoldernull499     override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
500         holder.bind(items[position])
501     }
502 
getItemCountnull503     override fun getItemCount(): Int = items.size
504 }
505 
506 private class ListViewHolder(val view: View) : ViewHolder(view) {
507     fun bind(position: String) {
508         view.findViewById<TextView>(R.id.textView).text = position
509     }
510 }
511 
512 class RecyclerViewWithMotionEvents(context: Context, attributeSet: AttributeSet) :
513     RecyclerView(context, attributeSet) {
514 
515     val motionEvents = mutableListOf<MotionEvent?>()
516 
onTouchEventnull517     override fun onTouchEvent(event: MotionEvent?): Boolean {
518         motionEvents.add(MotionEvent.obtain(event))
519         return super.onTouchEvent(event)
520     }
521 }
522 
awaitScrollIdlenull523 private suspend fun RecyclerView.awaitScrollIdle() {
524     val rv = this
525     withContext(Dispatchers.Main) {
526         suspendCancellableCoroutine<Unit> { continuation ->
527             val listener =
528                 object : OnScrollListener() {
529                     override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
530                         if (newState == RecyclerView.SCROLL_STATE_IDLE) {
531                             continuation.resume(Unit)
532                         }
533                     }
534                 }
535 
536             rv.addOnScrollListener(listener)
537 
538             continuation.invokeOnCancellation { rv.removeOnScrollListener(listener) }
539 
540             if (rv.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
541                 continuation.resume(Unit)
542             }
543         }
544     }
545 }
546 
547 private const val ItemDifferenceThreshold = 1
548