Repository: airbnb/epoxy Branch: master Commit: e45bd3a61fe3 Files: 936 Total size: 86.9 MB Directory structure: gitextract_6mir2ezo/ ├── .github/ │ └── workflows/ │ └── build_test.yml ├── .gitignore ├── .idea/ │ └── codeStyleSettings.xml ├── CHANGELOG.md ├── CONTRIBUTING.MD ├── LICENSE ├── README.md ├── RELEASING.md ├── UpdateProcessorTestResources.kt ├── blessedDeps.gradle ├── build.gradle ├── epoxy-adapter/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ ├── lint.xml │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── airbnb/ │ │ │ └── epoxy/ │ │ │ ├── ActivityRecyclerPool.kt │ │ │ ├── AsyncEpoxyController.java │ │ │ ├── AsyncEpoxyDiffer.java │ │ │ ├── BaseEpoxyAdapter.java │ │ │ ├── BaseEpoxyTouchCallback.java │ │ │ ├── BoundViewHolders.java │ │ │ ├── Carousel.java │ │ │ ├── ControllerHelper.java │ │ │ ├── ControllerHelperLookup.java │ │ │ ├── ControllerModelList.java │ │ │ ├── DebugTimer.java │ │ │ ├── DiffHelper.java │ │ │ ├── DiffPayload.java │ │ │ ├── DiffResult.java │ │ │ ├── EpoxyAdapter.java │ │ │ ├── EpoxyAsyncUtil.java │ │ │ ├── EpoxyController.java │ │ │ ├── EpoxyControllerAdapter.java │ │ │ ├── EpoxyDiffLogger.java │ │ │ ├── EpoxyDragCallback.java │ │ │ ├── EpoxyHolder.java │ │ │ ├── EpoxyItemSpacingDecorator.java │ │ │ ├── EpoxyModel.java │ │ │ ├── EpoxyModelGroup.java │ │ │ ├── EpoxyModelTouchCallback.java │ │ │ ├── EpoxyModelWithHolder.java │ │ │ ├── EpoxyModelWithView.java │ │ │ ├── EpoxyRecyclerView.kt │ │ │ ├── EpoxySwipeCallback.java │ │ │ ├── EpoxyTouchHelper.java │ │ │ ├── EpoxyTouchHelperCallback.kt │ │ │ ├── EpoxyViewHolder.java │ │ │ ├── EpoxyVisibilityItem.kt │ │ │ ├── EpoxyVisibilityTracker.kt │ │ │ ├── GeneratedModel.java │ │ │ ├── GroupModel.kt │ │ │ ├── HandlerExecutor.java │ │ │ ├── HiddenEpoxyModel.java │ │ │ ├── IdUtils.java │ │ │ ├── IllegalEpoxyUsage.java │ │ │ ├── ImmutableModelException.java │ │ │ ├── InternalExposer.kt │ │ │ ├── ListenersUtils.java │ │ │ ├── MainThreadExecutor.java │ │ │ ├── ModelCollector.kt │ │ │ ├── ModelGroupHolder.kt │ │ │ ├── ModelList.java │ │ │ ├── ModelState.java │ │ │ ├── NoOpControllerHelper.java │ │ │ ├── NoOpTimer.java │ │ │ ├── NotifyBlocker.java │ │ │ ├── OnModelBoundListener.java │ │ │ ├── OnModelBuildFinishedListener.java │ │ │ ├── OnModelCheckedChangeListener.java │ │ │ ├── OnModelClickListener.java │ │ │ ├── OnModelLongClickListener.java │ │ │ ├── OnModelUnboundListener.java │ │ │ ├── OnModelVisibilityChangedListener.java │ │ │ ├── OnModelVisibilityStateChangedListener.java │ │ │ ├── QuantityStringResAttribute.java │ │ │ ├── SimpleEpoxyAdapter.java │ │ │ ├── SimpleEpoxyController.java │ │ │ ├── SimpleEpoxyModel.java │ │ │ ├── StringAttributeData.java │ │ │ ├── StyleBuilderCallback.java │ │ │ ├── Timer.java │ │ │ ├── Typed2EpoxyController.java │ │ │ ├── Typed3EpoxyController.java │ │ │ ├── Typed4EpoxyController.java │ │ │ ├── TypedEpoxyController.java │ │ │ ├── UnboundedViewPool.kt │ │ │ ├── UpdateOp.java │ │ │ ├── UpdateOpHelper.java │ │ │ ├── ViewHolderState.java │ │ │ ├── ViewTypeManager.java │ │ │ ├── VisibilityState.java │ │ │ ├── WrappedEpoxyModelCheckedChangeListener.java │ │ │ ├── WrappedEpoxyModelClickListener.kt │ │ │ ├── preload/ │ │ │ │ ├── EpoxyModelPreloader.kt │ │ │ │ ├── EpoxyPreloader.kt │ │ │ │ ├── PreloadTargetProvider.kt │ │ │ │ ├── Preloadable.kt │ │ │ │ ├── PreloadableViewDataProvider.kt │ │ │ │ └── PreloaderExtensions.kt │ │ │ ├── stickyheader/ │ │ │ │ ├── StickyHeaderCallbacks.kt │ │ │ │ └── StickyHeaderLinearLayoutManager.kt │ │ │ └── utils/ │ │ │ └── utils.kt │ │ └── res/ │ │ ├── layout/ │ │ │ └── view_holder_empty_view.xml │ │ └── values/ │ │ ├── attrs.xml │ │ └── ids.xml │ └── test/ │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ ├── DiffPayloadTest.java │ ├── DifferCorrectnessTest.java │ ├── DifferNotifyTest.java │ ├── EpoxyAdapterTest.java │ ├── EpoxyControllerTest.java │ ├── EpoxyModelGroupTest.kt │ ├── EpoxyRecyclerViewTest.kt │ ├── EpoxyViewHolderTest.kt │ ├── EpoxyVisibilityTrackerNestedTest.kt │ ├── EpoxyVisibilityTrackerTest.kt │ ├── InsertedModel.java │ ├── ModelListTest.java │ ├── ModelTestUtils.java │ ├── TestAdapter.java │ ├── TestModel.java │ ├── TestObserver.java │ ├── TypedEpoxyControllerTest.java │ ├── UnboundedViewPoolTests.kt │ ├── UpdateOpHelperTest.java │ ├── ViewTypeManagerIntegrationTest.java │ └── test/ │ └── CarouselTest.java ├── epoxy-annotations/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ └── src/ │ └── main/ │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ ├── AfterPropsSet.java │ ├── AutoModel.java │ ├── CallbackProp.java │ ├── EpoxyAttribute.java │ ├── EpoxyBuildScope.kt │ ├── EpoxyDataBindingLayouts.java │ ├── EpoxyDataBindingPattern.java │ ├── EpoxyModelClass.java │ ├── ModelProp.java │ ├── ModelView.java │ ├── OnViewRecycled.java │ ├── OnVisibilityChanged.java │ ├── OnVisibilityStateChanged.java │ ├── PackageEpoxyConfig.java │ ├── PackageModelViewConfig.java │ └── TextProp.java ├── epoxy-compose/ │ ├── .gitignore │ ├── build.gradle │ ├── consumer-rules.pro │ ├── gradle.properties │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ └── ComposeInterop.kt ├── epoxy-composeinterop-maverickssample/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── composeinterop/ │ │ └── maverickssample/ │ │ └── MultiKeyComposeInteropFragmentTest.kt │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── composeinterop/ │ │ └── maverickssample/ │ │ ├── ComposeInteropListFragmnet.kt │ │ ├── MainActivity.kt │ │ ├── MultiKeyComposeInteropFragment.kt │ │ ├── SampleApplication.kt │ │ └── epoxyviews/ │ │ └── HeaderView.kt │ └── res/ │ ├── drawable/ │ │ └── ic_launcher_background.xml │ ├── drawable-v24/ │ │ └── ic_launcher_foreground.xml │ ├── layout/ │ │ ├── activity_main.xml │ │ ├── fragment_multi_key_compose_interop.xml │ │ ├── fragment_my.xml │ │ └── header_view.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── values-night/ │ └── themes.xml ├── epoxy-composesample/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── compose/ │ │ └── sample/ │ │ ├── ComposableInteropActivity.kt │ │ ├── EpoxyInteropActivity.kt │ │ ├── MainActivity.kt │ │ ├── epoxyviews/ │ │ │ └── HeaderView.kt │ │ └── ui/ │ │ └── theme/ │ │ ├── Color.kt │ │ ├── Shape.kt │ │ ├── Theme.kt │ │ └── Type.kt │ └── res/ │ ├── drawable/ │ │ └── ic_launcher_background.xml │ ├── drawable-v24/ │ │ └── ic_launcher_foreground.xml │ ├── layout/ │ │ ├── activity_composable_interop.xml │ │ ├── activity_epoxy_interop.xml │ │ ├── activity_main.xml │ │ └── header_view.xml │ ├── mipmap-anydpi-v26/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── values/ │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── values-night/ │ └── themes.xml ├── epoxy-databinding/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ └── DataBindingEpoxyModel.java ├── epoxy-glide-preloader/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── preloader/ │ │ └── ExampleInstrumentedTest.java │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ ├── GlidePreloadExtensions.kt │ └── GlidePreloadRequestHolder.kt ├── epoxy-integrationtest/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── airbnb/ │ │ │ └── epoxy/ │ │ │ └── integrationtest/ │ │ │ ├── AdapterWithFieldAssigned.java │ │ │ ├── AdapterWithIdChanged.java │ │ │ ├── BasicAutoModelsAdapter.java │ │ │ ├── ControllerWithAutoModel.java │ │ │ ├── ControllerWithoutImplicityAdding.java │ │ │ ├── EpoxyDataBindingConfig.java │ │ │ ├── KotlinViewWithDefaultParams.kt │ │ │ ├── Model.java │ │ │ ├── ModelChangesDuringBind.java │ │ │ ├── ModelGroupWithAnnotation.java │ │ │ ├── ModelWithCheckedChangeListener.java │ │ │ ├── ModelWithClickListener.java │ │ │ ├── ModelWithConstructors.java │ │ │ ├── ModelWithLongClickListener.java │ │ │ ├── ModelWithNoGeneratedClass.java │ │ │ ├── ModelsWithCustomTypes.java │ │ │ ├── TestActivity.kt │ │ │ ├── ViewWithAnnotationsForIntegrationTest.java │ │ │ ├── ViewWithDelegate.kt │ │ │ ├── ViewWithInterface.kt │ │ │ └── autoaddautomodels/ │ │ │ ├── ControllerWithImplicitlyAddedModels.java │ │ │ ├── ControllerWithImplicitlyAddedModels2.java │ │ │ ├── ControllerWithImplicitlyAddedModels3.java │ │ │ └── PackageConfig.java │ │ └── res/ │ │ ├── layout/ │ │ │ ├── model_with_checked_change.xml │ │ │ ├── model_with_click_listener.xml │ │ │ ├── model_with_data_binding.xml │ │ │ ├── vertical_linear_group.xml │ │ │ ├── view_holder_databinding_test.xml │ │ │ ├── view_holder_nested_databinding_test.xml │ │ │ ├── view_holder_no_databinding.xml │ │ │ └── view_with_annotations_for_integration_test.xml │ │ └── values/ │ │ ├── plurals.xml │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ ├── AutoModelIntegrationTest.java │ ├── BindDiffTest.kt │ ├── BindModelIntegrationTest.java │ ├── ControllerLifecycleHelper.kt │ ├── DataBindingModelIntegrationTest.java │ ├── DiffPayloadTestUtil.java │ ├── EpoxyAdapterIntegrationTest.java │ ├── EpoxyModelGroupRecyclingTest.kt │ ├── EpoxyModelIntegrationTest.java │ ├── EpoxyModelValidationTest.java │ ├── EpoxyViewBinderIntegrationTest.kt │ ├── EpoxyViewBinderVisibilityTrackerTest.kt │ ├── EpoxyVisibilityItemTest.kt │ ├── EpoxyVisibilityTrackerModelGroupTest.kt │ ├── KotlinDefaultParamTest.kt │ ├── ModelBuilderExtensionIntegrationTest.kt │ ├── ModelClickListenerTest.java │ ├── ModelGroupIntegrationTest.kt │ ├── ModelViewDelegateTest.kt │ ├── ModelViewInterfaceTest.kt │ ├── OnModelBindListenerTest.java │ ├── ViewAnnotationsStringOverloadsIntegrationTest.java │ ├── models/ │ │ ├── TrackerTestModel.kt │ │ └── TrackerTestModelGroup.kt │ └── utils/ │ └── VisibilityAssertHelper.kt ├── epoxy-kspsample/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── ksp/ │ │ └── sample/ │ │ ├── MainActivity.kt │ │ └── epoxyviews/ │ │ ├── EpoxyConfig.kt │ │ └── HeaderView.kt │ └── res/ │ ├── layout/ │ │ ├── activity_main.xml │ │ └── header_view.xml │ └── values/ │ └── strings.xml ├── epoxy-modelfactory/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ └── ModelProperties.java ├── epoxy-modelfactorytest/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── airbnb/ │ │ │ └── epoxy/ │ │ │ ├── TestModelPropertiesKotlinView.kt │ │ │ └── TestModelPropertiesView.java │ │ └── res/ │ │ └── values/ │ │ └── strings.xml │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ ├── FromModelPropertiesKotlinTest.kt │ │ ├── FromModelPropertiesTest.kt │ │ ├── ModelFactoryViewProcessorTest.kt │ │ ├── ParisConfig.kt │ │ └── ProcessorTestUtils.kt │ └── resources/ │ ├── AllTypesModelView.java │ ├── AllTypesModelViewModel_.java │ ├── BasicModelWithFinalAttribute.java │ ├── BasicModelWithFinalAttribute_.java │ ├── CallbackPropModelView.java │ ├── CallbackPropModelViewModel_.java │ ├── GroupPropMultipleSupportedAttributeDifferentNameModelView.java │ ├── GroupPropMultipleSupportedAttributeDifferentNameModelViewModel_.java │ ├── GroupPropMultipleSupportedAttributeSameNameModelView.java │ ├── GroupPropMultipleSupportedAttributeSameNameModelViewModel_.java │ ├── GroupPropSingleSupportedAttributeModelView.java │ ├── GroupPropSingleSupportedAttributeModelViewModel_.java │ ├── ListSubtypeModelView.java │ ├── ListSubtypeModelViewModel_.java │ ├── ModelFactoryBaseModelView.java │ ├── ModelFactoryBaseModelViewModel_.java │ ├── ModelFactoryBasicModelWithAttribute.java │ ├── ModelFactoryBasicModelWithAttribute_.java │ ├── StyleableModelView.java │ ├── StyleableModelViewModel_.java │ ├── TextPropModelView.java │ ├── TextPropModelViewModel_.java │ ├── ksp/ │ │ ├── AllTypesModelViewModel_.java │ │ ├── BasicModelWithFinalAttribute_.java │ │ ├── CallbackPropModelViewModel_.java │ │ ├── GroupPropMultipleSupportedAttributeDifferentNameModelViewModel_.java │ │ ├── GroupPropMultipleSupportedAttributeSameNameModelViewModel_.java │ │ ├── GroupPropSingleSupportedAttributeModelViewModel_.java │ │ ├── ListSubtypeModelViewModel_.java │ │ ├── ModelFactoryBaseModelViewModel_.java │ │ ├── ModelFactoryBasicModelWithAttribute_.java │ │ └── TextPropModelViewModel_.java │ └── test.json ├── epoxy-paging3/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── paging3/ │ │ ├── Item.kt │ │ ├── ListDataSource.kt │ │ └── PagedListModelCacheTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── paging3/ │ │ ├── PagedDataModelCache.kt │ │ ├── PagedListEpoxyController.kt │ │ ├── PagedListModelCache.kt │ │ └── PagingDataEpoxyController.kt │ └── test/ │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ └── paging3/ │ ├── DummyItem.kt │ ├── ListPagingSource.kt │ └── PagedDataModelCacheTest.kt ├── epoxy-preloadersample/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── preloadersample/ │ │ ├── ImageModel.kt │ │ ├── ImagesController.kt │ │ ├── KotlinHolder.kt │ │ ├── MainActivity.kt │ │ ├── NoPreloadActivity.kt │ │ └── PreloadActivity.kt │ └── res/ │ ├── drawable/ │ │ └── ic_launcher_background.xml │ ├── layout/ │ │ ├── activity_main.xml │ │ ├── list_activity.xml │ │ └── list_item.xml │ └── values/ │ ├── colors.xml │ ├── strings.xml │ └── styles.xml ├── epoxy-processor/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── airbnb/ │ │ │ └── epoxy/ │ │ │ └── processor/ │ │ │ ├── Asyncable.kt │ │ │ ├── AttributeInfo.kt │ │ │ ├── BaseModelAttributeInfo.kt │ │ │ ├── BaseProcessor.kt │ │ │ ├── BaseProcessorWithPackageConfigs.kt │ │ │ ├── BasicGeneratedModelInfo.kt │ │ │ ├── ClassNames.kt │ │ │ ├── ConfigManager.kt │ │ │ ├── ControllerClassInfo.kt │ │ │ ├── ControllerModelField.kt │ │ │ ├── ControllerProcessor.kt │ │ │ ├── DataBindingAttributeInfo.kt │ │ │ ├── DataBindingModelInfo.kt │ │ │ ├── DataBindingModuleLookup.kt │ │ │ ├── DataBindingProcessor.kt │ │ │ ├── EpoxyProcessor.kt │ │ │ ├── EpoxyProcessorException.kt │ │ │ ├── Extensions.kt │ │ │ ├── GeneratedModelInfo.kt │ │ │ ├── GeneratedModelWriter.kt │ │ │ ├── GroupedAttribute.kt │ │ │ ├── HashCodeValidator.kt │ │ │ ├── ImportScanner.java │ │ │ ├── JavaPoetDsl.kt │ │ │ ├── KClassNames.kt │ │ │ ├── KotlinModelBuilderExtensionWriter.kt │ │ │ ├── KotlinUtils.kt │ │ │ ├── Logger.kt │ │ │ ├── Memoizer.kt │ │ │ ├── MethodInfo.kt │ │ │ ├── ModelBuilderInterfaceWriter.kt │ │ │ ├── ModelViewInfo.kt │ │ │ ├── ModelViewProcessor.kt │ │ │ ├── ModelViewWriter.kt │ │ │ ├── MultiParamAttribute.kt │ │ │ ├── PackageConfigSettings.kt │ │ │ ├── PackageModelViewSettings.kt │ │ │ ├── ParisStyleAttributeInfo.kt │ │ │ ├── PoetExtensions.kt │ │ │ ├── StringOverloadWriter.kt │ │ │ ├── StyleWriter.kt │ │ │ ├── Synchronization.kt │ │ │ ├── Timer.kt │ │ │ ├── Type.kt │ │ │ ├── TypeNameWorkaround.kt │ │ │ ├── Utils.kt │ │ │ ├── ViewAttributeInfo.kt │ │ │ ├── XProcessingUtils.kt │ │ │ └── resourcescanning/ │ │ │ ├── JavacResourceScanner.kt │ │ │ ├── KspResourceScanner.kt │ │ │ ├── ResourceScanner.kt │ │ │ └── ResourceValue.kt │ │ └── resources/ │ │ └── META-INF/ │ │ └── services/ │ │ ├── com.google.devtools.ksp.processing.SymbolProcessorProvider │ │ └── javax.annotation.processing.Processor │ └── test/ │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ ├── KspResourceScannerTest.kt │ ├── PoetExtensionsTest.kt │ └── UtilsTests.kt ├── epoxy-processortest/ │ ├── .gitignore │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── airbnb/ │ │ │ └── integrationtest/ │ │ │ └── processortest/ │ │ │ ├── ProcessorTestModel.java │ │ │ └── differentpackage/ │ │ │ └── Model.java │ │ └── res/ │ │ ├── layout/ │ │ │ └── model_with_data_binding_without_donothash.xml │ │ └── values/ │ │ └── strings.xml │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ ├── ConfigTest.kt │ │ ├── ControllerProcessorTest.kt │ │ ├── DataBindingModelTest.kt │ │ ├── EpoxyResourceProcessorTest.kt │ │ ├── GuavaPatch.kt │ │ ├── ModelProcessorTest.kt │ │ ├── ProcessorTestUtils.kt │ │ ├── ViewProcessorTest.kt │ │ └── testpackage/ │ │ └── DifferentPackageTest.java │ └── resources/ │ ├── AbstractEpoxyModelWithView.java │ ├── AbstractEpoxyModelWithView_.java │ ├── AbstractModelWithHolder.java │ ├── AbstractModelWithHolder_.java │ ├── AutoLayoutModelView.java │ ├── AutoLayoutModelViewManualLayoutParams.java │ ├── AutoLayoutModelViewManualLayoutParamsModel_.java │ ├── AutoLayoutModelViewMatchParent.java │ ├── AutoLayoutModelViewMatchParentModel_.java │ ├── AutoLayoutModelViewModel_.java │ ├── AutoModelNotInAutoAdapter.java │ ├── AutoModelNotOnModelField.java │ ├── BaseModelView.java │ ├── BaseModelViewModel_.java │ ├── BasicModelWithAttribute.java │ ├── BasicModelWithAttribute_.java │ ├── ControllerProcessorTest/ │ │ └── controllerWithAutoModel/ │ │ ├── BasicModelWithAttribute.kt │ │ ├── ControllerWithAutoModel.kt │ │ ├── ControllerWithAutoModel_EpoxyHelper.java │ │ └── ksp/ │ │ └── ControllerWithAutoModel_EpoxyHelper.java │ ├── ControllerWithAutoModel.java │ ├── ControllerWithAutoModelAndImplicitAdding.java │ ├── ControllerWithAutoModelAndImplicitAdding_EpoxyHelper.java │ ├── ControllerWithAutoModelWithSuperClass$SubControllerWithAutoModelWithSuperClass_EpoxyHelper.java │ ├── ControllerWithAutoModelWithSuperClass.java │ ├── ControllerWithAutoModelWithSuperClass_EpoxyHelper.java │ ├── ControllerWithAutoModelWithoutValidation.java │ ├── ControllerWithAutoModelWithoutValidation_EpoxyHelper.java │ ├── ControllerWithAutoModel_EpoxyHelper.java │ ├── CustomPackageLayoutPatternViewModel_.java │ ├── DataBindingConfig.java │ ├── DataBindingModelWithAllFieldTypes.java │ ├── DataBindingModelWithAllFieldTypesNoValidation.java │ ├── DataBindingModelWithAllFieldTypesNoValidation_.java │ ├── DataBindingModelWithAllFieldTypes_.java │ ├── DefaultPackageLayoutPatternViewModel_.java │ ├── DoNotHashView.java │ ├── DoNotHashViewModel_.java │ ├── EpoxyModelGroupWithAnnotations.java │ ├── EpoxyModelGroupWithAnnotations_.java │ ├── GenerateDefaultLayoutMethod.java │ ├── GenerateDefaultLayoutMethodNextParentLayout$NoLayout_.java │ ├── GenerateDefaultLayoutMethodNextParentLayout$StillNoLayout_.java │ ├── GenerateDefaultLayoutMethodNextParentLayout$WithLayout_.java │ ├── GenerateDefaultLayoutMethodNextParentLayout.java │ ├── GenerateDefaultLayoutMethodNoLayout.java │ ├── GenerateDefaultLayoutMethodParentLayout$NoLayout_.java │ ├── GenerateDefaultLayoutMethodParentLayout$WithLayout_.java │ ├── GenerateDefaultLayoutMethodParentLayout.java │ ├── GenerateDefaultLayoutMethodParentStillNoLayout.java │ ├── GenerateDefaultLayoutMethod_.java │ ├── GeneratedModelSuffixViewSuffix_.java │ ├── GridSpanCountView.java │ ├── GridSpanCountViewModel_.java │ ├── IgnoreRequireHashCodeView.java │ ├── IgnoreRequireHashCodeViewModel_.java │ ├── LayoutOverloadsViewModel_.java │ ├── ModelAsInnerClass.java │ ├── ModelConfigRequireHashCodeAllowsMarkedAttributes.java │ ├── ModelConfigRequireHashCodeCharSequencePasses.java │ ├── ModelConfigRequireHashCodeInterfaceWithHashCodePasses.java │ ├── ModelConfigSubPackageOverridesParent.java │ ├── ModelDoNotHash.java │ ├── ModelDoNotHash_.java │ ├── ModelDoNotUseInToString.java │ ├── ModelDoNotUseInToString_.java │ ├── ModelForRProcessingTest.java │ ├── ModelForRProcessingTest_.java │ ├── ModelForTestingDuplicateRValues.java │ ├── ModelForTestingDuplicateRValues_.java │ ├── ModelNoValidation.java │ ├── ModelNoValidation_.java │ ├── ModelPackageWithNoConfigInheritsNearestParentConfig.java │ ├── ModelProcessorTest/ │ │ └── testKotlinModel/ │ │ ├── Model.kt │ │ ├── Model_.java │ │ └── ksp/ │ │ └── Model_.java │ ├── ModelRequiresEqualsFailsBasicObject.java │ ├── ModelRequiresHashCodeArrayFails.java │ ├── ModelRequiresHashCodeArraySucceeds.java │ ├── ModelRequiresHashCodeAutoValueClassPasses.java │ ├── ModelRequiresHashCodeEnumPasses.java │ ├── ModelRequiresHashCodeFailsBasicObject.java │ ├── ModelRequiresHashCodeIterableFails.java │ ├── ModelRequiresHashCodeIterableSucceeds.java │ ├── ModelReturningClassType.java │ ├── ModelReturningClassTypeWithVarargs.java │ ├── ModelReturningClassTypeWithVarargs_.java │ ├── ModelReturningClassType_.java │ ├── ModelViewExtendingSuperClass.java │ ├── ModelViewExtendingSuperClassModel_.java │ ├── ModelViewSuperClass.java │ ├── ModelViewSuperClassModel_.java │ ├── ModelViewWithParis.java │ ├── ModelViewWithParisModel_.java │ ├── ModelWithAbstractClass.java │ ├── ModelWithAbstractClassAndAnnotation.java │ ├── ModelWithAbstractClassAndAnnotation_.java │ ├── ModelWithAllFieldTypes.java │ ├── ModelWithAllFieldTypesBuilder.java │ ├── ModelWithAllFieldTypes_.java │ ├── ModelWithAllPrivateFieldTypes.java │ ├── ModelWithAllPrivateFieldTypes_.java │ ├── ModelWithAnnotatedClass.java │ ├── ModelWithAnnotatedClassAndSuperAttributes$SubModelWithAnnotatedClassAndSuperAttributes_.java │ ├── ModelWithAnnotatedClassAndSuperAttributes.java │ ├── ModelWithAnnotatedClassAndSuperAttributes_.java │ ├── ModelWithAnnotatedClass_.java │ ├── ModelWithAnnotation.java │ ├── ModelWithAnnotation_.java │ ├── ModelWithCheckedChangeListener.java │ ├── ModelWithCheckedChangeListener_.java │ ├── ModelWithConstructors.java │ ├── ModelWithConstructors_.java │ ├── ModelWithDataBindingBinding.java │ ├── ModelWithDataBindingBindingModel_.java │ ├── ModelWithDataBindingWithoutDonothashBinding.java │ ├── ModelWithDataBindingWithoutDonothashBindingModel_.java │ ├── ModelWithFieldAnnotation.java │ ├── ModelWithFieldAnnotation_.java │ ├── ModelWithFinalClass.java │ ├── ModelWithFinalField.java │ ├── ModelWithFinalField_.java │ ├── ModelWithIntDef.java │ ├── ModelWithIntDef_.java │ ├── ModelWithPrivateFieldWithGetterWithParams.java │ ├── ModelWithPrivateFieldWithIsPrefixGetter.java │ ├── ModelWithPrivateFieldWithPrivateGetter.java │ ├── ModelWithPrivateFieldWithPrivateSetter.java │ ├── ModelWithPrivateFieldWithSameAsFieldGetterAndSetterName.java │ ├── ModelWithPrivateFieldWithSameAsFieldGetterAndSetterName_.java │ ├── ModelWithPrivateFieldWithSettterWithoutParams.java │ ├── ModelWithPrivateFieldWithStaticGetter.java │ ├── ModelWithPrivateFieldWithStaticSetter.java │ ├── ModelWithPrivateFieldWithoutGetter.java │ ├── ModelWithPrivateFieldWithoutGetterAndSetter.java │ ├── ModelWithPrivateFieldWithoutSetter.java │ ├── ModelWithPrivateInnerClass.java │ ├── ModelWithPrivateViewClickListener.java │ ├── ModelWithPrivateViewClickListener_.java │ ├── ModelWithStaticField.java │ ├── ModelWithSuper.java │ ├── ModelWithSuperAttributes$SubModelWithSuperAttributes_.java │ ├── ModelWithSuperAttributes.java │ ├── ModelWithSuperAttributes_.java │ ├── ModelWithSuper_.java │ ├── ModelWithType.java │ ├── ModelWithType_.java │ ├── ModelWithVarargsConstructors.java │ ├── ModelWithVarargsConstructors_.java │ ├── ModelWithViewClickListener.java │ ├── ModelWithViewClickListener_.java │ ├── ModelWithViewLongClickListener.java │ ├── ModelWithViewLongClickListener_.java │ ├── ModelWithoutEpoxyExtension.java │ ├── ModelWithoutHash.java │ ├── ModelWithoutHash_.java │ ├── ModelWithoutSetter.java │ ├── ModelWithoutSetter_.java │ ├── NullOnRecycleView.java │ ├── NullOnRecycleViewModel_.java │ ├── NullOnRecycleView_throwsIfNotNullable.java │ ├── ObjectWithoutEqualsThrowsView.java │ ├── OnViewRecycledView.java │ ├── OnViewRecycledViewModel_.java │ ├── OnViewRecycledView_throwsIfHasParams.java │ ├── OnViewRecycledView_throwsIfPrivate.java │ ├── OnViewRecycledView_throwsIfStatic.java │ ├── OnVisibilityChangedView.java │ ├── OnVisibilityChangedViewModel_.java │ ├── OnVisibilityChangedView_throwsIfInvalidParams.java │ ├── OnVisibilityChangedView_throwsIfNoParams.java │ ├── OnVisibilityChangedView_throwsIfPrivate.java │ ├── OnVisibilityChangedView_throwsIfStatic.java │ ├── OnVisibilityStateChangedView.java │ ├── OnVisibilityStateChangedViewModel_.java │ ├── OnVisibilityStateChangedView_throwsIfInvalidParams.java │ ├── OnVisibilityStateChangedView_throwsIfNoParams.java │ ├── OnVisibilityStateChangedView_throwsIfPrivate.java │ ├── OnVisibilityStateChangedView_throwsIfStatic.java │ ├── PropDefaultsView.java │ ├── PropDefaultsViewModel_.java │ ├── PropDefaultsView_throwsForNonFinalValue.java │ ├── PropDefaultsView_throwsForNonStaticValue.java │ ├── PropDefaultsView_throwsForNotFound.java │ ├── PropDefaultsView_throwsForPrivateValue.java │ ├── PropDefaultsView_throwsForWrongType.java │ ├── PropGroupsView.java │ ├── PropGroupsViewModel_.java │ ├── Prop_throwsIfMultipleParams.java │ ├── Prop_throwsIfNoParams.java │ ├── Prop_throwsIfPrivate.java │ ├── Prop_throwsIfStatic.java │ ├── RLayoutInViewModelAnnotationWorksViewModel_.java │ ├── RequireAbstractModelFailsClassWithAttribute.java │ ├── RequireAbstractModelFailsEpoxyModelClass.java │ ├── RequireAbstractModelPassesClassWithAttribute.java │ ├── RequireAbstractModelPassesEpoxyModelClass.java │ ├── SavedStateView.java │ ├── SavedStateViewModel_.java │ ├── StringOverloads_throwsIfNotCharSequence.java │ ├── TestAfterBindPropsSuperView.java │ ├── TestAfterBindPropsView.java │ ├── TestAfterBindPropsViewModel_.java │ ├── TestCallbackPropMustBeNullableView.java │ ├── TestCallbackPropView.java │ ├── TestCallbackPropViewModel_.java │ ├── TestFieldPropCallbackPropView.java │ ├── TestFieldPropCallbackPropViewModel_.java │ ├── TestFieldPropChildView.java │ ├── TestFieldPropChildViewModel_.java │ ├── TestFieldPropDoNotHashOptionView.java │ ├── TestFieldPropDoNotHashOptionViewModel_.java │ ├── TestFieldPropGenerateStringOverloadsOptionView.java │ ├── TestFieldPropGenerateStringOverloadsOptionViewModel_.java │ ├── TestFieldPropIgnoreRequireHashCodeOptionView.java │ ├── TestFieldPropIgnoreRequireHashCodeOptionViewModel_.java │ ├── TestFieldPropModelPropView.java │ ├── TestFieldPropModelPropViewModel_.java │ ├── TestFieldPropNullOnRecycleOptionView.java │ ├── TestFieldPropNullOnRecycleOptionViewModel_.java │ ├── TestFieldPropParentView.java │ ├── TestFieldPropStringOverloadsIfNotCharSequenceView.java │ ├── TestFieldPropTextPropView.java │ ├── TestFieldPropTextPropViewModel_.java │ ├── TestFieldPropThrowsIfPrivateView.java │ ├── TestFieldPropThrowsIfStaticView.java │ ├── TestManyTypesView.java │ ├── TestManyTypesViewModelBuilder.java │ ├── TestManyTypesViewModel_.java │ ├── TestNullStringOverloadsView.java │ ├── TestNullStringOverloadsViewModel_.java │ ├── TestStringOverloadsView.java │ ├── TestStringOverloadsViewModel_.java │ ├── TestTextPropIfNotCharSequenceView.java │ ├── TestTextPropMustBeCharSequenceView.java │ ├── TestTextPropView.java │ ├── TestTextPropViewModel_.java │ ├── TextPropDefaultView.java │ ├── TextPropDefaultViewModel_.java │ ├── TextPropDefaultView_throwsForNonStringRes.java │ ├── ViewProcessorTest/ │ │ ├── annotationsAreCopied/ │ │ │ ├── SourceView.kt │ │ │ ├── SourceViewModelBuilder.java │ │ │ ├── SourceViewModel_.java │ │ │ └── ksp/ │ │ │ ├── SourceViewModelBuilder.java │ │ │ └── SourceViewModel_.java │ │ ├── annotationsAreCopied_java/ │ │ │ ├── SourceView.java │ │ │ ├── SourceViewModelBuilder.java │ │ │ ├── SourceViewModel_.java │ │ │ └── ksp/ │ │ │ ├── SourceViewModelBuilder.java │ │ │ └── SourceViewModel_.java │ │ ├── inheritingAttributesWorksCorrectly/ │ │ │ ├── SourceView.kt │ │ │ ├── SourceViewModelBuilder.java │ │ │ ├── SourceViewModel_.java │ │ │ └── ksp/ │ │ │ ├── SourceViewModelBuilder.java │ │ │ └── SourceViewModel_.java │ │ ├── inheritingAttributesWorksCorrectlyJavaClassPath/ │ │ │ ├── SourceView.kt │ │ │ ├── SourceViewModelBuilder.java │ │ │ ├── SourceViewModel_.java │ │ │ └── ksp/ │ │ │ ├── SourceViewModelBuilder.java │ │ │ └── SourceViewModel_.java │ │ ├── inheritingAttributesWorksCorrectlyJavaSources/ │ │ │ ├── AirEpoxyModel.java │ │ │ ├── SourceView.kt │ │ │ ├── SourceViewModelBuilder.java │ │ │ ├── SourceViewModel_.java │ │ │ └── ksp/ │ │ │ ├── SourceViewModelBuilder.java │ │ │ └── SourceViewModel_.java │ │ ├── testManyTypes/ │ │ │ ├── EpoxyModelViewProcessorKotlinExtensions.kt │ │ │ ├── TestManyTypesView.kt │ │ │ ├── TestManyTypesViewModelBuilder.java │ │ │ ├── TestManyTypesViewModel_.java │ │ │ └── ksp/ │ │ │ ├── EpoxyModelViewProcessorKotlinExtensions.kt │ │ │ ├── TestManyTypesViewModelBuilder.java │ │ │ └── TestManyTypesViewModel_.java │ │ ├── testStyleableViewKotlinSources/ │ │ │ ├── ModelViewWithParis.kt │ │ │ ├── ModelViewWithParisModel_.java │ │ │ └── ksp/ │ │ │ └── ModelViewWithParisModel_.java │ │ └── wildcardHandling/ │ │ ├── AirEpoxyModel.java │ │ ├── SourceView.kt │ │ ├── SourceViewModelBuilder.java │ │ ├── SourceViewModel_.java │ │ └── ksp/ │ │ ├── SourceViewModelBuilder.java │ │ └── SourceViewModel_.java │ ├── baseModelFromPackageConfig/ │ │ ├── BaseModelViewModel_.java │ │ └── ksp/ │ │ ├── BaseModelViewModel_.java │ │ └── actual/ │ │ └── BaseModelViewModel_.java │ ├── baseModelFromPackageConfigIsOverriddenByViewSetting/ │ │ ├── BaseModelViewModel_.java │ │ └── ksp/ │ │ ├── BaseModelViewModel_.java │ │ └── actual/ │ │ └── BaseModelViewModel_.java │ ├── baseModelWithAttribute/ │ │ ├── BaseModelViewModel_.java │ │ └── ksp/ │ │ ├── BaseModelViewModel_.java │ │ └── actual/ │ │ └── BaseModelViewModel_.java │ ├── baseModelWithDiffBind/ │ │ ├── BaseModelViewModel_.java │ │ └── ksp/ │ │ ├── BaseModelViewModel_.java │ │ └── actual/ │ │ └── BaseModelViewModel_.java │ ├── ksp/ │ │ ├── AbstractEpoxyModelWithView_.java │ │ ├── AutoLayoutModelViewManualLayoutParamsModel_.java │ │ ├── AutoLayoutModelViewMatchParentModel_.java │ │ ├── AutoLayoutModelViewModel_.java │ │ ├── BaseModelViewModel_.java │ │ ├── BasicModelWithAttribute_.java │ │ ├── ControllerWithAutoModelWithSuperClass$SubControllerWithAutoModelWithSuperClass_EpoxyHelper.java │ │ ├── ControllerWithAutoModelWithSuperClass_EpoxyHelper.java │ │ ├── ControllerWithAutoModel_EpoxyHelper.java │ │ ├── CustomPackageLayoutPatternViewModel_.java │ │ ├── DataBindingModelWithAllFieldTypes_.java │ │ ├── DefaultPackageLayoutPatternViewModel_.java │ │ ├── DoNotHashViewModel_.java │ │ ├── EpoxyModelGroupWithAnnotations_.java │ │ ├── GenerateDefaultLayoutMethodNextParentLayout$NoLayout_.java │ │ ├── GenerateDefaultLayoutMethodNextParentLayout$StillNoLayout_.java │ │ ├── GenerateDefaultLayoutMethodNextParentLayout$WithLayout_.java │ │ ├── GenerateDefaultLayoutMethodParentLayout$NoLayout_.java │ │ ├── GenerateDefaultLayoutMethodParentLayout$WithLayout_.java │ │ ├── GenerateDefaultLayoutMethod_.java │ │ ├── GeneratedModelSuffixViewSuffix_.java │ │ ├── GridSpanCountViewModel_.java │ │ ├── IgnoreRequireHashCodeViewModel_.java │ │ ├── LayoutOverloadsViewModel_.java │ │ ├── ModelDoNotHash_.java │ │ ├── ModelDoNotUseInToString_.java │ │ ├── ModelForRProcessingTest_.java │ │ ├── ModelReturningClassTypeWithVarargs_.java │ │ ├── ModelReturningClassType_.java │ │ ├── ModelViewExtendingSuperClassModel_.java │ │ ├── ModelViewSuperClassModel_.java │ │ ├── ModelViewWithParisModel_.java │ │ ├── ModelWithAbstractClassAndAnnotation_.java │ │ ├── ModelWithAllFieldTypesBuilder.java │ │ ├── ModelWithAllFieldTypes_.java │ │ ├── ModelWithAllPrivateFieldTypes_.java │ │ ├── ModelWithAnnotatedClass_.java │ │ ├── ModelWithAnnotation_.java │ │ ├── ModelWithCheckedChangeListener_.java │ │ ├── ModelWithConstructors_.java │ │ ├── ModelWithFieldAnnotation_.java │ │ ├── ModelWithFinalField_.java │ │ ├── ModelWithPrivateFieldWithSameAsFieldGetterAndSetterName_.java │ │ ├── ModelWithPrivateViewClickListener_.java │ │ ├── ModelWithSuperAttributes$SubModelWithSuperAttributes_.java │ │ ├── ModelWithSuperAttributes_.java │ │ ├── ModelWithSuper_.java │ │ ├── ModelWithViewClickListener_.java │ │ ├── ModelWithViewLongClickListener_.java │ │ ├── ModelWithoutHash_.java │ │ ├── ModelWithoutSetter_.java │ │ ├── NullOnRecycleViewModel_.java │ │ ├── OnViewRecycledViewModel_.java │ │ ├── OnVisibilityChangedViewModel_.java │ │ ├── OnVisibilityStateChangedViewModel_.java │ │ ├── PropDefaultsViewModel_.java │ │ ├── PropGroupsViewModel_.java │ │ ├── RLayoutInViewModelAnnotationWorksViewModel_.java │ │ ├── SavedStateViewModel_.java │ │ ├── TestAfterBindPropsViewModel_.java │ │ ├── TestCallbackPropViewModel_.java │ │ ├── TestFieldPropCallbackPropViewModel_.java │ │ ├── TestFieldPropChildViewModel_.java │ │ ├── TestFieldPropDoNotHashOptionViewModel_.java │ │ ├── TestFieldPropGenerateStringOverloadsOptionViewModel_.java │ │ ├── TestFieldPropIgnoreRequireHashCodeOptionViewModel_.java │ │ ├── TestFieldPropModelPropViewModel_.java │ │ ├── TestFieldPropNullOnRecycleOptionViewModel_.java │ │ ├── TestFieldPropTextPropViewModel_.java │ │ ├── TestManyTypesViewModelBuilder.java │ │ ├── TestManyTypesViewModel_.java │ │ ├── TestNullStringOverloadsViewModel_.java │ │ ├── TestStringOverloadsViewModel_.java │ │ ├── TestTextPropViewModel_.java │ │ └── TextPropDefaultViewModel_.java │ └── testModelWithHolderGeneratesNewHolderMethod/ │ ├── AbstractModelWithHolder.kt │ ├── AbstractModelWithHolder_.java │ └── ksp/ │ └── AbstractModelWithHolder_.java ├── epoxy-processortest2/ │ ├── .gitignore │ ├── README.md │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── processortest2/ │ │ └── ProcessorTest2Model.java │ └── res/ │ └── values/ │ └── strings.xml ├── epoxy-sample/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── assets/ │ │ ├── exploding_heart.json │ │ └── favourite_app_icon.json │ ├── java/ │ │ └── com/ │ │ └── airbnb/ │ │ └── epoxy/ │ │ └── sample/ │ │ ├── CarouselData.java │ │ ├── ColorData.java │ │ ├── EpoxyConfig.java │ │ ├── MainActivity.java │ │ ├── SampleController.kt │ │ ├── models/ │ │ │ ├── BaseEpoxyHolder.kt │ │ │ ├── BaseView.java │ │ │ ├── CarouselModelGroup.java │ │ │ ├── ColorModel.kt │ │ │ ├── ImageButtonModel.kt │ │ │ ├── SimpleAnimatorListener.java │ │ │ ├── TestModel1.kt │ │ │ ├── TestModel2.kt │ │ │ ├── TestModel3.kt │ │ │ ├── TestModel4.kt │ │ │ ├── TestModel5.kt │ │ │ ├── TestModel6.kt │ │ │ └── TestModel7.kt │ │ └── views/ │ │ ├── GridCarousel.java │ │ └── HeaderView.java │ └── res/ │ ├── drawable/ │ │ ├── ic_add_circle.xml │ │ ├── ic_change.xml │ │ ├── ic_delete.xml │ │ └── ic_shuffle.xml │ ├── layout/ │ │ ├── activity_main.xml │ │ ├── button.xml │ │ ├── model_carousel_group.xml │ │ ├── model_color.xml │ │ ├── model_image_button.xml │ │ ├── number_view.xml │ │ └── view_header.xml │ ├── values/ │ │ ├── dimens.xml │ │ └── strings.xml │ └── values-w820dp/ │ └── dimens.xml ├── epoxy-viewbinder/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle.properties │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── airbnb/ │ │ │ └── epoxy/ │ │ │ ├── EpoxyViewBinder.kt │ │ │ ├── EpoxyViewBinderExtensions.kt │ │ │ ├── EpoxyViewBinderVisibilityTracker.kt │ │ │ ├── EpoxyViewStub.kt │ │ │ └── ViewExtensions.kt │ │ └── res/ │ │ └── values/ │ │ └── ids.xml │ └── test/ │ └── java/ │ └── com/ │ └── example/ │ └── epoxy_viewbinder/ │ └── EpoxyViewBinderTest.kt ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── kotlinsample/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── airbnb/ │ │ │ └── epoxy/ │ │ │ └── kotlinsample/ │ │ │ ├── DragAndDropActivity.kt │ │ │ ├── EpoxyDataBindingPatterns.kt │ │ │ ├── MainActivity.kt │ │ │ ├── StickyHeaderActivity.kt │ │ │ ├── StickyHeaderAdapter.kt │ │ │ ├── StickyHeaderController.kt │ │ │ ├── helpers/ │ │ │ │ ├── EpoxyCarouselNoSnapBuilder.kt │ │ │ │ ├── KotlinEpoxyHolder.kt │ │ │ │ ├── KotlinModel.kt │ │ │ │ ├── ViewBindingEpoxyModelWithHolder.kt │ │ │ │ └── ViewBindingKotlinModel.kt │ │ │ ├── models/ │ │ │ │ ├── CarouselItemCustomView.kt │ │ │ │ ├── ColoredSquareView.kt │ │ │ │ ├── DecoratedLinearGroupModel.kt │ │ │ │ ├── ItemCustomView.kt │ │ │ │ ├── ItemDataClass.kt │ │ │ │ ├── ItemEpoxyHolder.kt │ │ │ │ ├── ItemViewBindingDataClass.kt │ │ │ │ ├── ItemViewBindingEpoxyHolder.kt │ │ │ │ ├── ManualLayoutParamsView.kt │ │ │ │ ├── OnVisibilityEventDrawable.kt │ │ │ │ └── StickyItemEpoxyHolder.kt │ │ │ └── views/ │ │ │ └── CarouselNoSnap.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ └── ic_launcher_background.xml │ │ ├── drawable-v24/ │ │ │ └── ic_launcher_foreground.xml │ │ ├── layout/ │ │ │ ├── activity.xml │ │ │ ├── carousel_custom_view_item.xml │ │ │ ├── colored_square_view.xml │ │ │ ├── custom_view_item.xml │ │ │ ├── data_class_item.xml │ │ │ ├── data_class_view_binding_item.xml │ │ │ ├── decorated_linear_group.xml │ │ │ ├── epoxy_layout_data_binding_item.xml │ │ │ ├── sticky_view_holder_item.xml │ │ │ ├── vertical_linear_group.xml │ │ │ ├── view_binding_holder_item.xml │ │ │ └── view_holder_item.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ └── values/ │ │ ├── colors.xml │ │ ├── ids.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test/ │ └── java/ │ └── com/ │ └── airbnb/ │ └── epoxy/ │ └── kotlinsample/ │ ├── AnnotationModel.kt │ ├── ConstructorWithLambdaModel.kt │ └── ConstructorWithoutLamdaModel.kt ├── ktlint.gradle ├── libs/ │ ├── rt.jar │ └── tools.jar ├── publishing.gradle ├── reports/ │ └── profile/ │ ├── css/ │ │ ├── base-style.css │ │ └── style.css │ ├── js/ │ │ └── report.js │ └── profile-2020-03-31-14-39-05.html └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build_test.yml ================================================ name: Build/Test on: # Trigger on every pull request pull_request: permissions: contents: read jobs: build-test: runs-on: macos-13 steps: - name: Checkout epoxy uses: actions/checkout@v4 - name: Setting up Java 17 uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 with: cache-read-only: ${{ github.ref != 'refs/heads/master' }} - name: Build / Unit tests / Lint run: "./gradlew check --stacktrace" - name: Run UI Tests uses: reactivecircus/android-emulator-runner@v2.32.0 with: api-level: 21 target: default arch: x86_64 emulator-options: -no-skin -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect disable-animations: true script: ./gradlew :epoxy-composeinterop-maverickssample:connectedDebugAndroidTest --stacktrace ================================================ FILE: .gitignore ================================================ # macOS .DS_Store # Mobile Tools for Java (J2ME) .mtj.tmp/ # Package Files # *.war *.ear # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* # IntelliJ *.iml .idea/ !.idea/codeStyles/ !.idea/codeStyleSettings.xml # Android .gradle build/ local.properties # Kotlin .kotlin ================================================ FILE: .idea/codeStyleSettings.xml ================================================ ================================================ FILE: CHANGELOG.md ================================================ # 5.2.1 Fix some issues in KSP2 when multiple processing rounds occur (https://github.com/airbnb/epoxy/pull/1396) # 5.2.0 Migrate to KSP 2 (#1393) # 5.1.4 Change the way the Compose interop works to avoid Android 12 bug (#1370) # 5.1.3 Update to kotlin 1.8.21 Fix click listener kapt bug (#1327) Resolve unchecked call warning for WrappedEpoxyModelClickListener (#1337) Fix refresh KDoc (#1334) epoxy-kspsample : use ksp block to specify arguments (#1347) # 5.1.2 Updates kotlin, ksp, and the xprocessing library. Notably, the androidx.room:room-compiler-processing library (aka xprocessing) has been updated to 2.6.0-alpha01. This version is incompatible with previous versions due to a breaking API change. All annotation processors using this library must be on the same version. Other annotation processors such as Epoxy and Paris also use xprocessing and if you use them you need to use a version of them that also uses xprocessing 2.6.0-alpha01 # 5.1.1 Remove incorrect ksp symbol validation in processing of @EpoxyModelClass # 5.1.0 Updates Kotlin to 1.7.20 and KSP to 1.7.20-1.0.7, as well as the room compiler processing (xprocessing) library to 2.5.0-beta01. Also deletes the epoxy-paging artifact in favor of the newer epoxy-paging3 # 5.0.0 This adds support for Kotlin Symbol Processing, while maintaining backwards compatibility with java annotation processing via the xprocessing library from Room. This includes a major version bump to 5.0.0 because there may be slight behavior differences with KSP, especially for generic types in generated code. For example, if you previously had an epoxy attribute in java source code with a raw type it may now appear in the generated code with a wildcard type, which may require tweaking the type that is passed to the model. Additionally, some type checking was improved, for example more accurate validation of proper equals and hashcode implementations. To use Epoxy with KSP, simply apply it with the ksp gradle plugin instead of kapt (https://github.com/google/ksp/blob/main/docs/quickstart.md). See the new epoxy-kspsample module for an example. Note that unfortunately the databinding processor does NOT support KSP, simply because Android databinding itself uses KAPT and KSP cannot currently depend on KAPT sources. The code changes are in place to enable KSP with databinding once the databinding plugin from Android supports KSP (although this is unlikely) - alternatively it may be possible to configure the KSP plugin to run after KAPT and depend on its outputs (you're on your own if you want to try that). Also, parallel processing support was removed because it is not compatible with KSP. We have also added easy interop with Jetpack Compose via functions in the `epoxy-composeinterop` artifact. See the epoxy-composesample module for example usage. # 4.6.4 (September 23, 2021) - Clean up dependency for the experimental epoxy module # 4.6.3 (September 11, 2021) - Add EpoxyModel#preBind hook(#1225) - Add unbind extension to ItemViewBindingEpoxyHolder (#1223) - Add missing loadStateFlow to PagingDataEpoxyController (#1209) # 4.6.2 (June 11, 2021) Fix Drag n Drop not working in 4.6.1 (#1195) # 4.6.1 (May 13, 2021) Adds "epoxyDisableDslMarker" annotation processor flag which you can use to delay migration to the model building scope DLSMarker introduced in 4.6.0 if it is a large breaking change for your project. Note that this only applies to your project modules that you apply it to, and does not apply to the handful of models that ship with the Epoxy library (like the Carousel or `group` builder). For example: ```groovy project.android.buildTypes.all { buildType -> buildType.javaCompileOptions.annotationProcessorOptions.arguments = [ epoxyDisableDslMarker : "true", ] } ``` # 4.6.0 (May 12, 2021) - View Binder Support (#1175) Bind epoxy models to views outside of a RecyclerView. ### Potentially Breaking - Use kotlin dsl marker for model building receivers (#1180) This change uses Kotlin's DSL marker annotation to enforce proper usage of model building extension functions. You may now need to change some references in your model building code to explicitly reference properties with `this`. # 4.5.0 (April 13, 2021) - Fix generated code consistency in builder interfaces (#1166) - Provided support to invalidate `modelCache` in `PagingDataEpoxyController` (#1161) - Explicitly add public modifier (#1162) - Unwrap context to find parent activity in order to share viewpool when using Hilt (#1157) # 4.4.4 (Mar 24, 2021) - Provide support for snapshot() function in PagingDataEpoxyController (#1144) # 4.4.3 (Mar 17, 2021) - Fixed interface model related regression introduced in the previous release. # 4.4.2 (Mar 1, 2021) - Updated package name of the model class generated for an interface # 4.4.1 (Feb 22, 2021) - Support for Paging3 (#1126) (Thanks to @osipxd and @anhanh11001!) - Update KotlinPoet to 1.7.2 (#1117) # 4.4.0 (Feb 18, 2021) Bad release, don't use # 4.3.1 (Dec 2, 2020) - Fix ANR and view pool resolution in nested group (#1101) # 4.3.0 (Dec 1, 2020) - ModelGroupHolder get recycle pool from parent (#1097) - Add support for `EpoxyModelGroup` in the `EpoxyVisibilityTracker` (#1091) - Convert EpoxyVisibilityTracker code to Kotlin (#1090) ## Breaking Changes Note that due to the conversion of EpoxyVisibilityTracker to kotlin you now need to access `EpoxyVisibilityTracker.partialImpressionThresholdPercentage` as a property `epoxyVisibilityTracker.setPartialImpressionThresholdPercentage(value)` -> `epoxyVisibilityTracker.partialImpressionThresholdPercentage = value` Also, the ModelGroupHolder improvement required the `ModelGroupHolder#createNewHolder` function to change its signature to accept a `ViewParent` parameter. If you override `createNewHolder()` anywhere you will need to change it to `createNewHolder(@NonNull ViewParent parent)` # 4.2.0 (Nov 11, 2020) - Add notify model changed method (#1063) - Update to Kotlin 1.4.20-RC and remove dependency on kotlin-android-extensions # 4.1.0 (Sept 17, 2020) - Fix some synchronization issues with the parallel Epoxy processing option - Add view visibility checks to EpoxyVisibilityItem and decouple RecyclerView #1052 # 4.0.0 (Sept 5, 2020) ## New - Incremental annotation processing for faster builds - Support for Android Jetpack Paging v3 library in new `epoxy-paging3` artifact - Model group building with Kotlin DSL (#1012) - A new annotation processor argument `logEpoxyTimings` can be set to get a detailed breakdown of how long the processors took and where they spent their time (off by default) - Another new argument `enableParallelEpoxyProcessing` can be set to true to have the annotation processor process annotations and generate files in parallel (via coroutines). You can enable these processor options in your build.gradle file like so: ``` project.android.buildTypes.all { buildType -> buildType.javaCompileOptions.annotationProcessorOptions.arguments = [ logEpoxyTimings : "true", enableParallelEpoxyProcessing : "true" ] } ``` Parallel processing can greatly speed up processing time (moreso than the incremental support), but given the hairy nature of parallel processing it is still incubating. Please report any issues or crashes that you notice. (We are currently using parallel mode in our large project at Airbnb with no problems.) - Add options to skip generation of functions for getters, reset, and method overloads to reduce generated code - New annotation processor options are: - epoxyDisableGenerateOverloads - epoxyDisableGenerateGetters - epoxyDisableGenerateReset ## Fixes - Synchronize ListUpdateCallback and PagedListModelCache functions (#987) - Avoid generating bitset checks in models when not needed (reduces code size) - Fix minor memory leak ## Breaking - Annotations that previously targeted package elements now target types (classes or interfaces). This includes: `EpoxyDataBindingPattern`, `EpoxyDataBindingLayouts`, `PackageModelViewConfig`, `PackageEpoxyConfig` This was necessary to work around an incremental annotation processor issue where annotation on package-info elements are not properly recompiled - In order to enable incremental annotation processing a change had to be made in how the processor of `@AutoModel` annotations work. If you use `@AutoModel` in an EpoxyController the annotated Model types must be either declared in a different module from the EpoxyController, or in the same module in the same java package. Also make sure you have kapt error types enabled. However, generally `@AutoModel` is considered legacy and is not recommended. It is a relic of Java Epoxy usage and instead the current best practice is to use Kotlin with the Kotlin model extension functions to build models. - Removed support for generating Epoxy models from Litho components # 4.0.0-beta6 (July 15, 2020) - PackageModelViewConfig can now be applied to classes and interfaces in addition to package-info.java # 4.0.0-beta5 (July 9, 2020) Fixes: - An occasional processor crash when the option to log timings is enabled - Incremental annotation processing of databinding models would fail to generate models (#1014) Breaking! - The annotation that support databinding, `EpoxyDataBindingLayouts` and `EpoxyDataBindingPattern`, must now be placed on a class or interface instead of in a `package-info.java` file. The interface or class must be in Java, Kotlin is not supported. This is necessary to support incremental processing. Example usage: ```java package com.example.app; import com.airbnb.epoxy.EpoxyDataBindingLayouts; import com.airbnb.epoxy.EpoxyDataBindingPattern; @EpoxyDataBindingPattern(rClass = R.class, layoutPrefix = "my_view_prefix") @EpoxyDataBindingLayouts({R.layout.my_model_layout}) interface EpoxyDataBindingConfig {} ``` # 4.0.0-beta4 (June 1, 2020) Fixes: - Synchronize ListUpdateCallback and PagedListModelCache functions (#987) - 4.0.0.beta1 generating duplicate method layout(int) #988 # 4.0.0-beta3 (May 27, 2020) - Sort functions in generated kotlin extension function files deterministically to prevent generated sources from changing - Avoid generating bitset checks in models when not needed - Add options to skip generation of functions for getters, reset, and method overloads to reduce generated code New annotation processor options are: - epoxyDisableGenerateOverloads - epoxyDisableGenerateGetters - epoxyDisableGenerateReset These can also be controlled (and overridden) on a per package level with the `PackageModelViewConfig` package annotation. # 4.0.0-beta1 (May 22, 2020) - Support for incremental annotation processing as an Aggregating processor (#972) - Removed Litho support - A new annotation processor argument `logEpoxyTimings` can be set to get a detailed breakdown of how long the processors took and where they spent their time (off by default) - Another new argument `enableParallelEpoxyProcessing` can be set to true to have the annotation processor process annotations and generate files in parallel (via coroutines). You can enable these processor options in your build.gradle file like so: ``` project.android.buildTypes.all { buildType -> buildType.javaCompileOptions.annotationProcessorOptions.arguments = [ logEpoxyTimings : "true", enableParallelEpoxyProcessing : "true" ] } ``` Parallel processing can greatly speed up processing time (moreso than the incremental support), but given the nature of parallel processing it is still incubating. Please report any issues or crashes that you notice. (We are currently using parallel mode in our large project at Airbnb with no problems.) ## Breaking In order to enable incremental annotation processing a change had to be made in how the processor of `@AutoModel` annotations work. If you use `@AutoModel` in an EpoxyController the annotated Model types must be either declared in a different module from the EpoxyController, or in the same module in the same java package. Also make sure you have kapt error types enabled. However, generally `@AutoModel` is considered legacy and is not recommended. It is a relic of Java Epoxy usage and instead the current best practice is to use Kotlin with the Kotlin model extension functions to build models. # 3.11.0 (May 20, 2020) - Introduce partial impression visibility states (#973) - Fix sticky header crash (#976) # 3.10.0 (May 15, 2020) - Carousel building with Kotlin DSL (#967) - Android ViewBinding: added an example in the sample project. (#939) - Fix setter with default value lookup in kotlin 1.4 (#966) - Change "result" property name in generated model (#965) - Add support for Sticky Headers (#842) - Use measured width/height if it exists in Carousel. (#915) - Add a getter to EpoxyViewHolder.getHolder(). (#952) (#953) - Fix visibility tracking during RecyclerView animations (#962) - Fix leak in ActivityRecyclerPool ((#906) - Rename ResultCallack to ResultCallback in AsyncEpoxyDiffer (#899) - Fix incorrect license attributes in POM file (#898) # 3.9.0 (Dec 17, 2019) - Fix reading EpoxyDataBindingPattern enableDoNotHash (#837) - Make EpoxyRecyclerView.setItemSpacingPx() open (#829) - Use same version for Mockito Core and Inline (#860) - Minor documentation and variable name updates. (#870) - Move epoxy-modelfactory tests to their own module (#834) - Remove executable bit from non-executable files (#864) - Various repo clean ups and version bumps # 3.8.0 (Sept 16, 2019) - Add support for Kotlin delegation via annotated interface properties #812 - Fix checked change crash and improve debug errors #806 - Remove extra space in Kotlin extensions #777 - Update project to AGP 3.5, Kotlin 1.3.50, Gradle 5.6 # 3.7.0 (July 1, 2019) - **New** Add a method to request visibility check externally (https://github.com/airbnb/epoxy/pull/775) # 3.6.0 (June 18, 2019) - **New** Preloader system with glide extensions https://github.com/airbnb/epoxy/pull/766 - **Fixed** model click listener crashing on nested model https://github.com/airbnb/epoxy/pull/767 # 3.5.1 (May 21, 2019) - Bumped Kotlin to 1.3.31 # 3.5.0 (May 8, 2019) - **New** Converted EpoxyRecyclerView to Kotlin (you may need to update your usage for this). Also added built in support for `EpoxyRecyclerView#withModels` for easy inline model building with Kotlin. - **Fixed** Crashes in visibility tracking # 3.4.2 (April 18, 2019) - **Fixed** Kotlin default param handling had issues with overloaded functions # 3.4.1 (April 16, 2019) - **New** Support kotlin default parameters in @ModelView classes (https://github.com/airbnb/epoxy/pull/722) # 3.4.0 (April 10, 2019) - **New** Generate OnModelCheckedChangeListener override for props of type `CompoundButton.OnCheckedChangeListener` (https://github.com/airbnb/epoxy/pull/725) - **New** Extract ID generation methods to new public IdUtils class (https://github.com/airbnb/epoxy/pull/724) - **Changed** Reset controller state on failed model build (https://github.com/airbnb/epoxy/pull/720) - **Changed** Disabled the auto-detach behavior on Carousels by default (https://github.com/airbnb/epoxy/pull/688) # 3.3.0 (Feb 5, 2019) - **Fixed** Two issues related to the recent EpoxyModelGroup changes (https://github.com/airbnb/epoxy/pull/676) # 3.2.0 (Jan 21, 2019) - **New** Enable recycling of views within EpoxyModelGroup (https://github.com/airbnb/epoxy/pull/657) - **New** Add support to tracking visibility in nested RecyclerViews (https://github.com/airbnb/epoxy/pull/633) - **New** Add method to clear cache in paging controller (https://github.com/airbnb/epoxy/pull/586) - **Fix** Crashes from synchronization in PagedListEpoxyController (https://github.com/airbnb/epoxy/pull/656) - **Fix** Get onSwipeProgressChanged callbacks on return to original item position (https://github.com/airbnb/epoxy/pull/654) # 3.1.0 (Dec 4, 2018) - **Fix** Memory leak in debug mode is removed (https://github.com/airbnb/epoxy/pull/613) - **Fix** For visibility callbacks, wrong visibility when the view becomes not visible (https://github.com/airbnb/epoxy/pull/619) # 3.0.0 (Nov 13, 2018) - **Breaking** Migrated to androidx packages (Big thanks to jeffreydelooff!) - **Breaking** The `Carousel.Padding` class changed the ordering of its parameters to match Android's ordering of "left, top, right, bottom". (https://github.com/airbnb/epoxy/pull/536 thanks to martinbonnin) This change won't break compilation, so you _must_ manually change your parameter ordering, otherwise you will get unexpected padding results. # 2.19.0 (Oct 18, 2018) This release adds built in support for monitoring visibility of views in the RecyclerView. (https://github.com/airbnb/epoxy/pull/560) Usage instructions and details are in the wiki - https://github.com/airbnb/epoxy/wiki/Visibility-Events Huge thanks to Emmanuel Boudrant for contributing this! # 2.18.0 (Sep 26, 2018) - **New** A new `PagedListEpoxyController` to improve integration with the Android Paging architecture component (#533 Thanks to Yigit!) With this change the old `PagingEpoxyController` has been deprecated, and [the wiki](https://github.com/airbnb/epoxy/wiki/Paging-Support) is updated. - **New** Add databinding option to not auto apply DoNotHash (#539) - **Fixed** Fix AsyncEpoxyController constructor to correctly use boolean setting (#537) - **Fixed** `app_name` is removed from module manifests (#543 Thanks @kettsun0123!) # 2.17.0 (Sep 6, 2018) - **New** Add support for setting the Padding via resource or directly in dp (https://github.com/airbnb/epoxy/pull/528 Thanks to pwillmann!) - **Fixed** Strip kotlin metadata annotation from generated classes (https://github.com/airbnb/epoxy/pull/523) - **Fixed** Reflect the annotations declared in constructor params (https://github.com/airbnb/epoxy/pull/519 Thanks to Shaishav Gandhi!) # 2.16.4 (Aug 29, 2018) - **New** `EpoxyAsyncUtil` and `AsyncEpoxyController` make it easier to use Epoxy's async behavior out of the box - **New** Epoxy's background diffing posts messages back to the main thread asynchronously so they are not blocked by waiting for vsync # 2.16.3 (Aug 24, 2018) - **New** Add `AsyncEpoxyController` for easy access to async support. Change background diffing to post asynchronously to the main thread (https://github.com/airbnb/epoxy/pull/509) # 2.16.2 (Aug 23, 2018) - **Fix** Kotlin lambdas can be used in model constructors (https://github.com/airbnb/epoxy/pull/501) - **New** Added function to check whether a model build is pending (https://github.com/airbnb/epoxy/pull/506) # 2.16.1 (Aug 15, 2018) - **Fix** Update EpoxyController async model building so threading works with tests (https://github.com/airbnb/epoxy/pull/504) # 2.16.0 (Aug 7, 2018) - **New** EpoxyController now supports asynchronous model building and diffing by allowing you to provide a custom Handler to run these tasks. See the [wiki](https://github.com/airbnb/epoxy/wiki/Epoxy-Controller#asynchronous-support) for more details. - **New** The `EpoxyController#addModelBuildListener` method was added to support listening for when model changes are dispatched to the recyclerview. # 2.15.0 (July 29, 2018) - **New** Added kotlin sample code for building models. Updated wiki with info (https://github.com/airbnb/epoxy/wiki/Kotlin-Model-Examples) - **Fix** Generated kotlin extension functions now work with Models with type variables (https://github.com/airbnb/epoxy/pull/478) - **Fix** Backup is not enabled in manifest now (https://github.com/airbnb/epoxy/pull/481) - **Fix** Click listener setter on generated model has correct nullability annotation (https://github.com/airbnb/epoxy/pull/458) - **Fix** Avoid kotlin crash using toString on lambdas (https://github.com/airbnb/epoxy/pull/482) - **Fix** If EpoxyModelGroup has annotations the generated class now calls super methods correctly. (https://github.com/airbnb/epoxy/pull/483) # 2.14.0 (June 27, 2018) - **New** Experimental support for creating Epoxy models from arbitrary data formats (#450) # 2.13.0 (June 19, 2018) - **Fix** Reduce memory usage in model groups and differ (#433) - **Fix** Support for wildcards in private epoxy attributes (#451) - **Fix** Generated Kotlin Extensions Don't Adhere to Constructor Nullability (#449) - **Fix** Infinite loop in annotation processor (#447) # 2.12.0 (April 18, 2018) - **Breaking** Several updates to the Paging Library integration were made (https://github.com/airbnb/epoxy/pull/421) - The `PagingEpoxyController` class had the methods `setNumPagesToLoad` and `setPageSizeHint` removed - Page hints are now taken from the `Config` object off of the PagedList. See the `setConfig` javadoc for information on how config values are used: https://github.com/airbnb/epoxy/blob/master/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagingEpoxyController.java#L220 - Several tweaks were made to how the page size and prefetch distance affect model rebuilding. Take some time to make sure your config values make sense and produce good results for your use case - A crash on empty list was fixed (https://github.com/airbnb/epoxy/issues/420) - **New** The [Paris](https://github.com/airbnb/paris) library is now officially supported to allow dynamically styling RecyclerView items though Epoxy models. See [the wiki](https://github.com/airbnb/epoxy/wiki/Paris-Integration-(Dynamic-Styling)) for more info. # 2.11.0 (April 7, 2018) - **Fix** Make databinding work with Android Studio 3.1 (https://github.com/airbnb/epoxy/pull/418) - Make `EpoxyController#isBuildingModels` public (https://github.com/airbnb/epoxy/pull/406 # 2.10.0 (February 25, 2018) - **Improved** Allow the `Model_` class suffix for models generated via `@ModelView` to be customized (https://github.com/airbnb/epoxy/pull/402 Big thanks to geralt-encore!) # 2.9.0 (January 29, 2018) - **Improved** Global defaults for EpoxyController settings. Set duplicate filtering and exception handlers for all your controllers. (https://github.com/airbnb/epoxy/pull/394) - **Improved** Add `@NonNull` annotations in EpoxyModel for better Kotlin interop - **Fixed** Model click listeners now rebind correctly on partial model diffs (https://github.com/airbnb/epoxy/pull/393) - **Fixed** Update Android Paging library to fix placeholder support (Thanks @wkranich! https://github.com/airbnb/epoxy/pull/360) - **Fixed** Improve error message for inaccessible private fields (https://github.com/airbnb/epoxy/pull/388) # 2.8.0 (December 22, 2017) - **New** Use `@ModelProp` directly on fields to avoid creating a setter (https://github.com/airbnb/epoxy/pull/343) - **New** Set EpoxyRecyclerView item spacing via xml attribute (https://github.com/airbnb/epoxy/pull/364) - **New** More flexibility over setting Carousel padding values (https://github.com/airbnb/epoxy/pull/369) - **New** Allow custom EpoxyModelGroup root view (https://github.com/airbnb/epoxy/pull/370) - **Fixed** Public visibility settings of the Carousel snap helper settings (https://github.com/airbnb/epoxy/pull/356) - **Fixed** Add more nullability annotations to better support Kotlin - **Fixed** Saving view state now works better (https://github.com/airbnb/epoxy/pull/367) # 2.7.3 (November 21, 2017) - **Fixed** When a model changed and a partial update was bound to an existing view the wrong values could be set for prop groups (https://github.com/airbnb/epoxy/pull/347) # 2.7.2 (October 28, 2017) - **Fixed** Using `EpoxyDataBindingPattern` could result in the wrong package being used for the BR class in generated models. # 2.7.1 (October 24, 2017) Several fixes: - https://github.com/airbnb/epoxy/pull/332 - https://github.com/airbnb/epoxy/pull/329 - https://github.com/airbnb/epoxy/pull/330 - https://github.com/airbnb/epoxy/pull/331 # 2.7.0 (October 17, 2017) * **New** If a `@ModelView` generated model has a custom base class the generated model will now inherit constructors from the base class (https://github.com/airbnb/epoxy/pull/315) * **New** Use the `EpoxyDataBindingPattern` annotation to specify a naming pattern for databinding layouts. This removes the need to declare every databinding layout explicitly ([Wiki](https://github.com/airbnb/epoxy/wiki/Data-Binding-Support#automatic-based-on-naming-pattern) - https://github.com/airbnb/epoxy/pull/319) * **New** If a view with `@ModelView` implements an interface then the generated model will implement a similar interface, enabling polymorphism with models. [Wiki](https://github.com/airbnb/epoxy/wiki/Generating-Models-from-View-Annotations#view-interfaces) * **Improvement** `PagingEpoxyController` now has getters to access the underlying data lists (Thanks to @pcqpcq - https://github.com/airbnb/epoxy/pull/317) * **Improvement** `EpoxyModelGroup` now supports partial rebinds (https://github.com/airbnb/epoxy/pull/316) # 2.6.0 (October 10, 2017) * **Improvement** If a `OnModelClickListener` is used it will not be called if a view is clicked while it is being removed or otherwise has no position (https://github.com/airbnb/epoxy/issues/293 - Thanks @niccorder!) * **New** `EpoxyRecyclerView` and `Carousel` provide out of the box integration with Epoxy along with other enhancements over regular RecyclerView (https://github.com/airbnb/epoxy/wiki/EpoxyRecyclerView) * **New** `EpoxyPagingController` provides integration with the Android Paging architecture component as well as normal, large lists of items (https://github.com/airbnb/epoxy/wiki/Large-Data-Sets) #### Kotlin * **Improvement** Disable kotlin extension function generation with the annotation processor flag `disableEpoxyKotlinExtensionGeneration` (https://github.com/airbnb/epoxy/pull/309) * **Fix** If a model has a non empty constructor the generated extension function will now use it. # 2.5.1 (October 2, 2017) * **Fixed** The wrong import was being generated for models using a view holder in 2.5.0 (https://github.com/airbnb/epoxy/pull/294) * **Fixed** Fix generated code failing to compile if a subclass of View.OnClickListener is used as an attribute (https://github.com/airbnb/epoxy/pull/296) # 2.5.0 (September 14, 2017) * **New Feature** Epoxy now generates a Kotlin DSL to use when building models in your EpoxyController! See [the wiki](https://github.com/airbnb/epoxy/wiki/Epoxy-Controller#usage-with-kotlin) for details * **New Feature** You can use the `autoLayout` parameter in `@ModelView` instead of needing to create a layout resource for `defaultLayout`. Epoxy will then create your view programmatically (https://github.com/airbnb/epoxy/pull/282). **Breaking** * The `onSwipeProgressChanged` callback in `EpoxyTouchHelper` had a `Canvas` parameter added (https://github.com/airbnb/epoxy/pull/280). You will need to update any of your usages to add this. Sorry for the inconvenience; this will hopefully help you add better swipe animations. # 2.4.0 (September 4, 2017) * **Improvement** If you are setting options on a @ModelProp and have no other annotation parameters you can now omit the explicit `options = ` param name (https://github.com/airbnb/epoxy/pull/268) * **Improvement** If you are using `@TextProp` you can now specify a default string via a string resource (https://github.com/airbnb/epoxy/pull/269) * **Fixed** EpoxyModelGroup was not binding model click listeners correctly (https://github.com/airbnb/epoxy/pull/267) * **Fixed** A model created with @ModelView could fail to compile if it had nullable prop overloads (https://github.com/airbnb/epoxy/pull/274) #### Potentially Breaking Fix A model created with @ModelView with a click listener had the wrong setter name for the model click listener overload (https://github.com/airbnb/epoxy/pull/275) If you were setting this you will need to update the setter name. If you were setting the click listener to null you may now have to cast it. # 2.3.0 (August 16, 2017) * **New** An `AfterPropsSet` annotation for use in `@ModelView` classes. This allows initialization work to be done after all properties are bound from the model. (https://github.com/airbnb/epoxy/pull/242) * **New** Annotations `TextProp` and `CallbackProp` as convenient replacements for `ModelProp`. (https://github.com/airbnb/epoxy/pull/260) * **New** Easy support for dragging and swiping via the `EpoxyTouchHelper` class. https://github.com/airbnb/epoxy/wiki/Touch-Support * **Change** Added the method `getRootView` to the view holder class in `EpoxyModelGroup` and made the bind methods on `EpoxyModelGroup` non final. This allows access to the root view of the group. * **Change** Generated models will now inherit class annotations from the base class (https://github.com/airbnb/epoxy/pull/255 Thanks geralt-encore!) # 2.2.0 (June 19, 2017) * **Main Feature** Models can now be completely generated from a custom view via annotations on the view. This should completely remove the overhead of creating a model manually in many cases! For more info, see [the wiki](https://github.com/airbnb/epoxy/wiki/Generating-Models-from-View-Annotations) * **New** Lowered the minimum SDK from 16 to 14. * **New** Models that have a `View.OnLongClickListener` as an EpoxyAttribute will now have an overloaded setter on the generated model that allows you to set a long click listener that will return the model, view, and adapter position. This is very similar to the `View.OnClickListener` support added in 2.0.0, but for long click listeners. **Upgrade Note** If you were setting a long click listener value to null anywhere you will need to now cast that to `View.OnLongClickListener` because of the new overloaded method. * **New** `id` overload on EpoxyModel to define a model id with multiple strings * **New** Option in `EpoxyAttribute` to not include the attribute in the generated `toString` method (Thanks to @geralt-encore!) * **New** @AutoModel models are now inherited from usages in super classes (Thanks to @geralt-encore!) * **Fixed** Generated getters could recursively call themselves (Thanks to @geralt-encore!) # 2.1.0 (May 9, 2017) * **New**: Support for Android Data Binding! Epoxy will now generate an EpoxyModel directly from a Data Binding xml layout, and handle all data binding details automatically. Thanks to @geralt-encore for helping with this! See more details in [the wiki](https://github.com/airbnb/epoxy/wiki/Data-Binding-Support). * **New**: Support for Litho. Epoxy will now generate an EpoxyModel for Litho Layout Specs. See more details in [the wiki](https://github.com/airbnb/epoxy/wiki/Litho-Support). * **New**: Support for implicitly adding AutoModels to an EpoxyController, this let's you drop the extra `.addTo(this)` line. More details and instructions [here](https://github.com/airbnb/epoxy/wiki/Epoxy-Controller#implicit-adding) # 2.0.0 (March 25, 2017) * **New**: The `EpoxyController` class helps you manage even models better. This should be used instead of the original `EpoxyAdapter` in most places. Read more about `EpoxyController` in [the wiki](https://github.com/airbnb/epoxy/wiki/Epoxy-Controller). * **Change**: In the new EpoxyController, the diffing algorithm uses both `equals` and `hashCode` on each model to check for changes. This is a change from the EpoxyAdapter where only `hashCode` was used. Generated models have both hashCode and equals implemented properly already, but if you have any custom hashCode implementations in your models make sure you have equals implemented as well. * **New**: Models that have a `View.OnClickListener` as an EpoxyAttribute will now have an overloaded setter on the generated model that allows you to set a click listener that will return the model, view, and adapter position. **Upgrade Note** If you were setting a click listener value to null anywhere you will need to now cast that to `View.OnClickListener` because of the new overloaded method. * **New**: Attach an onBind/onUnbind listener directly to a model instead of overriding the onModelBound method. Generated models will have methods created to set this listener and handle the callback for you. * **New**: Support for creating models in Kotlin (Thanks to @geralt-encore! https://github.com/airbnb/epoxy/pull/144) * **New**: `EpoxyModelWithView` supports creating a View programmatically instead of inflating from XML. * **New**: `EpoxyModelGroup` supports grouping models together in arbitrary formations. * **New**: Instead of setting attribute options like `@EpoxyAttribute(hash = false)` you should now do `@EpoxyAttribute(DoNotHash)`. You can also set other options like that. * **New**: Annotation processor options can now be set via gradle instead of with `PackageEpoxyConfig` * **New**: In an EpoxyController, if a model with the same id changes state Epoxy will include its previous state as a payload in the change notification. The new model will have its `bind(view, previouslyBoundModel)` method called so it can compare what changed since the previous model, and so it can update the view with only the data that changed. # 1.7.5 (Feb 21, 2017) * **New**: Models inherit layouts specified in superclass `@EpoxyModelClass` annotations [#119](https://github.com/airbnb/epoxy/pull/119) * **New**: Support module configuration options [#124](https://github.com/airbnb/epoxy/pull/124) # 1.6.2 (Feb 8, 2017) * New: Support layout resource annotations in library projects (https://github.com/airbnb/epoxy/pull/116) # 1.6.1 (Feb 6, 2017) * Allow the default layout resource to be specified in the EpoxyModelClass class annotation [(#109)](https://github.com/airbnb/epoxy/pull/109) [(#111)](https://github.com/airbnb/epoxy/pull/111) * Allow the `createNewHolder` method to be omitted and generated automatically [(#105)](https://github.com/airbnb/epoxy/pull/105) * Generate a subclass for abstract model classes if the EpoxyModelClass annotation is present [(#105)](https://github.com/airbnb/epoxy/pull/105) * Allow strings as model ids [(#107)](https://github.com/airbnb/epoxy/pull/107) * Add instructions to readme for avoiding memory leaks [(#106)](https://github.com/airbnb/epoxy/pull/106) * Add model callbacks for view attached/detached from window, and onFailedToRecycleView [(#104)](https://github.com/airbnb/epoxy/pull/104) * Improve documentation on model unbind behavior [(#103)](https://github.com/airbnb/epoxy/pull/103) * Fix generated methods from super classes that have var args [(#100)](https://github.com/airbnb/epoxy/pull/100) * Remove apt dependency [(#95)](https://github.com/airbnb/epoxy/pull/95) * Add `removeAllModels` method to EpoxyAdapter [(#94)](https://github.com/airbnb/epoxy/pull/94) * Use actual param names when generating methods from super classes [(#85)](https://github.com/airbnb/epoxy/pull/85) # 1.5.0 (11/21/2016) * Fixes models being used in separate modules * Generates a `reset()` method on each model to reset annotated fields to their defaults. * Changes `@EpoxyAttribute(hash = false)` to still differentiate between null and non null values in the hashcode implementation * Adds a `notifyModelChanged` method to EpoxyAdapter that allows a payload to be specified * Generates a `toString()` method on all generated model classes that includes the values of all annotated fields. # 1.4.0 (10/13/2016) * Optimizations to the diffing algorithm * Setters on generated classes are not created if an @EpoxyAttribute field is marked as `final` * Adds @EpoxyModelClass annotation to force a model to have a generated class, even if it doesn't have any @EpoxyAttribute fields * Fix to not generate methods for package private @EpoxyAttribute fields that are in a different package from the generated class * Have generated classes duplicate any super methods that have the model as the return type to help with chaining # 1.3.0 (09/15/2016) * Add support for using the view holder pattern with models. See the readme for more information. * Throw an exception if `EpoxyAdapter#notifyDataSetChanged()` is called when diffing is enabled. It doesn't make sense to allow this alongside diffing, and calling this is most likely to be an accidental mixup with `notifyModelsChanged()`. * Some performance improvements with the diffing algorithm. # 1.2.0 (09/07/2016) * Change signature of `EpoxyAdapter#onModelBound` to include the model position * Fix EpoxyModel hashcode to include the layout specified by `getDefaultLayout` * Enforce that the id of an `EpoxyModel` cannot change once it has been added to the adapter * Add optional hash parameter to the `EpoxyAttribute` annotation to exclude a field from being included in the generated hashcode method. # 1.1.0 (08/24/2016) * Initial release ================================================ FILE: CONTRIBUTING.MD ================================================ # Epoxy is an Open Source Project Pull requests are welcome! We'd love help improving this library. We have a code style setting for the project (checkstyle for Java, ktlint for Kotlin). Please run `Reformat Code` in Android Studio (or Intellij) on changed files before pushing them. Alternatively for Kotlin you can use [ktlint](https://ktlint.github.io/) tasks: check Kotlin code formatting with`./gradlew ktlint` and reformat all Kotlin code with `./gradlew ktlintformat`. Also, run `./gradlew check` locally to make sure that style checks and tests pass. If you update the model annotation processor you may find the `UpdateProcessorTestResults.kt` script very useful for updating the existing tests with your changes. (run it with kscript) - Run ./gradlew testDebug first to get test failures, then run `kscript UpdateProcessorTestResources.kt` to updates sources - You may have to repeat this cycle several times for all tests to be updated. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2018 Airbnb, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ [![Build Status](https://travis-ci.com/airbnb/epoxy.svg?branch=master)](https://travis-ci.com/github/airbnb/epoxy) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.airbnb.android/epoxy/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.airbnb.android/epoxy) [![GitHub license](https://img.shields.io/github/license/airbnb/epoxy)](https://github.com/airbnb/epoxy/blob/master/LICENSE) ![GitHub contributors](https://img.shields.io/github/contributors/airbnb/epoxy) # Epoxy Epoxy is an Android library for building complex screens in a RecyclerView. Models are automatically generated from custom views or databinding layouts via annotation processing. These models are then used in an EpoxyController to declare what items to show in the RecyclerView. This abstracts the boilerplate of view holders, diffing items and binding payload changes, item types, item ids, span counts, and more, in order to simplify building screens with multiple view types. Additionally, Epoxy adds support for saving view state and automatic diffing of item changes. [We developed Epoxy at Airbnb](https://medium.com/airbnb-engineering/epoxy-airbnbs-view-architecture-on-android-c3e1af150394#.xv4ymrtmk) to simplify the process of working with RecyclerViews, and to add the missing functionality we needed. We now use Epoxy for most of the main screens in our app and it has improved our developer experience greatly. * [Installation](#installation) * [Basic Usage](#basic-usage) * [Documentation](#documentation) * [Min SDK](#min-sdk) * [Contributing](#contributing) * [Sample App](https://github.com/airbnb/epoxy/wiki/Sample-App) ## Installation Gradle is the only supported build configuration, so just add the dependency to your project `build.gradle` file: ```groovy dependencies { implementation "com.airbnb.android:epoxy:$epoxyVersion" // Add the annotation processor if you are using Epoxy's annotations (recommended) annotationProcessor "com.airbnb.android:epoxy-processor:$epoxyVersion" } ``` Replace the variable `$epoxyVersion` with the latest version : [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.airbnb.android/epoxy/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.airbnb.android/epoxy) See the [releases page](https://github.com/airbnb/epoxy/releases) for up to date release versions and details #### Kotlin with KAPT If you are using Kotlin with KAPT you should also add ```groovy apply plugin: 'kotlin-kapt' kapt { correctErrorTypes = true } ``` so that `AutoModel` annotations work properly. More information [here](https://github.com/airbnb/epoxy/wiki/Epoxy-Controller#usage-with-kotlin) Also, make sure to use `kapt` instead of `annotationProcessor` in your dependencies in the `build.gradle` file. #### Kotlin with KSP (Recommended) KSP (Kotlin Symbol Processing) is recommended over KAPT as it is significantly faster. Add the KSP plugin to your root `build.gradle`: ```groovy plugins { id 'com.google.devtools.ksp' version "$KSP_VERSION" apply false } ``` Then apply it in your module's `build.gradle`: ```groovy plugins { id 'com.android.application' id 'kotlin-android' id 'com.google.devtools.ksp' } dependencies { implementation "com.airbnb.android:epoxy:$epoxyVersion" ksp "com.airbnb.android:epoxy-processor:$epoxyVersion" } ``` You can configure KSP processor options: ```groovy ksp { // Validation and debugging arg("validateEpoxyModelUsage", "true") // Validate model usage at runtime (default: true) arg("logEpoxyTimings", "false") // Log annotation processing timings (default: false) // Code generation options arg("epoxyDisableGenerateReset", "false") // Disable reset() method generation (default: false) arg("epoxyDisableGenerateGetters", "false") // Disable getter generation (default: false) arg("epoxyDisableGenerateOverloads", "false") // Disable builder overload generation (default: false) arg("disableEpoxyKotlinExtensionGeneration", "false") // Disable Kotlin extension generation (default: false) arg("epoxyDisableDslMarker", "false") // Disable DSL marker annotation (default: false) // Model requirements arg("requireHashCodeInEpoxyModels", "false") // Require hashCode/equals in models (default: false) arg("requireAbstractEpoxyModels", "false") // Require abstract model classes (default: false) arg("implicitlyAddAutoModels", "false") // Auto-add models to controllers (default: false) } ``` **Important:** DataBinding models are **not supported** with KSP, as Android's DataBinding library itself uses KAPT. If you need DataBinding support, you must continue using KAPT. For custom views with `@ModelView` or ViewHolder models, KSP works perfectly. See the [epoxy-kspsample](https://github.com/airbnb/epoxy/tree/master/epoxy-kspsample) module for a complete working example. ## Library Projects If you are using layout resources in Epoxy annotations then for library projects add [Butterknife's gradle plugin](https://github.com/JakeWharton/butterknife#library-projects) to your `buildscript`. ```groovy buildscript { repositories { mavenCentral() } dependencies { classpath 'com.jakewharton:butterknife-gradle-plugin:10.1.0' } } ``` and then apply it in your module: ```groovy apply plugin: 'com.android.library' apply plugin: 'com.jakewharton.butterknife' ``` Now make sure you use R2 instead of R inside all Epoxy annotations. ```java @ModelView(defaultLayout = R2.layout.view_holder_header) public class HeaderView extends LinearLayout { .... } ``` This is not necessary if you don't use resources as annotation parameters, such as with [custom view models](https://github.com/airbnb/epoxy/wiki/Generating-Models-from-View-Annotations). ## Basic Usage There are two main components of Epoxy: 1. The `EpoxyModel`s that describe how your views should be displayed in the RecyclerView. 2. The `EpoxyController` where the models are used to describe what items to show and with what data. ### Creating Models Epoxy generates models for you based on your view or layout. Generated model classes are suffixed with an underscore (`_`) are used directly in your EpoxyController classes. #### From Custom Views Add the `@ModelView` annotation on a view class. Then, add a "prop" annotation on each setter method to mark it as a property for the model. ```java @ModelView(autoLayout = Size.MATCH_WIDTH_WRAP_HEIGHT) public class HeaderView extends LinearLayout { ... // Initialization omitted @TextProp public void setTitle(CharSequence text) { titleView.setText(text); } } ``` A `HeaderViewModel_` is then generated in the same package. [More Details](https://github.com/airbnb/epoxy/wiki/Generating-Models-from-View-Annotations) #### From DataBinding If you use Android DataBinding you can simply set up your xml layouts like normal: ```xml ``` Then, create an interface or class in any package and add an `EpoxyDataBindingLayouts` annotation to declare your databinding layouts. ```java package com.airbnb.epoxy.sample; import com.airbnb.epoxy.EpoxyDataBindingLayouts; @EpoxyDataBindingLayouts({R.layout.header_view, ... // other layouts }) interface EpoxyConfig {} ``` From this layout name Epoxy generates a `HeaderViewBindingModel_`. [More Details](https://github.com/airbnb/epoxy/wiki/Data-Binding-Support) #### From ViewHolders If you use xml layouts without databinding you can create a model class to do the binding. ```java @EpoxyModelClass(layout = R.layout.header_view) public abstract class HeaderModel extends EpoxyModelWithHolder { @EpoxyAttribute String title; @Override public void bind(Holder holder) { holder.header.setText(title); } static class Holder extends BaseEpoxyHolder { @BindView(R.id.text) TextView header; } } ``` A `HeaderModel_` class is generated that subclasses HeaderModel and implements the model details. [More Details](https://github.com/airbnb/epoxy/wiki/ViewHolder-Models) ### Using your models in a controller A controller defines what items should be shown in the RecyclerView, by adding the corresponding models in the desired order. The controller's `buildModels` method declares which items to show. You are responsible for calling `requestModelBuild` whenever your data changes, which triggers `buildModels` to run again. Epoxy tracks changes in the models and automatically binds and updates views. As an example, our `PhotoController` shows a header, a list of photos, and a loader (if more photos are being loaded). The controller's `setData(photos, loadingMore)` method is called whenever photos are loaded, which triggers a call to `buildModels` so models representing the state of the new data can be built. ```java public class PhotoController extends Typed2EpoxyController, Boolean> { @AutoModel HeaderModel_ headerModel; @AutoModel LoaderModel_ loaderModel; @Override protected void buildModels(List photos, Boolean loadingMore) { headerModel .title("My Photos") .description("My album description!") .addTo(this); for (Photo photo : photos) { new PhotoModel() .id(photo.id()) .url(photo.url()) .addTo(this); } loaderModel .addIf(loadingMore, this); } } ``` #### Or with Kotlin An extension function is generated for each model so we can write this: ```kotlin class PhotoController : Typed2EpoxyController, Boolean>() { override fun buildModels(photos: List, loadingMore: Boolean) { header { id("header") title("My Photos") description("My album description!") } photos.forEach { photoView { id(it.id()) url(it.url()) } } if (loadingMore) loaderView { id("loader") } } } ``` ### Integrating with RecyclerView Get the backing adapter off the EpoxyController to set up your RecyclerView: ```java MyController controller = new MyController(); recyclerView.setAdapter(controller.getAdapter()); // Request a model build whenever your data changes controller.requestModelBuild(); // Or if you are using a TypedEpoxyController controller.setData(myData); ``` If you are using the [EpoxyRecyclerView](https://github.com/airbnb/epoxy/wiki/EpoxyRecyclerView) integration is easier. ```java epoxyRecyclerView.setControllerAndBuildModels(new MyController()); // Request a model build on the recyclerview when data changes epoxyRecyclerView.requestModelBuild(); ``` #### Kotlin Or use [Kotlin Extensions](https://github.com/airbnb/epoxy/wiki/EpoxyRecyclerView#kotlin-extensions) to simplify further and remove the need for a controller class. ```kotlin epoxyRecyclerView.withModels { header { id("header") title("My Photos") description("My album description!") } photos.forEach { photoView { id(it.id()) url(it.url()) } } if (loadingMore) loaderView { id("loader") } } } ``` ### More Reading And that's it! The controller's declarative style makes it very easy to visualize what the RecyclerView will look like, even when many different view types or items are used. Epoxy handles everything else. If a view only partially changes, such as the description, only that new value is set on the view, so the system is very efficient Epoxy handles much more than these basics, and is highly configurable. See [the wiki](https://github.com/airbnb/epoxy/wiki) for in depth documentation. ## Documentation See examples and browse complete documentation at the [Epoxy Wiki](https://github.com/airbnb/epoxy/wiki) If you still have questions, feel free to create a new issue. ## Min SDK We support a minimum SDK of 14. However, Epoxy is based on the v7 support libraries so it should work with lower versions if you care to override the min sdk level in the manifest. ## Contributing Pull requests are welcome! We'd love help improving this library. Feel free to browse through open issues to look for things that need work. If you have a feature request or bug, please open a new issue so we can track it. ## License ``` Copyright 2016 Airbnb, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` ================================================ FILE: RELEASING.md ================================================ Releasing ======== 1. Bump the VERSION_NAME property in `gradle.properties` based on Major.Minor.Patch naming scheme 2. Update `CHANGELOG.md` for the impending release. 3. `git commit -am "Prepare for release X.Y.Z."` (where X.Y.Z is the version you set in step 1) 4. Add your sonatype login information under gradle properties mavenCentralUsername and mavenCentralPassword in your local user gradle.properties file 5. Make sure you have a gpg signing key configured (https://vanniktech.github.io/gradle-maven-publish-plugin/central/#secrets) 5. Run `./gradlew publish` to build the artifacts and publish them to maven 7. Open PR on Github, merge, and publish release through Github UI. Publishing a release to an internal repository ======== To publish an internal release to an Artifactory repository: 1. Set credential values for ARTIFACTORY_USERNAME and ARTIFACTORY_PASSWORD in your local gradle.properties 2. Set values for ARTIFACTORY_RELEASE_URL (and optionally ARTIFACTORY_SNAPSHOT_URL if you are publishing a snapshot) 3. ./gradlew publishAllPublicationsToAirbnbArtifactoryRepository -PdoNotSignRelease=true --no-configuration-cache 4. "-PdoNotSignRelease=true" is optional, but we don't need to sign artifactory releases and this allows everyone to publish without setting up a gpg key If you need to publish to a different repository, look at the configuration in 'publishing.gradle' to see how to configure additional repositories. Maven Local Installation ======================= If testing changes locally, you can install to mavenLocal via `./gradlew publishToMavenLocal` ================================================ FILE: UpdateProcessorTestResources.kt ================================================ #!/usr/bin/env kscript @file:DependsOn("org.jsoup:jsoup:1.13.1") import org.jsoup.Jsoup import org.jsoup.nodes.Document import java.io.File fun main() { val testResultHtmlRegex = Regex("/build/reports/tests/.*/classes/.*\\.html") File(".") .walk() .filter { it.isFile } .filter { it.path.contains(testResultHtmlRegex) } .forEach { updateTestClass(it) } } fun updateTestClass(testReport: File) { val doc: Document = Jsoup.parse(testReport, "UTF-8") // Failing processor tests have their output in a
 block
    doc.getElementsByTag("pre")
        .filter { element ->
            // A failing block contains the text "Source declared the same top-level types of an expected source, but
            // didn't match exactly."
            element.text().contains("Source declared the same top-level types of an expected source")
        }.map { it.text() }
        .forEach { failingTestText ->
            updateIndividualTest(failingTestText)
        }
}

private fun updateIndividualTest(failingTestText: String) {
    val expectedFile = expectedFileRegex
        .find(failingTestText)
        ?.groupValues
        ?.getOrNull(1)

        ?.let { filePath ->
            // The test copies the source file to the build folder. We need to modify the original file to update its expected source
            File(
                filePath.replace(
                    "/build/intermediates/sourceFolderJavaResources/debug/",
                    "/src/test/resources/"
                )
            )
        }
        ?.takeIf { it.isFile }
        ?: error("Count not find expected file in $failingTestText")

    // The error message includes the source code that was generated.
    // Actual Source:
    //=================
    // [code here]
    //
    // javaSources was: [com.google.testing.compile.JavaFileObjects$ResourceSourceJavaFileObject[file:/Users/elihart/repos/epoxy/epoxy-modelfactorytest/build/intermediates/sourceFolderJavaResources/debug/GroupPropMultipleSupportedAttributeDifferentNameModelView.java]]
    // at com.airbnb.epoxy.ProcessorTestUtils.assertGeneration(ProcessorTestUtils.kt:33)
    // ...

    val actualSource = failingTestText.substringAfter(
        """
            Actual Source:
            =================

        """.trimIndent()
    ).substringBefore("javaSources was:")
        .substringBefore("object was:")

    expectedFile.writeText(actualSource)

    println("Updated test source ${expectedFile.path.substringAfter("/epoxy/")}")
}

// We expect to see a line like:
// Expected file: 
// Which tells us where the original processor test file lives
val expectedFileRegex = Regex("Expected file: <(.*)>")


================================================
FILE: blessedDeps.gradle
================================================
/**
 * "Blessed" dependencies give us the ability to force a dependency(s) version to be consistent
 *  for packaging a library. In turn, this also allows the ability to retract the forced update
 *  based off build type/flavor which reduces regressions caused by conflicts.
 *
 *  As an added bonus, we don't bloat our project build file by abstracting dependencies into its own
 *  gradle file, then applying it back in as necessary.
 */

rootProject.ext.TARGET_SDK_VERSION = 30
rootProject.ext.COMPILE_SDK_VERSION = 33
rootProject.ext.MIN_SDK_VERSION = 14
rootProject.ext.COMPOSE_MIN_SDK_VERSION = 21
rootProject.ext.PARIS_MIN_SDK_VERSION = 21

rootProject.ext.ANDROIDX_ANNOTATION = "1.5.0"
rootProject.ext.ANDROIDX_APPCOMPAT = "1.5.1"
rootProject.ext.ANDROIDX_CARDVIEW = "1.0.0"
rootProject.ext.ANDROIDX_CORE_KTX = "1.3.2"
rootProject.ext.ANDROIDX_DATABINDING_ADAPTERS = "3.2.1"
rootProject.ext.ANDROIDX_DATABINDING_COMPILER = "3.2.1"
rootProject.ext.ANDROIDX_DATABINDING_LIBRARY = "3.2.1"
rootProject.ext.ANDROIDX_ESPRESSO_CORE = "3.5.1"
rootProject.ext.ANDROIDX_FRAGMENT_TESTING = "1.3.3"
rootProject.ext.ANDROIDX_LEGACY = "1.0.0"
rootProject.ext.ANDROIDX_MATERIAL = "1.3.0"
rootProject.ext.ANDROIDX_PAGING = "2.0.0"
rootProject.ext.ANDROIDX_PAGING3 = "3.1.1"
rootProject.ext.ANDROIDX_RECYCLERVIEW = "1.3.0-rc01"
rootProject.ext.ANDROIDX_ROOM = "2.5.0-beta01"
rootProject.ext.ANDROIDX_RUNTIME = "2.3.1"
rootProject.ext.ANDROIDX_VERSIONED_PARCELABLE = "1.1.1"
rootProject.ext.ANDROID_ARCH_TESTING = "2.1.0"
rootProject.ext.ANDROID_DATA_BINDING = "1.3.1"
rootProject.ext.ANDROID_RUNTIME_VERSION = "4.1.1.4"
rootProject.ext.ANDROID_TEST_RUNNER = "1.5.2"
rootProject.ext.ANDROID_TEST_RULES = "1.5.0"
rootProject.ext.ASSERTJ_VERSION = "1.7.1"
rootProject.ext.AUTO_VALUE_VERSION = "1.7.4"
rootProject.ext.GLIDE_VERSION = "4.12.0"
rootProject.ext.GOOGLE_TESTING_COMPILE_VERSION = "0.23.0"
rootProject.ext.INCAP_VERSION = "0.3"
rootProject.ext.JUNIT_VERSION = "4.13.2"
rootProject.ext.KOTLIN_COROUTINES_VERSION = "1.6.4"
rootProject.ext.LOTTIE_VERSION = "2.8.0"
rootProject.ext.MOCKITO_VERSION = "5.20.0"
rootProject.ext.PARIS_VERSION = "2.2.1"
rootProject.ext.ROBOLECTRIC_VERSION = "4.9.2"
rootProject.ext.SQUARE_JAVAPOET_VERSION = "1.13.0"
rootProject.ext.SQUARE_KOTLINPOET_VERSION = "1.12.0"
rootProject.ext.COMPOSE_VERSION = "1.4.2"
rootProject.ext.COMPOSE_ACTIVITY_VERSION = "1.6.0"
rootProject.ext.KOTLINX_LIFECYCLE_RUNTIME_VERSION = "2.5.1"
rootProject.ext.XPROCESSING_VERSION = "2.8.4"
rootProject.ext.KOTLIN_TESTING_COMPILE_VERSION = '0.11.0'
rootProject.ext.LIFECYCLE_VIEWMODEL = '2.6.1'

rootProject.ext.deps = [
    activityCompose                     : "androidx.activity:activity-compose:$COMPOSE_ACTIVITY_VERSION",
    androidAnnotations                  : "androidx.annotation:annotation:$ANDROIDX_ANNOTATION",
    androidAppcompat                    : "androidx.appcompat:appcompat:$ANDROIDX_APPCOMPAT",
    androidArchCoreTesting              : "androidx.arch.core:core-testing:$ANDROID_ARCH_TESTING",
    androidCardView                     : "androidx.cardview:cardview:$ANDROIDX_CARDVIEW",
    androidCoreKtx                      : "androidx.core:core-ktx:$ANDROIDX_CORE_KTX",
    androidDesignLibrary                : "com.google.android.material:material:$ANDROIDX_MATERIAL",
    androidEspressoCore                 : "androidx.test.espresso:espresso-core:$ANDROIDX_ESPRESSO_CORE",
    androidFragmentTesting              : "androidx.fragment:fragment-testing:$ANDROIDX_FRAGMENT_TESTING",
    androidLifecycleRuntimeKtx          : "androidx.lifecycle:lifecycle-runtime-ktx:$KOTLINX_LIFECYCLE_RUNTIME_VERSION",
    androidPaging3Component             : "androidx.paging:paging-runtime:$ANDROIDX_PAGING3",
    androidPagingComponent              : "androidx.paging:paging-runtime:$ANDROIDX_PAGING",
    androidRecyclerView                 : "androidx.recyclerview:recyclerview:$ANDROIDX_RECYCLERVIEW",
    androidRuntime                      : "com.google.android:android:$ANDROID_RUNTIME_VERSION",
    androidTestCore                     : "androidx.test:core:1.3.0",
    androidTestExtJunitKtx              : "androidx.test.ext:junit-ktx:1.1.2",
    androidTestRules                    : "androidx.test:rules:$ANDROID_TEST_RULES",
    androidTestRunner                   : "androidx.test:runner:$ANDROID_TEST_RUNNER",
    assertj                             : "org.assertj:assertj-core:$ASSERTJ_VERSION",
    autoValue                           : "com.google.auto.value:auto-value:$AUTO_VALUE_VERSION",
    composeMaterial                     : "androidx.compose.material:material:$COMPOSE_VERSION",
    composeUi                           : "androidx.compose.ui:ui:$COMPOSE_VERSION",
    composeUiTooling                    : "androidx.compose.ui:ui-tooling:$COMPOSE_VERSION",
    dataBindingAdapters                 : "androidx.databinding:databinding-adapters:$ANDROIDX_DATABINDING_ADAPTERS",
    dataBindingLibrary                  : "androidx.databinding:databinding-library:$ANDROIDX_DATABINDING_LIBRARY",
    glide                               : "com.github.bumptech.glide:glide:$GLIDE_VERSION",
    googleTestingCompile                : "com.google.testing.compile:compile-testing:$GOOGLE_TESTING_COMPILE_VERSION",
    incapProcessor                      : "net.ltgt.gradle.incap:incap-processor:$INCAP_VERSION",
    incapRuntime                        : "net.ltgt.gradle.incap:incap:$INCAP_VERSION",
    junit                               : "junit:junit:$JUNIT_VERSION",
    kotlinCoroutines                    : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$KOTLIN_COROUTINES_VERSION",
    kotlinCoroutinesTest                : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$KOTLIN_COROUTINES_VERSION",
    kotlinxMetadata                     : "org.jetbrains.kotlin:kotlin-metadata-jvm:$KOTLIN_VERSION",
    lottie                              : "com.airbnb.android:lottie:$LOTTIE_VERSION",
    mockito                             : "org.mockito:mockito-core:$MOCKITO_VERSION",
    paris                               : "com.airbnb.android:paris:$PARIS_VERSION",
    parisProcessor                      : "com.airbnb.android:paris-processor:$PARIS_VERSION",
    robolectric                         : "org.robolectric:robolectric:$ROBOLECTRIC_VERSION",
    squareJavaPoet                      : "com.squareup:javapoet:$SQUARE_JAVAPOET_VERSION",
    squareKotlinPoet                    : "com.squareup:kotlinpoet:$SQUARE_KOTLINPOET_VERSION",
    kotlinPoetJavaInterop               : "com.squareup:kotlinpoet-javapoet:$SQUARE_KOTLINPOET_VERSION",
    kotlinPoetKspInterop                : "com.squareup:kotlinpoet-ksp:$SQUARE_KOTLINPOET_VERSION",
    versionedParcelable                 : "androidx.versionedparcelable:versionedparcelable:$ANDROIDX_VERSIONED_PARCELABLE",
    ksp                                 : "com.google.devtools.ksp:symbol-processing-api:$KSP_VERSION",
    kspAaEmbeddable                     : "com.google.devtools.ksp:symbol-processing-aa-embeddable:$KSP_VERSION",
    kspImpl                             : "com.google.devtools.ksp:symbol-processing:$KSP_VERSION",
    xProcessing                         : "androidx.room:room-compiler-processing:$XPROCESSING_VERSION",
    xProcessingTesting                  : "androidx.room:room-compiler-processing-testing:$XPROCESSING_VERSION",
    kotlinCompileTesting                : "dev.zacsweers.kctfork:ksp:$KOTLIN_TESTING_COMPILE_VERSION",
    kotlinAnnotationProcessingEmbeddable: "org.jetbrains.kotlin:kotlin-annotation-processing-embeddable:$KOTLIN_VERSION",
    lifecycleViewmodel                  : "androidx.lifecycle:lifecycle-viewmodel:$LIFECYCLE_VIEWMODEL",
    lifecycleViewmodelKtx               : "androidx.lifecycle:lifecycle-viewmodel-ktx:$LIFECYCLE_VIEWMODEL",
]


================================================
FILE: build.gradle
================================================
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {

  ext.KOTLIN_VERSION = "2.2.21"
  ext.ANDROID_PLUGIN_VERSION = '8.13.0'
  ext.KSP_VERSION = '2.3.3'

  repositories {
    google()
    mavenCentral()
    gradlePluginPortal()
  }
  dependencies {
    classpath "com.android.tools.build:gradle:$ANDROID_PLUGIN_VERSION"
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION"
    classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$KOTLIN_VERSION"
    // Upload with: (see RELEASING.md)
    // ./gradlew publishAllPublicationsToMavenCentral --no-configuration-cache
    classpath 'com.vanniktech:gradle-maven-publish-plugin:0.35.0'
    // Dokka is needed on classpath for vanniktech publish plugin
    classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.6.10"
  }
}

plugins {
  // Run ./gradlew dependencyUpdates to see available version updates
  id 'com.github.ben-manes.versions' version '0.42.0'
  id "com.google.devtools.ksp" version "$KSP_VERSION"
}

allprojects {

  repositories {
    google()
    mavenCentral()
  }

  // Prevent javadoc task complaining about errors with kotlin files
  tasks.withType(Javadoc) {
    excludes = ['**/*.kt']
  }
}

subprojects { project ->
  apply from: "$rootDir/blessedDeps.gradle"
  apply plugin: 'com.github.ben-manes.versions'
  apply from: "${project.rootDir}/ktlint.gradle"

  afterEvaluate {
    if (project.tasks.findByName('check')) {
      check.dependsOn('ktlint')
    }

    if (project.extensions.findByType(com.android.build.gradle.LibraryExtension.class) != null) {
      project.android.libraryVariants.all { variant ->
        def outputFolder = new File("build/generated/ksp/${variant.name}/kotlin")
        variant.addJavaSourceFoldersToModel(outputFolder)
        android.sourceSets.getAt(variant.name).java {
          srcDir(outputFolder)
        }
      }
    } else if (project.extensions.findByType(com.android.build.gradle.AbstractAppExtension.class) != null) {
      project.android.applicationVariants.all { variant ->
        def outputFolder = new File("build/generated/ksp/${variant.name}/kotlin")
        variant.addJavaSourceFoldersToModel(outputFolder)
        android.sourceSets.getAt(variant.name).java {
          srcDir(outputFolder)
        }
      }
    }
  }
}

def isNonStable = { String version ->
  def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.uppercase().contains(it) }
  def regex = /^[0-9,.v-]+(-r)?$/
  return !stableKeyword && !(version ==~ regex)
}

tasks.named("dependencyUpdates").configure {
  // disallow release candidates as upgradable versions from stable versions
  rejectVersionIf {
    isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)
  }
}


================================================
FILE: epoxy-adapter/.gitignore
================================================
/build


================================================
FILE: epoxy-adapter/build.gradle
================================================
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-parcelize'
apply from: '../publishing.gradle'

android {
  namespace 'com.airbnb.viewmodeladapter'

  defaultConfig {
    compileSdk rootProject.COMPILE_SDK_VERSION
    minSdkVersion rootProject.MIN_SDK_VERSION
    targetSdkVersion rootProject.TARGET_SDK_VERSION
    consumerProguardFiles 'proguard-rules.pro'
  }

  testOptions.unitTests.includeAndroidResources = true

  buildTypes.all { buildType ->
    buildType.javaCompileOptions.annotationProcessorOptions.arguments =
        [
            logEpoxyTimings: "true"
        ]
  }
}

kotlin {
  jvmToolchain(11)
}

configurations.all { strategy ->
  strategy.resolutionStrategy.force rootProject.deps.androidAnnotations, rootProject.deps.androidRecyclerView,
      rootProject.deps.androidDesignLibrary, rootProject.deps.androidAppcompat, rootProject.deps.junit,
      rootProject.deps.robolectric, rootProject.deps.mockito
}

dependencies {
  implementation rootProject.deps.androidAppcompat
  implementation rootProject.deps.androidAnnotations
  implementation rootProject.deps.androidRecyclerView
  implementation rootProject.deps.androidDesignLibrary
  api project(':epoxy-annotations')

  kapt project(':epoxy-processor')
  kaptTest project(':epoxy-processor')

  testImplementation rootProject.deps.junit
  testImplementation rootProject.deps.robolectric
  testImplementation rootProject.deps.mockito
  testImplementation rootProject.deps.androidTestCore
}


================================================
FILE: epoxy-adapter/gradle.properties
================================================
POM_NAME=Epoxy
POM_ARTIFACT_ID=epoxy
POM_PACKAGING=jar

================================================
FILE: epoxy-adapter/lint.xml
================================================


  
  
  
  
  
  



================================================
FILE: epoxy-adapter/proguard-rules.pro
================================================
# The generated ControllerHelper classes are needed when using AutoModel annotations.
# Each ControllerHelper is looked up reflectively, so we need to make sure it is
# kept and its name not obfuscated so the reflective lookup works.
-keep class * extends com.airbnb.epoxy.EpoxyController { *; }
-keep class * extends com.airbnb.epoxy.ControllerHelper { *; }
-keepclasseswithmembernames class * { @com.airbnb.epoxy.AutoModel ; }

================================================
FILE: epoxy-adapter/src/main/AndroidManifest.xml
================================================


    



================================================
FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ActivityRecyclerPool.kt
================================================
package com.airbnb.epoxy

import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.os.Build
import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent
import androidx.recyclerview.widget.RecyclerView
import java.lang.ref.WeakReference
import java.util.ArrayList

internal class ActivityRecyclerPool {

    /**
     * Store one unique pool per activity. They are cleared out when activities are destroyed, so this
     * only needs to hold pools for active activities.
     */
    private val pools = ArrayList(5)

    @JvmOverloads
    fun getPool(
        context: Context,
        poolFactory: () -> RecyclerView.RecycledViewPool
    ): PoolReference {

        val iterator = pools.iterator()
        var poolToUse: PoolReference? = null

        while (iterator.hasNext()) {
            val poolReference = iterator.next()
            when {
                poolReference.context === context -> {
                    if (poolToUse != null) {
                        throw IllegalStateException("A pool was already found")
                    }
                    poolToUse = poolReference
                    // finish iterating to remove any old contexts
                }
                poolReference.context.isActivityDestroyed() -> {
                    // A pool from a different activity that was destroyed.
                    // Clear the pool references to allow the activity to be GC'd
                    poolReference.viewPool.clear()
                    iterator.remove()
                }
            }
        }

        if (poolToUse == null) {
            poolToUse = PoolReference(context, poolFactory(), this)
            context.lifecycle()?.addObserver(poolToUse)
            pools.add(poolToUse)
        }

        return poolToUse
    }

    fun clearIfDestroyed(pool: PoolReference) {
        if (pool.context.isActivityDestroyed()) {
            pool.viewPool.clear()
            pools.remove(pool)
        }
    }

    private fun Context.lifecycle(): Lifecycle? {
        if (this is LifecycleOwner) {
            return lifecycle
        }

        if (this is ContextWrapper) {
            return baseContext.lifecycle()
        }

        return null
    }
}

internal class PoolReference(
    context: Context,
    val viewPool: RecyclerView.RecycledViewPool,
    private val parent: ActivityRecyclerPool
) : LifecycleObserver {
    private val contextReference: WeakReference = WeakReference(context)

    val context: Context? get() = contextReference.get()

    fun clearIfDestroyed() {
        parent.clearIfDestroyed(this)
    }

    @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    fun onContextDestroyed() {
        clearIfDestroyed()
    }
}

internal fun Context?.isActivityDestroyed(): Boolean {
    if (this == null) {
        return true
    }

    if (this !is Activity) {
        return (this as? ContextWrapper)?.baseContext?.isActivityDestroyed() ?: false
    }

    if (isFinishing) {
        return true
    }

    return if (Build.VERSION.SDK_INT >= 17) {
        isDestroyed
    } else {
        // Use this as a proxy for being destroyed on older devices
        !ViewCompat.isAttachedToWindow(window.decorView)
    }
}


================================================
FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncEpoxyController.java
================================================
package com.airbnb.epoxy;

import android.os.Handler;

import static com.airbnb.epoxy.EpoxyAsyncUtil.MAIN_THREAD_HANDLER;
import static com.airbnb.epoxy.EpoxyAsyncUtil.getAsyncBackgroundHandler;

/**
 * A subclass of {@link EpoxyController} that makes it easy to do model building and diffing in
 * the background.
 * 

* See https://github.com/airbnb/epoxy/wiki/Epoxy-Controller#asynchronous-support */ public abstract class AsyncEpoxyController extends EpoxyController { /** * A new instance that does model building and diffing asynchronously. */ public AsyncEpoxyController() { this(true); } /** * @param enableAsync True to do model building and diffing asynchronously, false to do them * both on the main thread. */ public AsyncEpoxyController(boolean enableAsync) { this(enableAsync, enableAsync); } /** * Individually control whether model building and diffing are done async or on the main thread. */ public AsyncEpoxyController(boolean enableAsyncModelBuilding, boolean enableAsyncDiffing) { super(getHandler(enableAsyncModelBuilding), getHandler(enableAsyncDiffing)); } private static Handler getHandler(boolean enableAsync) { return enableAsync ? getAsyncBackgroundHandler() : MAIN_THREAD_HANDLER; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/AsyncEpoxyDiffer.java ================================================ package com.airbnb.epoxy; import android.os.Handler; import java.util.Collections; import java.util.List; import java.util.concurrent.Executor; import androidx.annotation.AnyThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.DiffUtil.ItemCallback; /** * An adaptation of Google's {@link androidx.recyclerview.widget.AsyncListDiffer} * that adds support for payloads in changes. *

* Also adds support for canceling an in progress diff, and makes everything thread safe. */ class AsyncEpoxyDiffer { interface ResultCallback { void onResult(@NonNull DiffResult result); } private final Executor executor; private final ResultCallback resultCallback; private final ItemCallback> diffCallback; private final GenerationTracker generationTracker = new GenerationTracker(); AsyncEpoxyDiffer( @NonNull Handler handler, @NonNull ResultCallback resultCallback, @NonNull ItemCallback> diffCallback ) { this.executor = new HandlerExecutor(handler); this.resultCallback = resultCallback; this.diffCallback = diffCallback; } @Nullable private volatile List> list; /** * Non-null, unmodifiable version of list. *

* Collections.emptyList when list is null, wrapped by Collections.unmodifiableList otherwise */ @NonNull private volatile List> readOnlyList = Collections.emptyList(); /** * Get the current List - any diffing to present this list has already been computed and * dispatched via the ListUpdateCallback. *

* If a null List, or no List has been submitted, an empty list will be returned. *

* The returned list may not be mutated - mutations to content must be done through * {@link #submitList(List)}. * * @return current List. */ @AnyThread @NonNull public List> getCurrentList() { return readOnlyList; } /** * Prevents any ongoing diff from dispatching results. Returns true if there was an ongoing * diff to cancel, false otherwise. */ @SuppressWarnings("WeakerAccess") @AnyThread public boolean cancelDiff() { return generationTracker.finishMaxGeneration(); } /** * @return True if a diff operation is in progress. */ @SuppressWarnings("WeakerAccess") @AnyThread public boolean isDiffInProgress() { return generationTracker.hasUnfinishedGeneration(); } /** * Set the current list without performing any diffing. Cancels any diff in progress. *

* This can be used if you notified a change to the adapter manually and need this list to be * synced. */ @AnyThread public synchronized boolean forceListOverride(@Nullable List> newList) { // We need to make sure that generation changes and list updates are synchronized final boolean interruptedDiff = cancelDiff(); int generation = generationTracker.incrementAndGetNextScheduled(); tryLatchList(newList, generation); return interruptedDiff; } /** * Set a new List representing your latest data. *

* A diff will be computed between this list and the last list set. If this has not previously * been called then an empty list is used as the previous list. *

* The diff computation will be done on the thread given by the handler in the constructor. * When the diff is done it will be applied (dispatched to the result callback), * and the new List will be swapped in. */ @AnyThread @SuppressWarnings("WeakerAccess") public void submitList(@Nullable final List> newList) { final int runGeneration; @Nullable final List> previousList; synchronized (this) { // Incrementing generation means any currently-running diffs are discarded when they finish // We synchronize to guarantee list object and generation number are in sync runGeneration = generationTracker.incrementAndGetNextScheduled(); previousList = list; } if (newList == previousList) { // nothing to do onRunCompleted(runGeneration, newList, DiffResult.noOp(previousList)); return; } if (newList == null || newList.isEmpty()) { // fast simple clear all DiffResult result = null; if (previousList != null && !previousList.isEmpty()) { result = DiffResult.clear(previousList); } onRunCompleted(runGeneration, null, result); return; } if (previousList == null || previousList.isEmpty()) { // fast simple first insert onRunCompleted(runGeneration, newList, DiffResult.inserted(newList)); return; } final DiffCallback wrappedCallback = new DiffCallback(previousList, newList, diffCallback); executor.execute(new Runnable() { @Override public void run() { DiffUtil.DiffResult result = DiffUtil.calculateDiff(wrappedCallback); onRunCompleted(runGeneration, newList, DiffResult.diff(previousList, newList, result)); } }); } private void onRunCompleted( final int runGeneration, @Nullable final List> newList, @Nullable final DiffResult result ) { // We use an asynchronous handler so that the Runnable can be posted directly back to the main // thread without waiting on view invalidation synchronization. MainThreadExecutor.ASYNC_INSTANCE.execute(new Runnable() { @Override public void run() { final boolean dispatchResult = tryLatchList(newList, runGeneration); if (result != null && dispatchResult) { resultCallback.onResult(result); } } }); } /** * Marks the generation as done, and updates the list if the generation is the most recent. * * @return True if the given generation is the most recent, in which case the given list was * set. False if the generation is old and the list was ignored. */ @AnyThread private synchronized boolean tryLatchList(@Nullable List> newList, int runGeneration) { if (generationTracker.finishGeneration(runGeneration)) { list = newList; if (newList == null) { readOnlyList = Collections.emptyList(); } else { readOnlyList = Collections.unmodifiableList(newList); } return true; } return false; } /** * The concept of a "generation" is used to associate a diff result with a point in time when * it was created. This allows us to handle list updates concurrently, and ignore outdated diffs. *

* We track the highest start generation, and the highest finished generation, and these must * be kept in sync, so all access to this class is synchronized. *

* The general synchronization strategy for this class is that when a generation number * is queried that action must be synchronized with accessing the current list, so that the * generation number is synced with the list state at the time it was created. */ private static class GenerationTracker { // Max generation of currently scheduled runnable private volatile int maxScheduledGeneration; private volatile int maxFinishedGeneration; synchronized int incrementAndGetNextScheduled() { return ++maxScheduledGeneration; } synchronized boolean finishMaxGeneration() { boolean isInterrupting = hasUnfinishedGeneration(); maxFinishedGeneration = maxScheduledGeneration; return isInterrupting; } synchronized boolean hasUnfinishedGeneration() { return maxScheduledGeneration > maxFinishedGeneration; } synchronized boolean finishGeneration(int runGeneration) { boolean isLatestGeneration = maxScheduledGeneration == runGeneration && runGeneration > maxFinishedGeneration; if (isLatestGeneration) { maxFinishedGeneration = runGeneration; } return isLatestGeneration; } } private static class DiffCallback extends DiffUtil.Callback { final List> oldList; final List> newList; private final ItemCallback> diffCallback; DiffCallback(List> oldList, List> newList, ItemCallback> diffCallback) { this.oldList = oldList; this.newList = newList; this.diffCallback = diffCallback; } @Override public int getOldListSize() { return oldList.size(); } @Override public int getNewListSize() { return newList.size(); } @Override public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { return diffCallback.areItemsTheSame( oldList.get(oldItemPosition), newList.get(newItemPosition) ); } @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { return diffCallback.areContentsTheSame( oldList.get(oldItemPosition), newList.get(newItemPosition) ); } @Nullable @Override public Object getChangePayload(int oldItemPosition, int newItemPosition) { return diffCallback.getChangePayload( oldList.get(oldItemPosition), newList.get(newItemPosition) ); } } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/BaseEpoxyAdapter.java ================================================ package com.airbnb.epoxy; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; import com.airbnb.epoxy.stickyheader.StickyHeaderCallbacks; import org.jetbrains.annotations.NotNull; import java.util.Collections; import java.util.List; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup; import androidx.recyclerview.widget.RecyclerView; public abstract class BaseEpoxyAdapter extends RecyclerView.Adapter implements StickyHeaderCallbacks { private static final String SAVED_STATE_ARG_VIEW_HOLDERS = "saved_state_view_holders"; private int spanCount = 1; private final ViewTypeManager viewTypeManager = new ViewTypeManager(); /** * Keeps track of view holders that are currently bound so we can save their state in {@link * #onSaveInstanceState(Bundle)}. */ private final BoundViewHolders boundViewHolders = new BoundViewHolders(); private ViewHolderState viewHolderState = new ViewHolderState(); private final SpanSizeLookup spanSizeLookup = new SpanSizeLookup() { @Override public int getSpanSize(int position) { try { return getModelForPosition(position) .spanSize(spanCount, position, getItemCount()); } catch (IndexOutOfBoundsException e) { // There seems to be a GridLayoutManager bug where when the user is in accessibility mode // it incorrectly uses an outdated view position // when calling this method. This crashes when a view is animating out, when it is // removed from the adapter but technically still added // to the layout. We've posted a bug report and hopefully can update when the support // library fixes this // TODO: (eli_hart 8/23/16) Figure out if this has been fixed in new support library onExceptionSwallowed(e); return 1; } } }; public BaseEpoxyAdapter() { // Defaults to stable ids since view models generate unique ids. Set this to false in the // subclass if you don't want to support it setHasStableIds(true); spanSizeLookup.setSpanIndexCacheEnabled(true); } /** * This is called when recoverable exceptions happen at runtime. They can be ignored and Epoxy * will recover, but you can override this to be aware of when they happen. */ protected void onExceptionSwallowed(RuntimeException exception) { } @Override public int getItemCount() { return getCurrentModels().size(); } /** Return the models currently being used by the adapter to populate the recyclerview. */ abstract List> getCurrentModels(); public boolean isEmpty() { return getCurrentModels().isEmpty(); } @Override public long getItemId(int position) { // This does not call getModelForPosition so that we don't use the id of the empty model when // hidden, // so that the id stays constant when gone vs shown return getCurrentModels().get(position).id(); } @Override public int getItemViewType(int position) { return viewTypeManager.getViewTypeAndRememberModel(getModelForPosition(position)); } @Override public EpoxyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { EpoxyModel model = viewTypeManager.getModelForViewType(this, viewType); View view = model.buildView(parent); return new EpoxyViewHolder(parent, view, model.shouldSaveViewState()); } @Override public void onBindViewHolder(EpoxyViewHolder holder, int position) { onBindViewHolder(holder, position, Collections.emptyList()); } @Override public void onBindViewHolder(EpoxyViewHolder holder, int position, List payloads) { EpoxyModel modelToShow = getModelForPosition(position); EpoxyModel previouslyBoundModel = null; if (diffPayloadsEnabled()) { previouslyBoundModel = DiffPayload.getModelFromPayload(payloads, getItemId(position)); } holder.bind(modelToShow, previouslyBoundModel, payloads, position); if (payloads.isEmpty()) { // We only apply saved state to the view on initial bind, not on model updates. // Since view state should be independent of model props, we should not need to apply state // again in this case. This simplifies a rebind on update viewHolderState.restore(holder); } boundViewHolders.put(holder); if (diffPayloadsEnabled()) { onModelBound(holder, modelToShow, position, previouslyBoundModel); } else { onModelBound(holder, modelToShow, position, payloads); } } boolean diffPayloadsEnabled() { return false; } /** * Called immediately after a model is bound to a view holder. Subclasses can override this if * they want alerts on when a model is bound. */ protected void onModelBound(EpoxyViewHolder holder, EpoxyModel model, int position, @Nullable List payloads) { onModelBound(holder, model, position); } void onModelBound(EpoxyViewHolder holder, EpoxyModel model, int position, @Nullable EpoxyModel previouslyBoundModel) { onModelBound(holder, model, position); } /** * Called immediately after a model is bound to a view holder. Subclasses can override this if * they want alerts on when a model is bound. */ protected void onModelBound(EpoxyViewHolder holder, EpoxyModel model, int position) { } /** * Returns an object that manages the view holders currently bound to the RecyclerView. This * object is mainly used by the base Epoxy adapter to save view states, but you may find it useful * to help access views or models currently shown in the RecyclerView. */ protected BoundViewHolders getBoundViewHolders() { return boundViewHolders; } EpoxyModel getModelForPosition(int position) { return getCurrentModels().get(position); } @Override public void onViewRecycled(EpoxyViewHolder holder) { viewHolderState.save(holder); boundViewHolders.remove(holder); EpoxyModel model = holder.getModel(); holder.unbind(); onModelUnbound(holder, model); } @CallSuper @Override public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { // The last model is saved for optimization, but holding onto it can leak anything saved inside // the model (like a click listener that references a Fragment). This is only needed during // the viewholder creation phase, so it is safe to clear now. viewTypeManager.lastModelForViewTypeLookup = null; } /** * Called immediately after a model is unbound from a view holder. Subclasses can override this if * they want alerts on when a model is unbound. */ protected void onModelUnbound(EpoxyViewHolder holder, EpoxyModel model) { } @CallSuper @Override public boolean onFailedToRecycleView(EpoxyViewHolder holder) { //noinspection unchecked,rawtypes return ((EpoxyModel) holder.getModel()).onFailedToRecycleView(holder.objectToBind()); } @CallSuper @Override public void onViewAttachedToWindow(EpoxyViewHolder holder) { //noinspection unchecked,rawtypes ((EpoxyModel) holder.getModel()).onViewAttachedToWindow(holder.objectToBind()); } @CallSuper @Override public void onViewDetachedFromWindow(EpoxyViewHolder holder) { //noinspection unchecked,rawtypes ((EpoxyModel) holder.getModel()).onViewDetachedFromWindow(holder.objectToBind()); } public void onSaveInstanceState(Bundle outState) { // Save the state of currently bound views first so they are included. Views that were // scrolled off and unbound will already have had // their state saved. for (EpoxyViewHolder holder : boundViewHolders) { viewHolderState.save(holder); } if (viewHolderState.size() > 0 && !hasStableIds()) { throw new IllegalStateException("Must have stable ids when saving view holder state"); } outState.putParcelable(SAVED_STATE_ARG_VIEW_HOLDERS, viewHolderState); } public void onRestoreInstanceState(@Nullable Bundle inState) { // To simplify things we enforce that state is restored before views are bound, otherwise it // is more difficult to update view state once they are bound if (boundViewHolders.size() > 0) { throw new IllegalStateException( "State cannot be restored once views have been bound. It should be done before adding " + "the adapter to the recycler view."); } if (inState != null) { viewHolderState = inState.getParcelable(SAVED_STATE_ARG_VIEW_HOLDERS); if (viewHolderState == null) { throw new IllegalStateException( "Tried to restore instance state, but onSaveInstanceState was never called."); } } } /** * Finds the position of the given model in the list. Doesn't use indexOf to avoid unnecessary * equals() calls since we're looking for the same object instance. * * @return The position of the given model in the current models list, or -1 if the model can't be * found. */ protected int getModelPosition(EpoxyModel model) { int size = getCurrentModels().size(); for (int i = 0; i < size; i++) { if (model == getCurrentModels().get(i)) { return i; } } return -1; } /** * For use with a grid layout manager - use this to get the {@link SpanSizeLookup} for models in * this adapter. This will delegate span look up calls to each model's {@link * EpoxyModel#getSpanSize(int, int, int)}. Make sure to also call {@link #setSpanCount(int)} so * the span count is correct. */ public SpanSizeLookup getSpanSizeLookup() { return spanSizeLookup; } /** * If you are using a grid layout manager you must call this to set the span count of the grid. * This span count will be passed on to the models so models can choose what span count to be. * * @see #getSpanSizeLookup() * @see EpoxyModel#getSpanSize(int, int, int) */ public void setSpanCount(int spanCount) { this.spanCount = spanCount; } public int getSpanCount() { return spanCount; } public boolean isMultiSpan() { return spanCount > 1; } //region Sticky header /** * Optional callback to setup the sticky view, * by default it doesn't do anything. *

* The sub-classes should override the function if they are * using sticky header feature. */ @Override public void setupStickyHeaderView(@NotNull View stickyHeader) { // no-op } /** * Optional callback to perform tear down operation on the * sticky view, by default it doesn't do anything. *

* The sub-classes should override the function if they are * using sticky header feature. */ @Override public void teardownStickyHeaderView(@NotNull View stickyHeader) { // no-op } /** * Called to check if the item at the position is a sticky item, * by default returns false. *

* The sub-classes should override the function if they are * using sticky header feature. */ @Override public boolean isStickyHeader(int position) { return false; } //endregion } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/BaseEpoxyTouchCallback.java ================================================ package com.airbnb.epoxy; import android.view.View; interface BaseEpoxyTouchCallback { /** * Should return a composite flag which defines the enabled move directions in each state * (idle, swiping, dragging) for the given model. *

* Return 0 to disable movement for the model. * * @param model The model being targeted for movement. * @param adapterPosition The current adapter position of the targeted model * @see androidx.recyclerview.widget.ItemTouchHelper.Callback#getMovementFlags */ int getMovementFlagsForModel(T model, int adapterPosition); /** * Called when the user interaction with a view is over and the view has * completed its animation. This is a good place to clear all changes on the view that were done * in other previous touch callbacks (such as on touch start, change, release, etc). *

* This is the last callback in the lifecycle of a touch event. * * @param model The model whose view is being cleared. * @param itemView The view being cleared. */ void clearView(T model, View itemView); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/BoundViewHolders.java ================================================ package com.airbnb.epoxy; import java.util.Iterator; import java.util.NoSuchElementException; import androidx.annotation.Nullable; import androidx.collection.LongSparseArray; /** Helper class for keeping track of {@link EpoxyViewHolder}s that are currently bound. */ @SuppressWarnings("WeakerAccess") public class BoundViewHolders implements Iterable { private final LongSparseArray holders = new LongSparseArray<>(); @Nullable public EpoxyViewHolder get(EpoxyViewHolder holder) { return holders.get(holder.getItemId()); } public void put(EpoxyViewHolder holder) { holders.put(holder.getItemId(), holder); } public void remove(EpoxyViewHolder holder) { holders.remove(holder.getItemId()); } public int size() { return holders.size(); } @Override public Iterator iterator() { return new HolderIterator(); } @Nullable public EpoxyViewHolder getHolderForModel(EpoxyModel model) { return holders.get(model.id()); } private class HolderIterator implements Iterator { private int position = 0; @Override public boolean hasNext() { return position < holders.size(); } @Override public EpoxyViewHolder next() { if (!hasNext()) { throw new NoSuchElementException(); } return holders.valueAt(position++); } @Override public void remove() { throw new UnsupportedOperationException(); } } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/Carousel.java ================================================ package com.airbnb.epoxy; import android.content.Context; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.view.View; import android.view.ViewGroup; import com.airbnb.epoxy.ModelView.Size; import com.airbnb.viewmodeladapter.R; import java.util.List; import androidx.annotation.DimenRes; import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearSnapHelper; import androidx.recyclerview.widget.SnapHelper; /** * This feature is in Beta - please report bugs, feature requests, or other feedback at * https://github.com/airbnb/epoxy by creating a new issue. Thanks! * *

This is intended as a plug and play "Carousel" view - a Recyclerview with horizontal * scrolling. It comes with common defaults and performance optimizations and can be either used as * a top level RecyclerView, or nested within a vertical recyclerview. * *

This class provides: * *

1. Automatic integration with Epoxy. A {@link CarouselModel_} is generated from this class, * which you can use in your EpoxyController. Just call {@link #setModels(List)} to provide the list * of models to show in the carousel. * *

2. Default padding for carousel peeking, and an easy way to change this padding - {@link * #setPaddingDp(int)} * *

3. Easily control how many items are shown on screen in the carousel at a time - {@link * #setNumViewsToShowOnScreen(float)} * *

4. Easy snap support. By default a {@link LinearSnapHelper} is used, but you can set a global * default for all Carousels with {@link #setDefaultGlobalSnapHelperFactory(SnapHelperFactory)} * *

5. All of the benefits of {@link EpoxyRecyclerView} * *

If you need further flexibility you can subclass this view to change its width, height, * scrolling direction, etc. You can annotate a subclass with {@link ModelView} to generate a new * EpoxyModel. */ @ModelView(saveViewState = true, autoLayout = Size.MATCH_WIDTH_WRAP_HEIGHT) public class Carousel extends EpoxyRecyclerView { public static final int NO_VALUE_SET = -1; private static SnapHelperFactory defaultGlobalSnapHelperFactory = new SnapHelperFactory() { @Override @NonNull public SnapHelper buildSnapHelper(Context context) { return new LinearSnapHelper(); } }; @Dimension(unit = Dimension.DP) private static int defaultSpacingBetweenItemsDp = 8; private float numViewsToShowOnScreen; public Carousel(Context context) { super(context); } public Carousel(Context context, @Nullable AttributeSet attrs) { super(context, attrs); } public Carousel(Context context, @Nullable AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override protected void init() { super.init(); // When used as a model the padding can't be set via xml so we set it programmatically int defaultSpacingDp = getDefaultSpacingBetweenItemsDp(); if (defaultSpacingDp >= 0) { setItemSpacingDp(defaultSpacingDp); if (getPaddingLeft() == 0 && getPaddingRight() == 0 && getPaddingTop() == 0 && getPaddingBottom() == 0) { // Use the item spacing as the default padding if no other padding has been set setPaddingDp(defaultSpacingDp); } } SnapHelperFactory snapHelperFactory = getSnapHelperFactory(); if (snapHelperFactory != null) { snapHelperFactory.buildSnapHelper(getContext()).attachToRecyclerView(this); } // Carousels will be detached when their parent recyclerview is setRemoveAdapterWhenDetachedFromWindow(false); } /** * Return a {@link SnapHelperFactory} instance to use with this Carousel. The {@link SnapHelper} * created by the factory will be attached to this Carousel on view creation. Return null for no * snap helper to be attached automatically. */ @Nullable protected SnapHelperFactory getSnapHelperFactory() { return defaultGlobalSnapHelperFactory; } /** * Set a {@link SnapHelperFactory} instance to use with all Carousels by default. The {@link * SnapHelper} created by the factory will be attached to each Carousel on view creation. Set null * for no snap helper to be attached automatically. * *

A Carousel subclass can implement {@link #getSnapHelperFactory()} to override the global * default. */ public static void setDefaultGlobalSnapHelperFactory(@Nullable SnapHelperFactory factory) { defaultGlobalSnapHelperFactory = factory; } @ModelProp @Override public void setHasFixedSize(boolean hasFixedSize) { super.setHasFixedSize(hasFixedSize); } /** * Set the number of views to show on screen in this carousel at a time, partial numbers are * allowed. * *

This is useful where you want to easily control for the number of items on screen, * regardless of screen size. For example, you could set this to 1.2f so that one view is shown in * full and 20% of the next view "peeks" from the edge to indicate that there is more content to * scroll to. * *

Another pattern is setting a different view count depending on whether the device is phone * or tablet. * *

Additionally, if a LinearLayoutManager is used this value will be forwarded to {@link * LinearLayoutManager#setInitialPrefetchItemCount(int)} as a performance optimization. * *

If you want to only change the prefetch count without changing the view size you can simply * use {@link #setInitialPrefetchItemCount(int)} */ @ModelProp(group = "prefetch") public void setNumViewsToShowOnScreen(float viewCount) { numViewsToShowOnScreen = viewCount; setInitialPrefetchItemCount((int) Math.ceil(viewCount)); } /** * @return The number of views to show on screen in this carousel at a time. */ public float getNumViewsToShowOnScreen() { return numViewsToShowOnScreen; } /** * If you are using a Linear or Grid layout manager you can use this to set the item prefetch * count. Only use this if you are not using {@link #setNumViewsToShowOnScreen(float)} * * @see #setNumViewsToShowOnScreen(float) * @see LinearLayoutManager#setInitialPrefetchItemCount(int) */ @ModelProp(group = "prefetch") public void setInitialPrefetchItemCount(int numItemsToPrefetch) { if (numItemsToPrefetch < 0) { throw new IllegalStateException("numItemsToPrefetch must be greater than 0"); } // Use the linearlayoutmanager default of 2 if the user did not specify one int prefetchCount = numItemsToPrefetch == 0 ? 2 : numItemsToPrefetch; LayoutManager layoutManager = getLayoutManager(); if (layoutManager instanceof LinearLayoutManager) { ((LinearLayoutManager) layoutManager).setInitialPrefetchItemCount(prefetchCount); } } @Override public void onChildAttachedToWindow(View child) { if (numViewsToShowOnScreen > 0) { ViewGroup.LayoutParams childLayoutParams = child.getLayoutParams(); child.setTag(R.id.epoxy_recycler_view_child_initial_size_id, childLayoutParams.width); int itemSpacingPx = getSpacingDecorator().getPxBetweenItems(); int spaceBetweenItems = 0; if (itemSpacingPx > 0) { // The item decoration space is not counted in the width of the view spaceBetweenItems = (int) (itemSpacingPx * numViewsToShowOnScreen); } boolean isScrollingHorizontally = getLayoutManager().canScrollHorizontally(); int itemSizeInScrollingDirection = (int) ((getSpaceForChildren(isScrollingHorizontally) - spaceBetweenItems) / numViewsToShowOnScreen); if (isScrollingHorizontally) { childLayoutParams.width = itemSizeInScrollingDirection; } else { childLayoutParams.height = itemSizeInScrollingDirection; } // We don't need to request layout because the layout manager will do that for us next } } private int getSpaceForChildren(boolean horizontal) { if (horizontal) { return getTotalWidthPx(this) - getPaddingLeft() - (getClipToPadding() ? getPaddingRight() : 0); // If child views will be showing through padding than we include just one side of padding // since when the list is at position 0 only the child towards the end of the list will show // through the padding. } else { return getTotalHeightPx(this) - getPaddingTop() - (getClipToPadding() ? getPaddingBottom() : 0); } } @Px private static int getTotalWidthPx(View view) { if (view.getWidth() > 0) { // Can only get a width if we are laid out return view.getWidth(); } if (view.getMeasuredWidth() > 0) { return view.getMeasuredWidth(); } // Fall back to assuming we want the full screen width DisplayMetrics metrics = view.getContext().getResources().getDisplayMetrics(); return metrics.widthPixels; } @Px private static int getTotalHeightPx(View view) { if (view.getHeight() > 0) { return view.getHeight(); } if (view.getMeasuredHeight() > 0) { return view.getMeasuredHeight(); } // Fall back to assuming we want the full screen width DisplayMetrics metrics = view.getContext().getResources().getDisplayMetrics(); return metrics.heightPixels; } @Override public void onChildDetachedFromWindow(View child) { // Restore the view width that existed before we modified it Object initialWidth = child.getTag(R.id.epoxy_recycler_view_child_initial_size_id); if (initialWidth instanceof Integer) { ViewGroup.LayoutParams params = child.getLayoutParams(); params.width = (int) initialWidth; child.setTag(R.id.epoxy_recycler_view_child_initial_size_id, null); // No need to request layout since the view is unbound and not attached to window } } /** * Set a global default to use as the item spacing for all Carousels. Set to 0 for no item * spacing. */ public static void setDefaultItemSpacingDp(@Dimension(unit = Dimension.DP) int dp) { defaultSpacingBetweenItemsDp = dp; } /** * Return the item spacing to use in this carousel, or 0 for no spacing. * *

By default this uses the global default set in {@link #setDefaultItemSpacingDp(int)}, but * subclasses can override this to specify their own value. */ @Dimension(unit = Dimension.DP) protected int getDefaultSpacingBetweenItemsDp() { return defaultSpacingBetweenItemsDp; } /** * Set a dimension resource to specify the padding value to use on each side of the carousel and * in between carousel items. */ @ModelProp(group = "padding") public void setPaddingRes(@DimenRes int paddingRes) { int px = resToPx(paddingRes); setPadding(px, px, px, px); setItemSpacingPx(px); } /** * Set a DP value to use as the padding on each side of the carousel and in between carousel * items. * *

The default as the value returned by {@link #getDefaultSpacingBetweenItemsDp()} */ @ModelProp(defaultValue = "NO_VALUE_SET", group = "padding") public void setPaddingDp(@Dimension(unit = Dimension.DP) int paddingDp) { int px = dpToPx(paddingDp != NO_VALUE_SET ? paddingDp : getDefaultSpacingBetweenItemsDp()); setPadding(px, px, px, px); setItemSpacingPx(px); } /** * Use the {@link Padding} class to specify individual padding values for each side of the * carousel, as well as item spacing. * *

A value of null will set all padding and item spacing to 0. */ @ModelProp(group = "padding") public void setPadding(@Nullable Padding padding) { if (padding == null) { setPaddingDp(0); } else if (padding.paddingType == Padding.PaddingType.PX) { setPadding(padding.left, padding.top, padding.right, padding.bottom); setItemSpacingPx(padding.itemSpacing); } else if (padding.paddingType == Padding.PaddingType.DP) { setPadding( dpToPx(padding.left), dpToPx(padding.top), dpToPx(padding.right), dpToPx(padding.bottom)); setItemSpacingPx(dpToPx(padding.itemSpacing)); } else if (padding.paddingType == Padding.PaddingType.RESOURCE) { setPadding( resToPx(padding.left), resToPx(padding.top), resToPx(padding.right), resToPx(padding.bottom)); setItemSpacingPx(resToPx(padding.itemSpacing)); } } /** * Used to specify individual padding values programmatically. * * @see #setPadding(Padding) */ public static class Padding { public final int left; public final int top; public final int right; public final int bottom; public final int itemSpacing; public final PaddingType paddingType; enum PaddingType { PX, DP, RESOURCE } /** * @param paddingRes Padding as dimension resource. * @param itemSpacingRes Space as dimension resource to add between each carousel item. Will be * implemented via an item decoration. */ public static Padding resource(@DimenRes int paddingRes, @DimenRes int itemSpacingRes) { return new Padding( paddingRes, paddingRes, paddingRes, paddingRes, itemSpacingRes, PaddingType.RESOURCE); } /** * @param leftRes Left padding as dimension resource. * @param topRes Top padding as dimension resource. * @param rightRes Right padding as dimension resource. * @param bottomRes Bottom padding as dimension resource. * @param itemSpacingRes Space as dimension resource to add between each carousel item. Will be * implemented via an item decoration. */ public static Padding resource( @DimenRes int leftRes, @DimenRes int topRes, @DimenRes int rightRes, @DimenRes int bottomRes, @DimenRes int itemSpacingRes) { return new Padding( leftRes, topRes, rightRes, bottomRes, itemSpacingRes, PaddingType.RESOURCE); } /** * @param paddingDp Padding in dp. * @param itemSpacingDp Space in dp to add between each carousel item. Will be implemented via * an item decoration. */ public static Padding dp( @Dimension(unit = Dimension.DP) int paddingDp, @Dimension(unit = Dimension.DP) int itemSpacingDp) { return new Padding(paddingDp, paddingDp, paddingDp, paddingDp, itemSpacingDp, PaddingType.DP); } /** * @param leftDp Left padding in dp. * @param topDp Top padding in dp. * @param rightDp Right padding in dp. * @param bottomDp Bottom padding in dp. * @param itemSpacingDp Space in dp to add between each carousel item. Will be implemented via * an item decoration. */ public static Padding dp( @Dimension(unit = Dimension.DP) int leftDp, @Dimension(unit = Dimension.DP) int topDp, @Dimension(unit = Dimension.DP) int rightDp, @Dimension(unit = Dimension.DP) int bottomDp, @Dimension(unit = Dimension.DP) int itemSpacingDp) { return new Padding(leftDp, topDp, rightDp, bottomDp, itemSpacingDp, PaddingType.DP); } /** * @param paddingPx Padding in pixels to add on all sides of the carousel * @param itemSpacingPx Space in pixels to add between each carousel item. Will be implemented * via an item decoration. */ public Padding(@Px int paddingPx, @Px int itemSpacingPx) { this(paddingPx, paddingPx, paddingPx, paddingPx, itemSpacingPx, PaddingType.PX); } /** * @param leftPx Left padding in pixels. * @param topPx Top padding in pixels. * @param rightPx Right padding in pixels. * @param bottomPx Bottom padding in pixels. * @param itemSpacingPx Space in pixels to add between each carousel item. Will be implemented * via an item decoration. */ public Padding( @Px int leftPx, @Px int topPx, @Px int rightPx, @Px int bottomPx, @Px int itemSpacingPx) { this(leftPx, topPx, rightPx, bottomPx, itemSpacingPx, PaddingType.PX); } /** * @param left Left padding. * @param top Top padding. * @param right Right padding. * @param bottom Bottom padding. * @param itemSpacing Space to add between each carousel item. Will be implemented via an item * decoration. * @param paddingType Unit / Type of the given paddings/ itemspacing. */ private Padding( int left, int top, int right, int bottom, int itemSpacing, PaddingType paddingType) { this.left = left; this.top = top; this.right = right; this.bottom = bottom; this.itemSpacing = itemSpacing; this.paddingType = paddingType; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Padding padding = (Padding) o; if (left != padding.left) { return false; } if (top != padding.top) { return false; } if (right != padding.right) { return false; } if (bottom != padding.bottom) { return false; } return itemSpacing == padding.itemSpacing; } @Override public int hashCode() { int result = left; result = 31 * result + top; result = 31 * result + right; result = 31 * result + bottom; result = 31 * result + itemSpacing; return result; } } @ModelProp public void setModels(@NonNull List> models) { super.setModels(models); } @OnViewRecycled public void clear() { super.clear(); } /** Provide a SnapHelper implementation you want to use with a Carousel. */ public abstract static class SnapHelperFactory { /** * Create and return a new instance of a {@link androidx.recyclerview.widget.SnapHelper} for use * with a Carousel. */ @NonNull public abstract SnapHelper buildSnapHelper(Context context); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ControllerHelper.java ================================================ package com.airbnb.epoxy; import java.util.List; /** * A helper class for {@link EpoxyController} to handle {@link * com.airbnb.epoxy.AutoModel} models. This is only implemented by the generated classes created the * annotation processor. */ public abstract class ControllerHelper { public abstract void resetAutoModels(); protected void validateModelHashCodesHaveNotChanged(T controller) { List> currentModels = controller.getAdapter().getCopyOfModels(); for (int i = 0; i < currentModels.size(); i++) { EpoxyModel model = currentModels.get(i); model.validateStateHasNotChangedSinceAdded( "Model has changed since it was added to the controller.", i); } } protected void setControllerToStageTo(EpoxyModel model, T controller) { model.controllerToStageTo = controller; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ControllerHelperLookup.java ================================================ package com.airbnb.epoxy; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.LinkedHashMap; import java.util.Map; import androidx.annotation.Nullable; /** * Looks up a generated {@link ControllerHelper} implementation for a given adapter. * If the adapter has no {@link com.airbnb.epoxy.AutoModel} models then a No-Op implementation will * be returned. */ class ControllerHelperLookup { private static final String GENERATED_HELPER_CLASS_SUFFIX = "_EpoxyHelper"; private static final Map, Constructor> BINDINGS = new LinkedHashMap<>(); private static final NoOpControllerHelper NO_OP_CONTROLLER_HELPER = new NoOpControllerHelper(); static ControllerHelper getHelperForController(EpoxyController controller) { Constructor constructor = findConstructorForClass(controller.getClass()); if (constructor == null) { return NO_OP_CONTROLLER_HELPER; } try { return (ControllerHelper) constructor.newInstance(controller); } catch (IllegalAccessException e) { throw new RuntimeException("Unable to invoke " + constructor, e); } catch (InstantiationException e) { throw new RuntimeException("Unable to invoke " + constructor, e); } catch (InvocationTargetException e) { Throwable cause = e.getCause(); if (cause instanceof RuntimeException) { throw (RuntimeException) cause; } if (cause instanceof Error) { throw (Error) cause; } throw new RuntimeException("Unable to get Epoxy helper class.", cause); } } @Nullable private static Constructor findConstructorForClass(Class controllerClass) { Constructor helperCtor = BINDINGS.get(controllerClass); if (helperCtor != null || BINDINGS.containsKey(controllerClass)) { return helperCtor; } String clsName = controllerClass.getName(); if (clsName.startsWith("android.") || clsName.startsWith("java.")) { return null; } try { Class bindingClass = Class.forName(clsName + GENERATED_HELPER_CLASS_SUFFIX); //noinspection unchecked helperCtor = bindingClass.getConstructor(controllerClass); } catch (ClassNotFoundException e) { helperCtor = findConstructorForClass(controllerClass.getSuperclass()); } catch (NoSuchMethodException e) { throw new RuntimeException("Unable to find Epoxy Helper constructor for " + clsName, e); } BINDINGS.put(controllerClass, helperCtor); return helperCtor; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ControllerModelList.java ================================================ package com.airbnb.epoxy; /** * This ArrayList subclass enforces that no changes are made to the list after {@link #freeze()} is * called. This prevents model interceptors from storing the list and trying to change it later. We * could copy the list before diffing, but that would waste memory to make the copy for every * buildModels cycle, plus the interceptors could still try to modify the list and be confused about * why it doesn't do anything. */ class ControllerModelList extends ModelList { private static final ModelListObserver OBSERVER = new ModelListObserver() { @Override public void onItemRangeInserted(int positionStart, int itemCount) { throw new IllegalStateException( "Models cannot be changed once they are added to the controller"); } @Override public void onItemRangeRemoved(int positionStart, int itemCount) { throw new IllegalStateException( "Models cannot be changed once they are added to the controller"); } }; ControllerModelList(int expectedModelCount) { super(expectedModelCount); pauseNotifications(); } void freeze() { setObserver(OBSERVER); resumeNotifications(); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/DebugTimer.java ================================================ package com.airbnb.epoxy; import android.util.Log; class DebugTimer implements Timer { private final String tag; private long startTime; private String sectionName; DebugTimer(String tag) { this.tag = tag; reset(); } private void reset() { startTime = -1; sectionName = null; } @Override public void start(String sectionName) { if (startTime != -1) { throw new IllegalStateException("Timer was already started"); } startTime = System.nanoTime(); this.sectionName = sectionName; } @Override public void stop() { if (startTime == -1) { throw new IllegalStateException("Timer was not started"); } float durationMs = (System.nanoTime() - startTime) / 1000000f; Log.d(tag, String.format(sectionName + ": %.3fms", durationMs)); reset(); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/DiffHelper.java ================================================ package com.airbnb.epoxy; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; /** * Helper to track changes in the models list. */ class DiffHelper { private ArrayList oldStateList = new ArrayList<>(); // Using a HashMap instead of a LongSparseArray to // have faster look up times at the expense of memory private Map oldStateMap = new HashMap<>(); private ArrayList currentStateList = new ArrayList<>(); private Map currentStateMap = new HashMap<>(); private final BaseEpoxyAdapter adapter; private final boolean immutableModels; DiffHelper(BaseEpoxyAdapter adapter, boolean immutableModels) { this.adapter = adapter; this.immutableModels = immutableModels; adapter.registerAdapterDataObserver(observer); } private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() { @Override public void onChanged() { throw new UnsupportedOperationException( "Diffing is enabled. You should use notifyModelsChanged instead of notifyDataSetChanged"); } @Override public void onItemRangeChanged(int positionStart, int itemCount) { for (int i = positionStart; i < positionStart + itemCount; i++) { currentStateList.get(i).hashCode = adapter.getCurrentModels().get(i).hashCode(); } } @Override public void onItemRangeInserted(int positionStart, int itemCount) { if (itemCount == 0) { // no-op return; } if (itemCount == 1 || positionStart == currentStateList.size()) { for (int i = positionStart; i < positionStart + itemCount; i++) { currentStateList.add(i, createStateForPosition(i)); } } else { // Add in a batch since multiple insertions to the middle of the list are slow List newModels = new ArrayList<>(itemCount); for (int i = positionStart; i < positionStart + itemCount; i++) { newModels.add(createStateForPosition(i)); } currentStateList.addAll(positionStart, newModels); } // Update positions of affected items int size = currentStateList.size(); for (int i = positionStart + itemCount; i < size; i++) { currentStateList.get(i).position += itemCount; } } @Override public void onItemRangeRemoved(int positionStart, int itemCount) { if (itemCount == 0) { // no-op return; } List modelsToRemove = currentStateList.subList(positionStart, positionStart + itemCount); for (ModelState model : modelsToRemove) { currentStateMap.remove(model.id); } modelsToRemove.clear(); // Update positions of affected items int size = currentStateList.size(); for (int i = positionStart; i < size; i++) { currentStateList.get(i).position -= itemCount; } } @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { if (fromPosition == toPosition) { // no-op return; } if (itemCount != 1) { throw new IllegalArgumentException("Moving more than 1 item at a time is not " + "supported. Number of items moved: " + itemCount); } ModelState model = currentStateList.remove(fromPosition); model.position = toPosition; currentStateList.add(toPosition, model); if (fromPosition < toPosition) { // shift the affected items left for (int i = fromPosition; i < toPosition; i++) { currentStateList.get(i).position--; } } else { // shift the affected items right for (int i = toPosition + 1; i <= fromPosition; i++) { currentStateList.get(i).position++; } } } }; /** * Set the current list of models. The diff callbacks will be notified of the changes between the * current list and the last list that was set. */ void notifyModelChanges() { UpdateOpHelper updateOpHelper = new UpdateOpHelper(); buildDiff(updateOpHelper); // Send out the proper notify calls for the diff. We remove our // observer first so that we don't react to our own notify calls adapter.unregisterAdapterDataObserver(observer); notifyChanges(updateOpHelper); adapter.registerAdapterDataObserver(observer); } private void notifyChanges(UpdateOpHelper opHelper) { for (UpdateOp op : opHelper.opList) { switch (op.type) { case UpdateOp.ADD: adapter.notifyItemRangeInserted(op.positionStart, op.itemCount); break; case UpdateOp.MOVE: adapter.notifyItemMoved(op.positionStart, op.itemCount); break; case UpdateOp.REMOVE: adapter.notifyItemRangeRemoved(op.positionStart, op.itemCount); break; case UpdateOp.UPDATE: if (immutableModels && op.payloads != null) { adapter.notifyItemRangeChanged(op.positionStart, op.itemCount, new DiffPayload(op.payloads)); } else { adapter.notifyItemRangeChanged(op.positionStart, op.itemCount); } break; default: throw new IllegalArgumentException("Unknown type: " + op.type); } } } /** * Create a list of operations that define the difference between {@link #oldStateList} and {@link * #currentStateList}. */ private UpdateOpHelper buildDiff(UpdateOpHelper updateOpHelper) { prepareStateForDiff(); // The general approach is to first search for removals, then additions, and lastly changes. // Focusing on one type of operation at a time makes it easy to coalesce batch changes. // When we identify an operation and add it to the // result list we update the positions of items in the oldStateList to reflect // the change, this way subsequent operations will use the correct, updated positions. collectRemovals(updateOpHelper); // Only need to check for insertions if new list is bigger boolean hasInsertions = oldStateList.size() - updateOpHelper.getNumRemovals() != currentStateList.size(); if (hasInsertions) { collectInsertions(updateOpHelper); } collectMoves(updateOpHelper); collectChanges(updateOpHelper); resetOldState(); return updateOpHelper; } private void resetOldState() { oldStateList.clear(); oldStateMap.clear(); } private void prepareStateForDiff() { // We use a list of the models as well as a map by their id, // so we can easily find them by both position and id oldStateList.clear(); oldStateMap.clear(); // Swap the two lists so that we have a copy of the current state to calculate the next diff ArrayList tempList = oldStateList; oldStateList = currentStateList; currentStateList = tempList; Map tempMap = oldStateMap; oldStateMap = currentStateMap; currentStateMap = tempMap; // Remove all pairings in the old states so we can tell which of them were removed. The items // that still exist in the new list will be paired when we build the current list state below for (ModelState modelState : oldStateList) { modelState.pair = null; } int modelCount = adapter.getCurrentModels().size(); currentStateList.ensureCapacity(modelCount); for (int i = 0; i < modelCount; i++) { currentStateList.add(createStateForPosition(i)); } } private ModelState createStateForPosition(int position) { EpoxyModel model = adapter.getCurrentModels().get(position); model.addedToAdapter = true; ModelState state = ModelState.build(model, position, immutableModels); ModelState previousValue = currentStateMap.put(state.id, state); if (previousValue != null) { int previousPosition = previousValue.position; EpoxyModel previousModel = adapter.getCurrentModels().get(previousPosition); throw new IllegalStateException("Two models have the same ID. ID's must be unique!" + " Model at position " + position + ": " + model + " Model at position " + previousPosition + ": " + previousModel); } return state; } /** * Find all removal operations and add them to the result list. The general strategy here is to * walk through the {@link #oldStateList} and check for items that don't exist in the new list. * Walking through it in order makes it easy to batch adjacent removals. */ private void collectRemovals(UpdateOpHelper helper) { for (ModelState state : oldStateList) { // Update the position of the item to take into account previous removals, // so that future operations will reference the correct position state.position -= helper.getNumRemovals(); // This is our first time going through the list, so we // look up the item with the matching id in the new // list and hold a reference to it so that we can access it quickly in the future state.pair = currentStateMap.get(state.id); if (state.pair != null) { state.pair.pair = state; continue; } helper.remove(state.position); } } /** * Find all insertion operations and add them to the result list. The general strategy here is to * walk through the {@link #currentStateList} and check for items that don't exist in the old * list. Walking through it in order makes it easy to batch adjacent insertions. */ private void collectInsertions(UpdateOpHelper helper) { Iterator oldItemIterator = oldStateList.iterator(); for (ModelState itemToInsert : currentStateList) { if (itemToInsert.pair != null) { // Update the position of the next item in the old list to take any insertions into account ModelState nextOldItem = getNextItemWithPair(oldItemIterator); if (nextOldItem != null) { nextOldItem.position += helper.getNumInsertions(); } continue; } helper.add(itemToInsert.position); } } /** * Check if any items have had their values changed, batching if possible. */ private void collectChanges(UpdateOpHelper helper) { for (ModelState newItem : currentStateList) { ModelState previousItem = newItem.pair; if (previousItem == null) { continue; } // We use equals when we know the models are immutable and available, otherwise we have to // rely on the stored hashCode boolean modelChanged; if (immutableModels) { // Make sure that the old model hasn't changed, otherwise comparing it with the new one // won't be accurate. if (previousItem.model.isDebugValidationEnabled()) { previousItem.model .validateStateHasNotChangedSinceAdded("Model was changed before it could be diffed.", previousItem.position); } modelChanged = !previousItem.model.equals(newItem.model); } else { modelChanged = previousItem.hashCode != newItem.hashCode; } if (modelChanged) { helper.update(newItem.position, previousItem.model); } } } /** * Check which items have had a position changed. Recyclerview does not support batching these. */ private void collectMoves(UpdateOpHelper helper) { // This walks through both the new and old list simultaneous and checks for position changes. Iterator oldItemIterator = oldStateList.iterator(); ModelState nextOldItem = null; for (ModelState newItem : currentStateList) { if (newItem.pair == null) { // This item was inserted. However, insertions are done at the item's final position, and // aren't smart about inserting at a different position to take future moves into account. // As the old state list is updated to reflect moves, it needs to also consider insertions // affected by those moves in order for the final change set to be correct if (helper.moves.isEmpty()) { // There have been no moves, so the item is still at it's correct position continue; } else { // There have been moves, so the old list needs to take this inserted item // into account. The old list doesn't have this item inserted into it // (for optimization purposes), but we can create a pair for this item to // track its position in the old list and move it back to its final position if necessary newItem.pairWithSelf(); } } // We could iterate through only the new list and move each // item that is out of place, however in cases such as moving the first item // to the end, that strategy would do many moves to move all // items up one instead of doing one move to move the first item to the end. // To avoid this we compare the old item to the new item at // each index and move the one that is farthest from its correct position. // We only move on from a new item once its pair is placed in // the correct spot. Since we move from start to end, all new items we've // already iterated through are guaranteed to have their pair // be already in the right spot, which won't be affected by future MOVEs. if (nextOldItem == null) { nextOldItem = getNextItemWithPair(oldItemIterator); // We've already iterated through all old items and moved each // item once. However, subsequent moves may have shifted an item out of // its correct space once it was already moved. We finish // iterating through all the new items to ensure everything is still correct if (nextOldItem == null) { nextOldItem = newItem.pair; } } while (nextOldItem != null) { // Make sure the positions are updated to the latest // move operations before we calculate the next move updateItemPosition(newItem.pair, helper.moves); updateItemPosition(nextOldItem, helper.moves); // The item is the same and its already in the correct place if (newItem.id == nextOldItem.id && newItem.position == nextOldItem.position) { nextOldItem = null; break; } int newItemDistance = newItem.pair.position - newItem.position; int oldItemDistance = nextOldItem.pair.position - nextOldItem.position; // Both items are already in the correct position if (newItemDistance == 0 && oldItemDistance == 0) { nextOldItem = null; break; } if (oldItemDistance > newItemDistance) { helper.move(nextOldItem.position, nextOldItem.pair.position); nextOldItem.position = nextOldItem.pair.position; nextOldItem.lastMoveOp = helper.getNumMoves(); nextOldItem = getNextItemWithPair(oldItemIterator); } else { helper.move(newItem.pair.position, newItem.position); newItem.pair.position = newItem.position; newItem.pair.lastMoveOp = helper.getNumMoves(); break; } } } } /** * Apply the movement operations to the given item to update its position. Only applies the * operations that have not been applied yet, and stores how many operations have been applied so * we know which ones to apply next time. */ private void updateItemPosition(ModelState item, List moveOps) { int size = moveOps.size(); for (int i = item.lastMoveOp; i < size; i++) { UpdateOp moveOp = moveOps.get(i); int fromPosition = moveOp.positionStart; int toPosition = moveOp.itemCount; if (item.position > fromPosition && item.position <= toPosition) { item.position--; } else if (item.position < fromPosition && item.position >= toPosition) { item.position++; } } item.lastMoveOp = size; } /** * Gets the next item in the list that has a pair, meaning it wasn't inserted or removed. */ @Nullable private ModelState getNextItemWithPair(Iterator iterator) { ModelState nextItem = null; while (nextItem == null && iterator.hasNext()) { nextItem = iterator.next(); if (nextItem.pair == null) { // Skip this one and go on to the next nextItem = null; } } return nextItem; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/DiffPayload.java ================================================ package com.airbnb.epoxy; import java.util.Collections; import java.util.List; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.collection.LongSparseArray; /** * A helper class for tracking changed models found by the {@link com.airbnb.epoxy.DiffHelper} to * be included as a payload in the * {@link androidx.recyclerview.widget.RecyclerView.Adapter#notifyItemChanged(int, Object)} * call. */ public class DiffPayload { private final EpoxyModel singleModel; private final LongSparseArray> modelsById; DiffPayload(List> models) { if (models.isEmpty()) { throw new IllegalStateException("Models must not be empty"); } int modelCount = models.size(); if (modelCount == 1) { // Optimize for the common case of only one model changed. singleModel = models.get(0); modelsById = null; } else { singleModel = null; modelsById = new LongSparseArray<>(modelCount); for (EpoxyModel model : models) { modelsById.put(model.id(), model); } } } public DiffPayload(EpoxyModel changedItem) { this(Collections.singletonList(changedItem)); } /** * Looks through the payloads list and returns the first model found with the given model id. This * assumes that the payloads list will only contain objects of type {@link DiffPayload}, and will * throw if an unexpected type is found. */ @Nullable public static EpoxyModel getModelFromPayload(List payloads, long modelId) { if (payloads.isEmpty()) { return null; } for (Object payload : payloads) { DiffPayload diffPayload = (DiffPayload) payload; if (diffPayload.singleModel != null) { if (diffPayload.singleModel.id() == modelId) { return diffPayload.singleModel; } } else { EpoxyModel modelForId = diffPayload.modelsById.get(modelId); if (modelForId != null) { return modelForId; } } } return null; } @VisibleForTesting boolean equalsForTesting(DiffPayload that) { if (singleModel != null) { return that.singleModel == singleModel; } int thisSize = modelsById.size(); int thatSize = that.modelsById.size(); if (thisSize != thatSize) { return false; } for (int i = 0; i < thisSize; i++) { long thisKey = modelsById.keyAt(i); long thatKey = that.modelsById.keyAt(i); if (thisKey != thatKey) { return false; } EpoxyModel thisModel = modelsById.valueAt(i); EpoxyModel thatModel = that.modelsById.valueAt(i); if (thisModel != thatModel) { return false; } } return true; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/DiffResult.java ================================================ package com.airbnb.epoxy; import java.util.Collections; import java.util.List; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.AdapterListUpdateCallback; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListUpdateCallback; import androidx.recyclerview.widget.RecyclerView.Adapter; /** * Wraps the result of {@link AsyncEpoxyDiffer#submitList(List)}. */ public class DiffResult { @NonNull final List> previousModels; @NonNull final List> newModels; /** * If this is non null it means the full differ ran and the result is contained * in this object. If it is null, it means that either the old list or the new list was empty, so * we can simply add all or clear all items and skipped running the full diffing. */ @Nullable final DiffUtil.DiffResult differResult; /** No changes were made to the models. */ static DiffResult noOp(@Nullable List> models) { if (models == null) { models = Collections.emptyList(); } return new DiffResult(models, models, null); } /** The previous list was empty and the given non empty list was inserted. */ static DiffResult inserted(@NonNull List> newModels) { //noinspection unchecked return new DiffResult(Collections.EMPTY_LIST, newModels, null); } /** The previous list was non empty and the new list is empty. */ static DiffResult clear(@NonNull List> previousModels) { //noinspection unchecked return new DiffResult(previousModels, Collections.EMPTY_LIST, null); } /** * The previous and new models are both non empty and a full differ pass was run on them. * There may be no changes, however. */ static DiffResult diff( @NonNull List> previousModels, @NonNull List> newModels, @NonNull DiffUtil.DiffResult differResult ) { return new DiffResult(previousModels, newModels, differResult); } private DiffResult( @NonNull List> previousModels, @NonNull List> newModels, @Nullable DiffUtil.DiffResult differResult ) { this.previousModels = previousModels; this.newModels = newModels; this.differResult = differResult; } public void dispatchTo(Adapter adapter) { dispatchTo(new AdapterListUpdateCallback(adapter)); } public void dispatchTo(ListUpdateCallback callback) { if (differResult != null) { differResult.dispatchUpdatesTo(callback); } else if (newModels.isEmpty() && !previousModels.isEmpty()) { callback.onRemoved(0, previousModels.size()); } else if (!newModels.isEmpty() && previousModels.isEmpty()) { callback.onInserted(0, newModels.size()); } // Else nothing changed! } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyAdapter.java ================================================ package com.airbnb.epoxy; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import androidx.annotation.Nullable; /** * Allows you to easily combine different view types in the same adapter, and handles view holder * creation, binding, and ids for you. Subclasses just need to add their desired {@link EpoxyModel} * objects and the rest is done automatically. *

* {@link androidx.recyclerview.widget.RecyclerView.Adapter#setHasStableIds(boolean)} is set to true * by default, since {@link EpoxyModel} makes it easy to support unique ids. If you don't want to * support this then disable it in your base class (not recommended). */ @SuppressWarnings("WeakerAccess") public abstract class EpoxyAdapter extends BaseEpoxyAdapter { private final HiddenEpoxyModel hiddenModel = new HiddenEpoxyModel(); /** * Subclasses should modify this list as necessary with the models they want to show. Subclasses * are responsible for notifying data changes whenever this list is changed. */ protected final List> models = new ModelList(); private DiffHelper diffHelper; @Override List> getCurrentModels() { return models; } /** * Enables support for automatically notifying model changes via {@link #notifyModelsChanged()}. * If used, this should be called in the constructor, before any models are changed. * * @see #notifyModelsChanged() */ protected void enableDiffing() { if (diffHelper != null) { throw new IllegalStateException("Diffing was already enabled"); } if (!models.isEmpty()) { throw new IllegalStateException("You must enable diffing before modifying models"); } if (!hasStableIds()) { throw new IllegalStateException("You must have stable ids to use diffing"); } diffHelper = new DiffHelper(this, false); } @Override EpoxyModel getModelForPosition(int position) { EpoxyModel model = models.get(position); return model.isShown() ? model : hiddenModel; } /** * Intelligently notify item changes by comparing the current {@link #models} list against the * previous so you don't have to micromanage notification calls yourself. This may be * prohibitively slow for large model lists (in the hundreds), in which case consider doing * notification calls yourself. If you use this, all your view models must implement {@link * EpoxyModel#hashCode()} and {@link EpoxyModel#equals(Object)} to completely identify their * state, so that changes to a model's content can be detected. Before using this you must enable * it with {@link #enableDiffing()}, since keeping track of the model state adds extra computation * time to all other data change notifications. * * @see #enableDiffing() */ protected void notifyModelsChanged() { if (diffHelper == null) { throw new IllegalStateException("You must enable diffing before notifying models changed"); } diffHelper.notifyModelChanges(); } /** * Notify that the given model has had its data changed. It should only be called if the model * retained the same position. */ protected void notifyModelChanged(EpoxyModel model) { notifyModelChanged(model, null); } /** * Notify that the given model has had its data changed. It should only be called if the model * retained the same position. */ protected void notifyModelChanged(EpoxyModel model, @Nullable Object payload) { int index = getModelPosition(model); if (index != -1) { notifyItemChanged(index, payload); } } /** * Adds the model to the end of the {@link #models} list and notifies that the item was inserted. */ protected void addModel(EpoxyModel modelToAdd) { int initialSize = models.size(); pauseModelListNotifications(); models.add(modelToAdd); resumeModelListNotifications(); notifyItemRangeInserted(initialSize, 1); } /** * Adds the models to the end of the {@link #models} list and notifies that the items were * inserted. */ protected void addModels(EpoxyModel... modelsToAdd) { int initialSize = models.size(); int numModelsToAdd = modelsToAdd.length; ((ModelList) models).ensureCapacity(initialSize + numModelsToAdd); pauseModelListNotifications(); Collections.addAll(models, modelsToAdd); resumeModelListNotifications(); notifyItemRangeInserted(initialSize, numModelsToAdd); } /** * Adds the models to the end of the {@link #models} list and notifies that the items were * inserted. */ protected void addModels(Collection> modelsToAdd) { int initialSize = models.size(); pauseModelListNotifications(); models.addAll(modelsToAdd); resumeModelListNotifications(); notifyItemRangeInserted(initialSize, modelsToAdd.size()); } /** * Inserts the given model before the other in the {@link #models} list, and notifies that the * item was inserted. */ protected void insertModelBefore(EpoxyModel modelToInsert, EpoxyModel modelToInsertBefore) { int targetIndex = getModelPosition(modelToInsertBefore); if (targetIndex == -1) { throw new IllegalStateException("Model is not added: " + modelToInsertBefore); } pauseModelListNotifications(); models.add(targetIndex, modelToInsert); resumeModelListNotifications(); notifyItemInserted(targetIndex); } /** * Inserts the given model after the other in the {@link #models} list, and notifies that the item * was inserted. */ protected void insertModelAfter(EpoxyModel modelToInsert, EpoxyModel modelToInsertAfter) { int modelIndex = getModelPosition(modelToInsertAfter); if (modelIndex == -1) { throw new IllegalStateException("Model is not added: " + modelToInsertAfter); } int targetIndex = modelIndex + 1; pauseModelListNotifications(); models.add(targetIndex, modelToInsert); resumeModelListNotifications(); notifyItemInserted(targetIndex); } /** * If the given model exists it is removed and an item removal is notified. Otherwise this does * nothing. */ protected void removeModel(EpoxyModel model) { int index = getModelPosition(model); if (index != -1) { pauseModelListNotifications(); models.remove(index); resumeModelListNotifications(); notifyItemRemoved(index); } } /** * Removes all models */ protected void removeAllModels() { int numModelsRemoved = models.size(); pauseModelListNotifications(); models.clear(); resumeModelListNotifications(); notifyItemRangeRemoved(0, numModelsRemoved); } /** * Removes all models after the given model, which must have already been added. An example use * case is you want to keep a header but clear everything else, like in the case of refreshing * data. */ protected void removeAllAfterModel(EpoxyModel model) { List> modelsToRemove = getAllModelsAfter(model); int numModelsRemoved = modelsToRemove.size(); int initialModelCount = models.size(); // This is a sublist, so clearing it will clear the models in the original list pauseModelListNotifications(); modelsToRemove.clear(); resumeModelListNotifications(); notifyItemRangeRemoved(initialModelCount - numModelsRemoved, numModelsRemoved); } /** * Sets the visibility of the given model, and notifies that the item changed if the new * visibility is different from the previous. * * @param model The model to show. It should already be added to the {@link #models} list. * @param show True to show the model, false to hide it. */ protected void showModel(EpoxyModel model, boolean show) { if (model.isShown() == show) { return; } model.show(show); notifyModelChanged(model); } /** * Shows the given model, and notifies that the item changed if the item wasn't already shown. * * @param model The model to show. It should already be added to the {@link #models} list. */ protected void showModel(EpoxyModel model) { showModel(model, true); } /** * Shows the given models, and notifies that each item changed if the item wasn't already shown. * * @param models The models to show. They should already be added to the {@link #models} list. */ protected void showModels(EpoxyModel... models) { showModels(Arrays.asList(models)); } /** * Sets the visibility of the given models, and notifies that the items changed if the new * visibility is different from the previous. * * @param models The models to show. They should already be added to the {@link #models} list. * @param show True to show the models, false to hide them. */ protected void showModels(boolean show, EpoxyModel... models) { showModels(Arrays.asList(models), show); } /** * Shows the given models, and notifies that each item changed if the item wasn't already shown. * * @param models The models to show. They should already be added to the {@link #models} list. */ protected void showModels(Iterable> models) { showModels(models, true); } /** * Sets the visibility of the given models, and notifies that the items changed if the new * visibility is different from the previous. * * @param models The models to show. They should already be added to the {@link #models} list. * @param show True to show the models, false to hide them. */ protected void showModels(Iterable> models, boolean show) { for (EpoxyModel model : models) { showModel(model, show); } } /** * Hides the given model, and notifies that the item changed if the item wasn't already hidden. * * @param model The model to hide. This should already be added to the {@link #models} list. */ protected void hideModel(EpoxyModel model) { showModel(model, false); } /** * Hides the given models, and notifies that each item changed if the item wasn't already hidden. * * @param models The models to hide. They should already be added to the {@link #models} list. */ protected void hideModels(Iterable> models) { showModels(models, false); } /** * Hides the given models, and notifies that each item changed if the item wasn't already hidden. * * @param models The models to hide. They should already be added to the {@link #models} list. */ protected void hideModels(EpoxyModel... models) { hideModels(Arrays.asList(models)); } /** * Hides all models currently located after the given model in the {@link #models} list. * * @param model The model after which to hide. It must exist in the {@link #models} list. */ protected void hideAllAfterModel(EpoxyModel model) { hideModels(getAllModelsAfter(model)); } /** * Returns a sub list of all items in {@link #models} that occur after the given model. This list * is backed by the original models list, any changes to the returned list will be reflected in * the original {@link #models} list. * * @param model Must exist in {@link #models}. */ protected List> getAllModelsAfter(EpoxyModel model) { int index = getModelPosition(model); if (index == -1) { throw new IllegalStateException("Model is not added: " + model); } return models.subList(index + 1, models.size()); } /** * We pause the list's notifications when we modify models internally, since we already do the * proper adapter notifications for those modifications. By pausing these list notifications we * prevent the differ having to do work to track them. */ private void pauseModelListNotifications() { ((ModelList) models).pauseNotifications(); } private void resumeModelListNotifications() { ((ModelList) models).resumeNotifications(); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyAsyncUtil.java ================================================ package com.airbnb.epoxy; import android.os.Build; import android.os.Handler; import android.os.Handler.Callback; import android.os.HandlerThread; import android.os.Looper; import android.os.Message; import androidx.annotation.MainThread; /** * Various helpers for running Epoxy operations off the main thread. */ public final class EpoxyAsyncUtil { private EpoxyAsyncUtil() { } /** * A Handler class that uses the main thread's Looper. */ public static final Handler MAIN_THREAD_HANDLER = createHandler(Looper.getMainLooper(), false); /** * A Handler class that uses the main thread's Looper. Additionally, this handler calls * {@link Message#setAsynchronous(boolean)} for * each {@link Message} that is sent to it or {@link Runnable} that is posted to it */ public static final Handler AYSNC_MAIN_THREAD_HANDLER = createHandler(Looper.getMainLooper(), true); private static Handler asyncBackgroundHandler; /** * A Handler class that uses a separate background thread dedicated to Epoxy. Additionally, * this handler calls {@link Message#setAsynchronous(boolean)} for * each {@link Message} that is sent to it or {@link Runnable} that is posted to it */ @MainThread public static Handler getAsyncBackgroundHandler() { // This is initialized lazily so we don't create the thread unless it will be used. // It isn't synchronized so it should only be accessed on the main thread. if (asyncBackgroundHandler == null) { asyncBackgroundHandler = createHandler(buildBackgroundLooper("epoxy"), true); } return asyncBackgroundHandler; } /** * Create a Handler with the given Looper * * @param async If true the Handler will calls {@link Message#setAsynchronous(boolean)} for * each {@link Message} that is sent to it or {@link Runnable} that is posted to it. */ public static Handler createHandler(Looper looper, boolean async) { if (!async) { return new Handler(looper); } // Standard way of exposing async handler on older api's from the support library // https://android.googlesource.com/platform/frameworks/support/+/androidx-master-dev/core // /src/main/java/androidx/core/os/HandlerCompat.java#51 if (Build.VERSION.SDK_INT >= 28) { return Handler.createAsync(looper); } if (Build.VERSION.SDK_INT >= 16) { try { //noinspection JavaReflectionMemberAccess return Handler.class.getDeclaredConstructor(Looper.class, Callback.class, boolean.class) .newInstance(looper, null, true); } catch (Throwable ignored) { } } return new Handler(looper); } /** * Create a new looper that runs on a new background thread. */ public static Looper buildBackgroundLooper(String threadName) { HandlerThread handlerThread = new HandlerThread(threadName); handlerThread.start(); return handlerThread.getLooper(); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyController.java ================================================ package com.airbnb.epoxy; import android.os.Bundle; import android.os.Handler; import android.view.View; import com.airbnb.epoxy.stickyheader.StickyHeaderCallbacks; import org.jetbrains.annotations.NotNull; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.ListIterator; import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup; import static com.airbnb.epoxy.ControllerHelperLookup.getHelperForController; /** * A controller for easily combining {@link EpoxyModel} instances in a {@link RecyclerView.Adapter}. * Simply implement {@link #buildModels()} to declare which models should be used, and in which * order. Call {@link #requestModelBuild()} whenever your data changes, and the controller will call * {@link #buildModels()}, update the adapter with the new models, and notify any changes between * the new and old models. *

* The controller maintains a {@link androidx.recyclerview.widget.RecyclerView.Adapter} with the * latest models, which you can get via {@link #getAdapter()} to set on your RecyclerView. *

* All data change notifications are applied automatically via Epoxy's diffing algorithm. All of * your models must have a unique id set on them for diffing to work. You may choose to use {@link * AutoModel} annotations to have the controller create models with unique ids for you * automatically. *

* Once a model is created and added to the controller in {@link #buildModels()} it should be * treated as immutable and never modified again. This is necessary for adapter updates to be * accurate. */ public abstract class EpoxyController implements ModelCollector, StickyHeaderCallbacks { /** * We check that the adapter is not connected to multiple recyclerviews, but when a fragment has * its view quickly destroyed and recreated it may temporarily attach the same adapter to the * previous view and the new view (eg because of fragment transitions) if the controller is reused * across views. We want to allow this case since it is a brief transient state. This should be * enough time for screen transitions to happen. */ private static final int DELAY_TO_CHECK_ADAPTER_COUNT_MS = 3000; private static final Timer NO_OP_TIMER = new NoOpTimer(); public static Handler defaultModelBuildingHandler = MainThreadExecutor.INSTANCE.handler; public static Handler defaultDiffingHandler = MainThreadExecutor.INSTANCE.handler; private static boolean filterDuplicatesDefault = false; private static boolean globalDebugLoggingEnabled = false; private final EpoxyControllerAdapter adapter; private EpoxyDiffLogger debugObserver; private int recyclerViewAttachCount = 0; private final Handler modelBuildHandler; /** * This is iterated over in the build models thread, but items can be inserted or removed from * other threads at any time. */ private final List interceptors = new CopyOnWriteArrayList<>(); // Volatile because -> write only on main thread, read from builder thread private volatile boolean filterDuplicates = filterDuplicatesDefault; /** * This is used to track whether we are currently building models. If it is non null it means * a thread is in the building models method. We store the thread so we can know which one * is building models. *

* Volatile because -> write only on handler, read from any thread */ private volatile Thread threadBuildingModels = null; /** * Used to know that we should build models synchronously the first time. *

* Volatile because -> written from the build models thread, read from the main thread. */ private volatile boolean hasBuiltModelsEver; ////////////////////////////////////////////////////////////////////////////////////////// /* * These fields are expected to only be used on the model building thread so they are not * synchronized. */ /** Used to time operations and log their duration when in debug mode. */ private Timer timer = NO_OP_TIMER; private final ControllerHelper helper = getHelperForController(this); private ControllerModelList modelsBeingBuilt; private List modelInterceptorCallbacks; private EpoxyModel stagedModel; ////////////////////////////////////////////////////////////////////////////////////////// public EpoxyController() { this(defaultModelBuildingHandler, defaultDiffingHandler); } public EpoxyController(Handler modelBuildingHandler, Handler diffingHandler) { adapter = new EpoxyControllerAdapter(this, diffingHandler); modelBuildHandler = modelBuildingHandler; setDebugLoggingEnabled(globalDebugLoggingEnabled); } /** * Posting and canceling runnables is a bit expensive - it is synchronizes and iterates the * list of runnables. We want clients to be able to request model builds as often as they want and * have it act as a no-op if one is already requested, without being a performance hit. To do that * we track whether we have a call to build models posted already so we can avoid canceling a * current call and posting it again. */ @RequestedModelBuildType private volatile int requestedModelBuildType = RequestedModelBuildType.NONE; @Retention(RetentionPolicy.SOURCE) @IntDef({RequestedModelBuildType.NONE, RequestedModelBuildType.NEXT_FRAME, RequestedModelBuildType.DELAYED}) private @interface RequestedModelBuildType { int NONE = 0; /** A request has been made to build models immediately. It is posted. */ int NEXT_FRAME = 1; /** A request has been made to build models after a delay. It is post delayed. */ int DELAYED = 2; } /** * Call this to request a model update. The controller will schedule a call to {@link * #buildModels()} so that models can be rebuilt for the current data. Once a build is requested * all subsequent requests are ignored until the model build runs. Therefore, the calling code * need not worry about calling this multiple times in a row. *

* The exception is that the first time this is called on a new instance of {@link * EpoxyController} it is run synchronously. This allows state to be restored and the initial view * to be draw quicker. *

* If you would like to be alerted when models have finished building use * {@link #addModelBuildListener(OnModelBuildFinishedListener)} */ public void requestModelBuild() { if (isBuildingModels()) { throw new IllegalEpoxyUsage("Cannot call `requestModelBuild` from inside `buildModels`"); } // If it is the first time building models then we do it right away, otherwise we post the call. // We want to do it right away the first time so that scroll position can be restored correctly, // shared element transitions aren't delayed, and content is shown asap. We post later calls // so that they are debounced, and so any updates to data can be completely finished before // the models are built. if (hasBuiltModelsEver) { requestDelayedModelBuild(0); } else { buildModelsRunnable.run(); } } /** * Whether an update to models is currently pending. This can either be because * {@link #requestModelBuild()} was called, or because models are currently being built or diff * on a background thread. */ public boolean hasPendingModelBuild() { return requestedModelBuildType != RequestedModelBuildType.NONE // model build is posted || threadBuildingModels != null // model build is in progress || adapter.isDiffInProgress(); // Diff in progress } /** * Add a listener that will be called every time {@link #buildModels()} has finished running * and changes have been dispatched to the RecyclerView. *

* Since buildModels can be called once for many calls to {@link #requestModelBuild()}, this is * called just once for each buildModels execution, not for every request. *

* Use this to react to changes in your models that need to happen after the RecyclerView has * been notified, such as scrolling. */ public void addModelBuildListener(OnModelBuildFinishedListener listener) { adapter.addModelBuildListener(listener); } /** * Remove a listener added with {@link #addModelBuildListener(OnModelBuildFinishedListener)}. * This is safe to call from inside the callback * {@link OnModelBuildFinishedListener#onModelBuildFinished(DiffResult)} */ public void removeModelBuildListener(OnModelBuildFinishedListener listener) { adapter.removeModelBuildListener(listener); } /** * Call this to request a delayed model update. The controller will schedule a call to {@link * #buildModels()} so that models can be rebuilt for the current data. *

* Using this to delay a model update may be helpful in cases where user input is causing many * rapid changes in the models, such as typing. In that case, the view is already updated on * screen and constantly rebuilding models is potentially slow and unnecessary. The downside to * delaying the model build too long is that models will not be in sync with the data or view, and * scrolling the view offscreen and back onscreen will cause the model to bind old data. *

* If a previous request is still pending it will be removed in favor of this new delay *

* Any call to {@link #requestModelBuild()} will override a delayed request. *

* In most cases you should use {@link #requestModelBuild()} instead of this. * * @param delayMs The time in milliseconds to delay the model build by. Should be greater than or * equal to 0. A value of 0 is equivalent to calling {@link #requestModelBuild()} */ public synchronized void requestDelayedModelBuild(int delayMs) { if (isBuildingModels()) { throw new IllegalEpoxyUsage( "Cannot call `requestDelayedModelBuild` from inside `buildModels`"); } if (requestedModelBuildType == RequestedModelBuildType.DELAYED) { cancelPendingModelBuild(); } else if (requestedModelBuildType == RequestedModelBuildType.NEXT_FRAME) { return; } requestedModelBuildType = delayMs == 0 ? RequestedModelBuildType.NEXT_FRAME : RequestedModelBuildType.DELAYED; modelBuildHandler.postDelayed(buildModelsRunnable, delayMs); } /** * Cancels a pending call to {@link #buildModels()} if one has been queued by {@link * #requestModelBuild()}. */ public synchronized void cancelPendingModelBuild() { // Access to requestedModelBuildType is synchronized because the model building thread clears // it when model building starts, and the main thread needs to set it to indicate a build // request. // Additionally, it is crucial to guarantee that the state of requestedModelBuildType is in sync // with the modelBuildHandler, otherwise we could end up in a state where we think a model build // is queued, but it isn't, and model building never happens - stuck forever. if (requestedModelBuildType != RequestedModelBuildType.NONE) { requestedModelBuildType = RequestedModelBuildType.NONE; modelBuildHandler.removeCallbacks(buildModelsRunnable); } } private final Runnable buildModelsRunnable = new Runnable() { @Override public void run() { // Do this first to mark the controller as being in the model building process. threadBuildingModels = Thread.currentThread(); // This is needed to reset the requestedModelBuildType back to NONE. // As soon as we do this another model build can be posted. cancelPendingModelBuild(); helper.resetAutoModels(); modelsBeingBuilt = new ControllerModelList(getExpectedModelCount()); timer.start("Models built"); // The user's implementation of buildModels is wrapped in a try/catch so that if it fails // we can reset the state of this controller. This is useful when model building is done // on a dedicated thread, which may have its own error handler, and a failure may not // crash the app - in which case this controller would be in an invalid state and crash later // with confusing errors because "threadBuildingModels" and other properties are not // correctly set. This can happen particularly with Espresso testing. try { buildModels(); } catch (Throwable throwable) { timer.stop(); modelsBeingBuilt = null; hasBuiltModelsEver = true; threadBuildingModels = null; stagedModel = null; throw throwable; } addCurrentlyStagedModelIfExists(); timer.stop(); runInterceptors(); filterDuplicatesIfNeeded(modelsBeingBuilt); modelsBeingBuilt.freeze(); timer.start("Models diffed"); adapter.setModels(modelsBeingBuilt); // This timing is only right if diffing and model building are on the same thread timer.stop(); modelsBeingBuilt = null; hasBuiltModelsEver = true; threadBuildingModels = null; } }; /** An estimate for how many models will be built in the next {@link #buildModels()} phase. */ private int getExpectedModelCount() { int currentModelCount = adapter.getItemCount(); return currentModelCount != 0 ? currentModelCount : 25; } /** * Subclasses should implement this to describe what models should be shown for the current state. * Implementations should call either {@link #add(EpoxyModel)}, {@link * EpoxyModel#addTo(EpoxyController)}, or {@link EpoxyModel#addIf(boolean, EpoxyController)} with * the models that should be shown, in the order that is desired. *

* Once a model is added to the controller it should be treated as immutable and never modified * again. This is necessary for adapter updates to be accurate. If "validateEpoxyModelUsage" is * enabled then runtime validations will be done to make sure models are not changed. *

* You CANNOT call this method directly. Instead, call {@link #requestModelBuild()} to have the * controller schedule an update. */ protected abstract void buildModels(); int getFirstIndexOfModelInBuildingList(EpoxyModel model) { assertIsBuildingModels(); int size = modelsBeingBuilt.size(); for (int i = 0; i < size; i++) { if (modelsBeingBuilt.get(i) == model) { return i; } } return -1; } boolean isModelAddedMultipleTimes(EpoxyModel model) { assertIsBuildingModels(); int modelCount = 0; int size = modelsBeingBuilt.size(); for (int i = 0; i < size; i++) { if (modelsBeingBuilt.get(i) == model) { modelCount++; } } return modelCount > 1; } void addAfterInterceptorCallback(ModelInterceptorCallback callback) { assertIsBuildingModels(); if (modelInterceptorCallbacks == null) { modelInterceptorCallbacks = new ArrayList<>(); } modelInterceptorCallbacks.add(callback); } /** * Callbacks to each model for when interceptors are started and stopped, so the models know when * to allow changes. */ interface ModelInterceptorCallback { void onInterceptorsStarted(EpoxyController controller); void onInterceptorsFinished(EpoxyController controller); } private void runInterceptors() { if (!interceptors.isEmpty()) { if (modelInterceptorCallbacks != null) { for (ModelInterceptorCallback callback : modelInterceptorCallbacks) { callback.onInterceptorsStarted(this); } } timer.start("Interceptors executed"); for (Interceptor interceptor : interceptors) { interceptor.intercept(modelsBeingBuilt); } timer.stop(); if (modelInterceptorCallbacks != null) { for (ModelInterceptorCallback callback : modelInterceptorCallbacks) { callback.onInterceptorsFinished(this); } } } // Interceptors are cleared so that future model builds don't notify past models. // We need to make sure they are cleared even if there are no interceptors so that // we don't leak the models. modelInterceptorCallbacks = null; } /** A callback that is run after {@link #buildModels()} completes and before diffing is run. */ public interface Interceptor { /** * This is called immediately after {@link #buildModels()} and before diffing is run and the * models are set on the adapter. This is a final chance to make any changes to the the models * added in {@link #buildModels()}. This may be useful for actions that act on all models in * aggregate, such as toggling divider settings, or for cases such as rearranging models for an * experiment. *

* The models list must not be changed after this method returns. Doing so will throw an * exception. */ void intercept(@NonNull List> models); } /** * Add an interceptor callback to be run after models are built, to make any last changes before * they are set on the adapter. Interceptors are run in the order they are added. *

* Interceptors are run on the same thread that models are built on. * * @see Interceptor#intercept(List) */ public void addInterceptor(@NonNull Interceptor interceptor) { interceptors.add(interceptor); } /** Remove an interceptor that was added with {@link #addInterceptor(Interceptor)}. */ public void removeInterceptor(@NonNull Interceptor interceptor) { interceptors.remove(interceptor); } /** * Get the number of models added so far during the {@link #buildModels()} phase. It is only valid * to call this from within that method. *

* This is different from the number of models currently on the adapter, since models on the * adapter are not updated until after models are finished being built. To access current adapter * count call {@link #getAdapter()} and {@link EpoxyControllerAdapter#getItemCount()} */ protected int getModelCountBuiltSoFar() { assertIsBuildingModels(); return modelsBeingBuilt.size(); } private void assertIsBuildingModels() { if (!isBuildingModels()) { throw new IllegalEpoxyUsage("Can only call this when inside the `buildModels` method"); } } private void assertNotBuildingModels() { if (isBuildingModels()) { throw new IllegalEpoxyUsage("Cannot call this from inside `buildModels`"); } } /** * Add the model to this controller. Can only be called from inside {@link * EpoxyController#buildModels()}. */ public void add(@NonNull EpoxyModel model) { model.addTo(this); } /** * Add the models to this controller. Can only be called from inside {@link * EpoxyController#buildModels()}. */ protected void add(@NonNull EpoxyModel... modelsToAdd) { modelsBeingBuilt.ensureCapacity(modelsBeingBuilt.size() + modelsToAdd.length); for (EpoxyModel model : modelsToAdd) { add(model); } } /** * Add the models to this controller. Can only be called from inside {@link * EpoxyController#buildModels()}. */ protected void add(@NonNull List> modelsToAdd) { modelsBeingBuilt.ensureCapacity(modelsBeingBuilt.size() + modelsToAdd.size()); for (EpoxyModel model : modelsToAdd) { add(model); } } /** * Method to actually add the model to the list being built. Should be called after all * validations are done. */ void addInternal(EpoxyModel modelToAdd) { assertIsBuildingModels(); if (modelToAdd.hasDefaultId()) { throw new IllegalEpoxyUsage( "You must set an id on a model before adding it. Use the @AutoModel annotation if you " + "want an id to be automatically generated for you."); } if (!modelToAdd.isShown()) { throw new IllegalEpoxyUsage( "You cannot hide a model in an EpoxyController. Use `addIf` to conditionally add a " + "model instead."); } // The model being added may not have been staged if it wasn't mutated before it was added. // In that case we may have a previously staged model that still needs to be added. clearModelFromStaging(modelToAdd); modelToAdd.controllerToStageTo = null; modelsBeingBuilt.add(modelToAdd); } /** * Staging models allows them to be implicitly added after the user finishes modifying them. This * means that if a user has modified a model, and then moves on to modifying a different model, * the first model is automatically added as soon as the second model is modified. *

* There are some edge cases for handling models that are added without modification, or models * that are modified but then fail an `addIf` check. *

* This only works for AutoModels, and only if implicitly adding is enabled in configuration. */ void setStagedModel(EpoxyModel model) { if (model != stagedModel) { addCurrentlyStagedModelIfExists(); } stagedModel = model; } void addCurrentlyStagedModelIfExists() { if (stagedModel != null) { stagedModel.addTo(this); } stagedModel = null; } void clearModelFromStaging(EpoxyModel model) { if (stagedModel != model) { addCurrentlyStagedModelIfExists(); } stagedModel = null; } /** True if the current callstack originated from the buildModels call, on the same thread. */ protected boolean isBuildingModels() { return threadBuildingModels == Thread.currentThread(); } private void filterDuplicatesIfNeeded(List> models) { if (!filterDuplicates) { return; } timer.start("Duplicates filtered"); Set modelIds = new HashSet<>(models.size()); ListIterator> modelIterator = models.listIterator(); while (modelIterator.hasNext()) { EpoxyModel model = modelIterator.next(); if (!modelIds.add(model.id())) { int indexOfDuplicate = modelIterator.previousIndex(); modelIterator.remove(); int indexOfOriginal = findPositionOfDuplicate(models, model); EpoxyModel originalModel = models.get(indexOfOriginal); if (indexOfDuplicate <= indexOfOriginal) { // Adjust for the original positions of the models before the duplicate was removed indexOfOriginal++; } onExceptionSwallowed( new IllegalEpoxyUsage("Two models have the same ID. ID's must be unique!" + "\nOriginal has position " + indexOfOriginal + ":\n" + originalModel + "\nDuplicate has position " + indexOfDuplicate + ":\n" + model) ); } } timer.stop(); } private int findPositionOfDuplicate(List> models, EpoxyModel duplicateModel) { int size = models.size(); for (int i = 0; i < size; i++) { EpoxyModel model = models.get(i); if (model.id() == duplicateModel.id()) { return i; } } throw new IllegalArgumentException("No duplicates in list"); } /** * If set to true, Epoxy will search for models with duplicate ids added during {@link * #buildModels()} and remove any duplicates found. If models with the same id are found, the * first one is left in the adapter and any subsequent models are removed. {@link * #onExceptionSwallowed(RuntimeException)} will be called for each duplicate removed. *

* This may be useful if your models are created via server supplied data, in which case the * server may erroneously send duplicate items. Duplicate items are otherwise left in and can * result in undefined behavior. */ public void setFilterDuplicates(boolean filterDuplicates) { this.filterDuplicates = filterDuplicates; } public boolean isDuplicateFilteringEnabled() { return filterDuplicates; } /** * {@link #setFilterDuplicates(boolean)} is disabled in each EpoxyController by default. It can be * toggled individually in each controller, or alternatively you can use this to change the * default value for all EpoxyControllers. */ public static void setGlobalDuplicateFilteringDefault(boolean filterDuplicatesByDefault) { EpoxyController.filterDuplicatesDefault = filterDuplicatesByDefault; } /** * If enabled, DEBUG logcat messages will be printed to show when models are rebuilt, the time * taken to build them, the time taken to diff them, and the item change outcomes from the * differ. The tag of the logcat message is the class name of your EpoxyController. *

* This is useful to verify that models are being diffed as expected, as well as to watch for * slowdowns in model building or diffing to indicate when you should optimize model building or * model hashCode/equals implementations (which can often slow down diffing). *

* This should only be used in debug builds to avoid a performance hit in prod. */ public void setDebugLoggingEnabled(boolean enabled) { assertNotBuildingModels(); if (enabled) { timer = new DebugTimer(getClass().getSimpleName()); if (debugObserver == null) { debugObserver = new EpoxyDiffLogger(getClass().getSimpleName()); } adapter.registerAdapterDataObserver(debugObserver); } else { timer = NO_OP_TIMER; if (debugObserver != null) { adapter.unregisterAdapterDataObserver(debugObserver); } } } public boolean isDebugLoggingEnabled() { return timer != NO_OP_TIMER; } /** * Similar to {@link #setDebugLoggingEnabled(boolean)}, but this changes the global default for * all EpoxyControllers. *

* The default is false. */ public static void setGlobalDebugLoggingEnabled(boolean globalDebugLoggingEnabled) { EpoxyController.globalDebugLoggingEnabled = globalDebugLoggingEnabled; } /** * An optimized way to move a model from one position to another without rebuilding all models. * This is intended to be used with {@link androidx.recyclerview.widget.ItemTouchHelper} to * allow for efficient item dragging and rearranging. It cannot be *

* If you call this you MUST also update the data backing your models as necessary. *

* This will immediately change the model's position and notify the change to the RecyclerView. * However, a delayed request to rebuild models will be scheduled for the future to guarantee that * models are in sync with data. * * @param fromPosition Previous position of the item. * @param toPosition New position of the item. */ public void moveModel(int fromPosition, int toPosition) { assertNotBuildingModels(); adapter.moveModel(fromPosition, toPosition); requestDelayedModelBuild(500); } /** * An way to notify the adapter that a model has changed. This is intended to be used with * {@link androidx.recyclerview.widget.ItemTouchHelper} to allow revert swiping a model. *

* This will immediately notify the change to the RecyclerView. * * @param position Position of the item. */ public void notifyModelChanged(int position) { assertNotBuildingModels(); adapter.notifyModelChanged(position); } /** * Get the underlying adapter built by this controller. Use this to get the adapter to set on a * RecyclerView, or to get information about models currently in use. */ @NonNull public EpoxyControllerAdapter getAdapter() { return adapter; } public void onSaveInstanceState(@NonNull Bundle outState) { adapter.onSaveInstanceState(outState); } public void onRestoreInstanceState(@Nullable Bundle inState) { adapter.onRestoreInstanceState(inState); } /** * For use with a grid layout manager - use this to get the {@link SpanSizeLookup} for models in * this controller. This will delegate span look up calls to each model's {@link * EpoxyModel#getSpanSize(int, int, int)}. Make sure to also call {@link #setSpanCount(int)} so * the span count is correct. */ @NonNull public SpanSizeLookup getSpanSizeLookup() { return adapter.getSpanSizeLookup(); } /** * If you are using a grid layout manager you must call this to set the span count of the grid. * This span count will be passed on to the models so models can choose which span count to be. * * @see #getSpanSizeLookup() * @see EpoxyModel#getSpanSize(int, int, int) */ public void setSpanCount(int spanCount) { adapter.setSpanCount(spanCount); } public int getSpanCount() { return adapter.getSpanCount(); } public boolean isMultiSpan() { return adapter.isMultiSpan(); } /** * This is called when recoverable exceptions occur at runtime. By default they are ignored and * Epoxy will recover, but you can override this to be aware of when they happen. *

* A common use for this is being aware of duplicates when {@link #setFilterDuplicates(boolean)} * is enabled. *

* By default the global exception handler provided by * {@link #setGlobalExceptionHandler(ExceptionHandler)} * is called with the exception. Overriding this allows you to provide your own handling for a * controller. */ protected void onExceptionSwallowed(@NonNull RuntimeException exception) { globalExceptionHandler.onException(this, exception); } /** * Default handler for exceptions in all EpoxyControllers. Set with {@link * #setGlobalExceptionHandler(ExceptionHandler)} */ private static ExceptionHandler globalExceptionHandler = new ExceptionHandler() { @Override public void onException(@NonNull EpoxyController controller, @NonNull RuntimeException exception) { // Ignore exceptions as the default } }; /** * Set a callback to be notified when a recoverable exception occurs at runtime. By default these * are ignored and Epoxy will recover, but you can override this to be aware of when they happen. *

* For example, you could choose to rethrow the exception in development builds, or log them in * production. *

* A common use for this is being aware of duplicates when {@link #setFilterDuplicates(boolean)} * is enabled. *

* This callback will be used in all EpoxyController classes. If you would like specific handling * in a certain controller you can override {@link #onExceptionSwallowed(RuntimeException)} in * that controller. */ public static void setGlobalExceptionHandler( @NonNull ExceptionHandler globalExceptionHandler) { EpoxyController.globalExceptionHandler = globalExceptionHandler; } public interface ExceptionHandler { /** * This is called when recoverable exceptions happen at runtime. They can be ignored and Epoxy * will recover, but you can override this to be aware of when they happen. *

* For example, you could choose to rethrow the exception in development builds, or log them in * production. * * @param controller The EpoxyController that the error occurred in. */ void onException(@NonNull EpoxyController controller, @NonNull RuntimeException exception); } void onAttachedToRecyclerViewInternal(RecyclerView recyclerView) { recyclerViewAttachCount++; if (recyclerViewAttachCount > 1) { MainThreadExecutor.INSTANCE.handler.postDelayed(new Runnable() { @Override public void run() { // Only warn if there are still multiple adapters attached after a delay, to allow for // a grace period if (recyclerViewAttachCount > 1) { onExceptionSwallowed(new IllegalStateException( "This EpoxyController had its adapter added to more than one ReyclerView. Epoxy " + "does not support attaching an adapter to multiple RecyclerViews because " + "saved state will not work properly. If you did not intend to attach your " + "adapter " + "to multiple RecyclerViews you may be leaking a " + "reference to a previous RecyclerView. Make sure to remove the adapter from " + "any " + "previous RecyclerViews (eg if the adapter is reused in a Fragment across " + "multiple onCreateView/onDestroyView cycles). See https://github" + ".com/airbnb/epoxy/wiki/Avoiding-Memory-Leaks for more information.")); } } }, DELAY_TO_CHECK_ADAPTER_COUNT_MS); } onAttachedToRecyclerView(recyclerView); } void onDetachedFromRecyclerViewInternal(RecyclerView recyclerView) { recyclerViewAttachCount--; onDetachedFromRecyclerView(recyclerView); } /** Called when the controller's adapter is attach to a recyclerview. */ protected void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { } /** Called when the controller's adapter is detached from a recyclerview. */ protected void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { } /** * Called immediately after a model is bound to a view holder. Subclasses can override this if * they want alerts on when a model is bound. Alternatively you may attach a listener directly to * a generated model with model.onBind(...) * * @param previouslyBoundModel If non null, this is a model with the same id as the newly bound * model, and was previously bound to a view. This means that {@link * #buildModels()} returned a model that is different from the * previouslyBoundModel and the view is being rebound to incorporate * the change. You can compare this previous model with the new one to * see exactly what changed. *

* The newly bound model and the previously bound model are guaranteed * to have the same id, but will not necessarily be of the same type * depending on your implementation of {@link #buildModels()}. With * common usage patterns of Epoxy they should be the same type, and * will only differ if you are using different model classes with the * same id. *

* Comparing the newly bound model with the previous model allows you * to be more intelligent when updating your view. This may help you * optimize, or make it easier to work with animations. *

* If the new model and the previous model have the same view type * (given by {@link EpoxyModel#getViewType()}), and if you are using * the default ReyclerView item animator, the same view will be kept. * If you are using a custom item animator then the view will be the * same if the animator returns true in canReuseUpdatedViewHolder. *

* This previously bound model is taken as a payload from the diffing * process, and follows the same general conditions for all * recyclerview change payloads. */ protected void onModelBound(@NonNull EpoxyViewHolder holder, @NonNull EpoxyModel boundModel, int position, @Nullable EpoxyModel previouslyBoundModel) { } /** * Called immediately after a model is unbound from a view holder. Subclasses can override this if * they want alerts on when a model is unbound. Alternatively you may attach a listener directly * to a generated model with model.onUnbind(...) */ protected void onModelUnbound(@NonNull EpoxyViewHolder holder, @NonNull EpoxyModel model) { } /** * Called when the given viewholder is attached to the window, along with the model it is bound * to. * * @see BaseEpoxyAdapter#onViewAttachedToWindow(EpoxyViewHolder) */ protected void onViewAttachedToWindow(@NonNull EpoxyViewHolder holder, @NonNull EpoxyModel model) { } /** * Called when the given viewholder is detechaed from the window, along with the model it is bound * to. * * @see BaseEpoxyAdapter#onViewDetachedFromWindow(EpoxyViewHolder) */ protected void onViewDetachedFromWindow(@NonNull EpoxyViewHolder holder, @NonNull EpoxyModel model) { } //region Sticky header /** * Optional callback to setup the sticky view, * by default it doesn't do anything. * * The sub-classes should override the function if they are * using sticky header feature. */ @Override public void setupStickyHeaderView(@NotNull View stickyHeader) { // no-op } /** * Optional callback to perform tear down operation on the * sticky view, by default it doesn't do anything. * * The sub-classes should override the function if they are * using sticky header feature. */ @Override public void teardownStickyHeaderView(@NotNull View stickyHeader) { // no-op } /** * Called to check if the item at the position is a sticky item, * by default returns false. * * The sub-classes should override the function if they are * using sticky header feature. */ @Override public boolean isStickyHeader(int position) { return false; } //endregion } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyControllerAdapter.java ================================================ package com.airbnb.epoxy; import android.os.Handler; import android.view.View; import com.airbnb.epoxy.AsyncEpoxyDiffer.ResultCallback; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; import androidx.recyclerview.widget.DiffUtil.ItemCallback; import androidx.recyclerview.widget.RecyclerView; public final class EpoxyControllerAdapter extends BaseEpoxyAdapter implements ResultCallback { private final NotifyBlocker notifyBlocker = new NotifyBlocker(); private final AsyncEpoxyDiffer differ; private final EpoxyController epoxyController; private int itemCount; private final List modelBuildListeners = new ArrayList<>(); EpoxyControllerAdapter(@NonNull EpoxyController epoxyController, Handler diffingHandler) { this.epoxyController = epoxyController; differ = new AsyncEpoxyDiffer( diffingHandler, this, ITEM_CALLBACK ); registerAdapterDataObserver(notifyBlocker); } @Override protected void onExceptionSwallowed(@NonNull RuntimeException exception) { epoxyController.onExceptionSwallowed(exception); } @NonNull @Override List> getCurrentModels() { return differ.getCurrentList(); } @Override public int getItemCount() { // RecyclerView calls this A LOT. The base class implementation does // getCurrentModels().size() which adds some overhead because of the method calls. // We can easily memoize this, which seems to help when there are lots of models. return itemCount; } /** This is set from whatever thread model building happened on, so must be thread safe. */ void setModels(@NonNull ControllerModelList models) { // If debug model validations are on then we should help detect the error case where models // were incorrectly mutated once they were added. That check is also done before and after // bind, but there is no other check after that to see if a model is incorrectly // mutated after being bound. // If a data class inside a model is mutated, then when models are rebuilt the differ // will still recognize the old and new models as equal, even though the old model was changed. // To help catch that error case we check for mutations here, before running the differ. // // https://github.com/airbnb/epoxy/issues/805 List> currentModels = getCurrentModels(); if (!currentModels.isEmpty() && currentModels.get(0).isDebugValidationEnabled()) { for (int i = 0; i < currentModels.size(); i++) { EpoxyModel model = currentModels.get(i); model.validateStateHasNotChangedSinceAdded( "The model was changed between being bound and when models were rebuilt", i ); } } differ.submitList(models); } /** * @return True if a diff operation is in progress. */ public boolean isDiffInProgress() { return differ.isDiffInProgress(); } // Called on diff results from the differ @Override public void onResult(@NonNull DiffResult result) { itemCount = result.newModels.size(); notifyBlocker.allowChanges(); result.dispatchTo(this); notifyBlocker.blockChanges(); for (int i = modelBuildListeners.size() - 1; i >= 0; i--) { modelBuildListeners.get(i).onModelBuildFinished(result); } } public void addModelBuildListener(OnModelBuildFinishedListener listener) { modelBuildListeners.add(listener); } public void removeModelBuildListener(OnModelBuildFinishedListener listener) { modelBuildListeners.remove(listener); } @Override boolean diffPayloadsEnabled() { return true; } @Override public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { super.onAttachedToRecyclerView(recyclerView); epoxyController.onAttachedToRecyclerViewInternal(recyclerView); } @Override public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { super.onDetachedFromRecyclerView(recyclerView); epoxyController.onDetachedFromRecyclerViewInternal(recyclerView); } @Override public void onViewAttachedToWindow(@NonNull EpoxyViewHolder holder) { super.onViewAttachedToWindow(holder); epoxyController.onViewAttachedToWindow(holder, holder.getModel()); } @Override public void onViewDetachedFromWindow(@NonNull EpoxyViewHolder holder) { super.onViewDetachedFromWindow(holder); epoxyController.onViewDetachedFromWindow(holder, holder.getModel()); } @Override protected void onModelBound(@NonNull EpoxyViewHolder holder, @NonNull EpoxyModel model, int position, @Nullable EpoxyModel previouslyBoundModel) { epoxyController.onModelBound(holder, model, position, previouslyBoundModel); } @Override protected void onModelUnbound(@NonNull EpoxyViewHolder holder, @NonNull EpoxyModel model) { epoxyController.onModelUnbound(holder, model); } /** Get an unmodifiable copy of the current models set on the adapter. */ @NonNull public List> getCopyOfModels() { //noinspection unchecked return (List>) getCurrentModels(); } /** * @throws IndexOutOfBoundsException If the given position is out of range of the current model * list. */ @NonNull public EpoxyModel getModelAtPosition(int position) { return getCurrentModels().get(position); } /** * Searches the current model list for the model with the given id. Returns the matching model if * one is found, otherwise null is returned. */ @Nullable public EpoxyModel getModelById(long id) { for (EpoxyModel model : getCurrentModels()) { if (model.id() == id) { return model; } } return null; } @Override public int getModelPosition(@NonNull EpoxyModel targetModel) { int size = getCurrentModels().size(); for (int i = 0; i < size; i++) { EpoxyModel model = getCurrentModels().get(i); if (model.id() == targetModel.id()) { return i; } } return -1; } @NonNull @Override public BoundViewHolders getBoundViewHolders() { return super.getBoundViewHolders(); } @UiThread void moveModel(int fromPosition, int toPosition) { ArrayList> updatedList = new ArrayList<>(getCurrentModels()); updatedList.add(toPosition, updatedList.remove(fromPosition)); notifyBlocker.allowChanges(); notifyItemMoved(fromPosition, toPosition); notifyBlocker.blockChanges(); boolean interruptedDiff = differ.forceListOverride(updatedList); if (interruptedDiff) { // The move interrupted a model rebuild/diff that was in progress, // so models may be out of date and we should force them to rebuilt epoxyController.requestModelBuild(); } } @UiThread void notifyModelChanged(int position) { ArrayList> updatedList = new ArrayList<>(getCurrentModels()); notifyBlocker.allowChanges(); notifyItemChanged(position); notifyBlocker.blockChanges(); boolean interruptedDiff = differ.forceListOverride(updatedList); if (interruptedDiff) { // The move interrupted a model rebuild/diff that was in progress, // so models may be out of date and we should force them to rebuilt epoxyController.requestModelBuild(); } } private static final ItemCallback> ITEM_CALLBACK = new ItemCallback>() { @Override public boolean areItemsTheSame(EpoxyModel oldItem, EpoxyModel newItem) { return oldItem.id() == newItem.id(); } @Override public boolean areContentsTheSame(EpoxyModel oldItem, EpoxyModel newItem) { return oldItem.equals(newItem); } @Override public Object getChangePayload(EpoxyModel oldItem, EpoxyModel newItem) { return new DiffPayload(oldItem); } }; /** * Delegates the callbacks received in the adapter * to the controller. */ @Override public boolean isStickyHeader(int position) { return epoxyController.isStickyHeader(position); } /** * Delegates the callbacks received in the adapter * to the controller. */ @Override public void setupStickyHeaderView(@NotNull View stickyHeader) { epoxyController.setupStickyHeaderView(stickyHeader); } /** * Delegates the callbacks received in the adapter * to the controller. */ @Override public void teardownStickyHeaderView(@NotNull View stickyHeader) { epoxyController.teardownStickyHeaderView(stickyHeader); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyDiffLogger.java ================================================ package com.airbnb.epoxy; import android.util.Log; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; /** * This data observer can be registered with an Epoxy adapter or controller to log all item change * events. This may be useful to use in debug builds in order to observe model updates and monitor * for issues. *

* You may want to look for unexpected item updates to catch improper hashCode/equals * implementations in your models. *

* Additionally, you may want to look for frequent or unnecessary updates as an opportunity for * optimization. */ public class EpoxyDiffLogger extends AdapterDataObserver { private final String tag; public EpoxyDiffLogger(String tag) { this.tag = tag; } @Override public void onItemRangeChanged(int positionStart, int itemCount) { Log.d(tag, "Item range changed. Start: " + positionStart + " Count: " + itemCount); } @Override public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) { if (payload == null) { onItemRangeChanged(positionStart, itemCount); } else { Log.d(tag, "Item range changed with payloads. Start: " + positionStart + " Count: " + itemCount); } } @Override public void onItemRangeInserted(int positionStart, int itemCount) { Log.d(tag, "Item range inserted. Start: " + positionStart + " Count: " + itemCount); } @Override public void onItemRangeRemoved(int positionStart, int itemCount) { Log.d(tag, "Item range removed. Start: " + positionStart + " Count: " + itemCount); } @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { Log.d(tag, "Item moved. From: " + fromPosition + " To: " + toPosition); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyDragCallback.java ================================================ package com.airbnb.epoxy; import android.view.View; /** * For use with {@link EpoxyModelTouchCallback} */ public interface EpoxyDragCallback extends BaseEpoxyTouchCallback { /** * Called when the view switches from an idle state to a dragged state, as the user begins a drag * interaction with it. You can use this callback to modify the view to indicate it is being * dragged. *

* This is the first callback in the lifecycle of a drag event. * * @param model The model representing the view that is being dragged * @param itemView The view that is being dragged * @param adapterPosition The adapter position of the model */ void onDragStarted(T model, View itemView, int adapterPosition); /** * Called after {@link #onDragStarted(EpoxyModel, View, int)} when the dragged view is dropped to * a new position. The EpoxyController will be updated automatically for you to reposition the * models and notify the RecyclerView of the change. *

* You MUST use this callback to modify your data backing the models to reflect the change. *

* The next callback in the drag lifecycle will be {@link #onDragStarted(EpoxyModel, View, int)} * * @param modelBeingMoved The model representing the view that was moved * @param itemView The view that was moved * @param fromPosition The adapter position that the model came from * @param toPosition The new adapter position of the model */ void onModelMoved(int fromPosition, int toPosition, T modelBeingMoved, View itemView); /** * Called after {@link #onDragStarted(EpoxyModel, View, int)} when the view being dragged is * released. If the view was dragged to a new, valid location then {@link #onModelMoved(int, int, * EpoxyModel, View)} will be called before this and the view will settle to the new location. * Otherwise the view will animate back to its original position. *

* You can use this callback to modify the view as it animates back into position. *

* {@link BaseEpoxyTouchCallback#clearView(EpoxyModel, View)} will be called after this, when the * view has finished animating. Final cleanup of the view should be done there. * * @param model The model representing the view that is being released * @param itemView The view that was being dragged */ void onDragReleased(T model, View itemView); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyHolder.java ================================================ package com.airbnb.epoxy; import android.view.View; import android.view.ViewParent; import androidx.annotation.NonNull; /** * Used in conjunction with {@link com.airbnb.epoxy.EpoxyModelWithHolder} to provide a view holder * pattern when binding to a model. */ public abstract class EpoxyHolder { public EpoxyHolder(@NonNull ViewParent parent) { this(); } public EpoxyHolder() { } /** * Called when this holder is created, with the view that it should hold. You can use this * opportunity to find views by id, and do any other initialization you need. This is called only * once for the lifetime of the class. * * @param itemView A view inflated from the layout provided by * {@link EpoxyModelWithHolder#getLayout()} */ protected abstract void bindView(@NonNull View itemView); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyItemSpacingDecorator.java ================================================ package com.airbnb.epoxy; import android.graphics.Rect; import android.view.View; import androidx.annotation.Px; import androidx.core.view.ViewCompat; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.LayoutManager; import androidx.recyclerview.widget.RecyclerView.State; /** * Modifies item spacing in a recycler view so that items are equally spaced no matter where they * are on the grid. Only designed to work with standard linear or grid layout managers. */ public class EpoxyItemSpacingDecorator extends RecyclerView.ItemDecoration { private int pxBetweenItems; private boolean verticallyScrolling; private boolean horizontallyScrolling; private boolean firstItem; private boolean lastItem; private boolean grid; private boolean isFirstItemInRow; private boolean fillsLastSpan; private boolean isInFirstRow; private boolean isInLastRow; public EpoxyItemSpacingDecorator() { this(0); } public EpoxyItemSpacingDecorator(@Px int pxBetweenItems) { setPxBetweenItems(pxBetweenItems); } public void setPxBetweenItems(@Px int pxBetweenItems) { this.pxBetweenItems = pxBetweenItems; } @Px public int getPxBetweenItems() { return pxBetweenItems; } @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) { // Zero everything out for the common case outRect.setEmpty(); int position = parent.getChildAdapterPosition(view); if (position == RecyclerView.NO_POSITION) { // View is not shown return; } RecyclerView.LayoutManager layout = parent.getLayoutManager(); calculatePositionDetails(parent, position, layout); boolean left = useLeftPadding(); boolean right = useRightPadding(); boolean top = useTopPadding(); boolean bottom = useBottomPadding(); if (shouldReverseLayout(layout, horizontallyScrolling)) { if (horizontallyScrolling) { boolean temp = left; left = right; right = temp; } else { boolean temp = top; top = bottom; bottom = temp; } } // Divided by two because it is applied to the left side of one item and the right of another // to add up to the total desired space int padding = pxBetweenItems / 2; outRect.right = right ? padding : 0; outRect.left = left ? padding : 0; outRect.top = top ? padding : 0; outRect.bottom = bottom ? padding : 0; } private void calculatePositionDetails(RecyclerView parent, int position, LayoutManager layout) { int itemCount = parent.getAdapter().getItemCount(); firstItem = position == 0; lastItem = position == itemCount - 1; horizontallyScrolling = layout.canScrollHorizontally(); verticallyScrolling = layout.canScrollVertically(); grid = layout instanceof GridLayoutManager; if (grid) { GridLayoutManager grid = (GridLayoutManager) layout; final SpanSizeLookup spanSizeLookup = grid.getSpanSizeLookup(); int spanSize = spanSizeLookup.getSpanSize(position); int spanCount = grid.getSpanCount(); int spanIndex = spanSizeLookup.getSpanIndex(position, spanCount); isFirstItemInRow = spanIndex == 0; fillsLastSpan = spanIndex + spanSize == spanCount; isInFirstRow = isInFirstRow(position, spanSizeLookup, spanCount); isInLastRow = !isInFirstRow && isInLastRow(position, itemCount, spanSizeLookup, spanCount); } } private static boolean shouldReverseLayout(LayoutManager layout, boolean horizontallyScrolling) { boolean reverseLayout = layout instanceof LinearLayoutManager && ((LinearLayoutManager) layout).getReverseLayout(); boolean rtl = layout.getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL; if (horizontallyScrolling && rtl) { // This is how linearlayout checks if it should reverse layout in #resolveShouldLayoutReverse reverseLayout = !reverseLayout; } return reverseLayout; } private boolean useBottomPadding() { if (grid) { return (horizontallyScrolling && !fillsLastSpan) || (verticallyScrolling && !isInLastRow); } return verticallyScrolling && !lastItem; } private boolean useTopPadding() { if (grid) { return (horizontallyScrolling && !isFirstItemInRow) || (verticallyScrolling && !isInFirstRow); } return verticallyScrolling && !firstItem; } private boolean useRightPadding() { if (grid) { return (horizontallyScrolling && !isInLastRow) || (verticallyScrolling && !fillsLastSpan); } return horizontallyScrolling && !lastItem; } private boolean useLeftPadding() { if (grid) { return (horizontallyScrolling && !isInFirstRow) || (verticallyScrolling && !isFirstItemInRow); } return horizontallyScrolling && !firstItem; } private static boolean isInFirstRow(int position, SpanSizeLookup spanSizeLookup, int spanCount) { int totalSpan = 0; for (int i = 0; i <= position; i++) { totalSpan += spanSizeLookup.getSpanSize(i); if (totalSpan > spanCount) { return false; } } return true; } private static boolean isInLastRow(int position, int itemCount, SpanSizeLookup spanSizeLookup, int spanCount) { int totalSpan = 0; for (int i = itemCount - 1; i >= position; i--) { totalSpan += spanSizeLookup.getSpanSize(i); if (totalSpan > spanCount) { return false; } } return true; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyModel.java ================================================ package com.airbnb.epoxy; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.airbnb.epoxy.EpoxyController.ModelInterceptorCallback; import com.airbnb.epoxy.VisibilityState.Visibility; import java.util.List; import androidx.annotation.FloatRange; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import static com.airbnb.epoxy.IdUtils.hashLong64Bit; import static com.airbnb.epoxy.IdUtils.hashString64Bit; /** * Helper to bind data to a view using a builder style. The parameterized type should extend * Android's View or EpoxyHolder. * * @see EpoxyModelWithHolder * @see EpoxyModelWithView */ public abstract class EpoxyModel { /** * Counts how many of these objects are created, so that each new object can have a unique id . * Uses negative values so that these autogenerated ids don't clash with database ids that may be * set with {@link #id(long)} */ private static long idCounter = -1; /** * An id that can be used to uniquely identify this {@link EpoxyModel} for use in RecyclerView * stable ids. It defaults to a unique id for this object instance, if you want to maintain the * same id across instances use {@link #id(long)} */ private long id; @LayoutRes private int layout; private boolean shown = true; /** * Set to true once this model is diffed in an adapter. Used to ensure that this model's id * doesn't change after being diffed. */ boolean addedToAdapter; /** * The first controller this model was added to. A reference is kept in debug mode in order to run * validations. The model is allowed to be added to other controllers, but we only keep a * reference to the first. */ private EpoxyController firstControllerAddedTo; /** * Models are staged when they are changed. This allows them to be automatically added when they * are done being changed (eg the next model is changed/added or buildModels finishes). It is only * allowed for AutoModels, and only if implicit adding is enabled. */ EpoxyController controllerToStageTo; private boolean currentlyInInterceptors; private int hashCodeWhenAdded; private boolean hasDefaultId; @Nullable private SpanSizeOverrideCallback spanSizeOverride; protected EpoxyModel(long id) { id(id); } public EpoxyModel() { this(idCounter--); hasDefaultId = true; } boolean hasDefaultId() { return hasDefaultId; } /** * Get the view type to associate with this model in the recyclerview. For models that use a * layout resource, the view type is simply the layout resource value by default. *

* If this returns 0 Epoxy will assign a unique view type for this model at run time. * * @see androidx.recyclerview.widget.RecyclerView.Adapter#getItemViewType(int) */ protected int getViewType() { return getLayout(); } /** * Create and return a new instance of a view for this model. By default a view is created by * inflating the layout resource. */ public View buildView(@NonNull ViewGroup parent) { return LayoutInflater.from(parent.getContext()).inflate(getLayout(), parent, false); } /** * Hook that is called before {@link #bind(Object)}. This is similar to * {@link GeneratedModel#handlePreBind(EpoxyViewHolder, Object, int)}, but is intended for * subclasses of EpoxyModel to hook into rather than for generated code to hook into. * Overriding preBind is useful to capture state before the view changes, e.g. for animations. * * @param previouslyBoundModel This is a model with the same id that was previously bound. You can * compare this previous model with the current one to see exactly * what changed. *

* This model and the previously bound model are guaranteed to have * the same id, but will not necessarily be of the same type depending * on your implementation of {@link EpoxyController#buildModels()}. * With common usage patterns of Epoxy they should be the same type, * and will only differ if you are using different model classes with * the same id. *

* Comparing the newly bound model with the previous model allows you * to be more intelligent when binding your view. This may help you * optimize view binding, or make it easier to work with animations. *

* If the new model and the previous model have the same view type * (given by {@link EpoxyModel#getViewType()}), and if you are using * the default ReyclerView item animator, the same view will be * reused. This means that you only need to update the view to reflect * the data that changed. If you are using a custom item animator then * the view will be the same if the animator returns true in * canReuseUpdatedViewHolder. *

* This previously bound model is taken as a payload from the diffing * process, and follows the same general conditions for all * recyclerview change payloads. */ public void preBind(@NonNull T view, @Nullable EpoxyModel previouslyBoundModel) { } /** * Binds the current data to the given view. You should bind all fields including unset/empty * fields to ensure proper recycling. */ public void bind(@NonNull T view) { } /** * Similar to {@link #bind(Object)}, but provides a non null, non empty list of payloads * describing what changed. This is the payloads list specified in the adapter's notifyItemChanged * method. This is a useful optimization to allow you to only change part of a view instead of * updating the whole thing, which may prevent unnecessary layout calls. If there are no payloads * then {@link #bind(Object)} is called instead. This will only be used if the model is used with * an {@link EpoxyAdapter} */ public void bind(@NonNull T view, @NonNull List payloads) { bind(view); } /** * Similar to {@link #bind(Object)}, but provides a non null model which was previously bound to * this view. This will only be called if the model is used with an {@link EpoxyController}. * * @param previouslyBoundModel This is a model with the same id that was previously bound. You can * compare this previous model with the current one to see exactly * what changed. *

* This model and the previously bound model are guaranteed to have * the same id, but will not necessarily be of the same type depending * on your implementation of {@link EpoxyController#buildModels()}. * With common usage patterns of Epoxy they should be the same type, * and will only differ if you are using different model classes with * the same id. *

* Comparing the newly bound model with the previous model allows you * to be more intelligent when binding your view. This may help you * optimize view binding, or make it easier to work with animations. *

* If the new model and the previous model have the same view type * (given by {@link EpoxyModel#getViewType()}), and if you are using * the default ReyclerView item animator, the same view will be * reused. This means that you only need to update the view to reflect * the data that changed. If you are using a custom item animator then * the view will be the same if the animator returns true in * canReuseUpdatedViewHolder. *

* This previously bound model is taken as a payload from the diffing * process, and follows the same general conditions for all * recyclerview change payloads. */ public void bind(@NonNull T view, @NonNull EpoxyModel previouslyBoundModel) { bind(view); } /** * Called when the view bound to this model is recycled. Subclasses can override this if their * view should release resources when it's recycled. *

* Note that {@link #bind(Object)} can be called multiple times without an unbind call in between * if the view has remained on screen to be reused across item changes. This means that you should * not rely on unbind to clear a view or model's state before bind is called again. * * @see EpoxyAdapter#onViewRecycled(EpoxyViewHolder) */ public void unbind(@NonNull T view) { } /** * TODO link to the wiki * * @see OnVisibilityStateChanged annotation */ public void onVisibilityStateChanged(@Visibility int visibilityState, @NonNull T view) { } /** * TODO link to the wiki * * @see OnVisibilityChanged annotation */ public void onVisibilityChanged( @FloatRange(from = 0.0f, to = 100.0f) float percentVisibleHeight, @FloatRange(from = 0.0f, to = 100.0f) float percentVisibleWidth, @Px int visibleHeight, @Px int visibleWidth, @NonNull T view ) { } public long id() { return id; } /** * Override the default id in cases where the data subject naturally has an id, like an object * from a database. This id can only be set before the model is added to the adapter, it is an * error to change the id after that. */ public EpoxyModel id(long id) { if ((addedToAdapter || firstControllerAddedTo != null) && id != this.id) { throw new IllegalEpoxyUsage( "Cannot change a model's id after it has been added to the adapter."); } hasDefaultId = false; this.id = id; return this; } /** * Use multiple numbers as the id for this model. Useful when you don't have a single long that * represents a unique id. *

* This hashes the numbers, so there is a tiny risk of collision with other ids. */ public EpoxyModel id(@Nullable Number... ids) { long result = 0; if (ids != null) { for (@Nullable Number id : ids) { result = 31 * result + hashLong64Bit(id == null ? 0 : id.hashCode()); } } return id(result); } /** * Use two numbers as the id for this model. Useful when you don't have a single long that * represents a unique id. *

* This hashes the two numbers, so there is a tiny risk of collision with other ids. */ public EpoxyModel id(long id1, long id2) { long result = hashLong64Bit(id1); result = 31 * result + hashLong64Bit(id2); return id(result); } /** * Use a string as the model id. Useful for models that don't clearly map to a numerical id. This * is preferable to using {@link String#hashCode()} because that is a 32 bit hash and this is a 64 * bit hash, giving better spread and less chance of collision with other ids. *

* Since this uses a hashcode method to convert the String to a long there is a very small chance * that you may have a collision with another id. Assuming an even spread of hashcodes, and * several hundred models in the adapter, there would be roughly 1 in 100 trillion chance of a * collision. (http://preshing.com/20110504/hash-collision-probabilities/) * * @see IdUtils#hashString64Bit(CharSequence) */ public EpoxyModel id(@Nullable CharSequence key) { id(hashString64Bit(key)); return this; } /** * Use several strings to define the id of the model. *

* Similar to {@link #id(CharSequence)}, but with additional strings. */ public EpoxyModel id(@Nullable CharSequence key, @Nullable CharSequence... otherKeys) { long result = hashString64Bit(key); if (otherKeys != null) { for (CharSequence otherKey : otherKeys) { result = 31 * result + hashString64Bit(otherKey); } } return id(result); } /** * Set an id that is namespaced with a string. This is useful when you need to show models of * multiple types, side by side and don't want to risk id collisions. *

* Since this uses a hashcode method to convert the String to a long there is a very small chance * that you may have a collision with another id. Assuming an even spread of hashcodes, and * several hundred models in the adapter, there would be roughly 1 in 100 trillion chance of a * collision. (http://preshing.com/20110504/hash-collision-probabilities/) * * @see IdUtils#hashString64Bit(CharSequence) * @see IdUtils#hashLong64Bit(long) */ public EpoxyModel id(@Nullable CharSequence key, long id) { long result = hashString64Bit(key); result = 31 * result + hashLong64Bit(id); id(result); return this; } /** * Return the default layout resource to be used when creating views for this model. The resource * will be inflated to create a view for the model; additionally the layout int is used as the * views type in the RecyclerView. *

* This can be left unimplemented if you use the {@link EpoxyModelClass} annotation to define a * layout. *

* This default value can be overridden with {@link #layout(int)} at runtime to change the layout * dynamically. */ @LayoutRes protected abstract int getDefaultLayout(); @NonNull public EpoxyModel layout(@LayoutRes int layoutRes) { onMutation(); layout = layoutRes; return this; } @LayoutRes public final int getLayout() { if (layout == 0) { return getDefaultLayout(); } return layout; } /** * Sets fields of the model to default ones. */ @NonNull public EpoxyModel reset() { onMutation(); layout = 0; shown = true; return this; } /** * Add this model to the given controller. Can only be called from inside {@link * EpoxyController#buildModels()}. */ public void addTo(@NonNull EpoxyController controller) { controller.addInternal(this); } /** * Add this model to the given controller if the condition is true. Can only be called from inside * {@link EpoxyController#buildModels()}. */ public void addIf(boolean condition, @NonNull EpoxyController controller) { if (condition) { addTo(controller); } else if (controllerToStageTo != null) { // Clear this model from staging since it failed the add condition. If this model wasn't // staged (eg not changed before addIf was called, then we need to make sure to add the // previously staged model. controllerToStageTo.clearModelFromStaging(this); controllerToStageTo = null; } } /** * Add this model to the given controller if the {@link AddPredicate} return true. Can only be * called from inside {@link EpoxyController#buildModels()}. */ public void addIf(@NonNull AddPredicate predicate, @NonNull EpoxyController controller) { addIf(predicate.addIf(), controller); } /** * @see #addIf(AddPredicate, EpoxyController) */ public interface AddPredicate { boolean addIf(); } /** * This is used internally by generated models to turn on validation checking when * "validateEpoxyModelUsage" is enabled and the model is used with an {@link EpoxyController}. */ protected final void addWithDebugValidation(@NonNull EpoxyController controller) { if (controller == null) { throw new IllegalArgumentException("Controller cannot be null"); } if (controller.isModelAddedMultipleTimes(this)) { throw new IllegalEpoxyUsage( "This model was already added to the controller at position " + controller.getFirstIndexOfModelInBuildingList(this)); } if (firstControllerAddedTo == null) { firstControllerAddedTo = controller; // We save the current hashCode so we can compare it to the hashCode at later points in time // in order to validate that it doesn't change and enforce mutability. hashCodeWhenAdded = hashCode(); // The one time it is valid to change the model is during an interceptor callback. To support // that we need to update the hashCode after interceptors have been run. // The model can be added to multiple controllers, but we only allow an interceptor change // the first time, since after that it will have been added to an adapter. controller.addAfterInterceptorCallback(new ModelInterceptorCallback() { @Override public void onInterceptorsStarted(EpoxyController controller) { currentlyInInterceptors = true; } @Override public void onInterceptorsFinished(EpoxyController controller) { hashCodeWhenAdded = EpoxyModel.this.hashCode(); currentlyInInterceptors = false; } }); } } boolean isDebugValidationEnabled() { return firstControllerAddedTo != null; } /** * This is used internally by generated models to do validation checking when * "validateEpoxyModelUsage" is enabled and the model is used with an {@link EpoxyController}. * This method validates that it is ok to change this model. It is only valid if the model hasn't * yet been added, or the change is being done from an {@link EpoxyController.Interceptor} * callback. *

* This is also used to stage the model for implicitly adding it, if it is an AutoModel and * implicit adding is enabled. */ protected final void onMutation() { // The model may be added to multiple controllers, in which case if it was already diffed // and added to an adapter in one controller we don't want to even allow interceptors // from changing the model in a different controller if (isDebugValidationEnabled() && !currentlyInInterceptors) { throw new ImmutableModelException(this, getPosition(firstControllerAddedTo, this)); } if (controllerToStageTo != null) { controllerToStageTo.setStagedModel(this); } } private static int getPosition(@NonNull EpoxyController controller, @NonNull EpoxyModel model) { // If the model was added to multiple controllers, or was removed from the controller and then // modified, this won't be correct. But those should be very rare cases that we don't need to // worry about if (controller.isBuildingModels()) { return controller.getFirstIndexOfModelInBuildingList(model); } return controller.getAdapter().getModelPosition(model); } /** * This is used internally by generated models to do validation checking when * "validateEpoxyModelUsage" is enabled and the model is used with a {@link EpoxyController}. This * method validates that the model's hashCode hasn't been changed since it was added to the * controller. This is similar to {@link #onMutation()}, but that method is only used for * specific model changes such as calling a setter. By checking the hashCode, this method allows * us to catch more subtle changes, such as through setting a field directly or through changing * an object that is set on the model. */ protected final void validateStateHasNotChangedSinceAdded(String descriptionOfChange, int modelPosition) { if (isDebugValidationEnabled() && !currentlyInInterceptors && hashCodeWhenAdded != hashCode()) { throw new ImmutableModelException(this, descriptionOfChange, modelPosition); } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof EpoxyModel)) { return false; } EpoxyModel that = (EpoxyModel) o; if (id != that.id) { return false; } if (getViewType() != that.getViewType()) { return false; } return shown == that.shown; } @Override public int hashCode() { int result = (int) (id ^ (id >>> 32)); result = 31 * result + getViewType(); result = 31 * result + (shown ? 1 : 0); return result; } /** * Subclasses can override this if they want their view to take up more than one span in a grid * layout. * * @param totalSpanCount The number of spans in the grid * @param position The position of the model * @param itemCount The total number of items in the adapter */ public int getSpanSize(int totalSpanCount, int position, int itemCount) { return 1; } public EpoxyModel spanSizeOverride(@Nullable SpanSizeOverrideCallback spanSizeCallback) { this.spanSizeOverride = spanSizeCallback; return this; } public interface SpanSizeOverrideCallback { int getSpanSize(int totalSpanCount, int position, int itemCount); } /** * Returns the actual span size of this model, using the {@link SpanSizeOverrideCallback} if one * was set, otherwise using the value from {@link #getSpanSize(int, int, int)} */ public final int spanSize(int totalSpanCount, int position, int itemCount) { if (spanSizeOverride != null) { return spanSizeOverride.getSpanSize(totalSpanCount, position, itemCount); } return getSpanSize(totalSpanCount, position, itemCount); } /** * Change the visibility of the model so that it's view is shown. This only works if the model is * used in {@link EpoxyAdapter} or a {@link EpoxyModelGroup}, but is not supported in {@link * EpoxyController} */ @NonNull public EpoxyModel show() { return show(true); } /** * Change the visibility of the model's view. This only works if the model is * used in {@link EpoxyAdapter} or a {@link EpoxyModelGroup}, but is not supported in {@link * EpoxyController} */ @NonNull public EpoxyModel show(boolean show) { onMutation(); shown = show; return this; } /** * Change the visibility of the model so that it's view is hidden. This only works if the model is * used in {@link EpoxyAdapter} or a {@link EpoxyModelGroup}, but is not supported in {@link * EpoxyController} */ @NonNull public EpoxyModel hide() { return show(false); } /** * Whether the model's view should be shown on screen. If false it won't be inflated and drawn, * and will be like it was never added to the recycler view. */ public boolean isShown() { return shown; } /** * Whether the adapter should save the state of the view bound to this model. */ public boolean shouldSaveViewState() { return false; } /** * Called if the RecyclerView failed to recycle this model's view. You can take this opportunity * to clear the animation(s) that affect the View's transient state and return true * so that the View can be recycled. Keep in mind that the View in question is already removed * from the RecyclerView. * * @return True if the View should be recycled, false otherwise * @see EpoxyAdapter#onFailedToRecycleView(androidx.recyclerview.widget.RecyclerView.ViewHolder) */ public boolean onFailedToRecycleView(@NonNull T view) { return false; } /** * Called when this model's view is attached to the window. * * @see EpoxyAdapter#onViewAttachedToWindow(androidx.recyclerview.widget.RecyclerView.ViewHolder) */ public void onViewAttachedToWindow(@NonNull T view) { } /** * Called when this model's view is detached from the the window. * * @see EpoxyAdapter#onViewDetachedFromWindow(androidx.recyclerview.widget.RecyclerView * .ViewHolder) */ public void onViewDetachedFromWindow(@NonNull T view) { } @Override public String toString() { return getClass().getSimpleName() + "{" + "id=" + id + ", viewType=" + getViewType() + ", shown=" + shown + ", addedToAdapter=" + addedToAdapter + '}'; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyModelGroup.java ================================================ package com.airbnb.epoxy; import android.view.View; import android.view.ViewParent; import android.view.ViewStub; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import androidx.annotation.CallSuper; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; /** * An {@link EpoxyModel} that contains other models, and allows you to combine those models in * whatever view configuration you want. *

* The constructors take a list of models and a layout resource. The layout must have a viewgroup as * its top level view; it determines how the view of each model is laid out. There are two ways to * specify this *

* 1. Leave the viewgroup empty. The view for each model will be inflated and added in order. This * works fine if you don't need to include any other views, your model views don't need their layout * params changed, and your views don't need ids (eg for saving state). *

* Alternatively you can have nested view groups, with the innermost viewgroup given the id * "epoxy_model_group_child_container" to mark it as the viewgroup that should have the model views * added to it. The viewgroup marked with this id should be empty. This allows you to nest * viewgroups, such as a LinearLayout inside of a CardView. *

* 2. Include a {@link ViewStub} for each of the models in the list. There should be at least as * many view stubs as models. Extra stubs will be ignored. Each model will have its view replace the * stub in order of the view stub's position in the view group. That is, the view group's children * will be iterated through in order. The first view stub found will be used for the first model in * the models list, the second view stub will be used for the second model, and so on. A depth first * recursive search through nested viewgroups is done to find these viewstubs. *

* The layout can be of any ViewGroup subclass, and can have arbitrary other child views besides the * view stubs. It can arrange the views and view stubs however is needed. *

* Any layout param options set on the view stubs will be transferred to the corresponding model * view by default. If you want a model to keep the layout params from it's own layout resource you * can override {@link #useViewStubLayoutParams(EpoxyModel, int)} *

* If you want to override the id used for a model's view you can set {@link * ViewStub#setInflatedId(int)} via xml. That id will be transferred over to the view taking that * stub's place. This is necessary if you want your model to save view state, since without this the * model's view won't have an id to associate the saved state with. *

* By default this model inherits the same id as the first model in the list. Call {@link #id(long)} * to override that if needed. *

* When a model group is recycled, its child views are automatically recycled to a pool that is * shared with all other model groups in the activity. This enables model groups to more efficiently * manage their children. The shared pool is cleaned up when the activity is destroyed. */ @SuppressWarnings("rawtypes") public class EpoxyModelGroup extends EpoxyModelWithHolder { protected final List> models; private boolean shouldSaveViewStateDefault = false; @Nullable private Boolean shouldSaveViewState = null; /** * @param layoutRes The layout to use with these models. * @param models The models that will be used to bind the views in the given layout. */ public EpoxyModelGroup(@LayoutRes int layoutRes, Collection> models) { this(layoutRes, new ArrayList<>(models)); } /** * @param layoutRes The layout to use with these models. * @param models The models that will be used to bind the views in the given layout. */ public EpoxyModelGroup(@LayoutRes int layoutRes, EpoxyModel... models) { this(layoutRes, new ArrayList<>(Arrays.asList(models))); } /** * @param layoutRes The layout to use with these models. * @param models The models that will be used to bind the views in the given layout. */ private EpoxyModelGroup(@LayoutRes int layoutRes, List> models) { if (models.isEmpty()) { throw new IllegalArgumentException("Models cannot be empty"); } this.models = models; layout(layoutRes); id(models.get(0).id()); boolean saveState = false; for (EpoxyModel model : models) { if (model.shouldSaveViewState()) { saveState = true; break; } } // By default we save view state if any of the models need to save state. shouldSaveViewStateDefault = saveState; } /** * Constructor use for DSL */ protected EpoxyModelGroup() { models = new ArrayList<>(); shouldSaveViewStateDefault = false; } /** * Constructor use for DSL */ protected EpoxyModelGroup(@LayoutRes int layoutRes) { this(); layout(layoutRes); } protected void addModel(@NonNull EpoxyModel model) { // By default we save view state if any of the models need to save state. shouldSaveViewStateDefault |= model.shouldSaveViewState(); models.add(model); } @CallSuper @Override public void bind(@NonNull ModelGroupHolder holder) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, EpoxyViewHolder viewHolder, int modelIndex) { setViewVisibility(model, viewHolder); viewHolder.bind(model, null, Collections.emptyList(), modelIndex); } }); } @CallSuper @Override public void bind(@NonNull ModelGroupHolder holder, @NonNull final List payloads) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, EpoxyViewHolder viewHolder, int modelIndex) { setViewVisibility(model, viewHolder); viewHolder.bind(model, null, Collections.emptyList(), modelIndex); } }); } @Override public void bind(@NonNull ModelGroupHolder holder, @NonNull EpoxyModel previouslyBoundModel) { if (!(previouslyBoundModel instanceof EpoxyModelGroup)) { bind(holder); return; } final EpoxyModelGroup previousGroup = (EpoxyModelGroup) previouslyBoundModel; iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, EpoxyViewHolder viewHolder, int modelIndex) { setViewVisibility(model, viewHolder); if (modelIndex < previousGroup.models.size()) { EpoxyModel previousModel = previousGroup.models.get(modelIndex); if (previousModel.id() == model.id()) { viewHolder.bind(model, previousModel, Collections.emptyList(), modelIndex); return; } } viewHolder.bind(model, null, Collections.emptyList(), modelIndex); } }); } private static void setViewVisibility(EpoxyModel model, EpoxyViewHolder viewHolder) { if (model.isShown()) { viewHolder.itemView.setVisibility(View.VISIBLE); } else { viewHolder.itemView.setVisibility(View.GONE); } } @CallSuper @Override public void unbind(@NonNull ModelGroupHolder holder) { holder.unbindGroup(); } @CallSuper @Override public void onViewAttachedToWindow(ModelGroupHolder holder) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, EpoxyViewHolder viewHolder, int modelIndex) { //noinspection unchecked model.onViewAttachedToWindow(viewHolder.objectToBind()); } }); } @CallSuper @Override public void onViewDetachedFromWindow(ModelGroupHolder holder) { iterateModels(holder, new IterateModelsCallback() { @Override public void onModel(EpoxyModel model, EpoxyViewHolder viewHolder, int modelIndex) { //noinspection unchecked model.onViewDetachedFromWindow(viewHolder.objectToBind()); } }); } private void iterateModels(ModelGroupHolder holder, IterateModelsCallback callback) { holder.bindGroupIfNeeded(this); int modelCount = models.size(); for (int i = 0; i < modelCount; i++) { callback.onModel(models.get(i), holder.getViewHolders().get(i), i); } } private interface IterateModelsCallback { void onModel(EpoxyModel model, EpoxyViewHolder viewHolder, int modelIndex); } @Override public int getSpanSize(int totalSpanCount, int position, int itemCount) { // Defaults to using the span size of the first model. Override this if you need to customize it return models.get(0).spanSize(totalSpanCount, position, itemCount); } @Override protected final int getDefaultLayout() { throw new UnsupportedOperationException( "You should set a layout with layout(...) instead of using this."); } @NonNull public EpoxyModelGroup shouldSaveViewState(boolean shouldSaveViewState) { onMutation(); this.shouldSaveViewState = shouldSaveViewState; return this; } @Override public boolean shouldSaveViewState() { // By default state is saved if any of the models have saved state enabled. // Override this if you need custom behavior. if (shouldSaveViewState != null) { return shouldSaveViewState; } else { return shouldSaveViewStateDefault; } } /** * Whether the layout params set on the view stub for the given model should be carried over to * the model's view. Default is true *

* Set this to false if you want the layout params on the model's layout resource to be kept. * * @param model The model who's view is being created * @param modelPosition The position of the model in the models list */ protected boolean useViewStubLayoutParams(EpoxyModel model, int modelPosition) { return true; } @Override protected final ModelGroupHolder createNewHolder(@NonNull ViewParent parent) { return new ModelGroupHolder(parent); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof EpoxyModelGroup)) { return false; } if (!super.equals(o)) { return false; } EpoxyModelGroup that = (EpoxyModelGroup) o; return models.equals(that.models); } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + models.hashCode(); return result; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyModelTouchCallback.java ================================================ package com.airbnb.epoxy; import android.graphics.Canvas; import android.view.View; import com.airbnb.viewmodeladapter.R; import androidx.annotation.Nullable; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; /** * A wrapper around {@link androidx.recyclerview.widget.ItemTouchHelper.Callback} to enable * easier touch support when working with Epoxy models. *

* For simplicity you can use {@link EpoxyTouchHelper} to set up touch handling via this class for * you instead of using this class directly. However, you may choose to use this class directly with * your own {@link ItemTouchHelper} if you need extra flexibility or customization. */ public abstract class EpoxyModelTouchCallback extends EpoxyTouchHelperCallback implements EpoxyDragCallback, EpoxySwipeCallback { private static final int TOUCH_DEBOUNCE_MILLIS = 300; @Nullable private final EpoxyController controller; private final Class targetModelClass; private EpoxyViewHolder holderBeingDragged; private EpoxyViewHolder holderBeingSwiped; public EpoxyModelTouchCallback(@Nullable EpoxyController controller, Class targetModelClass) { this.controller = controller; this.targetModelClass = targetModelClass; } @Override protected int getMovementFlags(RecyclerView recyclerView, EpoxyViewHolder viewHolder) { EpoxyModel model = viewHolder.getModel(); // If multiple touch callbacks are registered on the recyclerview (to support combinations of // dragging and dropping) then we won't want to enable anything if another // callback has a view actively selected. boolean isOtherCallbackActive = holderBeingDragged == null && holderBeingSwiped == null && recyclerViewHasSelection(recyclerView); if (!isOtherCallbackActive && isTouchableModel(model)) { //noinspection unchecked return getMovementFlagsForModel((T) model, viewHolder.getAdapterPosition()); } else { return 0; } } @Override protected boolean canDropOver(RecyclerView recyclerView, EpoxyViewHolder current, EpoxyViewHolder target) { // By default we don't allow dropping on a model that isn't a drag target return isTouchableModel(target.getModel()); } protected boolean isTouchableModel(EpoxyModel model) { return targetModelClass.isInstance(model); } @Override protected boolean onMove(RecyclerView recyclerView, EpoxyViewHolder viewHolder, EpoxyViewHolder target) { if (controller == null) { throw new IllegalStateException( "A controller must be provided in the constructor if dragging is enabled"); } int fromPosition = viewHolder.getAdapterPosition(); int toPosition = target.getAdapterPosition(); controller.moveModel(fromPosition, toPosition); EpoxyModel model = viewHolder.getModel(); if (!isTouchableModel(model)) { throw new IllegalStateException( "A model was dragged that is not a valid target: " + model.getClass()); } //noinspection unchecked onModelMoved(fromPosition, toPosition, (T) model, viewHolder.itemView); return true; } @Override public void onModelMoved(int fromPosition, int toPosition, T modelBeingMoved, View itemView) { } @Override protected void onSwiped(EpoxyViewHolder viewHolder, int direction) { EpoxyModel model = viewHolder.getModel(); View view = viewHolder.itemView; int position = viewHolder.getAdapterPosition(); if (!isTouchableModel(model)) { throw new IllegalStateException( "A model was swiped that is not a valid target: " + model.getClass()); } //noinspection unchecked onSwipeCompleted((T) model, view, position, direction); } @Override public void onSwipeCompleted(T model, View itemView, int position, int direction) { } @Override protected void onSelectedChanged(@Nullable EpoxyViewHolder viewHolder, int actionState) { super.onSelectedChanged(viewHolder, actionState); if (viewHolder != null) { EpoxyModel model = viewHolder.getModel(); if (!isTouchableModel(model)) { throw new IllegalStateException( "A model was selected that is not a valid target: " + model.getClass()); } markRecyclerViewHasSelection((RecyclerView) viewHolder.itemView.getParent()); if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { holderBeingSwiped = viewHolder; //noinspection unchecked onSwipeStarted((T) model, viewHolder.itemView, viewHolder.getAdapterPosition()); } else if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { holderBeingDragged = viewHolder; //noinspection unchecked onDragStarted((T) model, viewHolder.itemView, viewHolder.getAdapterPosition()); } } else if (holderBeingDragged != null) { //noinspection unchecked onDragReleased((T) holderBeingDragged.getModel(), holderBeingDragged.itemView); holderBeingDragged = null; } else if (holderBeingSwiped != null) { //noinspection unchecked onSwipeReleased((T) holderBeingSwiped.getModel(), holderBeingSwiped.itemView); holderBeingSwiped = null; } } private void markRecyclerViewHasSelection(RecyclerView recyclerView) { recyclerView.setTag(R.id.epoxy_touch_helper_selection_status, Boolean.TRUE); } private boolean recyclerViewHasSelection(RecyclerView recyclerView) { return recyclerView.getTag(R.id.epoxy_touch_helper_selection_status) != null; } private void clearRecyclerViewSelectionMarker(RecyclerView recyclerView) { recyclerView.setTag(R.id.epoxy_touch_helper_selection_status, null); } @Override public void onSwipeStarted(T model, View itemView, int adapterPosition) { } @Override public void onSwipeReleased(T model, View itemView) { } @Override public void onDragStarted(T model, View itemView, int adapterPosition) { } @Override public void onDragReleased(T model, View itemView) { } @Override protected void clearView(final RecyclerView recyclerView, EpoxyViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); //noinspection unchecked clearView((T) viewHolder.getModel(), viewHolder.itemView); // If multiple touch helpers are in use, one touch helper can pick up buffered touch inputs // immediately after another touch event finishes. This leads to things like a view being // selected for drag when another view finishes its swipe off animation. To prevent that we // keep the recyclerview marked as having an active selection for a brief period after a // touch event ends. recyclerView.postDelayed(new Runnable() { @Override public void run() { clearRecyclerViewSelectionMarker(recyclerView); } }, TOUCH_DEBOUNCE_MILLIS); } @Override public void clearView(T model, View itemView) { } @Override protected void onChildDraw(Canvas c, RecyclerView recyclerView, EpoxyViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); EpoxyModel model; // It’s possible for a touch helper to still draw if the item is being removed, which means it // has technically be unbound by that point. getModel will throw an exception in this case. try { model = viewHolder.getModel(); } catch (IllegalStateException ignored) { return; } if (!isTouchableModel(model)) { throw new IllegalStateException( "A model was selected that is not a valid target: " + model.getClass()); } View itemView = viewHolder.itemView; float swipeProgress; if (Math.abs(dX) > Math.abs(dY)) { swipeProgress = dX / itemView.getWidth(); } else { swipeProgress = dY / itemView.getHeight(); } // Clamp to 1/-1 in the case of side padding where the view can be swiped extra float clampedProgress = Math.max(-1f, Math.min(1f, swipeProgress)); //noinspection unchecked onSwipeProgressChanged((T) model, itemView, clampedProgress, c); } @Override public void onSwipeProgressChanged(T model, View itemView, float swipeProgress, Canvas canvas) { } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyModelWithHolder.java ================================================ package com.airbnb.epoxy; import android.view.ViewParent; import com.airbnb.epoxy.VisibilityState.Visibility; import java.util.List; import androidx.annotation.FloatRange; import androidx.annotation.NonNull; import androidx.annotation.Px; /** * A version of {@link com.airbnb.epoxy.EpoxyModel} that allows you to use a view holder pattern * instead of a specific view when binding to your model. */ public abstract class EpoxyModelWithHolder extends EpoxyModel { public EpoxyModelWithHolder() { } public EpoxyModelWithHolder(long id) { super(id); } /** This should return a new instance of your {@link com.airbnb.epoxy.EpoxyHolder} class. */ protected abstract T createNewHolder(@NonNull ViewParent parent); @Override public void bind(@NonNull T holder) { super.bind(holder); } @Override public void bind(@NonNull T holder, @NonNull List payloads) { super.bind(holder, payloads); } @Override public void bind(@NonNull T holder, @NonNull EpoxyModel previouslyBoundModel) { super.bind(holder, previouslyBoundModel); } @Override public void unbind(@NonNull T holder) { super.unbind(holder); } @Override public void onVisibilityStateChanged(@Visibility int visibilityState, @NonNull T holder) { super.onVisibilityStateChanged(visibilityState, holder); } @Override public void onVisibilityChanged( @FloatRange(from = 0, to = 100) float percentVisibleHeight, @FloatRange(from = 0, to = 100) float percentVisibleWidth, @Px int visibleHeight, @Px int visibleWidth, @NonNull T holder) { super.onVisibilityChanged( percentVisibleHeight, percentVisibleWidth, visibleHeight, visibleWidth, holder); } @Override public boolean onFailedToRecycleView(T holder) { return super.onFailedToRecycleView(holder); } @Override public void onViewAttachedToWindow(T holder) { super.onViewAttachedToWindow(holder); } @Override public void onViewDetachedFromWindow(T holder) { super.onViewDetachedFromWindow(holder); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyModelWithView.java ================================================ package com.airbnb.epoxy; import android.view.View; import android.view.ViewGroup; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; /** * A model that allows its view to be built programmatically instead of by inflating a layout * resource. Just implement {@link #buildView} so the adapter can create a new view for this model * when needed. *

* {@link #getViewType()} is used by the adapter to know how to reuse views for this model. This * means that all models that return the same type should be able to share the same view, and the * view won't be shared with models of any other type. *

* If it is left unimplemented then at runtime a unique view type will be created to use for all * models of that class. The generated view type will be negative so that it cannot collide with * values from resource files, which are used in normal Epoxy models. If you would like to share * the same view between models of different classes you can have those classes return the same view * type. A good way to manually create a view type value is by creating an R.id. value in an ids * resource file. */ public abstract class EpoxyModelWithView extends EpoxyModel { /** * Get the view type associated with this model's view. Any models with the same view type will * have views recycled between them. * * @see androidx.recyclerview.widget.RecyclerView.Adapter#getItemViewType(int) */ @Override protected int getViewType() { return 0; } /** * Create and return a new instance of a view for this model. If no layout params are set on the * returned view then default layout params will be used. * * @param parent The parent ViewGroup that the returned view will be added to. */ @Override public abstract T buildView(@NonNull ViewGroup parent); @Override protected final int getDefaultLayout() { throw new UnsupportedOperationException( "Layout resources are unsupported. Views must be created with `buildView`"); } @Override public EpoxyModel layout(@LayoutRes int layoutRes) { throw new UnsupportedOperationException( "Layout resources are unsupported. Views must be created with `buildView`"); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyRecyclerView.kt ================================================ package com.airbnb.epoxy import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.util.AttributeSet import android.util.TypedValue import android.view.ViewGroup import androidx.annotation.CallSuper import androidx.annotation.DimenRes import androidx.annotation.Dimension import androidx.annotation.Px import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.preload.EpoxyModelPreloader import com.airbnb.epoxy.preload.EpoxyPreloader import com.airbnb.epoxy.preload.PreloadErrorHandler import com.airbnb.epoxy.preload.PreloadRequestHolder import com.airbnb.epoxy.preload.ViewMetadata import com.airbnb.viewmodeladapter.R /** * A RecyclerView implementation that makes for easier integration with Epoxy. The goal of this * class is to reduce boilerplate in setting up a RecyclerView by applying common defaults. * Additionally, several performance optimizations are made. * * Improvements in this class are: * * 1. A single view pool is automatically shared between all [EpoxyRecyclerView] instances in * the same activity. This should increase view recycling potential and increase performance when * nested RecyclerViews are used. See [.initViewPool]. * * 2. A layout manager is automatically added with assumed defaults. See [createLayoutManager] * * 3. Fixed size is enabled if this view's size is MATCH_PARENT * * 4. If a [GridLayoutManager] is used this will automatically sync the span count with the * [EpoxyController]. See [syncSpanCount] * * 5. Helper methods like [withModels], [setModels], [buildModelsWith] * make it simpler to set up simple RecyclerViews. * * 6. Set an EpoxyController and build models in one step - * [setControllerAndBuildModels] or [withModels] * * 7. Support for automatic item spacing. See [.setItemSpacingPx] * * 8. Defaults for usage as a nested recyclerview are provided in [Carousel]. * * 9. [setClipToPadding] is set to false by default since that behavior is commonly * desired in a scrolling list */ open class EpoxyRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RecyclerView(context, attrs, defStyleAttr) { protected val spacingDecorator = EpoxyItemSpacingDecorator() private var epoxyController: EpoxyController? = null /** * The adapter that was removed because the RecyclerView was detached from the window. We save it * so we can reattach it if the RecyclerView is reattached to window. This allows us to * automatically restore the adapter, without risking leaking the RecyclerView if this view is * never used again. * * Since the adapter is removed this recyclerview won't get adapter changes, but that's fine since * the view isn't attached to window and isn't being drawn. * * This reference is cleared if another adapter is manually set, so we don't override the user's * adapter choice. * * @see .setRemoveAdapterWhenDetachedFromWindow */ private var removedAdapter: RecyclerView.Adapter<*>? = null private var removeAdapterWhenDetachedFromWindow = true private var delayMsWhenRemovingAdapterOnDetach: Int = DEFAULT_ADAPTER_REMOVAL_DELAY_MS /** * Tracks whether [.removeAdapterRunnable] has been posted to run * later. This lets us know if we should cancel the runnable at certain times. This removes the * overhead of needlessly attempting to remove the runnable when it isn't posted. */ private var isRemoveAdapterRunnablePosted: Boolean = false private val removeAdapterRunnable = Runnable { if (isRemoveAdapterRunnablePosted) { // Canceling a runnable doesn't work accurately when a view switches between // attached/detached, so we manually check that this should still be run isRemoveAdapterRunnablePosted = false removeAdapter() } } private val preloadScrollListeners: MutableList> = mutableListOf() private val preloadConfigs: MutableList> = mutableListOf() private class PreloadConfig, U : ViewMetadata?, P : PreloadRequestHolder>( val maxPreload: Int, val errorHandler: PreloadErrorHandler, val preloader: EpoxyModelPreloader, val requestHolderFactory: () -> P ) /** * Setup a preloader to fetch content for a model's view before it is bound. * This can be called multiple times if you would like to add separate preloaders * for different models or content types. * * Preloaders are automatically attached and run, and are updated if the adapter changes. * * @param maxPreloadDistance How many items to prefetch ahead of the last bound item * @param errorHandler Called when the preloader encounters an exception. We recommend throwing an * exception in debug builds, and logging an error in production. * @param preloader Describes how view content for the EpoxyModel should be preloaded * @param requestHolderFactory Should create and return a new [PreloadRequestHolder] each time it is invoked */ fun , U : ViewMetadata?, P : PreloadRequestHolder> addPreloader( maxPreloadDistance: Int = 3, errorHandler: PreloadErrorHandler, preloader: EpoxyModelPreloader, requestHolderFactory: () -> P ) { preloadConfigs.add( PreloadConfig( maxPreloadDistance, errorHandler, preloader, requestHolderFactory ) ) updatePreloaders() } /** * Clears all preloaders added with [addPreloader] */ fun clearPreloaders() { preloadConfigs.clear() updatePreloaders() } private fun updatePreloaders() { preloadScrollListeners.forEach { removeOnScrollListener(it) } preloadScrollListeners.clear() val currAdapter = adapter ?: return preloadConfigs.forEach { preloadConfig -> if (currAdapter is EpoxyAdapter) { EpoxyPreloader.with( currAdapter, preloadConfig.requestHolderFactory, preloadConfig.errorHandler, preloadConfig.maxPreload, listOf(preloadConfig.preloader) ) } else { epoxyController?.let { EpoxyPreloader.with( it, preloadConfig.requestHolderFactory, preloadConfig.errorHandler, preloadConfig.maxPreload, listOf(preloadConfig.preloader) ) } }?.let { preloadScrollListeners.add(it) addOnScrollListener(it) } } } /** * If set to true, any adapter set on this recyclerview will be removed when this view is detached * from the window. This is useful to prevent leaking a reference to this RecyclerView. This is * useful in cases where the same adapter can be used across multiple views (views which can be * destroyed and recreated), such as with fragments. In that case the adapter is not necessarily * cleared from previous RecyclerViews, so the adapter will continue to hold a reference to those * views and leak them. More details at https://github.com/airbnb/epoxy/wiki/Avoiding-Memory-Leaks#parent-view * * The default is true, but you can disable this if you don't want your adapter detached * automatically. * * If the adapter is removed via this setting, it will be re-set on the RecyclerView if the * RecyclerView is re-attached to the window at a later point. */ fun setRemoveAdapterWhenDetachedFromWindow(removeAdapterWhenDetachedFromWindow: Boolean) { this.removeAdapterWhenDetachedFromWindow = removeAdapterWhenDetachedFromWindow } /** * If [.setRemoveAdapterWhenDetachedFromWindow] is set to true, this is the delay * in milliseconds between when [.onDetachedFromWindow] is called and when the adapter is * actually removed. * * By default a delay of {@value #DEFAULT_ADAPTER_REMOVAL_DELAY_MS} ms is used so that view * transitions can complete before the adapter is removed. Otherwise if the adapter is removed * before transitions finish it can clear the screen and break the transition. A notable case is * fragment transitions, in which the fragment view is detached from window before the transition * ends. */ fun setDelayMsWhenRemovingAdapterOnDetach(delayMsWhenRemovingAdapterOnDetach: Int) { this.delayMsWhenRemovingAdapterOnDetach = delayMsWhenRemovingAdapterOnDetach } init { if (attrs != null) { val a = context.obtainStyledAttributes( attrs, R.styleable.EpoxyRecyclerView, defStyleAttr, 0 ) setItemSpacingPx( a.getDimensionPixelSize( R.styleable.EpoxyRecyclerView_itemSpacing, 0 ) ) a.recycle() } init() } @CallSuper protected open fun init() { clipToPadding = false initViewPool() } /** * Get or create a view pool to use for this RecyclerView. By default the same pool is shared for * all [EpoxyRecyclerView] usages in the same Activity. * * @see .createViewPool * @see .shouldShareViewPoolAcrossContext */ private fun initViewPool() { if (!shouldShareViewPoolAcrossContext()) { setRecycledViewPool(createViewPool()) return } setRecycledViewPool( ACTIVITY_RECYCLER_POOL.getPool( getContextForSharedViewPool() ) { createViewPool() }.viewPool ) } /** * Attempts to find this view's parent Activity in order to share the view pool. If this view's * `context` is a ContextWrapper it will continually unwrap it until it finds the Activity. If * no Activity is found it will return the the view's context. */ private fun getContextForSharedViewPool(): Context { var workingContext = this.context while (workingContext is ContextWrapper) { if (workingContext is Activity) { return workingContext } workingContext = workingContext.baseContext } return this.context } /** * Create a new instance of a view pool to use with this recyclerview. By default a [ ] is used. */ protected open fun createViewPool(): RecyclerView.RecycledViewPool { return UnboundedViewPool() } /** * To maximize view recycling by default we share the same view pool across all instances in the same Activity. This behavior can be disabled by returning * false here. */ open fun shouldShareViewPoolAcrossContext(): Boolean { return true } override fun setLayoutParams(params: ViewGroup.LayoutParams) { val isFirstParams = layoutParams == null super.setLayoutParams(params) if (isFirstParams) { // Set a default layout manager if one was not set via xml // We need layout params for this to guess at the right size and type if (layoutManager == null) { layoutManager = createLayoutManager() } } } /** * Create a new [androidx.recyclerview.widget.RecyclerView.LayoutManager] * instance to use for this RecyclerView. * * By default a LinearLayoutManager is used, and a reasonable default is chosen for scrolling * direction based on layout params. * * If the RecyclerView is set to match parent size then the scrolling orientation is set to * vertical and [.setHasFixedSize] is set to true. * * If the height is set to wrap_content then the scrolling orientation is set to horizontal, and * [.setClipToPadding] is set to false. */ protected open fun createLayoutManager(): RecyclerView.LayoutManager { val layoutParams = layoutParams // 0 represents matching constraints in a LinearLayout or ConstraintLayout if (layoutParams.height == RecyclerView.LayoutParams.MATCH_PARENT || layoutParams.height == 0) { if (layoutParams.width == RecyclerView.LayoutParams.MATCH_PARENT || layoutParams.width == 0) { // If we are filling as much space as possible then we usually are fixed size setHasFixedSize(true) } // A sane default is a vertically scrolling linear layout return LinearLayoutManager(context) } else { // This is usually the case for horizontally scrolling carousels and should be a sane // default return LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) } } override fun setLayoutManager(layout: RecyclerView.LayoutManager?) { super.setLayoutManager(layout) syncSpanCount() } /** * If a grid layout manager is set we sync the span count between the layout and the epoxy * adapter automatically. */ private fun syncSpanCount() { val layout = layoutManager val controller = epoxyController if (layout is GridLayoutManager && controller != null) { if (controller.spanCount != layout.spanCount || layout.spanSizeLookup !== controller.spanSizeLookup) { controller.spanCount = layout.spanCount layout.spanSizeLookup = controller.spanSizeLookup } } } override fun requestLayout() { // Grid layout manager calls this when the span count is changed. Its the easiest way to // detect a span count change and update our controller accordingly. syncSpanCount() super.requestLayout() } fun setItemSpacingRes(@DimenRes itemSpacingRes: Int) { setItemSpacingPx(resToPx(itemSpacingRes)) } fun setItemSpacingDp(@Dimension(unit = Dimension.DP) dp: Int) { setItemSpacingPx(dpToPx(dp)) } /** * Set a pixel value to use as spacing between items. If this is a positive number an item * decoration will be added to space all items this far apart from each other. If the value is 0 * or negative no extra spacing will be used, and any previous spacing will be removed. * * This only works if a [LinearLayoutManager] or [GridLayoutManager] is used with this * RecyclerView. * * This can also be set via the `app:itemSpacing` styleable attribute. * * @see .setItemSpacingDp * @see .setItemSpacingRes */ open fun setItemSpacingPx(@Px spacingPx: Int) { removeItemDecoration(spacingDecorator) spacingDecorator.pxBetweenItems = spacingPx if (spacingPx > 0) { addItemDecoration(spacingDecorator) } } /** * Set a list of [EpoxyModel]'s to show in this RecyclerView. * * Alternatively you can set an [EpoxyController] to handle building models dynamically. * * @see withModels * @see setController * @see setControllerAndBuildModels * @see buildModelsWith */ open fun setModels(models: List>) { val controller = (epoxyController as? SimpleEpoxyController) ?: SimpleEpoxyController().also { setController(it) } controller.setModels(models) } /** * Set an EpoxyController to populate this RecyclerView. This does not make the controller build * its models, that must be done separately via [requestModelBuild]. * * Use this if you don't want [requestModelBuild] called automatically. Common cases * are if you are using [TypedEpoxyController] (in which case you must call setData on the * controller), or if you have not otherwise populated your controller's data yet. * * Otherwise if you want models built automatically for you use [setControllerAndBuildModels] * * The controller can be cleared with [clear] * * @see .setControllerAndBuildModels * @see .buildModelsWith * @see .setModels */ fun setController(controller: EpoxyController) { epoxyController = controller adapter = controller.adapter syncSpanCount() } /** * Set an EpoxyController to populate this RecyclerView, and tell the controller to build * models. * * The controller can be cleared with [clear] * * @see setController * @see buildModelsWith * @see setModels */ fun setControllerAndBuildModels(controller: EpoxyController) { controller.requestModelBuild() setController(controller) } /** * The simplest way to add models to the RecyclerView without needing to create an EpoxyController. * This is intended for Kotlin usage, and has the EpoxyController as the lambda receiver so * models can be added easily. * * Multiple calls to this will reuse the same underlying EpoxyController so views in the * RecyclerView will be reused. * * The Java equivalent is [buildModelsWith]. */ fun withModels(buildModels: EpoxyController.() -> Unit) { val controller = (epoxyController as? WithModelsController) ?: WithModelsController().also { setController(it) } controller.callback = buildModels controller.requestModelBuild() } private class WithModelsController : EpoxyController() { var callback: EpoxyController.() -> Unit = {} override fun buildModels() { callback(this) } } /** * Allows you to build models via a callback instead of needing to create a new EpoxyController * class. This is useful if your models are simple and you would like to simply declare them in * your activity/fragment. * * Multiple calls to this will reuse the same underlying EpoxyController so views in the * RecyclerView will be reused. * * Another useful pattern is having your Activity or Fragment implement [ModelBuilderCallback]. * * If you're using Kotlin, prefer [withModels]. * * @see setController * @see setControllerAndBuildModels * @see setModels */ fun buildModelsWith(callback: ModelBuilderCallback) { val controller = (epoxyController as? ModelBuilderCallbackController) ?: ModelBuilderCallbackController().also { setController(it) } controller.callback = callback controller.requestModelBuild() } private class ModelBuilderCallbackController : EpoxyController() { var callback: ModelBuilderCallback = object : ModelBuilderCallback { override fun buildModels(controller: EpoxyController) { } } override fun buildModels() { callback.buildModels(this) } } /** * A callback for creating models without needing a custom EpoxyController class. Used with [buildModelsWith] */ interface ModelBuilderCallback { /** * Analagous to [EpoxyController.buildModels]. You should create new model instances and * add them to the given controller. [AutoModel] cannot be used with models added this * way. */ fun buildModels(controller: EpoxyController) } /** * Request that the currently set EpoxyController has its models rebuilt. You can use this to * avoid saving your controller as a field. * * You cannot use this if your controller is a [TypedEpoxyController] or if you set * models via [setModels]. In that case you must set data directly on the * controller or set models again. */ fun requestModelBuild() { if (epoxyController == null) { throw IllegalStateException("A controller must be set before requesting a model build.") } if (epoxyController is SimpleEpoxyController) { throw IllegalStateException("Models were set with #setModels, they can not be rebuilt.") } epoxyController!!.requestModelBuild() } /** * Clear the currently set EpoxyController or Adapter as well as any models that are displayed. * * Any pending requests to the EpoxyController to build models are canceled. * * Any existing child views are recycled to the view pool. */ open fun clear() { // The controller is cleared so the next time models are set we can create a fresh one. epoxyController?.cancelPendingModelBuild() epoxyController = null // We use swapAdapter instead of setAdapter so that the view pool is not cleared. // 'removeAndRecycleExistingViews=true' is used in case this is a nested recyclerview // and we want to recycle the views back to a shared view pool swapAdapter(null, true) } @Px protected fun dpToPx(@Dimension(unit = Dimension.DP) dp: Int): Int { return TypedValue .applyDimension( TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), resources.displayMetrics ).toInt() } @Px protected fun resToPx(@DimenRes itemSpacingRes: Int): Int { return resources.getDimensionPixelOffset(itemSpacingRes) } override fun setAdapter(adapter: RecyclerView.Adapter<*>?) { super.setAdapter(adapter) clearRemovedAdapterAndCancelRunnable() updatePreloaders() } override fun swapAdapter( adapter: RecyclerView.Adapter<*>?, removeAndRecycleExistingViews: Boolean ) { super.swapAdapter(adapter, removeAndRecycleExistingViews) clearRemovedAdapterAndCancelRunnable() updatePreloaders() } public override fun onAttachedToWindow() { super.onAttachedToWindow() if (removedAdapter != null) { // Restore the adapter that was removed when the view was detached from window swapAdapter(removedAdapter, false) } clearRemovedAdapterAndCancelRunnable() } public override fun onDetachedFromWindow() { super.onDetachedFromWindow() preloadScrollListeners.forEach { it.cancelPreloadRequests() } if (removeAdapterWhenDetachedFromWindow) { if (delayMsWhenRemovingAdapterOnDetach > 0) { isRemoveAdapterRunnablePosted = true postDelayed(removeAdapterRunnable, delayMsWhenRemovingAdapterOnDetach.toLong()) } else { removeAdapter() } } clearPoolIfActivityIsDestroyed() } private fun removeAdapter() { val currentAdapter = adapter if (currentAdapter != null) { // Clear the adapter so the adapter releases its reference to this RecyclerView. // Views are recycled so they can return to a view pool (default behavior is to not recycle // them). swapAdapter(null, true) // Keep a reference to the removed adapter so we can add it back if the recyclerview is // attached again. removedAdapter = currentAdapter } // Do this after clearing the adapter, since that sends views back to the pool clearPoolIfActivityIsDestroyed() } private fun clearRemovedAdapterAndCancelRunnable() { removedAdapter = null if (isRemoveAdapterRunnablePosted) { removeCallbacks(removeAdapterRunnable) isRemoveAdapterRunnablePosted = false } } private fun clearPoolIfActivityIsDestroyed() { // Views in the pool hold context references which can keep the activity from being GC'd, // plus they can hold significant memory resources. We should clear it asap after the pool // is no longer needed - the main signal we use for this is that the activity is destroyed. if (context.isActivityDestroyed()) { recycledViewPool.clear() } } companion object { private const val DEFAULT_ADAPTER_REMOVAL_DELAY_MS = 2000 /** * Store one unique pool per activity. They are cleared out when activities are destroyed, so this * only needs to hold pools for active activities. */ private val ACTIVITY_RECYCLER_POOL = ActivityRecyclerPool() } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxySwipeCallback.java ================================================ package com.airbnb.epoxy; import android.graphics.Canvas; import android.view.View; import androidx.recyclerview.widget.ItemTouchHelper; /** * For use with {@link EpoxyModelTouchCallback} */ public interface EpoxySwipeCallback extends BaseEpoxyTouchCallback { /** * Called when the view switches from an idle state to a swiped state, as the user begins a swipe * interaction with it. You can use this callback to modify the view to indicate it is being * swiped. *

* This is the first callback made in the lifecycle of a swipe event. * * @param model The model representing the view that is being swiped * @param itemView The view that is being swiped * @param adapterPosition The adapter position of the model */ void onSwipeStarted(T model, View itemView, int adapterPosition); /** * Once a view has begun swiping with {@link #onSwipeStarted(EpoxyModel, View, int)} it will * receive this callback as the swipe distance changes. This can be called multiple times as the * swipe interaction progresses. * * @param model The model representing the view that is being swiped * @param itemView The view that is being swiped * @param swipeProgress A float from -1 to 1 representing the percentage that the view has been * swiped relative to its width. This will be positive if the view is being * swiped to the right and negative if it is swiped to the left. For * example, * @param canvas The canvas on which RecyclerView is drawing its children. You can draw to * this to support custom swipe animations. */ void onSwipeProgressChanged(T model, View itemView, float swipeProgress, Canvas canvas); /** * Called when the user has released their touch on the view. If the displacement passed the swipe * threshold then {@link #onSwipeCompleted(EpoxyModel, View, int, int)} will be called after this * and the view will be animated off screen. Otherwise the view will animate back to its original * position. * * @param model The model representing the view that was being swiped * @param itemView The view that was being swiped */ void onSwipeReleased(T model, View itemView); /** * Called after {@link #onSwipeReleased(EpoxyModel, View)} if the swipe surpassed the threshold to * be considered a full swipe. The view will now be animated off screen. *

* You MUST use this callback to remove this item from your backing data and request a model * update. *

* {@link #clearView(EpoxyModel, View)} will be called after this. * * @param model The model representing the view that was being swiped * @param itemView The view that was being swiped * @param position The adapter position of the model * @param direction The direction that the view was swiped. Can be any of {@link * ItemTouchHelper#LEFT}, {@link ItemTouchHelper#RIGHT}, {@link * ItemTouchHelper#UP}, {@link ItemTouchHelper#DOWN} depending on what swipe * directions were enabled. */ void onSwipeCompleted(T model, View itemView, int position, int direction); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyTouchHelper.java ================================================ package com.airbnb.epoxy; import android.graphics.Canvas; import android.view.View; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags; /** * A simple way to set up drag or swipe interactions with Epoxy. *

* Drag events work with the EpoxyController and automatically update the controller and * RecyclerView when an item is moved. You just need to implement a callback to update your data to * reflect the change. *

* Both swipe and drag events implement a small lifecycle to help you style the views as they are * moved. You can register callbacks for the lifecycle events you care about. *

* If you want to set up multiple drag and swipe rules for the same RecyclerView, you can use this * class multiple times to specify different targets or swipe and drag directions and callbacks. *

* If you want more control over configuration and handling, you can opt to not use this class and * instead you can implement {@link EpoxyModelTouchCallback} directly with your own {@link * ItemTouchHelper}. That class provides an interface that makes it easier to work with Epoxy models * and simplifies touch callbacks. *

* If you want even more control you can implement {@link EpoxyTouchHelperCallback}. This is just a * light layer over the normal RecyclerView touch callbacks, but it converts all view holders to * Epoxy view holders to remove some boilerplate for you. */ public abstract class EpoxyTouchHelper { /** * The entry point for setting up drag support. * * @param controller The EpoxyController with the models that will be dragged. The controller will * be updated for you when a model is dragged and moved by a user's touch * interaction. */ public static DragBuilder initDragging(EpoxyController controller) { return new DragBuilder(controller); } public static class DragBuilder { private final EpoxyController controller; private DragBuilder(EpoxyController controller) { this.controller = controller; } /** * The recyclerview that the EpoxyController has its adapter added to. An {@link * androidx.recyclerview.widget.ItemTouchHelper} will be created and configured for you, and * attached to this RecyclerView. */ public DragBuilder2 withRecyclerView(RecyclerView recyclerView) { return new DragBuilder2(controller, recyclerView); } } public static class DragBuilder2 { private final EpoxyController controller; private final RecyclerView recyclerView; private DragBuilder2(EpoxyController controller, RecyclerView recyclerView) { this.controller = controller; this.recyclerView = recyclerView; } /** Enable dragging vertically, up and down. */ public DragBuilder3 forVerticalList() { return withDirections(ItemTouchHelper.UP | ItemTouchHelper.DOWN); } /** Enable dragging horizontally, left and right. */ public DragBuilder3 forHorizontalList() { return withDirections(ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); } /** Enable dragging in all directions. */ public DragBuilder3 forGrid() { return withDirections(ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); } /** * Set custom movement flags to dictate which drag directions should be allowed. *

* Can be any of {@link ItemTouchHelper#LEFT}, {@link ItemTouchHelper#RIGHT}, {@link * ItemTouchHelper#UP}, {@link ItemTouchHelper#DOWN}, {@link ItemTouchHelper#START}, {@link * ItemTouchHelper#END} *

* Flags can be OR'd together to allow multiple directions. */ public DragBuilder3 withDirections(int directionFlags) { return new DragBuilder3(controller, recyclerView, makeMovementFlags(directionFlags, 0)); } } public static class DragBuilder3 { private final EpoxyController controller; private final RecyclerView recyclerView; private final int movementFlags; private DragBuilder3(EpoxyController controller, RecyclerView recyclerView, int movementFlags) { this.controller = controller; this.recyclerView = recyclerView; this.movementFlags = movementFlags; } /** * Set the type of Epoxy model that is draggable. This approach works well if you only have one * draggable type. */ public DragBuilder4 withTarget(Class targetModelClass) { List> targetClasses = new ArrayList<>(1); targetClasses.add(targetModelClass); return new DragBuilder4<>(controller, recyclerView, movementFlags, targetModelClass, targetClasses); } /** * Specify which Epoxy model types are draggable. Use this if you have more than one type that * is draggable. *

* If you only have one draggable type you should use {@link #withTarget(Class)} */ public DragBuilder4 withTargets(Class... targetModelClasses) { return new DragBuilder4<>(controller, recyclerView, movementFlags, EpoxyModel.class, Arrays.asList(targetModelClasses)); } /** * Use this if all models in the controller should be draggable, and if there are multiple types * of models in the controller. *

* If you only have one model type you should use {@link #withTarget(Class)} */ public DragBuilder4 forAllModels() { return withTarget(EpoxyModel.class); } } public static class DragBuilder4 { private final EpoxyController controller; private final RecyclerView recyclerView; private final int movementFlags; private final Class targetModelClass; private final List> targetModelClasses; private DragBuilder4(EpoxyController controller, RecyclerView recyclerView, int movementFlags, Class targetModelClass, List> targetModelClasses) { this.controller = controller; this.recyclerView = recyclerView; this.movementFlags = movementFlags; this.targetModelClass = targetModelClass; this.targetModelClasses = targetModelClasses; } /** * Set callbacks to handle drag actions and lifecycle events. *

* You MUST implement {@link DragCallbacks#onModelMoved(int, int, EpoxyModel, * View)} to update your data to reflect an item move. *

* You can optionally implement the other callbacks to modify the view being dragged. This is * useful if you want to change things like the view background, size, color, etc * * @return An {@link ItemTouchHelper} instance that has been initialized and attached to a * recyclerview. The touch helper has already been fully set up and can be ignored, but you may * want to hold a reference to it if you need to later detach the recyclerview to disable touch * events via setting null on {@link ItemTouchHelper#attachToRecyclerView(RecyclerView)} */ public ItemTouchHelper andCallbacks(final DragCallbacks callbacks) { ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new EpoxyModelTouchCallback(controller, targetModelClass) { @Override public int getMovementFlagsForModel(U model, int adapterPosition) { return movementFlags; } @Override protected boolean isTouchableModel(EpoxyModel model) { boolean isTargetType = targetModelClasses.size() == 1 ? super.isTouchableModel(model) : targetModelClasses.contains(model.getClass()); //noinspection unchecked return isTargetType && callbacks.isDragEnabledForModel((U) model); } @Override public void onDragStarted(U model, View itemView, int adapterPosition) { callbacks.onDragStarted(model, itemView, adapterPosition); } @Override public void onDragReleased(U model, View itemView) { callbacks.onDragReleased(model, itemView); } @Override public void onModelMoved(int fromPosition, int toPosition, U modelBeingMoved, View itemView) { callbacks.onModelMoved(fromPosition, toPosition, modelBeingMoved, itemView); } @Override public void clearView(U model, View itemView) { callbacks.clearView(model, itemView); } }); itemTouchHelper.attachToRecyclerView(recyclerView); return itemTouchHelper; } } public abstract static class DragCallbacks implements EpoxyDragCallback { @Override public void onDragStarted(T model, View itemView, int adapterPosition) { } @Override public void onDragReleased(T model, View itemView) { } @Override public abstract void onModelMoved(int fromPosition, int toPosition, T modelBeingMoved, View itemView); @Override public void clearView(T model, View itemView) { } /** * Whether the given model should be draggable. *

* True by default. You may override this to toggle draggability for a model. */ public boolean isDragEnabledForModel(T model) { return true; } @Override public final int getMovementFlagsForModel(T model, int adapterPosition) { // No-Op this is not used return 0; } } /** * The entry point for setting up swipe support for a RecyclerView. The RecyclerView must be set * with an Epoxy adapter or controller. */ public static SwipeBuilder initSwiping(RecyclerView recyclerView) { return new SwipeBuilder(recyclerView); } public static class SwipeBuilder { private final RecyclerView recyclerView; private SwipeBuilder(RecyclerView recyclerView) { this.recyclerView = recyclerView; } /** Enable swiping right. */ public SwipeBuilder2 right() { return withDirections(ItemTouchHelper.RIGHT); } /** Enable swiping left. */ public SwipeBuilder2 left() { return withDirections(ItemTouchHelper.LEFT); } /** Enable swiping horizontally, left and right. */ public SwipeBuilder2 leftAndRight() { return withDirections(ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT); } /** * Set custom movement flags to dictate which swipe directions should be allowed. *

* Can be any of {@link ItemTouchHelper#LEFT}, {@link ItemTouchHelper#RIGHT}, {@link * ItemTouchHelper#UP}, {@link ItemTouchHelper#DOWN}, {@link ItemTouchHelper#START}, {@link * ItemTouchHelper#END} *

* Flags can be OR'd together to allow multiple directions. */ public SwipeBuilder2 withDirections(int directionFlags) { return new SwipeBuilder2(recyclerView, makeMovementFlags(0, directionFlags)); } } public static class SwipeBuilder2 { private final RecyclerView recyclerView; private final int movementFlags; private SwipeBuilder2(RecyclerView recyclerView, int movementFlags) { this.recyclerView = recyclerView; this.movementFlags = movementFlags; } /** * Set the type of Epoxy model that is swipable. Use this if you only have one * swipable type. */ public SwipeBuilder3 withTarget(Class targetModelClass) { List> targetClasses = new ArrayList<>(1); targetClasses.add(targetModelClass); return new SwipeBuilder3<>(recyclerView, movementFlags, targetModelClass, targetClasses); } /** * Specify which Epoxy model types are swipable. Use this if you have more than one type that * is swipable. *

* If you only have one swipable type you should use {@link #withTarget(Class)} */ public SwipeBuilder3 withTargets( Class... targetModelClasses) { return new SwipeBuilder3<>(recyclerView, movementFlags, EpoxyModel.class, Arrays.asList(targetModelClasses)); } /** * Use this if all models in the controller should be swipable, and if there are multiple types * of models in the controller. *

* If you only have one model type you should use {@link #withTarget(Class)} */ public SwipeBuilder3 forAllModels() { return withTarget(EpoxyModel.class); } } public static class SwipeBuilder3 { private final RecyclerView recyclerView; private final int movementFlags; private final Class targetModelClass; private final List> targetModelClasses; private SwipeBuilder3( RecyclerView recyclerView, int movementFlags, Class targetModelClass, List> targetModelClasses) { this.recyclerView = recyclerView; this.movementFlags = movementFlags; this.targetModelClass = targetModelClass; this.targetModelClasses = targetModelClasses; } /** * Set callbacks to handle swipe actions and lifecycle events. *

* You MUST implement {@link SwipeCallbacks#onSwipeCompleted(EpoxyModel, View, int, int)} to * remove the swiped item from your data and request a model build. *

* You can optionally implement the other callbacks to modify the view as it is being swiped. * * @return An {@link ItemTouchHelper} instance that has been initialized and attached to a * recyclerview. The touch helper has already been fully set up and can be ignored, but you may * want to hold a reference to it if you need to later detach the recyclerview to disable touch * events via setting null on {@link ItemTouchHelper#attachToRecyclerView(RecyclerView)} */ public ItemTouchHelper andCallbacks(final SwipeCallbacks callbacks) { ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new EpoxyModelTouchCallback(null, targetModelClass) { @Override public int getMovementFlagsForModel(U model, int adapterPosition) { return movementFlags; } @Override protected boolean isTouchableModel(EpoxyModel model) { boolean isTargetType = targetModelClasses.size() == 1 ? super.isTouchableModel(model) : targetModelClasses.contains(model.getClass()); //noinspection unchecked return isTargetType && callbacks.isSwipeEnabledForModel((U) model); } @Override public void onSwipeStarted(U model, View itemView, int adapterPosition) { callbacks.onSwipeStarted(model, itemView, adapterPosition); } @Override public void onSwipeProgressChanged(U model, View itemView, float swipeProgress, Canvas canvas) { callbacks.onSwipeProgressChanged(model, itemView, swipeProgress, canvas); } @Override public void onSwipeCompleted(U model, View itemView, int position, int direction) { callbacks.onSwipeCompleted(model, itemView, position, direction); } @Override public void onSwipeReleased(U model, View itemView) { callbacks.onSwipeReleased(model, itemView); } @Override public void clearView(U model, View itemView) { callbacks.clearView(model, itemView); } }); itemTouchHelper.attachToRecyclerView(recyclerView); return itemTouchHelper; } } public abstract static class SwipeCallbacks implements EpoxySwipeCallback { @Override public void onSwipeStarted(T model, View itemView, int adapterPosition) { } @Override public void onSwipeProgressChanged(T model, View itemView, float swipeProgress, Canvas canvas) { } @Override public abstract void onSwipeCompleted(T model, View itemView, int position, int direction); @Override public void onSwipeReleased(T model, View itemView) { } @Override public void clearView(T model, View itemView) { } /** * Whether the given model should be swipable. *

* True by default. You may override this to toggle swipabaility for a model. */ public boolean isSwipeEnabledForModel(T model) { return true; } @Override public final int getMovementFlagsForModel(T model, int adapterPosition) { // Not used return 0; } } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyTouchHelperCallback.kt ================================================ package com.airbnb.epoxy import android.graphics.Canvas import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder /** * A wrapper around [androidx.recyclerview.widget.ItemTouchHelper.Callback] to cast all * view holders to [com.airbnb.epoxy.EpoxyViewHolder] for simpler use with Epoxy. */ abstract class EpoxyTouchHelperCallback : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int = getMovementFlags(recyclerView, viewHolder as EpoxyViewHolder) /** * @see getMovementFlags */ protected abstract fun getMovementFlags( recyclerView: RecyclerView, viewHolder: EpoxyViewHolder ): Int override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean = onMove(recyclerView, viewHolder as EpoxyViewHolder, target as EpoxyViewHolder) /** * @see onMove */ protected abstract fun onMove( recyclerView: RecyclerView, viewHolder: EpoxyViewHolder, target: EpoxyViewHolder ): Boolean override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int): Unit = onSwiped(viewHolder as EpoxyViewHolder, direction) /** * @see onSwiped */ protected abstract fun onSwiped(viewHolder: EpoxyViewHolder, direction: Int) override fun canDropOver( recyclerView: RecyclerView, current: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean = canDropOver(recyclerView, current as EpoxyViewHolder, target as EpoxyViewHolder) /** * @see canDropOver */ protected open fun canDropOver( recyclerView: RecyclerView, current: EpoxyViewHolder, target: EpoxyViewHolder ): Boolean = super.canDropOver(recyclerView, current, target) override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float = getSwipeThreshold(viewHolder as EpoxyViewHolder) /** * @see getSwipeThreshold */ protected open fun getSwipeThreshold(viewHolder: EpoxyViewHolder): Float = super.getSwipeThreshold(viewHolder) override fun getMoveThreshold(viewHolder: RecyclerView.ViewHolder): Float = getMoveThreshold(viewHolder as EpoxyViewHolder) /** * @see getMoveThreshold */ protected fun getMoveThreshold(viewHolder: EpoxyViewHolder): Float = super.getMoveThreshold(viewHolder) @Suppress("UNCHECKED_CAST") override fun chooseDropTarget( selected: RecyclerView.ViewHolder, dropTargets: List, curX: Int, curY: Int ): EpoxyViewHolder? = chooseDropTarget( selected as EpoxyViewHolder, dropTargets as List, curX, curY ) /** * @see chooseDropTarget */ protected fun chooseDropTarget( selected: EpoxyViewHolder, dropTargets: List, curX: Int, curY: Int ): EpoxyViewHolder? = super.chooseDropTarget(selected, dropTargets, curX, curY) as? EpoxyViewHolder override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int): Unit = onSelectedChanged(viewHolder as EpoxyViewHolder?, actionState) /** * @see onSelectedChanged */ protected open fun onSelectedChanged(viewHolder: EpoxyViewHolder?, actionState: Int): Unit = super.onSelectedChanged(viewHolder, actionState) override fun onMoved( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, fromPos: Int, target: RecyclerView.ViewHolder, toPos: Int, x: Int, y: Int ): Unit = onMoved( recyclerView, viewHolder as EpoxyViewHolder, fromPos, target as EpoxyViewHolder, toPos, x, y ) /** * @see onMoved */ protected fun onMoved( recyclerView: RecyclerView, viewHolder: EpoxyViewHolder, fromPos: Int, target: EpoxyViewHolder, toPos: Int, x: Int, y: Int ): Unit = super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Unit = clearView(recyclerView, viewHolder as EpoxyViewHolder) /** * @see clearView */ protected open fun clearView(recyclerView: RecyclerView, viewHolder: EpoxyViewHolder): Unit = super.clearView(recyclerView, viewHolder) override fun onChildDraw( c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean ): Unit = onChildDraw( c, recyclerView, viewHolder as EpoxyViewHolder, dX, dY, actionState, isCurrentlyActive ) /** * @see onChildDraw */ protected open fun onChildDraw( c: Canvas, recyclerView: RecyclerView, viewHolder: EpoxyViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean ): Unit = super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) override fun onChildDrawOver( c: Canvas, recyclerView: RecyclerView, viewHolder: ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean ) { onChildDrawOver( c, recyclerView, viewHolder as? EpoxyViewHolder, dX, dY, actionState, isCurrentlyActive ) } /** * @see onChildDrawOver */ protected fun onChildDrawOver( c: Canvas, recyclerView: RecyclerView, viewHolder: EpoxyViewHolder?, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean ): Unit = super.onChildDrawOver( c, recyclerView, viewHolder as ViewHolder, dX, dY, actionState, isCurrentlyActive ) } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyViewHolder.java ================================================ package com.airbnb.epoxy; import android.view.View; import android.view.ViewParent; import com.airbnb.epoxy.ViewHolderState.ViewState; import com.airbnb.epoxy.VisibilityState.Visibility; import java.util.List; import androidx.annotation.FloatRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.recyclerview.widget.RecyclerView; @SuppressWarnings("WeakerAccess") public class EpoxyViewHolder extends RecyclerView.ViewHolder { @SuppressWarnings("rawtypes") private EpoxyModel epoxyModel; private List payloads; private EpoxyHolder epoxyHolder; @Nullable ViewHolderState.ViewState initialViewState; // Once the EpoxyHolder is created parent will be set to null. private ViewParent parent; public EpoxyViewHolder(ViewParent parent, View view, boolean saveInitialState) { super(view); this.parent = parent; if (saveInitialState) { // We save the initial state of the view when it is created so that we can reset this initial // state before a model is bound for the first time. Otherwise the view may carry over // state from a previously bound model. initialViewState = new ViewState(); initialViewState.save(itemView); } } void restoreInitialViewState() { if (initialViewState != null) { initialViewState.restore(itemView); } } public void bind(@SuppressWarnings("rawtypes") EpoxyModel model, @Nullable EpoxyModel previouslyBoundModel, List payloads, int position) { this.payloads = payloads; if (epoxyHolder == null && model instanceof EpoxyModelWithHolder) { epoxyHolder = ((EpoxyModelWithHolder) model).createNewHolder(parent); epoxyHolder.bindView(itemView); } // Safe to set to null as it is only used for createNewHolder method parent = null; if (model instanceof GeneratedModel) { // The generated method will enforce that only a properly typed listener can be set //noinspection unchecked ((GeneratedModel) model).handlePreBind(this, objectToBind(), position); } // noinspection unchecked model.preBind(objectToBind(), previouslyBoundModel); if (previouslyBoundModel != null) { // noinspection unchecked model.bind(objectToBind(), previouslyBoundModel); } else if (payloads.isEmpty()) { // noinspection unchecked model.bind(objectToBind()); } else { // noinspection unchecked model.bind(objectToBind(), payloads); } if (model instanceof GeneratedModel) { // The generated method will enforce that only a properly typed listener can be set //noinspection unchecked ((GeneratedModel) model).handlePostBind(objectToBind(), position); } epoxyModel = model; } @NonNull Object objectToBind() { return epoxyHolder != null ? epoxyHolder : itemView; } public void unbind() { assertBound(); // noinspection unchecked epoxyModel.unbind(objectToBind()); epoxyModel = null; payloads = null; } public void visibilityStateChanged(@Visibility int visibilityState) { assertBound(); // noinspection unchecked epoxyModel.onVisibilityStateChanged(visibilityState, objectToBind()); } public void visibilityChanged( @FloatRange(from = 0.0f, to = 100.0f) float percentVisibleHeight, @FloatRange(from = 0.0f, to = 100.0f) float percentVisibleWidth, @Px int visibleHeight, @Px int visibleWidth ) { assertBound(); // noinspection unchecked epoxyModel.onVisibilityChanged(percentVisibleHeight, percentVisibleWidth, visibleHeight, visibleWidth, objectToBind()); } public List getPayloads() { assertBound(); return payloads; } public EpoxyModel getModel() { assertBound(); return epoxyModel; } public EpoxyHolder getHolder() { assertBound(); return epoxyHolder; } private void assertBound() { if (epoxyModel == null) { throw new IllegalStateException("This holder is not currently bound."); } } @Override public String toString() { return "EpoxyViewHolder{" + "epoxyModel=" + epoxyModel + ", view=" + itemView + ", super=" + super.toString() + '}'; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityItem.kt ================================================ package com.airbnb.epoxy import android.graphics.Rect import android.view.View import android.view.ViewGroup import androidx.annotation.IntRange import androidx.annotation.Px import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.RecyclerView /** * This class represent an item in a [android.view.ViewGroup] and it is * being reused with multiple model via the update method. There is 1:1 relationship between an * EpoxyVisibilityItem and a child within the [android.view.ViewGroup]. * * It contains the logic to compute the visibility state of an item. It will also invoke the * visibility callbacks on [com.airbnb.epoxy.EpoxyViewHolder] * * This class should remain non-public and is intended to be used by [EpoxyVisibilityTracker] * only. */ @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) class EpoxyVisibilityItem(adapterPosition: Int? = null) { private val localVisibleRect = Rect() var adapterPosition = RecyclerView.NO_POSITION private set @Px private var height = 0 @Px private var width = 0 @Px private var visibleHeight = 0 @Px private var visibleWidth = 0 @Px private var viewportHeight = 0 @Px private var viewportWidth = 0 private var partiallyVisible = false private var fullyVisible = false private var visible = false private var focusedVisible = false private var viewVisibility = View.GONE /** Store last value for de-duping */ private var lastVisibleHeightNotified: Int? = null private var lastVisibleWidthNotified: Int? = null private var lastVisibilityNotified: Int? = null init { adapterPosition?.let { reset(it) } } /** * Update the visibility item according the current layout. * * @param view the current [com.airbnb.epoxy.EpoxyViewHolder]'s itemView * @param parent the [android.view.ViewGroup] * @return true if the view has been measured */ fun update(view: View, parent: ViewGroup, detachEvent: Boolean): Boolean { // Clear the rect before calling getLocalVisibleRect localVisibleRect.setEmpty() val viewDrawn = view.getLocalVisibleRect(localVisibleRect) && !detachEvent height = view.height width = view.width viewportHeight = parent.height viewportWidth = parent.width visibleHeight = if (viewDrawn) localVisibleRect.height() else 0 visibleWidth = if (viewDrawn) localVisibleRect.width() else 0 viewVisibility = view.visibility return height > 0 && width > 0 } fun reset(newAdapterPosition: Int) { fullyVisible = false visible = false focusedVisible = false adapterPosition = newAdapterPosition lastVisibleHeightNotified = null lastVisibleWidthNotified = null lastVisibilityNotified = null } fun handleVisible(epoxyHolder: EpoxyViewHolder, detachEvent: Boolean) { val previousVisible = visible visible = !detachEvent && isVisible() if (visible != previousVisible) { if (visible) { epoxyHolder.visibilityStateChanged(VisibilityState.VISIBLE) } else { epoxyHolder.visibilityStateChanged(VisibilityState.INVISIBLE) } } } fun handleFocus(epoxyHolder: EpoxyViewHolder, detachEvent: Boolean) { val previousFocusedVisible = focusedVisible focusedVisible = !detachEvent && isInFocusVisible() if (focusedVisible != previousFocusedVisible) { if (focusedVisible) { epoxyHolder.visibilityStateChanged(VisibilityState.FOCUSED_VISIBLE) } else { epoxyHolder.visibilityStateChanged(VisibilityState.UNFOCUSED_VISIBLE) } } } fun handlePartialImpressionVisible( epoxyHolder: EpoxyViewHolder, detachEvent: Boolean, @IntRange(from = 0, to = 100) thresholdPercentage: Int ) { val previousPartiallyVisible = partiallyVisible partiallyVisible = !detachEvent && isPartiallyVisible(thresholdPercentage) if (partiallyVisible != previousPartiallyVisible) { if (partiallyVisible) { epoxyHolder.visibilityStateChanged(VisibilityState.PARTIAL_IMPRESSION_VISIBLE) } else { epoxyHolder.visibilityStateChanged(VisibilityState.PARTIAL_IMPRESSION_INVISIBLE) } } } fun handleFullImpressionVisible(epoxyHolder: EpoxyViewHolder, detachEvent: Boolean) { val previousFullyVisible = fullyVisible fullyVisible = !detachEvent && isFullyVisible() if (fullyVisible != previousFullyVisible) { if (fullyVisible) { epoxyHolder.visibilityStateChanged(VisibilityState.FULL_IMPRESSION_VISIBLE) } } } fun handleChanged(epoxyHolder: EpoxyViewHolder, visibilityChangedEnabled: Boolean): Boolean { var changed = false if (visibleHeight != lastVisibleHeightNotified || visibleWidth != lastVisibleWidthNotified || viewVisibility != lastVisibilityNotified) { if (visibilityChangedEnabled) { if (viewVisibility == View.GONE) { epoxyHolder.visibilityChanged(0f, 0f, 0, 0) } else { epoxyHolder.visibilityChanged( 100f / height * visibleHeight, 100f / width * visibleWidth, visibleHeight, visibleWidth ) } } lastVisibleHeightNotified = visibleHeight lastVisibleWidthNotified = visibleWidth lastVisibilityNotified = viewVisibility changed = true } return changed } private fun isVisible(): Boolean { return viewVisibility == View.VISIBLE && visibleHeight > 0 && visibleWidth > 0 } private fun isInFocusVisible(): Boolean { val halfViewportArea = viewportHeight * viewportWidth / 2 val totalArea = height * width val visibleArea = visibleHeight * visibleWidth // The model has entered the focused range either if it is larger than half of the viewport // and it occupies at least half of the viewport or if it is smaller than half of the viewport // and it is fully visible. return viewVisibility == View.VISIBLE && if (totalArea >= halfViewportArea) visibleArea >= halfViewportArea else totalArea == visibleArea } private fun isPartiallyVisible( @IntRange( from = 0, to = 100 ) thresholdPercentage: Int ): Boolean { // special case 0%: trigger as soon as some pixels are one the screen if (thresholdPercentage == 0) return isVisible() val totalArea = height * width val visibleArea = visibleHeight * visibleWidth val visibleAreaPercentage = visibleArea / totalArea.toFloat() * 100 return viewVisibility == View.VISIBLE && visibleAreaPercentage >= thresholdPercentage } private fun isFullyVisible(): Boolean { return viewVisibility == View.VISIBLE && visibleHeight == height && visibleWidth == width } fun shiftBy(offsetPosition: Int) { adapterPosition += offsetPosition } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.kt ================================================ package com.airbnb.epoxy import android.util.Log import android.util.SparseArray import android.view.View import androidx.annotation.IdRes import androidx.annotation.IntRange import androidx.recyclerview.widget.RecyclerView import com.airbnb.viewmodeladapter.R import java.util.ArrayList import java.util.HashMap /** * A simple way to track visibility events on [com.airbnb.epoxy.EpoxyModel]. * * [EpoxyVisibilityTracker] works with any [androidx.recyclerview.widget.RecyclerView] * backed by an Epoxy controller. Once attached the events will be forwarded to the Epoxy model (or * to the Epoxy view when using annotations). * * Note that support for visibility events on an [EpoxyModelGroup] is somewhat limited. Only model * additions will receive visibility events. Models that are removed from the group will not receive * events (e.g. [VisibilityState.INVISIBLE]) because the model group does not keep a reference, * nor does it get notified of model removals. * * @see OnVisibilityChanged * * @see OnVisibilityStateChanged * * @see OnModelVisibilityChangedListener * * @see OnModelVisibilityStateChangedListener */ open class EpoxyVisibilityTracker { /** * Used to listen to [RecyclerView.ItemAnimator] ending animations. */ private val itemAnimatorFinishedListener = RecyclerView.ItemAnimator.ItemAnimatorFinishedListener { processChangeEvent( "ItemAnimatorFinishedListener.onAnimationsFinished", /* don't check item animator to prevent recursion */ false ) } /** Maintain visibility item indexed by view id (identity hashcode) */ private val visibilityIdToItemMap = SparseArray() private val visibilityIdToItems: MutableList = ArrayList() /** listener used to process scroll, layout and attach events */ private val listener = Listener() /** listener used to process data events */ private val observer = DataObserver() private var attachedRecyclerView: RecyclerView? = null private var lastAdapterSeen: RecyclerView.Adapter<*>? = null /** All nested visibility trackers */ private val nestedTrackers: MutableMap = HashMap() /** This flag is for optimizing the process on detach. If detach is from data changed then it * need to re-process all views, else no need (ex: scroll). */ private var visibleDataChanged = false /** * Enable or disable visibility changed event. Default is `true`, disable it if you don't need * (triggered by every pixel scrolled). * * @see OnVisibilityChanged * * @see OnModelVisibilityChangedListener */ var onChangedEnabled = true /** * Set the threshold of percentage visible area to identify the partial impression view state. * * @param thresholdPercentage Percentage of visible area of an element in the range [0..100]. * Defaults to `null`, which disables * [VisibilityState.PARTIAL_IMPRESSION_VISIBLE] and * [VisibilityState.PARTIAL_IMPRESSION_INVISIBLE] events. */ @IntRange(from = 0, to = 100) var partialImpressionThresholdPercentage: Int? = null /** * Attach the tracker. * * @param recyclerView The recyclerview that the EpoxyController has its adapter added to. */ open fun attach(recyclerView: RecyclerView) { attachedRecyclerView = recyclerView recyclerView.addOnScrollListener(listener) recyclerView.addOnLayoutChangeListener(listener) recyclerView.addOnChildAttachStateChangeListener(listener) setTracker(recyclerView, this) } /** * Detach the tracker * * @param recyclerView The recycler view that the EpoxyController has its adapter added to. */ open fun detach(recyclerView: RecyclerView) { recyclerView.removeOnScrollListener(listener) recyclerView.removeOnLayoutChangeListener(listener) recyclerView.removeOnChildAttachStateChangeListener(listener) setTracker(recyclerView, null) attachedRecyclerView = null } /** * The tracker is storing visibility states internally and is using if to send events, only the * difference is sent. Use this method to clear the states and thus regenerate the visibility * events. This may be useful when you change the adapter on the [RecyclerView]. */ fun clearVisibilityStates() { // Clear our visibility items visibilityIdToItemMap.clear() visibilityIdToItems.clear() } /** * Calling this method will make the visibility tracking check and trigger events if necessary. It * is particularly useful when the visibility of an Epoxy model is changed outside of an Epoxy * RecyclerView. * * An example is when you nest an horizontal Epoxy backed RecyclerView in a non Epoxy vertical * RecyclerView. When the vertical RecyclerView scroll you want to notify the visibility tracker * attached on the horizontal RecyclerView. */ fun requestVisibilityCheck() { processChangeEvent("requestVisibilityCheck") } /** * Process a change event. * @param debug: string for debug usually the source of the call * @param checkItemAnimator: true if it need to check if ItemAnimator is running */ private fun processChangeEvent(debug: String, checkItemAnimator: Boolean = true) { // Only if attached val recyclerView = attachedRecyclerView ?: return val itemAnimator = recyclerView.itemAnimator if (checkItemAnimator && itemAnimator != null) { // `itemAnimatorFinishedListener.onAnimationsFinished` will process visibility check // - If the animations are running `onAnimationsFinished` will be invoked on animations end. // - If the animations are not running `onAnimationsFinished` will be invoked right away. if (itemAnimator.isRunning(itemAnimatorFinishedListener)) { // If running process visibility now as `onAnimationsFinished` was not yet called processChangeEventWithDetachedView(null, debug) } } else { processChangeEventWithDetachedView(null, debug) } } private fun processChangeEventWithDetachedView(detachedView: View?, debug: String) { // Only if attached val recyclerView = attachedRecyclerView ?: return // On every every events lookup for a new adapter processNewAdapterIfNecessary() // Process the detached child if any detachedView?.let { processChild(it, true, debug) } // Process all attached children for (i in 0 until recyclerView.childCount) { val child = recyclerView.getChildAt(i) if (child != null && child !== detachedView) { // Is some case the detached child is still in the recycler view. Don't process it as it // was already processed. processChild(child, false, debug) } } } /** * If there is a new adapter on the attached RecyclerView it will register the data observer and * clear the current visibility states */ private fun processNewAdapterIfNecessary() { attachedRecyclerView?.adapter?.let { adapter -> if (lastAdapterSeen != adapter) { // Unregister the old adapter lastAdapterSeen?.unregisterAdapterDataObserver(observer) // Register the new adapter adapter.registerAdapterDataObserver(observer) lastAdapterSeen = adapter } } } /** * Don't call this method directly, it is called from * [EpoxyVisibilityTracker.processVisibilityEvents] * * @param child the view to process for visibility event * @param detachEvent true if the child was just detached * @param eventOriginForDebug a debug strings used for logs */ private fun processChild(child: View, detachEvent: Boolean, eventOriginForDebug: String) { // Only if attached val recyclerView = attachedRecyclerView ?: return // Preemptive check for child's parent validity to prevent `IllegalArgumentException` in // `getChildViewHolder`. val isParentValid = child.parent == null || child.parent === recyclerView val viewHolder = if (isParentValid) recyclerView.getChildViewHolder(child) else null if (viewHolder is EpoxyViewHolder) { val epoxyHolder = viewHolder.holder processChild(recyclerView, child, detachEvent, eventOriginForDebug, viewHolder) if (epoxyHolder is ModelGroupHolder) { processModelGroupChildren(recyclerView, epoxyHolder, detachEvent, eventOriginForDebug) } } } /** * Loop through the children of the model group and process visibility events on each one in * relation to the model group's layout. This will attach or detach trackers to any nested * [RecyclerView]s. * * @param epoxyHolder the [ModelGroupHolder] with children to process * @param detachEvent true if the child was just detached * @param eventOriginForDebug a debug strings used for logs */ private fun processModelGroupChildren( recyclerView: RecyclerView, epoxyHolder: ModelGroupHolder, detachEvent: Boolean, eventOriginForDebug: String ) { // Iterate through models in the group and process each of them instead of the group for (groupChildHolder in epoxyHolder.viewHolders) { // Since the group is likely using a ViewGroup other than a RecyclerView, handle the // potential of a nested RecyclerView. This cannot be done through the normal flow // without recursively searching through the view children. if (groupChildHolder.itemView is RecyclerView) { if (detachEvent) { processChildRecyclerViewDetached(groupChildHolder.itemView as RecyclerView) } else { processChildRecyclerViewAttached(groupChildHolder.itemView as RecyclerView) } } processChild( recyclerView, groupChildHolder.itemView, detachEvent, eventOriginForDebug, groupChildHolder ) } } /** * Process visibility events for a view and propagate to a nested tracker if the view is a * [RecyclerView]. * * @param child the view to process for visibility event * @param detachEvent true if the child was just detached * @param eventOriginForDebug a debug strings used for logs * @param viewHolder the view holder for the child view */ private fun processChild( recyclerView: RecyclerView, child: View, detachEvent: Boolean, eventOriginForDebug: String, viewHolder: EpoxyViewHolder ) { val changed = processVisibilityEvents( recyclerView, viewHolder, detachEvent, eventOriginForDebug ) if (changed && child is RecyclerView) { nestedTrackers[child]?.processChangeEvent("parent") } } /** * Call this methods every time something related to ui (scroll, layout, ...) or something related * to data changed. * * @param recyclerView the recycler view * @param epoxyHolder the [RecyclerView] * @param detachEvent true if the event originated from a view detached from the * recycler view * @param eventOriginForDebug a debug strings used for logs * @return true if changed */ private fun processVisibilityEvents( recyclerView: RecyclerView, epoxyHolder: EpoxyViewHolder, detachEvent: Boolean, eventOriginForDebug: String ): Boolean { if (DEBUG_LOG) { Log.d( TAG, "$eventOriginForDebug.processVisibilityEvents " + "${System.identityHashCode(epoxyHolder)}, " + "$detachEvent, ${epoxyHolder.adapterPosition}" ) } val itemView = epoxyHolder.itemView val id = System.identityHashCode(itemView) var vi = visibilityIdToItemMap[id] if (vi == null) { // New view discovered, assign an EpoxyVisibilityItem vi = EpoxyVisibilityItem(epoxyHolder.adapterPosition) visibilityIdToItemMap.put(id, vi) visibilityIdToItems.add(vi) } else if (epoxyHolder.adapterPosition != RecyclerView.NO_POSITION && vi.adapterPosition != epoxyHolder.adapterPosition ) { // EpoxyVisibilityItem being re-used for a different adapter position vi.reset(epoxyHolder.adapterPosition) } var changed = false if (vi.update(itemView, recyclerView, detachEvent)) { // View is measured, process events vi.handleVisible(epoxyHolder, detachEvent) partialImpressionThresholdPercentage?.let { percentage -> vi.handlePartialImpressionVisible( epoxyHolder, detachEvent, percentage ) } vi.handleFocus(epoxyHolder, detachEvent) vi.handleFullImpressionVisible(epoxyHolder, detachEvent) changed = vi.handleChanged(epoxyHolder, onChangedEnabled) } return changed } private fun processChildRecyclerViewAttached(childRecyclerView: RecyclerView) { // Register itself in the EpoxyVisibilityTracker. This will take care of nested list // tracking (ex: carousel) val tracker = getTracker(childRecyclerView) ?: EpoxyVisibilityTracker().let { nested -> nested.partialImpressionThresholdPercentage = partialImpressionThresholdPercentage nested.attach(childRecyclerView) nested } nestedTrackers[childRecyclerView] = tracker } private fun processChildRecyclerViewDetached(childRecyclerView: RecyclerView) { nestedTrackers.remove(childRecyclerView) } /** * Helper class that host the [androidx.recyclerview.widget.RecyclerView] listener * implementations */ private inner class Listener : RecyclerView.OnScrollListener(), View.OnLayoutChangeListener, RecyclerView.OnChildAttachStateChangeListener { override fun onLayoutChange( recyclerView: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int ) { processChangeEvent("onLayoutChange") } override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { processChangeEvent("onScrolled") } override fun onChildViewAttachedToWindow(child: View) { if (child is RecyclerView) { processChildRecyclerViewAttached(child) } processChild(child, false, "onChildViewAttachedToWindow") } override fun onChildViewDetachedFromWindow(child: View) { if (child is RecyclerView) { processChildRecyclerViewDetached(child) } if (visibleDataChanged) { // On detach event caused by data set changed we need to re-process all children because // the removal caused the others views to changes. processChangeEventWithDetachedView(child, "onChildViewDetachedFromWindow") visibleDataChanged = false } else { processChild(child, true, "onChildViewDetachedFromWindow") } } } /** * The layout/scroll events are not enough to detect all sort of visibility changes. We also * need to look at the data events from the adapter. */ internal inner class DataObserver : RecyclerView.AdapterDataObserver() { /** * Clear the current visibility statues */ override fun onChanged() { if (notEpoxyManaged(attachedRecyclerView)) { return } if (DEBUG_LOG) { Log.d(TAG, "onChanged()") } visibilityIdToItemMap.clear() visibilityIdToItems.clear() visibleDataChanged = true } /** * For all items after the inserted range shift each [EpoxyVisibilityTracker] adapter * position by inserted item count. */ override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { if (notEpoxyManaged(attachedRecyclerView)) { return } if (DEBUG_LOG) { Log.d(TAG, "onItemRangeInserted($positionStart, $itemCount)") } for (item in visibilityIdToItems) { if (item.adapterPosition >= positionStart) { visibleDataChanged = true item.shiftBy(itemCount) } } } /** * For all items after the removed range reverse-shift each [EpoxyVisibilityTracker] * adapter position by removed item count */ override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { if (notEpoxyManaged(attachedRecyclerView)) { return } if (DEBUG_LOG) { Log.d(TAG, "onItemRangeRemoved($positionStart, $itemCount)") } for (item in visibilityIdToItems) { if (item.adapterPosition >= positionStart) { visibleDataChanged = true item.shiftBy(-itemCount) } } } /** * This is a bit more complex, for move we need to first swap the moved position then shift the * items between the swap. To simplify we split any range passed to individual item moved. * * ps: anyway [androidx.recyclerview.widget.AdapterListUpdateCallback] * does not seem to use range for moved items. */ override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { if (notEpoxyManaged(attachedRecyclerView)) { return } for (i in 0 until itemCount) { onItemMoved(fromPosition + i, toPosition + i) } } private fun onItemMoved(fromPosition: Int, toPosition: Int) { if (notEpoxyManaged(attachedRecyclerView)) { return } if (DEBUG_LOG) { Log.d(TAG, "onItemRangeMoved($fromPosition, $fromPosition, 1)") } for (item in visibilityIdToItems) { val position = item.adapterPosition if (position == fromPosition) { // We found the item to be moved, just swap the position. item.shiftBy(toPosition - fromPosition) visibleDataChanged = true } else if (fromPosition < toPosition) { // Item will be moved down in the list if (position in (fromPosition + 1)..toPosition) { // Item is between the moved from and to indexes, it should move up item.shiftBy(-1) visibleDataChanged = true } } else if (fromPosition > toPosition) { // Item will be moved up in the list if (position in toPosition until fromPosition) { // Item is between the moved to and from indexes, it should move down item.shiftBy(1) visibleDataChanged = true } } } } /** * @param recyclerView the recycler view * @return true if managed by an [BaseEpoxyAdapter] */ private fun notEpoxyManaged(recyclerView: RecyclerView?): Boolean { return recyclerView == null || recyclerView.adapter !is BaseEpoxyAdapter } } companion object { private const val TAG = "EpoxyVisibilityTracker" @IdRes private val TAG_ID = R.id.epoxy_visibility_tracker /** * @param recyclerView the view. * @return the tracker for the given [RecyclerView]. Null if no tracker was attached. */ private fun getTracker(recyclerView: RecyclerView): EpoxyVisibilityTracker? { return recyclerView.getTag(TAG_ID) as EpoxyVisibilityTracker? } /** * Store the tracker for the given [RecyclerView]. * @param recyclerView the view * @param tracker the tracker */ private fun setTracker( recyclerView: RecyclerView, tracker: EpoxyVisibilityTracker? ) { recyclerView.setTag(TAG_ID, tracker) } // Not actionable at runtime. It is only useful for internal test-troubleshooting. const val DEBUG_LOG = false } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/GeneratedModel.java ================================================ package com.airbnb.epoxy; /** Interface applied to generated models to allow the base adapter to interact with them. */ public interface GeneratedModel { /** * Called on the generated model immediately before the main model onBind method has been called. * This let's the generated model handle binding setup of its own *

* The ViewHolder is needed to get the model's adapter position when clicked. */ void handlePreBind(EpoxyViewHolder holder, T objectToBind, int position); /** * Called on the generated model immediately after the main model onBind method has been called. * This let's the generated model handle binding of its own and dispatch calls to its onBind * listener. *

* We don't want to rely on the main onBind method to dispatch the onBind listener call because * there are two onBind methods (one for payloads and one for no payloads), and one can call into * the other. We don't want to dispatch two onBind listener calls in that case. */ void handlePostBind(T objectToBind, int position); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/GroupModel.kt ================================================ package com.airbnb.epoxy import androidx.annotation.LayoutRes /** * An [EpoxyModelGroup] usable in a DSL manner via the [group] extension. *

* Example: * ``` * group { * id("photos") * layout(R.layout.photo_grid) * * // add your models here, example: * for (photo in photos) { * imageView { * id(photo.id) * url(photo.url) * } * } * } * ``` */ @EpoxyModelClass abstract class GroupModel : EpoxyModelGroup, ModelCollector { constructor() : super() constructor(@LayoutRes layoutRes: Int) : super(layoutRes) override fun add(model: EpoxyModel<*>) { super.addModel(model) } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/HandlerExecutor.java ================================================ package com.airbnb.epoxy; import android.os.Handler; import android.os.Looper; import java.util.concurrent.Executor; import androidx.annotation.NonNull; /** * An executor that does it's work via posting to a Handler. *

* A key feature of this is the runnable is executed synchronously if the current thread is the * same as the handler's thread. */ class HandlerExecutor implements Executor { final Handler handler; HandlerExecutor(Handler handler) { this.handler = handler; } @Override public void execute(@NonNull Runnable command) { // If we're already on the same thread then we can execute this synchronously if (Looper.myLooper() == handler.getLooper()) { command.run(); } else { handler.post(command); } } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/HiddenEpoxyModel.java ================================================ package com.airbnb.epoxy; import android.widget.Space; import com.airbnb.viewmodeladapter.R; /** * Used by the {@link EpoxyAdapter} as a placeholder for when {@link EpoxyModel#isShown()} is false. * Using a zero height and width {@link Space} view, as well as 0 span size, to exclude itself from * view. */ class HiddenEpoxyModel extends EpoxyModel { @Override public int getDefaultLayout() { return R.layout.view_holder_empty_view; } @Override public int getSpanSize(int spanCount, int position, int itemCount) { return 0; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/IdUtils.java ================================================ package com.airbnb.epoxy; import androidx.annotation.Nullable; /** * Utilities for generating 64-bit long IDs from types such as {@link CharSequence}. */ public final class IdUtils { private IdUtils() { } /** * Hash a long into 64 bits instead of the normal 32. This uses a xor shift implementation to * attempt psuedo randomness so object ids have an even spread for less chance of collisions. *

* From http://stackoverflow.com/a/11554034 *

* http://www.javamex.com/tutorials/random_numbers/xorshift.shtml */ public static long hashLong64Bit(long value) { value ^= (value << 21); value ^= (value >>> 35); value ^= (value << 4); return value; } /** * Hash a string into 64 bits instead of the normal 32. This allows us to better use strings as a * model id with less chance of collisions. This uses the FNV-1a algorithm for a good mix of speed * and distribution. *

* Performance comparisons found at http://stackoverflow.com/a/1660613 *

* Hash implementation from http://www.isthe.com/chongo/tech/comp/fnv/index.html#FNV-1a */ public static long hashString64Bit(@Nullable CharSequence str) { if (str == null) { return 0; } long result = 0xcbf29ce484222325L; final int len = str.length(); for (int i = 0; i < len; i++) { result ^= str.charAt(i); result *= 0x100000001b3L; } return result; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/IllegalEpoxyUsage.java ================================================ package com.airbnb.epoxy; public class IllegalEpoxyUsage extends RuntimeException { public IllegalEpoxyUsage(String message) { super(message); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ImmutableModelException.java ================================================ package com.airbnb.epoxy; import androidx.annotation.NonNull; /** * Thrown if a model is changed after it is added to an {@link com.airbnb.epoxy.EpoxyController}. */ class ImmutableModelException extends RuntimeException { private static final String MODEL_CANNOT_BE_CHANGED_MESSAGE = "Epoxy attribute fields on a model cannot be changed once the model is added to a " + "controller. Check that these fields are not updated, or that the assigned objects " + "are not mutated, outside of the buildModels method. The only exception is if " + "the change is made inside an Interceptor callback. Consider using an interceptor" + " if you need to change a model after it is added to the controller and before it" + " is set on the adapter. If the model is already set on the adapter then you must" + " call `requestModelBuild` instead to recreate all models."; ImmutableModelException(EpoxyModel model, int modelPosition) { this(model, "", modelPosition); } ImmutableModelException(EpoxyModel model, String descriptionOfWhenChangeHappened, int modelPosition) { super(buildMessage(model, descriptionOfWhenChangeHappened, modelPosition)); } @NonNull private static String buildMessage(EpoxyModel model, String descriptionOfWhenChangeHappened, int modelPosition) { return new StringBuilder(descriptionOfWhenChangeHappened) .append(" Position: ") .append(modelPosition) .append(" Model: ") .append(model.toString()) .append("\n\n") .append(MODEL_CANNOT_BE_CHANGED_MESSAGE) .toString(); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/InternalExposer.kt ================================================ package com.airbnb.epoxy /** * Exposes package private things as internal so files in other packages can use them. */ internal fun EpoxyViewHolder.objectToBindInternal() = objectToBind() internal fun EpoxyModel<*>.viewTypeInternal() = viewType internal fun BaseEpoxyAdapter.boundViewHoldersInternal() = boundViewHolders internal fun BaseEpoxyAdapter.getModelForPositionInternal(position: Int): EpoxyModel<*>? { return getModelForPosition(position) } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ListenersUtils.java ================================================ package com.airbnb.epoxy; import android.view.View; import android.view.ViewParent; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.ViewHolder; public class ListenersUtils { @Nullable static EpoxyViewHolder getEpoxyHolderForChildView(View v) { RecyclerView recyclerView = findParentRecyclerView(v); if (recyclerView == null) { return null; } ViewHolder viewHolder = recyclerView.findContainingViewHolder(v); if (viewHolder == null) { return null; } if (!(viewHolder instanceof EpoxyViewHolder)) { return null; } return (EpoxyViewHolder) viewHolder; } @Nullable private static RecyclerView findParentRecyclerView(@Nullable View v) { if (v == null) { return null; } ViewParent parent = v.getParent(); if (parent instanceof RecyclerView) { return (RecyclerView) parent; } if (parent instanceof View) { return findParentRecyclerView((View) parent); } return null; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/MainThreadExecutor.java ================================================ package com.airbnb.epoxy; import static com.airbnb.epoxy.EpoxyAsyncUtil.AYSNC_MAIN_THREAD_HANDLER; import static com.airbnb.epoxy.EpoxyAsyncUtil.MAIN_THREAD_HANDLER; class MainThreadExecutor extends HandlerExecutor { static final MainThreadExecutor INSTANCE = new MainThreadExecutor(false); static final MainThreadExecutor ASYNC_INSTANCE = new MainThreadExecutor(true); MainThreadExecutor(boolean async) { super(async ? AYSNC_MAIN_THREAD_HANDLER : MAIN_THREAD_HANDLER); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ModelCollector.kt ================================================ package com.airbnb.epoxy /** * Interface used to collect models. Used by [EpoxyController]. It is also convenient to build DSL * helpers for carousel: @link https://github.com/airbnb/epoxy/issues/847. */ @EpoxyBuildScope interface ModelCollector { fun add(model: EpoxyModel<*>) } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ModelGroupHolder.kt ================================================ package com.airbnb.epoxy import android.view.View import android.view.ViewGroup import android.view.ViewParent import android.view.ViewStub import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.RecyclerView import com.airbnb.viewmodeladapter.R import java.util.ArrayList class ModelGroupHolder(private val modelGroupParent: ViewParent) : EpoxyHolder() { val viewHolders = ArrayList(4) /** Use parent pool or create a local pool */ @VisibleForTesting val viewPool = findViewPool(modelGroupParent) /** * Get the root view group (aka * [androidx.recyclerview.widget.RecyclerView.ViewHolder.itemView]. * You can override [EpoxyModelGroup.bind] and use this method to make custom * changes to the root view. */ lateinit var rootView: ViewGroup private set private lateinit var childContainer: ViewGroup private lateinit var stubs: List private var boundGroup: EpoxyModelGroup? = null private fun usingStubs(): Boolean = stubs.isNotEmpty() override fun bindView(itemView: View) { if (itemView !is ViewGroup) { throw IllegalStateException( "The layout provided to EpoxyModelGroup must be a ViewGroup" ) } rootView = itemView childContainer = findChildContainer(rootView) stubs = if (childContainer.childCount != 0) { createViewStubData(childContainer) } else { emptyList() } } /** * By default the outermost viewgroup is used as the container that views are added to. However, * users can specify a different, nested view group to use as the child container by marking it * with a special id. */ private fun findChildContainer(outermostRoot: ViewGroup): ViewGroup { val customRoot = outermostRoot.findViewById(R.id.epoxy_model_group_child_container) return customRoot as? ViewGroup ?: outermostRoot } private fun createViewStubData(viewGroup: ViewGroup): List { return ArrayList(4).apply { collectViewStubs(viewGroup, this) if (isEmpty()) { throw IllegalStateException( "No view stubs found. If viewgroup is not empty it must contain ViewStubs." ) } } } private fun collectViewStubs( viewGroup: ViewGroup, stubs: ArrayList ) { for (i in 0 until viewGroup.childCount) { val child = viewGroup.getChildAt(i) if (child is ViewGroup) { collectViewStubs(child, stubs) } else if (child is ViewStub) { stubs.add(ViewStubData(viewGroup, child, i)) } } } fun bindGroupIfNeeded(group: EpoxyModelGroup) { val previouslyBoundGroup = this.boundGroup if (previouslyBoundGroup === group) { return } else if (previouslyBoundGroup != null) { // A different group is being bound; this can happen when an onscreen model is changed. // The models or their layouts could have changed, so views may need to be updated if (previouslyBoundGroup.models.size > group.models.size) { for (i in previouslyBoundGroup.models.size - 1 downTo group.models.size) { removeAndRecycleView(i) } } } this.boundGroup = group val models = group.models val modelCount = models.size if (usingStubs() && stubs.size < modelCount) { throw IllegalStateException( "Insufficient view stubs for EpoxyModelGroup. $modelCount models were provided but only ${stubs.size} view stubs exist." ) } viewHolders.ensureCapacity(modelCount) for (i in 0 until modelCount) { val model = models[i] val previouslyBoundModel = previouslyBoundGroup?.models?.getOrNull(i) val stubData = stubs.getOrNull(i) val parent = stubData?.viewGroup ?: childContainer if (previouslyBoundModel != null) { if (areSameViewType(previouslyBoundModel, model)) { continue } removeAndRecycleView(i) } val holder = getViewHolder(parent, model) if (stubData == null) { childContainer.addView(holder.itemView, i) } else { stubData.setView(holder.itemView, group.useViewStubLayoutParams(model, i)) } viewHolders.add(i, holder) } } private fun areSameViewType(model1: EpoxyModel<*>, model2: EpoxyModel<*>?): Boolean { return ViewTypeManager.getViewType(model1) == ViewTypeManager.getViewType(model2) } private fun getViewHolder(parent: ViewGroup, model: EpoxyModel<*>): EpoxyViewHolder { val viewType = ViewTypeManager.getViewType(model) val recycledView = viewPool.getRecycledView(viewType) return recycledView as? EpoxyViewHolder ?: HELPER_ADAPTER.createViewHolder( modelGroupParent, model, parent, viewType ) } fun unbindGroup() { if (boundGroup == null) { throw IllegalStateException("Group is not bound") } repeat(viewHolders.size) { // Remove from the end for more efficient list actions removeAndRecycleView(viewHolders.size - 1) } boundGroup = null } private fun removeAndRecycleView(modelPosition: Int) { if (usingStubs()) { stubs[modelPosition].resetStub() } else { childContainer.removeViewAt(modelPosition) } val viewHolder = viewHolders.removeAt(modelPosition) viewHolder.unbind() viewPool.putRecycledView(viewHolder) } companion object { private val HELPER_ADAPTER = HelperAdapter() private fun findViewPool(view: ViewParent): RecyclerView.RecycledViewPool { var viewPool: RecyclerView.RecycledViewPool? = null while (viewPool == null) { viewPool = if (view is RecyclerView) { view.recycledViewPool } else { val parent = view.parent if (parent is ViewParent) { findViewPool(parent) } else { // This model group is is not in a RecyclerView LocalGroupRecycledViewPool() } } } return viewPool } } } private class ViewStubData( val viewGroup: ViewGroup, val viewStub: ViewStub, val position: Int ) { fun setView(view: View, useStubLayoutParams: Boolean) { removeCurrentView() // Carry over the stub id manually since we aren't inflating via the stub val inflatedId = viewStub.inflatedId if (inflatedId != View.NO_ID) { view.id = inflatedId } if (useStubLayoutParams) { viewGroup.addView(view, position, viewStub.layoutParams) } else { viewGroup.addView(view, position) } } fun resetStub() { removeCurrentView() viewGroup.addView(viewStub, position) } private fun removeCurrentView() { val view = viewGroup.getChildAt(position) ?: throw IllegalStateException("No view exists at position $position") viewGroup.removeView(view) } } /** * Local pool to the [ModelGroupHolder] */ private class LocalGroupRecycledViewPool : RecyclerView.RecycledViewPool() /** * A viewholder's viewtype can only be set internally in an adapter when the viewholder * is created. To work around that we do the creation in an adapter. */ private class HelperAdapter : RecyclerView.Adapter() { private var model: EpoxyModel<*>? = null private var modelGroupParent: ViewParent? = null fun createViewHolder( modelGroupParent: ViewParent, model: EpoxyModel<*>, parent: ViewGroup, viewType: Int ): EpoxyViewHolder { this.model = model this.modelGroupParent = modelGroupParent val viewHolder = createViewHolder(parent, viewType) this.model = null this.modelGroupParent = null return viewHolder } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EpoxyViewHolder { return EpoxyViewHolder(modelGroupParent, model!!.buildView(parent), model!!.shouldSaveViewState()) } override fun onBindViewHolder(holder: EpoxyViewHolder, position: Int) { } override fun getItemCount() = 1 } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ModelList.java ================================================ package com.airbnb.epoxy; import java.util.AbstractList; import java.util.ArrayList; import java.util.Collection; import java.util.ConcurrentModificationException; import java.util.Iterator; import java.util.List; import java.util.ListIterator; import java.util.NoSuchElementException; import androidx.annotation.NonNull; /** * Used by our {@link EpoxyAdapter} to track models. It simply wraps ArrayList and notifies an * observer when remove or insertion operations are done on the list. This allows us to optimize * diffing since we have a knowledge of what changed in the list. */ class ModelList extends ArrayList> { ModelList(int expectedModelCount) { super(expectedModelCount); } ModelList() { } interface ModelListObserver { void onItemRangeInserted(int positionStart, int itemCount); void onItemRangeRemoved(int positionStart, int itemCount); } private boolean notificationsPaused; private ModelListObserver observer; void pauseNotifications() { if (notificationsPaused) { throw new IllegalStateException("Notifications already paused"); } notificationsPaused = true; } void resumeNotifications() { if (!notificationsPaused) { throw new IllegalStateException("Notifications already resumed"); } notificationsPaused = false; } void setObserver(ModelListObserver observer) { this.observer = observer; } private void notifyInsertion(int positionStart, int itemCount) { if (!notificationsPaused && observer != null) { observer.onItemRangeInserted(positionStart, itemCount); } } private void notifyRemoval(int positionStart, int itemCount) { if (!notificationsPaused && observer != null) { observer.onItemRangeRemoved(positionStart, itemCount); } } @Override public EpoxyModel set(int index, EpoxyModel element) { EpoxyModel previousModel = super.set(index, element); if (previousModel.id() != element.id()) { notifyRemoval(index, 1); notifyInsertion(index, 1); } return previousModel; } @Override public boolean add(EpoxyModel epoxyModel) { notifyInsertion(size(), 1); return super.add(epoxyModel); } @Override public void add(int index, EpoxyModel element) { notifyInsertion(index, 1); super.add(index, element); } @Override public boolean addAll(Collection> c) { notifyInsertion(size(), c.size()); return super.addAll(c); } @Override public boolean addAll(int index, Collection> c) { notifyInsertion(index, c.size()); return super.addAll(index, c); } @Override public EpoxyModel remove(int index) { notifyRemoval(index, 1); return super.remove(index); } @Override public boolean remove(Object o) { int index = indexOf(o); if (index == -1) { return false; } notifyRemoval(index, 1); super.remove(index); return true; } @Override public void clear() { if (!isEmpty()) { notifyRemoval(0, size()); super.clear(); } } @Override protected void removeRange(int fromIndex, int toIndex) { if (fromIndex == toIndex) { return; } notifyRemoval(fromIndex, toIndex - fromIndex); super.removeRange(fromIndex, toIndex); } @Override public boolean removeAll(Collection collection) { // Using this implementation from the Android ArrayList since the Java 1.8 ArrayList // doesn't call through to remove. Calling through to remove lets us leverage the notification // done there boolean result = false; Iterator it = iterator(); while (it.hasNext()) { if (collection.contains(it.next())) { it.remove(); result = true; } } return result; } @Override public boolean retainAll(Collection collection) { // Using this implementation from the Android ArrayList since the Java 1.8 ArrayList // doesn't call through to remove. Calling through to remove lets us leverage the notification // done there boolean result = false; Iterator it = iterator(); while (it.hasNext()) { if (!collection.contains(it.next())) { it.remove(); result = true; } } return result; } @NonNull @Override public Iterator> iterator() { return new Itr(); } /** * An Iterator implementation that calls through to the parent list's methods for modification. * Some implementations, like the Android ArrayList.ArrayListIterator class, modify the list data * directly instead of calling into the parent list's methods. We need the implementation to call * the parent methods so that the proper notifications are done. */ private class Itr implements Iterator> { int cursor; // index of next element to return int lastRet = -1; // index of last element returned; -1 if no such int expectedModCount = modCount; public boolean hasNext() { return cursor != size(); } @SuppressWarnings("unchecked") public EpoxyModel next() { checkForComodification(); int i = cursor; cursor = i + 1; lastRet = i; return ModelList.this.get(i); } public void remove() { if (lastRet < 0) { throw new IllegalStateException(); } checkForComodification(); try { ModelList.this.remove(lastRet); cursor = lastRet; lastRet = -1; expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } final void checkForComodification() { if (modCount != expectedModCount) { throw new ConcurrentModificationException(); } } } @NonNull @Override public ListIterator> listIterator() { return new ListItr(0); } @NonNull @Override public ListIterator> listIterator(int index) { return new ListItr(index); } /** * A ListIterator implementation that calls through to the parent list's methods for modification. * Some implementations may modify the list data directly instead of calling into the parent * list's methods. We need the implementation to call the parent methods so that the proper * notifications are done. */ private class ListItr extends Itr implements ListIterator> { ListItr(int index) { cursor = index; } public boolean hasPrevious() { return cursor != 0; } public int nextIndex() { return cursor; } public int previousIndex() { return cursor - 1; } @SuppressWarnings("unchecked") public EpoxyModel previous() { checkForComodification(); int i = cursor - 1; if (i < 0) { throw new NoSuchElementException(); } cursor = i; lastRet = i; return ModelList.this.get(i); } public void set(EpoxyModel e) { if (lastRet < 0) { throw new IllegalStateException(); } checkForComodification(); try { ModelList.this.set(lastRet, e); } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } public void add(EpoxyModel e) { checkForComodification(); try { int i = cursor; ModelList.this.add(i, e); cursor = i + 1; lastRet = -1; expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } } @NonNull @Override public List> subList(int start, int end) { if (start >= 0 && end <= size()) { if (start <= end) { return new SubList(this, start, end); } throw new IllegalArgumentException(); } throw new IndexOutOfBoundsException(); } /** * A SubList implementation from Android's AbstractList class. It's copied here to make sure the * implementation doesn't change, since some implementations, like the Java 1.8 ArrayList.SubList * class, modify the list data directly instead of calling into the parent list's methods. We need * the implementation to call the parent methods so that the proper notifications are done. */ private static class SubList extends AbstractList> { private final ModelList fullList; private int offset; private int size; private static final class SubListIterator implements ListIterator> { private final SubList subList; private final ListIterator> iterator; private int start; private int end; SubListIterator(ListIterator> it, SubList list, int offset, int length) { iterator = it; subList = list; start = offset; end = start + length; } public void add(EpoxyModel object) { iterator.add(object); subList.sizeChanged(true); end++; } public boolean hasNext() { return iterator.nextIndex() < end; } public boolean hasPrevious() { return iterator.previousIndex() >= start; } public EpoxyModel next() { if (iterator.nextIndex() < end) { return iterator.next(); } throw new NoSuchElementException(); } public int nextIndex() { return iterator.nextIndex() - start; } public EpoxyModel previous() { if (iterator.previousIndex() >= start) { return iterator.previous(); } throw new NoSuchElementException(); } public int previousIndex() { int previous = iterator.previousIndex(); if (previous >= start) { return previous - start; } return -1; } public void remove() { iterator.remove(); subList.sizeChanged(false); end--; } public void set(EpoxyModel object) { iterator.set(object); } } SubList(ModelList list, int start, int end) { fullList = list; modCount = fullList.modCount; offset = start; size = end - start; } @Override public void add(int location, EpoxyModel object) { if (modCount == fullList.modCount) { if (location >= 0 && location <= size) { fullList.add(location + offset, object); size++; modCount = fullList.modCount; } else { throw new IndexOutOfBoundsException(); } } else { throw new ConcurrentModificationException(); } } @Override public boolean addAll(int location, Collection> collection) { if (modCount == fullList.modCount) { if (location >= 0 && location <= size) { boolean result = fullList.addAll(location + offset, collection); if (result) { size += collection.size(); modCount = fullList.modCount; } return result; } throw new IndexOutOfBoundsException(); } throw new ConcurrentModificationException(); } @Override public boolean addAll(@NonNull Collection> collection) { if (modCount == fullList.modCount) { boolean result = fullList.addAll(offset + size, collection); if (result) { size += collection.size(); modCount = fullList.modCount; } return result; } throw new ConcurrentModificationException(); } @Override public EpoxyModel get(int location) { if (modCount == fullList.modCount) { if (location >= 0 && location < size) { return fullList.get(location + offset); } throw new IndexOutOfBoundsException(); } throw new ConcurrentModificationException(); } @NonNull @Override public Iterator> iterator() { return listIterator(0); } @NonNull @Override public ListIterator> listIterator(int location) { if (modCount == fullList.modCount) { if (location >= 0 && location <= size) { return new SubListIterator(fullList.listIterator(location + offset), this, offset, size); } throw new IndexOutOfBoundsException(); } throw new ConcurrentModificationException(); } @Override public EpoxyModel remove(int location) { if (modCount == fullList.modCount) { if (location >= 0 && location < size) { EpoxyModel result = fullList.remove(location + offset); size--; modCount = fullList.modCount; return result; } throw new IndexOutOfBoundsException(); } throw new ConcurrentModificationException(); } @Override protected void removeRange(int start, int end) { if (start != end) { if (modCount == fullList.modCount) { fullList.removeRange(start + offset, end + offset); size -= end - start; modCount = fullList.modCount; } else { throw new ConcurrentModificationException(); } } } @Override public EpoxyModel set(int location, EpoxyModel object) { if (modCount == fullList.modCount) { if (location >= 0 && location < size) { return fullList.set(location + offset, object); } throw new IndexOutOfBoundsException(); } throw new ConcurrentModificationException(); } @Override public int size() { if (modCount == fullList.modCount) { return size; } throw new ConcurrentModificationException(); } void sizeChanged(boolean increment) { if (increment) { size++; } else { size--; } modCount = fullList.modCount; } } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ModelState.java ================================================ package com.airbnb.epoxy; /** Helper to store relevant information about a model that we need to determine if it changed. */ class ModelState { long id; int hashCode; int position; EpoxyModel model; /** * A link to the item with the same id in the other list when diffing two lists. This will be null * if the item doesn't exist, in the case of insertions or removals. This is an optimization to * prevent having to look up the matching pair in a hash map every time. */ ModelState pair; /** * How many movement operations have been applied to this item in order to update its position. As * we find more item movements we need to update the position of affected items in the list in * order to correctly calculate the next movement. Instead of iterating through all items in the * list every time a movement operation happens we keep track of how many of these operations have * been applied to an item, and apply all new operations in order when we need to get this item's * up to date position. */ int lastMoveOp; static ModelState build(EpoxyModel model, int position, boolean immutableModel) { ModelState state = new ModelState(); state.lastMoveOp = 0; state.pair = null; state.id = model.id(); state.position = position; if (immutableModel) { state.model = model; } else { state.hashCode = model.hashCode(); } return state; } /** * Used for an item inserted into the new list when we need to track moves that effect the * inserted item in the old list. */ void pairWithSelf() { if (pair != null) { throw new IllegalStateException("Already paired."); } pair = new ModelState(); pair.lastMoveOp = 0; pair.id = id; pair.position = position; pair.hashCode = hashCode; pair.pair = this; pair.model = model; } @Override public String toString() { return "ModelState{" + "id=" + id + ", model=" + model + ", hashCode=" + hashCode + ", position=" + position + ", pair=" + pair + ", lastMoveOp=" + lastMoveOp + '}'; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/NoOpControllerHelper.java ================================================ package com.airbnb.epoxy; /** * A {@link ControllerHelper} implementation for adapters with no {@link * com.airbnb.epoxy.AutoModel} usage. */ class NoOpControllerHelper extends ControllerHelper { @Override public void resetAutoModels() { // No - Op } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/NoOpTimer.java ================================================ package com.airbnb.epoxy; class NoOpTimer implements Timer { @Override public void start(String sectionName) { } @Override public void stop() { } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/NotifyBlocker.java ================================================ package com.airbnb.epoxy; import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; /** * We don't allow any data change notifications except the ones done though diffing. Forcing * changes to happen through diffing reduces the chance for developer error when implementing an * adapter. *

* This observer throws upon any changes done outside of diffing. */ class NotifyBlocker extends AdapterDataObserver { private boolean changesAllowed; void allowChanges() { changesAllowed = true; } void blockChanges() { changesAllowed = false; } @Override public void onChanged() { if (!changesAllowed) { throw new IllegalStateException( "You cannot notify item changes directly. Call `requestModelBuild` instead."); } } @Override public void onItemRangeChanged(int positionStart, int itemCount) { onChanged(); } @Override public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { onChanged(); } @Override public void onItemRangeInserted(int positionStart, int itemCount) { onChanged(); } @Override public void onItemRangeRemoved(int positionStart, int itemCount) { onChanged(); } @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { onChanged(); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/OnModelBoundListener.java ================================================ package com.airbnb.epoxy; /** Used to register an onBind callback with a generated model. */ public interface OnModelBoundListener, V> { /** * This will be called immediately after a model was bound, with the model and view that were * bound together. * * @param model The model being bound * @param view The view that is being bound to the model * @param position The adapter position of the model */ void onModelBound(T model, V view, int position); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/OnModelBuildFinishedListener.java ================================================ package com.airbnb.epoxy; import androidx.annotation.NonNull; /** * Used with {@link EpoxyController#addModelBuildListener(OnModelBuildFinishedListener)} to be * alerted to new model changes. */ public interface OnModelBuildFinishedListener { /** * Called after {@link EpoxyController#buildModels()} has run and changes have been notified to * the adapter. This will be called even if no changes existed. */ void onModelBuildFinished(@NonNull DiffResult result); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/OnModelCheckedChangeListener.java ================================================ package com.airbnb.epoxy; import android.widget.CompoundButton; public interface OnModelCheckedChangeListener, V> { /** * Called when the view bound to the model is checked. * * @param model The model that the view is bound to. * @param parentView The view bound to the model which received the click. * @param checkedView The view that received the click. This is either a child of the parentView * or the parentView itself * @param isChecked The new value for isChecked property. * @param position The position of the model in the adapter. */ void onChecked(T model, V parentView, CompoundButton checkedView, boolean isChecked, int position); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/OnModelClickListener.java ================================================ package com.airbnb.epoxy; import android.view.View; /** Used to register a click listener on a generated model. */ public interface OnModelClickListener, V> { /** * Called when the view bound to the model is clicked. * * @param model The model that the view is bound to. * @param parentView The view bound to the model which received the click. * @param clickedView The view that received the click. This is either a child of the parentView * or the parentView itself * @param position The position of the model in the adapter. */ void onClick(T model, V parentView, View clickedView, int position); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/OnModelLongClickListener.java ================================================ package com.airbnb.epoxy; import android.view.View; public interface OnModelLongClickListener, V> { /** * Called when the view bound to the model is clicked. * * @param model The model that the view is bound to. * @param parentView The view bound to the model which received the click. * @param clickedView The view that received the click. This is either a child of the parentView * or the parentView itself * @param position The position of the model in the adapter. */ boolean onLongClick(T model, V parentView, View clickedView, int position); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/OnModelUnboundListener.java ================================================ package com.airbnb.epoxy; /** Used to register an onUnbind callback with a generated model. */ public interface OnModelUnboundListener, V> { /** * This will be called immediately after a model is unbound from a view, with the view and model * that were unbound. */ void onModelUnbound(T model, V view); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/OnModelVisibilityChangedListener.java ================================================ package com.airbnb.epoxy; import androidx.annotation.FloatRange; import androidx.annotation.Px; /** Used to register an onVisibilityChanged callback with a generated model. */ public interface OnModelVisibilityChangedListener, V> { /** * This will be called once the view visible part changes. *

* OnModelVisibilityChangedListener should be used with particular care since they will be * dispatched on every frame while scrolling. No heavy work should be done inside the * implementation. Using {@link OnModelVisibilityStateChangedListener} is recommended whenever * possible. *

* @param model The model being bound * @param view The view that is being bound to the model * @param percentVisibleHeight The percentage of height visible (0-100) * @param percentVisibleWidth The percentage of width visible (0-100) * @param heightVisible The visible height in pixel * @param widthVisible The visible width in pixel */ void onVisibilityChanged(T model, V view, @FloatRange(from = 0, to = 100) float percentVisibleHeight, @FloatRange(from = 0, to = 100) float percentVisibleWidth, @Px int heightVisible, @Px int widthVisible); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/OnModelVisibilityStateChangedListener.java ================================================ package com.airbnb.epoxy; import com.airbnb.epoxy.VisibilityState.Visibility; /** Used to register an onVisibilityChanged callback with a generated model. */ public interface OnModelVisibilityStateChangedListener, V> { /** * This will be called once the visibility changed. *

* @param model The model being bound * @param view The view that is being bound to the model * @param visibilityState The new visibility *

* @see VisibilityState */ void onVisibilityStateChanged(T model, V view, @Visibility int visibilityState); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/QuantityStringResAttribute.java ================================================ package com.airbnb.epoxy; import android.content.Context; import java.util.Arrays; import androidx.annotation.Nullable; import androidx.annotation.PluralsRes; public class QuantityStringResAttribute { @PluralsRes private final int id; private final int quantity; @Nullable private final Object[] formatArgs; public QuantityStringResAttribute(@PluralsRes int id, int quantity, @Nullable Object[] formatArgs) { this.quantity = quantity; this.id = id; this.formatArgs = formatArgs; } public QuantityStringResAttribute(int id, int quantity) { this(id, quantity, null); } @PluralsRes public int getId() { return id; } public int getQuantity() { return quantity; } @Nullable public Object[] getFormatArgs() { return formatArgs; } public CharSequence toString(Context context) { if (formatArgs == null || formatArgs.length == 0) { return context.getResources().getQuantityString(id, quantity); } else { return context.getResources().getQuantityString(id, quantity, formatArgs); } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof QuantityStringResAttribute)) { return false; } QuantityStringResAttribute that = (QuantityStringResAttribute) o; if (id != that.id) { return false; } if (quantity != that.quantity) { return false; } // Probably incorrect - comparing Object[] arrays with Arrays.equals return Arrays.equals(formatArgs, that.formatArgs); } @Override public int hashCode() { int result = id; result = 31 * result + quantity; result = 31 * result + Arrays.hashCode(formatArgs); return result; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/SimpleEpoxyAdapter.java ================================================ package com.airbnb.epoxy; import java.util.Collection; import java.util.List; /** * A non-abstract version of {@link com.airbnb.epoxy.EpoxyAdapter} that exposes all methods and * models as public. Use this if you don't want to create your own adapter subclass and instead want * to modify the adapter from elsewhere, such as from an activity. */ public class SimpleEpoxyAdapter extends EpoxyAdapter { public List> getModels() { return models; } @Override public void enableDiffing() { super.enableDiffing(); } @Override public void notifyModelsChanged() { super.notifyModelsChanged(); } @Override public BoundViewHolders getBoundViewHolders() { return super.getBoundViewHolders(); } @Override public void notifyModelChanged(EpoxyModel model) { super.notifyModelChanged(model); } @Override public void addModels(EpoxyModel... modelsToAdd) { super.addModels(modelsToAdd); } @Override public void addModels(Collection> modelsToAdd) { super.addModels(modelsToAdd); } @Override public void insertModelBefore(EpoxyModel modelToInsert, EpoxyModel modelToInsertBefore) { super.insertModelBefore(modelToInsert, modelToInsertBefore); } @Override public void insertModelAfter(EpoxyModel modelToInsert, EpoxyModel modelToInsertAfter) { super.insertModelAfter(modelToInsert, modelToInsertAfter); } @Override public void removeModel(EpoxyModel model) { super.removeModel(model); } @Override public void removeAllModels() { super.removeAllModels(); } @Override public void removeAllAfterModel(EpoxyModel model) { super.removeAllAfterModel(model); } @Override public void showModel(EpoxyModel model, boolean show) { super.showModel(model, show); } @Override public void showModel(EpoxyModel model) { super.showModel(model); } @Override public void showModels(EpoxyModel... models) { super.showModels(models); } @Override public void showModels(boolean show, EpoxyModel... models) { super.showModels(show, models); } @Override public void showModels(Iterable> epoxyModels) { super.showModels(epoxyModels); } @Override public void showModels(Iterable> epoxyModels, boolean show) { super.showModels(epoxyModels, show); } @Override public void hideModel(EpoxyModel model) { super.hideModel(model); } @Override public void hideModels(Iterable> epoxyModels) { super.hideModels(epoxyModels); } @Override public void hideModels(EpoxyModel... models) { super.hideModels(models); } @Override public void hideAllAfterModel(EpoxyModel model) { super.hideAllAfterModel(model); } @Override public List> getAllModelsAfter(EpoxyModel model) { return super.getAllModelsAfter(model); } @Override public int getModelPosition(EpoxyModel model) { return super.getModelPosition(model); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/SimpleEpoxyController.java ================================================ package com.airbnb.epoxy; import java.util.List; /** * A small wrapper around {@link com.airbnb.epoxy.EpoxyController} that lets you set a list of * models directly. */ public class SimpleEpoxyController extends EpoxyController { private List> currentModels; private boolean insideSetModels; /** * Set the models to add to this controller. Clears any previous models and adds this new list * . */ public void setModels(List> models) { currentModels = models; insideSetModels = true; requestModelBuild(); insideSetModels = false; } @Override public final void requestModelBuild() { if (!insideSetModels) { throw new IllegalEpoxyUsage( "You cannot call `requestModelBuild` directly. Call `setModels` instead."); } super.requestModelBuild(); } @Override protected final void buildModels() { if (!isBuildingModels()) { throw new IllegalEpoxyUsage( "You cannot call `buildModels` directly. Call `setModels` instead."); } add(currentModels); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/SimpleEpoxyModel.java ================================================ package com.airbnb.epoxy; import android.view.View; import androidx.annotation.CallSuper; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; /** * Helper class for cases where you don't need to do anything special when binding the view. This * allows you to just provide the layout instead of needing to create a separate {@link EpoxyModel} * subclass. This is useful for static layouts. You can also specify an onClick listener and the * span size. */ public class SimpleEpoxyModel extends EpoxyModel { @LayoutRes private final int layoutRes; private View.OnClickListener onClickListener; private int spanCount = 1; public SimpleEpoxyModel(@LayoutRes int layoutRes) { this.layoutRes = layoutRes; } public SimpleEpoxyModel onClick(View.OnClickListener listener) { this.onClickListener = listener; return this; } public SimpleEpoxyModel span(int span) { spanCount = span; return this; } @CallSuper @Override public void bind(@NonNull View view) { super.bind(view); view.setOnClickListener(onClickListener); view.setClickable(onClickListener != null); } @CallSuper @Override public void unbind(@NonNull View view) { super.unbind(view); view.setOnClickListener(null); } @Override protected int getDefaultLayout() { return layoutRes; } @Override public int getSpanSize(int totalSpanCount, int position, int itemCount) { return spanCount; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof SimpleEpoxyModel)) { return false; } if (!super.equals(o)) { return false; } SimpleEpoxyModel that = (SimpleEpoxyModel) o; if (layoutRes != that.layoutRes) { return false; } if (spanCount != that.spanCount) { return false; } return onClickListener != null ? onClickListener.equals(that.onClickListener) : that.onClickListener == null; } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + layoutRes; result = 31 * result + (onClickListener != null ? onClickListener.hashCode() : 0); result = 31 * result + spanCount; return result; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/StringAttributeData.java ================================================ package com.airbnb.epoxy; import android.content.Context; import java.util.Arrays; import androidx.annotation.Nullable; import androidx.annotation.PluralsRes; import androidx.annotation.StringRes; public class StringAttributeData { private final boolean hasDefault; @Nullable private final CharSequence defaultString; @StringRes private final int defaultStringRes; @Nullable private CharSequence string; @StringRes private int stringRes; @PluralsRes private int pluralRes; private int quantity; @Nullable private Object[] formatArgs; public StringAttributeData() { hasDefault = false; defaultString = null; defaultStringRes = 0; } public StringAttributeData(@Nullable CharSequence defaultString) { hasDefault = true; this.defaultString = defaultString; string = defaultString; defaultStringRes = 0; } public StringAttributeData(@StringRes int defaultStringRes) { hasDefault = true; this.defaultStringRes = defaultStringRes; stringRes = defaultStringRes; defaultString = null; } public void setValue(@Nullable CharSequence string) { this.string = string; stringRes = 0; pluralRes = 0; } public void setValue(@StringRes int stringRes) { setValue(stringRes, null); } public void setValue(@StringRes int stringRes, @Nullable Object[] formatArgs) { if (stringRes != 0) { this.stringRes = stringRes; this.formatArgs = formatArgs; string = null; pluralRes = 0; } else { handleInvalidStringRes(); } } private void handleInvalidStringRes() { if (hasDefault) { if (defaultStringRes != 0) { setValue(defaultStringRes); } else { setValue(defaultString); } } else { throw new IllegalArgumentException("0 is an invalid value for required strings."); } } public void setValue(@PluralsRes int pluralRes, int quantity, @Nullable Object[] formatArgs) { if (pluralRes != 0) { this.pluralRes = pluralRes; this.quantity = quantity; this.formatArgs = formatArgs; string = null; stringRes = 0; } else { handleInvalidStringRes(); } } public CharSequence toString(Context context) { if (pluralRes != 0) { if (formatArgs != null) { return context.getResources().getQuantityString(pluralRes, quantity, formatArgs); } else { return context.getResources().getQuantityString(pluralRes, quantity); } } else if (stringRes != 0) { if (formatArgs != null) { return context.getResources().getString(stringRes, formatArgs); } else { return context.getResources().getText(stringRes); } } else { return string; } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof StringAttributeData)) { return false; } StringAttributeData that = (StringAttributeData) o; if (stringRes != that.stringRes) { return false; } if (pluralRes != that.pluralRes) { return false; } if (quantity != that.quantity) { return false; } if (string != null ? !string.equals(that.string) : that.string != null) { return false; } return Arrays.equals(formatArgs, that.formatArgs); } @Override public int hashCode() { int result = string != null ? string.hashCode() : 0; result = 31 * result + stringRes; result = 31 * result + pluralRes; result = 31 * result + quantity; result = 31 * result + Arrays.hashCode(formatArgs); return result; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/StyleBuilderCallback.java ================================================ package com.airbnb.epoxy; /** * Used for specifying dynamic styling for a view when creating a model. This is only used if the * view is set up to be styled with the Paris library. */ public interface StyleBuilderCallback { void buildStyle(T builder); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/Timer.java ================================================ package com.airbnb.epoxy; interface Timer { void start(String sectionName); void stop(); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/Typed2EpoxyController.java ================================================ package com.airbnb.epoxy; import android.os.Handler; /** * This is a wrapper around {@link com.airbnb.epoxy.EpoxyController} to simplify how data is * accessed. Use this if the data required to build your models is represented by two objects. *

* To use this, create a subclass typed with your data object. Then, call {@link #setData} * whenever that data changes. This class will handle calling {@link #buildModels} with the * latest data. *

* You should NOT call {@link #requestModelBuild()} directly. * * @see TypedEpoxyController * @see Typed3EpoxyController * @see Typed4EpoxyController */ public abstract class Typed2EpoxyController extends EpoxyController { private T data1; private U data2; private boolean allowModelBuildRequests; public Typed2EpoxyController() { } public Typed2EpoxyController(Handler modelBuildingHandler, Handler diffingHandler) { super(modelBuildingHandler, diffingHandler); } /** * Call this with the latest data when you want models to be rebuilt. The data will be passed on * to {@link #buildModels(Object, Object)} */ public void setData(T data1, U data2) { this.data1 = data1; this.data2 = data2; allowModelBuildRequests = true; requestModelBuild(); allowModelBuildRequests = false; } @Override public final void requestModelBuild() { if (!allowModelBuildRequests) { throw new IllegalStateException( "You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + "model refresh with new data."); } super.requestModelBuild(); } @Override public void moveModel(int fromPosition, int toPosition) { allowModelBuildRequests = true; super.moveModel(fromPosition, toPosition); allowModelBuildRequests = false; } @Override public void requestDelayedModelBuild(int delayMs) { if (!allowModelBuildRequests) { throw new IllegalStateException( "You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + "model refresh with new data."); } super.requestDelayedModelBuild(delayMs); } @Override protected final void buildModels() { if (!isBuildingModels()) { throw new IllegalStateException( "You cannot call `buildModels` directly. Call `setData` instead to trigger a model " + "refresh with new data."); } buildModels(data1, data2); } protected abstract void buildModels(T data1, U data2); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/Typed3EpoxyController.java ================================================ package com.airbnb.epoxy; import android.os.Handler; /** * This is a wrapper around {@link com.airbnb.epoxy.EpoxyController} to simplify how data is * accessed. Use this if the data required to build your models is represented by three objects. *

* To use this, create a subclass typed with your data object. Then, call {@link #setData} * whenever that data changes. This class will handle calling {@link #buildModels} with the * latest data. *

* You should NOT call {@link #requestModelBuild()} directly. * * @see TypedEpoxyController * @see Typed2EpoxyController * @see Typed4EpoxyController */ public abstract class Typed3EpoxyController extends EpoxyController { private T data1; private U data2; private V data3; private boolean allowModelBuildRequests; public Typed3EpoxyController() { } public Typed3EpoxyController(Handler modelBuildingHandler, Handler diffingHandler) { super(modelBuildingHandler, diffingHandler); } /** * Call this with the latest data when you want models to be rebuilt. The data will be passed on * to {@link #buildModels(Object, Object, Object)} */ public void setData(T data1, U data2, V data3) { this.data1 = data1; this.data2 = data2; this.data3 = data3; allowModelBuildRequests = true; requestModelBuild(); allowModelBuildRequests = false; } @Override public final void requestModelBuild() { if (!allowModelBuildRequests) { throw new IllegalStateException( "You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + "model refresh with new data."); } super.requestModelBuild(); } @Override public void moveModel(int fromPosition, int toPosition) { allowModelBuildRequests = true; super.moveModel(fromPosition, toPosition); allowModelBuildRequests = false; } @Override public void requestDelayedModelBuild(int delayMs) { if (!allowModelBuildRequests) { throw new IllegalStateException( "You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + "model refresh with new data."); } super.requestDelayedModelBuild(delayMs); } @Override protected final void buildModels() { if (!isBuildingModels()) { throw new IllegalStateException( "You cannot call `buildModels` directly. Call `setData` instead to trigger a model " + "refresh with new data."); } buildModels(data1, data2, data3); } protected abstract void buildModels(T data1, U data2, V data3); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/Typed4EpoxyController.java ================================================ package com.airbnb.epoxy; import android.os.Handler; /** * This is a wrapper around {@link com.airbnb.epoxy.EpoxyController} to simplify how data is * accessed. Use this if the data required to build your models is represented by four objects. *

* To use this, create a subclass typed with your data object. Then, call {@link #setData} * whenever that data changes. This class will handle calling {@link #buildModels} with the * latest data. *

* You should NOT call {@link #requestModelBuild()} directly. * * @see TypedEpoxyController * @see Typed2EpoxyController * @see Typed3EpoxyController */ public abstract class Typed4EpoxyController extends EpoxyController { private T data1; private U data2; private V data3; private W data4; private boolean allowModelBuildRequests; public Typed4EpoxyController() { } public Typed4EpoxyController(Handler modelBuildingHandler, Handler diffingHandler) { super(modelBuildingHandler, diffingHandler); } /** * Call this with the latest data when you want models to be rebuilt. The data will be passed on * to {@link #buildModels(Object, Object, Object, Object)} */ public void setData(T data1, U data2, V data3, W data4) { this.data1 = data1; this.data2 = data2; this.data3 = data3; this.data4 = data4; allowModelBuildRequests = true; requestModelBuild(); allowModelBuildRequests = false; } @Override public final void requestModelBuild() { if (!allowModelBuildRequests) { throw new IllegalStateException( "You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + "model refresh with new data."); } super.requestModelBuild(); } @Override public void moveModel(int fromPosition, int toPosition) { allowModelBuildRequests = true; super.moveModel(fromPosition, toPosition); allowModelBuildRequests = false; } @Override public void requestDelayedModelBuild(int delayMs) { if (!allowModelBuildRequests) { throw new IllegalStateException( "You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + "model refresh with new data."); } super.requestDelayedModelBuild(delayMs); } @Override protected final void buildModels() { if (!isBuildingModels()) { throw new IllegalStateException( "You cannot call `buildModels` directly. Call `setData` instead to trigger a model " + "refresh with new data."); } buildModels(data1, data2, data3, data4); } protected abstract void buildModels(T data1, U data2, V data3, W data4); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/TypedEpoxyController.java ================================================ package com.airbnb.epoxy; import android.os.Handler; import androidx.annotation.Nullable; /** * This is a wrapper around {@link com.airbnb.epoxy.EpoxyController} to simplify how data is * accessed. Use this if the data required to build your models is represented by a single object. *

* To use this, create a subclass typed with your data object. Then, call {@link #setData(Object)} * whenever that data changes. This class will handle calling {@link #buildModels(Object)} with the * latest data. *

* You should NOT call {@link #requestModelBuild()} directly. * * @see Typed2EpoxyController * @see Typed3EpoxyController * @see Typed4EpoxyController */ public abstract class TypedEpoxyController extends EpoxyController { private T currentData; private boolean allowModelBuildRequests; public TypedEpoxyController() { } public TypedEpoxyController(Handler modelBuildingHandler, Handler diffingHandler) { super(modelBuildingHandler, diffingHandler); } public final void setData(T data) { currentData = data; allowModelBuildRequests = true; requestModelBuild(); allowModelBuildRequests = false; } @Override public final void requestModelBuild() { if (!allowModelBuildRequests) { throw new IllegalStateException( "You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + "model refresh with new data."); } super.requestModelBuild(); } @Override public void moveModel(int fromPosition, int toPosition) { allowModelBuildRequests = true; super.moveModel(fromPosition, toPosition); allowModelBuildRequests = false; } @Override public void requestDelayedModelBuild(int delayMs) { if (!allowModelBuildRequests) { throw new IllegalStateException( "You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + "model refresh with new data."); } super.requestDelayedModelBuild(delayMs); } @Nullable public final T getCurrentData() { return currentData; } @Override protected final void buildModels() { if (!isBuildingModels()) { throw new IllegalStateException( "You cannot call `buildModels` directly. Call `setData` instead to trigger a model " + "refresh with new data."); } buildModels(currentData); } protected abstract void buildModels(T data); } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/UnboundedViewPool.kt ================================================ package com.airbnb.epoxy import android.util.SparseArray import androidx.recyclerview.widget.RecyclerView.RecycledViewPool import androidx.recyclerview.widget.RecyclerView.ViewHolder import java.util.LinkedList import java.util.Queue /** * Like its parent, UnboundedViewPool lets you share Views between multiple RecyclerViews. However * there is no maximum number of recycled views that it will store. This usually ends up being * optimal, barring any hard memory constraints, as RecyclerViews do not recycle more Views than * they need. */ internal class UnboundedViewPool : RecycledViewPool() { private val scrapHeaps = SparseArray>() override fun clear() { scrapHeaps.clear() } override fun setMaxRecycledViews(viewType: Int, max: Int) { throw UnsupportedOperationException( "UnboundedViewPool does not support setting a maximum number of recycled views" ) } override fun getRecycledView(viewType: Int): ViewHolder? { val scrapHeap = scrapHeaps.get(viewType) return scrapHeap?.poll() } override fun putRecycledView(viewHolder: ViewHolder) { getScrapHeapForType(viewHolder.itemViewType).add(viewHolder) } override fun getRecycledViewCount(viewType: Int): Int { return scrapHeaps.get(viewType)?.size ?: 0 } private fun getScrapHeapForType(viewType: Int): Queue { var scrapHeap: Queue? = scrapHeaps.get(viewType) if (scrapHeap == null) { scrapHeap = LinkedList() scrapHeaps.put(viewType, scrapHeap) } return scrapHeap } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/UpdateOp.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import androidx.annotation.IntDef; import androidx.annotation.Nullable; /** Defines an operation that makes a change to the epoxy model list. */ class UpdateOp { @IntDef({ADD, REMOVE, UPDATE, MOVE}) @Retention(RetentionPolicy.SOURCE) @interface Type { } static final int ADD = 0; static final int REMOVE = 1; static final int UPDATE = 2; static final int MOVE = 3; @Type int type; int positionStart; /** Holds the target position if this is a MOVE */ int itemCount; ArrayList> payloads; private UpdateOp() { } static UpdateOp instance(@Type int type, int positionStart, int itemCount, @Nullable EpoxyModel payload) { UpdateOp op = new UpdateOp(); op.type = type; op.positionStart = positionStart; op.itemCount = itemCount; op.addPayload(payload); return op; } /** Returns the index one past the last item in the affected range. */ int positionEnd() { return positionStart + itemCount; } boolean isAfter(int position) { return position < positionStart; } boolean isBefore(int position) { return position >= positionEnd(); } boolean contains(int position) { return position >= positionStart && position < positionEnd(); } void addPayload(@Nullable EpoxyModel payload) { if (payload == null) { return; } if (payloads == null) { // In most cases this won't be a batch update so we can expect just one payload payloads = new ArrayList<>(1); } else if (payloads.size() == 1) { // There are multiple payloads, but we don't know how big the batch will end up being. // To prevent resizing the list many times we bump it to a medium size payloads.ensureCapacity(10); } payloads.add(payload); } @Override public String toString() { return "UpdateOp{" + "type=" + type + ", positionStart=" + positionStart + ", itemCount=" + itemCount + '}'; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/UpdateOpHelper.java ================================================ package com.airbnb.epoxy; import com.airbnb.epoxy.UpdateOp.Type; import java.util.ArrayList; import java.util.List; import androidx.annotation.Nullable; import static com.airbnb.epoxy.UpdateOp.ADD; import static com.airbnb.epoxy.UpdateOp.MOVE; import static com.airbnb.epoxy.UpdateOp.REMOVE; import static com.airbnb.epoxy.UpdateOp.UPDATE; /** Helper class to collect changes in a diff, batching when possible. */ class UpdateOpHelper { final List opList = new ArrayList<>(); // We have to be careful to update all item positions in the list when we // do a MOVE. This adds some complexity. // To do this we keep track of all moves and apply them to an item when we // need the up to date position final List moves = new ArrayList<>(); private UpdateOp lastOp; private int numInsertions; private int numInsertionBatches; private int numRemovals; private int numRemovalBatches; void reset() { opList.clear(); moves.clear(); lastOp = null; numInsertions = 0; numInsertionBatches = 0; numRemovals = 0; numRemovalBatches = 0; } void add(int indexToInsert) { add(indexToInsert, 1); } void add(int startPosition, int itemCount) { numInsertions += itemCount; // We can append to a previously ADD batch if the new items are added anywhere in the // range of the previous batch batch boolean batchWithLast = isLastOp(ADD) && (lastOp.contains(startPosition) || lastOp.positionEnd() == startPosition); if (batchWithLast) { addItemsToLastOperation(itemCount, null); } else { numInsertionBatches++; addNewOperation(ADD, startPosition, itemCount); } } void update(int indexToChange) { update(indexToChange, null); } void update(final int indexToChange, EpoxyModel payload) { if (isLastOp(UPDATE)) { if (lastOp.positionStart == indexToChange + 1) { // Change another item at the start of the batch range addItemsToLastOperation(1, payload); lastOp.positionStart = indexToChange; } else if (lastOp.positionEnd() == indexToChange) { // Add another item at the end of the batch range addItemsToLastOperation(1, payload); } else if (lastOp.contains(indexToChange)) { // This item is already included in the existing batch range, so we don't add any items // to the batch count, but we still need to add the new payload addItemsToLastOperation(0, payload); } else { // The item can't be batched with the previous update operation addNewOperation(UPDATE, indexToChange, 1, payload); } } else { addNewOperation(UPDATE, indexToChange, 1, payload); } } void remove(int indexToRemove) { remove(indexToRemove, 1); } void remove(int startPosition, int itemCount) { numRemovals += itemCount; boolean batchWithLast = false; if (isLastOp(REMOVE)) { if (lastOp.positionStart == startPosition) { // Remove additional items at the end of the batch range batchWithLast = true; } else if (lastOp.isAfter(startPosition) && startPosition + itemCount >= lastOp.positionStart) { // Removes additional items at the start and (possibly) end of the batch lastOp.positionStart = startPosition; batchWithLast = true; } } if (batchWithLast) { addItemsToLastOperation(itemCount, null); } else { numRemovalBatches++; addNewOperation(REMOVE, startPosition, itemCount); } } private boolean isLastOp(@UpdateOp.Type int updateType) { return lastOp != null && lastOp.type == updateType; } private void addNewOperation(@Type int type, int position, int itemCount) { addNewOperation(type, position, itemCount, null); } private void addNewOperation(@Type int type, int position, int itemCount, @Nullable EpoxyModel payload) { lastOp = UpdateOp.instance(type, position, itemCount, payload); opList.add(lastOp); } private void addItemsToLastOperation(int numItemsToAdd, EpoxyModel payload) { lastOp.itemCount += numItemsToAdd; lastOp.addPayload(payload); } void move(int from, int to) { // We can't batch moves lastOp = null; UpdateOp op = UpdateOp.instance(MOVE, from, to, null); opList.add(op); moves.add(op); } int getNumRemovals() { return numRemovals; } boolean hasRemovals() { return numRemovals > 0; } int getNumInsertions() { return numInsertions; } boolean hasInsertions() { return numInsertions > 0; } int getNumMoves() { return moves.size(); } int getNumInsertionBatches() { return numInsertionBatches; } int getNumRemovalBatches() { return numRemovalBatches; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ViewHolderState.java ================================================ package com.airbnb.epoxy; import android.os.Parcel; import android.os.Parcelable; import android.util.SparseArray; import android.view.View; import com.airbnb.epoxy.ViewHolderState.ViewState; import com.airbnb.viewmodeladapter.R; import java.util.Collection; import androidx.collection.LongSparseArray; /** * Helper for {@link EpoxyAdapter} to store the state of Views in the adapter. This is useful for * saving changes due to user input, such as text input or selection, when a view is scrolled off * screen or if the adapter needs to be recreated. *

* This saved state is separate from the state represented by a {@link EpoxyModel}, which should * represent the more permanent state of the data shown in the view. This class stores transient * state that is added to the View after it is bound to a {@link EpoxyModel}. For example, a {@link * EpoxyModel} may inflate and bind an EditText and then be responsible for styling it and attaching * listeners. If the user then inputs text, scrolls the view offscreen and then scrolls back, this * class will preserve the inputted text without the {@link EpoxyModel} needing to be aware of its * existence. *

* This class relies on the adapter having stable ids, as the state of a view is mapped to the id of * the {@link EpoxyModel}. */ @SuppressWarnings("WeakerAccess") class ViewHolderState extends LongSparseArray implements Parcelable { ViewHolderState() { } private ViewHolderState(int size) { super(size); } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { final int size = size(); dest.writeInt(size); for (int i = 0; i < size; i++) { dest.writeLong(keyAt(i)); dest.writeParcelable(valueAt(i), 0); } } public static final Creator CREATOR = new Creator() { public ViewHolderState[] newArray(int size) { return new ViewHolderState[size]; } public ViewHolderState createFromParcel(Parcel source) { int size = source.readInt(); ViewHolderState state = new ViewHolderState(size); for (int i = 0; i < size; i++) { long key = source.readLong(); ViewState value = source.readParcelable(ViewState.class.getClassLoader()); state.put(key, value); } return state; } }; public boolean hasStateForHolder(EpoxyViewHolder holder) { return get(holder.getItemId()) != null; } public void save(Collection holders) { for (EpoxyViewHolder holder : holders) { save(holder); } } /** Save the state of the view bound to the given holder. */ public void save(EpoxyViewHolder holder) { if (!holder.getModel().shouldSaveViewState()) { return; } // Reuse the previous sparse array if available. We shouldn't need to clear it since the // exact same view type is being saved to it, which // should have identical ids for all its views, and will just overwrite the previous state. ViewState state = get(holder.getItemId()); if (state == null) { state = new ViewState(); } state.save(holder.itemView); put(holder.getItemId(), state); } /** * If a state was previously saved for this view holder via {@link #save} it will be restored * here. */ public void restore(EpoxyViewHolder holder) { if (!holder.getModel().shouldSaveViewState()) { return; } ViewState state = get(holder.getItemId()); if (state != null) { state.restore(holder.itemView); } else { // The first time a model is bound it won't have previous state. We need to make sure // the view is reset to its initial state to clear any changes from previously bound models holder.restoreInitialViewState(); } } /** * A wrapper around a sparse array as a helper to save the state of a view. This also adds * parcelable support. */ public static class ViewState extends SparseArray implements Parcelable { ViewState() { } private ViewState(int size, int[] keys, Parcelable[] values) { super(size); for (int i = 0; i < size; ++i) { put(keys[i], values[i]); } } public void save(View view) { int originalId = view.getId(); setIdIfNoneExists(view); view.saveHierarchyState(this); view.setId(originalId); } public void restore(View view) { int originalId = view.getId(); setIdIfNoneExists(view); view.restoreHierarchyState(this); view.setId(originalId); } /** * If a view hasn't had an id set we need to set a temporary one in order to save state, since a * view won't save its state unless it has an id. The view's id is also the key into the sparse * array for its saved state, so the temporary one we choose just needs to be consistent between * saving and restoring state. */ private void setIdIfNoneExists(View view) { if (view.getId() == View.NO_ID) { view.setId(R.id.view_model_state_saving_id); } } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel parcel, int flags) { int size = size(); int[] keys = new int[size]; Parcelable[] values = new Parcelable[size]; for (int i = 0; i < size; ++i) { keys[i] = keyAt(i); values[i] = valueAt(i); } parcel.writeInt(size); parcel.writeIntArray(keys); parcel.writeParcelableArray(values, flags); } public static final Creator CREATOR = new Parcelable.ClassLoaderCreator() { @Override public ViewState createFromParcel(Parcel source, ClassLoader loader) { int size = source.readInt(); int[] keys = new int[size]; source.readIntArray(keys); Parcelable[] values = source.readParcelableArray(loader); return new ViewState(size, keys, values); } @Override public ViewState createFromParcel(Parcel source) { return createFromParcel(source, null); } @Override public ViewState[] newArray(int size) { return new ViewState[size]; } }; } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/ViewTypeManager.java ================================================ package com.airbnb.epoxy; import java.util.HashMap; import java.util.Map; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; class ViewTypeManager { private static final Map VIEW_TYPE_MAP = new HashMap<>(); /** * The last model that had its view type looked up. This is stored so in most cases we can quickly * look up what view type belongs to which model. */ @Nullable EpoxyModel lastModelForViewTypeLookup; /** * The type map is static so that models of the same class share the same views across different * adapters. This is useful for view recycling when the adapter instance changes, or when there * are multiple adapters. For testing purposes though it is good to be able to clear the map so we * don't carry over values across tests. */ @VisibleForTesting void resetMapForTesting() { VIEW_TYPE_MAP.clear(); } int getViewTypeAndRememberModel(EpoxyModel model) { lastModelForViewTypeLookup = model; return getViewType(model); } static int getViewType(EpoxyModel model) { int defaultViewType = model.getViewType(); if (defaultViewType != 0) { return defaultViewType; } // If a model does not specify a view type then we generate a value to use for models of that // class. Class modelClass = model.getClass(); Integer viewType = VIEW_TYPE_MAP.get(modelClass); if (viewType == null) { viewType = -VIEW_TYPE_MAP.size() - 1; VIEW_TYPE_MAP.put(modelClass, viewType); } return viewType; } /** * Find the model that has the given view type so we can create a view for that model. In most * cases this value is a layout resource and we could simply inflate it, but to support {@link * EpoxyModelWithView} we can't assume the view type is a layout. In that case we need to lookup * the model so we can ask it to create a new view for itself. *

* To make this efficient, we rely on the RecyclerView implementation detail that {@link * BaseEpoxyAdapter#getItemViewType(int)} is called immediately before {@link * BaseEpoxyAdapter#onCreateViewHolder(android.view.ViewGroup, int)} . We cache the last model * that had its view type looked up, and unless that implementation changes we expect to have a * very fast lookup for the correct model. *

* To be safe, we fallback to searching through all models for a view type match. This is slow and * shouldn't be needed, but is a guard against recyclerview behavior changing. */ EpoxyModel getModelForViewType(BaseEpoxyAdapter adapter, int viewType) { if (lastModelForViewTypeLookup != null && getViewType(lastModelForViewTypeLookup) == viewType) { // We expect this to be a hit 100% of the time return lastModelForViewTypeLookup; } adapter.onExceptionSwallowed( new IllegalStateException("Last model did not match expected view type")); // To be extra safe in case RecyclerView implementation details change... for (EpoxyModel model : adapter.getCurrentModels()) { if (getViewType(model) == viewType) { return model; } } // Check for the hidden model. HiddenEpoxyModel hiddenEpoxyModel = new HiddenEpoxyModel(); if (viewType == hiddenEpoxyModel.getViewType()) { return hiddenEpoxyModel; } throw new IllegalStateException("Could not find model for view type: " + viewType); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/VisibilityState.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import androidx.annotation.IntDef; public final class VisibilityState { @Retention(RetentionPolicy.SOURCE) @IntDef({VISIBLE, INVISIBLE, FOCUSED_VISIBLE, UNFOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, PARTIAL_IMPRESSION_INVISIBLE}) public @interface Visibility { } /** * Event triggered when a Component enters the Visible Range. This happens when at least a pixel * of the Component is visible. */ public static final int VISIBLE = 0; /** * Event triggered when a Component becomes invisible. This is the same with exiting the Visible * Range, the Focused Range and the Full Impression Range. All the code that needs to be executed * when a component leaves any of these ranges should be written in the handler for this event. */ public static final int INVISIBLE = 1; /** * Event triggered when a Component enters the Focused Range. This happens when either the * Component occupies at least half of the viewport or, if the Component is smaller than half of * the viewport, when the it is fully visible. */ public static final int FOCUSED_VISIBLE = 2; /** * Event triggered when a Component exits the Focused Range. The Focused Range is defined as at * least half of the viewport or, if the Component is smaller than half of the viewport, when the * it is fully visible. */ public static final int UNFOCUSED_VISIBLE = 3; /** * Event triggered when a Component enters the Full Impression Range. This happens, for instance * in the case of a vertical RecyclerView, when both the top and bottom edges of the component * become visible. */ public static final int FULL_IMPRESSION_VISIBLE = 4; /** * Event triggered when a Component enters the Partial Impression Range. This happens, for * instance in the case of a vertical RecyclerView, when the percentage of the visible area is * at least the specified threshold. The threshold can be set in * {@link EpoxyVisibilityTracker#setPartialImpressionThresholdPercentage(int)}. */ public static final int PARTIAL_IMPRESSION_VISIBLE = 5; /** * Event triggered when a Component exits the Partial Impression Range. This happens, for * instance in the case of a vertical RecyclerView, when the percentage of the visible area is * less than a specified threshold. The threshold can be set in * {@link EpoxyVisibilityTracker#setPartialImpressionThresholdPercentage(int)}. */ public static final int PARTIAL_IMPRESSION_INVISIBLE = 6; } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/WrappedEpoxyModelCheckedChangeListener.java ================================================ package com.airbnb.epoxy; import android.widget.CompoundButton; import android.widget.CompoundButton.OnCheckedChangeListener; import androidx.recyclerview.widget.RecyclerView; /** * Used in the generated models to transform normal checked change listener to model * checked change. */ public class WrappedEpoxyModelCheckedChangeListener, V> implements OnCheckedChangeListener { private final OnModelCheckedChangeListener originalCheckedChangeListener; public WrappedEpoxyModelCheckedChangeListener( OnModelCheckedChangeListener checkedListener ) { if (checkedListener == null) { throw new IllegalArgumentException("Checked change listener cannot be null"); } this.originalCheckedChangeListener = checkedListener; } @Override public void onCheckedChanged(CompoundButton button, boolean isChecked) { EpoxyViewHolder epoxyHolder = ListenersUtils.getEpoxyHolderForChildView(button); if (epoxyHolder == null) { // Initial binding can trigger the checked changed listener when the checked value is set. // The view is not attached at this point so the holder can't be looked up, and in any case // it is generally better to not trigger a callback for the binding anyway, since it isn't // a user action. // // https://github.com/airbnb/epoxy/issues/797 return; } final int adapterPosition = epoxyHolder.getAdapterPosition(); if (adapterPosition != RecyclerView.NO_POSITION) { originalCheckedChangeListener .onChecked((T) epoxyHolder.getModel(), (V) epoxyHolder.objectToBind(), button, isChecked, adapterPosition); } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof WrappedEpoxyModelCheckedChangeListener)) { return false; } WrappedEpoxyModelCheckedChangeListener that = (WrappedEpoxyModelCheckedChangeListener) o; return originalCheckedChangeListener.equals(that.originalCheckedChangeListener); } @Override public int hashCode() { return originalCheckedChangeListener.hashCode(); } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/WrappedEpoxyModelClickListener.kt ================================================ package com.airbnb.epoxy import android.view.View import android.view.View.OnClickListener import android.view.View.OnLongClickListener import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView /** * Used in the generated models to transform normal view click listeners to model click * listeners. */ class WrappedEpoxyModelClickListener, V> : OnClickListener, OnLongClickListener { // Save the original click listener to call back to when clicked. // This also lets us call back to the original hashCode and equals methods private val originalClickListener: OnModelClickListener? private val originalLongClickListener: OnModelLongClickListener? constructor(clickListener: OnModelClickListener?) { requireNotNull(clickListener) { "Click listener cannot be null" } this.originalClickListener = clickListener originalLongClickListener = null } constructor(clickListener: OnModelLongClickListener?) { requireNotNull(clickListener) { "Click listener cannot be null" } this.originalLongClickListener = clickListener originalClickListener = null } override fun onClick(view: View) { val modelInfo = getClickedModelInfo(view) ?: return @Suppress("UNCHECKED_CAST") originalClickListener?.onClick( modelInfo.model as T, modelInfo.boundObject as V, view, modelInfo.adapterPosition ) ?: error("Original click listener is null") } override fun onLongClick(view: View): Boolean { val modelInfo = getClickedModelInfo(view) ?: return false @Suppress("UNCHECKED_CAST") return originalLongClickListener?.onLongClick( modelInfo.model as T, modelInfo.boundObject as V, view, modelInfo.adapterPosition ) ?: error("Original long click listener is null") } private fun getClickedModelInfo(view: View): ClickedModelInfo? { val epoxyHolder = ListenersUtils.getEpoxyHolderForChildView(view) ?: error("Could not find RecyclerView holder for clicked view") val adapterPosition = epoxyHolder.adapterPosition if (adapterPosition == RecyclerView.NO_POSITION) return null val boundObject = epoxyHolder.objectToBind() val holderToUse = if (boundObject is ModelGroupHolder) { // For a model group the clicked view could belong to any of the nested models in the group. // We check the viewholder of each model to see if the clicked view is in that hierarchy // in order to figure out which model it belongs to. // If it doesn't match any of the nested models then it could be set by the top level // parent model. boundObject.viewHolders .firstOrNull { view in it.itemView.allViewsInHierarchy } ?: epoxyHolder } else { epoxyHolder } // We return the holder and position because since we may be returning a nested group // holder the callee cannot use that to get the adapter position of the main model. return ClickedModelInfo( holderToUse.model, adapterPosition, holderToUse.objectToBind() ) } private class ClickedModelInfo( val model: EpoxyModel<*>, val adapterPosition: Int, val boundObject: Any ) /** * Returns a sequence of this View plus any and all children, recursively. */ private val View.allViewsInHierarchy: Sequence get() { return if (this is ViewGroup) { children.flatMap { sequenceOf(it) + if (it is ViewGroup) it.allViewsInHierarchy else emptySequence() }.plus(this) } else { sequenceOf(this) } } /** Returns a [Sequence] over the child views in this view group. */ internal val ViewGroup.children: Sequence get() = object : Sequence { override fun iterator() = this@children.iterator() } /** Returns a [MutableIterator] over the views in this view group. */ internal operator fun ViewGroup.iterator() = object : MutableIterator { private var index = 0 override fun hasNext() = index < childCount override fun next() = getChildAt(index++) ?: throw IndexOutOfBoundsException() override fun remove() = removeViewAt(--index) } override fun equals(other: Any?): Boolean { if (this === other) { return true } if (other !is WrappedEpoxyModelClickListener<*, *>) { return false } if (if (originalClickListener != null) { originalClickListener != other.originalClickListener } else { other.originalClickListener != null } ) { return false } return if (originalLongClickListener != null) { originalLongClickListener == other.originalLongClickListener } else { other.originalLongClickListener == null } } override fun hashCode(): Int { var result = originalClickListener?.hashCode() ?: 0 result = 31 * result + (originalLongClickListener?.hashCode() ?: 0) return result } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/preload/EpoxyModelPreloader.kt ================================================ package com.airbnb.epoxy.preload import android.view.View import com.airbnb.epoxy.EpoxyModel /** * Describes how view content for an EpoxyModel should be preloaded. * * @param T The type of EpoxyModel that this preloader applies to * @param U The type of view metadata to provide to the request builder. * @param P The type of [PreloadRequestHolder] that will execute the preload request */ abstract class EpoxyModelPreloader, U : ViewMetadata?, P : PreloadRequestHolder>( val modelType: Class, /** * A list of view ids, one for each view that should be preloaded. * This should be left empty if the EpoxyModel's type uses the [Preloadable] interface. */ val preloadableViewIds: List ) { /** * An optional signature to differentiate views within the same model. This is useful if your EpoxyModel can contain varying amounts of preloadable views, * or preloadable views of varying sizes. * * By default the model's class, span size, and layout resource, are used to differentiate views. This signature allows additional differentiation. * For example, if your EpoxyModel shows an preloadable view that varies between portrait or landscape, this orientation will affect the view dimensions. * In this case you could return a boolean here to differentiate the two cases so that the preloaded data has the correct orientation. * * The returned object can be anything, but it must implement [Object.hashCode] */ open fun viewSignature(epoxyModel: T): Any? = null /** * Provide optional metadata about a view. This can be used in [EpoxyModelPreloader.buildRequest] * * A preload request works best if it exactly matches the actual request (in order to match cache keys exactly) * Things such as request transformations, thumbnails, or crop type can affect the cache key. * If your preloadable view is configurable you can capture those options via this metadata. */ abstract fun buildViewMetadata(view: View): U /** * Start a preload request with the given target. * * @param epoxyModel The EpoxyModel whose content is being preloaded. * @param preloadTarget The target to ues to create and store the request. * @param viewData Information about the view that will hold the preloaded content. */ abstract fun startPreload( epoxyModel: T, preloadTarget: P, viewData: ViewData ) companion object { /** * Helper to create a [EpoxyModelPreloader]. * * @param viewSignature see [EpoxyModelPreloader.viewSignature] * @param preloadableViewIds see [EpoxyModelPreloader.preloadableViewIds] * @param viewMetadata see [EpoxyModelPreloader.buildViewMetadata] * @param doPreload see [EpoxyModelPreloader.startPreload] */ inline fun , P : PreloadRequestHolder> with( preloadableViewIds: List = emptyList(), noinline doPreload: (epoxyModel: T, preloadTarget: P, viewData: ViewData) -> Unit ): EpoxyModelPreloader = with( preloadableViewIds, viewMetadata = { ViewMetadata.getDefault(it) }, viewSignature = { null }, doPreload = doPreload ) /** * Helper to create a [EpoxyModelPreloader]. * * @param viewSignature see [EpoxyModelPreloader.viewSignature] * @param preloadableViewIds see [EpoxyModelPreloader.preloadableViewIds] * @param viewMetadata see [EpoxyModelPreloader.buildViewMetadata] * @param doPreload see [EpoxyModelPreloader.startPreload] */ inline fun , U : ViewMetadata?, P : PreloadRequestHolder> with( preloadableViewIds: List = emptyList(), noinline viewMetadata: (View) -> U, noinline viewSignature: (T) -> Any? = { _ -> null }, noinline doPreload: (epoxyModel: T, preloadTarget: P, viewData: ViewData) -> Unit ): EpoxyModelPreloader = with( preloadableViewIds = preloadableViewIds, epoxyModelClass = T::class.java, viewMetadata = viewMetadata, viewSignature = viewSignature, doPreload = doPreload ) /** * Helper to create a [EpoxyModelPreloader]. This is similar to the other helper methods but not inlined so it can be used with Java. * * @param epoxyModelClass The specific type of EpoxyModel that this preloader is for. * @param viewSignature see [EpoxyModelPreloader.viewSignature] * @param preloadableViewIds see [EpoxyModelPreloader.preloadableViewIds] * @param viewMetadata see [EpoxyModelPreloader.buildViewMetadata] * @param doPreload see [EpoxyModelPreloader.startPreload] */ fun , U : ViewMetadata?, P : PreloadRequestHolder> with( preloadableViewIds: List = emptyList(), epoxyModelClass: Class, viewMetadata: (View) -> U, viewSignature: (T) -> Any? = { _ -> null }, doPreload: (epoxyModel: T, preloadTarget: P, viewData: ViewData) -> Unit ): EpoxyModelPreloader = object : EpoxyModelPreloader( modelType = epoxyModelClass, preloadableViewIds = preloadableViewIds ) { override fun buildViewMetadata(view: View) = viewMetadata(view) override fun viewSignature(epoxyModel: T) = viewSignature(epoxyModel) override fun startPreload(epoxyModel: T, preloadTarget: P, viewData: ViewData) { doPreload(epoxyModel, preloadTarget, viewData) } } } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/preload/EpoxyPreloader.kt ================================================ package com.airbnb.epoxy.preload import android.content.Context import android.view.View import android.widget.ImageView import androidx.annotation.IdRes import androidx.annotation.Px import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.BaseEpoxyAdapter import com.airbnb.epoxy.EpoxyAdapter import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.getModelForPositionInternal import kotlin.math.max import kotlin.math.min /** * A scroll listener that prefetches view content. * * To use this, create implementations of [EpoxyModelPreloader] for each EpoxyModel class that you want to preload. * Then, use the [EpoxyPreloader.with] methods to create an instance that preloads models of that type. * Finally, add the resulting scroll listener to your RecyclerView. * * If you are using [com.airbnb.epoxy.EpoxyRecyclerView] then use [com.airbnb.epoxy.EpoxyRecyclerView.addPreloader] * to setup the preloader as a listener. * * Otherwise there is a [RecyclerView.addEpoxyPreloader] extension for easy usage. */ class EpoxyPreloader

private constructor( private val adapter: BaseEpoxyAdapter, preloadTargetFactory: () -> P, errorHandler: PreloadErrorHandler, private val maxItemsToPreload: Int, modelPreloaders: List> ) : RecyclerView.OnScrollListener() { private var lastVisibleRange: IntRange = IntRange.EMPTY private var lastPreloadRange: IntProgression = IntRange.EMPTY private var totalItemCount = -1 private var scrollState: Int = RecyclerView.SCROLL_STATE_IDLE private val modelPreloaders: Map>, EpoxyModelPreloader<*, *, out P>> = modelPreloaders.associateBy { it.modelType } private val requestHolderFactory = PreloadTargetProvider(maxItemsToPreload, preloadTargetFactory) private val viewDataCache = PreloadableViewDataProvider(adapter, errorHandler) constructor( epoxyController: EpoxyController, requestHolderFactory: () -> P, errorHandler: PreloadErrorHandler, maxItemsToPreload: Int, modelPreloaders: List> ) : this( epoxyController.adapter, requestHolderFactory, errorHandler, maxItemsToPreload, modelPreloaders ) constructor( adapter: EpoxyAdapter, requestHolderFactory: () -> P, errorHandler: PreloadErrorHandler, maxItemsToPreload: Int, modelPreloaders: List> ) : this( adapter as BaseEpoxyAdapter, requestHolderFactory, errorHandler, maxItemsToPreload, modelPreloaders ) init { require(maxItemsToPreload > 0) { "maxItemsToPreload must be greater than 0. Was $maxItemsToPreload" } } override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { scrollState = newState } override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (dx == 0 && dy == 0) { // Sometimes flings register a bunch of 0 dx/dy scroll events. To avoid redundant prefetching we just skip these // Additionally, the first RecyclerView layout notifies a scroll of 0, since that can be an important time for // performance (eg page load) we avoid prefetching at the same time. return } if (dx.isFling() || dy.isFling()) { // We avoid preloading during flings for two reasons // 1. Image requests are expensive and we don't want to drop frames on fling // 2. We'll likely scroll past the preloading item anyway return } // Update item count before anything else because validations depend on it totalItemCount = recyclerView.adapter?.itemCount ?: 0 val layoutManager = recyclerView.layoutManager as LinearLayoutManager val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() val lastVisiblePosition = layoutManager.findLastVisibleItemPosition() if (firstVisiblePosition.isInvalid() || lastVisiblePosition.isInvalid()) { lastVisibleRange = IntRange.EMPTY lastPreloadRange = IntRange.EMPTY return } val visibleRange = IntRange(firstVisiblePosition, lastVisiblePosition) if (visibleRange == lastVisibleRange) { return } val isIncreasing = visibleRange.first > lastVisibleRange.first || visibleRange.last > lastVisibleRange.last val preloadRange = calculatePreloadRange(firstVisiblePosition, lastVisiblePosition, isIncreasing) // Start preload for any items that weren't already preloaded preloadRange .subtract(lastPreloadRange) .forEach { preloadAdapterPosition(it) } lastVisibleRange = visibleRange lastPreloadRange = preloadRange } /** * @receiver The number of pixels scrolled. * @return True if this distance is large enough to be considered a fast fling. */ private fun Int.isFling() = Math.abs(this) > FLING_THRESHOLD_PX private fun calculatePreloadRange( firstVisiblePosition: Int, lastVisiblePosition: Int, isIncreasing: Boolean ): IntProgression { val from = if (isIncreasing) lastVisiblePosition + 1 else firstVisiblePosition - 1 val to = from + if (isIncreasing) maxItemsToPreload - 1 else 1 - maxItemsToPreload return IntProgression.fromClosedRange( rangeStart = from.clampToAdapterRange(), rangeEnd = to.clampToAdapterRange(), step = if (isIncreasing) 1 else -1 ) } /** Check if an item index is valid. It may not be if the adapter is empty, or if adapter changes have been dispatched since the last layout pass. */ private fun Int.isInvalid() = this == RecyclerView.NO_POSITION || this >= totalItemCount private fun Int.clampToAdapterRange() = min(totalItemCount - 1, max(this, 0)) private fun preloadAdapterPosition(position: Int) { @Suppress("UNCHECKED_CAST") val epoxyModel = adapter.getModelForPositionInternal(position) as? EpoxyModel ?: return @Suppress("UNCHECKED_CAST") val preloader = modelPreloaders[epoxyModel::class.java] as? EpoxyModelPreloader, ViewMetadata?, P> ?: return viewDataCache .dataForModel(preloader, epoxyModel, position) .forEach { viewData -> val preloadTarget = requestHolderFactory.next() preloader.startPreload(epoxyModel, preloadTarget, viewData) } } /** * Cancels all current preload requests in progress. */ fun cancelPreloadRequests() { requestHolderFactory.clearAll() } companion object { /** * * Represents a threshold for fast scrolling. * This is a bit arbitrary and was determined by looking at values while flinging vs slow scrolling. * Ideally it would be based on DP, but this is simpler. */ private const val FLING_THRESHOLD_PX = 75 /** * Helper to create a preload scroll listener. Add the result to your RecyclerView. * for different models or content types. * * @param maxItemsToPreload How many items to prefetch ahead of the last bound item * @param errorHandler Called when the preloader encounters an exception. By default this throws only * if the app is not in a debuggle model * @param modelPreloader Describes how view content for the EpoxyModel should be preloaded * @param requestHolderFactory Should create and return a new [PreloadRequestHolder] each time it is invoked */ fun

with( epoxyController: EpoxyController, requestHolderFactory: () -> P, errorHandler: PreloadErrorHandler, maxItemsToPreload: Int, modelPreloader: EpoxyModelPreloader, out ViewMetadata?, out P> ): EpoxyPreloader

= with( epoxyController, requestHolderFactory, errorHandler, maxItemsToPreload, listOf(modelPreloader) ) fun

with( epoxyController: EpoxyController, requestHolderFactory: () -> P, errorHandler: PreloadErrorHandler, maxItemsToPreload: Int, modelPreloaders: List, out ViewMetadata?, out P>> ): EpoxyPreloader

{ return EpoxyPreloader( epoxyController, requestHolderFactory, errorHandler, maxItemsToPreload, modelPreloaders ) } /** Helper to create a preload scroll listener. Add the result to your RecyclerView. */ fun

with( epoxyAdapter: EpoxyAdapter, requestHolderFactory: () -> P, errorHandler: PreloadErrorHandler, maxItemsToPreload: Int, modelPreloaders: List, out ViewMetadata?, out P>> ): EpoxyPreloader

{ return EpoxyPreloader( epoxyAdapter, requestHolderFactory, errorHandler, maxItemsToPreload, modelPreloaders ) } } } class EpoxyPreloadException(errorMessage: String) : RuntimeException(errorMessage) typealias PreloadErrorHandler = (Context, RuntimeException) -> Unit /** * Data about an image view to be preloaded. This data is used to construct a Glide image request. * * @param metadata Any custom, additional data that the [EpoxyModelPreloader] chooses to provide that may be necessary to create the image request. */ class ViewData( @IdRes val viewId: Int, @Px val width: Int, @Px val height: Int, val metadata: U ) interface ViewMetadata { companion object { fun getDefault(view: View): ViewMetadata? { return when (view) { is ImageView -> ImageViewMetadata(view.scaleType) else -> null } } } } /** * Default implementation of [ViewMetadata] for an ImageView. * This data can help the preload request know how to configure itself. */ open class ImageViewMetadata( val scaleType: ImageView.ScaleType ) : ViewMetadata ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/preload/PreloadTargetProvider.kt ================================================ package com.airbnb.epoxy.preload import java.util.ArrayDeque internal class PreloadTargetProvider

( maxPreload: Int, requestHolderFactory: () -> P ) { private val queue = ArrayDeque

((0 until maxPreload).map { requestHolderFactory() }) internal fun next(): P { val result = queue.poll() queue.offer(result) result.clear() return result } fun clearAll() { queue.forEach { it.clear() } } } /** * This is responsible for holding details for a preloading request. * Your implementation can do anything it wants with the request, but it must * cancel and clear itself when [clear] is called. * * It is also recommended that your implementation calls [clear] when your request finishes loading * to avoid unnecessarily hanging onto the request result (assuming the result is also stored in * cache). Otherwise this holder can be stored in a pool for later use and may leak the preloaded * data. */ interface PreloadRequestHolder { /** Clear any ongoing preload request. */ fun clear() } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/preload/Preloadable.kt ================================================ package com.airbnb.epoxy.preload import android.view.View /** * Declares Views that should be preloaded. This can either be implemented by a custom view or by an [EpoxyHolder]. * * The preloadable views can be recursive ie if [Preloadable.viewsToPreload] includes any views that are themselves Preloadable those nested * views will instead by used. */ interface Preloadable { val viewsToPreload: List } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/preload/PreloadableViewDataProvider.kt ================================================ package com.airbnb.epoxy.preload import android.view.View import androidx.core.view.ViewCompat import com.airbnb.epoxy.BaseEpoxyAdapter import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.boundViewHoldersInternal import com.airbnb.epoxy.objectToBindInternal import com.airbnb.epoxy.viewTypeInternal /** * In order to preload content we need to know the size of the view that they it be loaded into. * This class provides the view size, as well as other view metadata that might be necessary to construct the preload request. */ internal class PreloadableViewDataProvider( val adapter: BaseEpoxyAdapter, val errorHandler: PreloadErrorHandler ) { /** * A given model class might have different sized preloadable views depending on configuration. * We use this cache key to separate view configurations. */ private data class CacheKey( val epoxyModelClass: Class>, val spanSize: Int, val viewType: Int, /** An optional, custom signature provided by the model preloader. This allows the user to specify custom cache mixins */ val signature: Any? ) private val cache = mutableMapOf>?>() /** @return A list containing the data necessary to load each view in the given model. */ fun , U : ViewMetadata?, P : PreloadRequestHolder> dataForModel( preloader: EpoxyModelPreloader, epoxyModel: T, position: Int ): List> { val cacheKey = cacheKey(preloader, epoxyModel, position) @Suppress("UNCHECKED_CAST") return cache.getOrPut(cacheKey) { // Look up view data based on currently bound views. This can be null if a matching view type is not found. // In that case we save the null so we know to try the lookup again next time. findViewData(preloader, epoxyModel, cacheKey) } as? List> ?: return emptyList() } private fun > cacheKey( preloader: EpoxyModelPreloader, epoxyModel: T, position: Int ): CacheKey { val modelSpanSize = if (adapter.isMultiSpan) { epoxyModel.spanSize(adapter.spanCount, position, adapter.itemCount) } else { 1 } return CacheKey( epoxyModel.javaClass, modelSpanSize, epoxyModel.viewTypeInternal(), preloader.viewSignature(epoxyModel) ) } private fun , U : ViewMetadata?, P : PreloadRequestHolder> findViewData( preloader: EpoxyModelPreloader, epoxyModel: T, cacheKey: CacheKey ): List>? { // It is a bit tricky to get details on the view to be preloaded, since the view doesn't necessarily exist at the time of preload. // This approach looks at currently bound views and tries to get one who's cache key is the same as what we need. // This should mostly work, since RecyclerViews are generally the same type of views shown repeatedly. // If a model is only shown sporadically we may never be able to get data about it with this approach, which we could address in the future. val holderMatch = adapter.boundViewHoldersInternal().find { val boundModel = it.model if (boundModel::class == epoxyModel::class) { @Suppress("UNCHECKED_CAST") // We need the view sizes, but viewholders can be bound without actually being laid out on screen yet ViewCompat.isAttachedToWindow(it.itemView) && ViewCompat.isLaidOut(it.itemView) && cacheKey(preloader, boundModel as T, it.adapterPosition) == cacheKey } else { false } } val rootView = holderMatch?.itemView ?: return null val boundObject = holderMatch.objectToBindInternal() // Allows usage of view holder models val preloadableViews: List = when { preloader.preloadableViewIds.isNotEmpty() -> rootView.findViews( preloader.preloadableViewIds, epoxyModel ) rootView is Preloadable -> rootView.viewsToPreload boundObject is Preloadable -> boundObject.viewsToPreload else -> emptyList() } if (preloadableViews.isEmpty()) { errorHandler(rootView.context, EpoxyPreloadException("No preloadable views were found in ${epoxyModel.javaClass.simpleName}")) } return preloadableViews .flatMap { it.recursePreloadableViews() } .mapNotNull { it.buildData(preloader, epoxyModel) } } /** Returns child views with the given view ids. */ private fun > View.findViews( viewIds: List, epoxyModel: T ): List { return viewIds.mapNotNull { id -> findViewById(id).apply { if (this == null) errorHandler(context, EpoxyPreloadException("View with id $id in ${epoxyModel.javaClass.simpleName} could not be found.")) } } } /** If a View with the [Preloadable] interface is used we want to get all of the preloadable views contained in that Preloadable instead. */ private fun T.recursePreloadableViews(): List { return if (this is Preloadable) { viewsToPreload.flatMap { it.recursePreloadableViews() } } else { listOf(this) } } private fun , U : ViewMetadata?, P : PreloadRequestHolder> View.buildData( preloader: EpoxyModelPreloader, epoxyModel: T ): ViewData? { // Glide's internal size determiner takes view dimensions and subtracts padding to get target size. // TODO: We could support size overrides by allowing the preloader to specify a size override callback val width = width - paddingLeft - paddingRight val height = height - paddingTop - paddingBottom if (width <= 0 || height <= 0) { // If no placeholder or aspect ratio is used then the view might be empty before its content loads errorHandler(context, EpoxyPreloadException("${this.javaClass.simpleName} in ${epoxyModel.javaClass.simpleName} has zero size. A size must be set to allow preloading.")) return null } return ViewData( id, width, height, preloader.buildViewMetadata(this) ) } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/preload/PreloaderExtensions.kt ================================================ package com.airbnb.epoxy.preload import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.utils.isDebuggable /** * Helper to create and add an [EpoxyPreloader] to this RecyclerView. * * If you are using [com.airbnb.epoxy.EpoxyRecyclerView], prefer[com.airbnb.epoxy.EpoxyRecyclerView.addPreloader] * instead. * * @param maxPreloadDistance How many items to prefetch ahead of the last bound item * @param errorHandler Called when the preloader encounters an exception. By default this throws only * if the app is not in a debuggle model * @param preloader Describes how view content for the EpoxyModel should be preloaded * @param requestHolderFactory Should create and return a new [PreloadRequestHolder] each time it is invoked */ fun , U : ViewMetadata?, P : PreloadRequestHolder> RecyclerView.addEpoxyPreloader( epoxyController: EpoxyController, maxPreloadDistance: Int = 3, errorHandler: PreloadErrorHandler = { context, err -> if (!context.isDebuggable) throw err }, preloader: EpoxyModelPreloader, requestHolderFactory: () -> P ) { EpoxyPreloader.with( epoxyController, requestHolderFactory, errorHandler, maxPreloadDistance, preloader ).let { addOnScrollListener(it) } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/stickyheader/StickyHeaderCallbacks.kt ================================================ package com.airbnb.epoxy.stickyheader import android.view.View import androidx.recyclerview.widget.RecyclerView /** * Adds sticky headers capabilities to any [RecyclerView.Adapter] * combined with [StickyHeaderLinearLayoutManager]. */ interface StickyHeaderCallbacks { /** * Return true if the view at the specified [position] needs to be sticky * else false. */ fun isStickyHeader(position: Int): Boolean //region Optional callbacks /** * Callback to adjusts any necessary properties of the [stickyHeader] view * that is being used as a sticky, eg. elevation. * Default behaviour is no-op. * * [teardownStickyHeaderView] will be called sometime after this method * and before any other calls to this method go through. */ fun setupStickyHeaderView(stickyHeader: View) = Unit /** * Callback to revert any properties changed in [setupStickyHeaderView]. * Default behaviour is no-op. * * Called after [setupStickyHeaderView]. */ fun teardownStickyHeaderView(stickyHeader: View) = Unit //endregion } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/stickyheader/StickyHeaderLinearLayoutManager.kt ================================================ package com.airbnb.epoxy.stickyheader import android.content.Context import android.graphics.PointF import android.os.Build import android.os.Parcelable import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.BaseEpoxyAdapter import kotlinx.android.parcel.Parcelize /** * Adds sticky headers capabilities to your [RecyclerView.Adapter]. * The adapter / controller must override [StickyHeaderCallbacks.isStickyHeader] to * indicate which items are sticky. * * Example usage: * ``` * class StickyHeaderController() : EpoxyController() { * override fun isStickyHeader(position: Int) { * // Write your logic to tell which item is sticky. * } * } * ``` */ class StickyHeaderLinearLayoutManager @JvmOverloads constructor( context: Context, orientation: Int = RecyclerView.VERTICAL, reverseLayout: Boolean = false ) : LinearLayoutManager(context, orientation, reverseLayout) { private var adapter: BaseEpoxyAdapter? = null // Translation for header private var translationX: Float = 0f private var translationY: Float = 0f // Header positions for the currently displayed list and their observer. private val headerPositions = mutableListOf() private val headerPositionsObserver = HeaderPositionsAdapterDataObserver() // Sticky header's ViewHolder and dirty state. private var stickyHeader: View? = null private var stickyHeaderPosition = RecyclerView.NO_POSITION // Save / Restore scroll state private var scrollPosition = RecyclerView.NO_POSITION private var scrollOffset = 0 override fun onAttachedToWindow(recyclerView: RecyclerView) { super.onAttachedToWindow(recyclerView) setAdapter(recyclerView.adapter) } override fun onAdapterChanged(oldAdapter: RecyclerView.Adapter<*>?, newAdapter: RecyclerView.Adapter<*>?) { super.onAdapterChanged(oldAdapter, newAdapter) setAdapter(newAdapter) } @Suppress("UNCHECKED_CAST") private fun setAdapter(newAdapter: RecyclerView.Adapter<*>?) { adapter?.unregisterAdapterDataObserver(headerPositionsObserver) if (newAdapter is BaseEpoxyAdapter) { adapter = newAdapter adapter?.registerAdapterDataObserver(headerPositionsObserver) headerPositionsObserver.onChanged() } else { adapter = null headerPositions.clear() } } override fun onSaveInstanceState(): Parcelable? { return super.onSaveInstanceState()?.let { SavedState( superState = it, scrollPosition = scrollPosition, scrollOffset = scrollOffset ) } } override fun onRestoreInstanceState(state: Parcelable) { (state as SavedState).let { scrollPosition = it.scrollPosition scrollOffset = it.scrollOffset super.onRestoreInstanceState(it.superState) } } override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { val scrolled = restoreView { super.scrollVerticallyBy(dy, recycler, state) } if (scrolled != 0) { updateStickyHeader(recycler, false) } return scrolled } override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { val scrolled = restoreView { super.scrollHorizontallyBy(dx, recycler, state) } if (scrolled != 0) { updateStickyHeader(recycler, false) } return scrolled } override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { restoreView { super.onLayoutChildren(recycler, state) } if (!state.isPreLayout) { updateStickyHeader(recycler, true) } } override fun scrollToPosition(position: Int) = scrollToPositionWithOffset(position, INVALID_OFFSET) override fun scrollToPositionWithOffset(position: Int, offset: Int) = scrollToPositionWithOffset(position, offset, true) private fun scrollToPositionWithOffset(position: Int, offset: Int, adjustForStickyHeader: Boolean) { // Reset pending scroll. setScrollState(RecyclerView.NO_POSITION, INVALID_OFFSET) // Adjusting is disabled. if (!adjustForStickyHeader) { super.scrollToPositionWithOffset(position, offset) return } // There is no header above or the position is a header. val headerIndex = findHeaderIndexOrBefore(position) if (headerIndex == -1 || findHeaderIndex(position) != -1) { super.scrollToPositionWithOffset(position, offset) return } // The position is right below a header, scroll to the header. if (findHeaderIndex(position - 1) != -1) { super.scrollToPositionWithOffset(position - 1, offset) return } // Current sticky header is the same as at the position. Adjust the scroll offset and reset pending scroll. if (stickyHeader != null && headerIndex == findHeaderIndex(stickyHeaderPosition)) { val adjustedOffset = (if (offset != INVALID_OFFSET) offset else 0) + stickyHeader!!.height super.scrollToPositionWithOffset(position, adjustedOffset) return } // Remember this position and offset and scroll to it to trigger creating the sticky header. setScrollState(position, offset) super.scrollToPositionWithOffset(position, offset) } //region Computation // Mainly [RecyclerView] functionality by removing sticky header from calculations override fun computeVerticalScrollExtent(state: RecyclerView.State): Int = restoreView { super.computeVerticalScrollExtent(state) } override fun computeVerticalScrollOffset(state: RecyclerView.State): Int = restoreView { super.computeVerticalScrollOffset(state) } override fun computeVerticalScrollRange(state: RecyclerView.State): Int = restoreView { super.computeVerticalScrollRange(state) } override fun computeHorizontalScrollExtent(state: RecyclerView.State): Int = restoreView { super.computeHorizontalScrollExtent(state) } override fun computeHorizontalScrollOffset(state: RecyclerView.State): Int = restoreView { super.computeHorizontalScrollOffset(state) } override fun computeHorizontalScrollRange(state: RecyclerView.State): Int = restoreView { super.computeHorizontalScrollRange(state) } override fun computeScrollVectorForPosition(targetPosition: Int): PointF? = restoreView { super.computeScrollVectorForPosition(targetPosition) } override fun onFocusSearchFailed( focused: View, focusDirection: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State ): View? = restoreView { super.onFocusSearchFailed(focused, focusDirection, recycler, state) } /** * Perform the [operation] without the sticky header view by * detaching the view -> performing operation -> detaching the view. */ private fun restoreView(operation: () -> T): T { stickyHeader?.let(this::detachView) val result = operation() stickyHeader?.let(this::attachView) return result } //endregion /** * Offsets the vertical location of the sticky header relative to the its default position. */ fun setStickyHeaderTranslationY(translationY: Float) { this.translationY = translationY requestLayout() } /** * Offsets the horizontal location of the sticky header relative to the its default position. */ fun setStickyHeaderTranslationX(translationX: Float) { this.translationX = translationX requestLayout() } /** * Returns true if `view` is the current sticky header. */ fun isStickyHeader(view: View): Boolean = view === stickyHeader /** * Updates the sticky header state (creation, binding, display), to be called whenever there's a layout or scroll */ private fun updateStickyHeader(recycler: RecyclerView.Recycler, layout: Boolean) { val headerCount = headerPositions.size val childCount = childCount if (headerCount > 0 && childCount > 0) { // Find first valid child. var anchorView: View? = null var anchorIndex = -1 var anchorPos = -1 for (i in 0 until childCount) { val child = getChildAt(i) val params = child!!.layoutParams as RecyclerView.LayoutParams if (isViewValidAnchor(child, params)) { anchorView = child anchorIndex = i anchorPos = params.viewAdapterPosition break } } if (anchorView != null && anchorPos != -1) { val headerIndex = findHeaderIndexOrBefore(anchorPos) val headerPos = if (headerIndex != -1) headerPositions[headerIndex] else -1 val nextHeaderPos = if (headerCount > headerIndex + 1) headerPositions[headerIndex + 1] else -1 // Show sticky header if: // - There's one to show; // - It's on the edge or it's not the anchor view; // - Isn't followed by another sticky header; if (headerPos != -1 && (headerPos != anchorPos || isViewOnBoundary(anchorView)) && nextHeaderPos != headerPos + 1 ) { // 1. Ensure existing sticky header, if any, is of correct type. if (stickyHeader != null && getItemViewType(stickyHeader!!) != adapter?.getItemViewType(headerPos)) { // A sticky header was shown before but is not of the correct type. Scrap it. scrapStickyHeader(recycler) } // 2. Ensure sticky header is created, if absent, or bound, if being laid out or the position changed. if (stickyHeader == null) createStickyHeader(recycler, headerPos) // 3. Bind the sticky header if (layout || getPosition(stickyHeader!!) != headerPos) bindStickyHeader(recycler, stickyHeader!!, headerPos) // 4. Draw the sticky header using translation values which depend on orientation, direction and // position of the next header view. stickyHeader?.let { val nextHeaderView: View? = if (nextHeaderPos != -1) { val nextHeaderView = getChildAt(anchorIndex + (nextHeaderPos - anchorPos)) // The header view itself is added to the RecyclerView. Discard it if it comes up. if (nextHeaderView === stickyHeader) null else nextHeaderView } else null it.translationX = getX(it, nextHeaderView) it.translationY = getY(it, nextHeaderView) } return } } } if (stickyHeader != null) { scrapStickyHeader(recycler) } } /** * Creates [RecyclerView.ViewHolder] for [position], including measure / layout, and assigns it to * [stickyHeader]. */ private fun createStickyHeader(recycler: RecyclerView.Recycler, position: Int) { val stickyHeader = recycler.getViewForPosition(position) // Setup sticky header if the adapter requires it. adapter?.setupStickyHeaderView(stickyHeader) // Add sticky header as a child view, to be detached / reattached whenever LinearLayoutManager#fill() is called, // which happens on layout and scroll (see overrides). addView(stickyHeader) measureAndLayout(stickyHeader) // Ignore sticky header, as it's fully managed by this LayoutManager. ignoreView(stickyHeader) this.stickyHeader = stickyHeader this.stickyHeaderPosition = position } /** * Binds the [stickyHeader] for the given [position]. */ private fun bindStickyHeader(recycler: RecyclerView.Recycler, stickyHeader: View, position: Int) { // Bind the sticky header. recycler.bindViewToPosition(stickyHeader, position) stickyHeaderPosition = position measureAndLayout(stickyHeader) // If we have a pending scroll wait until the end of layout and scroll again. if (scrollPosition != RecyclerView.NO_POSITION) { stickyHeader.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { if (Build.VERSION.SDK_INT < 16) stickyHeader.viewTreeObserver.removeGlobalOnLayoutListener(this) else stickyHeader.viewTreeObserver.removeOnGlobalLayoutListener(this) if (scrollPosition != RecyclerView.NO_POSITION) { scrollToPositionWithOffset(scrollPosition, scrollOffset) setScrollState(RecyclerView.NO_POSITION, INVALID_OFFSET) } } }) } } /** * Measures and lays out [stickyHeader]. */ private fun measureAndLayout(stickyHeader: View) { measureChildWithMargins(stickyHeader, 0, 0) when (orientation) { VERTICAL -> stickyHeader.layout(paddingLeft, 0, width - paddingRight, stickyHeader.measuredHeight) else -> stickyHeader.layout(0, paddingTop, stickyHeader.measuredWidth, height - paddingBottom) } } /** * Returns [stickyHeader] to the [RecyclerView]'s [RecyclerView.RecycledViewPool], assigning it * to `null`. * * @param recycler If passed, the sticky header will be returned to the recycled view pool. */ private fun scrapStickyHeader(recycler: RecyclerView.Recycler?) { val stickyHeader = stickyHeader ?: return this.stickyHeader = null this.stickyHeaderPosition = RecyclerView.NO_POSITION // Revert translation values. stickyHeader.translationX = 0f stickyHeader.translationY = 0f // Teardown holder if the adapter requires it. adapter?.teardownStickyHeaderView(stickyHeader) // Stop ignoring sticky header so that it can be recycled. stopIgnoringView(stickyHeader) // Remove and recycle sticky header. removeView(stickyHeader) recycler?.recycleView(stickyHeader) } /** * Returns true when `view` is a valid anchor, ie. the first view to be valid and visible. */ private fun isViewValidAnchor(view: View, params: RecyclerView.LayoutParams): Boolean { return when { !params.isItemRemoved && !params.isViewInvalid -> when (orientation) { VERTICAL -> when { reverseLayout -> view.top + view.translationY <= height + translationY else -> view.bottom - view.translationY >= translationY } else -> when { reverseLayout -> view.left + view.translationX <= width + translationX else -> view.right - view.translationX >= translationX } } else -> false } } /** * Returns true when the `view` is at the edge of the parent [RecyclerView]. */ private fun isViewOnBoundary(view: View): Boolean { return when (orientation) { VERTICAL -> when { reverseLayout -> view.bottom - view.translationY > height + translationY else -> view.top + view.translationY < translationY } else -> when { reverseLayout -> view.right - view.translationX > width + translationX else -> view.left + view.translationX < translationX } } } /** * Returns the position in the Y axis to position the header appropriately, depending on orientation, direction and * [android.R.attr.clipToPadding]. */ private fun getY(headerView: View, nextHeaderView: View?): Float { when (orientation) { VERTICAL -> { var y = translationY if (reverseLayout) { y += (height - headerView.height).toFloat() } if (nextHeaderView != null) { val bottomMargin = (nextHeaderView.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 0 val topMargin = (nextHeaderView.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin ?: 0 y = when { reverseLayout -> (nextHeaderView.bottom + bottomMargin).toFloat().coerceAtLeast(y) else -> (nextHeaderView.top - topMargin - headerView.height).toFloat().coerceAtMost(y) } } return y } else -> return translationY } } /** * Returns the position in the X axis to position the header appropriately, depending on orientation, direction and * [android.R.attr.clipToPadding]. */ private fun getX(headerView: View, nextHeaderView: View?): Float { when (orientation) { HORIZONTAL -> { var x = translationX if (reverseLayout) { x += (width - headerView.width).toFloat() } if (nextHeaderView != null) { val leftMargin = (nextHeaderView.layoutParams as? ViewGroup.MarginLayoutParams)?.leftMargin ?: 0 val rightMargin = (nextHeaderView.layoutParams as? ViewGroup.MarginLayoutParams)?.rightMargin ?: 0 x = when { reverseLayout -> (nextHeaderView.right + rightMargin).toFloat().coerceAtLeast(x) else -> (nextHeaderView.left - leftMargin - headerView.width).toFloat().coerceAtMost(x) } } return x } else -> return translationX } } /** * Finds the header index of `position` in `headerPositions`. */ private fun findHeaderIndex(position: Int): Int { var low = 0 var high = headerPositions.size - 1 while (low <= high) { val middle = (low + high) / 2 when { headerPositions[middle] > position -> high = middle - 1 headerPositions[middle] < position -> low = middle + 1 else -> return middle } } return -1 } /** * Finds the header index of `position` or the one before it in `headerPositions`. */ private fun findHeaderIndexOrBefore(position: Int): Int { var low = 0 var high = headerPositions.size - 1 while (low <= high) { val middle = (low + high) / 2 when { headerPositions[middle] > position -> high = middle - 1 middle < headerPositions.size - 1 && headerPositions[middle + 1] <= position -> low = middle + 1 else -> return middle } } return -1 } /** * Finds the header index of `position` or the one next to it in `headerPositions`. */ private fun findHeaderIndexOrNext(position: Int): Int { var low = 0 var high = headerPositions.size - 1 while (low <= high) { val middle = (low + high) / 2 when { middle > 0 && headerPositions[middle - 1] >= position -> high = middle - 1 headerPositions[middle] < position -> low = middle + 1 else -> return middle } } return -1 } private fun setScrollState(position: Int, offset: Int) { scrollPosition = position scrollOffset = offset } /** * Save / restore existing [RecyclerView] state and * scrolling position and offset. */ @Parcelize data class SavedState( val superState: Parcelable, val scrollPosition: Int, val scrollOffset: Int ) : Parcelable /** * Handles header positions while adapter changes occur. * * This is used in detriment of [RecyclerView.LayoutManager]'s callbacks to control when they're received. */ private inner class HeaderPositionsAdapterDataObserver : RecyclerView.AdapterDataObserver() { override fun onChanged() { // There's no hint at what changed, so go through the adapter. headerPositions.clear() val itemCount = adapter?.itemCount ?: 0 for (i in 0 until itemCount) { val isSticky = adapter?.isStickyHeader(i) ?: false if (isSticky) { headerPositions.add(i) } } // Remove sticky header immediately if the entry it represents has been removed. A layout will follow. if (stickyHeader != null && !headerPositions.contains(stickyHeaderPosition)) { scrapStickyHeader(null) } } override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { // Shift headers below down. val headerCount = headerPositions.size if (headerCount > 0) { var i = findHeaderIndexOrNext(positionStart) while (i != -1 && i < headerCount) { headerPositions[i] = headerPositions[i] + itemCount i++ } } // Add new headers. for (i in positionStart until positionStart + itemCount) { val isSticky = adapter?.isStickyHeader(i) ?: false if (isSticky) { val headerIndex = findHeaderIndexOrNext(i) if (headerIndex != -1) { headerPositions.add(headerIndex, i) } else { headerPositions.add(i) } } } } override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { var headerCount = headerPositions.size if (headerCount > 0) { // Remove headers. for (i in positionStart + itemCount - 1 downTo positionStart) { val index = findHeaderIndex(i) if (index != -1) { headerPositions.removeAt(index) headerCount-- } } // Remove sticky header immediately if the entry it represents has been removed. A layout will follow. if (stickyHeader != null && !headerPositions.contains(stickyHeaderPosition)) { scrapStickyHeader(null) } // Shift headers below up. var i = findHeaderIndexOrNext(positionStart + itemCount) while (i != -1 && i < headerCount) { headerPositions[i] = headerPositions[i] - itemCount i++ } } } override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { // Shift moved headers by toPosition - fromPosition. // Shift headers in-between by -itemCount (reverse if upwards). val headerCount = headerPositions.size if (headerCount > 0) { if (fromPosition < toPosition) { var i = findHeaderIndexOrNext(fromPosition) while (i != -1 && i < headerCount) { val headerPos = headerPositions[i] if (headerPos >= fromPosition && headerPos < fromPosition + itemCount) { headerPositions[i] = headerPos - (toPosition - fromPosition) sortHeaderAtIndex(i) } else if (headerPos >= fromPosition + itemCount && headerPos <= toPosition) { headerPositions[i] = headerPos - itemCount sortHeaderAtIndex(i) } else { break } i++ } } else { var i = findHeaderIndexOrNext(toPosition) loop@ while (i != -1 && i < headerCount) { val headerPos = headerPositions[i] when { headerPos >= fromPosition && headerPos < fromPosition + itemCount -> { headerPositions[i] = headerPos + (toPosition - fromPosition) sortHeaderAtIndex(i) } headerPos in toPosition..fromPosition -> { headerPositions[i] = headerPos + itemCount sortHeaderAtIndex(i) } else -> break@loop } i++ } } } } private fun sortHeaderAtIndex(index: Int) { val headerPos = headerPositions.removeAt(index) val headerIndex = findHeaderIndexOrNext(headerPos) if (headerIndex != -1) { headerPositions.add(headerIndex, headerPos) } else { headerPositions.add(headerPos) } } } } ================================================ FILE: epoxy-adapter/src/main/java/com/airbnb/epoxy/utils/utils.kt ================================================ package com.airbnb.epoxy.utils import android.content.Context import android.content.pm.ApplicationInfo @PublishedApi internal val Context.isDebuggable: Boolean get() = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 ================================================ FILE: epoxy-adapter/src/main/res/layout/view_holder_empty_view.xml ================================================ ================================================ FILE: epoxy-adapter/src/main/res/values/attrs.xml ================================================ ================================================ FILE: epoxy-adapter/src/main/res/values/ids.xml ================================================ ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/DiffPayloadTest.java ================================================ package com.airbnb.epoxy; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentMatcher; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; import static com.airbnb.epoxy.DiffPayload.getModelFromPayload; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNull; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @RunWith(RobolectricTestRunner.class) public class DiffPayloadTest { private final List> models = new ArrayList<>(); private BaseEpoxyAdapter adapter; private AdapterDataObserver observer; @Before public void before() { adapter = new BaseEpoxyAdapter() { @Override List> getCurrentModels() { return models; } }; observer = spy(new AdapterDataObserver() { @Override public void onItemRangeChanged(int positionStart, int itemCount, Object payload) { } }); adapter.registerAdapterDataObserver(observer); } @Test public void payloadsDisabled() { DiffHelper diffHelper = new DiffHelper(adapter, false); TestModel firstModel = new TestModel(); models.add(firstModel); diffHelper.notifyModelChanges(); verify(observer).onItemRangeInserted(0, 1); TestModel updatedFirstModel = firstModel.clone().incrementValue(); models.clear(); models.add(updatedFirstModel); diffHelper.notifyModelChanges(); verify(observer).onItemRangeChanged(0, 1, null); verifyNoMoreInteractions(observer); } @Test public void noPayloadsForNoChanges() { DiffHelper diffHelper = new DiffHelper(adapter, true); TestModel firstModel = new TestModel(); models.add(firstModel); diffHelper.notifyModelChanges(); verify(observer).onItemRangeInserted(0, 1); models.clear(); diffHelper.notifyModelChanges(); verify(observer).onItemRangeRemoved(0, 1); verifyNoMoreInteractions(observer); } @Test public void singlePayload() { DiffHelper diffHelper = new DiffHelper(adapter, true); TestModel firstModel = new TestModel(); models.add(firstModel); diffHelper.notifyModelChanges(); verify(observer).onItemRangeInserted(0, 1); models.clear(); TestModel changedFirstModel = firstModel.clone().incrementValue(); this.models.add(changedFirstModel); diffHelper.notifyModelChanges(); verify(observer).onItemRangeChanged(eq(0), eq(1), argThat(new DiffPayloadMatcher(firstModel))); verifyNoMoreInteractions(observer); } @Test public void batchPayload() { DiffHelper diffHelper = new DiffHelper(adapter, true); TestModel firstModel = new TestModel(); TestModel secondModel = new TestModel(); models.add(firstModel); models.add(secondModel); diffHelper.notifyModelChanges(); TestModel changedFirstModel = firstModel.clone().incrementValue(); TestModel changedSecondModel = secondModel.clone().incrementValue(); models.clear(); models.add(changedFirstModel); models.add(changedSecondModel); diffHelper.notifyModelChanges(); verify(observer) .onItemRangeChanged(eq(0), eq(2), argThat(new DiffPayloadMatcher(firstModel, secondModel))); } @Test public void multiplePayloads() { DiffHelper diffHelper = new DiffHelper(adapter, true); TestModel firstModel = new TestModel(); TestModel secondModel = new TestModel(); TestModel thirdModel = new TestModel(); models.add(firstModel); models.add(thirdModel); diffHelper.notifyModelChanges(); TestModel changedFirstModel = firstModel.clone().incrementValue(); TestModel changedThirdModel = thirdModel.clone().incrementValue(); models.clear(); models.add(changedFirstModel); models.add(secondModel); models.add(changedThirdModel); diffHelper.notifyModelChanges(); verify(observer).onItemRangeChanged(eq(0), eq(1), argThat(new DiffPayloadMatcher(firstModel))); verify(observer).onItemRangeChanged(eq(2), eq(1), argThat(new DiffPayloadMatcher(thirdModel))); } @Test public void getSingleModelFromPayload() { TestModel model = new TestModel(); List payloads = payloadsWithChangedModels(model); EpoxyModel modelFromPayload = getModelFromPayload(payloads, model.id()); assertEquals(model, modelFromPayload); } @Test public void returnsNullWhenNoModelFoundInPayload() { TestModel model = new TestModel(); List payloads = payloadsWithChangedModels(model); EpoxyModel modelFromPayload = getModelFromPayload(payloads, model.id() - 1); assertNull(modelFromPayload); } @Test public void returnsNullForEmptyPayload() { List payloads = new ArrayList<>(); EpoxyModel modelFromPayload = getModelFromPayload(payloads, 2); assertNull(modelFromPayload); } @Test public void getMultipleModelsFromPayload() { TestModel model1 = new TestModel(); TestModel model2 = new TestModel(); List payloads = payloadsWithChangedModels(model1, model2); EpoxyModel modelFromPayload1 = getModelFromPayload(payloads, model1.id()); EpoxyModel modelFromPayload2 = getModelFromPayload(payloads, model2.id()); assertEquals(model1, modelFromPayload1); assertEquals(model2, modelFromPayload2); } @Test public void getSingleModelsFromMultipleDiffPayloads() { TestModel model1 = new TestModel(); DiffPayload diffPayload1 = diffPayloadWithModels(model1); TestModel model2 = new TestModel(); DiffPayload diffPayload2 = diffPayloadWithModels(model2); List payloads = payloadsWithDiffPayloads(diffPayload1, diffPayload2); EpoxyModel modelFromPayload1 = getModelFromPayload(payloads, model1.id()); EpoxyModel modelFromPayload2 = getModelFromPayload(payloads, model2.id()); assertEquals(model1, modelFromPayload1); assertEquals(model2, modelFromPayload2); } @Test public void getMultipleModelsFromMultipleDiffPayloads() { TestModel model1Payload1 = new TestModel(1); TestModel model2Payload1 = new TestModel(2); DiffPayload diffPayload1 = diffPayloadWithModels(model1Payload1, model2Payload1); TestModel model1Payload2 = new TestModel(3); TestModel model2Payload2 = new TestModel(4); DiffPayload diffPayload2 = diffPayloadWithModels(model1Payload2, model2Payload2); List payloads = payloadsWithDiffPayloads(diffPayload1, diffPayload2); EpoxyModel model1FromPayload1 = getModelFromPayload(payloads, model1Payload1.id()); EpoxyModel model2FromPayload1 = getModelFromPayload(payloads, model2Payload1.id()); EpoxyModel model1FromPayload2 = getModelFromPayload(payloads, model1Payload2.id()); EpoxyModel model2FromPayload2 = getModelFromPayload(payloads, model2Payload2.id()); assertEquals(model1Payload1, model1FromPayload1); assertEquals(model2Payload1, model2FromPayload1); assertEquals(model1Payload2, model1FromPayload2); assertEquals(model2Payload2, model2FromPayload2); } static class DiffPayloadMatcher implements ArgumentMatcher { private final DiffPayload expectedPayload; DiffPayloadMatcher(EpoxyModel... changedModels) { List> epoxyModels = Arrays.asList(changedModels); expectedPayload = new DiffPayload(epoxyModels); } @Override public boolean matches(DiffPayload argument) { return expectedPayload.equalsForTesting(argument); } } static DiffPayload diffPayloadWithModels(EpoxyModel... models) { List> epoxyModels = Arrays.asList(models); return new DiffPayload(epoxyModels); } static List payloadsWithDiffPayloads(DiffPayload... diffPayloads) { List payloads = Arrays.asList(diffPayloads); return new ArrayList(payloads); } static List payloadsWithChangedModels(EpoxyModel... models) { return payloadsWithDiffPayloads(diffPayloadWithModels(models)); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/DifferCorrectnessTest.java ================================================ package com.airbnb.epoxy; import com.google.common.collect.Collections2; import junit.framework.Assert; import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Random; import static com.airbnb.epoxy.ModelTestUtils.addModels; import static com.airbnb.epoxy.ModelTestUtils.changeValues; import static com.airbnb.epoxy.ModelTestUtils.convertToTestModels; import static com.airbnb.epoxy.ModelTestUtils.remove; import static com.airbnb.epoxy.ModelTestUtils.removeModelsAfterPosition; import static junit.framework.Assert.assertEquals; @RunWith(RobolectricTestRunner.class) public class DifferCorrectnessTest { private static final boolean SHOW_LOGS = false; /** * If true, will log the time taken on the diff and skip the validation since that takes a long * time for big change sets. */ private static final boolean SPEED_RUN = false; private final TestObserver testObserver = new TestObserver(SHOW_LOGS); private final TestAdapter testAdapter = new TestAdapter(); private final List> models = testAdapter.models; private static long totalDiffMillis = 0; private static long totalDiffOperations = 0; private static long totalDiffs = 0; @BeforeClass public static void beforeClass() { totalDiffMillis = 0; totalDiffOperations = 0; totalDiffs = 0; } @AfterClass public static void afterClass() { if (SPEED_RUN) { System.out.println("Total time for all diffs (ms): " + totalDiffMillis); } else { System.out.println("Total operations for diffs: " + totalDiffOperations); double avgOperations = ((double) totalDiffOperations / totalDiffs); System.out.println("Average operations per diff: " + avgOperations); } } @Before public void setUp() { if (!SPEED_RUN) { testAdapter.registerAdapterDataObserver(testObserver); } } @Test public void noChange() { diffAndValidateWithOpCount(0); } @Test public void simpleUpdate() { addModels(models); diffAndValidate(); changeValues(models); diffAndValidateWithOpCount(1); } @Test public void updateStart() { addModels(models); diffAndValidate(); changeValues(models, 0, models.size() / 2); diffAndValidateWithOpCount(1); } @Test public void updateMiddle() { addModels(models); diffAndValidate(); changeValues(models, models.size() / 3, models.size() * 2 / 3); diffAndValidateWithOpCount(1); } @Test public void updateEnd() { addModels(models); diffAndValidate(); changeValues(models, models.size() / 2, models.size()); diffAndValidateWithOpCount(1); } @Test public void shuffle() { // Tries all permutations of item shuffles, with various list sizes. Also randomizes // item values so that the diff must deal with both item updates and movements for (int i = 0; i < 9; i++) { List> originalModels = new ArrayList<>(); addModels(i, originalModels); int permutationNumber = 0; for (List> permutedModels : Collections2.permutations(originalModels)) { permutationNumber++; // Resetting to the original models each time, otherwise each subsequent permutation is // only a small difference models.clear(); models.addAll(originalModels); diffAndValidate(); models.clear(); models.addAll(permutedModels); changeValues(models); log("\n\n***** Permutation " + permutationNumber + " - List Size: " + i + " ****** \n"); log("old models:\n" + models); log("\n"); log("new models:\n" + models); log("\n"); diffAndValidate(); } } } @Test public void swapEnds() { addModels(models); diffAndValidate(); EpoxyModel firstModel = models.remove(0); EpoxyModel lastModel = models.remove(models.size() - 1); models.add(0, lastModel); models.add(firstModel); diffAndValidateWithOpCount(2); } @Test public void moveFrontToEnd() { addModels(models); diffAndValidate(); EpoxyModel firstModel = models.remove(0); models.add(firstModel); diffAndValidateWithOpCount(1); } @Test public void moveEndToFront() { addModels(models); diffAndValidate(); EpoxyModel lastModel = models.remove(models.size() - 1); models.add(0, lastModel); diffAndValidateWithOpCount(1); } @Test public void moveEndToFrontAndChangeValues() { addModels(models); diffAndValidate(); EpoxyModel lastModel = models.remove(models.size() - 1); models.add(0, lastModel); changeValues(models); diffAndValidateWithOpCount(2); } @Test public void swapHalf() { addModels(models); diffAndValidate(); List> firstHalf = models.subList(0, models.size() / 2); ArrayList> firstHalfCopy = new ArrayList<>(firstHalf); firstHalf.clear(); models.addAll(firstHalfCopy); diffAndValidateWithOpCount(firstHalfCopy.size()); } @Test public void reverse() { addModels(models); diffAndValidate(); Collections.reverse(models); diffAndValidate(); } @Test public void removeAll() { addModels(models); diffAndValidate(); models.clear(); diffAndValidateWithOpCount(1); } @Test public void removeEnd() { addModels(models); diffAndValidate(); int half = models.size() / 2; ModelTestUtils.remove(models, half, half); diffAndValidateWithOpCount(1); } @Test public void removeMiddle() { addModels(models); diffAndValidate(); int third = models.size() / 3; ModelTestUtils.remove(models, third, third); diffAndValidateWithOpCount(1); } @Test public void removeStart() { addModels(models); diffAndValidate(); int half = models.size() / 2; ModelTestUtils.remove(models, 0, half); diffAndValidateWithOpCount(1); } @Test public void multipleRemovals() { addModels(models); diffAndValidate(); int size = models.size(); int tenth = size / 10; // Remove a tenth of the models at the end, middle, and start ModelTestUtils.removeModelsAfterPosition(models, size - tenth); ModelTestUtils.remove(models, size / 2, tenth); ModelTestUtils.remove(models, 0, tenth); diffAndValidateWithOpCount(3); } @Test public void simpleAdd() { addModels(models); diffAndValidateWithOpCount(1); } @Test public void addToStart() { addModels(models); diffAndValidate(); addModels(models, 0); diffAndValidateWithOpCount(1); } @Test public void addToMiddle() { addModels(models); diffAndValidate(); addModels(models, models.size() / 2); diffAndValidateWithOpCount(1); } @Test public void addToEnd() { addModels(models); diffAndValidate(); addModels(models); diffAndValidateWithOpCount(1); } @Test public void multipleInsertions() { addModels(models); diffAndValidate(); addModels(models, 0); addModels(models, models.size() * 2 / 3); addModels(models); diffAndValidateWithOpCount(3); } @Test public void moveTwoInFrontOfInsertion() { addModels(4, models); diffAndValidate(); addModels(1, models, 0); EpoxyModel lastModel = models.remove(models.size() - 1); models.add(0, lastModel); lastModel = models.remove(models.size() - 1); models.add(0, lastModel); diffAndValidate(); } @Test public void randomCombinations() { int maxBatchSize = 3; int maxModelCount = 10; int maxSeed = 100000; // This modifies the models list in a random way many times, with different size lists. for (int modelCount = 1; modelCount < maxModelCount; modelCount++) { for (int randomSeed = 0; randomSeed < maxSeed; randomSeed++) { log("\n\n*** Combination seed " + randomSeed + " Model Count: " + modelCount + " *** \n"); // We keep the list from the previous loop and keep modifying it. This allows us to test // that state is maintained properly between diffs. We just make sure the list size // says the same by adding or removing if necessary int currentModelCount = models.size(); if (currentModelCount < modelCount) { addModels(modelCount - currentModelCount, models); } else if (currentModelCount > modelCount) { removeModelsAfterPosition(models, modelCount); } diffAndValidate(); modifyModelsRandomly(models, maxBatchSize, new Random(randomSeed)); log("\nResulting diff: \n"); diffAndValidate(); } } } private void modifyModelsRandomly(List> models, int maxBatchSize, Random random) { for (int i = 0; i < models.size(); i++) { int batchSize = randInt(1, maxBatchSize, random); switch (random.nextInt(4)) { case 0: // insert log("Inserting " + batchSize + " at " + i); addModels(batchSize, models, i); i += batchSize; break; case 1: // remove int numAvailableToRemove = models.size() - i; batchSize = numAvailableToRemove < batchSize ? numAvailableToRemove : batchSize; log("Removing " + batchSize + " at " + i); remove(models, i, batchSize); break; case 2: // change int numAvailableToChange = models.size() - i; batchSize = numAvailableToChange < batchSize ? numAvailableToChange : batchSize; log("Changing " + batchSize + " at " + i); changeValues(models, i, batchSize); break; case 3: // move int targetPosition = random.nextInt(models.size()); EpoxyModel currentItem = models.remove(i); models.add(targetPosition, currentItem); log("Moving " + i + " to " + targetPosition); break; default: throw new IllegalStateException("unhandled)"); } } } private void diffAndValidate() { diffAndValidateWithOpCount(-1); } private void diffAndValidateWithOpCount(int expectedOperationCount) { testObserver.operationCount = 0; long start = System.currentTimeMillis(); testAdapter.notifyModelsChanged(); long end = System.currentTimeMillis(); totalDiffMillis += (end - start); totalDiffOperations += testObserver.operationCount; totalDiffs++; if (!SPEED_RUN) { if (expectedOperationCount != -1) { assertEquals("Operation count is incorrect", expectedOperationCount, testObserver.operationCount); } List newModels = convertToTestModels(models); checkDiff(testObserver.initialModels, testObserver.modelsAfterDiffing, newModels); testObserver.setUpForNextDiff(newModels); } } private static int randInt(int min, int max, Random rand) { // nextInt is normally exclusive of the top value, // so add 1 to make it inclusive return rand.nextInt((max - min) + 1) + min; } private void log(String text) { log(text, false); } private void log(String text, boolean forceShow) { if (forceShow || SHOW_LOGS) { System.out.println(text); } } private void checkDiff(List modelsBeforeDiff, List modelsAfterDiff, List actualModels) { assertEquals("Diff produces list of different size.", actualModels.size(), modelsAfterDiff.size()); for (int i = 0; i < modelsAfterDiff.size(); i++) { TestModel model = modelsAfterDiff.get(i); final TestModel expected = actualModels.get(i); if (model == InsertedModel.INSTANCE) { // If the item at this index is new then it shouldn't exist in the original list for (TestModel oldModel : modelsBeforeDiff) { Assert.assertNotSame("The inserted model should not exist in the original list", oldModel.id(), expected.id()); } } else { assertEquals("Models at same index should have same id", expected.id(), model.id()); if (model.updated) { // If there was a change operation then the item hashcodes should be different Assert .assertNotSame("Incorrectly updated an item.", model.hashCode(), expected.hashCode()); } else { assertEquals("Models should have same hashcode when not updated", expected.hashCode(), model.hashCode()); } // Clear state so the model can be used again in another diff model.updated = false; } } } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/DifferNotifyTest.java ================================================ package com.airbnb.epoxy; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import java.util.List; import static com.airbnb.epoxy.ModelTestUtils.addModels; import static com.airbnb.epoxy.ModelTestUtils.changeValue; import static com.airbnb.epoxy.ModelTestUtils.remove; import static junit.framework.Assert.assertEquals; /** * Tests that changes to the models via notify calls besides * {@link EpoxyAdapter#notifyModelsChanged()} * will properly update the model state maintained by the differ. */ @RunWith(RobolectricTestRunner.class) public class DifferNotifyTest { private static final int INITIAL_MODEL_COUNT = 20; private static final boolean SHOW_LOGS = false; private final TestObserver testObserver = new TestObserver(SHOW_LOGS); private final TestAdapter adapter = new TestAdapter(); private final List> models = adapter.models; @Test(expected = UnsupportedOperationException.class) public void notifyChange() { adapter.notifyDataSetChanged(); } @Test public void notifyAddedToEmpty() { addModels(models); adapter.notifyItemRangeInserted(0, models.size()); assertCorrectness(); } @Test public void notifyAddedToStart() { addInitialModels(); addModels(models, 0); adapter.notifyItemRangeInserted(0, models.size() - INITIAL_MODEL_COUNT); assertCorrectness(); } @Test public void notifyAddedToEnd() { addInitialModels(); addModels(models, INITIAL_MODEL_COUNT); adapter.notifyItemRangeInserted(INITIAL_MODEL_COUNT, models.size() - INITIAL_MODEL_COUNT); assertCorrectness(); } @Test public void notifyAddedToMiddle() { addInitialModels(); addModels(models, INITIAL_MODEL_COUNT / 2); adapter.notifyItemRangeInserted(INITIAL_MODEL_COUNT / 2, models.size() - INITIAL_MODEL_COUNT); assertCorrectness(); } @Test public void notifyRemoveAll() { addInitialModels(); models.clear(); adapter.notifyItemRangeRemoved(0, INITIAL_MODEL_COUNT); assertCorrectness(); } @Test public void notifyRemoveStart() { addInitialModels(); remove(models, 0, INITIAL_MODEL_COUNT / 2); adapter.notifyItemRangeRemoved(0, INITIAL_MODEL_COUNT / 2); assertCorrectness(); } @Test public void notifyRemoveMiddle() { addInitialModels(); remove(models, INITIAL_MODEL_COUNT / 3, INITIAL_MODEL_COUNT / 3); adapter.notifyItemRangeRemoved(INITIAL_MODEL_COUNT / 3, INITIAL_MODEL_COUNT / 3); assertCorrectness(); } @Test public void notifyRemoveEnd() { addInitialModels(); remove(models, INITIAL_MODEL_COUNT / 2, INITIAL_MODEL_COUNT / 2); adapter.notifyItemRangeRemoved(INITIAL_MODEL_COUNT / 2, INITIAL_MODEL_COUNT / 2); assertCorrectness(); } @Test public void notifyFrontMovedToEnd() { addInitialModels(); EpoxyModel modelToMove = models.remove(0); models.add(modelToMove); adapter.notifyItemMoved(0, INITIAL_MODEL_COUNT - 1); assertCorrectness(); } @Test public void notifyEndMovedToFront() { addInitialModels(); EpoxyModel modelToMove = models.remove(INITIAL_MODEL_COUNT - 1); models.add(0, modelToMove); adapter.notifyItemMoved(INITIAL_MODEL_COUNT - 1, 0); assertCorrectness(); } @Test public void notifyMiddleMovedToEnd() { addInitialModels(); EpoxyModel modelToMove = models.remove(INITIAL_MODEL_COUNT / 2); models.add(modelToMove); adapter.notifyItemMoved(INITIAL_MODEL_COUNT / 2, INITIAL_MODEL_COUNT - 1); assertCorrectness(); } @Test public void notifyMiddleMovedToFront() { addInitialModels(); EpoxyModel modelToMove = models.remove(INITIAL_MODEL_COUNT / 2); models.add(0, modelToMove); adapter.notifyItemMoved(INITIAL_MODEL_COUNT / 2, 0); assertCorrectness(); } @Test public void notifyValuesUpdated() { addInitialModels(); int numModelsUpdated = 0; for (int i = INITIAL_MODEL_COUNT / 3; i < INITIAL_MODEL_COUNT * 2 / 3; i++) { changeValue(models.get(i)); numModelsUpdated++; } adapter.notifyItemRangeChanged(INITIAL_MODEL_COUNT / 3, numModelsUpdated); assertCorrectness(); } private void addInitialModels() { addModels(INITIAL_MODEL_COUNT, models); adapter.notifyModelsChanged(); } private void assertCorrectness() { testObserver.operationCount = 0; adapter.registerAdapterDataObserver(testObserver); adapter.notifyModelsChanged(); adapter.unregisterAdapterDataObserver(testObserver); assertEquals("Should not have any operations", 0, testObserver.operationCount); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyAdapterTest.java ================================================ package com.airbnb.epoxy; import junit.framework.Assert; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import java.util.ArrayList; import java.util.List; import androidx.recyclerview.widget.RecyclerView; import static junit.framework.TestCase.assertFalse; import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @RunWith(RobolectricTestRunner.class) public class EpoxyAdapterTest { @Rule public ExpectedException thrown = ExpectedException.none(); private final TestAdapter testAdapter = new TestAdapter(); private final TestObserver differObserver = new TestObserver(); @Mock RecyclerView.AdapterDataObserver observer; @Before public void setup() { MockitoAnnotations.initMocks(this); testAdapter.registerAdapterDataObserver(observer); } @Test public void testAddModel() { testAdapter.addModel(new TestModel()); verify(observer).onItemRangeInserted(0, 1); assertEquals(1, testAdapter.models.size()); testAdapter.addModel(new TestModel()); verify(observer).onItemRangeInserted(1, 1); assertEquals(2, testAdapter.models.size()); checkDifferState(); } @Test public void testAddModels() { List list = new ArrayList<>(); list.add(new TestModel()); list.add(new TestModel()); testAdapter.addModels(list); verify(observer).onItemRangeInserted(0, 2); assertEquals(2, testAdapter.models.size()); List list2 = new ArrayList<>(); list2.add(new TestModel()); list2.add(new TestModel()); testAdapter.addModels(list2); verify(observer).onItemRangeInserted(2, 2); assertEquals(4, testAdapter.models.size()); checkDifferState(); } @Test public void testAddModelsVarArgs() { testAdapter.addModels(new TestModel(), new TestModel()); verify(observer).onItemRangeInserted(0, 2); assertEquals(2, testAdapter.models.size()); testAdapter.addModels(new TestModel(), new TestModel()); verify(observer).onItemRangeInserted(2, 2); assertEquals(4, testAdapter.models.size()); checkDifferState(); } @Test public void testNotifyModelChanged() { TestModel testModel = new TestModel(); testAdapter.addModels(testModel); testAdapter.notifyModelChanged(testModel); verify(observer).onItemRangeChanged(0, 1, null); checkDifferState(); } @Test public void testNotifyModelChangedWithPayload() { Object payload = new Object(); TestModel testModel = new TestModel(); testAdapter.addModels(testModel); testAdapter.notifyModelChanged(testModel, payload); verify(observer).onItemRangeChanged(0, 1, payload); checkDifferState(); } @Test(expected = IllegalStateException.class) public void testInsertModelBeforeThrowsForInvalidModel() { testAdapter.insertModelBefore(new TestModel(), new TestModel()); } @Test() public void testInsertModelBefore() { TestModel firstModel = new TestModel(); testAdapter.addModels(firstModel); testAdapter.insertModelBefore(new TestModel(), firstModel); verify(observer, times(2)).onItemRangeInserted(0, 1); assertEquals(2, testAdapter.models.size()); assertEquals(firstModel, testAdapter.models.get(1)); checkDifferState(); } @Test(expected = IllegalStateException.class) public void testInsertModelAfterThrowsForInvalidModel() { testAdapter.insertModelAfter(new TestModel(), new TestModel()); } @Test() public void testInsertModelAfter() { TestModel firstModel = new TestModel(); testAdapter.addModels(firstModel); testAdapter.insertModelAfter(new TestModel(), firstModel); verify(observer).onItemRangeInserted(1, 1); assertEquals(2, testAdapter.models.size()); assertEquals(firstModel, testAdapter.models.get(0)); checkDifferState(); } @Test public void testRemoveModels() { TestModel testModel = new TestModel(); testAdapter.addModels(testModel); testAdapter.removeModel(testModel); verify(observer).onItemRangeRemoved(0, 1); assertEquals(0, testAdapter.models.size()); checkDifferState(); } @Test public void testRemoveAllModels() { for (int i = 0; i < 10; i++) { TestModel model = new TestModel(); testAdapter.addModels(model); } testAdapter.removeAllModels(); verify(observer).onItemRangeRemoved(0, 10); assertEquals(0, testAdapter.models.size()); checkDifferState(); } @Test public void testRemoveAllAfterModels() { List models = new ArrayList<>(); for (int i = 0; i < 10; i++) { TestModel model = new TestModel(); models.add(model); testAdapter.addModels(model); } testAdapter.removeAllAfterModel(models.get(5)); verify(observer).onItemRangeRemoved(6, 4); assertEquals(models.subList(0, 6), testAdapter.models); checkDifferState(); } @Test public void testShowModel() { TestModel testModel = new TestModel(); testModel.hide(); testAdapter.addModels(testModel); testAdapter.showModel(testModel); verify(observer).onItemRangeChanged(0, 1, null); assertTrue(testModel.isShown()); checkDifferState(); } @Test public void testShowModels() { TestModel testModel1 = new TestModel(); testModel1.hide(); TestModel testModel2 = new TestModel(); testModel2.hide(); testAdapter.addModels(testModel1, testModel2); testAdapter.showModels(testAdapter.models); verify(observer).onItemRangeChanged(0, 1, null); verify(observer).onItemRangeChanged(1, 1, null); assertTrue(testModel1.isShown()); assertTrue(testModel2.isShown()); checkDifferState(); } @Test public void testShowModelsVarArgs() { TestModel testModel1 = new TestModel(); testModel1.hide(); TestModel testModel2 = new TestModel(); testModel2.hide(); testAdapter.addModels(testModel1, testModel2); testAdapter.showModels(testModel1, testModel2); verify(observer).onItemRangeChanged(0, 1, null); verify(observer).onItemRangeChanged(1, 1, null); assertTrue(testModel1.isShown()); assertTrue(testModel2.isShown()); checkDifferState(); } @Test public void testShowModelsConditionalTrue() { TestModel testModel1 = new TestModel(); testModel1.hide(); TestModel testModel2 = new TestModel(); testModel2.hide(); testAdapter.addModels(testModel1, testModel2); testAdapter.showModels(testAdapter.models, true); verify(observer).onItemRangeChanged(0, 1, null); verify(observer).onItemRangeChanged(1, 1, null); assertTrue(testModel1.isShown()); assertTrue(testModel2.isShown()); checkDifferState(); } @Test public void testShowModelsVarArgsConditionalTrue() { TestModel testModel1 = new TestModel(); testModel1.hide(); TestModel testModel2 = new TestModel(); testModel2.hide(); testAdapter.addModels(testModel1, testModel2); testAdapter.showModels(true, testModel1, testModel2); verify(observer).onItemRangeChanged(0, 1, null); verify(observer).onItemRangeChanged(1, 1, null); assertTrue(testModel1.isShown()); assertTrue(testModel2.isShown()); checkDifferState(); } @Test public void testShowModelsConditionalFalse() { TestModel testModel1 = new TestModel(); TestModel testModel2 = new TestModel(); testAdapter.addModels(testModel1, testModel2); testAdapter.showModels(testAdapter.models, false); verify(observer).onItemRangeChanged(0, 1, null); verify(observer).onItemRangeChanged(1, 1, null); assertFalse(testModel1.isShown()); assertFalse(testModel2.isShown()); checkDifferState(); } @Test public void testShowModelsVarArgsConditionalFalse() { TestModel testModel1 = new TestModel(); TestModel testModel2 = new TestModel(); testAdapter.addModels(testModel1, testModel2); testAdapter.showModels(false, testModel1, testModel2); verify(observer).onItemRangeChanged(0, 1, null); verify(observer).onItemRangeChanged(1, 1, null); assertFalse(testModel1.isShown()); assertFalse(testModel2.isShown()); checkDifferState(); } @Test public void testShowModelNoopIfAlreadyShown() { TestModel testModel = new TestModel(); testAdapter.addModels(testModel); testAdapter.showModel(testModel); verify(observer, times(0)).onItemRangeChanged(0, 1, null); assertTrue(testModel.isShown()); } @Test public void testHideModel() { TestModel testModel = new TestModel(); testAdapter.addModels(testModel); testAdapter.hideModel(testModel); verify(observer).onItemRangeChanged(0, 1, null); assertFalse(testModel.isShown()); checkDifferState(); } @Test public void testHideModels() { TestModel testModel1 = new TestModel(); TestModel testModel2 = new TestModel(); testAdapter.addModels(testModel1, testModel2); testAdapter.hideModels(testAdapter.models); verify(observer).onItemRangeChanged(0, 1, null); verify(observer).onItemRangeChanged(1, 1, null); assertFalse(testModel1.isShown()); assertFalse(testModel2.isShown()); checkDifferState(); } @Test public void testHideModelsVarArgs() { TestModel testModel1 = new TestModel(); TestModel testModel2 = new TestModel(); testAdapter.addModels(testModel1, testModel2); testAdapter.hideModels(testModel1, testModel2); verify(observer).onItemRangeChanged(0, 1, null); verify(observer).onItemRangeChanged(1, 1, null); assertFalse(testModel1.isShown()); assertFalse(testModel2.isShown()); checkDifferState(); } @Test public void testHideAllAfterModel() { List models = new ArrayList<>(); int modelCount = 10; for (int i = 0; i < modelCount; i++) { TestModel model = new TestModel(); models.add(model); testAdapter.addModels(model); } int hideIndex = 5; testAdapter.hideAllAfterModel(models.get(hideIndex)); for (int i = hideIndex + 1; i < modelCount; i++) { verify(observer).onItemRangeChanged(i, 1, null); } for (int i = 0; i < modelCount; i++) { assertEquals(i <= hideIndex, models.get(i).isShown()); } checkDifferState(); } @Test public void testThrowIfChangeModelIdAfterNotify() { TestModel testModel = new TestModel(); testModel.id(100); testAdapter.addModel(testModel); thrown.expect(IllegalEpoxyUsage.class); thrown.expectMessage("Cannot change a model's id after it has been added to the adapter"); testModel.id(200); } @Test public void testAllowSetSameModelIdAfterNotify() { TestModel testModel = new TestModel(); testModel.id(100); testAdapter.addModel(testModel); testModel.id(100); } @Test public void testThrowIfChangeModelIdAfterDiff() { TestModel testModel = new TestModel(); testModel.id(100); testAdapter.models.add(testModel); testAdapter.notifyModelsChanged(); thrown.expect(IllegalEpoxyUsage.class); thrown.expectMessage("Cannot change a model's id after it has been added to the adapter"); testModel.id(200); } /** Make sure that the differ is in a correct state, and then running it produces no changes. */ private void checkDifferState() { differObserver.operationCount = 0; testAdapter.registerAdapterDataObserver(differObserver); testAdapter.notifyModelsChanged(); testAdapter.unregisterAdapterDataObserver(differObserver); Assert.assertEquals("Should not have any operations", 0, differObserver.operationCount); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyControllerTest.java ================================================ package com.airbnb.epoxy; import com.airbnb.epoxy.EpoxyController.Interceptor; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import java.util.ArrayList; import java.util.List; import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; import static junit.framework.Assert.assertFalse; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @RunWith(RobolectricTestRunner.class) public class EpoxyControllerTest { List> savedModels; boolean noExceptionsDuringBasicBuildModels = true; @Test public void basicBuildModels() { AdapterDataObserver observer = mock(AdapterDataObserver.class); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .addTo(this); } @Override protected void onExceptionSwallowed(RuntimeException exception) { noExceptionsDuringBasicBuildModels = false; } }; controller.getAdapter().registerAdapterDataObserver(observer); controller.requestModelBuild(); assertTrue(noExceptionsDuringBasicBuildModels); assertEquals(1, controller.getAdapter().getItemCount()); verify(observer).onItemRangeInserted(0, 1); verifyNoMoreInteractions(observer); } @Test(expected = IllegalEpoxyUsage.class) public void addingSameModelTwiceThrows() { final CarouselModel_ model = new CarouselModel_(); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { add(model); add(model); } }; controller.requestModelBuild(); } @Test public void filterDuplicates() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .id(1) .addTo(this); new TestModel() .id(1) .addTo(this); } }; controller.setFilterDuplicates(true); controller.requestModelBuild(); assertEquals(1, controller.getAdapter().getItemCount()); } boolean exceptionSwallowed; @Test public void exceptionSwallowedWhenDuplicateFiltered() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .id(1) .addTo(this); new TestModel() .id(1) .addTo(this); } @Override protected void onExceptionSwallowed(RuntimeException exception) { exceptionSwallowed = true; } }; controller.setFilterDuplicates(true); controller.requestModelBuild(); assertTrue(exceptionSwallowed); } boolean interceptorCalled; @Test public void interceptorRunsAfterBuildModels() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .addTo(this); } }; controller.addInterceptor(new Interceptor() { @Override public void intercept(List> models) { assertEquals(1, models.size()); interceptorCalled = true; } }); controller.requestModelBuild(); assertTrue(interceptorCalled); assertEquals(1, controller.getAdapter().getItemCount()); } @Test public void interceptorCanAddModels() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .addTo(this); } }; controller.addInterceptor(new Interceptor() { @Override public void intercept(List> models) { models.add(new TestModel()); } }); controller.requestModelBuild(); assertEquals(2, controller.getAdapter().getItemCount()); } @Test(expected = IllegalStateException.class) public void savedModelsCannotBeAddedToLater() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .addTo(this); } }; controller.addInterceptor(new Interceptor() { @Override public void intercept(List> models) { savedModels = models; } }); controller.requestModelBuild(); savedModels.add(new TestModel()); } @Test public void interceptorCanModifyModels() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .addTo(this); } }; controller.addInterceptor(new Interceptor() { @Override public void intercept(List> models) { TestModel model = ((TestModel) models.get(0)); model.value(model.value() + 1); } }); controller.requestModelBuild(); } @Test public void interceptorsRunInOrderAdded() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .addTo(this); } }; controller.addInterceptor(new Interceptor() { @Override public void intercept(List> models) { assertEquals(1, models.size()); models.add(new TestModel()); } }); controller.addInterceptor(new Interceptor() { @Override public void intercept(List> models) { assertEquals(2, models.size()); models.add(new TestModel()); } }); controller.requestModelBuild(); assertEquals(3, controller.getAdapter().getItemCount()); } @Test public void moveModel() { AdapterDataObserver observer = mock(AdapterDataObserver.class); final List testModels = new ArrayList<>(); testModels.add(new TestModel(1)); testModels.add(new TestModel(2)); testModels.add(new TestModel(3)); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { add(testModels); } }; EpoxyControllerAdapter adapter = controller.getAdapter(); adapter.registerAdapterDataObserver(observer); controller.requestModelBuild(); verify(observer).onItemRangeInserted(0, 3); testModels.add(0, testModels.remove(1)); controller.moveModel(1, 0); verify(observer).onItemRangeMoved(1, 0, 1); assertEquals(testModels, adapter.getCurrentModels()); controller.requestModelBuild(); assertEquals(testModels, adapter.getCurrentModels()); verifyNoMoreInteractions(observer); } @Test public void moveModelOtherWay() { AdapterDataObserver observer = mock(AdapterDataObserver.class); final List testModels = new ArrayList<>(); testModels.add(new TestModel(1)); testModels.add(new TestModel(2)); testModels.add(new TestModel(3)); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { add(testModels); } }; EpoxyControllerAdapter adapter = controller.getAdapter(); adapter.registerAdapterDataObserver(observer); controller.requestModelBuild(); verify(observer).onItemRangeInserted(0, 3); testModels.add(2, testModels.remove(1)); controller.moveModel(1, 2); verify(observer).onItemRangeMoved(1, 2, 1); assertEquals(testModels, adapter.getCurrentModels()); controller.requestModelBuild(); assertEquals(testModels, adapter.getCurrentModels()); verifyNoMoreInteractions(observer); } @Test public void multipleMoves() { AdapterDataObserver observer = mock(AdapterDataObserver.class); final List testModels = new ArrayList<>(); testModels.add(new TestModel(1)); testModels.add(new TestModel(2)); testModels.add(new TestModel(3)); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { add(testModels); } }; EpoxyControllerAdapter adapter = controller.getAdapter(); adapter.registerAdapterDataObserver(observer); controller.requestModelBuild(); testModels.add(0, testModels.remove(1)); controller.moveModel(1, 0); verify(observer).onItemRangeMoved(1, 0, 1); testModels.add(2, testModels.remove(1)); controller.moveModel(1, 2); verify(observer).onItemRangeMoved(1, 2, 1); assertEquals(testModels, adapter.getCurrentModels()); controller.requestModelBuild(); assertEquals(testModels, adapter.getCurrentModels()); } @Test public void testDuplicateFilteringDisabledByDefault() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { } }; assertFalse(controller.isDuplicateFilteringEnabled()); } @Test public void testDuplicateFilteringCanBeToggled() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { } }; assertFalse(controller.isDuplicateFilteringEnabled()); controller.setFilterDuplicates(true); assertTrue(controller.isDuplicateFilteringEnabled()); controller.setFilterDuplicates(false); assertFalse(controller.isDuplicateFilteringEnabled()); } @Test public void testGlobalDuplicateFilteringDefault() { EpoxyController.setGlobalDuplicateFilteringDefault(true); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { } }; assertTrue(controller.isDuplicateFilteringEnabled()); controller.setFilterDuplicates(false); assertFalse(controller.isDuplicateFilteringEnabled()); controller.setFilterDuplicates(true); assertTrue(controller.isDuplicateFilteringEnabled()); // Reset static field for future tests EpoxyController.setGlobalDuplicateFilteringDefault(false); } @Test public void testDebugLoggingCanBeToggled() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { } }; assertFalse(controller.isDebugLoggingEnabled()); controller.setDebugLoggingEnabled(true); assertTrue(controller.isDebugLoggingEnabled()); controller.setDebugLoggingEnabled(false); assertFalse(controller.isDebugLoggingEnabled()); } @Test public void testGlobalDebugLoggingDefault() { EpoxyController.setGlobalDebugLoggingEnabled(true); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { } }; assertTrue(controller.isDebugLoggingEnabled()); controller.setDebugLoggingEnabled(false); assertFalse(controller.isDebugLoggingEnabled()); controller.setDebugLoggingEnabled(true); assertTrue(controller.isDebugLoggingEnabled()); // Reset static field for future tests EpoxyController.setGlobalDebugLoggingEnabled(false); } @Test public void testModelBuildListener() { OnModelBuildFinishedListener observer = mock(OnModelBuildFinishedListener.class); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .addTo(this); } }; controller.addModelBuildListener(observer); controller.requestModelBuild(); verify(observer).onModelBuildFinished(any(DiffResult.class)); } @Test public void testRemoveModelBuildListener() { OnModelBuildFinishedListener observer = mock(OnModelBuildFinishedListener.class); EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { new TestModel() .addTo(this); } }; controller.addModelBuildListener(observer); controller.removeModelBuildListener(observer); controller.requestModelBuild(); verify(observer, never()).onModelBuildFinished(any(DiffResult.class)); } @Test public void testDiffInProgress() { EpoxyController controller = new EpoxyController() { @Override protected void buildModels() { assertTrue(this.hasPendingModelBuild()); new TestModel() .addTo(this); } }; assertFalse(controller.hasPendingModelBuild()); controller.requestModelBuild(); // Model build should happen synchronously in tests assertFalse(controller.hasPendingModelBuild()); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyModelGroupTest.kt ================================================ package com.airbnb.epoxy import android.view.View import android.view.ViewGroup import android.view.ViewStub import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.Space import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode @Config(sdk = [21]) @RunWith(ParameterizedRobolectricTestRunner::class) @LooperMode(LooperMode.Mode.LEGACY) class EpoxyModelGroupTest(val useViewStubs: Boolean) { private lateinit var recyclerView: RecyclerView private var topLevelHolder: EpoxyViewHolder? = null private val modelGroupHolder get() = topLevelHolder!!.objectToBind() as ModelGroupHolder @Before fun init() { recyclerView = RecyclerView(ApplicationProvider.getApplicationContext()) topLevelHolder?.unbind() topLevelHolder = null } @After fun unbind() { topLevelHolder!!.unbind() assertEquals(0, modelGroupHolder.viewHolders.size) } private fun bind(modelGroup: EpoxyModelGroup, previousGroup: EpoxyModelGroup? = null) { if (topLevelHolder == null) { topLevelHolder = EpoxyViewHolder(recyclerView, modelGroup.buildView(recyclerView), false) } topLevelHolder!!.bind(modelGroup, previousGroup, emptyList(), 0) } private fun assertModelsBound(modelGroup: EpoxyModelGroup) { assertEquals(modelGroupHolder.viewHolders.size, modelGroup.models.size) modelGroupHolder.viewHolders.forEachIndexed { index, viewHolder -> assertEquals("Model at index $index", viewHolder.model, modelGroup.models[index]) } } @Test fun bindLinearLayout() { createFrameLayoutGroup(3).let { bind(it) assertModelsBound(it) } } @Test fun bind_Unbind_Rebind_LinearLayoutWithLessModels() { val firstGroup = createFrameLayoutGroup(3) bind(firstGroup) unbind() createSpaceGroup(2).let { bind(it, firstGroup) assertModelsBound(it) } } @Test fun bind_Unbind_Rebind_LinearLayoutWithMoreModels() { val firstGroup = createFrameLayoutGroup(3) bind(firstGroup) unbind() createSpaceGroup(4).let { bind(it, firstGroup) assertModelsBound(it) } } @Test fun rebind_LinearLayoutWithSameViewTypes() { val firstGroup = createFrameLayoutGroup(3) bind(firstGroup) createFrameLayoutGroup(4).let { bind(it, firstGroup) assertModelsBound(it) } } @Test fun rebind_LinearLayoutWithMoreModels() { val firstGroup = createFrameLayoutGroup(3) bind(firstGroup) createSpaceGroup(4).let { bind(it, firstGroup) assertModelsBound(it) } } @Test fun rebind_LinearLayoutWithLessModels() { val firstGroup = createFrameLayoutGroup(3) bind(firstGroup) createSpaceGroup(2).let { bind(it, firstGroup) assertModelsBound(it) } } @Test fun viewholdersAreRecycled() { bind(createFrameLayoutGroup(3)) val firstHolders = modelGroupHolder.viewHolders.toSet() unbind() bind(createFrameLayoutGroup(3)) val secondHolders = modelGroupHolder.viewHolders.toSet() assertEquals(firstHolders, secondHolders) } @Test fun viewStubsOutOfOrder() { val models = (0 until 4).map { NestedModelFrameLayout().id(it) } val modelGroup = object : EpoxyModelGroup(0, models) { public override fun buildView(parent: ViewGroup): View { return LinearLayout(parent.context).apply { addView( ViewStub(parent.context).apply { inflatedId = 0 } ) addView( LinearLayout(parent.context).apply { addView( ViewStub(parent.context).apply { inflatedId = 1 } ) addView(Space(parent.context)) addView( ViewStub(parent.context).apply { inflatedId = 2 } ) } ) addView( ViewStub(parent.context).apply { inflatedId = 3 } ) } } } bind(modelGroup) modelGroupHolder.viewHolders.forEachIndexed { index, viewholder -> val view = viewholder.itemView assertEquals(index, view.id) val indexInsideParentView = (view.parent as ViewGroup).indexOfChild(view) when (view.id) { 0 -> assertEquals(0, indexInsideParentView) 1 -> assertEquals(0, indexInsideParentView) 2 -> assertEquals(2, indexInsideParentView) 3 -> assertEquals(2, indexInsideParentView) } } } private fun createFrameLayoutGroup(modelCount: Int): EpoxyModelGroup { val models = (0 until modelCount).map { NestedModelFrameLayout().id(it) } return if (useViewStubs) ViewStubsGroupModel(models) else LinerLayoutGroupModel(models) } private fun createSpaceGroup(modelCount: Int): EpoxyModelGroup { val models = (0 until modelCount).map { NestedModelSpace().id(it) } return if (useViewStubs) ViewStubsGroupModel(models) else LinerLayoutGroupModel(models) } companion object { @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "Use viewstubs: {0}") fun useViewStubsParameters() = listOf( arrayOf(true), arrayOf(false) ) } } private class LinerLayoutGroupModel(models: List>) : EpoxyModelGroup(0, models) { public override fun buildView(parent: ViewGroup): View { return LinearLayout(parent.context) } } private class ViewStubsGroupModel(models: List>) : EpoxyModelGroup(0, models) { public override fun buildView(parent: ViewGroup): View { fun LinearLayout.addStubLayer(): LinearLayout { addView(ViewStub(parent.context)) LinearLayout(parent.context).let { addView(it) return it } } return (0 until models.size).fold(LinearLayout(parent.context)) { linearLayout, _ -> linearLayout.addStubLayer() } } } private class NestedModelFrameLayout : EpoxyModelWithView() { override fun buildView(parent: ViewGroup): FrameLayout { return FrameLayout(parent.context) } } private class NestedModelSpace : EpoxyModelWithView() { override fun buildView(parent: ViewGroup): Space { return Space(parent.context) } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyRecyclerViewTest.kt ================================================ package com.airbnb.epoxy import android.view.View import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode @Config(sdk = [21]) @RunWith(RobolectricTestRunner::class) @LooperMode(LooperMode.Mode.LEGACY) class EpoxyRecyclerViewTest { private class TestModel : EpoxyModel() { override fun getDefaultLayout(): Int = 0 } @Test fun withModels() { val epoxyRecyclerView = EpoxyRecyclerView(ApplicationProvider.getApplicationContext()) var modelsToBuild = 1 epoxyRecyclerView.withModels { repeat(modelsToBuild) { TestModel().id(it).addTo(this) } } // Initial call should build models assertEquals(1, epoxyRecyclerView.adapter?.itemCount) // Can rebuild models with requestModelBuild modelsToBuild = 4 epoxyRecyclerView.requestModelBuild() assertEquals(4, epoxyRecyclerView.adapter?.itemCount) // Setting a different callback should override previous one epoxyRecyclerView.withModels { TestModel().id(1).addTo(this) } assertEquals(1, epoxyRecyclerView.adapter?.itemCount) // requestModelBuild uses new callback epoxyRecyclerView.requestModelBuild() assertEquals(1, epoxyRecyclerView.adapter?.itemCount) } @Test fun buildModelsWith() { val epoxyRecyclerView = EpoxyRecyclerView(ApplicationProvider.getApplicationContext()) var modelsToBuild = 1 epoxyRecyclerView.buildModelsWith(object : EpoxyRecyclerView.ModelBuilderCallback { override fun buildModels(controller: EpoxyController) { repeat(modelsToBuild) { TestModel().id(it).addTo(controller) } } }) // Initial call should build models assertEquals(1, epoxyRecyclerView.adapter?.itemCount) // Can rebuild models with requestModelBuild modelsToBuild = 4 epoxyRecyclerView.requestModelBuild() assertEquals(4, epoxyRecyclerView.adapter?.itemCount) // Setting a different callback should override previous one epoxyRecyclerView.buildModelsWith(object : EpoxyRecyclerView.ModelBuilderCallback { override fun buildModels(controller: EpoxyController) { TestModel().id(1).addTo(controller) } }) assertEquals(1, epoxyRecyclerView.adapter?.itemCount) // requestModelBuild uses new callback epoxyRecyclerView.requestModelBuild() assertEquals(1, epoxyRecyclerView.adapter?.itemCount) } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyViewHolderTest.kt ================================================ package com.airbnb.epoxy import android.view.View import android.view.ViewParent import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.inOrder import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class EpoxyViewHolderTest { private lateinit var epoxyViewHolder: EpoxyViewHolder @Mock lateinit var viewParent: ViewParent @Mock lateinit var view: View @Mock lateinit var model: TestModel @Mock lateinit var previousModel: TestModel @Before fun setup() { MockitoAnnotations.openMocks(this) epoxyViewHolder = EpoxyViewHolder(viewParent, view, false) } @Test fun testBindCallsPreBindWithPrevious() { epoxyViewHolder.bind(model, previousModel, emptyList(), 0) val inOrder = inOrder(model) inOrder.verify(model).preBind(view, previousModel) inOrder.verify(model).bind(view, previousModel) } @Test fun testBindCallsPreBind() { epoxyViewHolder.bind(model, null, emptyList(), 0) val inOrder = inOrder(model) inOrder.verify(model).preBind(view, null) inOrder.verify(model).bind(view) } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerNestedTest.kt ================================================ package com.airbnb.epoxy import android.app.Activity import android.util.Log import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyVisibilityTracker.Companion.DEBUG_LOG import com.airbnb.epoxy.VisibilityState.INVISIBLE import com.airbnb.epoxy.VisibilityState.PARTIAL_IMPRESSION_INVISIBLE import com.airbnb.epoxy.VisibilityState.PARTIAL_IMPRESSION_VISIBLE import com.airbnb.epoxy.VisibilityState.VISIBLE import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLog private typealias AssertHelper = EpoxyVisibilityTrackerTest.AssertHelper private typealias TrackerTestModel = EpoxyVisibilityTrackerTest.TrackerTestModel /** * This class test the EpoxyVisibilityTracker by using a RecyclerView that scroll vertically. The * view port height is provided by Robolectric. * * We are just controlling how many items are displayed with VISIBLE_ITEMS constant. * * In order to control the RecyclerView's height we are using theses qualifiers: * - `mdpi` for density factor 1 * - `h831dp` where : 831 = 56 (ToolBar) + 775 (RecyclerView) */ @Config(sdk = [21], qualifiers = "h831dp-mdpi") @RunWith(RobolectricTestRunner::class) class EpoxyVisibilityTrackerNestedTest { companion object { private const val TAG = "EpoxyVisibilityTrackerNestedTest" /** * Visibility ratio for horizontal carousel */ private const val ONE_AND_HALF_VISIBLE = 1.5f private fun log(message: String) { if (DEBUG_LOG) { Log.d(TAG, message) } } private var ids = 0 } private lateinit var activity: Activity private lateinit var recyclerView: RecyclerView private lateinit var epoxyController: TypedEpoxyController>> private var viewportHeight: Int = 0 private var itemHeight: Int = 0 private var itemWidth: Int = 0 private val epoxyVisibilityTracker = EpoxyVisibilityTracker() /** * For nested visibility what we want is to scroll the parent recycler view and see of the * nested recycler view get visibility updates. */ @Test fun testScrollBy() { if (true) return val testHelper = buildTestData( 10, 10, EpoxyVisibilityTrackerTest.TWO_AND_HALF_VISIBLE, ONE_AND_HALF_VISIBLE ) // At this point we have the 1st and 2nd item visible // The 3rd item is 50% visible // Now scroll to the end for (to in 0..testHelper.size) { var str = "visible : " testHelper.forEachIndexed { y, helpers -> if (helpers[0].visible) { str = "$str[$y ${helpers[0].visibleHeight}] " } } log(str) (recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(to, 10) } // Verify visibility event. We will do a pass on every items and assert visiblity for the // first and second items in the carousel. testHelper.forEachIndexed { y, helpers -> helpers.forEachIndexed { x, helper -> when { // From 0 to 6 nothing should be visible but they should have been visible // during the scroll y < 7 && x == 0 -> { with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, percentVisibleWidth = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = EpoxyVisibilityTrackerTest.ALL_STATES ) } } y < 7 && x == 1 -> { with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, percentVisibleWidth = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE, PARTIAL_IMPRESSION_INVISIBLE, INVISIBLE ) ) } } // Items at row 7 should be partially visible y == 7 && x == 0 -> { with(helper) { assert( visibleHeight = 50, visibleWidth = 100, visible = true, partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE ) ) } } y == 7 && x == 1 -> { with(helper) { assert( visibleHeight = 50, visibleWidth = 50, visible = true, partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE ) ) } } // Items at row 8 and 9 should be entirely visible (on height) y > 7 && x == 0 -> { with(helper) { assert( percentVisibleHeight = 100.0f, percentVisibleWidth = 100.0f, visible = false, partialImpression = true, fullImpression = true, visitedStates = EpoxyVisibilityTrackerTest.ALL_STATES ) } } y > 7 && x == 1 -> { with(helper) { assert( percentVisibleHeight = 100.0f, percentVisibleWidth = 50.0f, visible = false, partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE ) ) } } } log("$y : $x valid") } } } /** * Attach an EpoxyController on the RecyclerView */ private fun buildTestData( verticalSampleSize: Int, horizontalSampleSize: Int, verticalVisibleItemsOnScreen: Float, horizontalVisibleItemsOnScreen: Float ): List> { // Compute individual item height itemHeight = (recyclerView.measuredHeight / verticalVisibleItemsOnScreen).toInt() itemWidth = (recyclerView.measuredWidth / horizontalVisibleItemsOnScreen).toInt() // Build a test sample of sampleSize items val helpers = mutableListOf>().apply { for (i in 0 until verticalSampleSize) { add( mutableListOf().apply { for (j in 0 until horizontalSampleSize) { add(AssertHelper(ids++)) } } ) } } log(helpers.ids()) epoxyController.setData(helpers) return helpers } /** * Setup a RecyclerView and compute item height so we have 3.5 items on screen */ @Before fun setup() { Robolectric.setupActivity(Activity::class.java).apply { setContentView( EpoxyRecyclerView(this).apply { epoxyVisibilityTracker.attach(this) recyclerView = this // Plug an epoxy controller epoxyController = object : TypedEpoxyController>>() { override fun buildModels(data: List>?) { data?.forEachIndexed { index, helpers -> val models = mutableListOf>() helpers.forEach { helper -> models.add( TrackerTestModel( itemPosition = index, itemHeight = itemHeight, itemWidth = itemWidth, helper = helper ).id("$index-${helper.id}") ) } add( CarouselModel_() .id(index) .paddingDp(0) .models(models) ) } } } recyclerView.adapter = epoxyController.adapter } ) viewportHeight = recyclerView.measuredHeight activity = this } ShadowLog.stream = System.out } @After fun tearDown() { epoxyVisibilityTracker.detach(recyclerView) } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerTest.kt ================================================ package com.airbnb.epoxy import android.app.Activity import android.util.Log import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyVisibilityTracker.Companion.DEBUG_LOG import com.airbnb.epoxy.VisibilityState.FOCUSED_VISIBLE import com.airbnb.epoxy.VisibilityState.FULL_IMPRESSION_VISIBLE import com.airbnb.epoxy.VisibilityState.INVISIBLE import com.airbnb.epoxy.VisibilityState.PARTIAL_IMPRESSION_INVISIBLE import com.airbnb.epoxy.VisibilityState.PARTIAL_IMPRESSION_VISIBLE import com.airbnb.epoxy.VisibilityState.UNFOCUSED_VISIBLE import com.airbnb.epoxy.VisibilityState.VISIBLE import org.junit.After import org.junit.Assert import org.junit.Before import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode import org.robolectric.shadows.ShadowLog /** * This class test the EpoxyVisibilityTracker by using a RecyclerView that scroll vertically. The * view port height is provided by Robolectric. * * We are just controlling how many items are displayed with VISIBLE_ITEMS constant. * * In order to control the RecyclerView's height we are using theses qualifiers: * - `mdpi` for density factor 1 * - `h831dp` where : 831 = 56 (ToolBar) + 775 (RecyclerView) */ @Config(sdk = [21], qualifiers = "h831dp-mdpi") @RunWith(RobolectricTestRunner::class) @LooperMode(LooperMode.Mode.LEGACY) class EpoxyVisibilityTrackerTest { companion object { private const val TAG = "EpoxyVisibilityTrackerTest" /** * Make sure the RecyclerView display: * - 2 full items * - 50% of the next item. */ internal const val TWO_AND_HALF_VISIBLE = 2.5f internal val ALL_STATES = intArrayOf( VISIBLE, INVISIBLE, FOCUSED_VISIBLE, UNFOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, PARTIAL_IMPRESSION_INVISIBLE, FULL_IMPRESSION_VISIBLE ) /** * Tolerance used for robolectric ui assertions when comparing data in pixels */ private const val TOLERANCE_PIXELS = 1 private fun log(message: String) { if (DEBUG_LOG) { Log.d(TAG, message) } } private var ids = 0 } private lateinit var activity: Activity private lateinit var recyclerView: RecyclerView private lateinit var epoxyController: TypedEpoxyController> private var viewportHeight: Int = 0 private var itemHeight: Int = 0 private val epoxyVisibilityTracker = EpoxyVisibilityTracker() /** * Test visibility events when loading a recycler view */ @Test fun testDataAttachedToRecyclerView() { val testHelper = buildTestData(10, TWO_AND_HALF_VISIBLE) val firstHalfVisibleItem = 2 val firstInvisibleItem = firstHalfVisibleItem + 1 // Verify visibility event testHelper.forEachIndexed { index, helper -> when { index in 0 until firstHalfVisibleItem -> { // Item expected to be 100% visible with(helper) { assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } } index == firstHalfVisibleItem -> { // Item expected to be 50% visible with(helper) { assert( visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE ) ) } } index in firstInvisibleItem..9 -> { // Item expected not to be visible with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = intArrayOf() ) } } else -> throw IllegalStateException("index should not be bigger than 9") } log("$index valid") } } /** * Test visibility events when loading a recycler view but without any partial visible states */ @Test fun testDataAttachedToRecyclerView_WithoutPartial() { // disable partial visibility states epoxyVisibilityTracker.partialImpressionThresholdPercentage = null val testHelper = buildTestData(10, TWO_AND_HALF_VISIBLE) val firstHalfVisibleItem = 2 val firstInvisibleItem = firstHalfVisibleItem + 1 // Verify visibility event testHelper.forEachIndexed { index, helper -> when { index in 0 until firstHalfVisibleItem -> { // Item expected to be 100% visible with(helper) { assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = false, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } } index == firstHalfVisibleItem -> { // Item expected to be 50% visible with(helper) { assert( visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, partialImpression = false, fullImpression = false, visitedStates = intArrayOf( VISIBLE ) ) } } index in firstInvisibleItem..9 -> { // Item expected not to be visible with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = intArrayOf() ) } } else -> throw IllegalStateException("index should not be bigger than 9") } log("$index valid") } } /** * Test partial visibility events when loading a recycler view */ @Test fun testDataAttachedToRecyclerView_OneElementJustBelowPartialThreshold() { val testHelper = buildTestData(2, 1.49f) val firstAlmostPartiallyVisibleItem = 1 // Verify visibility event testHelper.forEachIndexed { index, helper -> when { index in 0 until firstAlmostPartiallyVisibleItem -> { // Item expected to be 100% visible with(helper) { assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } } index == firstAlmostPartiallyVisibleItem -> { // Item expected to be 49% visible with(helper) { assert( visibleHeight = (itemHeight * 0.49).toInt(), percentVisibleHeight = 49.0f, visible = true, partialImpression = false, fullImpression = false, visitedStates = intArrayOf( VISIBLE ) ) } } else -> throw IllegalStateException("index should not be bigger than 9") } log("$index valid") } } /** * Test visibility events when adding data to a recycler view (item inserted from adapter) */ @Test fun testInsertData() { // Build initial list val testHelper = buildTestData(10, TWO_AND_HALF_VISIBLE) val secondFullyVisibleItemBeforeInsert = testHelper[1] val halfVisibleItemBeforeInsert = testHelper[2] // Insert in visible area val position = 1 val inserted = insertAt(testHelper, position) with(testHelper[position]) { assert( id = inserted.id, visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } with(secondFullyVisibleItemBeforeInsert) { assert( visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, UNFOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } with(halfVisibleItemBeforeInsert) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE, PARTIAL_IMPRESSION_INVISIBLE, INVISIBLE ) ) } } /** * Test visibility events when removing data from a recycler view (item removed from adapter) */ @Test @Ignore // test started failing with robolectric upgrade :/ fun testDeleteData() { // Build initial list val testHelper = buildTestData(10, TWO_AND_HALF_VISIBLE) val halfVisibleItemBeforeDelete = testHelper[2] val firstNonVisibleItemBeforeDelete = testHelper[3] // Delete from visible area val position = 1 val deleted = deleteAt(testHelper, position) with(deleted) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) } with(halfVisibleItemBeforeDelete) { assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } with(firstNonVisibleItemBeforeDelete) { assert( visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE ) ) } } /** * Test visibility events when moving data from a recycler view (item moved within adapter) * * This test is a bit more complex so we will add more data in the sample size so we can test * moving a range. * * What is done : * - build a test adapter with a larger sample : 100 items (20 items per screen) * - make sure first item is in focus * - move the 2 first items to the position 14 * - make sure recycler view is still displaying the focused item (scrolled to ~14) * - make sure the 3rd item is not visible */ @Test fun testMoveDataUp() { val llm = recyclerView.layoutManager as LinearLayoutManager // Build initial list val itemsPerScreen = 20 val testHelper = buildTestData(100, itemsPerScreen.toFloat()) // First item should be visible and in focus Assert.assertEquals(0, llm.findFirstCompletelyVisibleItemPosition()) Assert.assertEquals(20, llm.findLastVisibleItemPosition()) // Move the 2 first items to the position 24 val moved1 = testHelper[0] val moved2 = testHelper[1] val item3 = testHelper[2] moveTwoItems(testHelper, from = 0, to = 14) // Because we moved the item in focus (item 0) and the layout manager will maintain the // focus the recycler view should scroll to end Assert.assertEquals(14, llm.findFirstVisibleItemPosition()) Assert.assertEquals( 14 + itemsPerScreen - 1, llm.findLastCompletelyVisibleItemPosition() ) with(moved1) { // moved 1 should still be in focus so still 100% visible assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } with(moved2) { // moved 2 should still be in focus so still 100% visible assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } with(item3) { // the item after moved2 should not be visible now assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) } } /** * Same kind of test than `testMoveDataUp()` but we move from 24 to 0. */ @Test fun testMoveDataDown() { val llm = recyclerView.layoutManager as LinearLayoutManager // Build initial list val itemsPerScreen = 20 val testHelper = buildTestData(100, itemsPerScreen.toFloat()) // Scroll to item 24, sharp (recyclerView.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(24, 0) // First item should be visible and in focus Assert.assertEquals(24, llm.findFirstCompletelyVisibleItemPosition()) Assert.assertEquals(44, llm.findLastVisibleItemPosition()) // Move the 2 first items to the position 24 val moved1 = testHelper[24] val moved2 = testHelper[25] val item3 = testHelper[26] moveTwoItems(testHelper, from = 24, to = 0) // Because we moved the item in focus (item 0) and the layout manager will maintain the // focus the recycler view should scroll to end Assert.assertEquals(0, llm.findFirstVisibleItemPosition()) Assert.assertEquals(19, llm.findLastCompletelyVisibleItemPosition()) with(moved1) { // moved 1 should still be in focus so still 100% visible assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } with(moved2) { // moved 2 should still be in focus so still 100% visible assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } with(item3) { // the item after moved2 should not be visible now assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) } } /** * Test visibility events using scrollToPosition on the recycler view */ @Test fun testScrollBy() { val testHelper = buildTestData(10, TWO_AND_HALF_VISIBLE) // At this point we have the 1st and 2nd item visible // The 3rd item is 50% visible // Now scroll to the end for (to in 0..testHelper.size) { (recyclerView.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(to, 10) } // Verify visibility event testHelper.forEachIndexed { index, helper -> when { index in 0..1 -> { // Item expected not to be visible but should have visited all states with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) } } index == 2 -> { // This item was only half visible, it was never fully visible with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) } } index in 3..6 -> { // Theses items were never rendered with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) } } index == 7 -> { // Item expected to be 50% visible with(helper) { assert( visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE, UNFOCUSED_VISIBLE ) ) } } index in 8..9 -> { // Item expected to be 100% visible with(helper) { assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } } else -> throw IllegalStateException("index should not be bigger than 9") } log("$index valid") } } /** * Test visibility events using scrollToPosition on the recycler view */ @Test fun testScrollToPosition() { val testHelper = buildTestData(10, TWO_AND_HALF_VISIBLE) // At this point we have the 1st and 2nd item visible // The 3rd item is 50% visible // Now scroll to the end val scrollToPosition = testHelper.size - 1 log("scrollToPosition=$scrollToPosition") recyclerView.scrollToPosition(scrollToPosition) // Verify visibility event testHelper.forEachIndexed { index, helper -> when { index in 0..1 -> { // Item expected not to be visible but should have visited all states with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE, UNFOCUSED_VISIBLE, PARTIAL_IMPRESSION_INVISIBLE, INVISIBLE ) ) } } index == 2 -> { // This item was only half visible, it was never fully visible with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE, PARTIAL_IMPRESSION_INVISIBLE, INVISIBLE ) ) } } index in 3..6 -> { // Theses items were never rendered with(helper) { assert( visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, partialImpression = false, fullImpression = false, visitedStates = intArrayOf() ) } } index == 7 -> { // Item expected to be 50% visible with(helper) { assert( visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE ) ) } } index in 8..9 -> { // Item expected to be 100% visible with(helper) { assert( visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, PARTIAL_IMPRESSION_VISIBLE, FOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) } } else -> throw IllegalStateException("index should not be bigger than 9") } log("$index valid") } } /** * Attach an EpoxyController on the RecyclerView */ private fun buildTestData( sampleSize: Int, visibleItemsOnScreen: Float ): MutableList { // Compute individual item height itemHeight = (recyclerView.measuredHeight / visibleItemsOnScreen).toInt() // Build a test sample of sampleSize items val helpers = mutableListOf().apply { for (index in 0 until sampleSize) add(AssertHelper(ids++)) } log(helpers.ids()) epoxyController.setData(helpers) return helpers } private fun insertAt(helpers: MutableList, position: Int): AssertHelper { log("insert at $position") val helper = AssertHelper(ids++) helpers.add(position, helper) log(helpers.ids()) epoxyController.setData(helpers) return helper } private fun deleteAt(helpers: MutableList, position: Int): AssertHelper { log("delete at $position") val helper = helpers.removeAt(position) log(helpers.ids()) epoxyController.setData(helpers) return helper } private fun moveTwoItems(helpers: MutableList, from: Int, to: Int) { log("move at $from -> $to") val helper1 = helpers.removeAt(from) val helper2 = helpers.removeAt(from) helpers.add(to, helper2) helpers.add(to, helper1) log(helpers.ids()) epoxyController.setData(helpers) } /** * Setup a RecyclerView and compute item height so we have 3.5 items on screen */ @Before fun setup() { Robolectric.setupActivity(Activity::class.java).apply { setContentView( EpoxyRecyclerView(this).apply { epoxyVisibilityTracker.partialImpressionThresholdPercentage = 50 epoxyVisibilityTracker.attach(this) recyclerView = this // Plug an epoxy controller epoxyController = object : TypedEpoxyController>() { override fun buildModels(data: List?) { data?.forEachIndexed { index, helper -> add( TrackerTestModel( itemPosition = index, itemHeight = itemHeight, helper = helper ).id(helper.id) ) } } } recyclerView.adapter = epoxyController.adapter } ) viewportHeight = recyclerView.measuredHeight activity = this } ShadowLog.stream = System.out } @After fun tearDown() { epoxyVisibilityTracker.detach(recyclerView) } /** * Epoxy model used for test */ internal class TrackerTestModel( private val itemPosition: Int, private val itemHeight: Int, private val itemWidth: Int = FrameLayout.LayoutParams.MATCH_PARENT, private val helper: AssertHelper ) : EpoxyModelWithView() { override fun buildView(parent: ViewGroup): View { log("buildView[$itemPosition](id=${helper.id})") return TextView(parent.context).apply { // Force height layoutParams = RecyclerView.LayoutParams(itemWidth, itemHeight) } } override fun onVisibilityChanged(ph: Float, pw: Float, vh: Int, vw: Int, view: View) { helper.percentVisibleHeight = ph helper.percentVisibleWidth = pw helper.visibleHeight = vh helper.visibleWidth = vw if (ph.toInt() != 100) helper.fullImpression = false } override fun onVisibilityStateChanged(state: Int, view: View) { log("onVisibilityStateChanged[$itemPosition](id=${helper.id})=${state.description()}") helper.visitedStates.add(state) when (state) { VISIBLE, INVISIBLE -> helper.visible = state == VISIBLE FOCUSED_VISIBLE, UNFOCUSED_VISIBLE -> helper.focused = state == FOCUSED_VISIBLE PARTIAL_IMPRESSION_VISIBLE, PARTIAL_IMPRESSION_INVISIBLE -> helper.partialImpression = state == PARTIAL_IMPRESSION_VISIBLE FULL_IMPRESSION_VISIBLE -> helper.fullImpression = state == FULL_IMPRESSION_VISIBLE } } } /** * Helper for asserting visibility */ internal class AssertHelper(val id: Int) { var created = false var visitedStates = mutableListOf() var visibleHeight = 0 var visibleWidth = 0 var percentVisibleHeight = 0.0f var percentVisibleWidth = 0.0f var visible = false var focused = false var partialImpression = false var fullImpression = false fun assert( id: Int? = null, visibleHeight: Int? = null, visibleWidth: Int? = null, percentVisibleHeight: Float? = null, percentVisibleWidth: Float? = null, visible: Boolean? = null, partialImpression: Boolean? = null, fullImpression: Boolean? = null, visitedStates: IntArray? = null ) { id?.let { Assert.assertEquals( "id expected $it got ${this.id}", it, this.id ) } visibleHeight?.let { // assert using tolerance, see TOLERANCE_PIXELS log("assert visibleHeight, got $it, expected ${this.visibleHeight}") Assert.assertTrue( "visibleHeight expected ${it}px got ${this.visibleHeight}px", Math.abs(it - this.visibleHeight) <= TOLERANCE_PIXELS ) } visibleWidth?.let { // assert using tolerance, see TOLERANCE_PIXELS log("assert visibleWidth, got $it, expected ${this.visibleWidth}") Assert.assertTrue( "visibleWidth expected ${it}px got ${this.visibleWidth}px", Math.abs(it - this.visibleWidth) <= TOLERANCE_PIXELS ) } percentVisibleHeight?.let { Assert.assertEquals( "percentVisibleHeight expected $it got ${this.percentVisibleHeight}", it, this.percentVisibleHeight, 0.05f ) } percentVisibleWidth?.let { Assert.assertEquals( "percentVisibleWidth expected $it got ${this.percentVisibleWidth}", it, this.percentVisibleWidth, 0.05f ) } visible?.let { Assert.assertEquals( "visible expected $it got ${this.visible}", it, this.visible ) } partialImpression?.let { Assert.assertEquals( "partialImpression expected $it got ${this.partialImpression}", it, this.partialImpression ) } fullImpression?.let { Assert.assertEquals( "fullImpression expected $it got ${this.fullImpression}", it, this.fullImpression ) } visitedStates?.let { assertVisited(it) } } private fun assertVisited(states: IntArray) { val expectedStates = mutableListOf() states.forEach { expectedStates.add(it) } for (state in expectedStates) { if (!visitedStates.contains(state)) { Assert.fail( "Expected visited ${expectedStates.description()}, " + "got ${visitedStates.description()}" ) } } for (state in ALL_STATES) { if (!expectedStates.contains(state) && visitedStates.contains(state)) { Assert.fail( "Expected ${state.description()} not visited, " + "got ${visitedStates.description()}" ) } } } } } internal fun List.ids(): String { val builder = StringBuilder("[") forEachIndexed { index, element -> (element as? EpoxyVisibilityTrackerTest.AssertHelper)?.let { builder.append(it.id) } builder.append(if (index < size - 1) "," else "]") } return builder.toString() } /** * List of Int to VisibilityState constant names. */ private fun List.description(): String { val builder = StringBuilder("[") forEachIndexed { index, state -> builder.append(state.description()) builder.append(if (index < size - 1) "," else "") } builder.append("]") return builder.toString() } /** * Int to VisibilityState constant name. */ private fun Int.description(): String { return when (this) { VISIBLE -> "VISIBLE" INVISIBLE -> "INVISIBLE" FOCUSED_VISIBLE -> "FOCUSED_VISIBLE" UNFOCUSED_VISIBLE -> "UNFOCUSED_VISIBLE" PARTIAL_IMPRESSION_VISIBLE -> "PARTIAL_IMPRESSION_VISIBLE" PARTIAL_IMPRESSION_INVISIBLE -> "PARTIAL_IMPRESSION_INVISIBLE" FULL_IMPRESSION_VISIBLE -> "FULL_IMPRESSION_VISIBLE" else -> throw IllegalStateException("Please declare new state here") } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/InsertedModel.java ================================================ package com.airbnb.epoxy; class InsertedModel extends TestModel { static final InsertedModel INSTANCE = new InsertedModel(); @Override public int getDefaultLayout() { return 0; } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/ModelListTest.java ================================================ package com.airbnb.epoxy; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import static junit.framework.TestCase.assertFalse; import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @RunWith(RobolectricTestRunner.class) public class ModelListTest { private final ModelList.ModelListObserver observer = mock(ModelList.ModelListObserver.class); private final ModelList modelList = new ModelList(); @Before public void before() { modelList.add(new TestModel()); modelList.add(new TestModel()); modelList.add(new TestModel()); modelList.setObserver(observer); } @Test public void testSet() { modelList.set(0, new TestModel()); verify(observer).onItemRangeRemoved(0, 1); verify(observer).onItemRangeInserted(0, 1); } @Test public void testSetSameIdDoesntNotify() { EpoxyModel newModelWithSameId = new TestModel(); newModelWithSameId.id(modelList.get(0).id()); modelList.set(0, newModelWithSameId); verifyNoMoreInteractions(observer); assertEquals(newModelWithSameId, modelList.get(0)); } @Test public void testAdd() { modelList.add(new TestModel()); modelList.add(new TestModel()); verify(observer).onItemRangeInserted(3, 1); verify(observer).onItemRangeInserted(4, 1); } @Test public void testAddAtIndex() { modelList.add(0, new TestModel()); modelList.add(2, new TestModel()); verify(observer).onItemRangeInserted(0, 1); verify(observer).onItemRangeInserted(2, 1); } @Test public void testAddAll() { List> newModels = new ArrayList<>(); newModels.add(new TestModel()); newModels.add(new TestModel()); modelList.addAll(newModels); verify(observer).onItemRangeInserted(3, 2); } @Test public void testAddAllAtIndex() { List> newModels = new ArrayList<>(); newModels.add(new TestModel()); newModels.add(new TestModel()); modelList.addAll(0, newModels); verify(observer).onItemRangeInserted(0, 2); } @Test public void testRemoveIndex() { EpoxyModel removedModel = modelList.remove(0); assertFalse(modelList.contains(removedModel)); assertEquals(2, modelList.size()); verify(observer).onItemRangeRemoved(0, 1); } @Test public void testRemoveObject() { EpoxyModel model = modelList.get(0); boolean model1Removed = modelList.remove(model); assertEquals(2, modelList.size()); assertTrue(model1Removed); assertFalse(modelList.contains(model)); verify(observer).onItemRangeRemoved(0, 1); } @Test public void testRemoveObjectNotAdded() { boolean removed = modelList.remove(new TestModel()); assertFalse(removed); verifyNoMoreInteractions(observer); } @Test public void testClear() { modelList.clear(); verify(observer).onItemRangeRemoved(0, 3); } @Test public void testClearWhenAlreadyEmpty() { modelList.clear(); modelList.clear(); verify(observer).onItemRangeRemoved(0, 3); verifyNoMoreInteractions(observer); } @Test public void testSublistClear() { modelList.subList(0, 2).clear(); verify(observer).onItemRangeRemoved(0, 2); } @Test public void testNoClearWhenEmpty() { modelList.clear(); modelList.clear(); verify(observer).onItemRangeRemoved(0, 3); verifyNoMoreInteractions(observer); } @Test public void testRemoveRange() { modelList.removeRange(0, 2); assertEquals(1, modelList.size()); verify(observer).onItemRangeRemoved(0, 2); } @Test public void testRemoveEmptyRange() { modelList.removeRange(1, 1); verifyNoMoreInteractions(observer); } @Test public void testIteratorRemove() { Iterator> iterator = modelList.iterator(); iterator.next(); iterator.remove(); verify(observer).onItemRangeRemoved(0, 1); } @Test public void testRemoveAll() { List> modelsToRemove = new ArrayList<>(); modelsToRemove.add(modelList.get(0)); modelsToRemove.add(modelList.get(1)); modelList.removeAll(modelsToRemove); verify(observer, times(2)).onItemRangeRemoved(0, 1); } @Test public void testRetainAll() { List> modelsToRetain = new ArrayList<>(); modelsToRetain.add(modelList.get(0)); modelList.retainAll(modelsToRetain); verify(observer, times(2)).onItemRangeRemoved(1, 1); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/ModelTestUtils.java ================================================ package com.airbnb.epoxy; import java.util.ArrayList; import java.util.List; class ModelTestUtils { private static final int DEFAULT_NUM_MODELS = 20; static void changeValues(List models) { changeValues(models, 0, models.size()); } static void changeValues(List models, int start, int count) { for (int i = start; i < count; i++) { models.set(i, ((TestModel) models.get(i)).clone().randomizeValue()); } } static void changeValue(EpoxyModel model) { ((TestModel) model).randomizeValue(); } static void remove(List models, int start, int count) { models.subList(start, start + count).clear(); } static void removeModelsAfterPosition(List models, int start) { remove(models, start, models.size() - start); } static void addModels(List list) { addModels(DEFAULT_NUM_MODELS, list); } static void addModels(int count, List list) { addModels(count, list, list.size()); } static void addModels(List list, int index) { addModels(DEFAULT_NUM_MODELS, list, index); } static void addModels(int count, List list, int index) { List modelsToAdd = new ArrayList<>(count); for (int i = 0; i < count; i++) { modelsToAdd.add(new TestModel()); } list.addAll(index, modelsToAdd); } static List> convertToGenericModels(List list) { List> result = new ArrayList<>(list.size()); for (TestModel testModel : list) { result.add(testModel); } return result; } static List convertToTestModels(List> list) { List result = new ArrayList<>(list.size()); for (EpoxyModel model : list) { result.add((TestModel) model); } return result; } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/TestAdapter.java ================================================ package com.airbnb.epoxy; class TestAdapter extends EpoxyAdapter { TestAdapter() { enableDiffing(); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/TestModel.java ================================================ package com.airbnb.epoxy; import android.view.View; import java.util.Random; public class TestModel extends EpoxyModel { private static final Random RANDOM = new Random(10); boolean updated; private int value; public TestModel() { // Uses a random id to make sure the algorithm doesn't have different behavior for // consecutive or varied ids super(RANDOM.nextLong()); randomizeValue(); } public TestModel(long id) { super(id); randomizeValue(); } TestModel randomizeValue() { value = RANDOM.nextInt(); return this; } @Override public int getDefaultLayout() { return 0; } TestModel value(int value) { this.value = value; return this; } TestModel incrementValue() { this.value++; return this; } int value() { return value; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } if (!super.equals(o)) { return false; } TestModel testModel = (TestModel) o; return value == testModel.value; } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + value; return result; } public TestModel clone() { TestModel clone = new TestModel() .value(value); return (TestModel) clone.id(id()) .layout(getLayout()); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/TestObserver.java ================================================ package com.airbnb.epoxy; import java.util.ArrayList; import java.util.List; import androidx.recyclerview.widget.RecyclerView; class TestObserver extends RecyclerView.AdapterDataObserver { List modelsAfterDiffing = new ArrayList<>(); List initialModels = new ArrayList<>(); int operationCount = 0; private boolean showLogs; TestObserver(boolean showLogs) { this.showLogs = showLogs; } TestObserver() { this(false); } void setUpForNextDiff(List models) { initialModels = new ArrayList<>(models); modelsAfterDiffing = new ArrayList<>(models); } @Override public void onItemRangeChanged(int positionStart, int itemCount) { if (showLogs) { System.out.println("Item range changed. Start: " + positionStart + " Count: " + itemCount); } for (int i = positionStart; i < positionStart + itemCount; i++) { modelsAfterDiffing.get(i).updated = true; } operationCount++; } @Override public void onItemRangeInserted(int positionStart, int itemCount) { if (showLogs) { System.out.println("Item range inserted. Start: " + positionStart + " Count: " + itemCount); } List modelsToAdd = new ArrayList<>(itemCount); for (int i = 0; i < itemCount; i++) { modelsToAdd.add(InsertedModel.INSTANCE); } modelsAfterDiffing.addAll(positionStart, modelsToAdd); operationCount++; } @Override public void onItemRangeRemoved(int positionStart, int itemCount) { if (showLogs) { System.out.println("Item range removed. Start: " + positionStart + " Count: " + itemCount); } modelsAfterDiffing.subList(positionStart, positionStart + itemCount).clear(); operationCount++; } @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { if (showLogs) { System.out.println("Item moved. From: " + fromPosition + " To: " + toPosition); } TestModel itemToMove = modelsAfterDiffing.remove(fromPosition); modelsAfterDiffing.add(toPosition, itemToMove); operationCount++; } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/TypedEpoxyControllerTest.java ================================================ package com.airbnb.epoxy; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.annotation.LooperMode; import org.robolectric.annotation.LooperMode.Mode; import static junit.framework.Assert.assertEquals; @RunWith(RobolectricTestRunner.class) @LooperMode(Mode.LEGACY) public class TypedEpoxyControllerTest { static class TestTypedController extends TypedEpoxyController { int numTimesBuiltModels = 0; @Override protected void buildModels(String data) { assertEquals("data", data); numTimesBuiltModels++; } } @Test public void setData() { TestTypedController controller = new TestTypedController(); controller.setData("data"); controller.setData("data"); controller.cancelPendingModelBuild(); controller.setData("data"); controller.setData("data"); assertEquals(4, controller.numTimesBuiltModels); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/UnboundedViewPoolTests.kt ================================================ package com.airbnb.epoxy import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class UnboundedViewPoolTests { private val testAdapter = RvAdapter() @Test fun `correctly stores different viewHolders`() { UnboundedViewPool().run { addAndAssert(123, 10) addAndAssert(321, 5) } } private fun UnboundedViewPool.addAndAssert(expectedId: Int, expectedCount: Int) { repeat(expectedCount) { putRecycledView(createViewHolder(expectedId)) } assertEquals(expectedCount, getRecycledViewCount(expectedId)) } @Test fun `correctly removes from pool`() { UnboundedViewPool().run { val expectedId = 548 putRecycledView(createViewHolder(expectedId)) assertNotNull(getRecycledView(expectedId)) assertNull(getRecycledView(expectedId)) } } @Test fun `correctly clears pool`() { UnboundedViewPool().run { val expectedId = 10 addAndAssert(expectedId, 10) clear() assertNull(getRecycledView(expectedId)) assertEquals(0, getRecycledViewCount(expectedId)) } } private fun createViewHolder(id: Int) = testAdapter.createViewHolder(FrameLayout(ApplicationProvider.getApplicationContext()), id) /** * Creating adapter is needed because it is setting viewType to viewHolder internally * during creation of new viewHolder */ private class RvAdapter : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = EpoxyViewHolder(parent, View(parent.context), false) override fun getItemCount() = 0 override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {} } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/UpdateOpHelperTest.java ================================================ package com.airbnb.epoxy; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import java.util.List; import static org.junit.Assert.assertEquals; @RunWith(RobolectricTestRunner.class) public class UpdateOpHelperTest { private final UpdateOpHelper helper = new UpdateOpHelper(); @Test public void insertionBatch() { helper.add(0); // New batch helper.add(1); // Add at the end helper.add(0); // Add at the start helper.add(1); // Add in the middle assertEquals(1, helper.getNumInsertionBatches()); assertEquals(4, helper.getNumInsertions()); List opList = helper.opList; assertEquals(1, opList.size()); assertEquals(0, opList.get(0).positionStart); assertEquals(4, opList.get(0).itemCount); assertEquals(0, helper.getNumRemovalBatches()); assertEquals(0, helper.getNumRemovals()); assertEquals(0, helper.getNumMoves()); } @Test public void insertionMultipleBatches() { helper.add(1); // New batch helper.add(3); // New batch helper.add(1); // New batch helper.add(0); // New batch assertEquals(4, helper.getNumInsertionBatches()); assertEquals(4, helper.getNumInsertions()); List opList = helper.opList; assertEquals(4, opList.size()); assertEquals(1, opList.get(0).positionStart); assertEquals(1, opList.get(0).itemCount); assertEquals(3, opList.get(1).positionStart); assertEquals(1, opList.get(1).itemCount); assertEquals(1, opList.get(2).positionStart); assertEquals(1, opList.get(2).itemCount); assertEquals(0, opList.get(3).positionStart); assertEquals(1, opList.get(3).itemCount); } @Test public void insertionBatchRanges() { helper.add(1, 2); helper.add(1, 1); helper.add(4, 1); assertEquals(1, helper.getNumInsertionBatches()); assertEquals(4, helper.getNumInsertions()); List opList = helper.opList; assertEquals(1, opList.size()); assertEquals(1, opList.get(0).positionStart); assertEquals(4, opList.get(0).itemCount); } @Test public void removeBatch() { helper.remove(3); // New batch helper.remove(3); // Remove at the end helper.remove(2); // Remove at the start assertEquals(1, helper.getNumRemovalBatches()); assertEquals(3, helper.getNumRemovals()); List opList = helper.opList; assertEquals(1, opList.size()); assertEquals(2, opList.get(0).positionStart); assertEquals(3, opList.get(0).itemCount); assertEquals(0, helper.getNumInsertionBatches()); assertEquals(0, helper.getNumInsertions()); assertEquals(0, helper.getNumMoves()); } @Test public void removeMultipleBatches() { helper.remove(3); helper.remove(4); helper.remove(2); assertEquals(3, helper.getNumRemovalBatches()); assertEquals(3, helper.getNumRemovals()); List opList = helper.opList; assertEquals(3, opList.size()); assertEquals(3, opList.get(0).positionStart); assertEquals(1, opList.get(0).itemCount); assertEquals(4, opList.get(1).positionStart); assertEquals(1, opList.get(1).itemCount); assertEquals(2, opList.get(2).positionStart); assertEquals(1, opList.get(2).itemCount); } @Test public void removeBatchRange() { helper.remove(3, 2); helper.remove(3, 2); helper.remove(0, 3); assertEquals(1, helper.getNumRemovalBatches()); assertEquals(7, helper.getNumRemovals()); List opList = helper.opList; assertEquals(1, opList.size()); assertEquals(0, opList.get(0).positionStart); assertEquals(7, opList.get(0).itemCount); } @Test public void update() { helper.update(1); // New Batch helper.update(0); // Update at start of batch helper.update(2); // Update at end of batch helper.update(0); // Update same item as before (shouldn't be added to batch length) List opList = helper.opList; assertEquals(1, opList.size()); assertEquals(0, opList.get(0).positionStart); assertEquals(3, opList.get(0).itemCount); assertEquals(0, helper.getNumInsertionBatches()); assertEquals(0, helper.getNumInsertions()); assertEquals(0, helper.getNumRemovalBatches()); assertEquals(0, helper.getNumRemovals()); assertEquals(0, helper.getNumMoves()); } @Test public void updateMultipleBatches() { helper.update(3); helper.update(5); helper.update(3); helper.update(0); List opList = helper.opList; assertEquals(4, opList.size()); assertEquals(3, opList.get(0).positionStart); assertEquals(1, opList.get(0).itemCount); assertEquals(5, opList.get(1).positionStart); assertEquals(1, opList.get(1).itemCount); assertEquals(3, opList.get(2).positionStart); assertEquals(1, opList.get(2).itemCount); assertEquals(0, opList.get(3).positionStart); assertEquals(1, opList.get(3).itemCount); assertEquals(0, helper.getNumInsertionBatches()); assertEquals(0, helper.getNumInsertions()); assertEquals(0, helper.getNumRemovalBatches()); assertEquals(0, helper.getNumRemovals()); assertEquals(0, helper.getNumMoves()); } @Test public void moves() { helper.move(0, 3); helper.move(0, 4); assertEquals(2, helper.getNumMoves()); assertEquals(0, helper.getNumInsertionBatches()); assertEquals(0, helper.getNumInsertions()); assertEquals(0, helper.getNumRemovalBatches()); assertEquals(0, helper.getNumRemovals()); List opList = helper.opList; assertEquals(2, opList.size()); assertEquals(0, opList.get(0).positionStart); assertEquals(3, opList.get(0).itemCount); assertEquals(0, opList.get(0).positionStart); assertEquals(3, opList.get(0).itemCount); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/ViewTypeManagerIntegrationTest.java ================================================ package com.airbnb.epoxy; import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import androidx.annotation.NonNull; import androidx.test.core.app.ApplicationProvider; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @RunWith(RobolectricTestRunner.class) public class ViewTypeManagerIntegrationTest { @Before public void resetViewTypeMap() { new ViewTypeManager().resetMapForTesting(); } static class TestModel extends EpoxyModelWithView { @Override public View buildView(@NonNull ViewGroup parent) { return new FrameLayout(ApplicationProvider.getApplicationContext()); } } static class ModelWithViewType extends TestModel { @Override protected int getViewType() { return 1; } } static class ModelWithViewType2 extends TestModel { @Override protected int getViewType() { return 2; } } @Test public void modelWithLayout() { SimpleEpoxyAdapter adapter = new SimpleEpoxyAdapter(); adapter.addModel(new ModelWithViewType()); adapter.addModel(new ModelWithViewType2()); // The view type should be the value declared in the model assertEquals(1, adapter.getItemViewType(0)); assertEquals(2, adapter.getItemViewType(1)); } static class ModelWithoutViewType extends TestModel {} static class ModelWithoutViewType2 extends TestModel {} static class ModelWithoutViewType3 extends TestModel {} @Test public void modelsWithoutLayoutHaveViewTypesGenerated() { SimpleEpoxyAdapter adapter = new SimpleEpoxyAdapter(); adapter.addModel(new ModelWithoutViewType()); adapter.addModel(new ModelWithoutViewType2()); adapter.addModel(new ModelWithoutViewType3()); adapter.addModel(new ModelWithoutViewType()); adapter.addModel(new ModelWithoutViewType2()); adapter.addModel(new ModelWithoutViewType3()); // Models with view type 0 should have a view type generated for them assertEquals(-1, adapter.getItemViewType(0)); assertEquals(-2, adapter.getItemViewType(1)); assertEquals(-3, adapter.getItemViewType(2)); // Models of same class should share the same generated view type assertEquals(-1, adapter.getItemViewType(3)); assertEquals(-2, adapter.getItemViewType(4)); assertEquals(-3, adapter.getItemViewType(5)); } @Test public void fastModelLookupOfLastModel() { SimpleEpoxyAdapter adapter = spy(new SimpleEpoxyAdapter()); TestModel modelToAdd = spy(new ModelWithoutViewType()); adapter.addModel(modelToAdd); int itemViewType = adapter.getItemViewType(0); adapter.onCreateViewHolder(null, itemViewType); // onExceptionSwallowed is called if the fast model look up failed verify(adapter, never()).onExceptionSwallowed(any(RuntimeException.class)); verify(modelToAdd).buildView(null); } @Test public void fallbackLookupOfUnknownModel() { SimpleEpoxyAdapter adapter = spy(new SimpleEpoxyAdapter()); TestModel modelToAdd = spy(new ModelWithViewType()); adapter.addModel(modelToAdd); // If we pass a view type that hasn't been looked up recently it should fallback to searching // through all models to find a match. adapter.onCreateViewHolder(null, 1); // onExceptionSwallowed is called when the fast model look up fails verify(adapter).onExceptionSwallowed(any(RuntimeException.class)); verify(modelToAdd).buildView(null); } @Test public void viewTypesSharedAcrossAdapters() { SimpleEpoxyAdapter adapter1 = new SimpleEpoxyAdapter(); SimpleEpoxyAdapter adapter2 = new SimpleEpoxyAdapter(); adapter1.addModel(new ModelWithoutViewType()); adapter1.addModel(new ModelWithoutViewType2()); adapter2.addModel(new ModelWithoutViewType()); adapter2.addModel(new ModelWithoutViewType2()); assertEquals(adapter1.getItemViewType(0), adapter2.getItemViewType(0)); assertEquals(adapter1.getItemViewType(1), adapter2.getItemViewType(1)); } } ================================================ FILE: epoxy-adapter/src/test/java/com/airbnb/epoxy/test/CarouselTest.java ================================================ package com.airbnb.epoxy.test; import android.content.Context; import com.airbnb.epoxy.Carousel; import com.airbnb.epoxy.Carousel.SnapHelperFactory; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import androidx.annotation.NonNull; import androidx.recyclerview.widget.LinearSnapHelper; import androidx.recyclerview.widget.SnapHelper; @RunWith(RobolectricTestRunner.class) public class CarouselTest { @Test public void testOverrideGlobalSnapHelper() { Carousel.setDefaultGlobalSnapHelperFactory(new SnapHelperFactory() { @NonNull @Override public SnapHelper buildSnapHelper(Context context) { return new LinearSnapHelper(); } }); } @Test public void testODisableGlobalSnapHelper() { Carousel.setDefaultGlobalSnapHelperFactory(null); } } ================================================ FILE: epoxy-annotations/.gitignore ================================================ /build ================================================ FILE: epoxy-annotations/build.gradle ================================================ apply plugin: 'java' apply plugin: 'org.jetbrains.kotlin.jvm' apply from: '../publishing.gradle' kotlin { jvmToolchain(8) } dependencies { implementation rootProject.deps.androidAnnotations // Allow us to use android support library annotations (@LayoutRes) in this project. // Since this isn't an android module normally we couldn't access them otherwise. compileOnly rootProject.deps.androidRuntime } ================================================ FILE: epoxy-annotations/gradle.properties ================================================ POM_NAME=Epoxy annotations POM_ARTIFACT_ID=epoxy-annotations POM_PACKAGING=jar ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/AfterPropsSet.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * This can be used to annotate methods inside classes with a {@link com.airbnb.epoxy.ModelView} * annotation. Methods with this annotation will be called after a view instance is bound to a * model and all model props have been set. This is useful if you need to wait until multiple props * are set before doing certain initialization. *

* Methods with this annotation will be called after both the initial bind when the view comes on * screen, and after partial binds when an onscreen view is updated. */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.CLASS) public @interface AfterPropsSet { } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/AutoModel.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Used to annotate model fields in an EpoxyController. Model fields annotated with this should not * be assigned a value directly; a model will automatically be created for them. A stable ID will * also be generated and assigned to the model. This ID will be the same across all instances of the * adapter, so it can be used for saving state of a model. */ @Target(ElementType.FIELD) @Retention(RetentionPolicy.CLASS) public @interface AutoModel { } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/CallbackProp.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * A convenient replacement for {@link ModelProp} when the prop represents a callback or listener. *

* This is the same as using {@link ModelProp} with the options {@link * com.airbnb.epoxy.ModelProp.Option#NullOnRecycle} and * {@link com.airbnb.epoxy.ModelProp.Option#DoNotHash} *

* This can only be used on setters who's parameter is marked as nullable. The prop will be set to * null when the view is recycled to ensure that the listener is not leaked. *

* Be aware that since this applies the option {@link com.airbnb.epoxy.ModelProp.Option#DoNotHash} * changing the value of the listener will not trigger an update to the view. */ @Target({ElementType.METHOD, ElementType.FIELD}) @Retention(RetentionPolicy.CLASS) public @interface CallbackProp { } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/EpoxyAttribute.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Used to annotate fields on EpoxyModel classes in order to generate a subclass of that model with * getters, setters, equals, and hashcode for the annotated fields. */ @Target(ElementType.FIELD) @Retention(RetentionPolicy.CLASS) public @interface EpoxyAttribute { /** * Options that can be included on the attribute to affect how the model's generated class is * created. */ enum Option { /** * A getter is generated for this attribute by default. Add this option to prevent a getter from * being generated. */ NoGetter, /** * A setter is generated for this attribute by default. Add this option to prevent a setter from * being generated. */ NoSetter, /** * By default every attribute's hashCode and equals method is called when determining the * model's state. This option can be used to exclude an attribute's hashCode/equals from * contributing to the state. *

* This is useful for objects that may change without actually changing the model's state. A * common case is an anonymous click listener that gets recreated with every bind call. *

* When this is used, the attribute will affect the model state solely based on whether it is * null or non null. *

* A good rule of thumb for whether to use this on an attribute is, "If this is the only * attribute that changed do I still need to rebind and update the view?" If the answer if no * then you can use this to prevent the rebind. */ DoNotHash, /** * This is meant to be used in conjunction with {@link PackageEpoxyConfig#requireHashCode()}. * When that is enabled every attribute must implement hashCode/equals. However, there are some * valid cases where the attribute type does not implement hashCode/equals, but it should still * be hashed at runtime and contribute to the model's state. Use this option on an attribute in * that case to tell the processor to let it pass the hashCode/equals validation. *

* An example case is AutoValue classes, where the generated class correctly implements * hashCode/equals at runtime. *

* If you use this it is your responsibility to ensure that the object assigned to the attribute * at runtime correctly implements hashCode/equals. If you don't want the attribute to * contribute to model state you should use {@link Option#DoNotHash} instead. */ IgnoreRequireHashCode, /** * This attribute is used in {@link Object#toString()} implementation by default. * Add this option to prevent this attribute being used in {@link Object#toString()}. */ DoNotUseInToString } /** Specify any {@link Option} values that should be used when generating the model class. */ Option[] value() default {}; /** * Whether or not to include this attribute in equals and hashCode calculations. *

* It may be useful to disable this for objects that get recreated without the underlying data * changing such as a click listener that gets created inline in every bind call. * * @deprecated Use {@link Option#DoNotHash} instead. */ @Deprecated boolean hash() default true; /** * Whether or not to generate setter for this attribute. *

* It may be useful to disable this for attribute which can be immutable and doesn't require * setter. * * @deprecated Use {@link Option#NoSetter} instead. */ @Deprecated boolean setter() default true; } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/EpoxyBuildScope.kt ================================================ package com.airbnb.epoxy /** * Used to mark Epoxy model building DSLs so that when using generated kotlin extension functions * for building models you cannot incorrectly nest models and also don't see cluttered, incorrect * code completion suggestions. */ @DslMarker annotation class EpoxyBuildScope ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/EpoxyDataBindingLayouts.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import androidx.annotation.LayoutRes; /** * Used to specify a list of databinding layout resources that you want EpoxyModels generated for. * The models will be generated in the same package as this annotation. Every layout must be a valid * databinding layout. The name of the generated model will be based on the layout resource name. *

* The layouts must not specify a custom databinding class name or package via the * class="com.example.CustomClassName" override in the layout xml. *

* Alternatively you can use {@link EpoxyDataBindingPattern} to avoid explicitly declaring each * layout. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) public @interface EpoxyDataBindingLayouts { /** A list of databinding layout resources that should have EpoxyModel's generated for them. */ @LayoutRes int[] value(); /** * If true, any variable whose type does not implement equals and hashcode will have the * {@link EpoxyAttribute.Option#DoNotHash} behavior applied to them automatically. *

* This is generally helpful for listeners - other variables should almost always implement * equals and hashcode. *

* For details on the nuances of this, see https://github.com/airbnb/epoxy/wiki/DoNotHash */ boolean enableDoNotHash() default true; } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/EpoxyDataBindingPattern.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Used to specify a naming pattern for the databinding layouts that you want models generated for. * Use this instead of {@link EpoxyDataBindingLayouts} to avoid having to explicitly list every * databinding layout. *

* The layouts must not specify a custom databinding class name or package via the * class="com.example.CustomClassName" override in the layout xml. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) public @interface EpoxyDataBindingPattern { /** * The R class used in this module (eg "com.example.app.R.class"). This is needed so Epoxy can * look up layout files. */ Class rClass(); /** * A string prefix that your databinding layouts start with. Epoxy will generate a model for each * databinding layout whose name starts with this. *

* For example, if you set this prefix to "view_holder" and you have a "view_holder_header.xml" * databinding layout, Epoxy will generate a HeaderBindingModel_ class for that layout. */ String layoutPrefix(); /** * If true, any variable whose type does not implement equals and hashcode will have the * {@link EpoxyAttribute.Option#DoNotHash} behavior applied to them automatically. *

* This is generally helpful for listeners - other variables should almost always implement * equals and hashcode. *

* For details on the nuances of this, see https://github.com/airbnb/epoxy/wiki/DoNotHash */ boolean enableDoNotHash() default true; } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/EpoxyModelClass.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import androidx.annotation.LayoutRes; /** * Used to annotate EpoxyModel classes in order to generate a subclass of that model with getters, * setters, equals, and hashcode for the annotated fields, as well as other helper methods and * boilerplate reduction. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) public @interface EpoxyModelClass { /** * A layout resource that should be used as the default layout for the model. If you set this you * don't have to implement `getDefaultLayout`; it will be generated for you. */ @LayoutRes int layout() default 0; /** * If true, any layout file name that has {@link #layout()} as a prefix will be included as a * method on the generated model. *

* For example, if the layout is "R.layout.my_view" then any layouts in the form of * "R.layout.my_view_*" will result in a generated method like "with*Layout" that will apply that * other layout instead of the default. */ boolean useLayoutOverloads() default false; } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/ModelProp.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Used in conjunction with {@link ModelView} to automatically generate EpoxyModels from custom * views - https://github.com/airbnb/epoxy/wiki/Generating-Models-from-View-Annotations *

* This annotation should be used on setter methods within a custom view class. Setters annotated * with this will have a corresponding field on the generated model. *

* Alternatively, if your setter has no side effects, you can use this annotation on a field to have * Epoxy set that field directly and avoid the boiler plate of a setter. *

* For convenience you can use {@link TextProp} instead for props representing text. *

* Similarly you can use {@link CallbackProp} for props representing listeners or callbacks. *

* Alternatively, the {@link #options()} parameter can be used to configure a prop. */ @Target({ElementType.METHOD, ElementType.FIELD}) @Retention(RetentionPolicy.CLASS) public @interface ModelProp { enum Option { /** * By default every prop's hashCode and equals method is called when determining the * model's state. This option can be used to exclude an prop's hashCode/equals from * contributing to the state. *

* This is useful for props that may change without actually changing the model's state. A * common case is an anonymous click listener that gets recreated with every bind call. *

* When this is used, the prop will affect the model state solely based on whether it is * null or non null. *

* A good rule of thumb for whether to use this on an prop is, "If this is the only * prop that changed do I still need to rebind and update the view?" If the answer if no * then you can use this to prevent the rebind. */ DoNotHash, /** * This is meant to be used in conjunction with {@link PackageEpoxyConfig#requireHashCode()}. * When that is enabled every prop must implement hashCode/equals. However, there are some * valid cases where the prop type does not implement hashCode/equals, but it should still * be hashed at runtime and contribute to the model's state. Use this option on an prop in * that case to tell the processor to let it pass the hashCode/equals validation. *

* An example case is AutoValue classes, where the generated class correctly implements * hashCode/equals at runtime. *

* If you use this it is your responsibility to ensure that the object assigned to the prop * at runtime correctly implements hashCode/equals. If you don't want the prop to * contribute to model state you should use {@link Option#DoNotHash} instead. */ IgnoreRequireHashCode, /** * Setters with a type of {@link CharSequence} can add this option to have {@link * androidx.annotation.StringRes} and {@link androidx.annotation.PluralsRes} * overload methods generated on the model, so users can set the string via a resource. */ GenerateStringOverloads, /** * Setters with a param annotated with @Nullable can use this to have null set when the view is * recycled. */ NullOnRecycle } /** Specify any {@link Option} values that should be used when generating the model class. */ Option[] options() default {}; /** * The same as {@link #options()}, but this allows the shortcut of setting an option eg * "@ModelProp(DoNotHash)". */ Option[] value() default {}; /** * The name of the constant field that should be used as the default value for this prop. The * default value will be used if the prop value isn't set on the model. *

* For example, you would define a constant in your view class like static final int * DEFAULT_NUM_LINES = 3, and then set this parameter to "DEFAULT_NUM_LINES" so that the * annotation processor knows what constant to reference. *

* The name of the constant must be used instead of referencing the constant directly since * objects are not valid annotation parameters. */ String defaultValue() default ""; /** * Specify an optional group name. Multiple props with the same group name will only allow one of * the props to be set on the view. *

* https://github.com/airbnb/epoxy/wiki/Generating-Models-from-View-Annotations#prop-groups */ String group() default ""; } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/ModelView.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import androidx.annotation.LayoutRes; /** * An annotation on custom view classes to automatically generate an EpoxyModel for that view. Used * in conjunction with {@link ModelProp} *

* See https://github.com/airbnb/epoxy/wiki/Generating-Models-from-View-Annotations */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) public @interface ModelView { /** * Use with {@link #autoLayout()} to declare what layout parameters should be used to size your * view when it is added to a RecyclerView. This maps to the LayoutParams options {@code * layout_width} and {@code layout_height}. If you want to set the LayoutParams manually, you can * use {@link Size#MANUAL} and set the params in the View's constructor when it is initialized (a * runtime exception will be thrown if layout params are not set during view instantiation when * MANUAL is used). */ enum Size { NONE, MANUAL, WRAP_WIDTH_WRAP_HEIGHT, WRAP_WIDTH_MATCH_HEIGHT, MATCH_WIDTH_WRAP_HEIGHT, MATCH_WIDTH_MATCH_HEIGHT } /** * If set to an option besides {@link Size#NONE} Epoxy will create an instance of this view * programmatically at runtime instead of inflating the view from xml. This is an alternative to * using {@link #defaultLayout()}, and is a good option if you just need to specify layout * parameters on your view with no other styling. *

* The size option you choose will define which layout parameters Epoxy uses at runtime when * creating the view. */ Size autoLayout() default Size.NONE; /** * The layout file to use in the generated model to inflate the view. This is required unless a * default pattern is set via {@link PackageModelViewConfig} or {@link #autoLayout()} is used. *

* Overrides any default set in {@link PackageModelViewConfig} */ @LayoutRes int defaultLayout() default 0; /** * An optional EpoxyModel subclass to use as the base class of the generated view. A default can * also be set with {@link PackageModelViewConfig} *

* * Overrides any default set in {@link PackageModelViewConfig} */ Class baseModelClass() default Void.class; /** * Whether the model should save view state when unbound. *

* see: EpoxyModel#shouldSaveViewState */ boolean saveViewState() default false; /** * True to have the generated model take up the total available span count. False to instead use a * span count of 1. If you need to programmatically determine your model's span size you can use * the spanSizeCallback method on EpoxyModel. */ boolean fullSpan() default true; } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/OnViewRecycled.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * This can be used to annotate methods inside classes with a {@link com.airbnb.epoxy.ModelView} * annotation. Methods with this annotation will be called when the view is recycled by the * RecyclerView. */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.CLASS) public @interface OnViewRecycled { } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/OnVisibilityChanged.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * This can be used to annotate methods inside classes with a {@link ModelView} annotation. Methods * with this annotation will be called when visibility part of the view change. *

* Annotated methods should follow this signature : * `@OnVisibilityChanged * public void method( * float percentVisibleHeight, float percentVisibleWidth: Float, * int visibleHeight, int visibleWidth * )` *

* The equivalent methods on the model is com.airbnb.epoxy.EpoxyModel#onVisibilityChanged *

* See also: OnModelVisibilityChangedListener */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.CLASS) public @interface OnVisibilityChanged { } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/OnVisibilityStateChanged.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * This can be used to annotate methods inside classes with a {@link ModelView} annotation. Methods * with this annotation will be called when the visibility state is changed. *

* Annotated methods should follow this signature : * `@OnVisibilityStateChanged * public void method(@Visibility int state)` *

* Possible States are declared in com.airbnb.epoxy.VisibilityState. *

* The equivalent methods on the model is * com.airbnb.epoxy.EpoxyModel#onVisibilityStateChanged *

* See also: OnModelVisibilityStateChangedListener */ @SuppressWarnings("JavadocReference") @Target(ElementType.METHOD) @Retention(RetentionPolicy.CLASS) public @interface OnVisibilityStateChanged { } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/PackageEpoxyConfig.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Use this annotation on any class or interface in your package to specify default behavior for the Epoxy * annotation processor for that package. You can only have one instance of this annotation per * package. *

* If an instance of this annotation is not found in a package then the default values are used. *

* See https://github.com/airbnb/epoxy/wiki/Configuration for more details on these options. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) public @interface PackageEpoxyConfig { boolean REQUIRE_HASHCODE_DEFAULT = false; boolean REQUIRE_ABSTRACT_MODELS_DEFAULT = false; boolean IMPLICITLY_ADD_AUTO_MODELS_DEFAULT = false; /** * If true, all fields marked with {@link com.airbnb.epoxy.EpoxyAttribute} must have a type that * implements hashCode and equals (besides the default Object implementation), or the attribute * must set DoNotHash as an option. *

* Setting this to true is useful for ensuring that all model attributes correctly implement * hashCode and equals, or use DoNotHash (eg for click listeners). It is a common mistake to miss * these, which leads to invalid model state and incorrect diffing. *

* The check is done at compile time and compilation will fail if a hashCode validation fails. *

* Since it is done at compile time this can only check the direct type of the field. Interfaces * or classes will pass the check if they either have an abstract hashCode/equals method (since it * is assumed that the object at runtime will implement it) or their class hierarchy must have an * implementation of hashCode/equals besides the default Object implementation. *

* If an attribute is an Iterable or Array then the type of object in that collection must * implement hashCode/equals. */ boolean requireHashCode() default REQUIRE_HASHCODE_DEFAULT; /** * If true, all classes that contains {@link com.airbnb.epoxy.EpoxyAttribute} or {@link * com.airbnb.epoxy.EpoxyModelClass} annotations in your project must be abstract. Otherwise * compilation will fail. *

* Forcing models to be abstract can prevent the mistake of using the original model class instead * of the generated class. */ boolean requireAbstractModels() default REQUIRE_ABSTRACT_MODELS_DEFAULT; /** * If true, models in an EpoxyController that use the {@link AutoModel} annotation don't need to * be explicitly added to the controller with ".addTo". Instead, they will be added automatically * after they are modified. *

* For more details, see the wiki: * https://github.com/airbnb/epoxy/wiki/Epoxy-Controller#implicit-adding */ boolean implicitlyAddAutoModels() default IMPLICITLY_ADD_AUTO_MODELS_DEFAULT; } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/PackageModelViewConfig.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Settings that apply to all views annotated with {@link com.airbnb.epoxy.ModelView} in this * package. Also applies to subpackages, unless other package config values are set in those sub * packages. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) public @interface PackageModelViewConfig { /** * The R class used in this module (eg "com.example.app.R.class"). This is needed so Epoxy can * look up layout files. */ Class rClass(); /** * A default layout pattern to be used for specifying layouts for generated models. If this is set * then a layout can be omitted from a view's {@link com.airbnb.epoxy.ModelView} annotation. *

* The "%s" placeholder represents the view's name in snack case. For example, the default value * will use a layout resource of "R.layout.my_view" for the MyView class. If the layout name is * changed to "view_holder_%s" then the layout used would be "R.layout.view_holder_my_view". */ String defaultLayoutPattern() default "%s"; /** An optional EpoxyModel subclass that generated models should extend. */ Class defaultBaseModelClass() default Void.class; /** * If true, any layout file name that has a view's default layout as a prefix will be included as * a method on the generated model for that view. *

* For example, if the layout is "R.layout.my_view" then any layouts in the form of * "R.layout.my_view_*" will result in a generated method like "with*Layout" that will apply that * other layout instead of the default. */ boolean useLayoutOverloads() default false; /** * Suffix, which will be appended to generated model's names. "Model_" is a default value. */ String generatedModelSuffix() default "Model_"; /** * Controls whether "builder" setter functions that returns the model type will be duplicated * from super model classes with the function return type updated to use the generated model name. * This helps make all setters (such as id(...) ) return the same generated model so they can be * chained in a builder pattern. This is mainly intended for Java usage and is generally * unnecessary when using models in kotlin, especially if the generated kotlin model * build extension functions are used. Disabling this can greatly reduce the number of * methods generated on models. * * Default is false. This may also be set project wide with an annotation processor option. */ Option disableGenerateBuilderOverloads() default Option.Default; /** * Controls whether getter functions (that return the value of each attribute) are generated * on models. * * Disabling this can greatly reduce the number of methods generated on models. * * Default is false. This may also be set project wide with an annotation processor option. */ Option disableGenerateGetters() default Option.Default; /** * Controls whether the "reset" function (that clears all attribute values) are generated * on models. This function is generally legacy and is not recommended to be used with the modern * immutable model approach of EpoxyControllers. * * Disabling this reduces the amount of generated code. * * Default is false. This may also be set project wide with an annotation processor option. */ Option disableGenerateReset() default Option.Default; /** * Enable or Disable an option, or inherit the default. */ enum Option { Default, Enabled, Disabled } } ================================================ FILE: epoxy-annotations/src/main/java/com/airbnb/epoxy/TextProp.java ================================================ package com.airbnb.epoxy; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import androidx.annotation.StringRes; /** * A convenient replacement for {@link ModelProp} when the prop represents text. *

* This can only be used when the setter parameter is a {@link CharSequence} *

* This is the same as using {@link ModelProp} with the option {@link * com.airbnb.epoxy.ModelProp.Option#GenerateStringOverloads} */ @Target({ElementType.METHOD, ElementType.FIELD}) @Retention(RetentionPolicy.CLASS) public @interface TextProp { @StringRes int defaultRes() default 0; } ================================================ FILE: epoxy-compose/.gitignore ================================================ /build ================================================ FILE: epoxy-compose/build.gradle ================================================ plugins { id 'com.android.library' id 'kotlin-android' id 'org.jetbrains.kotlin.plugin.compose' } apply from: '../publishing.gradle' android { namespace 'com.airbnb.epoxy' defaultConfig { compileSdk rootProject.COMPILE_SDK_VERSION minSdkVersion rootProject.COMPOSE_MIN_SDK_VERSION targetSdkVersion rootProject.TARGET_SDK_VERSION consumerProguardFiles "consumer-rules.pro" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } buildFeatures { compose true } } kotlin { jvmToolchain(8) } dependencies { api project(':epoxy-annotations') api project(':epoxy-adapter') implementation rootProject.deps.composeUi implementation rootProject.deps.activityCompose implementation rootProject.deps.androidCoreKtx implementation rootProject.deps.androidAppcompat implementation rootProject.deps.androidDesignLibrary implementation rootProject.deps.androidLifecycleRuntimeKtx } ================================================ FILE: epoxy-compose/consumer-rules.pro ================================================ ================================================ FILE: epoxy-compose/gradle.properties ================================================ POM_NAME=Epoxy Compose Interop POM_ARTIFACT_ID=epoxy-compose POM_PACKAGING=jar ================================================ FILE: epoxy-compose/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: epoxy-compose/src/main/AndroidManifest.xml ================================================ ================================================ FILE: epoxy-compose/src/main/java/com/airbnb/epoxy/ComposeInterop.kt ================================================ package com.airbnb.epoxy import android.util.SparseArray import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.viewinterop.AndroidView /** * An epoxy viewModel that can inflate a Composable function * The keys parameter is responsible for recomposition of the composable function in epoxy. * Make sure the class of the object passed in as keys, have equals implemented, that is * their equality can be checked. * * However if your composeFunction relies on mutableState to describe your UI, * then passing in key as parameter is not needed. * * @param keys variable number of arguments that are responsible for * @param composeFunction The composable function to display in epoxy */ class ComposeEpoxyModel( vararg val keys: Any, private val composeFunction: @Composable () -> Unit, ) : EpoxyModelWithView() { private val keyedTags by lazy { SparseArray(2) } /** * add tag to this epoxy model */ fun addTag(key: Int, tag: Any) { keyedTags.put(key, tag) } fun tag(key: Int): Any? { return keyedTags.get(key) } override fun buildView(parent: ViewGroup): ComposeView = ComposeView(parent.context) override fun bind(view: ComposeView) { super.bind(view) view.setContent(composeFunction) } override fun equals(other: Any?): Boolean { if (other === this) return true if (other !is ComposeEpoxyModel) return false return keys.contentEquals(other.keys) } override fun hashCode(): Int { var code = super.hashCode() keys.forEach { code = 31 * code + it.hashCode() } return code } } fun ModelCollector.composableInterop( id: String, vararg keys: Any, composeFunction: @Composable () -> Unit ) { // Note this is done to avoid ART bug in Android 12 (background https://issuetracker.google.com/issues/197818595 and // https://github.com/airbnb/epoxy/issues/1199). // The main objective is to have the creation of the ComposeEpoxyModel the setting of the id and the adding to the // controller in the same method. // Note that even this manual inlining might be spoiled by R8 outlining which, if enabled might outline the creation // of the ComposeEpoxyModel and setting of the id to a separate method. val composeEpoxyModel = ComposeEpoxyModel(*keys, composeFunction = composeFunction) composeEpoxyModel.id(id) add(composeEpoxyModel) } /** * [composeEpoxyModel] can be used directly in cases where more control over the epoxy model * is needed. Eg. When the epoxy model needs to be modified before it's added. */ @Deprecated( message = "Use composeEpoxyModel with modelAction lambda instead to avoid crash on Android 12", replaceWith = ReplaceWith( expression = "composeEpoxyModel(id, *keys, modelAction = modelAction, composeFunction = composeFunction)", imports = ["com.airbnb.epoxy.composeEpoxyModel"] ) ) fun composeEpoxyModel( id: String, vararg keys: Any, composeFunction: @Composable () -> Unit ): ComposeEpoxyModel { return ComposeEpoxyModel(*keys, composeFunction = composeFunction).apply { id(id) } } /** * [composeEpoxyModel] can be used directly in cases where more control over the epoxy model * is needed. Eg. When the epoxy model needs to be modified before it's added. * * Note: This does not return the model and instead takes a modelAction lambda that can be used to * add the model to the controller, or modify it before adding. * * This is done to avoid ART bug in Android 12 (background https://issuetracker.google.com/issues/197818595 and * https://github.com/airbnb/epoxy/issues/1199). * The main objective is to have the creation of the ComposeEpoxyModel the setting of the id and * the adding to the controller in the same method. * Note that even with this construct this might be spoiled by R8 outlining which, if enabled might * outline the creation of the ComposeEpoxyModel and setting of the id to a separate method. */ inline fun composeEpoxyModel( id: String, vararg keys: Any, noinline composeFunction: @Composable () -> Unit, modelAction: (ComposeEpoxyModel) -> Unit ) { val composeEpoxyModel = ComposeEpoxyModel(*keys, composeFunction = composeFunction) composeEpoxyModel.id(id) modelAction.invoke(composeEpoxyModel) } @Suppress("UNCHECKED_CAST") @Composable inline fun > EpoxyInterop( modifier: Modifier = Modifier, crossinline modelBuilder: T.() -> Unit, ) { val model = T::class.java.newInstance().apply(modelBuilder) AndroidView( factory = { context -> FrameLayout(context).apply { addView((model.buildView(this))) } }, modifier = modifier, ) { view -> val modelView = view.getChildAt(0) (model as EpoxyModel).bind(modelView) (model as GeneratedModel).handlePostBind(modelView, 0) } } ================================================ FILE: epoxy-composeinterop-maverickssample/.gitignore ================================================ /build ================================================ FILE: epoxy-composeinterop-maverickssample/build.gradle ================================================ plugins { id 'com.android.application' id 'kotlin-android' id 'kotlin-kapt' id 'org.jetbrains.kotlin.plugin.compose' } android { namespace 'com.airbnb.epoxy.composeinterop.maverickssample' lint { disable 'MutableCollectionMutableState' } defaultConfig { applicationId "com.airbnb.epoxy.composeinterop.maverickssample" compileSdk rootProject.COMPILE_SDK_VERSION minSdkVersion rootProject.COMPOSE_MIN_SDK_VERSION targetSdkVersion rootProject.TARGET_SDK_VERSION versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } buildFeatures { dataBinding true compose true } packagingOptions { exclude "**/attach_hotspot_windows.dll" exclude "META-INF/licenses/**" exclude "META-INF/AL2.0" exclude "META-INF/LGPL2.1" } } kotlin { jvmToolchain(11) } dependencies { implementation 'com.airbnb.android:mavericks:2.3.0' implementation project(':epoxy-compose') implementation project(':epoxy-databinding') implementation project(':epoxy-adapter') implementation project(':epoxy-annotations') implementation 'androidx.constraintlayout:constraintlayout:2.0.4' kapt project(':epoxy-processor') implementation rootProject.deps.androidAppcompat implementation rootProject.deps.androidDesignLibrary implementation rootProject.deps.paris implementation rootProject.deps.composeUi implementation rootProject.deps.androidCoreKtx implementation rootProject.deps.composeMaterial implementation rootProject.deps.activityCompose implementation rootProject.deps.composeUiTooling kapt rootProject.deps.parisProcessor testImplementation rootProject.deps.junit androidTestImplementation rootProject.deps.androidTestExtJunitKtx androidTestImplementation rootProject.deps.androidTestRules androidTestImplementation rootProject.deps.androidTestRunner androidTestImplementation "androidx.compose.ui:ui-test-junit4:$COMPOSE_VERSION" debugImplementation("androidx.compose.ui:ui-test-manifest:$COMPOSE_VERSION") } ================================================ FILE: epoxy-composeinterop-maverickssample/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: epoxy-composeinterop-maverickssample/src/androidTest/java/com/airbnb/epoxy/composeinterop/maverickssample/MultiKeyComposeInteropFragmentTest.kt ================================================ package com.airbnb.epoxy.composeinterop.maverickssample import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MultiKeyComposeInteropFragmentTest { @get:Rule val composeTestRule = createAndroidComposeRule() @Test fun testComposableInInteropUsingMutableStateUpdatesEvenWithoutKey() { for (i in 0..10) { val textToAssert = "withoutInteropKey mutableState: $i, recomposingCount: ${i + 1}" waitUntil(textToAssert) checkIfTextIsDisplayed(textToAssert) composeTestRule.onNodeWithText(textToAssert).performClick() } waitUntil("withoutInteropKey mutableState: 11, recomposingCount: 12") checkIfTextIsDisplayed("withoutInteropKey mutableState: 11, recomposingCount: 12") Thread.sleep(2000) waitUntil("withoutInteropKey mutableState: 11, recomposingCount: 12") checkIfTextIsDisplayed("withoutInteropKey mutableState: 11, recomposingCount: 12") } @Test fun testComposableInteropWithIntegerKeyUpdatesAndWithoutKeyDoesNot() { val withoutInteropKeyText = "withoutInteropKey Int: 0, recomposingCount: 1" for (i in 0..10) { waitUntil(withoutInteropKeyText) waitUntil("withInteropKey Int: $i, recomposingCount: ${i + 1}") checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey Int: $i, recomposingCount: ${i + 1}") composeTestRule.onNodeWithText(withoutInteropKeyText).performClick() } waitUntil("withInteropKey Int: 11, recomposingCount: 12") checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey Int: 11, recomposingCount: 12") Thread.sleep(2000) checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey Int: 11, recomposingCount: 12") } @Test fun testComposableInteropWithStringKeyUpdatesAndWithoutKeyDoesNot() { var str = "" val withoutInteropKeyText = "withoutInteropKey String: , recomposingCount: 1" for (i in 0..10) { waitUntil(withoutInteropKeyText) waitUntil("withInteropKey String: $str, recomposingCount: ${i + 1}") checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey String: $str, recomposingCount: ${i + 1}") composeTestRule.onNodeWithText(withoutInteropKeyText).performClick() str += "#" } waitUntil("withInteropKey String: $str, recomposingCount: 12") checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey String: $str, recomposingCount: 12") Thread.sleep(2000) checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey String: $str, recomposingCount: 12") } @Test fun testComposableInteropWithListKeyUpdatesAndWithoutKeyDoesNot() { val list = mutableListOf() val withoutInteropKeyText = "withoutInteropKey List: [], recomposingCount: 1" for (i in 0..10) { waitUntil(withoutInteropKeyText) waitUntil("withInteropKey List: $list, recomposingCount: ${i + 1}") checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey List: $list, recomposingCount: ${i + 1}") composeTestRule.onNodeWithText(withoutInteropKeyText).performClick() list.add(1) } waitUntil("withInteropKey List: $list, recomposingCount: 12") checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey List: $list, recomposingCount: 12") Thread.sleep(2000) checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey List: $list, recomposingCount: 12") } @Test fun testComposableInteropWithDataClassKeyUpdatesAndWithoutKeyDoesNot() { val list = mutableListOf() val withoutInteropKeyText = "withoutInteropKey DataClass: DataClassKey(listInDataClass=[]), recomposingCount: 1" for (i in 0..10) { waitUntil(withoutInteropKeyText) waitUntil("withInteropKey DataClass: DataClassKey(listInDataClass=$list), recomposingCount: ${i + 1}") checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey DataClass: DataClassKey(listInDataClass=$list), recomposingCount: ${i + 1}") composeTestRule.onNodeWithText(withoutInteropKeyText).performClick() list.add(2) } waitUntil("withInteropKey DataClass: DataClassKey(listInDataClass=$list), recomposingCount: 12") checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey DataClass: DataClassKey(listInDataClass=$list), recomposingCount: 12") Thread.sleep(2000) checkIfTextIsDisplayed(withoutInteropKeyText) checkIfTextIsDisplayed("withInteropKey DataClass: DataClassKey(listInDataClass=$list), recomposingCount: 12") } private fun waitUntil(text: String) { composeTestRule.waitUntil { composeTestRule.onAllNodesWithText(text) .fetchSemanticsNodes().isNotEmpty() } } private fun checkIfTextIsDisplayed(text: String) { composeTestRule.onNodeWithText(text).assertIsDisplayed() } } ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/AndroidManifest.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/java/com/airbnb/epoxy/composeinterop/maverickssample/ComposeInteropListFragmnet.kt ================================================ package com.airbnb.epoxy.composeinterop.maverickssample import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.ClickableText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.fragment.app.Fragment import com.airbnb.epoxy.EpoxyRecyclerView import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.composableInterop import com.airbnb.epoxy.composeinterop.maverickssample.epoxyviews.headerView import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksView import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState data class CounterState( val counter: List = List(100) { it }, ) : MavericksState class HelloWorldViewModel(initialState: CounterState) : MavericksViewModel(initialState) { fun increase(index: Int) { withState { state -> val updatedCounterList = state.counter.mapIndexed { i, value -> if (i == index) value + 1 else value } setState { copy(counter = updatedCounterList) } } } } class ComposeInteropListFragmnet : Fragment(R.layout.fragment_my), MavericksView { private val viewModel by fragmentViewModel(HelloWorldViewModel::class) private val controller: MyEpoxyController by lazy { MyEpoxyController(viewModel) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = inflater.inflate(R.layout.fragment_my, container, false).apply { findViewById(R.id.epoxyRecyclerView).apply { setController(controller) } } override fun invalidate() = withState(viewModel) { controller.setData(it) } } class MyEpoxyController(private val viewModel: HelloWorldViewModel) : TypedEpoxyController() { private fun annotatedString(str: String) = buildAnnotatedString { withStyle( style = SpanStyle(fontWeight = FontWeight.Bold) ) { append(str) } } override fun buildModels(state: CounterState) { state.counter.forEachIndexed { index, counterValue -> composableInterop("$index", counterValue) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { TextFromCompose(counterValue) { this@MyEpoxyController.viewModel.increase(index) } } } headerView { id(index) title("Text from normal epoxy model: $counterValue") clickListener { _ -> this@MyEpoxyController.viewModel.increase(index) } } } } @Composable fun TextFromCompose(counterValue: Int, onClick: () -> Unit) { ClickableText( text = annotatedString("Text from composable interop $counterValue"), onClick = { onClick() } ) } } ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/java/com/airbnb/epoxy/composeinterop/maverickssample/MainActivity.kt ================================================ package com.airbnb.epoxy.composeinterop.maverickssample import android.os.Bundle import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) } } ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/java/com/airbnb/epoxy/composeinterop/maverickssample/MultiKeyComposeInteropFragment.kt ================================================ package com.airbnb.epoxy.composeinterop.maverickssample import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import com.airbnb.epoxy.EpoxyRecyclerView import com.airbnb.epoxy.ModelCollector import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.composableInterop import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksView import com.airbnb.mvrx.MavericksViewModel import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState data class MultiKeyComposeInteropState( val keyAsInt: Int = 0, val keyAsString: String = "", val keyAsList: List = emptyList(), val keyAsDataClass: DataClassKey = DataClassKey() ) : MavericksState data class DataClassKey( var listInDataClass: MutableList = mutableListOf() ) class MultiKeyComposeInteropViewModel(initialState: MultiKeyComposeInteropState) : MavericksViewModel(initialState) { fun increaseCounter() { withState { state -> val updatedCounter = state.keyAsInt + 1 setState { copy(keyAsInt = updatedCounter) } } } fun appendKeyString() { withState { state -> val updatedString = state.keyAsString + "#" setState { copy(keyAsString = updatedString) } } } fun appendKeyList() { withState { state -> val updatedList = state.keyAsList.toMutableList() updatedList.add(1) setState { copy(keyAsList = updatedList) } } } fun appendDataClass() { withState { state -> val updatedDataClass = state.keyAsDataClass.copy(listInDataClass = state.keyAsDataClass.listInDataClass.toMutableList()) updatedDataClass.listInDataClass.add(2) setState { copy(keyAsDataClass = updatedDataClass) } } } val mutableCounter = mutableStateOf(0) var recomposingForWithoutKeyMutableState = 0 var recomposingForWithoutKeyInt = 0 var recomposingForWithKeyInt = 0 var recomposingForWithoutKeyString = 0 var recomposingForWithKeyString = 0 var recomposingForWithoutKeyList = 0 var recomposingForWithKeyList = 0 var recomposingForWithoutKeyDataClass = 0 var recomposingForWithKeyDataClass = 0 fun increaseMutableState() { mutableCounter.value++ println("counter.value: ${mutableCounter.value}") } } class MultiKeyComposeInteropFargment : Fragment(R.layout.fragment_multi_key_compose_interop), MavericksView { private val viewModel by fragmentViewModel(MultiKeyComposeInteropViewModel::class) private val controller: MultiKeyComposeInteropEpoxyController by lazy { MultiKeyComposeInteropEpoxyController(viewModel) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = inflater.inflate(R.layout.fragment_multi_key_compose_interop, container, false).apply { findViewById(R.id.epoxyRecyclerView).apply { setController(controller) } } override fun invalidate() = withState(viewModel) { controller.setData(it) } } class MultiKeyComposeInteropEpoxyController(private val viewModel: MultiKeyComposeInteropViewModel) : TypedEpoxyController() { override fun buildModels(state: MultiKeyComposeInteropState) { withMutableState() withoutInteropKeyInt(state) withInteropKeyInt(state) withoutInteropKeyString(state) withInteropKeyString(state) withoutInteropKeyList(state) withInteropKeyList(state) withoutInteropKeyDataClass(state) withInteropKeyDataClass(state) } @Composable fun InteropDivider() { Divider( color = Color.LightGray, thickness = 1.dp, modifier = Modifier.padding(top = 10.dp, bottom = 10.dp) ) } @Composable fun ComposableWithMutableState() { viewModel.recomposingForWithoutKeyMutableState++ ClickableText( text = AnnotatedString("withoutInteropKey mutableState: ${viewModel.mutableCounter.value}, recomposingCount: ${viewModel.recomposingForWithoutKeyMutableState}"), onClick = { viewModel.increaseMutableState() } ) } @Composable fun ComposableTextWithoutKeyInt(counter: Int) { viewModel.recomposingForWithoutKeyInt++ ClickableText( text = AnnotatedString("withoutInteropKey Int: $counter, recomposingCount: ${viewModel.recomposingForWithoutKeyInt}"), onClick = { viewModel.increaseCounter() } ) } @Composable fun ComposableTextWithKeyInt(counter: Int) { viewModel.recomposingForWithKeyInt++ ClickableText( text = AnnotatedString("withInteropKey Int: $counter, recomposingCount: ${viewModel.recomposingForWithKeyInt}"), onClick = { viewModel.increaseCounter() } ) } @Composable fun ComposableTextWithoutKeyString(keyAsString: String) { viewModel.recomposingForWithoutKeyString++ ClickableText( text = AnnotatedString("withoutInteropKey String: $keyAsString, recomposingCount: ${viewModel.recomposingForWithoutKeyString}"), onClick = { this@MultiKeyComposeInteropEpoxyController.viewModel.appendKeyString() } ) } @Composable fun ComposableTextWithKeyString(keyAsString: String) { viewModel.recomposingForWithKeyString++ ClickableText( text = AnnotatedString("withInteropKey String: $keyAsString, recomposingCount: ${viewModel.recomposingForWithKeyString}"), onClick = { this@MultiKeyComposeInteropEpoxyController.viewModel.appendKeyString() } ) } @Composable fun ComposableWithoutInteropKeyList(keyAsList: List) { viewModel.recomposingForWithoutKeyList++ ClickableText( text = AnnotatedString("withoutInteropKey List: $keyAsList, recomposingCount: ${viewModel.recomposingForWithoutKeyList}"), onClick = { this@MultiKeyComposeInteropEpoxyController.viewModel.appendKeyList() } ) } @Composable fun ComposableWithInteropKeyList(keyAsList: List) { viewModel.recomposingForWithKeyList++ ClickableText( text = AnnotatedString("withInteropKey List: $keyAsList, recomposingCount: ${viewModel.recomposingForWithKeyList}"), onClick = { this@MultiKeyComposeInteropEpoxyController.viewModel.appendKeyList() } ) } @Composable fun ComposableWithoutInteropKeyDataClass(keyAsDataClass: DataClassKey) { viewModel.recomposingForWithoutKeyDataClass++ ClickableText( text = AnnotatedString("withoutInteropKey DataClass: $keyAsDataClass, recomposingCount: ${viewModel.recomposingForWithoutKeyDataClass}"), onClick = { this@MultiKeyComposeInteropEpoxyController.viewModel.appendDataClass() } ) } @Composable fun ComposableWithInteropKeyDataClass(keyAsDataClass: DataClassKey) { viewModel.recomposingForWithKeyDataClass++ ClickableText( text = AnnotatedString("withInteropKey DataClass: $keyAsDataClass, recomposingCount: ${viewModel.recomposingForWithKeyDataClass}"), onClick = { this@MultiKeyComposeInteropEpoxyController.viewModel.appendDataClass() } ) } private fun ModelCollector.withMutableState() { composableInterop("id_counter_for_mutableState") { Column { this@MultiKeyComposeInteropEpoxyController.ComposableWithMutableState() this@MultiKeyComposeInteropEpoxyController.InteropDivider() } } } private fun ModelCollector.withoutInteropKeyInt(state: MultiKeyComposeInteropState) { composableInterop("id_counter_without_keys") { this@MultiKeyComposeInteropEpoxyController.ComposableTextWithoutKeyInt(state.keyAsInt) } } private fun ModelCollector.withInteropKeyInt(state: MultiKeyComposeInteropState) { composableInterop("id_counter_with_keys", state.keyAsInt) { Column { this@MultiKeyComposeInteropEpoxyController.ComposableTextWithKeyInt(state.keyAsInt) this@MultiKeyComposeInteropEpoxyController.InteropDivider() } } } private fun ModelCollector.withoutInteropKeyString(state: MultiKeyComposeInteropState) { composableInterop("id_withoutInteropKeyString") { this@MultiKeyComposeInteropEpoxyController.ComposableTextWithoutKeyString(state.keyAsString) } } private fun ModelCollector.withInteropKeyString(state: MultiKeyComposeInteropState) { composableInterop("id_withInteropKeyString", state.keyAsString) { Column { this@MultiKeyComposeInteropEpoxyController.ComposableTextWithKeyString(state.keyAsString) this@MultiKeyComposeInteropEpoxyController.InteropDivider() } } } private fun ModelCollector.withoutInteropKeyList(state: MultiKeyComposeInteropState) { composableInterop("id_withoutInteropKeyList") { this@MultiKeyComposeInteropEpoxyController.ComposableWithoutInteropKeyList(state.keyAsList) } } private fun ModelCollector.withInteropKeyList(state: MultiKeyComposeInteropState) { composableInterop("id_withInteropKeyList", state.keyAsList) { Column { this@MultiKeyComposeInteropEpoxyController.ComposableWithInteropKeyList(state.keyAsList) this@MultiKeyComposeInteropEpoxyController.InteropDivider() } } } private fun ModelCollector.withoutInteropKeyDataClass(state: MultiKeyComposeInteropState) { composableInterop("id_withoutInteropKeyDataClass") { this@MultiKeyComposeInteropEpoxyController.ComposableWithoutInteropKeyDataClass(state.keyAsDataClass) } } private fun ModelCollector.withInteropKeyDataClass(state: MultiKeyComposeInteropState) { composableInterop("id_withInteropKeyDataClass", state.keyAsDataClass) { Column { this@MultiKeyComposeInteropEpoxyController.ComposableWithInteropKeyDataClass(state.keyAsDataClass) this@MultiKeyComposeInteropEpoxyController.InteropDivider() } } } } ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/java/com/airbnb/epoxy/composeinterop/maverickssample/SampleApplication.kt ================================================ package com.airbnb.epoxy.composeinterop.maverickssample import android.app.Application import com.airbnb.mvrx.Mavericks class SampleApplication : Application() { override fun onCreate() { super.onCreate() Mavericks.initialize(this) } } ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/java/com/airbnb/epoxy/composeinterop/maverickssample/epoxyviews/HeaderView.kt ================================================ package com.airbnb.epoxy.composeinterop.maverickssample.epoxyviews import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import com.airbnb.epoxy.CallbackProp import com.airbnb.epoxy.ModelProp import com.airbnb.epoxy.ModelView import com.airbnb.epoxy.TextProp import com.airbnb.epoxy.composeinterop.maverickssample.R import com.airbnb.paris.annotations.Style import com.airbnb.paris.annotations.Styleable import com.airbnb.paris.extensions.headerViewStyle import com.airbnb.paris.extensions.layoutHeight import com.airbnb.paris.extensions.layoutWidth @Styleable // Dynamic styling via the Paris library @ModelView(saveViewState = true) class HeaderView(context: Context?) : LinearLayout(context) { private var title: TextView private var caption: TextView private var image: ImageView init { inflate(getContext(), R.layout.header_view, this) title = findViewById(R.id.title_text) caption = findViewById(R.id.caption_text) image = findViewById(R.id.image) } @TextProp(defaultRes = R.string.app_name) fun setTitle(title: CharSequence?) { this.title.text = title } @TextProp fun setCaption(caption: CharSequence?) { this.caption.text = caption } @ModelProp fun setShowImage(isVisible: Boolean) { image.visibility = if (isVisible) View.VISIBLE else View.GONE } @CallbackProp fun setClickListener(listener: OnClickListener?) { this.title.setOnClickListener(listener) } companion object { @Style(isDefault = true) val headerStyle: com.airbnb.paris.styles.Style = headerViewStyle { layoutWidth(ViewGroup.LayoutParams.MATCH_PARENT) layoutHeight(ViewGroup.LayoutParams.WRAP_CONTENT) } } } ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/layout/fragment_multi_key_compose_interop.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/layout/fragment_my.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/layout/header_view.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/values/colors.xml ================================================ #FFBB86FC #FF6200EE #FF3700B3 #FF03DAC5 #FF018786 #FF000000 #FFFFFFFF ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/values/strings.xml ================================================ MavericksExample ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/values/themes.xml ================================================ ================================================ FILE: epoxy-composeinterop-maverickssample/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: epoxy-composesample/.gitignore ================================================ /build ================================================ FILE: epoxy-composesample/build.gradle ================================================ plugins { id 'com.android.application' id 'kotlin-android' id 'kotlin-kapt' id 'org.jetbrains.kotlin.plugin.compose' } android { namespace 'com.airbnb.epoxy.compose.sample' defaultConfig { applicationId "com.airbnb.epoxy.compose.sample" compileSdk rootProject.COMPILE_SDK_VERSION minSdkVersion rootProject.COMPOSE_MIN_SDK_VERSION targetSdkVersion rootProject.TARGET_SDK_VERSION versionCode 1 versionName "1.0" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } buildFeatures { compose true } } kotlin { jvmToolchain(11) } dependencies { implementation project(':epoxy-compose') implementation project(':epoxy-databinding') implementation project(':epoxy-adapter') implementation project(':epoxy-annotations') implementation 'androidx.constraintlayout:constraintlayout:2.0.4' kapt project(':epoxy-processor') implementation 'com.jakewharton:butterknife:10.2.3' kapt 'com.jakewharton:butterknife-compiler:10.2.3' implementation rootProject.deps.androidAppcompat implementation rootProject.deps.androidDesignLibrary implementation rootProject.deps.paris implementation rootProject.deps.composeUi implementation rootProject.deps.androidCoreKtx implementation rootProject.deps.composeMaterial implementation rootProject.deps.activityCompose implementation rootProject.deps.composeUiTooling kapt rootProject.deps.parisProcessor } ================================================ FILE: epoxy-composesample/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: epoxy-composesample/src/main/AndroidManifest.xml ================================================ ================================================ FILE: epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/ComposableInteropActivity.kt ================================================ package com.airbnb.epoxy.compose.sample import android.os.Bundle import androidx.activity.ComponentActivity import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.Divider import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.airbnb.epoxy.EpoxyRecyclerView import com.airbnb.epoxy.composableInterop import com.airbnb.epoxy.compose.sample.epoxyviews.headerView class ComposableInteropActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_composable_interop) val recyclerView: EpoxyRecyclerView = findViewById(R.id.epoxy_recycler_view) recyclerView.withModels { headerView { id("header") title("Testing Composable in Epoxy") } for (i in 0..100) { composableInterop(id = "compose_text_$i") { ShowCaption("Caption coming from composable") } composableInterop(id = "news_$i") { NewsStory() } } } } } @Composable @Preview fun NewsStory() { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Image( painter = painterResource(id = R.drawable.header), contentDescription = null ) ShowCaption("Above Image and below text are coming from Compose in Epoxy") ShowCaption("Davenport, California") ShowCaption("December 2021") Divider( color = Color(0x859797CF), thickness = 2.dp, modifier = Modifier.padding(top = 16.dp) ) } } @Composable fun ShowCaption(text: String) { Text( text, textAlign = TextAlign.Center, modifier = Modifier .padding(4.dp) .fillMaxWidth() ) } ================================================ FILE: epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/EpoxyInteropActivity.kt ================================================ package com.airbnb.epoxy.compose.sample import android.os.Bundle import androidx.activity.ComponentActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.airbnb.epoxy.EpoxyInterop import com.airbnb.epoxy.compose.sample.epoxyviews.HeaderViewModel_ class EpoxyInteropActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_epoxy_interop) val composeView: ComposeView = findViewById(R.id.compose_view) composeView.setContent { Column { Title("Testing Epoxy in Composable") LazyColumn { items(100) { index -> EpoxyInterop( modifier = Modifier.fillMaxWidth(), ) { id("id_header_view_model", index.toLong()) title("Epoxy model in compose $index") showImage(true) } } } } } } } @Composable fun Title(titleText: String) { Text( text = titleText, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, modifier = Modifier .background(Color(0x85DFDFFF), RectangleShape) .padding(20.dp) .fillMaxWidth() ) } ================================================ FILE: epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/MainActivity.kt ================================================ package com.airbnb.epoxy.compose.sample import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewById(R.id.button).setOnClickListener { startActivity(Intent(this, ComposableInteropActivity::class.java)) } findViewById(R.id.button2).setOnClickListener { startActivity(Intent(this, EpoxyInteropActivity::class.java)) } } } ================================================ FILE: epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/epoxyviews/HeaderView.kt ================================================ package com.airbnb.epoxy.compose.sample.epoxyviews import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import com.airbnb.epoxy.AfterPropsSet import com.airbnb.epoxy.ModelProp import com.airbnb.epoxy.ModelView import com.airbnb.epoxy.TextProp import com.airbnb.epoxy.compose.sample.R import com.airbnb.paris.annotations.Style import com.airbnb.paris.annotations.Styleable import com.airbnb.paris.extensions.headerViewStyle import com.airbnb.paris.extensions.layoutHeight import com.airbnb.paris.extensions.layoutWidth @Styleable // Dynamic styling via the Paris library @ModelView class HeaderView(context: Context?) : LinearLayout(context) { private var title: TextView? = null private var caption: TextView? = null private var image: ImageView? = null init { orientation = VERTICAL inflate(getContext(), R.layout.header_view, this) title = findViewById(R.id.title_text) caption = findViewById(R.id.caption_text) image = findViewById(R.id.image) } @TextProp(defaultRes = R.string.app_name) fun setTitle(title: CharSequence?) { this.title?.text = title } @TextProp fun setCaption(caption: CharSequence?) { this.caption?.text = caption } @ModelProp fun setShowImage(isVisible: Boolean) { image?.visibility = if (isVisible) View.VISIBLE else View.GONE } @AfterPropsSet fun changeTitleColor() { title?.text?.last()?.let { if (it.isDigit()) { val isEven = it.digitToInt() % 2 == 0 if (isEven) { title?.setTextColor(resources.getColor(com.google.android.material.R.color.design_default_color_primary)) } else { title?.setTextColor(resources.getColor(com.google.android.material.R.color.design_default_color_secondary)) } } } } companion object { @Style(isDefault = true) val headerStyle: com.airbnb.paris.styles.Style = headerViewStyle { layoutWidth(ViewGroup.LayoutParams.MATCH_PARENT) layoutHeight(ViewGroup.LayoutParams.WRAP_CONTENT) } } } ================================================ FILE: epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/ui/theme/Color.kt ================================================ package com.airbnb.epoxy.compose.sample.ui.theme import androidx.compose.ui.graphics.Color val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) ================================================ FILE: epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/ui/theme/Shape.kt ================================================ package com.airbnb.epoxy.compose.sample.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Shapes import androidx.compose.ui.unit.dp val Shapes = Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(4.dp), large = RoundedCornerShape(0.dp) ) ================================================ FILE: epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/ui/theme/Theme.kt ================================================ package com.airbnb.epoxy.compose.sample.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme import androidx.compose.material.darkColors import androidx.compose.material.lightColors import androidx.compose.runtime.Composable private val DarkColorPalette = darkColors( primary = Purple200, primaryVariant = Purple700, secondary = Teal200 ) private val LightColorPalette = lightColors( primary = Purple500, primaryVariant = Purple700, secondary = Teal200 /* Other default colors to override background = Color.White, surface = Color.White, onPrimary = Color.White, onSecondary = Color.Black, onBackground = Color.Black, onSurface = Color.Black, */ ) @Composable fun EpoxyTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { val colors = if (darkTheme) { DarkColorPalette } else { LightColorPalette } MaterialTheme( colors = colors, typography = Typography, shapes = Shapes, content = content ) } ================================================ FILE: epoxy-composesample/src/main/java/com/airbnb/epoxy/compose/sample/ui/theme/Type.kt ================================================ package com.airbnb.epoxy.compose.sample.ui.theme import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( body1 = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp ) /* Other default text styles to override button = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.W500, fontSize = 14.sp ), caption = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 12.sp ) */ ) ================================================ FILE: epoxy-composesample/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: epoxy-composesample/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: epoxy-composesample/src/main/res/layout/activity_composable_interop.xml ================================================ ================================================ FILE: epoxy-composesample/src/main/res/layout/activity_epoxy_interop.xml ================================================ ================================================ FILE: epoxy-composesample/src/main/res/layout/activity_main.xml ================================================