1 /* <lambda>null2 * Copyright 2020 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.recyclerview.widget 18 19 import android.content.Context 20 import android.view.View 21 import android.view.View.MeasureSpec.AT_MOST 22 import android.view.ViewGroup 23 import androidx.recyclerview.widget.ConcatAdapter.Config.Builder 24 import androidx.recyclerview.widget.ConcatAdapter.Config.StableIdMode.ISOLATED_STABLE_IDS 25 import androidx.recyclerview.widget.ConcatAdapter.Config.StableIdMode.NO_STABLE_IDS 26 import androidx.recyclerview.widget.ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS 27 import androidx.recyclerview.widget.ConcatAdapterSubject.Companion.assertThat 28 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.Changed 29 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.DataSetChanged 30 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.Inserted 31 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.Moved 32 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.Removed 33 import androidx.recyclerview.widget.ConcatAdapterTest.LoggingAdapterObserver.Event.StateRestorationPolicy 34 import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy.ALLOW 35 import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy.PREVENT 36 import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY 37 import androidx.recyclerview.widget.RecyclerView.LayoutParams 38 import androidx.recyclerview.widget.RecyclerView.LayoutParams.MATCH_PARENT 39 import androidx.recyclerview.widget.RecyclerView.NO_POSITION 40 import androidx.test.annotation.UiThreadTest 41 import androidx.test.core.app.ApplicationProvider 42 import androidx.test.ext.junit.runners.AndroidJUnit4 43 import androidx.test.filters.SdkSuppress 44 import androidx.test.filters.SmallTest 45 import com.google.common.truth.Truth.assertThat 46 import com.google.common.truth.Truth.assertWithMessage 47 import java.lang.reflect.Method 48 import java.lang.reflect.Modifier 49 import org.junit.Assert.fail 50 import org.junit.Before 51 import org.junit.Test 52 import org.junit.runner.RunWith 53 54 @RunWith(AndroidJUnit4::class) 55 @SmallTest 56 class ConcatAdapterTest { 57 private lateinit var recyclerView: RecyclerView 58 59 @Before 60 fun init() { 61 val context = ApplicationProvider.getApplicationContext<Context>() 62 recyclerView = 63 RecyclerView(context).also { 64 it.layoutManager = LinearLayoutManager(context) 65 it.itemAnimator = null 66 } 67 } 68 69 @Test(expected = UnsupportedOperationException::class) 70 fun cannotCallSetStableIds_true() { 71 val concatenated = ConcatAdapter() 72 concatenated.setHasStableIds(true) 73 } 74 75 @Test(expected = UnsupportedOperationException::class) 76 fun cannotCallSetStableIds_false() { 77 val concatenated = ConcatAdapter() 78 concatenated.setHasStableIds(false) 79 } 80 81 @UiThreadTest 82 @Test 83 fun attachAndDetachAll() { 84 val concatenated = ConcatAdapter() 85 val adapter1 = NestedTestAdapter(10, getLayoutParams = { LayoutParams(MATCH_PARENT, 3) }) 86 concatenated.addAdapter(adapter1) 87 recyclerView.adapter = concatenated 88 measureAndLayout(100, 50) 89 assertThat(recyclerView.childCount).isEqualTo(10) 90 assertThat(adapter1.attachedViewHolders()).hasSize(10) 91 measureAndLayout(100, 0) 92 assertThat(recyclerView.childCount).isEqualTo(0) 93 assertThat(adapter1.attachedViewHolders()).isEmpty() 94 95 val adapter2 = NestedTestAdapter(5, getLayoutParams = { LayoutParams(MATCH_PARENT, 3) }) 96 concatenated.addAdapter(adapter2) 97 assertThat(recyclerView.isLayoutRequested).isTrue() 98 measureAndLayout(100, 200) 99 assertThat(recyclerView.childCount).isEqualTo(15) 100 assertThat(adapter1.attachedViewHolders()).hasSize(10) 101 assertThat(adapter2.attachedViewHolders()).hasSize(5) 102 concatenated.removeAdapter(adapter1) 103 assertThat(recyclerView.isLayoutRequested).isTrue() 104 measureAndLayout(100, 200) 105 assertThat(recyclerView.childCount).isEqualTo(5) 106 assertThat(adapter1.attachedViewHolders()).isEmpty() 107 assertThat(adapter2.attachedViewHolders()).hasSize(5) 108 measureAndLayout(100, 0) 109 assertThat(adapter2.attachedViewHolders()).isEmpty() 110 } 111 112 @Test 113 @UiThreadTest 114 fun concatInsideConcat() { 115 val concatenated = ConcatAdapter() 116 val adapter1 = NestedTestAdapter(10) 117 concatenated.addAdapter(adapter1) 118 recyclerView.adapter = concatenated 119 measureAndLayout(100, 100) 120 assertThat(recyclerView.childCount).isEqualTo(10) 121 concatenated.removeAdapter(adapter1) 122 assertThat(recyclerView.isLayoutRequested).isTrue() 123 measureAndLayout(100, 100) 124 assertThat(adapter1.attachedViewHolders()).isEmpty() 125 } 126 127 @UiThreadTest 128 @Test 129 fun recycleOnRemoval() { 130 val concatenated = ConcatAdapter() 131 val adapter1 = NestedTestAdapter(10) 132 concatenated.addAdapter(adapter1) 133 recyclerView.adapter = concatenated 134 measureAndLayout(100, 100) 135 assertThat(recyclerView.childCount).isEqualTo(10) 136 adapter1.removeItems(3, 2) 137 assertThat(recyclerView.isLayoutRequested).isTrue() 138 measureAndLayout(100, 100) 139 assertThat(adapter1.recycleEvents()).hasSize(2) 140 assertThat(adapter1.attachedViewHolders()).hasSize(8) 141 assertThat(adapter1.attachedViewHolders()).containsNoneIn(adapter1.recycleEvents()) 142 } 143 144 @UiThreadTest 145 @Test 146 fun checkAttachDetach_adapterAdditions() { 147 val concatenated = ConcatAdapter() 148 val adapter1 = NestedTestAdapter(0) 149 concatenated.addAdapter(adapter1) 150 recyclerView.adapter = concatenated 151 measureAndLayout(100, 100) 152 adapter1.addItems(0, 3) 153 assertThat(recyclerView.isLayoutRequested).isTrue() 154 measureAndLayout(100, 100) 155 assertThat(adapter1.attachedViewHolders()).hasSize(3) 156 assertThat(adapter1.recycleEvents()).hasSize(0) 157 } 158 159 @UiThreadTest 160 @Test 161 fun failedToRecycleTest() { 162 val adapter1 = NestedTestAdapter(10) 163 val adapter2 = NestedTestAdapter(5) 164 val concatenated = ConcatAdapter(adapter1, adapter2) 165 recyclerView.adapter = concatenated 166 measureAndLayout(100, 200) 167 val viewHolder = recyclerView.findViewHolderForAdapterPosition(12) 168 check(viewHolder != null) { "should have that view holder for position 12" } 169 assertThat(adapter2.attachedViewHolders()).contains(viewHolder) 170 // give it transient state so that it won't be recycled 171 viewHolder.itemView.setHasTransientState(true) 172 adapter2.removeItems(2, 2) 173 assertThat(recyclerView.isLayoutRequested).isTrue() 174 measureAndLayout(100, 200) 175 assertThat(adapter2.attachedViewHolders()).hasSize(3) 176 assertThat(adapter2.failedToRecycleEvents()) 177 .contains( 178 RecycledViewHolderEvent( 179 itemId = 12, 180 absoluteAdapterPosition = NO_POSITION, 181 bindingAdapterPosition = NO_POSITION 182 ) 183 ) 184 assertThat(adapter2.failedToRecycleEvents()).hasSize(1) 185 assertThat(adapter2.attachedViewHolders()).doesNotContain(viewHolder) 186 } 187 188 @Suppress("DEPRECATION") 189 @UiThreadTest 190 @Test 191 fun localAdapterPositions() { 192 val adapter1 = NestedTestAdapter(10) 193 val adapter2 = NestedTestAdapter(4) 194 val adapter3 = NestedTestAdapter(8) 195 val concatenated = ConcatAdapter(adapter1, adapter2, adapter3) 196 recyclerView.adapter = concatenated 197 measureAndLayout(100, 100) 198 assertThat(recyclerView.childCount).isEqualTo(22) 199 (0 until 22).forEach { 200 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 201 assertThat(recyclerView.getChildAdapterPosition(viewHolder.itemView)).isEqualTo(it) 202 assertThat(viewHolder.absoluteAdapterPosition).isEqualTo(it) 203 } 204 (0 until 10).forEach { 205 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 206 assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it) 207 assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter1) 208 } 209 210 (10 until 14).forEach { 211 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 212 assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 10) 213 assertThat(viewHolder.adapterPosition).isEqualTo(it - 10) 214 assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter2) 215 } 216 217 (14 until 22).forEach { 218 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 219 assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 14) 220 assertThat(viewHolder.adapterPosition).isEqualTo(it - 14) 221 assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter3) 222 } 223 } 224 225 @Suppress("LocalVariableName") 226 @UiThreadTest 227 @Test 228 fun localAdapterPositions_nested() { 229 val adapter1_1 = NestedTestAdapter(10) 230 val adapter1_2 = NestedTestAdapter(5) 231 val adapter1 = ConcatAdapter(adapter1_1, adapter1_2) 232 val adapter2_1 = NestedTestAdapter(3) 233 val adapter2_2 = NestedTestAdapter(6) 234 val adapter2 = ConcatAdapter(adapter2_1, adapter2_2) 235 val concatenated = ConcatAdapter(adapter1, adapter2) 236 recyclerView.adapter = concatenated 237 measureAndLayout(100, 100) 238 assertThat(recyclerView.childCount).isEqualTo(24) 239 (0 until 24).forEach { 240 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 241 assertThat(viewHolder.absoluteAdapterPosition).isEqualTo(it) 242 assertThat(recyclerView.getChildAdapterPosition(viewHolder.itemView)).isEqualTo(it) 243 } 244 (0 until 10).forEach { 245 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 246 assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it) 247 assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter1_1) 248 } 249 (10 until 15).forEach { 250 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 251 assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 10) 252 assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter1_2) 253 } 254 (15 until 18).forEach { 255 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 256 assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 15) 257 assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter2_1) 258 } 259 (18 until 24).forEach { 260 val viewHolder = checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) 261 assertThat(viewHolder.bindingAdapterPosition).isEqualTo(it - 18) 262 assertThat(viewHolder.bindingAdapter).isSameInstanceAs(adapter2_2) 263 } 264 } 265 266 @UiThreadTest 267 @Test 268 fun localAdapterPositions_notIncluded() { 269 val adapter1 = NestedTestAdapter(10) 270 val concatenated = ConcatAdapter(adapter1) 271 recyclerView.adapter = concatenated 272 measureAndLayout(100, 100) 273 assertThat(recyclerView.childCount).isEqualTo(10) 274 val vh = checkNotNull(recyclerView.findViewHolderForAdapterPosition(3)) 275 assertThat(vh.bindingAdapterPosition).isEqualTo(3) 276 277 val toBeRemoved = checkNotNull(recyclerView.findViewHolderForAdapterPosition(4)) 278 adapter1.removeItems(4, 1) 279 assertThat(toBeRemoved.bindingAdapterPosition).isEqualTo(NO_POSITION) 280 assertThat(toBeRemoved.absoluteAdapterPosition).isEqualTo(NO_POSITION) 281 measureAndLayout(100, 100) 282 assertThat(toBeRemoved.bindingAdapter).isNull() 283 284 recyclerView.adapter = null 285 measureAndLayout(100, 100) 286 assertThat(vh.bindingAdapterPosition).isEqualTo(NO_POSITION) 287 assertThat(vh.absoluteAdapterPosition).isEqualTo(NO_POSITION) 288 assertThat(vh.bindingAdapter).isNull() 289 } 290 291 @UiThreadTest 292 @Test 293 fun attachDetachTest() { 294 val adapter1 = NestedTestAdapter(10) 295 val adapter2 = NestedTestAdapter(5) 296 val concatenated = ConcatAdapter(adapter1, adapter2) 297 recyclerView.adapter = concatenated 298 assertThat(adapter1.attachedRecyclerViews()).containsExactly(recyclerView) 299 assertThat(adapter2.attachedRecyclerViews()).containsExactly(recyclerView) 300 val adapter3 = NestedTestAdapter(3) 301 concatenated.addAdapter(adapter3) 302 assertThat(adapter3.attachedRecyclerViews()).containsExactly(recyclerView) 303 concatenated.removeAdapter(adapter3) 304 assertThat(adapter3.attachedRecyclerViews()).isEmpty() 305 recyclerView.adapter = null 306 assertThat(adapter1.attachedRecyclerViews()).isEmpty() 307 assertThat(adapter2.attachedRecyclerViews()).isEmpty() 308 } 309 310 @UiThreadTest 311 @Test 312 public fun scrollView() { 313 val adapter1 = 314 NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 40) }) 315 val adapter2 = 316 NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) }) 317 val concatenated = ConcatAdapter(adapter1, adapter2) 318 recyclerView.adapter = concatenated 319 measureAndLayout(100, 100) 320 recyclerView.scrollBy(0, 90) 321 assertThat(adapter1.attachedViewHolders()).hasSize(3) 322 assertThat(adapter1.attachedViewHolders().map { it.bindingAdapterPosition }) 323 .containsExactly(2, 3, 4) 324 } 325 326 @UiThreadTest 327 @Test 328 public fun recycledViewPositions() { 329 val adapter1 = 330 NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 40) }) 331 val adapter2 = 332 NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) }) 333 val concatenated = ConcatAdapter(adapter1, adapter2) 334 recyclerView.setItemViewCacheSize(0) 335 recyclerView.adapter = concatenated 336 measureAndLayout(100, 100) 337 // trigger recycle 338 recyclerView.scrollBy(0, 90) 339 // two views are recycled 340 assertThat(adapter1.recycleEvents()) 341 .containsExactly( 342 RecycledViewHolderEvent( 343 itemId = 0, 344 absoluteAdapterPosition = 0, 345 bindingAdapterPosition = 0 346 ), 347 RecycledViewHolderEvent( 348 itemId = 1, 349 absoluteAdapterPosition = 1, 350 bindingAdapterPosition = 1 351 ) 352 ) 353 .inOrder() 354 } 355 356 @UiThreadTest 357 @Test 358 public fun recycledViewPositions_failedRecycle() { 359 val adapter1 = 360 NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 40) }) 361 val adapter2 = 362 NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) }) 363 val concatenated = ConcatAdapter(adapter1, adapter2) 364 recyclerView.setItemViewCacheSize(0) 365 recyclerView.adapter = concatenated 366 measureAndLayout(100, 100) 367 // give second view transient state 368 val viewHolder = recyclerView.findViewHolderForAdapterPosition(1) 369 check(viewHolder != null) { "should have that view holder for position 1" } 370 // give it transient state so that it won't be recycled 371 viewHolder.itemView.setHasTransientState(true) 372 // trigger recycle 373 recyclerView.scrollBy(0, 90) 374 // two views are recycled but one of them fails to recycle 375 assertThat(adapter1.failedToRecycleEvents()) 376 .containsExactly( 377 RecycledViewHolderEvent( 378 itemId = 1, 379 absoluteAdapterPosition = 1, 380 bindingAdapterPosition = 1 381 ) 382 ) 383 assertThat(adapter1.recycleEvents()) 384 .containsExactly( 385 RecycledViewHolderEvent( 386 itemId = 0, 387 absoluteAdapterPosition = 0, 388 bindingAdapterPosition = 0 389 ) 390 ) 391 } 392 393 @UiThreadTest 394 @Test 395 public fun recycledViewPositions_withAdapterChanges() { 396 val adapter1 = 397 NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 20) }) 398 val adapter2 = 399 NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) }) 400 val concatenated = ConcatAdapter(adapter1, adapter2) 401 recyclerView.setItemViewCacheSize(0) 402 recyclerView.adapter = concatenated 403 val layoutManager = (recyclerView.layoutManager!! as LinearLayoutManager) 404 measureAndLayout(100, 100) 405 // remove items 3 and 1 406 adapter1.removeItems(3, 1) 407 adapter1.removeItems(1, 1) 408 409 // scroll to the beginning of the second adapter to trigger recycle of all items in adapter 410 // 1 411 layoutManager.scrollToPositionWithOffset(3, 0) 412 measureAndLayout(100, 100) 413 assertThat(adapter1.recycleEvents()) 414 .containsExactly( 415 RecycledViewHolderEvent( 416 itemId = 0, 417 bindingAdapterPosition = 0, 418 absoluteAdapterPosition = 0 419 ), 420 RecycledViewHolderEvent( 421 itemId = 1, 422 bindingAdapterPosition = NO_POSITION, 423 absoluteAdapterPosition = NO_POSITION 424 ), 425 RecycledViewHolderEvent( 426 itemId = 2, 427 bindingAdapterPosition = 1, 428 absoluteAdapterPosition = 1 429 ), 430 RecycledViewHolderEvent( 431 itemId = 3, 432 bindingAdapterPosition = NO_POSITION, 433 absoluteAdapterPosition = NO_POSITION 434 ), 435 RecycledViewHolderEvent( 436 itemId = 4, 437 bindingAdapterPosition = 2, 438 absoluteAdapterPosition = 2 439 ) 440 ) 441 } 442 443 @UiThreadTest 444 @Test 445 public fun recycledViewPositions_withAdapterChanges_secondAdapter() { 446 val adapter1 = 447 NestedTestAdapter(count = 5, getLayoutParams = { LayoutParams(MATCH_PARENT, 20) }) 448 val adapter2 = 449 NestedTestAdapter(count = 10, getLayoutParams = { LayoutParams(MATCH_PARENT, 10) }) 450 val concatenated = ConcatAdapter(adapter1, adapter2) 451 recyclerView.setItemViewCacheSize(0) 452 val layoutManager = (recyclerView.layoutManager!! as LinearLayoutManager) 453 454 recyclerView.adapter = concatenated 455 // start from the second adapter 456 layoutManager.scrollToPositionWithOffset(adapter1.itemCount, 0) 457 measureAndLayout(100, 100) 458 assertThat(adapter1.attachedViewHolders()).isEmpty() 459 assertThat(adapter2.attachedViewHolders()).hasSize(10) 460 // remove items 3 and 1 from first adapter 461 adapter1.removeItems(3, 1) 462 adapter1.removeItems(1, 1) 463 // remove items 2, 4 from the second adapter 464 adapter2.removeItems(4, 1) 465 adapter2.removeItems(2, 1) 466 // scroll to the top of the list 467 layoutManager.scrollToPositionWithOffset(0, 0) 468 measureAndLayout(100, 100) 469 assertThat(adapter1.attachedViewHolders()).hasSize(3) 470 assertThat(adapter2.attachedViewHolders()).hasSize(4) 471 // the UI will have (item ids) 472 // 0, 2, 4, 5, 6 473 // nothing is recycled in adapter 1 474 assertThat(adapter1.recycleEvents()).isEmpty() 475 // all items but first two are recycled in adapter2 476 assertThat(adapter2.recycleEvents()) 477 .containsExactly( 478 // first two items, 5 and 6, are not recycled as they are still visible 479 // items 7 and 9 are recycled (removed) 480 // items 8, 10 are still visible 481 RecycledViewHolderEvent( 482 itemId = 7, 483 bindingAdapterPosition = NO_POSITION, 484 absoluteAdapterPosition = NO_POSITION 485 ), 486 // pos 4 (item id 9) is removed from adapter 2 487 RecycledViewHolderEvent( 488 itemId = 9, 489 bindingAdapterPosition = NO_POSITION, 490 absoluteAdapterPosition = NO_POSITION 491 ), 492 RecycledViewHolderEvent( 493 itemId = 11, 494 bindingAdapterPosition = 4, 495 absoluteAdapterPosition = 7 496 ), 497 RecycledViewHolderEvent( 498 itemId = 12, 499 bindingAdapterPosition = 5, 500 absoluteAdapterPosition = 8 501 ), 502 RecycledViewHolderEvent( 503 itemId = 13, 504 bindingAdapterPosition = 6, 505 absoluteAdapterPosition = 9 506 ), 507 RecycledViewHolderEvent( 508 itemId = 14, 509 bindingAdapterPosition = 7, 510 absoluteAdapterPosition = 10 511 ), 512 ) 513 } 514 515 @UiThreadTest 516 @Test 517 fun attachDetachTest_multipleRecyclerViews() { 518 val recyclerView2 = RecyclerView(ApplicationProvider.getApplicationContext()) 519 val adapter1 = NestedTestAdapter(10) 520 val adapter2 = NestedTestAdapter(5) 521 val concatenated = ConcatAdapter(adapter1, adapter2) 522 recyclerView.adapter = concatenated 523 recyclerView2.adapter = concatenated 524 assertThat(adapter1.attachedRecyclerViews()).containsExactly(recyclerView, recyclerView2) 525 assertThat(adapter2.attachedRecyclerViews()).containsExactly(recyclerView, recyclerView2) 526 val adapter3 = NestedTestAdapter(3) 527 concatenated.addAdapter(adapter3) 528 assertThat(adapter3.attachedRecyclerViews()).containsExactly(recyclerView, recyclerView2) 529 concatenated.removeAdapter(adapter3) 530 assertThat(adapter3.attachedRecyclerViews()).isEmpty() 531 recyclerView.adapter = null 532 assertThat(adapter1.attachedRecyclerViews()).containsExactly(recyclerView2) 533 assertThat(adapter2.attachedRecyclerViews()).containsExactly(recyclerView2) 534 recyclerView2.adapter = null 535 assertThat(adapter1.attachedRecyclerViews()).isEmpty() 536 assertThat(adapter2.attachedRecyclerViews()).isEmpty() 537 assertThat(adapter3.attachedRecyclerViews()).isEmpty() 538 } 539 540 @Test 541 @UiThreadTest 542 fun adapterRemoval() { 543 val adapter1 = NestedTestAdapter(3) 544 val adapter2 = NestedTestAdapter(5) 545 val concatenated = ConcatAdapter(adapter1, adapter2) 546 recyclerView.adapter = concatenated 547 measureAndLayout(100, 100) 548 assertThat(recyclerView.childCount).isEqualTo(8) 549 assertThat(concatenated.removeAdapter(adapter1)).isTrue() 550 measureAndLayout(100, 100) 551 assertThat(recyclerView.childCount).isEqualTo(5) 552 assertThat(concatenated.removeAdapter(adapter1)).isFalse() 553 assertThat(concatenated.removeAdapter(adapter2)).isTrue() 554 measureAndLayout(100, 100) 555 assertThat(recyclerView.childCount).isEqualTo(0) 556 } 557 558 @Test 559 @UiThreadTest 560 fun boundAdapter() { 561 val adapter1 = NestedTestAdapter(3) 562 val adapter2 = NestedTestAdapter(5) 563 val concatenated = ConcatAdapter(adapter1, adapter2) 564 recyclerView.adapter = concatenated 565 measureAndLayout(100, 100) 566 assertThat(recyclerView.childCount).isEqualTo(8) 567 val adapter1ViewHolders = 568 (0 until 3).map { checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) } 569 val adapter2ViewHolders = 570 (3 until 8).map { checkNotNull(recyclerView.findViewHolderForAdapterPosition(it)) } 571 adapter1ViewHolders.forEach { assertThat(it.bindingAdapter).isSameInstanceAs(adapter1) } 572 adapter2ViewHolders.forEach { assertThat(it.bindingAdapter).isSameInstanceAs(adapter2) } 573 assertThat(concatenated.removeAdapter(adapter1)).isTrue() 574 // even when position is invalid, we should still be able to find the bound adapter 575 adapter1ViewHolders.forEach { assertThat(it.bindingAdapter).isSameInstanceAs(adapter1) } 576 measureAndLayout(100, 100) 577 assertThat(recyclerView.childCount).isEqualTo(5) 578 adapter1ViewHolders.forEach { assertThat(it.bindingAdapter).isNull() } 579 assertThat(concatenated.removeAdapter(adapter1)).isFalse() 580 assertThat(concatenated.removeAdapter(adapter2)).isTrue() 581 measureAndLayout(100, 100) 582 assertThat(recyclerView.childCount).isEqualTo(0) 583 adapter2ViewHolders.forEach { assertThat(it.bindingAdapter).isNull() } 584 } 585 586 private fun measureAndLayout(@Suppress("SameParameterValue") width: Int, height: Int) { 587 measure(width, height) 588 layout(width, height) 589 } 590 591 private fun measure(width: Int, height: Int) { 592 recyclerView.measure(AT_MOST or width, AT_MOST or height) 593 } 594 595 private fun layout(width: Int, height: Int) { 596 recyclerView.layout(0, 0, width, height) 597 } 598 599 @Test 600 fun size() { 601 val concatenated = ConcatAdapter() 602 val observer = LoggingAdapterObserver(concatenated) 603 assertThat(concatenated).hasItemCount(0) 604 concatenated.addAdapter(NestedTestAdapter(0)) 605 observer.assertEventsAndClear("Empty adapter shouldn't cause notify") 606 607 val adapter1 = NestedTestAdapter(3) 608 concatenated.addAdapter(adapter1) 609 assertThat(concatenated).hasItemCount(3) 610 observer.assertEventsAndClear( 611 "adapter with count should trigger notify", 612 Inserted(positionStart = 0, itemCount = 3) 613 ) 614 615 val adapter2 = NestedTestAdapter(5) 616 concatenated.addAdapter(adapter2) 617 assertThat(concatenated).hasItemCount(8) 618 observer.assertEventsAndClear( 619 "appended non-empty adapter should trigger insert event", 620 Inserted(positionStart = 3, itemCount = 5) 621 ) 622 623 val adapter3 = NestedTestAdapter(2) 624 concatenated.addAdapter(2, adapter3) 625 assertThat(concatenated).hasItemCount(10) 626 observer.assertEventsAndClear( 627 "appended non-empty adapter should trigger insert event in right index", 628 Inserted(positionStart = 3, itemCount = 2) 629 ) 630 631 concatenated.addAdapter(NestedTestAdapter(0)) 632 assertThat(concatenated).hasItemCount(10) 633 observer.assertEventsAndClear("empty new adapter shouldn't trigger events") 634 } 635 636 @Test 637 fun nested_addition() { 638 val concatenated = ConcatAdapter() 639 val observer = LoggingAdapterObserver(concatenated) 640 641 val adapter1 = NestedTestAdapter(0) 642 concatenated.addAdapter(adapter1) 643 observer.assertEventsAndClear("empty adapter triggers no events") 644 645 adapter1.addItems(positionStart = 0, itemCount = 3) 646 observer.assertEventsAndClear( 647 "non-empty adapter triggers an event", 648 Inserted(positionStart = 0, itemCount = 3) 649 ) 650 assertThat(concatenated).hasItemCount(3) 651 adapter1.addItems(positionStart = 1, itemCount = 2) 652 observer.assertEventsAndClear( 653 "inner adapter change should trigger an event", 654 Inserted(positionStart = 1, itemCount = 2) 655 ) 656 assertThat(concatenated).hasItemCount(5) 657 val adapter2 = NestedTestAdapter(2) 658 concatenated.addAdapter(adapter2) 659 observer.assertEventsAndClear( 660 "added adapter should trigger an event", 661 Inserted(positionStart = 5, itemCount = 2) 662 ) 663 assertThat(concatenated).hasItemCount(7) 664 665 adapter2.addItems(positionStart = 0, itemCount = 3) 666 observer.assertEventsAndClear( 667 "nested adapter prepends data", 668 Inserted(positionStart = 5, itemCount = 3) 669 ) 670 assertThat(concatenated).hasItemCount(10) 671 672 adapter2.addItems(positionStart = 2, itemCount = 4) 673 observer.assertEventsAndClear( 674 "nested adapter adds items with inner offset", 675 Inserted(positionStart = 7, itemCount = 4) 676 ) 677 assertThat(concatenated).hasItemCount(14) 678 } 679 680 @Test 681 fun nested_removal() { 682 val adapter1 = NestedTestAdapter(10) 683 val adapter2 = NestedTestAdapter(15) 684 val adapter3 = NestedTestAdapter(20) 685 686 val concatenated = ConcatAdapter(adapter1, adapter2, adapter3) 687 val observer = LoggingAdapterObserver(concatenated) 688 assertThat(concatenated).hasItemCount(45) 689 690 adapter1.removeItems(positionStart = 0, itemCount = 2) 691 observer.assertEventsAndClear( 692 "removal from first adapter top", 693 Removed(positionStart = 0, itemCount = 2) 694 ) 695 assertThat(concatenated).hasItemCount(43) 696 adapter1.removeItems(positionStart = 2, itemCount = 1) 697 observer.assertEventsAndClear( 698 "removal from first adapter inner", 699 Removed(positionStart = 2, itemCount = 1) 700 ) 701 assertThat(concatenated).hasItemCount(42) 702 // now first adapter has size 7 703 adapter2.removeItems(positionStart = 0, itemCount = 3) 704 observer.assertEventsAndClear( 705 "removal from second adapter should be offset", 706 Removed(positionStart = adapter1.itemCount, itemCount = 3) 707 ) 708 assertThat(concatenated).hasItemCount(39) 709 adapter2.removeItems(positionStart = 6, itemCount = 4) 710 observer.assertEventsAndClear( 711 "inner item removal from middle adapter should be offset", 712 Removed(positionStart = adapter1.itemCount + 6, itemCount = 4) 713 ) 714 assertThat(concatenated).hasItemCount(35) 715 716 adapter3.removeItems(positionStart = 0, itemCount = 3) 717 observer.assertEventsAndClear( 718 "removal from last adapter should be offset by adapter 1 and 2", 719 Removed(positionStart = adapter1.itemCount + adapter2.itemCount, itemCount = 3) 720 ) 721 722 adapter3.removeItems(positionStart = 2, itemCount = 5) 723 observer.assertEventsAndClear( 724 "removal from inner items from last adapter should be offset by adapter 1 & 2", 725 Removed(positionStart = adapter1.itemCount + adapter2.itemCount + 2, itemCount = 5) 726 ) 727 728 concatenated.removeAdapter(adapter2) 729 observer.assertEventsAndClear( 730 "removing an adapter should trigger removal", 731 Removed(positionStart = adapter1.itemCount, itemCount = adapter2.itemCount) 732 ) 733 assertThat(concatenated).hasItemCount(adapter1.itemCount + adapter3.itemCount) 734 concatenated.removeAdapter(adapter1) 735 observer.assertEventsAndClear( 736 "removing first adapter should trigger removal", 737 Removed(positionStart = 0, itemCount = adapter1.itemCount) 738 ) 739 assertThat(concatenated).hasItemCount(adapter3.itemCount) 740 concatenated.removeAdapter(adapter3) 741 observer.assertEventsAndClear( 742 "removing last adapter should trigger a removal", 743 Removed(positionStart = 0, itemCount = adapter3.itemCount) 744 ) 745 assertThat(concatenated).hasItemCount(0) 746 } 747 748 @Test 749 fun nested_move() { 750 val adapter1 = NestedTestAdapter(10) 751 val adapter2 = NestedTestAdapter(15) 752 val adapter3 = NestedTestAdapter(20) 753 val concatenated = ConcatAdapter(adapter1, adapter2, adapter3) 754 val observer = LoggingAdapterObserver(concatenated) 755 adapter1.moveItem(fromPosition = 3, toPosition = 5) 756 observer.assertEventsAndClear( 757 "move from first adapter should come as is", 758 Moved(fromPosition = 3, toPosition = 5) 759 ) 760 assertThat(concatenated).hasItemCount(45) 761 adapter2.moveItem(fromPosition = 2, toPosition = 4) 762 observer.assertEventsAndClear( 763 "move in adapter 2 should be offset", 764 Moved(fromPosition = adapter1.itemCount + 2, toPosition = adapter1.itemCount + 4) 765 ) 766 adapter3.moveItem(fromPosition = 7, toPosition = 2) 767 observer.assertEventsAndClear( 768 "move in adapter 3 should be offset by adapter 1 & 2", 769 Moved( 770 fromPosition = adapter1.itemCount + adapter2.itemCount + 7, 771 toPosition = adapter1.itemCount + adapter2.itemCount + 2 772 ) 773 ) 774 assertThat(concatenated).hasItemCount(45) 775 } 776 777 @Test fun nested_itemChange_withPayload() = nested_itemChange("payload") 778 779 @Test fun nested_itemChange_withoutPayload() = nested_itemChange(null) 780 781 fun nested_itemChange(payload: Any? = null) { 782 val adapter1 = NestedTestAdapter(10) 783 val adapter2 = NestedTestAdapter(15) 784 val adapter3 = NestedTestAdapter(20) 785 val concatenated = ConcatAdapter(adapter1, adapter2, adapter3) 786 val observer = LoggingAdapterObserver(concatenated) 787 788 adapter1.changeItems(positionStart = 3, itemCount = 5, payload = payload) 789 observer.assertEventsAndClear( 790 "change from first adapter should come as is", 791 Changed(positionStart = 3, itemCount = 5, payload = payload) 792 ) 793 assertThat(concatenated).hasItemCount(45) 794 adapter2.changeItems(positionStart = 2, itemCount = 4, payload = payload) 795 observer.assertEventsAndClear( 796 "change in adapter 2 should be offset", 797 Changed(positionStart = adapter1.itemCount + 2, itemCount = 4, payload = payload) 798 ) 799 adapter3.changeItems(positionStart = 7, itemCount = 2, payload = payload) 800 observer.assertEventsAndClear( 801 "change in adapter 3 should be offset by adapter 1 & 2", 802 Changed( 803 positionStart = adapter1.itemCount + adapter2.itemCount + 7, 804 itemCount = 2, 805 payload = payload 806 ) 807 ) 808 assertThat(concatenated).hasItemCount(45) 809 } 810 811 @Test 812 fun notifyDataSetChanged() { 813 // we could add some logic to make data set changes add/remove/itemChange events yet 814 // it is very hard to get right and might cause very undesired animations. Not doing it 815 // for V1. 816 val adapter1 = NestedTestAdapter(10) 817 val adapter2 = NestedTestAdapter(15) 818 val adapter3 = NestedTestAdapter(20) 819 val concatenated = ConcatAdapter(adapter1, adapter2, adapter3) 820 val observer = LoggingAdapterObserver(concatenated) 821 822 adapter1.changeDataSet(3) 823 observer.assertEventsAndClear("data set change should come as is", DataSetChanged) 824 assertThat(concatenated).hasItemCount(38) 825 adapter2.changeDataSet(20) 826 observer.assertEventsAndClear( 827 "data set change in adapter 2 should become full data set change", 828 DataSetChanged 829 ) 830 assertThat(concatenated).hasItemCount(43) 831 adapter3.changeDataSet(newSize = 0) 832 observer.assertEventsAndClear( 833 """when an adapter changes size to 0, it should still come as 0 as we cannot 834 |rely on itemCount changing immediately. In theory we would but adapter might be 835 |faulty and not update its size immediately, which would work fine in RV because 836 |everything is delayed but not here if we immediately read the item count 837 """ 838 .trimMargin(), 839 DataSetChanged 840 ) 841 assertThat(concatenated).hasItemCount(23) 842 } 843 844 @Test 845 fun viewTypeMapping_allViewsHaveDifferentTypes() { 846 val adapter1 = NestedTestAdapter(10) { _, position -> position } 847 val concatenated = ConcatAdapter(adapter1) 848 val adapter1ViewTypes = (0 until 10).map { concatenated.getItemViewType(it) }.toSet() 849 850 assertWithMessage("all items have unique types").that(adapter1ViewTypes).hasSize(10) 851 repeat(adapter1.itemCount) { 852 assertThat(concatenated) 853 .bindView(recyclerView, it) 854 .verifyBoundTo(adapter = adapter1, localPosition = it) 855 } 856 val adapter2 = NestedTestAdapter(5) { _, position -> position } 857 concatenated.addAdapter(adapter2) 858 repeat(adapter2.itemCount) { 859 assertThat(concatenated) 860 .bindView(recyclerView, adapter1.itemCount + it) 861 .verifyBoundTo(adapter = adapter2, localPosition = it) 862 } 863 864 concatenated.removeAdapter(adapter1) 865 repeat(adapter2.itemCount) { 866 assertThat(concatenated) 867 .bindView(recyclerView, it) 868 .verifyBoundTo(adapter = adapter2, localPosition = it) 869 } 870 } 871 872 @Test 873 fun viewTypeMapping_shareTypesWithinAdapter() { 874 val adapter1 = NestedTestAdapter(10) { item, _ -> item.id % 3 } 875 val adapter2 = NestedTestAdapter(20) { item, _ -> item.id % 4 } 876 val concatenated = ConcatAdapter(adapter1, adapter2) 877 val adapter1Types = 878 (0 until adapter1.itemCount).map { concatenated.getItemViewType(it) }.toSet() 879 assertThat(adapter1Types).hasSize(3) 880 val adapter2Types = 881 (adapter1.itemCount until adapter2.itemCount) 882 .map { concatenated.getItemViewType(it) } 883 .toSet() 884 assertThat(adapter2Types).hasSize(4) 885 adapter2Types.forEach { assertThat(adapter1Types).doesNotContain(it) } 886 (0 until adapter1.itemCount).forEach { 887 assertThat(concatenated) 888 .bindView(recyclerView, it) 889 .verifyBoundTo(adapter = adapter1, localPosition = it) 890 } 891 892 (0 until adapter2.itemCount).forEach { 893 assertThat(concatenated) 894 .bindView(recyclerView, adapter1.itemCount + it) 895 .verifyBoundTo(adapter = adapter2, localPosition = it) 896 } 897 898 concatenated.removeAdapter(adapter1) 899 repeat(adapter2.itemCount) { 900 assertThat(concatenated) 901 .bindView(recyclerView, it) 902 .verifyBoundTo(adapter = adapter2, localPosition = it) 903 } 904 } 905 906 @Test(expected = java.lang.UnsupportedOperationException::class) 907 fun stateRestorationTest_callingOnTheConcatAdapterIsNotAllowed() { 908 val concatenated = ConcatAdapter() 909 concatenated.stateRestorationPolicy = PREVENT 910 } 911 912 @Test 913 fun stateRestoration_subAdapterAllowsNonEmpty() { 914 val adapter1 = NestedTestAdapter(1).also { it.stateRestorationPolicy = ALLOW } 915 val adapter2 = NestedTestAdapter(0).also { it.stateRestorationPolicy = PREVENT_WHEN_EMPTY } 916 val concatenated = ConcatAdapter(adapter1, adapter2) 917 assertThat(concatenated).cannotRestoreState() 918 adapter2.addItems(0, 1) 919 assertThat(concatenated).canRestoreState() 920 adapter2.removeItems(0, 1) 921 assertThat(concatenated).cannotRestoreState() 922 } 923 924 @Test 925 fun stateRestoration_subAdapterAllowsNonEmpty_viaNotifyChange() { 926 val adapter1 = NestedTestAdapter(1).also { it.stateRestorationPolicy = ALLOW } 927 val adapter2 = NestedTestAdapter(0).also { it.stateRestorationPolicy = PREVENT_WHEN_EMPTY } 928 val concatenated = ConcatAdapter(adapter1, adapter2) 929 assertThat(concatenated).cannotRestoreState() 930 adapter2.changeDataSet(1) 931 assertThat(concatenated).canRestoreState() 932 adapter2.changeDataSet(0) 933 assertThat(concatenated).cannotRestoreState() 934 } 935 936 @Test 937 fun stateRestoration() { 938 val adapter1 = NestedTestAdapter(10) 939 val adapter2 = NestedTestAdapter(5) 940 val adapter3 = NestedTestAdapter(20) 941 val concatenated = ConcatAdapter(adapter1, adapter2, adapter3) 942 assertThat(concatenated).hasStateRestorationPolicy(ALLOW) 943 adapter2.stateRestorationPolicy = PREVENT 944 assertThat(concatenated).hasStateRestorationPolicy(PREVENT) 945 946 adapter3.stateRestorationPolicy = PREVENT_WHEN_EMPTY 947 assertThat(concatenated).hasStateRestorationPolicy(PREVENT) 948 949 adapter2.stateRestorationPolicy = ALLOW 950 assertThat(concatenated).hasStateRestorationPolicy(ALLOW) 951 952 concatenated.removeAdapter(adapter3) 953 assertThat(concatenated).hasStateRestorationPolicy(ALLOW) 954 955 val adapter4 = 956 NestedTestAdapter(3).also { 957 it.stateRestorationPolicy = PREVENT 958 concatenated.addAdapter(it) 959 } 960 assertThat(concatenated).hasStateRestorationPolicy(PREVENT) 961 adapter4.stateRestorationPolicy = PREVENT_WHEN_EMPTY 962 assertThat(concatenated).hasStateRestorationPolicy(ALLOW) 963 concatenated.removeAdapter(adapter1) 964 assertThat(concatenated).hasStateRestorationPolicy(ALLOW) 965 adapter4.stateRestorationPolicy = ALLOW 966 assertThat(concatenated).hasStateRestorationPolicy(ALLOW) 967 } 968 969 @Test 970 fun disposal() { 971 val adapter1 = NestedTestAdapter(10) 972 val adapter2 = NestedTestAdapter(5) 973 val concatenated = ConcatAdapter(adapter1, adapter2) 974 assertThat(adapter1.observerCount()).isEqualTo(1) 975 assertThat(adapter2.observerCount()).isEqualTo(1) 976 concatenated.removeAdapter(adapter1) 977 assertThat(adapter1.observerCount()).isEqualTo(0) 978 assertThat(adapter2.observerCount()).isEqualTo(1) 979 980 val adapter3 = NestedTestAdapter(2) 981 concatenated.addAdapter(adapter3) 982 assertThat(adapter3.observerCount()).isEqualTo(1) 983 concatenated.adapters.forEach { concatenated.removeAdapter(it) } 984 listOf(adapter1, adapter2, adapter3).forEachIndexed { index, adapter -> 985 assertWithMessage("adapter ${index + 1}").apply { 986 that(adapter.observerCount()).isEqualTo(0) 987 that(adapter.attachedRecyclerViews()).isEmpty() 988 } 989 } 990 } 991 992 /** 993 * Running only on 26 due to the getParameters method call and this is not API version dependent 994 * test so it is fine to only run it on new devices. 995 */ 996 @SdkSuppress(minSdkVersion = 26) 997 @Test 998 fun overrideTest() { 999 // custom method instead of using toGenericString to avoid having class name 1000 fun Method.describe() = 1001 """ 1002 $name(${parameters.map { 1003 it.type.canonicalName 1004 }}) : ${returnType.canonicalName} 1005 """ 1006 .trimIndent() 1007 1008 val excludedMethods = 1009 setOf( 1010 "registerAdapterDataObserver(" + 1011 "[androidx.recyclerview.widget.RecyclerView.AdapterDataObserver]) : void", 1012 "unregisterAdapterDataObserver(" + 1013 "[androidx.recyclerview.widget.RecyclerView.AdapterDataObserver]) : void", 1014 "canRestoreState([]) : boolean", 1015 "onBindViewHolder([androidx.recyclerview.widget.RecyclerView.ViewHolder, int, " + 1016 "java.util.List]) : void" 1017 ) 1018 val adapterMethods = 1019 RecyclerView.Adapter::class 1020 .java 1021 .declaredMethods 1022 .filterNot { Modifier.isPrivate(it.modifiers) || Modifier.isFinal(it.modifiers) } 1023 .map { it.describe() } 1024 .filterNot { excludedMethods.contains(it) } 1025 val concatenatedAdapterMethods = 1026 ConcatAdapter::class.java.declaredMethods.map { it.describe() } 1027 assertWithMessage( 1028 """ 1029 ConcatAdapter should override all methods in RecyclerView.Adapter for future 1030 compatibility. If you want to exclude a method, update the test. 1031 """ 1032 .trimIndent() 1033 ) 1034 .that(concatenatedAdapterMethods) 1035 .containsAtLeastElementsIn(adapterMethods) 1036 } 1037 1038 @Test 1039 fun getAdapters() { 1040 val adapter1 = NestedTestAdapter(1) 1041 val adapter2 = NestedTestAdapter(2) 1042 val concatenated = ConcatAdapter(adapter1, adapter2) 1043 assertThat(concatenated.adapters).isEqualTo(listOf(adapter1, adapter2)) 1044 concatenated.removeAdapter(adapter1) 1045 assertThat(concatenated.adapters).isEqualTo(listOf(adapter2)) 1046 } 1047 1048 @Test 1049 fun sharedTypes() { 1050 val adapter1 = NestedTestAdapter(3) { _, pos -> pos % 2 } 1051 val adapter2 = NestedTestAdapter(3) { _, pos -> pos % 3 } 1052 val concatenated = 1053 ConcatAdapter(Builder().setIsolateViewTypes(false).build(), adapter1, adapter2) 1054 assertThat(concatenated).bindView(recyclerView, 2).verifyBoundTo(adapter1, 2) 1055 assertThat(concatenated).bindView(recyclerView, 3).verifyBoundTo(adapter2, 0) 1056 assertThat(concatenated.getItemViewType(0)).isEqualTo(0) 1057 assertThat(concatenated.getItemViewType(1)).isEqualTo(1) 1058 assertThat(concatenated.getItemViewType(2)).isEqualTo(0) 1059 // notice that it resets to 0 because type is based on position 1060 assertThat(concatenated.getItemViewType(3)).isEqualTo(0) 1061 assertThat(concatenated.getItemViewType(4)).isEqualTo(1) 1062 assertThat(concatenated.getItemViewType(5)).isEqualTo(2) 1063 // ensure we bind via the correct adapter when a type is limited to a specific adapter 1064 assertThat(concatenated).bindView(recyclerView, 5).verifyBoundTo(adapter2, 2) 1065 } 1066 1067 @Test 1068 fun sharedTypes_allUnique() { 1069 val adapter1 = NestedTestAdapter(3) { item, _ -> item.id } 1070 val adapter2 = NestedTestAdapter(3) { item, _ -> item.id } 1071 val concatenated = 1072 ConcatAdapter(Builder().setIsolateViewTypes(false).build(), adapter1, adapter2) 1073 assertThat(concatenated).bindView(recyclerView, 0).verifyBoundTo(adapter1, 0) 1074 assertThat(concatenated).bindView(recyclerView, 1).verifyBoundTo(adapter1, 1) 1075 assertThat(concatenated).bindView(recyclerView, 2).verifyBoundTo(adapter1, 2) 1076 assertThat(concatenated).bindView(recyclerView, 3).verifyBoundTo(adapter2, 0) 1077 assertThat(concatenated).bindView(recyclerView, 4).verifyBoundTo(adapter2, 1) 1078 assertThat(concatenated).bindView(recyclerView, 5).verifyBoundTo(adapter2, 2) 1079 } 1080 1081 @Test 1082 fun stableIds_noStableId() { 1083 val concatenatedAdapter = ConcatAdapter(Builder().setStableIdMode(NO_STABLE_IDS).build()) 1084 assertThat(concatenatedAdapter).doesNotHaveStableIds() 1085 // accept adapters with stable ids 1086 assertThat(concatenatedAdapter.addAdapter(PositionAsIdsNestedTestAdapter(10))).isTrue() 1087 } 1088 1089 @Test 1090 fun stableIds_isolated_addAdapterWithoutStableId() { 1091 val concatenatedAdapter = 1092 ConcatAdapter(Builder().setStableIdMode(ISOLATED_STABLE_IDS).build()) 1093 assertThat(concatenatedAdapter).hasStableIds() 1094 assertThat(concatenatedAdapter) 1095 .throwsException { 1096 it.addAdapter( 1097 NestedTestAdapter(10).also { nested -> nested.setHasStableIds(false) } 1098 ) 1099 } 1100 .hasMessageThat() 1101 .contains( 1102 "All sub adapters must have stable ids when stable id mode" + 1103 " is ISOLATED_STABLE_IDS or SHARED_STABLE_IDS" 1104 ) 1105 } 1106 1107 @Test 1108 fun stableIds_shared_addAdapterWithoutStableId() { 1109 val concatenatedAdapter = 1110 ConcatAdapter(Builder().setStableIdMode(SHARED_STABLE_IDS).build()) 1111 assertThat(concatenatedAdapter).hasStableIds() 1112 assertThat(concatenatedAdapter) 1113 .throwsException { 1114 it.addAdapter( 1115 NestedTestAdapter(10).also { nested -> nested.setHasStableIds(false) } 1116 ) 1117 } 1118 .hasMessageThat() 1119 .contains( 1120 "All sub adapters must have stable ids when stable id mode" + 1121 " is ISOLATED_STABLE_IDS or SHARED_STABLE_IDS" 1122 ) 1123 } 1124 1125 @Test 1126 fun stableIds_isolated() { 1127 val concatenatedAdapter = 1128 ConcatAdapter(Builder().setStableIdMode(ISOLATED_STABLE_IDS).build()) 1129 assertThat(concatenatedAdapter).hasStableIds() 1130 val adapter1 = PositionAsIdsNestedTestAdapter(10) 1131 val adapter2 = PositionAsIdsNestedTestAdapter(10) 1132 concatenatedAdapter.addAdapter(adapter1) 1133 concatenatedAdapter.addAdapter(adapter2) 1134 assertThat(concatenatedAdapter).hasItemIds((0..19)) 1135 // call again, ensure we are not popping up new ids 1136 assertThat(concatenatedAdapter).hasItemIds((0..19)) 1137 concatenatedAdapter.removeAdapter(adapter1) 1138 assertThat(concatenatedAdapter).hasItemIds((10..19)) 1139 1140 val adapter3 = PositionAsIdsNestedTestAdapter(5) 1141 concatenatedAdapter.addAdapter(adapter3) 1142 assertThat(concatenatedAdapter).hasItemIds((10..24)) 1143 1144 // add in between 1145 val adapter4 = PositionAsIdsNestedTestAdapter(5) 1146 concatenatedAdapter.addAdapter(1, adapter4) 1147 assertThat(concatenatedAdapter).hasItemIds((10..19) + (25..29) + (20..24)) 1148 } 1149 1150 @Test 1151 fun stableIds_shared() { 1152 val concatenatedAdapter = 1153 ConcatAdapter(Builder().setStableIdMode(SHARED_STABLE_IDS).build()) 1154 assertThat(concatenatedAdapter).hasStableIds() 1155 val adapter1 = UniqueItemIdsNestedTestAdapter(10) 1156 val adapter2 = UniqueItemIdsNestedTestAdapter(10) 1157 concatenatedAdapter.addAdapter(adapter1) 1158 concatenatedAdapter.addAdapter(adapter2) 1159 assertThat(concatenatedAdapter).hasItemIds(adapter1.itemIds() + adapter2.itemIds()) 1160 // call again, ensure we are not popping up new ids 1161 assertThat(concatenatedAdapter).hasItemIds(adapter1.itemIds() + adapter2.itemIds()) 1162 concatenatedAdapter.removeAdapter(adapter1) 1163 assertThat(concatenatedAdapter).hasItemIds(adapter2.itemIds()) 1164 1165 val adapter3 = UniqueItemIdsNestedTestAdapter(5) 1166 concatenatedAdapter.addAdapter(adapter3) 1167 assertThat(concatenatedAdapter).hasItemIds(adapter2.itemIds() + adapter3.itemIds()) 1168 1169 // add in between 1170 val adapter4 = UniqueItemIdsNestedTestAdapter(5) 1171 concatenatedAdapter.addAdapter(1, adapter4) 1172 assertThat(concatenatedAdapter) 1173 .hasItemIds(adapter2.itemIds() + adapter4.itemIds() + adapter3.itemIds()) 1174 } 1175 1176 @Test 1177 fun builderDefaults() { 1178 val defaultBuilder = Builder().build() 1179 assertThat(defaultBuilder.isolateViewTypes) 1180 .isEqualTo(ConcatAdapter.Config.DEFAULT.isolateViewTypes) 1181 assertThat(defaultBuilder.stableIdMode).isEqualTo(ConcatAdapter.Config.DEFAULT.stableIdMode) 1182 } 1183 1184 @Test 1185 fun getWrappedAdapterAndPositionTest() { 1186 val adapter1 = NestedTestAdapter(10) 1187 val adapter2 = NestedTestAdapter(10) 1188 val concatAdapter = ConcatAdapter(adapter1, adapter2) 1189 val result0 = concatAdapter.getWrappedAdapterAndPosition(0) 1190 assertThat(result0.first).isEqualTo(adapter1) 1191 assertThat(result0.second).isEqualTo(0) 1192 val result5 = concatAdapter.getWrappedAdapterAndPosition(5) 1193 assertThat(result5.first).isEqualTo(adapter1) 1194 assertThat(result5.second).isEqualTo(5) 1195 val result10 = concatAdapter.getWrappedAdapterAndPosition(10) 1196 assertThat(result10.first).isEqualTo(adapter2) 1197 assertThat(result10.second).isEqualTo(0) 1198 val result15 = concatAdapter.getWrappedAdapterAndPosition(15) 1199 assertThat(result15.first).isEqualTo(adapter2) 1200 assertThat(result15.second).isEqualTo(5) 1201 try { 1202 val result20 = concatAdapter.getWrappedAdapterAndPosition(20) 1203 fail("Should throw exception on invalid position, instead got $result20") 1204 } catch (e: IllegalArgumentException) { 1205 // Expected, pass 1206 } 1207 } 1208 1209 private var itemCounter = 0 1210 1211 private fun produceItem(): TestItem = (itemCounter++).let { TestItem(id = it, value = it) } 1212 1213 internal open inner class PositionAsIdsNestedTestAdapter(count: Int) : 1214 NestedTestAdapter(count) { 1215 init { 1216 setHasStableIds(true) 1217 } 1218 1219 override fun getItemId(position: Int): Long { 1220 return position.toLong() 1221 } 1222 } 1223 1224 internal open inner class UniqueItemIdsNestedTestAdapter(count: Int) : 1225 NestedTestAdapter(count) { 1226 init { 1227 setHasStableIds(true) 1228 } 1229 1230 override fun getItemId(position: Int): Long { 1231 return items[position].id.toLong() 1232 } 1233 1234 fun itemIds() = items.map { it.id } 1235 } 1236 1237 internal open inner class NestedTestAdapter( 1238 count: Int = 0, 1239 val getLayoutParams: ((ConcatAdapterViewHolder) -> LayoutParams)? = null, 1240 val itemTypeLookup: ((TestItem, position: Int) -> Int)? = null 1241 ) : RecyclerView.Adapter<ConcatAdapterViewHolder>() { 1242 private val attachedViewHolders = mutableListOf<ConcatAdapterViewHolder>() 1243 private val recycledViewHolderEvents = mutableListOf<RecycledViewHolderEvent>() 1244 private val failedToRecycleEvents = mutableListOf<RecycledViewHolderEvent>() 1245 private var attachedRecyclerViews = mutableListOf<RecyclerView>() 1246 private var observers = mutableListOf<RecyclerView.AdapterDataObserver>() 1247 1248 val items = 1249 mutableListOf<TestItem>().also { list -> repeat(count) { list.add(produceItem()) } } 1250 1251 fun attachedViewHolders(): List<ConcatAdapterViewHolder> = attachedViewHolders 1252 1253 override fun onViewAttachedToWindow(holder: ConcatAdapterViewHolder) { 1254 assertThat(attachedViewHolders).doesNotContain(holder) 1255 attachedViewHolders.add(holder) 1256 } 1257 1258 override fun onViewDetachedFromWindow(holder: ConcatAdapterViewHolder) { 1259 assertThat(attachedViewHolders).contains(holder) 1260 holder.onDetached() 1261 attachedViewHolders.remove(holder) 1262 } 1263 1264 override fun registerAdapterDataObserver(observer: RecyclerView.AdapterDataObserver) { 1265 assertThat(observers).doesNotContain(observer) 1266 observers.add(observer) 1267 super.registerAdapterDataObserver(observer) 1268 } 1269 1270 override fun unregisterAdapterDataObserver(observer: RecyclerView.AdapterDataObserver) { 1271 assertThat(observers).contains(observer) 1272 observers.remove(observer) 1273 super.unregisterAdapterDataObserver(observer) 1274 } 1275 1276 fun observerCount() = observers.size 1277 1278 override fun getItemViewType(position: Int): Int { 1279 itemTypeLookup?.let { 1280 return it(items[position], position) 1281 } 1282 return super.getItemViewType(position) 1283 } 1284 1285 override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { 1286 assertThat(attachedRecyclerViews).doesNotContain(recyclerView) 1287 attachedRecyclerViews.add(recyclerView) 1288 } 1289 1290 override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { 1291 assertThat(attachedRecyclerViews).contains(recyclerView) 1292 attachedRecyclerViews.remove(recyclerView) 1293 } 1294 1295 fun attachedRecyclerViews(): List<RecyclerView> = attachedRecyclerViews 1296 1297 fun addItems(positionStart: Int, itemCount: Int = 1) { 1298 require(itemCount > 0) 1299 require(positionStart >= 0 && positionStart <= items.size) 1300 val newItems = (0 until itemCount).map { produceItem() } 1301 items.addAll(positionStart, newItems) 1302 notifyItemRangeInserted(positionStart, itemCount) 1303 } 1304 1305 fun removeItems(positionStart: Int, itemCount: Int = 1) { 1306 require(positionStart >= 0) 1307 require(positionStart + itemCount <= items.size) 1308 require(itemCount > 0) 1309 repeat(itemCount) { items.removeAt(positionStart) } 1310 notifyItemRangeRemoved(positionStart, itemCount) 1311 } 1312 1313 fun moveItem(fromPosition: Int, toPosition: Int) { 1314 require(fromPosition >= 0 && fromPosition < items.size) 1315 require(toPosition >= 0 && toPosition < items.size) 1316 if (fromPosition == toPosition) return 1317 items.add(toPosition, items.removeAt(fromPosition)) 1318 notifyItemMoved(fromPosition, toPosition) 1319 } 1320 1321 fun changeDataSet(newSize: Int = items.size) { 1322 require(newSize >= 0) 1323 val newItems = (0 until newSize).map { produceItem() } 1324 items.clear() 1325 items.addAll(newItems) 1326 notifyDataSetChanged() 1327 } 1328 1329 fun changeItems(positionStart: Int, itemCount: Int, payload: Any? = null) { 1330 require(positionStart >= 0 && positionStart < items.size) 1331 require(positionStart + itemCount <= items.size) 1332 (positionStart until positionStart + itemCount).forEach { 1333 val prev = items[it] 1334 items[it] = prev.copy(value = prev.value + 1) 1335 } 1336 if (payload == null) { 1337 notifyItemRangeChanged(positionStart, itemCount) 1338 } else { 1339 notifyItemRangeChanged(positionStart, itemCount, payload) 1340 } 1341 } 1342 1343 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConcatAdapterViewHolder { 1344 return ConcatAdapterViewHolder(parent.context, viewType).also { holder -> 1345 getLayoutParams?.invoke(holder)?.let { holder.itemView.layoutParams = it } 1346 } 1347 } 1348 1349 override fun onBindViewHolder(holder: ConcatAdapterViewHolder, position: Int) { 1350 assertThat(getItemViewType(position)).isEqualTo(holder.localViewType) 1351 holder.bindTo(this, items[position], position) 1352 } 1353 1354 override fun onViewRecycled(holder: ConcatAdapterViewHolder) { 1355 recycledViewHolderEvents.add(RecycledViewHolderEvent(holder)) 1356 holder.onRecycled() 1357 } 1358 1359 override fun getItemCount() = items.size 1360 1361 override fun onFailedToRecycleView(holder: ConcatAdapterViewHolder): Boolean { 1362 failedToRecycleEvents.add(RecycledViewHolderEvent(holder)) 1363 return super.onFailedToRecycleView(holder) 1364 } 1365 1366 fun getItemAt(localPosition: Int) = items[localPosition] 1367 1368 fun recycleEvents(): List<RecycledViewHolderEvent> = recycledViewHolderEvents 1369 1370 fun failedToRecycleEvents(): List<RecycledViewHolderEvent> = failedToRecycleEvents 1371 } 1372 1373 internal class ConcatAdapterViewHolder(context: Context, val localViewType: Int) : 1374 RecyclerView.ViewHolder(View(context)) { 1375 private var boundItem: TestItem? = null 1376 private var boundAdapter: RecyclerView.Adapter<*>? = null 1377 private var boundPosition: Int? = null 1378 1379 fun bindTo(adapter: RecyclerView.Adapter<*>, item: TestItem, position: Int) { 1380 boundAdapter = adapter 1381 boundPosition = position 1382 boundItem = item 1383 } 1384 1385 fun boundItem() = boundItem 1386 1387 fun boundLocalPosition() = boundPosition 1388 1389 fun boundAdapter() = boundAdapter 1390 1391 fun onDetached() { 1392 assertPosition() 1393 } 1394 1395 fun onRecycled() { 1396 assertPosition() 1397 boundItem = null 1398 boundPosition = -1 1399 boundAdapter = null 1400 } 1401 1402 private fun assertPosition() { 1403 val shouldHavePosition = 1404 !isRemoved() && isBound() && !isAdapterPositionUnknown() && !isInvalid() 1405 assertWithMessage("binding adapter position $this") 1406 .that(shouldHavePosition) 1407 .isEqualTo(bindingAdapterPosition != NO_POSITION) 1408 assertWithMessage("binding adapter position $this") 1409 .that(shouldHavePosition) 1410 .isEqualTo(absoluteAdapterPosition != NO_POSITION) 1411 } 1412 } 1413 1414 class LoggingAdapterObserver(private val src: RecyclerView.Adapter<*>) : 1415 RecyclerView.AdapterDataObserver() { 1416 init { 1417 src.registerAdapterDataObserver(this) 1418 } 1419 1420 private val events = mutableListOf<Event>() 1421 1422 fun assertEventsAndClear(message: String, vararg expected: Event) { 1423 assertWithMessage(message).that(events).isEqualTo(expected.toList()) 1424 events.clear() 1425 } 1426 1427 override fun onChanged() { 1428 events.add(DataSetChanged) 1429 } 1430 1431 override fun onItemRangeChanged(positionStart: Int, itemCount: Int) { 1432 events.add(Changed(positionStart = positionStart, itemCount = itemCount)) 1433 } 1434 1435 override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) { 1436 events.add( 1437 Changed(positionStart = positionStart, itemCount = itemCount, payload = payload) 1438 ) 1439 } 1440 1441 override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { 1442 events.add(Inserted(positionStart = positionStart, itemCount = itemCount)) 1443 } 1444 1445 override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { 1446 events.add(Removed(positionStart = positionStart, itemCount = itemCount)) 1447 } 1448 1449 override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { 1450 require(itemCount == 1) { "RV does not support moving more than 1 item at a time" } 1451 events.add(Moved(fromPosition = fromPosition, toPosition = toPosition)) 1452 } 1453 1454 override fun onStateRestorationPolicyChanged() { 1455 events.add(StateRestorationPolicy(newValue = src.stateRestorationPolicy)) 1456 } 1457 1458 sealed class Event { 1459 object DataSetChanged : Event() 1460 1461 data class Changed( 1462 val positionStart: Int, 1463 val itemCount: Int, 1464 val payload: Any? = null 1465 ) : Event() 1466 1467 data class Inserted(val positionStart: Int, val itemCount: Int) : Event() 1468 1469 data class Removed(val positionStart: Int, val itemCount: Int) : Event() 1470 1471 data class Moved(val fromPosition: Int, val toPosition: Int) : Event() 1472 1473 data class StateRestorationPolicy( 1474 val newValue: RecyclerView.Adapter.StateRestorationPolicy 1475 ) : Event() 1476 } 1477 } 1478 1479 internal data class TestItem(val id: Int, val value: Int, val viewType: Int = 0) 1480 1481 internal data class RecycledViewHolderEvent( 1482 val itemId: Int?, 1483 val absoluteAdapterPosition: Int, 1484 val bindingAdapterPosition: Int, 1485 ) { 1486 constructor( 1487 viewHolder: ConcatAdapterViewHolder 1488 ) : this( 1489 itemId = viewHolder.boundItem()?.id, 1490 absoluteAdapterPosition = viewHolder.absoluteAdapterPosition, 1491 bindingAdapterPosition = viewHolder.bindingAdapterPosition 1492 ) 1493 } 1494 } 1495