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