├── docs ├── wip.md ├── material.md ├── guided-step.md └── object-adapter.md ├── fixture ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ ├── res │ │ ├── values │ │ │ └── strings.xml │ │ └── layout │ │ │ └── layout_test_guided_action_custom.xml │ │ └── java │ │ └── jp │ │ └── co │ │ └── cyberagent │ │ └── fixture │ │ ├── ObjectAdapterExt.kt │ │ ├── KotestProjectConfig.kt │ │ ├── Memo.kt │ │ └── TestLifecycleOwner.kt └── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── sample ├── src │ └── main │ │ ├── res │ │ ├── values │ │ │ ├── strings.xml │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── layout │ │ │ ├── layout_divider.xml │ │ │ ├── activity_main.xml │ │ │ ├── model_info.xml │ │ │ └── model_entry.xml │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ ├── navigation │ │ │ └── nav_main.xml │ │ └── drawable │ │ │ ├── ic_tv.xml │ │ │ └── ic_launcher_background.xml │ │ ├── java │ │ └── jp │ │ │ └── co │ │ │ └── cyberagent │ │ │ └── lounge │ │ │ └── sample │ │ │ ├── binding │ │ │ └── ViewBindingAdapters.kt │ │ │ ├── utils │ │ │ ├── RandomColor.kt │ │ │ └── PagedListCreator.kt │ │ │ ├── model │ │ │ ├── EntryModel.kt │ │ │ ├── InfoModel.kt │ │ │ └── TextModel.kt │ │ │ └── ui │ │ │ ├── MainActivity.kt │ │ │ ├── HomeFragment.kt │ │ │ ├── VerticalGridExampleFragment.kt │ │ │ ├── GuidedStepExampleFragment.kt │ │ │ ├── PagedListStressTestFragment.kt │ │ │ └── RowsExampleFragment.kt │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle.kts ├── lounge ├── src │ ├── test │ │ └── java │ │ │ └── jp │ │ │ └── co │ │ │ └── cyberagent │ │ │ └── lounge │ │ │ ├── util │ │ │ ├── ValueHolder.kt │ │ │ └── TestModel.kt │ │ │ ├── AdapterDslTest.kt │ │ │ ├── LambdaLoungeControllerTest.kt │ │ │ ├── LoungeGuidedActionsStylistTest.kt │ │ │ ├── LoungeControllerPropertyTest.kt │ │ │ ├── LoungeGuidedActionsBuilderTest.kt │ │ │ └── ListRowModelTest.kt │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── jp │ │ └── co │ │ └── cyberagent │ │ └── lounge │ │ ├── HeaderData.kt │ │ ├── Guidance.kt │ │ ├── LoungeModel.kt │ │ ├── DeferredLoungeModel.kt │ │ ├── LambdaLoungeController.kt │ │ ├── MemorizedController.kt │ │ ├── LoungeGuidedActionsStylist.kt │ │ ├── LoungeBuildModelScope.kt │ │ ├── AdapterDsl.kt │ │ ├── internal │ │ ├── Logger.kt │ │ └── LoungeAdapter.kt │ │ ├── LoungeControllerInterceptor.kt │ │ ├── LoungeGuidedStepSupportFragment.kt │ │ ├── SimpleLoungeModelAwaitInterceptor.kt │ │ ├── TypedPresenter.kt │ │ ├── KeyUtils.kt │ │ ├── LoungeControllerProperty.kt │ │ ├── LoungeGuidedActionsBuilder.kt │ │ ├── LoungeGuidedActionBuilder.kt │ │ ├── TypedRowPresenter.kt │ │ └── LoungeGuidedAction.kt ├── gradle.properties └── build.gradle.kts ├── lounge-paging ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── jp │ │ │ └── co │ │ │ └── cyberagent │ │ │ └── lounge │ │ │ └── paging │ │ │ ├── internal │ │ │ ├── ChannelExt.kt │ │ │ ├── CacheOp.kt │ │ │ └── PagedListModelCache.kt │ │ │ ├── PagedListLoungeBuildModelScope.kt │ │ │ ├── LambdaPagedListLoungeController.kt │ │ │ ├── PagedListLoungeController.kt │ │ │ └── PagedListRowModel.kt │ └── test │ │ └── java │ │ └── jp │ │ └── co │ │ └── cyberagent │ │ └── lounge │ │ └── paging │ │ ├── util │ │ ├── TestModel.kt │ │ ├── EmptyPresenter.kt │ │ ├── Item.kt │ │ ├── PagedListUtil.kt │ │ └── ListDataSource.kt │ │ ├── PagedListLoungeControllerTest.kt │ │ └── PagedListRowModelTest.kt ├── gradle.properties └── build.gradle.kts ├── lounge-material ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── res │ │ └── values │ │ └── themes.xml ├── gradle.properties └── build.gradle.kts ├── lounge-databinding ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── jp │ │ └── co │ │ └── cyberagent │ │ └── lounge │ │ └── databinding │ │ ├── SimpleDataBindingRowPresenter.kt │ │ ├── SimpleDataBindingPresenter.kt │ │ ├── DataBindingRowPresenter.kt │ │ └── DataBindingPresenter.kt ├── gradle.properties └── build.gradle.kts ├── lounge-navigation ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ ├── res │ │ └── values │ │ │ └── values.xml │ │ └── java │ │ └── jp │ │ └── co │ │ └── cyberagent │ │ └── lounge │ │ └── navigation │ │ └── BrowseSupportFragmentBackPressedCallback.kt ├── gradle.properties └── build.gradle.kts ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml └── detekt.xml ├── .editorconfig ├── tools ├── prepare_docs.sh └── deploy_snapshot.sh ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── deploy-docs.yml │ └── ci.yml └── PULL_REQUEST_TEMPLATE ├── CHANGELOG.md ├── gradle.properties ├── RELEASING.md ├── LICENSE ├── README.md ├── settings.gradle.kts ├── mkdocs.yml ├── gradlew.bat ├── config └── detekt │ └── detekt.yml ├── .gitignore └── gradlew /docs/wip.md: -------------------------------------------------------------------------------- 1 | # WIP 2 | -------------------------------------------------------------------------------- /fixture/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lounge Sample 3 | 4 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /lounge/src/test/java/jp/co/cyberagent/lounge/util/ValueHolder.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.util 2 | 3 | data class ValueHolder(val v: T) 4 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /lounge/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ca-love/lounge/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /lounge-paging/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /lounge-material/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /fixture/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test Guided Action 4 | 5 | -------------------------------------------------------------------------------- /lounge-databinding/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /lounge-navigation/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /lounge/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=lounge 2 | POM_NAME=Lounge 3 | POM_DESCRIPTION=Leanback enhancement library for accelerating Android TV development. 4 | POM_PACKAGING=aar 5 | -------------------------------------------------------------------------------- /lounge-material/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=lounge-material 2 | POM_NAME=Lounge Material 3 | POM_DESCRIPTION=Building a Leanback app with Material Design Components. 4 | POM_PACKAGING=aar 5 | -------------------------------------------------------------------------------- /lounge-navigation/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=lounge-navigation 2 | POM_NAME=Lounge Navigation 3 | POM_DESCRIPTION=Building a Leanback app with Navigation Components. 4 | POM_PACKAGING=aar 5 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = off 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /lounge-paging/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=lounge-paging 2 | POM_NAME=Lounge Paging 3 | POM_DESCRIPTION=Leanback enhancement library for accelerating Android TV development. Additional Paging support. 4 | POM_PACKAGING=aar 5 | -------------------------------------------------------------------------------- /tools/prepare_docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Generate API docs 5 | ./gradlew dokkaHtmlMultiModule 6 | 7 | # Copy *.md files into docs directory 8 | cp README.md docs/index.md 9 | cp CHANGELOG.md docs/changelog.md 10 | -------------------------------------------------------------------------------- /fixture/src/main/java/jp/co/cyberagent/fixture/ObjectAdapterExt.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.fixture 2 | 3 | import androidx.leanback.widget.ObjectAdapter 4 | 5 | val ObjectAdapter.items: List 6 | get() = (0 until size()).map { get(it) } 7 | -------------------------------------------------------------------------------- /lounge-databinding/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=lounge-databinding 2 | POM_NAME=Lounge DataBinding 3 | POM_DESCRIPTION=Leanback enhancement library for accelerating Android TV development. Additional DataBinding support. 4 | POM_PACKAGING=aar 5 | -------------------------------------------------------------------------------- /lounge-navigation/src/main/res/values/values.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #6200EE 4 | #3700B3 5 | #03DAC5 6 | 7 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/layout_divider.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /lounge-material/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `module-config` 3 | com.android.library 4 | `kotlin-android` 5 | id("com.vanniktech.maven.publish") 6 | } 7 | 8 | dependencies { 9 | implementation(Kotlin.stdlib.jdk8) 10 | implementation(AndroidX.appCompat) 11 | 12 | api(Google.android.material) 13 | api(AndroidX.leanback) 14 | } 15 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/binding/ViewBindingAdapters.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.binding 2 | 3 | import android.view.View 4 | import androidx.databinding.BindingAdapter 5 | 6 | @BindingAdapter("backgroundColorInt") 7 | fun View.bindColorBackground(colorInt: Int) { 8 | setBackgroundColor(colorInt) 9 | } 10 | -------------------------------------------------------------------------------- /.idea/detekt.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | true 6 | true 7 | config/detekt/detekt.yml 8 | 9 | -------------------------------------------------------------------------------- /lounge-paging/src/main/java/jp/co/cyberagent/lounge/paging/internal/ChannelExt.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging.internal 2 | 3 | import kotlinx.coroutines.channels.SendChannel 4 | 5 | internal fun SendChannel.offerSafe(element: E) { 6 | @Suppress("EXPERIMENTAL_API_USAGE") 7 | if (!isClosedForSend) { 8 | offer(element) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lounge-paging/src/test/java/jp/co/cyberagent/lounge/paging/util/TestModel.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging.util 2 | 3 | import androidx.leanback.widget.Presenter 4 | import jp.co.cyberagent.lounge.LoungeModel 5 | 6 | data class TestModel( 7 | override val key: Long, 8 | ) : LoungeModel { 9 | 10 | override val presenter: Presenter 11 | get() = EmptyPresenter 12 | } 13 | -------------------------------------------------------------------------------- /fixture/src/main/res/layout/layout_test_guided_action_custom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /lounge-databinding/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `module-config` 3 | com.android.library 4 | `kotlin-android` 5 | id("com.vanniktech.maven.publish") 6 | } 7 | 8 | android { 9 | buildFeatures { 10 | dataBinding = true 11 | } 12 | } 13 | 14 | 15 | dependencies { 16 | api(project(":lounge")) 17 | api(AndroidX.leanback) 18 | 19 | implementation(Kotlin.stdlib.jdk8) 20 | implementation(AndroidX.appCompat) 21 | implementation(AndroidX.core.ktx) 22 | } 23 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/HeaderData.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.leanback.widget.HeaderItem 4 | 5 | /** 6 | * Represents the data of a [HeaderItem] in a data class. 7 | * So we can get proper implementation of [hashCode] and [equals]. 8 | * 9 | * @see ListRowModel 10 | */ 11 | data class HeaderData( 12 | val name: String, 13 | val description: String? = null, 14 | val contentDescription: String? = null, 15 | ) 16 | -------------------------------------------------------------------------------- /lounge-navigation/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `module-config` 3 | com.android.library 4 | `kotlin-android` 5 | id("com.vanniktech.maven.publish") 6 | } 7 | 8 | dependencies { 9 | api(AndroidX.leanback) 10 | 11 | implementation(Kotlin.stdlib.jdk8) 12 | implementation(AndroidX.appCompat) 13 | implementation(AndroidX.core.ktx) 14 | implementation(AndroidX.fragmentKtx) 15 | implementation(AndroidX.activityKtx) 16 | implementation(AndroidX.navigation.fragmentKtx) 17 | } 18 | -------------------------------------------------------------------------------- /fixture/src/main/java/jp/co/cyberagent/fixture/KotestProjectConfig.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.fixture 2 | 3 | import io.kotest.core.config.AbstractProjectConfig 4 | import io.kotest.core.spec.IsolationMode 5 | import io.kotest.extensions.robolectric.RobolectricExtension 6 | 7 | class KotestProjectConfig : AbstractProjectConfig() { 8 | override val isolationMode: IsolationMode = IsolationMode.InstancePerLeaf 9 | 10 | override fun extensions() = super.extensions() + RobolectricExtension() 11 | } 12 | -------------------------------------------------------------------------------- /lounge-paging/src/main/java/jp/co/cyberagent/lounge/paging/PagedListLoungeBuildModelScope.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging 2 | 3 | import androidx.paging.PagedList 4 | import jp.co.cyberagent.lounge.LoungeBuildModelScope 5 | import jp.co.cyberagent.lounge.LoungeModel 6 | 7 | /** 8 | * A [LoungeBuildModelScope] that works with [PagedList]. 9 | */ 10 | interface PagedListLoungeBuildModelScope : LoungeBuildModelScope { 11 | 12 | /** 13 | * Gets built models from the [PagedList]. 14 | */ 15 | suspend fun getItemModels(): List 16 | } 17 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/Guidance.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.graphics.drawable.Drawable 4 | import androidx.leanback.widget.GuidanceStylist 5 | 6 | /** 7 | * Constructs a [GuidanceStylist.Guidance]. 8 | */ 9 | @Suppress("FunctionName") 10 | fun Guidance( 11 | title: String? = null, 12 | description: String? = null, 13 | breadcrumb: String? = null, 14 | icon: Drawable? = null, 15 | ): GuidanceStylist.Guidance = GuidanceStylist.Guidance( 16 | title, 17 | description, 18 | breadcrumb, 19 | icon, 20 | ) 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | ## ⚠️ Is your feature request related to a problem? Please describe 7 | 8 | 9 | ## 💡 Describe the solution you'd like 10 | 11 | 12 | ## 🤚 Do you want to develop this feature yourself? 13 | 14 | - [ ] Yes 15 | - [ ] No 16 | -------------------------------------------------------------------------------- /lounge-paging/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `module-config` 3 | com.android.library 4 | `kotlin-android` 5 | id("com.vanniktech.maven.publish") 6 | } 7 | 8 | dependencies { 9 | api(project(":lounge")) 10 | api(AndroidX.leanback) 11 | api(AndroidX.paging.runtimeKtx) 12 | 13 | implementation(Kotlin.stdlib.jdk8) 14 | implementation(KotlinX.coroutines.android) 15 | 16 | implementation(AndroidX.appCompat) 17 | implementation(AndroidX.core.ktx) 18 | implementation(AndroidX.lifecycle.runtimeKtx) 19 | 20 | testImplementation(project(":fixture")) 21 | } 22 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeModel.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.leanback.widget.ObjectAdapter 4 | import androidx.leanback.widget.Presenter 5 | 6 | /** 7 | * Helper to connect data with [Presenter]. 8 | */ 9 | interface LoungeModel { 10 | 11 | /** 12 | * A key that can be used to uniquely identify this [LoungeModel] for use in [ObjectAdapter] with 13 | * stable ids. 14 | */ 15 | val key: Long 16 | 17 | /** 18 | * A [Presenter] that can bind this model. 19 | */ 20 | val presenter: Presenter 21 | } 22 | -------------------------------------------------------------------------------- /lounge-paging/src/test/java/jp/co/cyberagent/lounge/paging/util/EmptyPresenter.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging.util 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import androidx.leanback.widget.Presenter 6 | 7 | object EmptyPresenter : Presenter() { 8 | override fun onCreateViewHolder(parent: ViewGroup?): ViewHolder { 9 | return ViewHolder(View(parent?.context)) 10 | } 11 | 12 | override fun onBindViewHolder(viewHolder: ViewHolder?, item: Any?) = Unit 13 | 14 | override fun onUnbindViewHolder(viewHolder: ViewHolder?) = Unit 15 | } 16 | -------------------------------------------------------------------------------- /sample/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 15 | 16 | -------------------------------------------------------------------------------- /lounge/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `module-config` 3 | com.android.library 4 | `kotlin-android` 5 | id("com.vanniktech.maven.publish") 6 | } 7 | 8 | dependencies { 9 | api(AndroidX.leanback) 10 | 11 | implementation(Kotlin.stdlib.jdk8) 12 | implementation(KotlinX.coroutines.android) 13 | implementation(AndroidX.appCompat) 14 | implementation(AndroidX.core.ktx) 15 | implementation(AndroidX.fragmentKtx) 16 | implementation(AndroidX.activityKtx) 17 | implementation(AndroidX.lifecycle.runtimeKtx) 18 | 19 | testImplementation(project(":fixture")) 20 | debugImplementation(AndroidX.fragmentTesting) 21 | } 22 | -------------------------------------------------------------------------------- /lounge-paging/src/main/java/jp/co/cyberagent/lounge/paging/internal/CacheOp.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging.internal 2 | 3 | import jp.co.cyberagent.lounge.LoungeModel 4 | import kotlinx.coroutines.CompletableDeferred 5 | 6 | internal sealed class CacheOp { 7 | class Insert(val position: Int, val count: Int) : CacheOp() 8 | class Remove(val position: Int, val count: Int) : CacheOp() 9 | class Move(val fromPosition: Int, val toPosition: Int) : CacheOp() 10 | class Change(val position: Int, val count: Int) : CacheOp() 11 | class Get(val result: CompletableDeferred>) : CacheOp() 12 | object Clear : CacheOp() 13 | } 14 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/utils/RandomColor.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.utils 2 | 3 | private val recycle = listOf( 4 | -0xbbcca, -0x16e19d, -0x63d850, -0x98c549, 5 | -0xc0ae4b, -0xde690d, -0xfc560c, -0xff432c, 6 | -0xff6978, -0xb350b0, -0x743cb6, -0x3223c7, 7 | -0x14c5, -0x3ef9, -0x6800, -0xa8de, 8 | -0x86aab8, -0x616162, -0x9f8275, -0xcccccd 9 | ) 10 | 11 | private val colors: MutableList = mutableListOf() 12 | 13 | val randomColor: Int 14 | get() { 15 | if (colors.isEmpty()) { 16 | colors += recycle 17 | colors.shuffle() 18 | } 19 | return colors.removeLast() 20 | } 21 | -------------------------------------------------------------------------------- /fixture/src/main/java/jp/co/cyberagent/fixture/Memo.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.fixture 2 | 3 | import io.kotest.core.TestConfiguration 4 | import kotlin.properties.ReadOnlyProperty 5 | 6 | private val NOT_INITIALIZED = Any() 7 | 8 | @Suppress("UNCHECKED_CAST") 9 | fun TestConfiguration.memoized( 10 | destructor: (T) -> Unit = {}, 11 | factory: () -> T, 12 | ): ReadOnlyProperty { 13 | var value: Any? = NOT_INITIALIZED 14 | beforeEach { 15 | value = factory() 16 | } 17 | afterEach { 18 | destructor(value as T) 19 | value = NOT_INITIALIZED 20 | } 21 | return ReadOnlyProperty { _, _ -> 22 | value as T 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | #### 1.2.0 4 | 5 | * Change: targetVersion/compileVersion update to 31 6 | * Change: AGP is updated to 7.1.1 7 | * Change: MDC is update to 1.5.0 8 | 9 | #### 1.1.2 10 | 11 | * Fix: Deadlock in `PagedListModelCache` 12 | * Change: Provide correct coroutine scope for `PagedListModelCache` 13 | 14 | #### 1.1.1 15 | 16 | * Fix: Fix `JobCancellationException` cased by sending element to closed channel 17 | 18 | #### 1.1.0 19 | 20 | * Change: Remove `workerDispatcher` from `PagedListLoungeController` constructor 21 | * Fix: Fix internal `IndexOutOfBoundsException` in `PagedListModelCache` 22 | 23 | #### 1.0.0 24 | 25 | * First release 26 | -------------------------------------------------------------------------------- /fixture/src/main/java/jp/co/cyberagent/fixture/TestLifecycleOwner.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.fixture 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleOwner 5 | import androidx.lifecycle.LifecycleRegistry 6 | 7 | class TestLifecycleOwner : LifecycleOwner { 8 | private val lifecycleRegistry = LifecycleRegistry(this) 9 | 10 | override fun getLifecycle(): LifecycleRegistry = lifecycleRegistry 11 | } 12 | 13 | fun TestLifecycleOwner.withStartedThenCreated( 14 | block: () -> Unit, 15 | ) { 16 | lifecycle.currentState = Lifecycle.State.STARTED 17 | try { 18 | block() 19 | } finally { 20 | lifecycle.currentState = Lifecycle.State.CREATED 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/DeferredLoungeModel.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | /** 4 | * A [LoungeModel] whose content can be deferred. 5 | * This is useful if a [LoungeModel] need to load content asynchronously. 6 | * We can use a [LoungeControllerInterceptor] to control whether to display the model after its 7 | * content ready or display the model directly. 8 | * 9 | * @see ListRowModel 10 | * @see LoungeControllerInterceptor 11 | * @see SimpleLoungeModelAwaitInterceptor 12 | */ 13 | interface DeferredLoungeModel : LoungeModel { 14 | 15 | /** 16 | * Awaits for completion of this model without blocking a thread. 17 | */ 18 | suspend fun await() 19 | } 20 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/model/EntryModel.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.model 2 | 3 | import androidx.leanback.widget.Presenter 4 | import jp.co.cyberagent.lounge.LoungeModel 5 | import jp.co.cyberagent.lounge.databinding.SimpleDataBindingPresenter 6 | import jp.co.cyberagent.lounge.sample.BR 7 | import jp.co.cyberagent.lounge.sample.R 8 | import jp.co.cyberagent.lounge.toLoungeModelKey 9 | 10 | data class EntryModel( 11 | val name: String, 12 | val onClick: () -> Unit, 13 | ) : LoungeModel { 14 | override val key: Long = name.toLoungeModelKey() 15 | 16 | override val presenter: Presenter 17 | get() = SimpleDataBindingPresenter.get(R.layout.model_entry, BR.model) 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | ## 🐛 Describe the bug 7 | 8 | 9 | ## ⚠️ Current behavior 10 | 11 | 12 | ## ✅ Expected behavior 13 | 14 | 15 | ## 💣 Steps to reproduce 16 | 17 | 18 | ## 📷 Screenshots 19 | 20 | 21 | ## 📱 Tech info 22 | - Device: 23 | - OS: 24 | - Library/App version: 25 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LambdaLoungeController.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import kotlinx.coroutines.CoroutineDispatcher 5 | import kotlinx.coroutines.Dispatchers 6 | 7 | /** 8 | * A small wrapper around [LoungeController] that lets you implement [buildModels] by a lambda. 9 | */ 10 | class LambdaLoungeController( 11 | lifecycle: Lifecycle, 12 | modelBuildingDispatcher: CoroutineDispatcher = Dispatchers.Main, 13 | ) : LoungeController(lifecycle, modelBuildingDispatcher) { 14 | 15 | /** 16 | * Forwards method [buildModels] to call this lambda. 17 | */ 18 | var buildModels: suspend LoungeBuildModelScope.() -> Unit = {} 19 | 20 | override suspend fun buildModels() = buildModels(this) 21 | } 22 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/model/InfoModel.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.model 2 | 3 | import androidx.leanback.widget.Presenter 4 | import jp.co.cyberagent.lounge.LoungeModel 5 | import jp.co.cyberagent.lounge.databinding.SimpleDataBindingPresenter 6 | import jp.co.cyberagent.lounge.sample.BR 7 | import jp.co.cyberagent.lounge.sample.R 8 | import jp.co.cyberagent.lounge.sample.utils.randomColor 9 | import jp.co.cyberagent.lounge.toLoungeModelKey 10 | 11 | data class InfoModel( 12 | val title: String, 13 | val colorInt: Int = randomColor, 14 | ) : LoungeModel { 15 | 16 | override val key: Long = title.toLoungeModelKey() 17 | 18 | override val presenter: Presenter 19 | get() = SimpleDataBindingPresenter.get(R.layout.model_info, BR.model) 20 | } 21 | -------------------------------------------------------------------------------- /fixture/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `module-config` 3 | com.android.library 4 | `kotlin-android` 5 | } 6 | 7 | dependencies { 8 | implementation(Kotlin.stdlib.jdk8) 9 | implementation(KotlinX.coroutines.android) 10 | implementation(AndroidX.appCompat) 11 | implementation(AndroidX.core.ktx) 12 | implementation(AndroidX.fragmentKtx) 13 | implementation(AndroidX.activityKtx) 14 | implementation(AndroidX.lifecycle.runtimeKtx) 15 | implementation(AndroidX.leanback) 16 | 17 | api(KotlinX.coroutines.test) 18 | api(Testing.kotest.runner.junit5) 19 | api(Testing.kotest.assertions.core) 20 | api(Libs.Kotest.robolectric) 21 | api(Testing.roboElectric) 22 | api(AndroidX.test.coreKtx) 23 | api(AndroidX.test.espresso.core) 24 | api(AndroidX.test.espresso.contrib) 25 | api(AndroidX.test.rules) 26 | api(AndroidX.test.runner) 27 | } 28 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # Maven settings: 3 | VERSION_NAME=1.3.0-SNAPSHOT 4 | GROUP=jp.co.cyberagent.lounge 5 | POM_NAME=lounge 6 | POM_INCEPTION_YEAR=2020 7 | POM_URL=https://github.com/ca-love/lounge/ 8 | POM_SCM_URL=https://github.com/ca-love/lounge/ 9 | POM_SCM_CONNECTION=scm:git:git@github.com:ca-love/lounge.git 10 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/ca-love/lounge.git 11 | POM_LICENCE_NAME=The MIT License 12 | POM_LICENCE_URL=https://opensource.org/licenses/MIT 13 | POM_LICENCE_DIST=repo 14 | POM_DEVELOPER_ID=ca-love 15 | POM_DEVELOPER_NAME=CL 16 | POM_DEVELOPER_URL=https://www.cl-live.com 17 | RELEASE_SIGNING_ENABLED=true 18 | # Develop settings: 19 | org.gradle.jvmargs=-Xmx1536m 20 | org.gradle.vfs.watch=true 21 | android.useAndroidX=true 22 | android.enableJetifier=false 23 | kotlin.code.style=official 24 | -------------------------------------------------------------------------------- /sample/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle.kts. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/MemorizedController.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | /** 4 | * Possess a [LoungeController] associated with the [key] in current [LoungeBuildModelScope]. 5 | * Calling this method with the same key more than once during model building will throw [IllegalStateException]. 6 | * If no [LoungeController] is associated with the [key], a new [LoungeController] will be created by the [factory]. 7 | * 8 | * @see [LoungeController.possessTagDuringBuilding] 9 | */ 10 | inline fun LoungeBuildModelScope.memorizedController( 11 | key: Any, 12 | noinline factory: () -> T, 13 | ): T { 14 | check(this is LoungeController) { 15 | "LoungeBuildModelScope must be a LoungeController to invoke memorizedController." 16 | } 17 | return possessTagDuringBuilding(key, T::class, factory) 18 | } 19 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. Change the version in top-level `gradle.properties` to a non-SNAPSHOT version. 4 | 2. Update the `CHANGELOG.md` for the impending release. 5 | 3. Update the `README.md` with the new version. 6 | 4. `git commit -am "Prepare for release X.Y.Z."` (where X.Y.Z is the new version). 7 | 5. `./gradlew publish --no-daemon --no-parallel -PmavenCentralUsername="$NEXUS_USERNAME" -PmavenCentralPassword="$NEXUS_PASSWORD"` 8 | 6. Visit [SONATYPE](https://oss.sonatype.org/#stagingRepositories) and promote the artifact. 9 | 7. `git tag -a X.Y.X -m "X.Y.Z"` (where X.Y.Z is the new version) 10 | 8. Update the top-level `gradle.properties` to the next SNAPSHOT version. 11 | 9. `git commit -am "Prepare next development version."` 12 | 10. `git push && git push --tags` 13 | 14 | If step 5 or 6 fails, drop the Sonatype repo, fix the problem, commit, and start again at step 5. 15 | -------------------------------------------------------------------------------- /lounge/src/test/java/jp/co/cyberagent/lounge/util/TestModel.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.util 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import androidx.leanback.widget.Presenter 6 | import jp.co.cyberagent.lounge.LoungeBuildModelScope 7 | import jp.co.cyberagent.lounge.LoungeModel 8 | 9 | data class TestModel( 10 | override val key: Long, 11 | ) : LoungeModel { 12 | 13 | override val presenter: Presenter 14 | get() = Companion 15 | 16 | companion object : Presenter() { 17 | override fun onCreateViewHolder(parent: ViewGroup?): ViewHolder = 18 | ViewHolder(View(parent?.context)) 19 | 20 | override fun onBindViewHolder(viewHolder: ViewHolder?, item: Any?) = Unit 21 | 22 | override fun onUnbindViewHolder(viewHolder: ViewHolder?) = Unit 23 | } 24 | } 25 | 26 | suspend fun LoungeBuildModelScope.testModel(key: Long) { 27 | +TestModel(key) 28 | } 29 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeGuidedActionsStylist.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.leanback.widget.GuidedAction 4 | import androidx.leanback.widget.GuidedActionsStylist 5 | 6 | /** 7 | * A [GuidedActionsStylist] to use with [LoungeGuidedAction]. 8 | */ 9 | open class LoungeGuidedActionsStylist : GuidedActionsStylist() { 10 | 11 | override fun getItemViewType(action: GuidedAction?): Int { 12 | return if (action is LoungeGuidedAction && 13 | action.layoutId != LoungeGuidedAction.DEFAULT_LAYOUT_ID 14 | ) { 15 | action.layoutId 16 | } else { 17 | super.getItemViewType(action) 18 | } 19 | } 20 | 21 | override fun onProvideItemLayoutId(viewType: Int): Int { 22 | return if (viewType != VIEW_TYPE_DEFAULT || viewType != VIEW_TYPE_DATE_PICKER) { 23 | viewType 24 | } else { 25 | super.onProvideItemLayoutId(viewType) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs via GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | name: Generate API docs and deploy docs 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: gradle/wrapper-validation-action@v1 15 | - uses: actions/setup-java@v1 16 | with: 17 | java-version: 11 18 | - uses: actions/cache@v2 19 | with: 20 | path: ~/.gradle/caches 21 | key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/versions.properties') }} 22 | 23 | - name: Prepare Docs 24 | run: ./tools/prepare_docs.sh 25 | 26 | - name: Deploy docs 27 | uses: mhausenblas/mkdocs-deploy-gh-pages@master 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /tools/deploy_snapshot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REPOSITORY="ca-love/lounge" 4 | REF="refs/heads/master" 5 | 6 | set -e 7 | 8 | VERSION_NAME=$(grep VERSION_NAME gradle.properties) 9 | 10 | if [[ ! "$VERSION_NAME" =~ "SNAPSHOT" ]]; then 11 | echo "Skipping snapshot deployment: wrong version name '$VERSION_NAME'." 12 | elif [[ "$GITHUB_REPOSITORY" != "$REPOSITORY" ]]; then 13 | echo "Skipping snapshot deployment: wrong repository. Expected '$REPOSITORY' but was '$GITHUB_REPOSITORY'." 14 | elif [[ "$GITHUB_EVENT_NAME" != "push" ]]; then 15 | echo "Skipping snapshot deployment: was '$GITHUB_EVENT_NAME'." 16 | elif [[ "$GITHUB_REF" != "$REF" ]]; then 17 | echo "Skipping snapshot deployment: wrong ref. Expected '$REF' but was '$GITHUB_REF'." 18 | else 19 | echo "Deploying..." 20 | ./gradlew publish --no-daemon --no-parallel -PmavenCentralRepositoryUsername="$NEXUS_USERNAME" -PmavenCentralRepositoryPassword="$NEXUS_PASSWORD" 21 | echo "Deployed!" 22 | fi 23 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeBuildModelScope.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import kotlinx.coroutines.CoroutineDispatcher 5 | 6 | /** 7 | * A scope which can build [LoungeModel]s. 8 | */ 9 | interface LoungeBuildModelScope { 10 | 11 | /** 12 | * The lifecycle of this scope's host. 13 | */ 14 | val lifecycle: Lifecycle 15 | 16 | /** 17 | * The dispatcher for building models. 18 | */ 19 | val modelBuildingDispatcher: CoroutineDispatcher 20 | 21 | /** 22 | * Adds a [LoungeModel] to this scope. 23 | */ 24 | suspend operator fun LoungeModel.unaryPlus() 25 | 26 | /** 27 | * Adds a list of [LoungeModel]s to this scope. 28 | */ 29 | suspend operator fun List.unaryPlus() 30 | } 31 | 32 | /** 33 | * Adds this model to the [LoungeBuildModelScope]. 34 | */ 35 | suspend fun LoungeModel.addTo(scope: LoungeBuildModelScope) = with(scope) { +this@addTo } 36 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/AdapterDsl.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.leanback.widget.ObjectAdapter 4 | import androidx.lifecycle.Lifecycle 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.Dispatchers 7 | 8 | /** 9 | * A simply function that let you construct an [ObjectAdapter] from some [LoungeModel]s directly. 10 | * 11 | * Example: 12 | * 13 | * ``` 14 | * val adapter = objectAdapterWithLoungeModels(lifecycle) { 15 | * +MyModel(0) 16 | * +MyModel(1) 17 | * } 18 | * ``` 19 | */ 20 | fun objectAdapterWithLoungeModels( 21 | lifecycle: Lifecycle, 22 | modelBuildingDispatcher: CoroutineDispatcher = Dispatchers.Main, 23 | buildModels: suspend LoungeBuildModelScope.() -> Unit, 24 | ): ObjectAdapter { 25 | val controller = LambdaLoungeController(lifecycle, modelBuildingDispatcher) 26 | controller.buildModels = buildModels 27 | controller.requestModelBuild() 28 | return controller.adapter 29 | } 30 | -------------------------------------------------------------------------------- /lounge-databinding/src/main/java/jp/co/cyberagent/lounge/databinding/SimpleDataBindingRowPresenter.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.databinding 2 | 3 | import androidx.annotation.LayoutRes 4 | import androidx.databinding.ViewDataBinding 5 | 6 | /** 7 | * A small wrapper around [DataBindingRowPresenter] which you can simply bind a item 8 | * to a [ViewDataBinding] via the BR id. 9 | * 10 | * @param T type of the item to be bind. 11 | * @param layoutId the DataBinding layout that the item will bind to. 12 | * @param modelVariableId the variable id ([BR](https://developer.android.com/topic/libraries/data-binding/generated-binding#dynamic_variables)) 13 | * defined in the DataBinding layout. 14 | */ 15 | open class SimpleDataBindingRowPresenter( 16 | @LayoutRes layoutId: Int, 17 | private val modelVariableId: Int, 18 | ) : DataBindingRowPresenter(layoutId) { 19 | 20 | override fun onBindRow(binding: ViewDataBinding, item: T) { 21 | binding.setVariable(modelVariableId, item) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/internal/Logger.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.internal 2 | 3 | import android.util.Log 4 | import androidx.annotation.VisibleForTesting 5 | import kotlin.system.measureNanoTime 6 | 7 | @Suppress("MagicNumber") 8 | internal inline fun logMeasureTimeMillis( 9 | enabled: Boolean, 10 | tag: String, 11 | blockName: () -> String, 12 | block: () -> Unit, 13 | ) { 14 | if (enabled) { 15 | val durationMs: Float = measureNanoTime(block).toFloat() / 1000000f 16 | log(tag, "${blockName()} in %.3f ms.".format(durationMs)) 17 | } else { 18 | block() 19 | } 20 | } 21 | 22 | internal fun log(tag: String, message: String) = LoggerInstance.log(tag, message) 23 | 24 | @VisibleForTesting 25 | internal var LoggerInstance = AndroidLogger() 26 | 27 | internal interface Logger { 28 | fun log(tag: String, message: String) 29 | } 30 | 31 | internal class AndroidLogger : Logger { 32 | override fun log(tag: String, message: String) { 33 | Log.d(tag, message) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.ui 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.navigation.fragment.NavHostFragment 6 | import jp.co.cyberagent.lounge.LoungeController 7 | import jp.co.cyberagent.lounge.navigation.createGuidedStepFragmentNavigator 8 | import jp.co.cyberagent.lounge.sample.R 9 | 10 | class MainActivity : AppCompatActivity(R.layout.activity_main) { 11 | 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | (supportFragmentManager.findFragmentById(R.id.nav_host) as? NavHostFragment) 15 | ?.navController 16 | ?.apply { 17 | navigatorProvider.addNavigator( 18 | createGuidedStepFragmentNavigator(R.id.nav_host) 19 | ) 20 | setGraph(R.navigation.nav_main) 21 | } 22 | } 23 | 24 | companion object { 25 | init { 26 | LoungeController.GlobalDebugLogEnabled = true 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docs/material.md: -------------------------------------------------------------------------------- 1 | # MaterialDesign Support 2 | 3 | Lounge provides `Theme.MaterialComponents.Leanback.Bridge`, 4 | a theme contains style attributes both required by Leanback and MaterialComponents. 5 | 6 | ## Usage 7 | 8 | ### Installation 9 | 10 | ```gradle 11 | dependencies { 12 | implementation 'jp.co.cyberagent.lounge:lounge-material:$latestVersion' 13 | } 14 | ``` 15 | 16 | ### Ensure you are using AppCompatActivity 17 | 18 | ```kotlin 19 | class TvActivity : AppCompatActivity() { 20 | // Set up 21 | } 22 | ``` 23 | 24 | ### Change your app theme 25 | 26 | In your theme xml files: 27 | 28 | ```xml 29 | 32 | ``` 33 | 34 | In your `AndroidManifest.xml`: 35 | 36 | ```xml 37 | 41 | 44 | 45 | ``` 46 | 47 | Now your can use MaterialComponents in your application. 48 | -------------------------------------------------------------------------------- /lounge-paging/src/main/java/jp/co/cyberagent/lounge/paging/LambdaPagedListLoungeController.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import jp.co.cyberagent.lounge.LoungeModel 5 | import kotlinx.coroutines.CoroutineDispatcher 6 | import kotlinx.coroutines.Dispatchers 7 | 8 | /** 9 | * A small wrapper around [PagedListLoungeController] that lets you implement 10 | * [buildItemModel] and [buildModels] by lambdas. 11 | */ 12 | class LambdaPagedListLoungeController( 13 | lifecycle: Lifecycle, 14 | modelBuildingDispatcher: CoroutineDispatcher = Dispatchers.Main, 15 | ) : PagedListLoungeController( 16 | lifecycle, modelBuildingDispatcher 17 | ) { 18 | 19 | lateinit var buildItemModel: (Int, T?) -> LoungeModel 20 | 21 | var buildModels: suspend PagedListLoungeBuildModelScope.(List) -> Unit = { +it } 22 | 23 | override fun buildItemModel(position: Int, item: T?): LoungeModel = 24 | buildItemModel.invoke(position, item) 25 | 26 | override suspend fun buildModels() = buildModels(getItemModels()) 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `module-config` 3 | com.android.application 4 | `kotlin-android` 5 | `kotlin-kapt` 6 | } 7 | 8 | android { 9 | defaultConfig { 10 | vectorDrawables.useSupportLibrary = true 11 | multiDexEnabled = true 12 | } 13 | 14 | buildFeatures { 15 | dataBinding = true 16 | } 17 | 18 | lintOptions { 19 | isWarningsAsErrors = false 20 | isAbortOnError = false 21 | } 22 | } 23 | 24 | dependencies { 25 | implementation(Kotlin.stdlib.jdk8) 26 | 27 | implementation(AndroidX.appCompat) 28 | implementation(AndroidX.constraintLayout) 29 | implementation(AndroidX.core.ktx) 30 | implementation(AndroidX.fragmentKtx) 31 | implementation(AndroidX.activityKtx) 32 | implementation(AndroidX.navigation.fragmentKtx) 33 | implementation(AndroidX.multidex) 34 | implementation(Google.android.material) 35 | 36 | 37 | implementation(project(":lounge")) 38 | implementation(project(":lounge-databinding")) 39 | implementation(project(":lounge-material")) 40 | implementation(project(":lounge-navigation")) 41 | implementation(project(":lounge-paging")) 42 | } 43 | -------------------------------------------------------------------------------- /lounge/src/test/java/jp/co/cyberagent/lounge/AdapterDslTest.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import io.kotest.core.spec.style.FunSpec 5 | import io.kotest.extensions.robolectric.RobolectricTest 6 | import io.kotest.matchers.shouldBe 7 | import jp.co.cyberagent.fixture.TestLifecycleOwner 8 | import jp.co.cyberagent.fixture.memoized 9 | import jp.co.cyberagent.lounge.util.TestModel 10 | import kotlinx.coroutines.ExperimentalCoroutinesApi 11 | import kotlinx.coroutines.test.TestCoroutineDispatcher 12 | 13 | @ExperimentalCoroutinesApi 14 | @RobolectricTest 15 | class AdapterDslTest : FunSpec({ 16 | val owner by memoized { TestLifecycleOwner() } 17 | val dispatcher by memoized { TestCoroutineDispatcher() } 18 | 19 | test("objectAdapterWithLoungeModels") { 20 | val adapter = objectAdapterWithLoungeModels(owner.lifecycle, dispatcher) { 21 | +TestModel(1L) 22 | +TestModel(10L) 23 | } 24 | 25 | owner.lifecycle.currentState = Lifecycle.State.STARTED 26 | adapter.size() shouldBe 2 27 | adapter.get(0) shouldBe TestModel(1L) 28 | adapter.get(1) shouldBe TestModel(10L) 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /lounge-navigation/src/main/java/jp/co/cyberagent/lounge/navigation/BrowseSupportFragmentBackPressedCallback.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.navigation 2 | 3 | import androidx.activity.OnBackPressedCallback 4 | import androidx.activity.addCallback 5 | import androidx.leanback.app.BrowseSupportFragment 6 | import androidx.lifecycle.LifecycleOwner 7 | 8 | /** 9 | * A helper method that constructs a [OnBackPressedCallback] to simulate back press behavior when 10 | * [BrowseSupportFragment.isHeadersTransitionOnBackEnabled] equals to true. 11 | * This is useful when you use [BrowseSupportFragment] in a single activity architecture that 12 | * parent activity can has its own onBackPressed handling. 13 | */ 14 | fun BrowseSupportFragment.addHeadersTransitionOnBackPressedCallback( 15 | owner: LifecycleOwner? = this, 16 | ): OnBackPressedCallback { 17 | val dispatcher = requireActivity().onBackPressedDispatcher 18 | return dispatcher.addCallback(owner) { 19 | if (isShowingHeaders) { 20 | isEnabled = false 21 | dispatcher.onBackPressed() 22 | isEnabled = true 23 | } else { 24 | startHeadersTransition(true) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeControllerInterceptor.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | /** 4 | * Intercept the lifecycle of the [LoungeController]. 5 | * You can add a interceptor via [LoungeController.addInterceptor]. 6 | */ 7 | interface LoungeControllerInterceptor { 8 | 9 | /** 10 | * Callback before building models. 11 | * 12 | * @param controller the intercept target. 13 | */ 14 | suspend fun beforeBuildModels( 15 | controller: LoungeController, 16 | ) = Unit 17 | 18 | /** 19 | * Callback after building models. 20 | * You can modify built [models]. 21 | * 22 | * @param controller the intercept target. 23 | */ 24 | suspend fun afterBuildModels( 25 | controller: LoungeController, 26 | models: MutableList, 27 | ) = Unit 28 | 29 | /** 30 | * Callback before add a [LoungeModel] to the controller. 31 | * 32 | * @param controller the intercept target. 33 | * @param addPosition the index of the model if added. 34 | * @param model the model before add. 35 | */ 36 | suspend fun beforeAddModel( 37 | controller: LoungeController, 38 | addPosition: Int, 39 | model: LoungeModel, 40 | ) = Unit 41 | } 42 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/internal/LoungeAdapter.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.internal 2 | 3 | import androidx.leanback.widget.ArrayObjectAdapter 4 | import androidx.leanback.widget.Presenter 5 | import androidx.leanback.widget.PresenterSelector 6 | import jp.co.cyberagent.lounge.LoungeModel 7 | 8 | internal class LoungeAdapter : ArrayObjectAdapter() { 9 | 10 | init { 11 | presenterSelector = LoungeModelPresenterSelector() 12 | setHasStableIds(true) 13 | } 14 | 15 | var listener: Listener? = null 16 | 17 | override fun get(position: Int): Any { 18 | listener?.onGetItemAt(position) 19 | return super.get(position) 20 | } 21 | 22 | override fun getId(position: Int): Long { 23 | return (super.get(position) as LoungeModel).key 24 | } 25 | 26 | fun interface Listener { 27 | fun onGetItemAt(position: Int) 28 | } 29 | } 30 | 31 | private class LoungeModelPresenterSelector : PresenterSelector() { 32 | 33 | override fun getPresenter(item: Any?): Presenter { 34 | require(item is LoungeModel) { "Require LoungeModel but get $item." } 35 | return item.presenter 36 | } 37 | 38 | override fun getPresenters(): Array = emptyArray() 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lounge 2 | 3 |

4 | 5 | 6 | 7 |

8 | 9 | Lounge is an Android library for building Leanback user interface required by Android TV. 10 | 11 | ## Installation 12 | 13 | Add dependencies to module `build.gradle`: 14 | 15 | ```gradle 16 | dependencies { 17 | // Leanckback helper for ObjectAdapter, Presenter, GuidedAction and et al. 18 | implementation("jp.co.cyberagent.lounge:lounge:$latestVersion") 19 | 20 | // Paging Support: 21 | implementation("jp.co.cyberagent.lounge:lounge-paging:$latestVersion") 22 | // DataBinding Support: 23 | implementation("jp.co.cyberagent.lounge:lounge-databinding:$latestVersion") 24 | // Navigation Component Support: 25 | implementation("jp.co.cyberagent.lounge:lounge-navigation:$latestVersion") 26 | // Material Design Support: 27 | implementation("jp.co.cyberagent.lounge:lounge-material:$latestVersion") 28 | } 29 | ``` 30 | 31 | ## Documentation 32 | 33 | Check out Lounge's [full documentation here](https://ca-love.github.io/lounge/). 34 | 35 | ## Contributing 36 | 37 | Feel free to open a issue or submit a pull request for any bugs/improvements. 38 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/model_info.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | 20 | 21 | 26 | 27 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 13 | 17 | 18 | 28 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## 🚀 Description 4 | 5 | 6 | ## 📄 Motivation and Context 7 | 8 | 9 | 10 | ## 🧪 How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## 📷 Screenshots (if appropriate) 16 | 17 | 18 | ## 📦 Types of changes 19 | 20 | - [ ] Bug fix (non-breaking change which fixes an issue) 21 | - [ ] New feature (non-breaking change which adds functionality) 22 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 23 | 24 | ## ✅ Checklist 25 | 26 | 27 | - [ ] My code follows the code style of this project. 28 | - [ ] My change requires a change to the documentation. 29 | - [ ] I have updated the documentation accordingly. -------------------------------------------------------------------------------- /lounge/src/test/java/jp/co/cyberagent/lounge/LambdaLoungeControllerTest.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import io.kotest.core.spec.style.FunSpec 5 | import io.kotest.extensions.robolectric.RobolectricTest 6 | import io.kotest.matchers.shouldBe 7 | import jp.co.cyberagent.fixture.TestLifecycleOwner 8 | import jp.co.cyberagent.fixture.memoized 9 | import jp.co.cyberagent.lounge.util.TestModel 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.test.TestCoroutineDispatcher 13 | import kotlinx.coroutines.test.setMain 14 | 15 | @ExperimentalCoroutinesApi 16 | @RobolectricTest 17 | class LambdaLoungeControllerTest : FunSpec({ 18 | 19 | val owner by memoized { TestLifecycleOwner() } 20 | val dispatcher by memoized { TestCoroutineDispatcher() } 21 | 22 | beforeEach { 23 | Dispatchers.setMain(dispatcher) 24 | } 25 | 26 | test("Build models") { 27 | val models = List(10) { TestModel(it + 1L) } 28 | val controller = LambdaLoungeController(owner.lifecycle, dispatcher).apply { 29 | buildModels = { +models } 30 | } 31 | 32 | controller.requestModelBuild() 33 | owner.lifecycle.currentState = Lifecycle.State.STARTED 34 | 35 | models.forEachIndexed { index, testModel -> 36 | controller.adapter[index] shouldBe testModel 37 | } 38 | controller.adapter.size() shouldBe models.size 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeGuidedStepSupportFragment.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.leanback.app.GuidedStepSupportFragment 6 | import androidx.leanback.widget.GuidedAction 7 | import androidx.leanback.widget.GuidedActionsStylist 8 | 9 | /** 10 | * A subclass of [GuidedStepSupportFragment] with proper implementation for using [LoungeGuidedAction]. 11 | * 12 | * @see createGuidedActions 13 | */ 14 | open class LoungeGuidedStepSupportFragment : GuidedStepSupportFragment() { 15 | 16 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 17 | super.onViewCreated(view, savedInstanceState) 18 | setActionsDiffCallback(LoungeGuidedActionDiffCallback) 19 | } 20 | 21 | override fun onCreateActionsStylist(): GuidedActionsStylist { 22 | return LoungeGuidedActionsStylist() 23 | } 24 | 25 | override fun onGuidedActionClicked(action: GuidedAction?) { 26 | onLoungeGuidedActionClicked(action) 27 | } 28 | 29 | override fun onSubGuidedActionClicked(action: GuidedAction?): Boolean { 30 | return onSubLoungeGuidedActionClicked(action) 31 | } 32 | 33 | override fun onGuidedActionFocused(action: GuidedAction?) { 34 | onLoungeGuidedActionFocused(action) 35 | } 36 | 37 | override fun onGuidedActionEditedAndProceed(action: GuidedAction?): Long { 38 | return onLoungeGuidedActionEditedAndProceed(action) 39 | } 40 | 41 | override fun onGuidedActionEditCanceled(action: GuidedAction?) { 42 | onLoungeGuidedActionEditCanceled(action) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/model/TextModel.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.model 2 | 3 | import android.view.ViewGroup 4 | import android.widget.TextView 5 | import androidx.core.view.setPadding 6 | import androidx.leanback.widget.Presenter 7 | import com.google.android.material.textview.MaterialTextView 8 | import jp.co.cyberagent.lounge.LoungeModel 9 | import jp.co.cyberagent.lounge.TypedPresenter 10 | import jp.co.cyberagent.lounge.sample.utils.randomColor 11 | import jp.co.cyberagent.lounge.toLoungeModelKey 12 | 13 | data class TextModel( 14 | val title: String, 15 | override val key: Long = title.toLoungeModelKey(), 16 | ) : LoungeModel { 17 | 18 | override val presenter: Presenter = TextModelPresenter 19 | } 20 | 21 | @Suppress("MagicNumber") 22 | private object TextModelPresenter : TypedPresenter() { 23 | 24 | override fun onCreate(parent: ViewGroup): ViewHolder { 25 | val textView = MaterialTextView(parent.context).apply { 26 | layoutParams = ViewGroup.LayoutParams(200, 120) 27 | isFocusable = true 28 | isFocusableInTouchMode = true 29 | setPadding(32) 30 | setBackgroundColor(randomColor) 31 | @Suppress("DEPRECATION") 32 | setTextAppearance(context, com.google.android.material.R.style.TextAppearance_AppCompat_Body1) 33 | } 34 | return ViewHolder(textView) 35 | } 36 | 37 | override fun onBind(vh: ViewHolder, item: TextModel) { 38 | vh.textView.text = item.title 39 | } 40 | 41 | class ViewHolder(val textView: TextView) : Presenter.ViewHolder(textView) 42 | } 43 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/model_entry.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 12 | 21 | 22 | 31 | 32 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/SimpleLoungeModelAwaitInterceptor.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | /** 4 | * An interceptor that can be used with [DeferredLoungeModel]. 5 | * Await the completion of first few models before add them to the controller. 6 | * 7 | * Takes the [ListRowModel] as an example. The [ListRowModel] has its own [LoungeController] and 8 | * it may need some time to complete its first models building. 9 | * In the first rendering, its header data can already become visible before the completion of its row data. 10 | * And this is not our desired behavior. 11 | * Use this interceptor, we can ensure to display as soon as the first few visible rows complete, 12 | * and keep other invisible rows load content asynchronously. 13 | * 14 | * @param awaitNumOfModels the number of models to await. The default value will await all models 15 | * to be completion. 16 | */ 17 | class SimpleLoungeModelAwaitInterceptor( 18 | private val awaitNumOfModels: Int = AWAIT_NUM_UNLIMITED, 19 | ) : LoungeControllerInterceptor { 20 | 21 | init { 22 | check(awaitNumOfModels >= AWAIT_NUM_UNLIMITED) { 23 | "LoungeModelAwaitInterceptor awaitNum must be at least -1, but $awaitNumOfModels was specified." 24 | } 25 | } 26 | 27 | override suspend fun beforeAddModel( 28 | controller: LoungeController, 29 | addPosition: Int, 30 | model: LoungeModel, 31 | ) { 32 | if (addPosition >= awaitNumOfModels && awaitNumOfModels != AWAIT_NUM_UNLIMITED) return 33 | if (model is DeferredLoungeModel) { 34 | model.await() 35 | } 36 | } 37 | 38 | companion object { 39 | private const val AWAIT_NUM_UNLIMITED = -1 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/ui/HomeFragment.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.ui 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import androidx.appcompat.view.ContextThemeWrapper 6 | import androidx.leanback.app.BrowseSupportFragment 7 | import androidx.navigation.fragment.findNavController 8 | import jp.co.cyberagent.lounge.listRowOf 9 | import jp.co.cyberagent.lounge.objectAdapterWithLoungeModels 10 | import jp.co.cyberagent.lounge.sample.R 11 | import jp.co.cyberagent.lounge.sample.model.EntryModel 12 | 13 | class HomeFragment : BrowseSupportFragment() { 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | title = "Home" 18 | headersState = HEADERS_DISABLED 19 | isHeadersTransitionOnBackEnabled = false 20 | adapter = objectAdapterWithLoungeModels(lifecycle) { 21 | listRowOf( 22 | headerData = null, 23 | key = "entries" 24 | ) { 25 | +EntryModel("Rows Example") { 26 | findNavController().navigate(R.id.to_rows) 27 | } 28 | 29 | +EntryModel("Vertical Grid Example") { 30 | findNavController().navigate(R.id.to_vertical_grid) 31 | } 32 | 33 | +EntryModel("Guided Step Example") { 34 | findNavController().navigate(R.id.to_guided_step) 35 | } 36 | 37 | +EntryModel("PagedList Stress Test") { 38 | findNavController().navigate(R.id.fragment_paged_list_stress_test) 39 | } 40 | } 41 | } 42 | } 43 | 44 | override fun getContext(): Context? { 45 | return super.getContext()?.let { ContextThemeWrapper(it, R.style.AppTheme_Home) } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lounge-paging/src/test/java/jp/co/cyberagent/lounge/paging/util/Item.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | * For from airbnb/epoxy 17 | */ 18 | package jp.co.cyberagent.lounge.paging.util 19 | 20 | import androidx.leanback.widget.Presenter 21 | import androidx.recyclerview.widget.DiffUtil 22 | import jp.co.cyberagent.lounge.LoungeModel 23 | 24 | /** 25 | * Dummy item for testing. 26 | */ 27 | data class Item(val id: Int, val value: String) { 28 | companion object { 29 | val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { 30 | override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.id == newItem.id 31 | 32 | override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem == newItem 33 | } 34 | } 35 | } 36 | 37 | class FakePlaceholderModel(val pos: Int) : LoungeModel { 38 | override val key = -pos.toLong() 39 | override val presenter: Presenter = EmptyPresenter 40 | } 41 | 42 | class FakeModel(val item: Item) : LoungeModel { 43 | override val key = item.id.toLong() 44 | override val presenter: Presenter = EmptyPresenter 45 | } 46 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/utils/PagedListCreator.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.utils 2 | 3 | import android.util.Log 4 | import androidx.paging.Config 5 | import androidx.paging.ItemKeyedDataSource 6 | import androidx.paging.PagedList 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.GlobalScope 9 | import kotlinx.coroutines.asExecutor 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.launch 12 | import kotlin.math.min 13 | 14 | fun List.asPagedList(pageSize: Int = 10, enablePlaceholders: Boolean): PagedList { 15 | val config = Config(pageSize, enablePlaceholders = enablePlaceholders) 16 | return PagedList( 17 | DataSource(this), 18 | config, 19 | Dispatchers.Main.asExecutor(), 20 | Dispatchers.IO.asExecutor(), 21 | ) 22 | } 23 | 24 | private class DataSource( 25 | private val list: List, 26 | ) : ItemKeyedDataSource() { 27 | 28 | override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { 29 | callback.onResult(list.take(params.requestedLoadSize), 0, list.size) 30 | } 31 | 32 | override fun loadAfter(params: LoadParams, callback: LoadCallback) { 33 | GlobalScope.launch { 34 | delay(1000) 35 | val start = params.key + 1 36 | val end = min(params.key + params.requestedLoadSize, list.lastIndex) 37 | Log.d("PagedList", "${list.size}, $start, $end, ${params.key}, ${params.requestedLoadSize}") 38 | callback.onResult(list.slice(start..end)) 39 | } 40 | } 41 | 42 | override fun loadBefore(params: LoadParams, callback: LoadCallback) = Unit 43 | 44 | override fun getKey(item: T): Int = list.indexOf(item) 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - "**.md" 9 | pull_request: 10 | paths-ignore: 11 | - "**.md" 12 | 13 | jobs: 14 | check: 15 | name: Check 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: gradle/wrapper-validation-action@v1 21 | - uses: actions/setup-java@v1 22 | with: 23 | java-version: 11 24 | - uses: actions/cache@v2 25 | with: 26 | path: ~/.gradle/caches 27 | key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/versions.properties') }} 28 | 29 | - name: Check 30 | run: ./gradlew lint detekt 31 | 32 | # Check if there has been a binary incompatible change to the API. 33 | # If this change is intentional, run `./gradlew apiDump` and commit the new API files. 34 | # - name: Check binary compatibility 35 | # run: ./gradlew apiCheck 36 | 37 | unit-tests: 38 | name: Unit tests 39 | runs-on: ubuntu-latest 40 | timeout-minutes: 10 41 | steps: 42 | - uses: actions/checkout@v2 43 | - uses: gradle/wrapper-validation-action@v1 44 | - uses: actions/setup-java@v1 45 | with: 46 | java-version: 11 47 | - uses: actions/cache@v2 48 | with: 49 | path: ~/.gradle/caches 50 | key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/versions.properties') }} 51 | 52 | - name: Unit tests 53 | run: ./gradlew testDebugUnitTest 54 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 17 | 21 | 25 | 26 | 27 | 28 | 35 | 36 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | import de.fayard.refreshVersions.bootstrapRefreshVersions 2 | import de.fayard.refreshVersions.migrateRefreshVersionsIfNeeded 3 | 4 | pluginManagement { 5 | repositories { 6 | gradlePluginPortal() 7 | google() 8 | mavenCentral() 9 | jcenter() 10 | } 11 | } 12 | 13 | buildscript { 14 | repositories { gradlePluginPortal() } 15 | dependencies.classpath("de.fayard.refreshVersions:refreshVersions:0.9.7") 16 | //// # available:0.10.0") 17 | //// # available:0.10.1") 18 | //// # available:0.11.0") 19 | //// # available:0.20.0") 20 | //// # available:0.21.0") 21 | //// # available:0.22.0") 22 | //// # available:0.23.0") 23 | //// # available:0.30.0") 24 | //// # available:0.30.1") 25 | //// # available:0.30.2") 26 | //// # available:0.40.0") 27 | //// # available:0.40.1") 28 | } 29 | 30 | migrateRefreshVersionsIfNeeded("0.9.7") // Will be automatically removed by refreshVersions when upgraded to the latest version. 31 | 32 | bootstrapRefreshVersions() 33 | 34 | rootProject.name = "lounge" 35 | 36 | include( 37 | "sample", 38 | "fixture", 39 | "lounge", 40 | "lounge-databinding", 41 | "lounge-material", 42 | "lounge-navigation", 43 | "lounge-paging" 44 | ) 45 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "Lounge" 2 | repo_name: ca-love/lounge 3 | repo_url: https://github.com/ca-love/lounge 4 | remote_branch: gh-pages 5 | 6 | theme: 7 | name: material 8 | language: "en" 9 | palette: 10 | primary: red 11 | accent: red 12 | font: 13 | text: Open Sans 14 | code: Roboto Mono 15 | features: 16 | - navigation.expand 17 | 18 | extra: 19 | manifest: manifest.webmanifest 20 | 21 | nav: 22 | - "Overview": index.md 23 | - "ObjectAdapter Support": object-adapter.md 24 | - "GuidedStep Support": guided-step.md 25 | - "Paging Support": wip.md 26 | - "MaterialDesign Support": material.md 27 | - "Navigation Support": wip.md 28 | - "Changelog": changelog.md 29 | - "API Docs": 30 | - "lounge": api/lounge/lounge 31 | - "lounge-databinding": api/lounge-databinding/lounge-databinding 32 | - "lounge-paging": api/lounge-paging/lounge-paging 33 | - "lounge-material": api/lounge-material/lounge-material 34 | - "lounge-navigation": api/lounge-navigation/lounge-navigation 35 | 36 | markdown_extensions: 37 | - admonition 38 | - smarty 39 | - codehilite: 40 | guess_lang: false 41 | linenums: True 42 | - footnotes 43 | - meta 44 | - toc: 45 | permalink: true 46 | - pymdownx.arithmatex 47 | - pymdownx.betterem: 48 | smart_enable: all 49 | - pymdownx.caret 50 | - pymdownx.critic 51 | - pymdownx.details 52 | - pymdownx.emoji: 53 | emoji_index: !!python/name:materialx.emoji.twemoji 54 | emoji_generator: !!python/name:materialx.emoji.to_svg 55 | - pymdownx.inlinehilite 56 | - pymdownx.magiclink 57 | - pymdownx.mark 58 | - pymdownx.smartsymbols 59 | - pymdownx.superfences 60 | - pymdownx.tasklist: 61 | custom_checkbox: true 62 | - pymdownx.tabbed 63 | - pymdownx.tilde 64 | - tables 65 | 66 | plugins: 67 | - search 68 | - minify: 69 | minify_html: true 70 | -------------------------------------------------------------------------------- /sample/src/main/res/navigation/nav_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 16 | 20 | 24 | 25 | 26 | 30 | 31 | 35 | 36 | 40 | 44 | 50 | 51 | 52 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /lounge-databinding/src/main/java/jp/co/cyberagent/lounge/databinding/SimpleDataBindingPresenter.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.databinding 2 | 3 | import androidx.annotation.LayoutRes 4 | import androidx.databinding.ViewDataBinding 5 | import jp.co.cyberagent.lounge.LoungeModel 6 | import java.util.concurrent.ConcurrentHashMap 7 | 8 | /** 9 | * A small wrapper around [DataBindingPresenter] which you can simply bind a item 10 | * to a [ViewDataBinding] via the BR id. 11 | * 12 | * @param T type of the item to be bind. 13 | * @param layoutId the DataBinding layout that the item will bind to. 14 | * @param modelVariableId the variable id ([BR](https://developer.android.com/topic/libraries/data-binding/generated-binding#dynamic_variables)) 15 | * defined in the DataBinding layout. 16 | */ 17 | open class SimpleDataBindingPresenter( 18 | @LayoutRes layoutId: Int, 19 | private val modelVariableId: Int, 20 | ) : DataBindingPresenter(layoutId) { 21 | 22 | override fun onBind(binding: ViewDataBinding, item: T) { 23 | binding.setVariable(modelVariableId, item) 24 | } 25 | 26 | companion object { 27 | 28 | private val cache = ConcurrentHashMap>() 29 | 30 | /** 31 | * Returns the cached presenter for the key that corresponded to the given [layoutId] 32 | * and [modelVariableId]. If the presenter not presented in the cache yet, then a new 33 | * [SimpleDataBindingPresenter] will be created and puts it into the cache, then returns it. 34 | */ 35 | fun get( 36 | layoutId: Int, 37 | modelVariableId: Int, 38 | ): SimpleDataBindingPresenter { 39 | val key = (layoutId.toLong() shl 32) or modelVariableId.toLong() 40 | return cache.getOrPut(key) { 41 | SimpleDataBindingPresenter(layoutId, modelVariableId) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lounge/src/test/java/jp/co/cyberagent/lounge/LoungeGuidedActionsStylistTest.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import androidx.fragment.app.testing.launchFragmentInContainer 6 | import androidx.leanback.app.GuidedStepSupportFragment 7 | import androidx.leanback.widget.GuidedAction 8 | import androidx.leanback.widget.GuidedActionsStylist 9 | import androidx.test.core.app.ApplicationProvider 10 | import androidx.test.espresso.Espresso.onView 11 | import androidx.test.espresso.assertion.ViewAssertions.matches 12 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 13 | import androidx.test.espresso.matcher.ViewMatchers.withText 14 | import io.kotest.core.spec.style.FunSpec 15 | import io.kotest.extensions.robolectric.RobolectricTest 16 | import jp.co.cyberagent.fixture.R 17 | 18 | @RobolectricTest 19 | class LoungeGuidedActionsStylistTest : FunSpec({ 20 | 21 | test("LoungeGuidedActionStylist") { 22 | val testFragment = TestFragment() 23 | launchFragmentInContainer(themeResId = androidx.leanback.R.style.Theme_Leanback) { testFragment } 24 | 25 | val context = ApplicationProvider.getApplicationContext() 26 | onView(withText("Normal")).check(matches(isDisplayed())) 27 | onView(withText(context.getString(R.string.test_guided_action))).check(matches(isDisplayed())) 28 | } 29 | }) 30 | 31 | class TestFragment : GuidedStepSupportFragment() { 32 | override fun onCreateActionsStylist(): GuidedActionsStylist { 33 | return LoungeGuidedActionsStylist() 34 | } 35 | 36 | override fun onCreateActions(actions: MutableList, savedInstanceState: Bundle?) { 37 | actions += createGuidedActions(requireContext()) { 38 | guidedAction { 39 | title("Normal") 40 | } 41 | 42 | guidedAction { 43 | layoutId(R.layout.layout_test_guided_action_custom) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lounge-paging/src/test/java/jp/co/cyberagent/lounge/paging/util/PagedListUtil.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging.util 2 | 3 | import androidx.paging.Config 4 | import androidx.paging.ItemKeyedDataSource 5 | import androidx.paging.PagedList 6 | import kotlin.math.max 7 | import kotlin.math.min 8 | 9 | const val DefaultInitialPageMultiplier = 2 10 | 11 | fun List.asPagedList( 12 | pageSize: Int = size, 13 | initialPosition: Int = 0, 14 | enablePlaceholders: Boolean = false, 15 | ): PagedList { 16 | val config = Config( 17 | pageSize = pageSize, 18 | initialLoadSizeHint = pageSize * DefaultInitialPageMultiplier, 19 | enablePlaceholders = enablePlaceholders, 20 | ) 21 | return PagedList( 22 | dataSource = DataSource(this), 23 | config = config, 24 | notifyExecutor = { it.run() }, 25 | fetchExecutor = { it.run() }, 26 | initialKey = initialPosition, 27 | ) 28 | } 29 | 30 | private class DataSource( 31 | private val list: List, 32 | ) : ItemKeyedDataSource() { 33 | 34 | override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { 35 | val start = params.requestedInitialKey ?: 0 36 | val end = min(start + params.requestedLoadSize - 1, list.lastIndex) 37 | callback.onResult( 38 | list.slice(start..end), 39 | start, 40 | list.size, 41 | ) 42 | } 43 | 44 | override fun loadAfter(params: LoadParams, callback: LoadCallback) { 45 | val start = params.key + 1 46 | val end = min(params.key + params.requestedLoadSize, list.lastIndex) 47 | callback.onResult(list.slice(start..end)) 48 | } 49 | 50 | override fun loadBefore(params: LoadParams, callback: LoadCallback) { 51 | val end = params.key - 1 52 | val start = max(params.key - params.requestedLoadSize, 0) 53 | callback.onResult(list.slice(start..end)) 54 | } 55 | 56 | override fun getKey(item: T): Int = list.indexOf(item) 57 | } 58 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/ui/VerticalGridExampleFragment.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.ui 2 | 3 | import android.os.Bundle 4 | import androidx.leanback.app.VerticalGridSupportFragment 5 | import androidx.leanback.widget.VerticalGridPresenter 6 | import androidx.lifecycle.coroutineScope 7 | import jp.co.cyberagent.lounge.LoungeController 8 | import jp.co.cyberagent.lounge.loungeProp 9 | import jp.co.cyberagent.lounge.sample.model.InfoModel 10 | import kotlinx.coroutines.delay 11 | import kotlin.random.Random 12 | 13 | @Suppress("MagicNumber") 14 | class VerticalGridExampleFragment : VerticalGridSupportFragment() { 15 | 16 | private val updateInterval = 3000L 17 | private val random = Random(System.currentTimeMillis()) 18 | private val controller = Controller() 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | title = "VerticalGrid Example" 23 | gridPresenter = VerticalGridPresenter().apply { 24 | numberOfColumns = 5 25 | } 26 | adapter = controller.adapter 27 | lifecycle.coroutineScope.launchWhenStarted { 28 | var cnt = 0 29 | while (true) { 30 | controller.infoList = List(random.nextInt(10, 1000)) { 31 | when { 32 | cnt % 15 == 0 -> "FooBar $it" 33 | cnt % 3 == 0 -> "Foo $it" 34 | cnt % 5 == 0 -> "Bar $it" 35 | else -> it.toString() 36 | } 37 | } 38 | title = "VerticalGrid Example $cnt" 39 | cnt++ 40 | delay(updateInterval) 41 | } 42 | } 43 | } 44 | 45 | inner class Controller : LoungeController(lifecycle) { 46 | 47 | init { 48 | debugName = "VerticalGridExample" 49 | } 50 | 51 | var infoList: List by loungeProp(emptyList()) 52 | 53 | override suspend fun buildModels() { 54 | infoList.forEach { title -> 55 | +InfoModel(title) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/TypedPresenter.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.view.ViewGroup 4 | import androidx.leanback.widget.Presenter 5 | 6 | /** 7 | * A wrapper around [Presenter] which provides generic typed APIs. 8 | */ 9 | @Suppress("UNCHECKED_CAST") 10 | abstract class TypedPresenter : Presenter() { 11 | 12 | /** 13 | * Creates a new ViewHolder. 14 | */ 15 | protected abstract fun onCreate(parent: ViewGroup): VH 16 | 17 | /** 18 | * Binds a ViewHolder to an item. 19 | */ 20 | protected abstract fun onBind(vh: VH, item: T) 21 | 22 | /** 23 | * Binds a ViewHolder to an item with a list of payloads. 24 | * @param vh The ViewHolder which should be updated to represent the contents of 25 | * the item at the given position in the data set. 26 | * @param item The item which should be bound to view holder. 27 | * @param payloads A non-null list of merged payloads. Can be empty list if requires full update. 28 | */ 29 | open fun onBind(vh: VH, item: T, payloads: List) { 30 | onBind(vh, item) 31 | } 32 | 33 | /** 34 | * Unbinds a ViewHolder from an item. Any expensive references may be 35 | * released here, and any fields that are not bound for every item should be 36 | * cleared here. 37 | */ 38 | protected open fun onUnbind(vh: VH) = Unit 39 | 40 | // region ---- override parent presenter ---- 41 | 42 | final override fun onCreateViewHolder(parent: ViewGroup): VH { 43 | return onCreate(parent) 44 | } 45 | 46 | final override fun onBindViewHolder(viewHolder: ViewHolder, item: Any?) { 47 | onBind(viewHolder as VH, item as T) 48 | } 49 | 50 | final override fun onBindViewHolder( 51 | viewHolder: ViewHolder, 52 | item: Any?, 53 | payloads: List, 54 | ) { 55 | onBind(viewHolder as VH, item as T, payloads) 56 | } 57 | 58 | final override fun onUnbindViewHolder(viewHolder: ViewHolder) { 59 | onUnbind(viewHolder as VH) 60 | } 61 | 62 | // endregion 63 | } 64 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/KeyUtils.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | /** 4 | * Hash a object into 64 bits lounge model key. 5 | * 6 | * @see hashLong64Bit 7 | * @see hashString64Bit 8 | */ 9 | fun Any.toLoungeModelKey(): Long { 10 | return when (this) { 11 | is Long -> hashLong64Bit(this) 12 | is Int -> hashLong64Bit(toLong()) 13 | else -> hashString64Bit(toString()) 14 | } 15 | } 16 | 17 | /** 18 | * Hash a string into 64 bits instead of the normal 32. This allows us to better use strings as a 19 | * model id with less chance of collisions. This uses the FNV-1a algorithm for a good mix of speed 20 | * and distribution. 21 | * Performance comparisons found at http://stackoverflow.com/a/1660613 22 | * Hash implementation from http://www.isthe.com/chongo/tech/comp/fnv/index.html#FNV-1a 23 | * 24 | * Forked from [airbnb/epoxy](https://github.com/airbnb/epoxy/blob/3905f50321b98ad296b4d058b765ebf1fb5f4dea/epoxy-adapter/src/main/java/com/airbnb/epoxy/IdUtils.java#L36). 25 | */ 26 | @Suppress("MagicNumber") 27 | fun hashString64Bit(v: CharSequence): Long { 28 | var result = -0x340d631b7bdddcdbL 29 | val len = v.length 30 | for (i in 0 until len) { 31 | result = result xor v[i].toLong() 32 | result *= 0x100000001b3L 33 | } 34 | return result 35 | } 36 | 37 | /** 38 | * Hash a long into 64 bits instead of the normal 32. This uses a xor shift implementation to 39 | * attempt psuedo randomness so object ids have an even spread for less chance of collisions. 40 | * From http://stackoverflow.com/a/11554034 41 | * http://www.javamex.com/tutorials/random_numbers/xorshift.shtml 42 | * 43 | * Forked from [airbnb/epoxy](https://github.com/airbnb/epoxy/blob/3905f50321b98ad296b4d058b765ebf1fb5f4dea/epoxy-adapter/src/main/java/com/airbnb/epoxy/IdUtils.java#L20) 44 | */ 45 | @Suppress("MagicNumber") 46 | fun hashLong64Bit(v: Long): Long { 47 | var value = v 48 | value = value xor (value shl 21) 49 | value = value xor (value ushr 35) 50 | value = value xor (value shl 4) 51 | return value 52 | } 53 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_tv.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /lounge-databinding/src/main/java/jp/co/cyberagent/lounge/databinding/DataBindingRowPresenter.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.databinding 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.annotation.LayoutRes 6 | import androidx.databinding.DataBindingUtil 7 | import androidx.databinding.ViewDataBinding 8 | import androidx.leanback.widget.RowPresenter 9 | import jp.co.cyberagent.lounge.TypedRowPresenter 10 | 11 | /** 12 | * A wrapper around [TypedRowPresenter] which you can directly binding a item to a [ViewDataBinding] 13 | * instead of [RowPresenter.ViewHolder]. 14 | * 15 | * @param T type of the item to be bind. 16 | * @param DB type of the [ViewDataBinding] that the item will bind to. 17 | * @param layoutId the layout file id that corresponded to [DB]. 18 | */ 19 | abstract class DataBindingRowPresenter( 20 | @LayoutRes val layoutId: Int, 21 | ) : TypedRowPresenter>() { 22 | 23 | /** 24 | * Binds the given row object to the given DataBinding. 25 | */ 26 | abstract fun onBindRow(binding: DB, item: T) 27 | 28 | /** 29 | * Unbinds the given DataBinding. 30 | */ 31 | open fun onUnbindRow(binding: DB) = Unit 32 | 33 | /** 34 | * A ViewHolder that cache its view's [ViewDataBinding]. 35 | */ 36 | class ViewHolder( 37 | val binding: DB, 38 | ) : RowPresenter.ViewHolder(binding.root) 39 | 40 | // region ---- override parent presenter ---- 41 | 42 | override fun onCreateRow(parent: ViewGroup): ViewHolder { 43 | val layoutInflater = LayoutInflater.from(parent.context) 44 | val binding = DataBindingUtil.inflate(layoutInflater, layoutId, parent, false) 45 | return ViewHolder(binding) 46 | } 47 | 48 | final override fun onBindRow(vh: ViewHolder, item: T) { 49 | super.onBindRow(vh, item) 50 | onBindRow(vh.binding, item) 51 | vh.binding.executePendingBindings() 52 | } 53 | 54 | final override fun onUnbindRow(vh: ViewHolder) { 55 | super.onUnbindRow(vh) 56 | onUnbindRow(vh.binding) 57 | } 58 | 59 | // endregion 60 | } 61 | -------------------------------------------------------------------------------- /lounge-paging/src/test/java/jp/co/cyberagent/lounge/paging/util/ListDataSource.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | * For from airbnb/epoxy 17 | */ 18 | package jp.co.cyberagent.lounge.paging.util 19 | 20 | import androidx.paging.PositionalDataSource 21 | 22 | /** 23 | * Simple data source that works with a given list and its loading can be stopped / started. 24 | */ 25 | class ListDataSource( 26 | private val data: List, 27 | ) : PositionalDataSource() { 28 | private var pendingActions = arrayListOf<() -> Unit>() 29 | private var running = true 30 | 31 | private fun compute(f: () -> Unit) { 32 | if (running) { 33 | f() 34 | } else { 35 | pendingActions.add(f) 36 | } 37 | } 38 | 39 | fun start() { 40 | running = true 41 | val pending = pendingActions 42 | pendingActions = arrayListOf() 43 | pending.forEach(this::compute) 44 | } 45 | 46 | fun stop() { 47 | running = false 48 | } 49 | 50 | override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback) { 51 | compute { 52 | callback.onResult( 53 | data.subList( 54 | params.startPosition, 55 | Math.min(data.size, params.startPosition + params.loadSize) 56 | ) 57 | ) 58 | } 59 | } 60 | 61 | override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { 62 | val start = computeInitialLoadPosition(params, data.size) 63 | val itemCnt = computeInitialLoadSize(params, start, data.size) 64 | callback.onResult( 65 | data.subList(start, start + itemCnt), 66 | start, 67 | data.size 68 | ) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lounge/src/test/java/jp/co/cyberagent/lounge/LoungeControllerPropertyTest.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import io.kotest.core.spec.style.FunSpec 5 | import io.kotest.extensions.robolectric.RobolectricTest 6 | import io.kotest.matchers.shouldBe 7 | import jp.co.cyberagent.fixture.TestLifecycleOwner 8 | import jp.co.cyberagent.fixture.memoized 9 | import jp.co.cyberagent.lounge.LoungePropertyPredicate.Companion.referentialPredicate 10 | import jp.co.cyberagent.lounge.LoungePropertyPredicate.Companion.structuralPredicate 11 | import jp.co.cyberagent.lounge.util.ValueHolder 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi 13 | import kotlinx.coroutines.test.TestCoroutineDispatcher 14 | 15 | @ExperimentalCoroutinesApi 16 | @RobolectricTest 17 | class LoungeControllerPropertyTest : FunSpec({ 18 | val owner by memoized { TestLifecycleOwner() } 19 | val dispatcher by memoized { TestCoroutineDispatcher() } 20 | 21 | test("structuralPredicate") { 22 | var buildCount = 0 23 | 24 | val controller = object : LoungeController(owner.lifecycle, dispatcher) { 25 | var prop by loungeProp(ValueHolder(0), predicate = structuralPredicate()) 26 | 27 | override suspend fun buildModels() { 28 | buildCount++ 29 | } 30 | } 31 | 32 | owner.lifecycle.currentState = Lifecycle.State.STARTED 33 | buildCount shouldBe 0 34 | controller.requestModelBuild() 35 | buildCount shouldBe 1 36 | controller.prop = ValueHolder(0) 37 | buildCount shouldBe 1 38 | controller.prop = ValueHolder(1) 39 | buildCount shouldBe 2 40 | } 41 | 42 | test("referentialPredicate") { 43 | var buildCount = 0 44 | 45 | val controller = object : LoungeController(owner.lifecycle, dispatcher) { 46 | var prop by loungeProp(ValueHolder(0), predicate = referentialPredicate()) 47 | 48 | override suspend fun buildModels() { 49 | buildCount++ 50 | } 51 | } 52 | 53 | owner.lifecycle.currentState = Lifecycle.State.STARTED 54 | buildCount shouldBe 0 55 | controller.requestModelBuild() 56 | buildCount shouldBe 1 57 | controller.prop = ValueHolder(0) 58 | buildCount shouldBe 2 59 | controller.prop = ValueHolder(1) 60 | buildCount shouldBe 3 61 | } 62 | }) 63 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/ui/GuidedStepExampleFragment.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.ui 2 | 3 | import android.os.Bundle 4 | import androidx.leanback.widget.GuidanceStylist 5 | import androidx.leanback.widget.GuidedAction 6 | import androidx.navigation.fragment.findNavController 7 | import jp.co.cyberagent.lounge.Guidance 8 | import jp.co.cyberagent.lounge.LoungeGuidedStepSupportFragment 9 | import jp.co.cyberagent.lounge.createGuidedActions 10 | import jp.co.cyberagent.lounge.sample.R 11 | 12 | class GuidedStepExampleFragment : LoungeGuidedStepSupportFragment() { 13 | 14 | override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance { 15 | return Guidance( 16 | title = "GuidedStep Sample: ${parentFragmentManager.backStackEntryCount}", 17 | description = "GuidedStep Description", 18 | breadcrumb = "GuidedStep BreadCrumb", 19 | ) 20 | } 21 | 22 | override fun onCreateActions(actions: MutableList, savedInstanceState: Bundle?) { 23 | actions += createGuidedActions(requireContext()) { 24 | guidedAction { 25 | title("Next") 26 | description("Next Description") 27 | onClicked { findNavController().navigate(R.id.to_guided_step_self) } 28 | } 29 | 30 | guidedAction { 31 | title("Pop Back") 32 | description("Pop Back Description") 33 | onClicked { findNavController().popBackStack() } 34 | } 35 | 36 | guidedAction { 37 | title("Pop Back All") 38 | description("Pop Back All Description") 39 | subActions { 40 | guidedAction { 41 | title("Yes") 42 | onSubClicked { 43 | findNavController().popBackStack(R.id.fragment_home, false) 44 | true 45 | } 46 | } 47 | 48 | guidedAction { 49 | title("No") 50 | } 51 | } 52 | } 53 | 54 | guidedAction { 55 | infoOnly(true) 56 | focusable(false) 57 | layoutId(R.layout.layout_divider) 58 | } 59 | 60 | guidedAction { 61 | title("Home") 62 | description("Home Description") 63 | onClicked { findNavController().navigate(R.id.to_home) } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lounge-databinding/src/main/java/jp/co/cyberagent/lounge/databinding/DataBindingPresenter.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.databinding 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.annotation.LayoutRes 6 | import androidx.databinding.DataBindingUtil 7 | import androidx.databinding.ViewDataBinding 8 | import androidx.leanback.widget.Presenter 9 | import jp.co.cyberagent.lounge.TypedPresenter 10 | 11 | /** 12 | * A wrapper around [TypedPresenter] which you can directly binding a item to a [ViewDataBinding] 13 | * instead of [Presenter.ViewHolder]. 14 | * 15 | * @param T type of the item to be bind. 16 | * @param DB type of the [ViewDataBinding] that the item will bind to. 17 | * @param layoutId the layout file id that corresponded to [DB]. 18 | */ 19 | abstract class DataBindingPresenter( 20 | @LayoutRes val layoutId: Int, 21 | ) : TypedPresenter>() { 22 | 23 | /** 24 | * Binds a DataBinding to an item. 25 | */ 26 | abstract fun onBind(binding: DB, item: T) 27 | 28 | /** 29 | * Binds a DataBinding to an item. 30 | */ 31 | open fun onBind(binding: DB, item: T, payloads: List) { 32 | onBind(binding, item) 33 | } 34 | 35 | /** 36 | * Unbinds a DataBinding from an item. Any expensive references may be 37 | * released here, and any fields that are not bound for every item should be 38 | * cleared here. 39 | */ 40 | open fun onUnbind(binding: DB) = Unit 41 | 42 | /** 43 | * A ViewHolder that cache its view's [ViewDataBinding]. 44 | */ 45 | class ViewHolder( 46 | val binding: DB, 47 | ) : Presenter.ViewHolder(binding.root) 48 | 49 | // region ---- override parent presenter ---- 50 | 51 | override fun onCreate(parent: ViewGroup): ViewHolder { 52 | val layoutInflater = LayoutInflater.from(parent.context) 53 | val binding = DataBindingUtil.inflate(layoutInflater, layoutId, parent, false) 54 | return ViewHolder(binding) 55 | } 56 | 57 | final override fun onBind(vh: ViewHolder, item: T) { 58 | onBind(vh.binding, item) 59 | vh.binding.executePendingBindings() 60 | } 61 | 62 | final override fun onBind(vh: ViewHolder, item: T, payloads: List) { 63 | onBind(vh.binding, item, payloads) 64 | vh.binding.executePendingBindings() 65 | } 66 | 67 | final override fun onUnbind(vh: ViewHolder) { 68 | onUnbind(vh.binding) 69 | } 70 | 71 | // endregion 72 | } 73 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeControllerProperty.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import jp.co.cyberagent.lounge.LoungePropertyPredicate.Companion.structuralPredicate 4 | import kotlin.properties.ReadWriteProperty 5 | import kotlin.reflect.KProperty 6 | 7 | /** 8 | * A delegation property that can be used in a [LoungeController]. 9 | * If the delegated value changed then an update will be requested 10 | * by calling [LoungeController.requestModelBuild]. 11 | */ 12 | fun loungeProp( 13 | initialValue: T, 14 | predicate: LoungePropertyPredicate = structuralPredicate(), 15 | ): ReadWriteProperty = 16 | LoungeControllerProperty(initialValue, predicate) 17 | 18 | private class LoungeControllerProperty( 19 | private var value: T, 20 | private val predicate: LoungePropertyPredicate, 21 | ) : ReadWriteProperty { 22 | 23 | override fun getValue(thisRef: LoungeController, property: KProperty<*>): T = value 24 | 25 | override fun setValue(thisRef: LoungeController, property: KProperty<*>, value: T) { 26 | if (predicate.isChanged(this.value, value)) { 27 | this.value = value 28 | thisRef.requestModelBuild() 29 | } 30 | } 31 | } 32 | 33 | /** 34 | * A functional interface to determine whether the property value changed. 35 | */ 36 | fun interface LoungePropertyPredicate { 37 | 38 | /** 39 | * Determine whether the property value changed. Returns true to request model build. 40 | */ 41 | fun isChanged(oldValue: T, newValue: T): Boolean 42 | 43 | companion object { 44 | /** 45 | * A predicate to treat values of [loungeProp] changed if they are structurally unequal (!=). 46 | */ 47 | @Suppress("UNCHECKED_CAST") 48 | fun structuralPredicate(): LoungePropertyPredicate = 49 | StructuralEqualityPredicate as LoungePropertyPredicate 50 | 51 | /** 52 | * A predictor to treat values of [loungeProp] changed if they are referentially unequal (!==). 53 | */ 54 | @Suppress("UNCHECKED_CAST") 55 | fun referentialPredicate(): LoungePropertyPredicate = 56 | ReferentialEqualityPredicate as LoungePropertyPredicate 57 | } 58 | } 59 | 60 | private object StructuralEqualityPredicate : LoungePropertyPredicate { 61 | override fun isChanged(oldValue: Any?, newValue: Any?): Boolean = 62 | oldValue != newValue 63 | } 64 | 65 | private object ReferentialEqualityPredicate : LoungePropertyPredicate { 66 | override fun isChanged(oldValue: Any?, newValue: Any?): Boolean = 67 | oldValue !== newValue 68 | } 69 | -------------------------------------------------------------------------------- /lounge/src/test/java/jp/co/cyberagent/lounge/LoungeGuidedActionsBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.content.Context 4 | import androidx.test.core.app.ApplicationProvider 5 | import io.kotest.core.spec.style.FunSpec 6 | import io.kotest.extensions.robolectric.RobolectricTest 7 | import io.kotest.matchers.shouldBe 8 | import jp.co.cyberagent.fixture.memoized 9 | 10 | @RobolectricTest 11 | class LoungeGuidedActionsBuilderTest : FunSpec({ 12 | 13 | val context by memoized { 14 | ApplicationProvider.getApplicationContext() 15 | } 16 | 17 | test("Create actions") { 18 | val actions = createGuidedActions(context) { 19 | guidedAction { 20 | id(1L) 21 | title("Title1") 22 | } 23 | 24 | guidedAction { 25 | id(1000L) 26 | } 27 | } 28 | 29 | actions.size shouldBe 2 30 | actions[0].id shouldBe 1L 31 | actions[0].title shouldBe "Title1" 32 | actions[1].id shouldBe 1000L 33 | actions[1].title shouldBe null 34 | } 35 | 36 | test("Create actions with lounge property") { 37 | var invokeResult: String? = null 38 | val iid = 5L 39 | val layoutId = 100 40 | val action = createGuidedActions(context) { 41 | guidedAction { 42 | id(iid) 43 | layoutId(layoutId) 44 | onClicked { 45 | invokeResult = "click ${it.id}" 46 | } 47 | 48 | onSubClicked { 49 | invokeResult = "subclick ${it.id}" 50 | true 51 | } 52 | 53 | onFocused { 54 | invokeResult = "focus ${it.id}" 55 | } 56 | 57 | onEditedAndProceed { 58 | invokeResult = "edit ${it.id}" 59 | 1 60 | } 61 | 62 | onEditCanceled { 63 | invokeResult = "editcancel ${it.id}" 64 | } 65 | 66 | subActions { 67 | guidedAction { 68 | id(iid + 1) 69 | } 70 | } 71 | } 72 | }.first() as LoungeGuidedAction 73 | 74 | action.id shouldBe iid 75 | action.layoutId shouldBe layoutId 76 | 77 | invokeResult shouldBe null 78 | onLoungeGuidedActionClicked(action) 79 | invokeResult shouldBe "click $iid" 80 | onSubLoungeGuidedActionClicked(action) shouldBe true 81 | invokeResult shouldBe "subclick $iid" 82 | onLoungeGuidedActionFocused(action) 83 | invokeResult shouldBe "focus $iid" 84 | onLoungeGuidedActionEditedAndProceed(action) 85 | invokeResult shouldBe "edit $iid" 86 | onLoungeGuidedActionEditCanceled(action) 87 | invokeResult shouldBe "editcancel $iid" 88 | 89 | action.subActions.size shouldBe 1 90 | action.subActions.first().id shouldBe iid + 1 91 | } 92 | }) 93 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/ui/PagedListStressTestFragment.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("MagicNumber") 2 | package jp.co.cyberagent.lounge.sample.ui 3 | 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.leanback.app.RowsSupportFragment 7 | import androidx.lifecycle.Lifecycle 8 | import androidx.lifecycle.lifecycleScope 9 | import androidx.paging.PagedList 10 | import jp.co.cyberagent.lounge.LoungeController 11 | import jp.co.cyberagent.lounge.paging.pagedListRowForIndexed 12 | import jp.co.cyberagent.lounge.sample.model.TextModel 13 | import jp.co.cyberagent.lounge.sample.utils.asPagedList 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.delay 16 | import kotlinx.coroutines.isActive 17 | import kotlin.random.Random 18 | 19 | class PagedListStressTestFragment : RowsSupportFragment() { 20 | 21 | private val random = Random 22 | private val keyCandidates = List(50) { (it + 1).toString() } 23 | private val controller by lazy { 24 | TestController(lifecycle) 25 | } 26 | 27 | override fun onCreate(savedInstanceState: Bundle?) { 28 | super.onCreate(savedInstanceState) 29 | adapter = controller.adapter 30 | } 31 | 32 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 33 | super.onViewCreated(view, savedInstanceState) 34 | viewLifecycleOwner.lifecycleScope.launchWhenStarted { 35 | while (isActive) { 36 | val prevData = controller.pagedListRows.toMap() 37 | val prevKeys = prevData.keys.shuffled(random).toMutableList() 38 | val newData = mutableMapOf>() 39 | repeat(random.nextInt(20)) { 40 | val key = prevKeys.removeFirstOrNull() 41 | val list = when { 42 | key == null || random.nextBoolean() -> createPagedList(random) 43 | else -> prevData[key]!! 44 | } 45 | newData[key ?: keyCandidates.random()] = list 46 | 47 | if (random.nextBoolean()) { 48 | controller.pagedListRows = newData.toMap() 49 | controller.requestModelBuild() 50 | } 51 | 52 | delay(random.nextLong(50)) 53 | } 54 | 55 | newData.values.forEach { 56 | it.loadAround(it.lastIndex) 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | private fun createPagedList(random: Random): PagedList { 64 | val offset = random.nextLong(5) 65 | val items = List(random.nextInt(20, 50)) { 66 | Item(it + offset, it.toString()) 67 | } 68 | return items.asPagedList(10, true) 69 | } 70 | 71 | private class TestController(lifecycle: Lifecycle) : 72 | LoungeController(lifecycle, Dispatchers.Default) { 73 | 74 | var pagedListRows: Map> = emptyMap() 75 | 76 | override suspend fun buildModels() { 77 | val data = pagedListRows.toList() 78 | data.forEach { (name, list) -> 79 | pagedListRowForIndexed( 80 | name = name, 81 | pagedList = list 82 | ) { index, item -> 83 | if (item == null) { 84 | TextModel("PlaceHolder", key = -index.toLong()) 85 | } else { 86 | TextModel(item.value, key = item.id) 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | private data class Item(val id: Long, val value: String) 94 | -------------------------------------------------------------------------------- /sample/src/main/java/jp/co/cyberagent/lounge/sample/ui/RowsExampleFragment.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.sample.ui 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.leanback.app.BrowseSupportFragment 6 | import androidx.lifecycle.Lifecycle 7 | import androidx.lifecycle.lifecycleScope 8 | import androidx.paging.PagedList 9 | import jp.co.cyberagent.lounge.HeaderData 10 | import jp.co.cyberagent.lounge.LoungeController 11 | import jp.co.cyberagent.lounge.SimpleLoungeModelAwaitInterceptor 12 | import jp.co.cyberagent.lounge.listRowFor 13 | import jp.co.cyberagent.lounge.loungeProp 14 | import jp.co.cyberagent.lounge.navigation.addHeadersTransitionOnBackPressedCallback 15 | import jp.co.cyberagent.lounge.paging.pagedListRowFor 16 | import jp.co.cyberagent.lounge.paging.pagedListRowForIndexed 17 | import jp.co.cyberagent.lounge.sample.model.InfoModel 18 | import jp.co.cyberagent.lounge.sample.model.TextModel 19 | import jp.co.cyberagent.lounge.sample.utils.asPagedList 20 | import kotlinx.coroutines.delay 21 | import kotlin.random.Random 22 | 23 | @Suppress("MagicNumber") 24 | class RowsExampleFragment : BrowseSupportFragment() { 25 | 26 | private val random = Random(System.currentTimeMillis()) 27 | private val controller = RowsController(lifecycle) 28 | 29 | override fun onCreate(savedInstanceState: Bundle?) { 30 | super.onCreate(savedInstanceState) 31 | 32 | title = "Rows Example" 33 | adapter = controller.adapter 34 | addHeadersTransitionOnBackPressedCallback(this) 35 | } 36 | 37 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 38 | super.onViewCreated(view, savedInstanceState) 39 | 40 | viewLifecycleOwner.lifecycleScope.launchWhenStarted { 41 | while (true) { 42 | controller.row1 = createList(10..20) 43 | controller.row2 = createList(10..20) 44 | 45 | controller.pagedRow1 = createList(50..200).asPagedList(10, true) 46 | controller.pagedRow2 = createList(50..200).asPagedList(10, false) 47 | 48 | delay(10000L) 49 | } 50 | } 51 | } 52 | 53 | private fun createList(sizeRange: IntRange): List { 54 | val bias = random.nextInt(5) 55 | return List(sizeRange.random(random)) { 56 | "Item ${it + bias}" 57 | } 58 | } 59 | } 60 | 61 | private class RowsController(lifecycle: Lifecycle) : LoungeController(lifecycle) { 62 | 63 | init { 64 | debugName = "RowsController" 65 | addInterceptor(SimpleLoungeModelAwaitInterceptor()) 66 | } 67 | 68 | var row1: List? by loungeProp(null) 69 | var row2: List? by loungeProp(null) 70 | 71 | var pagedRow1: PagedList? by loungeProp(null) 72 | var pagedRow2: PagedList? by loungeProp(null) 73 | 74 | override suspend fun buildModels() { 75 | listRowFor("ListRow 1", row1.orEmpty()) { item -> 76 | InfoModel(item) 77 | } 78 | 79 | listRowFor("ListRow 2", row2.orEmpty()) { item -> 80 | TextModel(item) 81 | } 82 | 83 | pagedListRowForIndexed( 84 | headerData = HeaderData("PagedListRow 1", "Placeholder Enabled"), 85 | pagedList = pagedRow1 86 | ) { index, item -> 87 | InfoModel(item ?: "Placeholder $index") 88 | } 89 | 90 | pagedListRowFor( 91 | headerData = HeaderData("PagedListRow 2", "Placeholder Disabled"), 92 | pagedList = pagedRow2 93 | ) { item -> 94 | TextModel(item!!) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeGuidedActionsBuilder.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.content.Context 4 | import androidx.leanback.app.GuidedStepSupportFragment 5 | import androidx.leanback.widget.GuidedAction 6 | 7 | /** 8 | * A DSL to create a list of [LoungeGuidedAction]. 9 | * 10 | * For [LoungeGuidedAction] to function properly, you need to use other components together 11 | * in you [GuidedStepSupportFragment]. 12 | * - Uses [LoungeGuidedActionsStylist] if you set [LoungeGuidedActionBuilder.layoutId] 13 | * - Uses [onLoungeGuidedActionClicked] if you set [LoungeGuidedActionBuilder.onClicked] 14 | * - Uses [onSubLoungeGuidedActionClicked] if you set [LoungeGuidedActionBuilder.onSubClicked] 15 | * - Uses [onLoungeGuidedActionFocused] if you set [LoungeGuidedActionBuilder.onFocused] 16 | * - Uses [onLoungeGuidedActionEditedAndProceed] if you set [LoungeGuidedActionBuilder.onEditedAndProceed] 17 | * - Uses [onLoungeGuidedActionEditCanceled] if you set [LoungeGuidedActionBuilder.onEditCanceled] 18 | * 19 | * Example usage: 20 | * 21 | * ``` 22 | * class GuidedStepExampleFragment : GuidedStepSupportFragment() { 23 | * 24 | * override fun onCreateActionsStylist(): GuidedActionsStylist = LoungeGuidedActionsStylist() 25 | * 26 | * override fun onCreateActions(actions: MutableList, savedInstanceState: Bundle?) { 27 | * actions += createActions { 28 | * guidedAction { 29 | * id("id") 30 | * title("title") 31 | * description("description") 32 | * onClick { showToast("Clicked!") } 33 | * } 34 | * 35 | * guidedAction { 36 | * infoOnly(true) 37 | * focusable(false) 38 | * layoutId(R.layout.layout_divider) 39 | * } 40 | * } 41 | * } 42 | * 43 | * override fun onGuidedActionClicked(action: GuidedAction) = onLoungeGuidedActionClick(action) 44 | * } 45 | * ``` 46 | * 47 | * Instead of inheriting [GuidedStepSupportFragment] and override with a bunch of boilerplate code, 48 | * you can choose to inherit [LoungeGuidedStepSupportFragment]. 49 | * The [LoungeGuidedStepSupportFragment] has already override those methods that let 50 | * [LoungeGuidedAction] to function properly. 51 | * 52 | * Example usage: 53 | * 54 | * ``` 55 | * class GuidedStepExampleFragment : LoungeGuidedStepSupportFragment() { 56 | * 57 | * override fun onCreateActions(actions: MutableList, savedInstanceState: Bundle?) { 58 | * actions += createActions { 59 | * guidedAction { 60 | * id("id") 61 | * title("title") 62 | * description("description") 63 | * onClick { showToast("Clicked!") } 64 | * } 65 | * 66 | * guidedAction { 67 | * infoOnly(true) 68 | * focusable(false) 69 | * layoutId(R.layout.layout_divider) 70 | * } 71 | * } 72 | * } 73 | * } 74 | * ``` 75 | */ 76 | fun createGuidedActions( 77 | context: Context, 78 | body: LoungeGuidedActionsBuilder.() -> Unit, 79 | ): List { 80 | val builder = LoungeGuidedActionsBuilder(context) 81 | return builder.apply(body).build() 82 | } 83 | 84 | /** 85 | * A builder to create a list of [GuidedAction]. 86 | */ 87 | class LoungeGuidedActionsBuilder internal constructor( 88 | private val context: Context, 89 | ) { 90 | 91 | private val actions: MutableList = mutableListOf() 92 | 93 | /** 94 | * Builds and add a new [GuidedAction]. 95 | */ 96 | fun guidedAction( 97 | body: LoungeGuidedActionBuilder.() -> Unit, 98 | ) { 99 | actions += LoungeGuidedActionBuilder(context).apply(body).build() 100 | } 101 | 102 | internal fun build(): List { 103 | return actions.toList() 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeGuidedActionBuilder.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.content.Context 4 | import androidx.annotation.LayoutRes 5 | import androidx.leanback.widget.GuidedAction 6 | import jp.co.cyberagent.lounge.LoungeGuidedAction.OnGuidedActionClickedListener 7 | import jp.co.cyberagent.lounge.LoungeGuidedAction.OnGuidedActionEditCanceledListener 8 | import jp.co.cyberagent.lounge.LoungeGuidedAction.OnGuidedActionEditedAndProceedListener 9 | import jp.co.cyberagent.lounge.LoungeGuidedAction.OnGuidedActionFocusedListener 10 | import jp.co.cyberagent.lounge.LoungeGuidedAction.OnSubGuidedActionClickedListener 11 | 12 | /** 13 | * Builder class to build a [LoungeGuidedAction]. 14 | */ 15 | class LoungeGuidedActionBuilder(context: Context) : 16 | GuidedAction.BuilderBase(context) { 17 | 18 | private var _layoutId: Int = LoungeGuidedAction.DEFAULT_LAYOUT_ID 19 | private var _onClickedListener: OnGuidedActionClickedListener? = null 20 | private var _onSubClickedListener: OnSubGuidedActionClickedListener? = null 21 | private var _onFocusedListener: OnGuidedActionFocusedListener? = null 22 | private var _onEditedAndProceedListener: OnGuidedActionEditedAndProceedListener? = null 23 | private var _onEditCanceledListener: OnGuidedActionEditCanceledListener? = null 24 | 25 | /** 26 | * Sets the custom layout id for this action. 27 | * 28 | * Only takes effect when used with the [LoungeGuidedActionsStylist]. 29 | */ 30 | fun layoutId(@LayoutRes id: Int) = apply { 31 | _layoutId = id 32 | } 33 | 34 | /** 35 | * Sets the listener when clicked this action. 36 | * 37 | * Only takes effect when used with [onLoungeGuidedActionClicked]. 38 | */ 39 | fun onClicked(l: OnGuidedActionClickedListener?) = apply { 40 | _onClickedListener = l 41 | } 42 | 43 | /** 44 | * Sets the listener when clicked this action in sub actions. 45 | * 46 | * Only takes effect when used with [onSubLoungeGuidedActionClicked]. 47 | */ 48 | fun onSubClicked(l: OnSubGuidedActionClickedListener?) = apply { 49 | _onSubClickedListener = l 50 | } 51 | 52 | /** 53 | * Sets the listener when focused this action. 54 | * 55 | * Only takes effect when used with [onLoungeGuidedActionFocused]. 56 | */ 57 | fun onFocused(l: OnGuidedActionFocusedListener?) = apply { 58 | _onFocusedListener = l 59 | } 60 | 61 | /** 62 | * Sets the listener when edited and proceed this action, for example when user clicks confirm button 63 | * in IME window. 64 | * 65 | * Only takes effect when used with [onLoungeGuidedActionEditedAndProceed]. 66 | */ 67 | fun onEditedAndProceed(l: OnGuidedActionEditedAndProceedListener?) = apply { 68 | _onEditedAndProceedListener = l 69 | } 70 | 71 | /** 72 | * Sets the listener when canceled editing this action, for example when user closes 73 | * IME window by BACK key. 74 | * 75 | * Only takes effect when used with [onLoungeGuidedActionEditCanceled]. 76 | */ 77 | fun onEditCanceled(l: OnGuidedActionEditCanceledListener?) = apply { 78 | _onEditCanceledListener = l 79 | } 80 | 81 | /** 82 | * Set sub actions list via [LoungeGuidedActionBuilder]. 83 | */ 84 | fun subActions( 85 | body: LoungeGuidedActionsBuilder.() -> Unit, 86 | ) { 87 | subActions(LoungeGuidedActionsBuilder(context).apply(body).build()) 88 | } 89 | 90 | /** 91 | * Builds the [LoungeGuidedAction] corresponding to this Builder. 92 | */ 93 | fun build(): LoungeGuidedAction { 94 | val action = LoungeGuidedAction() 95 | applyValues(action) 96 | action.layoutId = _layoutId 97 | action.onClickedListener = _onClickedListener 98 | action.onSubClickedListener = _onSubClickedListener 99 | action.onFocusedListener = _onFocusedListener 100 | action.onEditedAndProceedListener = _onEditedAndProceedListener 101 | action.onEditCanceledListener = _onEditCanceledListener 102 | return action 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /docs/guided-step.md: -------------------------------------------------------------------------------- 1 | # GuidedStep Support 2 | 3 | `GuidedStepSupportFragment` is used to represent a single step in a series of steps. 4 | Lounge provides some helper methods/classes to simplify creating `GuidedStepSupportFragment`. 5 | 6 | ## Create guided actions 7 | 8 | We can override `onCreateActions()` to add user actions. 9 | Lounge provides a DSL `createGuidedActions(Context)` to build multiple actions. 10 | 11 | ```kotlin 12 | override fun onCreateActions(actions: MutableList, savedInstanceState: Bundle?) { 13 | actions += createGuidedActions(requireContext()) { 14 | guidedAction { 15 | // You can access all methods defined in `GuidedAction.Builder` 16 | title("Next") 17 | } 18 | 19 | guidedAction { 20 | title("Back") 21 | description("Really?") 22 | } 23 | } 24 | } 25 | ``` 26 | 27 | ### Set Event Listener 28 | 29 | When using the `GuidedAction.Builder`, we cannot directly define the event listener, like click listener, for an action. 30 | We need to override `onGuidedActionClicked()` and process via identify the passed-in `GuidedAction`. 31 | Use `guidedAction {}` DSL and other helper methods like `onLoungeGuidedActionClicked` together, 32 | set event listener can become easier. 33 | 34 | ```kotlin 35 | override fun onCreateActions(actions: MutableList, savedInstanceState: Bundle?) { 36 | actions += createGuidedActions(requireContext()) { 37 | guidedAction { 38 | title("Hi") 39 | onClicked { showToast("Hello World!") } 40 | } 41 | } 42 | } 43 | 44 | override fun onGuidedActionClicked(action: GuidedAction?) { 45 | onLoungeGuidedActionClicked(action) 46 | } 47 | ``` 48 | 49 | ### Add SubActions 50 | 51 | You can add sub actions via `subActions {}`. 52 | 53 | ```kotlin 54 | createGuidedActions(requireContext()) { 55 | guidedAction { 56 | title("Sign Out") 57 | subActions { 58 | guidedAction { 59 | title("Yes") 60 | } 61 | 62 | guidedAction { 63 | title("No") 64 | } 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | ### Custom Action Layout 71 | 72 | By override `onCreateActionsStylist()` and returns a `LoungeGuidedActionsStylist`, 73 | your layout file passed via `layoutId(Int)` can be correctly inflated. 74 | 75 | ```kotlin 76 | override fun onCreateActions(actions: MutableList, savedInstanceState: Bundle?) { 77 | actions += createGuidedActions(requireContext()) { 78 | guidedAction { 79 | infoOnly(true) 80 | focusable(false) 81 | layoutId(R.layout.layout_divider) 82 | } 83 | } 84 | } 85 | 86 | override fun onCreateActionsStylist(): GuidedActionsStylist { 87 | return LoungeGuidedActionsStylist() 88 | } 89 | ``` 90 | 91 | ### LoungeGuidedStepSupportFragment 92 | 93 | If you want to reduce more boilerplate codes, 94 | you can extend the `LoungeGuidedStepSupportFragment`. 95 | `LoungeGuidedStepSupportFragment` already properly override methods that `createGuidedActions(Context)` DSL required. 96 | 97 | ```kotlin 98 | class GuidedStepExampleFragment : LoungeGuidedStepSupportFragment() { 99 | override fun onCreateActions(actions: MutableList, savedInstanceState: Bundle?) { 100 | actions += createGuidedActions(requireContext()) { 101 | } 102 | } 103 | ``` 104 | 105 | ## Create Guidance 106 | 107 | We can override `onCreateGuidance()` and return a new `GuidanceStylist.Guidance` that contains context information, such as the step title, description, and icon. 108 | Lounge provides a top-level function `Guidance` for creating `GuidanceStylist.Guidance`. 109 | All parameters of `Guidance` are default null. 110 | 111 | ```kotlin 112 | override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance { 113 | return Guidance( 114 | title = "Title", 115 | description = "Description" 116 | ) 117 | } 118 | ``` 119 | 120 | ## Navigation 121 | 122 | When using `GuidedStepSupportFragment` with Navigation Component, 123 | we need to manually `setUiStyle` to get correct transition animation. 124 | Lounge provides a `GuidedStepFragmentNavigator` to automatically do `setUiStyle` based on the back stack. 125 | 126 | See TODO for more details. 127 | -------------------------------------------------------------------------------- /lounge-material/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 67 | 68 | -------------------------------------------------------------------------------- /lounge-paging/src/test/java/jp/co/cyberagent/lounge/paging/PagedListLoungeControllerTest.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import io.kotest.core.spec.style.FunSpec 5 | import io.kotest.extensions.robolectric.RobolectricTest 6 | import io.kotest.matchers.collections.shouldContainExactly 7 | import io.kotest.matchers.shouldBe 8 | import jp.co.cyberagent.fixture.TestLifecycleOwner 9 | import jp.co.cyberagent.fixture.items 10 | import jp.co.cyberagent.fixture.memoized 11 | import jp.co.cyberagent.fixture.withStartedThenCreated 12 | import jp.co.cyberagent.lounge.LoungeModel 13 | import jp.co.cyberagent.lounge.paging.util.TestModel 14 | import jp.co.cyberagent.lounge.paging.util.asPagedList 15 | import kotlinx.coroutines.ExperimentalCoroutinesApi 16 | import kotlinx.coroutines.test.TestCoroutineDispatcher 17 | 18 | @ExperimentalCoroutinesApi 19 | @RobolectricTest 20 | class PagedListLoungeControllerTest : FunSpec({ 21 | val owner by memoized { TestLifecycleOwner() } 22 | val dispatcher by memoized { TestCoroutineDispatcher() } 23 | 24 | test("Build models") { 25 | val buildOrder = mutableListOf() 26 | val controller = object : PagedListLoungeController( 27 | owner.lifecycle, 28 | dispatcher, 29 | ) { 30 | override fun buildItemModel(position: Int, item: Int?): LoungeModel { 31 | buildOrder += item!! 32 | return TestModel(item!!.toLong()) 33 | } 34 | } 35 | 36 | val list = List(10) { it } 37 | val pagedList = list.asPagedList( 38 | pageSize = 2, 39 | initialPosition = 3, 40 | enablePlaceholders = false, 41 | ) 42 | 43 | owner.withStartedThenCreated { 44 | controller.adapter.size() shouldBe 0 45 | controller.submitList(pagedList) 46 | } 47 | controller.adapter.size() shouldBe 4 48 | controller.adapter.items.shouldContainExactly( 49 | TestModel(3), 50 | TestModel(4), 51 | TestModel(5), 52 | TestModel(6), 53 | ) 54 | 55 | owner.withStartedThenCreated { 56 | controller.adapter.size() shouldBe 4 57 | controller.adapter.get(0) 58 | } 59 | controller.adapter.size() shouldBe 6 60 | controller.adapter.items.shouldContainExactly( 61 | TestModel(1), 62 | TestModel(2), 63 | TestModel(3), 64 | TestModel(4), 65 | TestModel(5), 66 | TestModel(6), 67 | ) 68 | 69 | owner.withStartedThenCreated { 70 | controller.adapter.size() shouldBe 6 71 | controller.adapter.get(5) 72 | } 73 | controller.adapter.size() shouldBe 8 74 | controller.adapter.items.shouldContainExactly( 75 | TestModel(1), 76 | TestModel(2), 77 | TestModel(3), 78 | TestModel(4), 79 | TestModel(5), 80 | TestModel(6), 81 | TestModel(7), 82 | TestModel(8), 83 | ) 84 | 85 | buildOrder.shouldContainExactly( 86 | 3, 4, 5, 6, 1, 2, 7, 8, 87 | ) 88 | } 89 | 90 | test("Insert other model") { 91 | val controller = object : PagedListLoungeController( 92 | owner.lifecycle, 93 | dispatcher, 94 | ) { 95 | override fun buildItemModel(position: Int, item: Int?): LoungeModel { 96 | return TestModel(item!!.toLong()) 97 | } 98 | 99 | override suspend fun buildModels() { 100 | +TestModel(-100L) 101 | +getItemModels() 102 | } 103 | } 104 | 105 | owner.lifecycle.currentState = Lifecycle.State.STARTED 106 | val pagedList = listOf(1, 2).asPagedList(2, 0, false) 107 | controller.submitList(pagedList) 108 | controller.adapter.size() shouldBe 3 109 | controller.adapter.items.shouldContainExactly( 110 | TestModel(-100L), 111 | TestModel(1L), 112 | TestModel(2L), 113 | ) 114 | } 115 | 116 | test("Request force build") { 117 | val buildOrder = mutableListOf() 118 | val controller = object : PagedListLoungeController( 119 | owner.lifecycle, 120 | dispatcher, 121 | ) { 122 | override fun buildItemModel(position: Int, item: Int?): LoungeModel { 123 | buildOrder += item!! 124 | return TestModel(item!!.toLong()) 125 | } 126 | } 127 | 128 | val pagedList = listOf(10).asPagedList( 129 | pageSize = 1, 130 | initialPosition = 0, 131 | enablePlaceholders = false, 132 | ) 133 | 134 | owner.lifecycle.currentState = Lifecycle.State.STARTED 135 | controller.submitList(pagedList) 136 | buildOrder.shouldContainExactly(10) 137 | controller.requestModelBuild() 138 | buildOrder.shouldContainExactly(10) 139 | controller.requestForceModelBuild() 140 | buildOrder.shouldContainExactly(10, 10) 141 | } 142 | }) 143 | -------------------------------------------------------------------------------- /config/detekt/detekt.yml: -------------------------------------------------------------------------------- 1 | config: 2 | # is automatically ignored when custom-checks.jar is on the classpath 3 | # however other CI checks use the argsfile where our plugin is not applied 4 | # we need to care take of this by explicitly allowing this properties 5 | excludes: 'custom-checks.*' 6 | 7 | custom-checks: 8 | active: true 9 | SpekTestDiscovery: 10 | active: true 11 | includes: [ '**/test/**/*Spec.kt' ] 12 | 13 | comments: 14 | CommentOverPrivateProperty: 15 | active: false 16 | UndocumentedPublicClass: 17 | active: true 18 | excludes: [ '**/*.kt' ] 19 | includes: [ '**/detekt-api/src/main/**/api/*.kt' ] 20 | UndocumentedPublicFunction: 21 | active: true 22 | excludes: [ '**/*.kt' ] 23 | includes: [ '**/detekt-api/src/main/**/api/*.kt' ] 24 | 25 | complexity: 26 | StringLiteralDuplication: 27 | active: true 28 | excludes: [ '**/test/**', '**/*.Test.kt', '**/*.Spec.kt' ] 29 | threshold: 5 30 | ignoreAnnotation: true 31 | excludeStringsWithLessThan5Characters: true 32 | ignoreStringsRegex: '$^' 33 | ComplexInterface: 34 | active: true 35 | threshold: 10 36 | includeStaticDeclarations: false 37 | includePrivateDeclarations: false 38 | ComplexMethod: 39 | active: true 40 | ignoreSingleWhenExpression: true 41 | MethodOverloading: 42 | active: true 43 | LongParameterList: 44 | active: false 45 | 46 | exceptions: 47 | NotImplementedDeclaration: 48 | active: true 49 | InstanceOfCheckForException: 50 | active: true 51 | RethrowCaughtException: 52 | active: true 53 | ReturnFromFinally: 54 | active: true 55 | SwallowedException: 56 | active: false 57 | ThrowingExceptionFromFinally: 58 | active: true 59 | ThrowingExceptionsWithoutMessageOrCause: 60 | active: true 61 | ThrowingNewInstanceOfSameException: 62 | active: true 63 | 64 | formatting: 65 | active: true 66 | android: false 67 | autoCorrect: true 68 | Indentation: 69 | indentSize: 2 70 | continuationIndentSize: 2 71 | ParameterListWrapping: 72 | indentSize: 2 73 | MaximumLineLength: 74 | active: true 75 | ImportOrdering: 76 | active: true 77 | 78 | naming: 79 | MemberNameEqualsClassName: 80 | active: true 81 | VariableNaming: 82 | active: true 83 | variablePattern: '[a-z][A-Za-z0-9]*' 84 | privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' 85 | excludeClassPattern: '$^' 86 | TopLevelPropertyNaming: 87 | constantPattern: '[A-Z][_A-Za-z0-9]*' 88 | 89 | performance: 90 | ArrayPrimitive: 91 | active: true 92 | 93 | potential-bugs: 94 | EqualsAlwaysReturnsTrueOrFalse: 95 | active: true 96 | InvalidRange: 97 | active: true 98 | IteratorHasNextCallsNextMethod: 99 | active: true 100 | IteratorNotThrowingNoSuchElementException: 101 | active: true 102 | MissingWhenCase: 103 | active: true 104 | RedundantElseInWhen: 105 | active: true 106 | UnsafeCallOnNullableType: 107 | active: true 108 | UnsafeCast: 109 | active: true 110 | excludes: [ '**/test/**', '**/*.Test.kt', '**/*.Spec.kt' ] 111 | UselessPostfixExpression: 112 | active: true 113 | WrongEqualsTypeParameter: 114 | active: true 115 | 116 | style: 117 | ClassOrdering: 118 | active: true 119 | CollapsibleIfStatements: 120 | active: true 121 | EqualsNullCall: 122 | active: true 123 | ForbiddenComment: 124 | active: false 125 | values: [ 'TODO:', 'FIXME:', 'STOPSHIP:', '@author' ] 126 | FunctionOnlyReturningConstant: 127 | active: true 128 | LoopWithTooManyJumpStatements: 129 | active: true 130 | LibraryCodeMustSpecifyReturnType: 131 | active: true 132 | excludes: [ '**/*.kt' ] 133 | includes: [ '**/detekt-api/src/main/**/api/*.kt' ] 134 | MaxLineLength: 135 | excludes: [ '**/test/**', '**/*.Test.kt', '**/*.Spec.kt' ] 136 | excludeCommentStatements: true 137 | MagicNumber: 138 | ignoreHashCodeFunction: true 139 | ignorePropertyDeclaration: true 140 | ignoreAnnotation: true 141 | ignoreEnums: true 142 | ignoreNumbers: [ '-1', '0', '1', '2', '32', '100', '1000' ] 143 | MayBeConst: 144 | active: true 145 | NestedClassesVisibility: 146 | active: true 147 | ProtectedMemberInFinalClass: 148 | active: true 149 | RedundantVisibilityModifierRule: 150 | active: true 151 | ReturnCount: 152 | excludeGuardClauses: true 153 | SpacingBetweenPackageAndImports: 154 | active: true 155 | UnnecessaryAbstractClass: 156 | active: true 157 | UnnecessaryInheritance: 158 | active: true 159 | UnusedPrivateClass: 160 | active: true 161 | UnusedPrivateMember: 162 | active: true 163 | allowedNames: '(_|ignored|expected)' 164 | UseCheckOrError: 165 | active: true 166 | UselessCallOnNotNull: 167 | active: true 168 | UtilityClassWithPublicConstructor: 169 | active: true 170 | -------------------------------------------------------------------------------- /lounge-paging/src/main/java/jp/co/cyberagent/lounge/paging/PagedListLoungeController.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.annotation.CallSuper 5 | import androidx.lifecycle.Lifecycle 6 | import androidx.lifecycle.coroutineScope 7 | import androidx.paging.PagedList 8 | import androidx.recyclerview.widget.DiffUtil 9 | import jp.co.cyberagent.lounge.LoungeController 10 | import jp.co.cyberagent.lounge.LoungeModel 11 | import jp.co.cyberagent.lounge.paging.internal.PagedListModelCache 12 | import kotlinx.coroutines.CoroutineDispatcher 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.SupervisorJob 16 | import kotlinx.coroutines.cancel 17 | import kotlinx.coroutines.job 18 | 19 | /** 20 | * A [LoungeController] that can work with a [PagedList]. 21 | * The implementation is inspired by [epoxy/PagedListEpoxyController](https://github.com/airbnb/epoxy/blob/master/epoxy-paging/src/main/java/com/airbnb/epoxy/paging/PagedListEpoxyController.kt). 22 | * 23 | * Internally, this controller caches the model for each item in the [PagedList]. 24 | * You should override [buildItemModel] method to build the model for the given item. 25 | * Since [PagedList] might include `null` items if placeholders are enabled, 26 | * this method needs to handle `null` values in the list. 27 | * 28 | * @param T The type of the items in the [PagedList]. 29 | * @param lifecycle of [LoungeController]'s host. 30 | * @param modelBuildingDispatcher the dispatcher for building models. 31 | * @param itemDiffCallback detect changes between [PagedList]s. 32 | * 33 | * @see LambdaPagedListLoungeController 34 | */ 35 | abstract class PagedListLoungeController( 36 | lifecycle: Lifecycle, 37 | modelBuildingDispatcher: CoroutineDispatcher = Dispatchers.Main, 38 | @Suppress("UNCHECKED_CAST") 39 | itemDiffCallback: DiffUtil.ItemCallback = DefaultPagedListItemDiffCallback as DiffUtil.ItemCallback, 40 | ) : LoungeController(lifecycle, modelBuildingDispatcher), 41 | PagedListLoungeBuildModelScope { 42 | 43 | private val modelCacheScope = 44 | CoroutineScope(SupervisorJob(lifecycle.coroutineScope.coroutineContext.job) + modelBuildingDispatcher) 45 | 46 | private val modelCache = PagedListModelCache( 47 | modelBuilder = { position, item -> buildItemModel(position, item) }, 48 | rebuildCallback = { requestModelBuild() }, 49 | coroutineScope = modelCacheScope, 50 | diffCallback = itemDiffCallback, 51 | ) 52 | 53 | /** 54 | * Returns the PagedList currently being displayed. 55 | * 56 | * This is not necessarily the most recent list passed to [submitList]. 57 | */ 58 | val currentList: PagedList? 59 | get() = modelCache.currentList 60 | 61 | /** 62 | * Builds the model for a given item. This must return a single model for each item. 63 | * If you want to inject headers etc, you can also override [buildModels] function and calls 64 | * [getItemModels] to get all models built by this method. 65 | * 66 | * If the [item] is `null`, you should provide the placeholder. If your [PagedList] is 67 | * configured without placeholders, you don't need to handle the `null` case. 68 | */ 69 | abstract fun buildItemModel(position: Int, item: T?): LoungeModel 70 | 71 | /** 72 | * Gets all built models from [buildItemModel]. You can call this method inside [buildModels] 73 | * to change the behavior. 74 | */ 75 | override suspend fun getItemModels(): List { 76 | checkIsBuilding("getPagedListModels") 77 | return modelCache.getModels() 78 | } 79 | 80 | override suspend fun buildModels() { 81 | +getItemModels() 82 | } 83 | 84 | /** 85 | * Submit a new paged list. A diff will be calculated between this list and the previous list 86 | * so you may still get cached models from the previous list when calling [getItemModels]. 87 | */ 88 | fun submitList(pagedList: PagedList?) { 89 | modelCache.submitList(pagedList) 90 | } 91 | 92 | /** 93 | * Clears the cached models then call [requestModelBuild]. 94 | * So model build will run for every item in the current [PagedList]. 95 | */ 96 | fun requestForceModelBuild() { 97 | modelCache.clearModels() 98 | requestModelBuild() 99 | } 100 | 101 | @CallSuper 102 | override fun close() { 103 | modelCacheScope.cancel() 104 | super.close() 105 | } 106 | 107 | override fun notifyGetItemAt(position: Int) { 108 | modelCache.notifyGetItemAt(position) 109 | } 110 | } 111 | 112 | private object DefaultPagedListItemDiffCallback : DiffUtil.ItemCallback() { 113 | 114 | override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean { 115 | return oldItem == newItem 116 | } 117 | 118 | @SuppressLint("DiffUtilEquals") 119 | override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { 120 | return oldItem == newItem 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /lounge-paging/src/main/java/jp/co/cyberagent/lounge/paging/internal/PagedListModelCache.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging.internal 2 | 3 | import androidx.paging.AsyncPagedListDiffer 4 | import androidx.paging.PagedList 5 | import androidx.recyclerview.widget.AsyncDifferConfig 6 | import androidx.recyclerview.widget.DiffUtil 7 | import androidx.recyclerview.widget.ListUpdateCallback 8 | import jp.co.cyberagent.lounge.LoungeModel 9 | import kotlinx.coroutines.CompletableDeferred 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.channels.Channel 13 | import kotlinx.coroutines.flow.consumeAsFlow 14 | import kotlinx.coroutines.flow.launchIn 15 | import kotlinx.coroutines.flow.onEach 16 | import kotlinx.coroutines.launch 17 | import java.util.concurrent.Executor 18 | import kotlin.math.min 19 | 20 | internal class PagedListModelCache( 21 | private val modelBuilder: (Int, T?) -> LoungeModel, 22 | private val rebuildCallback: () -> Unit, 23 | diffCallback: DiffUtil.ItemCallback, 24 | private val coroutineScope: CoroutineScope, 25 | diffExecutor: Executor? = null, // For test 26 | ) { 27 | 28 | private val modelCache = mutableListOf() 29 | 30 | private val opChannel = Channel(Channel.UNLIMITED) 31 | 32 | init { 33 | opChannel 34 | .consumeAsFlow() 35 | .onEach { handleOp(it) } 36 | .launchIn(coroutineScope) 37 | } 38 | 39 | private val listUpdateCallback: ListUpdateCallback = object : ListUpdateCallback { 40 | override fun onInserted(position: Int, count: Int) { 41 | opChannel.offerSafe(CacheOp.Insert(position, count)) 42 | } 43 | 44 | override fun onRemoved(position: Int, count: Int) { 45 | opChannel.offerSafe(CacheOp.Remove(position, count)) 46 | } 47 | 48 | override fun onMoved(fromPosition: Int, toPosition: Int) { 49 | opChannel.offerSafe(CacheOp.Move(fromPosition, toPosition)) 50 | } 51 | 52 | override fun onChanged(position: Int, count: Int, payload: Any?) { 53 | opChannel.offerSafe(CacheOp.Change(position, count)) 54 | } 55 | } 56 | 57 | private val diffConfig = AsyncDifferConfig.Builder(diffCallback) 58 | .apply { 59 | if (diffExecutor != null) { 60 | setBackgroundThreadExecutor(diffExecutor) 61 | } 62 | } 63 | .build() 64 | 65 | private val differ = AsyncPagedListDiffer( 66 | listUpdateCallback, 67 | diffConfig 68 | ) 69 | 70 | val currentList: PagedList? 71 | get() = differ.currentList 72 | 73 | fun notifyGetItemAt(position: Int) { 74 | // TODO the position may not be a good value if there are too many injected items. 75 | differ.getItem(min(position, differ.itemCount - 1)) 76 | } 77 | 78 | fun submitList(pagedList: PagedList?) { 79 | coroutineScope.launch(Dispatchers.Main.immediate) { 80 | differ.submitList(pagedList) 81 | } 82 | } 83 | 84 | suspend fun getModels(): List { 85 | val models = CompletableDeferred>() 86 | opChannel.send(CacheOp.Get(models)) 87 | return models.await() 88 | } 89 | 90 | fun clearModels() { 91 | opChannel.offerSafe(CacheOp.Clear) 92 | } 93 | 94 | private fun handleOp(op: CacheOp) { 95 | when (op) { 96 | is CacheOp.Insert -> { 97 | (0 until op.count).forEach { _ -> 98 | modelCache.add(op.position, null) 99 | } 100 | rebuildCallback() 101 | } 102 | is CacheOp.Remove -> { 103 | (0 until op.count).forEach { _ -> 104 | modelCache.removeAt(op.position) 105 | } 106 | rebuildCallback() 107 | } 108 | is CacheOp.Move -> { 109 | val model = modelCache.removeAt(op.fromPosition) 110 | modelCache.add(op.toPosition, model) 111 | rebuildCallback() 112 | } 113 | is CacheOp.Change -> { 114 | (op.position until (op.position + op.count)).forEach { 115 | modelCache[it] = null 116 | } 117 | rebuildCallback() 118 | } 119 | is CacheOp.Get -> { 120 | val models = buildCacheModels() 121 | if (models == null) { 122 | // Cache and pagedList are not sync, schedule to build again 123 | opChannel.offerSafe(op) 124 | } else { 125 | op.result.complete(models) 126 | } 127 | } 128 | CacheOp.Clear -> { 129 | modelCache.fill(null) 130 | } 131 | } 132 | } 133 | 134 | private fun buildCacheModels(): List? { 135 | val currentList: List = differ.currentList?.toList().orEmpty() 136 | // Simple check whether modelCache and currentList are sync or not 137 | if (modelCache.size != currentList.size) { 138 | return null 139 | } 140 | 141 | modelCache.indices.forEach { index -> 142 | if (modelCache[index] == null) { 143 | modelCache[index] = modelBuilder(index, currentList[index]) 144 | } 145 | } 146 | 147 | @Suppress("UNCHECKED_CAST") 148 | return modelCache.toList() as List 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /lounge-paging/src/test/java/jp/co/cyberagent/lounge/paging/PagedListRowModelTest.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleRegistry 5 | import io.kotest.core.spec.style.FunSpec 6 | import io.kotest.extensions.robolectric.RobolectricTest 7 | import io.kotest.matchers.collections.shouldContainExactly 8 | import io.kotest.matchers.shouldBe 9 | import jp.co.cyberagent.fixture.TestLifecycleOwner 10 | import jp.co.cyberagent.fixture.items 11 | import jp.co.cyberagent.fixture.memoized 12 | import jp.co.cyberagent.lounge.HeaderData 13 | import jp.co.cyberagent.lounge.LambdaLoungeController 14 | import jp.co.cyberagent.lounge.ListRowModel 15 | import jp.co.cyberagent.lounge.LoungeBuildModelScope 16 | import jp.co.cyberagent.lounge.LoungeController 17 | import jp.co.cyberagent.lounge.paging.util.TestModel 18 | import jp.co.cyberagent.lounge.paging.util.asPagedList 19 | import kotlinx.coroutines.CoroutineDispatcher 20 | import kotlinx.coroutines.ExperimentalCoroutinesApi 21 | import kotlinx.coroutines.test.TestCoroutineDispatcher 22 | 23 | @ExperimentalCoroutinesApi 24 | @RobolectricTest 25 | class PagedListRowModelTest : FunSpec({ 26 | val owner by memoized { TestLifecycleOwner() } 27 | val dispatcher by memoized { TestCoroutineDispatcher() } 28 | val models = List(10) { TestModel(it + 1L) } 29 | 30 | test("PagedListRowOf") { 31 | val headerData = HeaderData("Title", "Description", "ContentDescription") 32 | val controller = testController(owner.lifecycle, dispatcher) { 33 | pagedListRowOf( 34 | headerData = headerData, 35 | pagedList = models.asPagedList(), 36 | buildItemModel = { _, m -> m!! }, 37 | ) { 38 | +it.take(3) 39 | } 40 | } 41 | controller.adapter.size() shouldBe 1 42 | val listRow = controller.adapter[0] as ListRowModel 43 | listRow.headerData shouldBe headerData 44 | listRow.adapter.items shouldContainExactly models.take(3) 45 | } 46 | 47 | test("PagedListRowOf simple") { 48 | val controller = testController(owner.lifecycle, dispatcher) { 49 | pagedListRowOf( 50 | name = "Title", 51 | pagedList = models.asPagedList(), 52 | buildItemModel = { _, m -> m!! }, 53 | ) { 54 | +it.take(3) 55 | } 56 | } 57 | controller.adapter.size() shouldBe 1 58 | val listRow = controller.adapter[0] as ListRowModel 59 | listRow.headerData shouldBe HeaderData("Title") 60 | listRow.adapter.items shouldContainExactly models.take(3) 61 | } 62 | 63 | test("PagedListRowFor") { 64 | val headerData = HeaderData("Title", "Description", "ContentDescription") 65 | val controller = testController(owner.lifecycle, dispatcher) { 66 | pagedListRowFor(headerData = headerData, pagedList = models.asPagedList()) { it!! } 67 | } 68 | controller.adapter.size() shouldBe 1 69 | val listRow = controller.adapter[0] as ListRowModel 70 | listRow.headerData shouldBe headerData 71 | listRow.adapter.items shouldContainExactly models 72 | } 73 | 74 | test("PagedListRowFor simple") { 75 | val controller = testController(owner.lifecycle, dispatcher) { 76 | pagedListRowFor(name = "Title", pagedList = models.asPagedList()) { it!! } 77 | } 78 | controller.adapter.size() shouldBe 1 79 | val listRow = controller.adapter[0] as ListRowModel 80 | listRow.headerData shouldBe HeaderData("Title") 81 | listRow.adapter.items shouldContainExactly models 82 | } 83 | 84 | test("PagedListRowForIndexed") { 85 | val headerData = HeaderData("Title", "Description", "ContentDescription") 86 | val controller = testController(owner.lifecycle, dispatcher) { 87 | pagedListRowForIndexed( 88 | headerData = headerData, 89 | pagedList = models.asPagedList() 90 | ) { index, m -> 91 | m!!.copy(key = index + 100L) 92 | } 93 | } 94 | controller.adapter.size() shouldBe 1 95 | val listRow = controller.adapter[0] as ListRowModel 96 | listRow.headerData shouldBe headerData 97 | listRow.adapter.items shouldContainExactly models.mapIndexed { index, m -> 98 | m.copy(key = index + 100L) 99 | } 100 | } 101 | 102 | test("PagedListRowForIndexed simple") { 103 | val controller = testController(owner.lifecycle, dispatcher) { 104 | pagedListRowForIndexed(name = "Title", pagedList = models.asPagedList()) { index, m -> 105 | m!!.copy(key = index + 100L) 106 | } 107 | } 108 | controller.adapter.size() shouldBe 1 109 | val listRow = controller.adapter[0] as ListRowModel 110 | listRow.headerData shouldBe HeaderData("Title") 111 | listRow.adapter.items shouldContainExactly models.mapIndexed { index, m -> 112 | m.copy(key = index + 100L) 113 | } 114 | } 115 | }) 116 | 117 | private fun testController( 118 | lifecycle: LifecycleRegistry, 119 | dispatcher: CoroutineDispatcher, 120 | buildModels: suspend LoungeBuildModelScope.() -> Unit, 121 | ): LoungeController { 122 | val controller = LambdaLoungeController(lifecycle, dispatcher) 123 | controller.buildModels = buildModels 124 | controller.requestModelBuild() 125 | lifecycle.currentState = Lifecycle.State.STARTED 126 | return controller 127 | } 128 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/TypedRowPresenter.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.view.ViewGroup 4 | import androidx.annotation.CallSuper 5 | import androidx.leanback.widget.RowPresenter 6 | 7 | /** 8 | * A wrapper around [RowPresenter] which provides generic typed APIs. 9 | */ 10 | @Suppress("UNCHECKED_CAST", "TooManyFunctions") 11 | abstract class TypedRowPresenter : RowPresenter() { 12 | 13 | /** 14 | * Called to create a ViewHolder object for a Row. Subclasses will override 15 | * this method to return a different concrete ViewHolder object. 16 | * 17 | * @param parent The parent View for the Row's view holder. 18 | * @return A ViewHolder for the Row's View. 19 | */ 20 | protected abstract fun onCreateRow(parent: ViewGroup): VH 21 | 22 | /** 23 | * Binds the given row object to the given ViewHolder. 24 | * Derived classes of [TypedRowPresenter] overriding 25 | * [TypedRowPresenter.onBindRow] must call through the super class's 26 | * implementation of this method. 27 | */ 28 | @CallSuper 29 | protected open fun onBindRow(vh: VH, item: T) { 30 | super.onBindRowViewHolder(vh, item) 31 | } 32 | 33 | /** 34 | * Unbinds the given ViewHolder. 35 | * Derived classes of [TypedRowPresenter] overriding [TypedRowPresenter.onUnbindRow] 36 | * must call through the super class's implementation of this method. 37 | */ 38 | @CallSuper 39 | protected open fun onUnbindRow(vh: VH) { 40 | super.onUnbindRowViewHolder(vh) 41 | } 42 | 43 | /** 44 | * Called after a [RowPresenter.ViewHolder] is created for a Row. 45 | * Subclasses may override this method and start by calling 46 | * super class's [initializeRow]. 47 | * 48 | * @param vh The ViewHolder to initialize for the Row. 49 | */ 50 | protected open fun initializeRow(vh: VH) { 51 | super.initializeRowViewHolder(vh) 52 | } 53 | 54 | /** 55 | * Called when the row view's expanded state changes. A subclass may override this method to 56 | * respond to expanded state changes of a Row. 57 | * The default implementation will hide/show the header view. Subclasses may 58 | * make visual changes to the Row View but must not create animation on the 59 | * Row view. 60 | */ 61 | protected open fun onRowExpanded(vh: VH, expanded: Boolean) { 62 | super.onRowViewExpanded(vh, expanded) 63 | } 64 | 65 | /** 66 | * This method is only called from [onRowSelected]. 67 | * The default behavior is to signal row selected events with a null item parameter. 68 | * A Subclass of [TypedRowPresenter] having child items should override this method and dispatch 69 | * events with item information. 70 | */ 71 | protected open fun dispatchItemSelected(vh: VH, selected: Boolean) { 72 | super.dispatchItemSelectedListener(vh, selected) 73 | } 74 | 75 | /** 76 | * Called when the given row view changes selection state. A subclass may override this to 77 | * respond to selected state changes of a Row. A subclass may make visual changes to Row view 78 | * but must not create animation on the Row view. 79 | */ 80 | protected open fun onRowSelected(vh: VH, selected: Boolean) { 81 | super.onRowViewSelected(vh, selected) 82 | } 83 | 84 | /** 85 | * Callback when the select level changes. The default implementation applies 86 | * the select level to [androidx.leanback.widget.RowHeaderPresenter.setSelectLevel] 87 | * when [getSelectEffectEnabled] is true. Subclasses may override 88 | * this function and implement a different select effect. In this case, 89 | * the method [isUsingDefaultSelectEffect] should also be overridden to disable 90 | * the default dimming effect. 91 | */ 92 | protected open fun onRowSelectLevelChanged(vh: VH) { 93 | super.onSelectLevelChanged(vh) 94 | } 95 | 96 | /** 97 | * Invoked when the row view is attached to the window. 98 | */ 99 | protected open fun onRowAttachedToWindow(vh: VH) { 100 | super.onRowViewAttachedToWindow(vh) 101 | } 102 | 103 | /** 104 | * Invoked when the row view is detached from the window. 105 | */ 106 | protected open fun onRowDetachedFromWindow(vh: VH) { 107 | super.onRowViewDetachedFromWindow(vh) 108 | } 109 | 110 | // region ---- override parent presenter ---- 111 | 112 | final override fun createRowViewHolder(parent: ViewGroup): ViewHolder { 113 | return onCreateRow(parent) 114 | } 115 | 116 | final override fun onBindRowViewHolder(vh: ViewHolder?, item: Any?) { 117 | onBindRow(vh as VH, item as T) 118 | } 119 | 120 | final override fun onUnbindRowViewHolder(vh: ViewHolder?) { 121 | super.onUnbindRowViewHolder(vh) 122 | onUnbindRow(vh as VH) 123 | } 124 | 125 | final override fun initializeRowViewHolder(vh: ViewHolder?) { 126 | initializeRow(vh as VH) 127 | } 128 | 129 | final override fun onRowViewExpanded(vh: ViewHolder?, expanded: Boolean) { 130 | onRowExpanded(vh as VH, expanded) 131 | } 132 | 133 | final override fun dispatchItemSelectedListener(vh: ViewHolder?, selected: Boolean) { 134 | dispatchItemSelected(vh as VH, selected) 135 | } 136 | 137 | final override fun onRowViewSelected(vh: ViewHolder?, selected: Boolean) { 138 | onRowSelected(vh as VH, selected) 139 | } 140 | 141 | final override fun onSelectLevelChanged(vh: ViewHolder?) { 142 | onRowSelectLevelChanged(vh as VH) 143 | } 144 | 145 | final override fun onRowViewAttachedToWindow(vh: ViewHolder?) { 146 | onRowAttachedToWindow(vh as VH) 147 | } 148 | 149 | final override fun onRowViewDetachedFromWindow(vh: ViewHolder?) { 150 | onRowDetachedFromWindow(vh as VH) 151 | } 152 | 153 | // endregion 154 | } 155 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/android,intellij,gradle,java,kotlin 3 | # Edit at https://www.gitignore.io/?templates=android,intellij,gradle,java,kotlin 4 | 5 | ### Android ### 6 | # Built application files 7 | *.apk 8 | *.ap_ 9 | *.aab 10 | 11 | # Files for the ART/Dalvik VM 12 | *.dex 13 | 14 | # Java class files 15 | *.class 16 | 17 | # Generated files 18 | bin/ 19 | gen/ 20 | out/ 21 | release/* 22 | 23 | # Gradle files 24 | .gradle/ 25 | build/ 26 | 27 | # Local configuration file (sdk path, etc) 28 | local.properties 29 | 30 | # Proguard folder generated by Eclipse 31 | proguard/ 32 | 33 | # Log Files 34 | *.log 35 | 36 | # Android Studio Navigation editor temp files 37 | .navigation/ 38 | 39 | # Android Studio captures folder 40 | captures/ 41 | 42 | # IntelliJ 43 | *.iml 44 | .idea/workspace.xml 45 | .idea/tasks.xml 46 | .idea/gradle.xml 47 | .idea/assetWizardSettings.xml 48 | .idea/dictionaries 49 | .idea/libraries 50 | .idea/compiler.xml 51 | .idea/misc.xml 52 | .idea/jarRepositories.xml 53 | .idea/kotlinScripting.xml 54 | .idea/runConfigurations.xml 55 | .idea/$CACHE_FILE$ 56 | # Android Studio 3 in .gitignore file. 57 | .idea/caches 58 | .idea/modules.xml 59 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 60 | .idea/navEditor.xml 61 | 62 | # Keystore files 63 | # Uncomment the following lines if you do not want to check your keystore files in. 64 | #*.jks 65 | #*.keystore 66 | 67 | # External native build folder generated in Android Studio 2.2 and later 68 | .externalNativeBuild 69 | 70 | # Google Services (e.g. APIs or Firebase) 71 | # google-services.json 72 | 73 | # Freeline 74 | freeline.py 75 | freeline/ 76 | freeline_project_description.json 77 | 78 | # fastlane 79 | fastlane/report.xml 80 | fastlane/Preview.html 81 | fastlane/screenshots 82 | fastlane/test_output 83 | fastlane/readme.md 84 | 85 | # Version control 86 | vcs.xml 87 | 88 | # lint 89 | lint/intermediates/ 90 | lint/generated/ 91 | lint/outputs/ 92 | lint/tmp/ 93 | # lint/reports/ 94 | 95 | ### Android Patch ### 96 | gen-external-apklibs 97 | output.json 98 | 99 | # Replacement of .externalNativeBuild directories introduced 100 | # with Android Studio 3.5. 101 | .cxx/ 102 | 103 | ### Intellij ### 104 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 105 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 106 | 107 | # User-specific stuff 108 | .idea/**/workspace.xml 109 | .idea/**/tasks.xml 110 | .idea/**/usage.statistics.xml 111 | .idea/**/dictionaries 112 | .idea/**/shelf 113 | 114 | # Generated files 115 | .idea/**/contentModel.xml 116 | 117 | # Sensitive or high-churn files 118 | .idea/**/dataSources/ 119 | .idea/**/dataSources.ids 120 | .idea/**/dataSources.local.xml 121 | .idea/**/sqlDataSources.xml 122 | .idea/**/dynamic.xml 123 | .idea/**/uiDesigner.xml 124 | .idea/**/dbnavigator.xml 125 | 126 | # Gradle 127 | .idea/**/gradle.xml 128 | .idea/**/libraries 129 | 130 | # Gradle and Maven with auto-import 131 | # When using Gradle or Maven with auto-import, you should exclude module files, 132 | # since they will be recreated, and may cause churn. Uncomment if using 133 | # auto-import. 134 | # .idea/modules.xml 135 | # .idea/*.iml 136 | # .idea/modules 137 | # *.iml 138 | # *.ipr 139 | 140 | # CMake 141 | cmake-build-*/ 142 | 143 | # Mongo Explorer plugin 144 | .idea/**/mongoSettings.xml 145 | 146 | # File-based project format 147 | *.iws 148 | 149 | # IntelliJ 150 | 151 | # mpeltonen/sbt-idea plugin 152 | .idea_modules/ 153 | 154 | # JIRA plugin 155 | atlassian-ide-plugin.xml 156 | 157 | # Cursive Clojure plugin 158 | .idea/replstate.xml 159 | 160 | # Crashlytics plugin (for Android Studio and IntelliJ) 161 | com_crashlytics_export_strings.xml 162 | crashlytics.properties 163 | crashlytics-build.properties 164 | fabric.properties 165 | 166 | # Editor-based Rest Client 167 | .idea/httpRequests 168 | 169 | # Android studio 3.1+ serialized cache file 170 | .idea/caches/build_file_checksums.ser 171 | 172 | ### Intellij Patch ### 173 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 174 | 175 | # *.iml 176 | # modules.xml 177 | # .idea/misc.xml 178 | # *.ipr 179 | 180 | # Sonarlint plugin 181 | .idea/**/sonarlint/ 182 | 183 | # SonarQube Plugin 184 | .idea/**/sonarIssues.xml 185 | 186 | # Markdown Navigator plugin 187 | .idea/**/markdown-navigator.xml 188 | .idea/**/markdown-navigator/ 189 | 190 | ### Java ### 191 | # Compiled class file 192 | 193 | # Log file 194 | 195 | # BlueJ files 196 | *.ctxt 197 | 198 | # Mobile Tools for Java (J2ME) 199 | .mtj.tmp/ 200 | 201 | # Package Files # 202 | *.jar 203 | *.war 204 | *.nar 205 | *.ear 206 | *.zip 207 | *.tar.gz 208 | *.rar 209 | 210 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 211 | hs_err_pid* 212 | 213 | ### Kotlin ### 214 | # Compiled class file 215 | 216 | # Log file 217 | 218 | # BlueJ files 219 | 220 | # Mobile Tools for Java (J2ME) 221 | 222 | # Package Files # 223 | 224 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 225 | 226 | ### Gradle ### 227 | .gradle 228 | 229 | # Ignore Gradle GUI config 230 | gradle-app.setting 231 | 232 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 233 | !gradle-wrapper.jar 234 | 235 | # Cache of project 236 | .gradletasknamecache 237 | 238 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 239 | # gradle/wrapper/gradle-wrapper.properties 240 | 241 | ### Gradle Patch ### 242 | **/build/ 243 | 244 | # End of https://www.gitignore.io/api/android,intellij,gradle,java,kotlin 245 | 246 | docs/api 247 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | 19 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | xmlns:android 28 | 29 | ^$ 30 | 31 | 32 | 33 |
34 |
35 | 36 | 37 | 38 | xmlns:.* 39 | 40 | ^$ 41 | 42 | 43 | BY_NAME 44 | 45 |
46 |
47 | 48 | 49 | 50 | .*:id 51 | 52 | http://schemas.android.com/apk/res/android 53 | 54 | 55 | 56 |
57 |
58 | 59 | 60 | 61 | .*:name 62 | 63 | http://schemas.android.com/apk/res/android 64 | 65 | 66 | 67 |
68 |
69 | 70 | 71 | 72 | name 73 | 74 | ^$ 75 | 76 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | style 84 | 85 | ^$ 86 | 87 | 88 | 89 |
90 |
91 | 92 | 93 | 94 | .* 95 | 96 | ^$ 97 | 98 | 99 | BY_NAME 100 | 101 |
102 |
103 | 104 | 105 | 106 | .* 107 | 108 | http://schemas.android.com/apk/res/android 109 | 110 | 111 | ANDROID_ATTRIBUTE_ORDER 112 | 113 |
114 |
115 | 116 | 117 | 118 | .* 119 | 120 | .* 121 | 122 | 123 | BY_NAME 124 | 125 |
126 |
127 |
128 |
129 | 130 | 146 |
147 |
-------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 18 | 24 | 30 | 36 | 42 | 48 | 54 | 60 | 66 | 72 | 78 | 84 | 90 | 96 | 102 | 108 | 114 | 120 | 126 | 132 | 138 | 144 | 150 | 156 | 162 | 168 | 174 | 180 | 186 | 192 | 198 | 204 | 205 | -------------------------------------------------------------------------------- /docs/object-adapter.md: -------------------------------------------------------------------------------- 1 | # ObjectAdapter Support 2 | 3 | `ObjectAdapter` is used in many Leanback components (e.g. `BrowseSupportFragment`, `VerticalGridSupportFragment`, et al.) to display list items. 4 | `ObjectAdapter` uses `Presenter`s to create views and bind data to those views. 5 | 6 | Lounge provides `LoungeController` and `LoungeModel` to help you construct `ObjectAdapter` in a declarative programming style. 7 | The implementation of `LoungeController` is referred to the well-known RecyclerView library [Airbnb/Epoxy](https://github.com/airbnb/epoxy). 8 | If you are familiar with Epoxy, Lounge will be easy to use. 9 | 10 | ## Basic Usage 11 | 12 | Described here are the fundamentals for building list UIs with Lounge. 13 | 14 | ### Creating LoungeModel 15 | 16 | `LoungeModel` is the base unit that describe how your views should be displayed via the `Presenter`. 17 | 18 | ```kotlin 19 | data class TextModel( 20 | val name: String 21 | ) : LoungeModel { 22 | override val key: Long = name.toLoungeModelKey() 23 | override val presenter: Presenter = TextModelPresenter 24 | } 25 | ``` 26 | 27 | Each `LoungeModel` should have a unique `key` to allow RecyclerView diffing algorithm works. 28 | You can use the extension function `Any.toLoungeModelKey()` to get a key from any object. 29 | To get better performance, it is recommended to also implement `equals()` properly. 30 | In Kotlin, we can easily achieve this via data class. 31 | 32 | The `presenter` is a normal Leanback `Presenter` which can bind the `LoungeModel` to a view. 33 | You can create `Presenter` using the default `ViewHolder` pattern that provided by Leanback itself or using the `DataBindingPresenter` provided by Lounge. 34 | `Presenter` better be a singleton object so it can be shared with multiple instances. 35 | 36 | `DataBindingPresenter` example: 37 | 38 | ```kotlin 39 | object TextModelPresenter : DataBindingPresenter(R.layout.model_text) { 40 | override fun onBind(binding: ModelTextBinding, item: TextModel) { 41 | binding.model = item 42 | } 43 | } 44 | ``` 45 | 46 | Or simply using `SimpleDataBindingPresenter`: 47 | 48 | ```kotlin 49 | data class TextModel( 50 | val name: String, 51 | ) : LoungeModel { 52 | override val key: Long = name.toLoungeModelKey() 53 | override val presenter: Presenter 54 | get() = SimpleDataBindingPresenter(R.layout.model_text, BR.model) 55 | } 56 | ``` 57 | 58 | ### Using LoungeModel inside LoungeController 59 | 60 | `LoungeController` defines what `LoungeModel`s should be added into the `ObjectAdapter`. 61 | 62 | The controller's `buildModels` method declared which `LoungeModel`s to show. 63 | You are responsible for calling `requestModelBuild` whenever your data changes, 64 | which triggers `buildModels` to run again. 65 | 66 | Example to show a list of `TextModel`: 67 | 68 | ```kotlin 69 | class MyController(lifecycle: Lifecycle) : LoungeController(lifecycle) { 70 | 71 | var names: List = emptyList() 72 | set(value) { 73 | if (field != value) { 74 | field = value 75 | requestModelBuild() 76 | } 77 | } 78 | 79 | override suspend fun buildModels() { 80 | names.forEach { 81 | +TextModel(it) 82 | } 83 | } 84 | } 85 | ``` 86 | 87 | Every time `names` changes, we call `requestModelBuild`. 88 | The custom getter of `names` contains a lot of boilerplate code, instead we can use the `loungeProp` delegated property: 89 | 90 | ```kotlin 91 | var names: List by loungeProp(emptyList()) 92 | ``` 93 | 94 | Similar to Epoxy, `requestModelBuild` requests that models be built but does not guarantee that it will happen immediately. 95 | Calling `requestModelBuild` multiple times will cancel the previous uncompleted build. 96 | This is to decouple model building from data changes. 97 | This way all data updates can be completed in full without worrying about calling requestModelBuild multiple times. 98 | 99 | ### Integrating with Leanback components 100 | 101 | We can get the backing `ObjectAdapter` off the `LoungeController` and set up into Leanback components. 102 | Here are some examples. 103 | 104 | #### Set up for VerticalGridSupportFragment 105 | 106 | ```kotlin 107 | class MyVerticalGripFragment : VerticalGridSupportFragment() { 108 | 109 | private val viewModel by viewModels() 110 | private val controller by lazy { MyController(lifecycle) } 111 | 112 | override fun onCreate(savedInstanceState: Bundle?) { 113 | super.onCreate(savedInstanceState) 114 | gridPresenter = VerticalGridPresenter().apply { 115 | numberOfColumns = 5 116 | } 117 | adapter = controller.adapter 118 | } 119 | 120 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 121 | super.onViewCreated(view, savedInstanceState) 122 | viewModel.names.observe(viewLifecycleOwner) { 123 | controller.names = it 124 | } 125 | } 126 | } 127 | ``` 128 | 129 | #### Set up for RowsSupportFragment 130 | 131 | Lounge provides `listRow`, `listRowFor`, `listRowOf` for simplify creating multiple rows UI. 132 | 133 | Create a `LoungeController` that has two rows, await all data becoming available before the first build: 134 | 135 | ```kotlin 136 | class MyRowsController(lifecycle: Lifecycle) : LoungeController(lifecycle) { 137 | 138 | var row1: List? by loungeProp(null) 139 | var row2: List? by loungeProp(null) 140 | 141 | override suspend fun buildModels() { 142 | val row1 = row1 ?: awaitCancellation() 143 | val row2 = row2 ?: awaitCancellation() 144 | 145 | listRowFor( 146 | name = "Row 1", 147 | list = row1 148 | ) { 149 | TextModel(it) 150 | } 151 | 152 | listRowFor( 153 | name = "Row 2", 154 | list = row2 155 | ) { 156 | TextModel(it) 157 | } 158 | } 159 | } 160 | ``` 161 | 162 | Use the controller inside `BrowseSupportFragment`: 163 | 164 | ```kotlin 165 | class MyRowsFragment : RowsSupportFragment() { 166 | private val controller by lazy { MyRowsController(lifecycle) } 167 | 168 | override fun onCreate(savedInstanceState: Bundle?) { 169 | super.onCreate(savedInstanceState) 170 | adapter = controller.adapter 171 | } 172 | 173 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 174 | super.onViewCreated(view, savedInstanceState) 175 | // Observe data and update row1, row2 176 | } 177 | } 178 | ``` 179 | 180 | ## Advanced Usage 181 | 182 | // TODO 183 | -------------------------------------------------------------------------------- /lounge/src/test/java/jp/co/cyberagent/lounge/ListRowModelTest.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import androidx.lifecycle.Lifecycle 4 | import androidx.lifecycle.LifecycleRegistry 5 | import io.kotest.core.spec.style.FunSpec 6 | import io.kotest.extensions.robolectric.RobolectricTest 7 | import io.kotest.matchers.shouldBe 8 | import jp.co.cyberagent.fixture.TestLifecycleOwner 9 | import jp.co.cyberagent.fixture.memoized 10 | import jp.co.cyberagent.lounge.util.TestModel 11 | import kotlinx.coroutines.CoroutineDispatcher 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.ExperimentalCoroutinesApi 14 | import kotlinx.coroutines.test.TestCoroutineDispatcher 15 | import kotlinx.coroutines.test.setMain 16 | 17 | @ExperimentalCoroutinesApi 18 | @RobolectricTest 19 | class ListRowModelTest : FunSpec({ 20 | val owner by memoized { TestLifecycleOwner() } 21 | val dispatcher by memoized { TestCoroutineDispatcher() } 22 | val models = List(10) { TestModel(it + 1L) } 23 | 24 | beforeEach { 25 | Dispatchers.setMain(dispatcher) 26 | } 27 | 28 | test("ListRowModel has ListRow info") { 29 | val headerData = HeaderData("Title", "Description", "ContentDescription") 30 | val controller = testController(owner.lifecycle, dispatcher) {} 31 | val listRow = ListRowModel( 32 | key = 1L, 33 | headerData = HeaderData("Title", "Description", "ContentDescription"), 34 | controller = controller, 35 | ) 36 | listRow.id shouldBe 1L 37 | listRow.adapter shouldBe controller.adapter 38 | listRow.headerItem.name shouldBe headerData.name 39 | listRow.headerItem.description shouldBe headerData.description 40 | listRow.headerItem.contentDescription shouldBe headerData.contentDescription 41 | } 42 | 43 | test("ListRow") { 44 | val listRowController = object : LoungeController(owner.lifecycle, dispatcher) { 45 | override suspend fun buildModels() = +models 46 | } 47 | val headerData = HeaderData("Title", "Description", "ContentDescription") 48 | val controller = testController(owner.lifecycle, dispatcher) { 49 | listRow( 50 | headerData = headerData, 51 | key = "key", 52 | controller = listRowController 53 | ) 54 | } 55 | listRowController.requestModelBuild() 56 | controller.adapter.size() shouldBe 1 57 | val listRow = controller.adapter[0] as ListRowModel 58 | listRow.key shouldBe "key".toLoungeModelKey() 59 | listRow.headerData shouldBe headerData 60 | listRow.controller shouldBe listRowController 61 | models.forEachIndexed { index, m -> listRow.adapter[index] shouldBe m } 62 | } 63 | 64 | test("ListRow name as key") { 65 | val headerData = HeaderData("Title") 66 | val controller = testController(owner.lifecycle, dispatcher) { 67 | listRow( 68 | headerData = headerData, 69 | controller = testController(owner.lifecycle, dispatcher) {} 70 | ) 71 | } 72 | val listRow = controller.adapter[0] as ListRowModel 73 | listRow.key shouldBe headerData.toLoungeModelKey() 74 | } 75 | 76 | test("ListRowOf") { 77 | val headerData = HeaderData("Title", "Description", "ContentDescription") 78 | val controller = testController(owner.lifecycle, dispatcher) { 79 | listRowOf(headerData = headerData) { 80 | +models 81 | } 82 | } 83 | controller.adapter.size() shouldBe 1 84 | val listRow = controller.adapter[0] as ListRowModel 85 | listRow.headerData shouldBe headerData 86 | models.forEachIndexed { index, m -> listRow.adapter[index] shouldBe m } 87 | } 88 | 89 | test("ListRowOf simple") { 90 | val controller = testController(owner.lifecycle, dispatcher) { 91 | listRowOf(name = "Title") { 92 | +models 93 | } 94 | } 95 | controller.adapter.size() shouldBe 1 96 | val listRow = controller.adapter[0] as ListRowModel 97 | listRow.headerData shouldBe HeaderData("Title") 98 | models.forEachIndexed { index, m -> listRow.adapter[index] shouldBe m } 99 | } 100 | 101 | test("ListRowFor") { 102 | val headerData = HeaderData("Title", "Description", "ContentDescription") 103 | val controller = testController(owner.lifecycle, dispatcher) { 104 | listRowFor(headerData = headerData, list = models) { it } 105 | } 106 | controller.adapter.size() shouldBe 1 107 | val listRow = controller.adapter[0] as ListRowModel 108 | listRow.headerData shouldBe headerData 109 | models.forEachIndexed { index, m -> listRow.adapter[index] shouldBe m } 110 | } 111 | 112 | test("ListRowFor simple") { 113 | val controller = testController(owner.lifecycle, dispatcher) { 114 | listRowFor(name = "Title", list = models) { it } 115 | } 116 | controller.adapter.size() shouldBe 1 117 | val listRow = controller.adapter[0] as ListRowModel 118 | listRow.headerData shouldBe HeaderData("Title") 119 | models.forEachIndexed { index, m -> listRow.adapter[index] shouldBe m } 120 | } 121 | 122 | test("ListRowForIndexed") { 123 | val headerData = HeaderData("Title", "Description", "ContentDescription") 124 | val controller = testController(owner.lifecycle, dispatcher) { 125 | listRowForIndexed(headerData = headerData, list = models) { index, m -> 126 | m.copy(key = index + 100L) 127 | } 128 | } 129 | controller.adapter.size() shouldBe 1 130 | val listRow = controller.adapter[0] as ListRowModel 131 | listRow.headerData shouldBe headerData 132 | models.forEachIndexed { index, m -> 133 | listRow.adapter[index] shouldBe m.copy(key = index + 100L) 134 | } 135 | } 136 | 137 | test("ListRowForIndexed simple") { 138 | val controller = testController(owner.lifecycle, dispatcher) { 139 | listRowForIndexed(name = "Title", list = models) { index, m -> 140 | m.copy(key = index + 100L) 141 | } 142 | } 143 | controller.adapter.size() shouldBe 1 144 | val listRow = controller.adapter[0] as ListRowModel 145 | listRow.headerData shouldBe HeaderData("Title") 146 | models.forEachIndexed { index, m -> 147 | listRow.adapter[index] shouldBe m.copy(key = index + 100L) 148 | } 149 | } 150 | }) 151 | 152 | private fun testController( 153 | lifecycle: LifecycleRegistry, 154 | dispatcher: CoroutineDispatcher, 155 | buildModels: suspend LoungeBuildModelScope.() -> Unit, 156 | ): LoungeController { 157 | val controller = LambdaLoungeController(lifecycle, dispatcher) 158 | controller.buildModels = buildModels 159 | controller.requestModelBuild() 160 | lifecycle.currentState = Lifecycle.State.STARTED 161 | return controller 162 | } 163 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /lounge/src/main/java/jp/co/cyberagent/lounge/LoungeGuidedAction.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.leanback.app.GuidedStepSupportFragment 5 | import androidx.leanback.widget.DiffCallback 6 | import androidx.leanback.widget.GuidedAction 7 | import androidx.leanback.widget.GuidedActionDiffCallback 8 | 9 | /** 10 | * A [GuidedAction] with more convenient options. 11 | */ 12 | open class LoungeGuidedAction : GuidedAction() { 13 | 14 | /** 15 | * Custom layout id of this action. 16 | */ 17 | var layoutId: Int = DEFAULT_LAYOUT_ID 18 | 19 | /** 20 | * Listener invoked when this action is clicked. 21 | */ 22 | var onClickedListener: OnGuidedActionClickedListener? = null 23 | 24 | /** 25 | * Listener invoked when this action in sub actions is clicked. 26 | */ 27 | var onSubClickedListener: OnSubGuidedActionClickedListener? = null 28 | 29 | /** 30 | * Listener invoked when this action is focused (made to be the current selection). 31 | */ 32 | var onFocusedListener: OnGuidedActionFocusedListener? = null 33 | 34 | /** 35 | * Listener invoked when this action has been edited, for example when user clicks confirm button 36 | * in IME window. 37 | */ 38 | var onEditedAndProceedListener: OnGuidedActionEditedAndProceedListener? = null 39 | 40 | /** 41 | * Listener invoked when this action has been canceled editing, for example when user closes 42 | * IME window by BACK key. 43 | */ 44 | var onEditCanceledListener: OnGuidedActionEditCanceledListener? = null 45 | 46 | /** 47 | * Listener invoked when an action is clicked. 48 | */ 49 | fun interface OnGuidedActionClickedListener { 50 | fun onClicked(action: LoungeGuidedAction) 51 | } 52 | 53 | /** 54 | * Listener invoked when an action in sub actions is clicked. 55 | */ 56 | fun interface OnSubGuidedActionClickedListener { 57 | fun onClicked(action: LoungeGuidedAction): Boolean 58 | } 59 | 60 | /** 61 | * Listener invoked when an action is focused (made to be the current selection). 62 | */ 63 | fun interface OnGuidedActionFocusedListener { 64 | fun onFocused(action: LoungeGuidedAction) 65 | } 66 | 67 | /** 68 | * Listener invoked when an action has been edited, for example when user clicks confirm button 69 | * in IME window. 70 | */ 71 | fun interface OnGuidedActionEditedAndProceedListener { 72 | fun onEditedAndProceed(action: LoungeGuidedAction): Long 73 | } 74 | 75 | /** 76 | * Listener invoked when an action has been canceled editing, for example when user closes 77 | * IME window by BACK key. 78 | */ 79 | fun interface OnGuidedActionEditCanceledListener { 80 | fun onEditCanceled(action: LoungeGuidedAction) 81 | } 82 | 83 | companion object { 84 | /** 85 | * Indicates the action should use a default layout in [LoungeGuidedActionsStylist]. 86 | */ 87 | const val DEFAULT_LAYOUT_ID = 0 88 | } 89 | } 90 | 91 | /** 92 | * If this action is a [LoungeGuidedAction], invokes its [LoungeGuidedAction.onClickedListener]. 93 | * 94 | * You can override [GuidedStepSupportFragment.onGuidedActionClicked] and call this function with 95 | * the given action. 96 | */ 97 | fun onLoungeGuidedActionClicked(action: GuidedAction?) { 98 | (action as? LoungeGuidedAction)?.onClickedListener?.onClicked(action) 99 | } 100 | 101 | /** 102 | * If this action is a [LoungeGuidedAction], invokes its [LoungeGuidedAction.onSubClickedListener] 103 | * and returns the invoked result. Otherwise returns true. 104 | * 105 | * You can override [GuidedStepSupportFragment.onSubGuidedActionClicked] and call this function with 106 | * the given action. 107 | */ 108 | fun onSubLoungeGuidedActionClicked(action: GuidedAction?): Boolean { 109 | return (action as? LoungeGuidedAction)?.onSubClickedListener?.onClicked(action) ?: true 110 | } 111 | 112 | /** 113 | * If this action is a [LoungeGuidedAction], invokes its [LoungeGuidedAction.onFocusedListener]. 114 | * 115 | * You can override [GuidedStepSupportFragment.onGuidedActionFocused] and call this function with 116 | * the given action. 117 | */ 118 | fun onLoungeGuidedActionFocused(action: GuidedAction?) { 119 | (action as? LoungeGuidedAction)?.onFocusedListener?.onFocused(action) 120 | } 121 | 122 | /** 123 | * If this action is a [LoungeGuidedAction], invokes its [LoungeGuidedAction.onEditedAndProceedListener] 124 | * and returns the invoked result. Otherwise returns [GuidedAction.ACTION_ID_NEXT]. 125 | * 126 | * You can override [GuidedStepSupportFragment.onGuidedActionEditedAndProceed] and call this function with 127 | * the given action. 128 | */ 129 | fun onLoungeGuidedActionEditedAndProceed(action: GuidedAction?): Long { 130 | return (action as? LoungeGuidedAction)?.onEditedAndProceedListener?.onEditedAndProceed(action) 131 | ?: GuidedAction.ACTION_ID_NEXT 132 | } 133 | 134 | /** 135 | * If this action is a [LoungeGuidedAction], invokes its [LoungeGuidedAction.onEditCanceledListener]. 136 | * 137 | * You can override [GuidedStepSupportFragment.onGuidedActionEditCanceled] and call this function with 138 | * the given action. 139 | */ 140 | fun onLoungeGuidedActionEditCanceled(action: GuidedAction?) { 141 | (action as? LoungeGuidedAction)?.onEditCanceledListener?.onEditCanceled(action) 142 | } 143 | 144 | /** 145 | * DiffCallback used for [LoungeGuidedAction]. 146 | * 147 | * @see [GuidedStepSupportFragment.setActionsDiffCallback] 148 | */ 149 | object LoungeGuidedActionDiffCallback : DiffCallback() { 150 | 151 | private val guidedActionDiffCallback = GuidedActionDiffCallback.getInstance() 152 | 153 | override fun areItemsTheSame(oldItem: GuidedAction, newItem: GuidedAction): Boolean = 154 | guidedActionDiffCallback.areItemsTheSame(oldItem, newItem) 155 | 156 | @SuppressLint("DiffUtilEquals") 157 | override fun areContentsTheSame(oldItem: GuidedAction, newItem: GuidedAction): Boolean { 158 | val rawCompare = guidedActionDiffCallback.areContentsTheSame(oldItem, newItem) 159 | return if (oldItem is LoungeGuidedAction && newItem is LoungeGuidedAction) { 160 | rawCompare && 161 | oldItem.layoutId == newItem.layoutId && 162 | oldItem.onClickedListener == newItem.onClickedListener && 163 | oldItem.onSubClickedListener == newItem.onSubClickedListener && 164 | oldItem.onFocusedListener == newItem.onFocusedListener && 165 | oldItem.onEditedAndProceedListener == newItem.onEditedAndProceedListener && 166 | oldItem.onEditCanceledListener == newItem.onEditCanceledListener 167 | } else { 168 | rawCompare 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /lounge-paging/src/main/java/jp/co/cyberagent/lounge/paging/PagedListRowModel.kt: -------------------------------------------------------------------------------- 1 | package jp.co.cyberagent.lounge.paging 2 | 3 | import androidx.leanback.widget.HeaderItem 4 | import androidx.leanback.widget.ListRow 5 | import androidx.leanback.widget.ListRowPresenter 6 | import androidx.paging.PagedList 7 | import jp.co.cyberagent.lounge.HeaderData 8 | import jp.co.cyberagent.lounge.ListRowModel 9 | import jp.co.cyberagent.lounge.LoungeBuildModelScope 10 | import jp.co.cyberagent.lounge.LoungeModel 11 | import jp.co.cyberagent.lounge.listRow 12 | import jp.co.cyberagent.lounge.memorizedController 13 | 14 | /** 15 | * Adds a [ListRowModel] that works with [PagedList] to this scope. 16 | * Either [headerData] or [key] must be provided to properly set the [ListRowModel.key]. 17 | * 18 | * @param headerData if provided, set a [HeaderItem] with the data to the [ListRow]. 19 | * @param key if provided, set it as the [ListRowModel.key]. 20 | * @param presenter the [ListRowPresenter] for the [ListRow]. 21 | * @param buildItemModel build a item in [PagedList] into a [LoungeModel]. 22 | * @param buildModels models added in this scope will be add to the [ListRowModel]. 23 | */ 24 | suspend fun LoungeBuildModelScope.pagedListRowOf( 25 | headerData: HeaderData? = null, 26 | pagedList: PagedList?, 27 | key: Any? = null, 28 | presenter: ListRowPresenter = ListRowModel.DefaultListRowPresenter, 29 | buildItemModel: (Int, T?) -> LoungeModel, 30 | buildModels: suspend PagedListLoungeBuildModelScope.(List) -> Unit, 31 | ) { 32 | val controllerKey = requireNotNull(key ?: headerData) { 33 | "Require key or headerData to be non-null." 34 | } 35 | val controller = memorizedController(controllerKey) { 36 | LambdaPagedListLoungeController(lifecycle, modelBuildingDispatcher) 37 | } 38 | controller.buildItemModel = buildItemModel 39 | controller.buildModels = buildModels 40 | controller.submitList(pagedList) 41 | controller.requestForceModelBuild() 42 | listRow( 43 | headerData = headerData, 44 | key = key, 45 | controller = controller, 46 | presenter = presenter 47 | ) 48 | } 49 | 50 | /** 51 | * Adds a [ListRowModel] that works with [PagedList] to this scope. 52 | * Either [name] or [key] must be provided to properly set the [ListRowModel.key]. 53 | * 54 | * @param name if provided, set a [HeaderItem] with the name to the [ListRow]. 55 | * @param key if provided, set it as the [ListRowModel.key]. 56 | * @param presenter the [ListRowPresenter] for the [ListRow]. 57 | * @param buildItemModel build a item in [PagedList] into a [LoungeModel]. 58 | * @param buildModels models added in this scope will be add to the [ListRowModel]. 59 | */ 60 | suspend fun LoungeBuildModelScope.pagedListRowOf( 61 | name: String? = null, 62 | pagedList: PagedList?, 63 | key: Any? = null, 64 | presenter: ListRowPresenter = ListRowModel.DefaultListRowPresenter, 65 | buildItemModel: (Int, T?) -> LoungeModel, 66 | buildModels: suspend PagedListLoungeBuildModelScope.(List) -> Unit, 67 | ) { 68 | pagedListRowOf( 69 | headerData = name?.let { HeaderData(it) }, 70 | pagedList = pagedList, 71 | key = key, 72 | presenter = presenter, 73 | buildItemModel = buildItemModel, 74 | buildModels = buildModels, 75 | ) 76 | } 77 | 78 | /** 79 | * Adds a [ListRowModel] that works with [PagedList] to this scope. 80 | * Builds the model on each item of the [PagedList]. 81 | * Either [headerData] or [key] must be provided to properly set the [ListRowModel.key]. 82 | * 83 | * @param headerData if provided, set a [HeaderItem] with the data to the [ListRow]. 84 | * @param key if provided, set it as the [ListRowModel.key]. 85 | * @param presenter the [ListRowPresenter] for the [ListRow]. 86 | * @param buildItemModel build a item in [PagedList] into a [LoungeModel]. 87 | */ 88 | suspend fun LoungeBuildModelScope.pagedListRowFor( 89 | headerData: HeaderData? = null, 90 | pagedList: PagedList?, 91 | key: Any? = null, 92 | presenter: ListRowPresenter = ListRowModel.DefaultListRowPresenter, 93 | buildItemModel: (T?) -> LoungeModel, 94 | ) { 95 | pagedListRowOf( 96 | headerData = headerData, 97 | pagedList = pagedList, 98 | key = key, 99 | presenter = presenter, 100 | buildItemModel = { _, item -> buildItemModel(item) }, 101 | buildModels = { +it } 102 | ) 103 | } 104 | 105 | /** 106 | * Adds a [ListRowModel] that works with [PagedList] to this scope. 107 | * Builds the model on each item of the [PagedList]. 108 | * Either [name] or [key] must be provided to properly set the [ListRowModel.key]. 109 | * 110 | * @param name if provided, set a [HeaderItem] with the name to the [ListRow]. 111 | * @param key if provided, set it as the [ListRowModel.key]. 112 | * @param presenter the [ListRowPresenter] for the [ListRow]. 113 | * @param buildItemModel build a item in [PagedList] into a [LoungeModel]. 114 | */ 115 | suspend fun LoungeBuildModelScope.pagedListRowFor( 116 | name: String? = null, 117 | pagedList: PagedList?, 118 | key: Any? = null, 119 | presenter: ListRowPresenter = ListRowModel.DefaultListRowPresenter, 120 | buildItemModel: (T?) -> LoungeModel, 121 | ) { 122 | pagedListRowFor( 123 | headerData = name?.let { HeaderData(it) }, 124 | pagedList = pagedList, 125 | key = key, 126 | presenter = presenter, 127 | buildItemModel = buildItemModel, 128 | ) 129 | } 130 | 131 | /** 132 | * Adds a [ListRowModel] that works with [PagedList] to this scope. 133 | * Builds the model on each item of the [PagedList], providing sequential index with the element. 134 | * Either [headerData] or [key] must be provided to properly set the [ListRowModel.key]. 135 | * 136 | * @param headerData if provided, set a [HeaderItem] with the data to the [ListRow]. 137 | * @param key if provided, set it as the [ListRowModel.key]. 138 | * @param presenter the [ListRowPresenter] for the [ListRow]. 139 | * @param buildItemModel build a item in [PagedList] into a [LoungeModel]. 140 | */ 141 | suspend fun LoungeBuildModelScope.pagedListRowForIndexed( 142 | headerData: HeaderData? = null, 143 | pagedList: PagedList?, 144 | key: Any? = null, 145 | presenter: ListRowPresenter = ListRowModel.DefaultListRowPresenter, 146 | buildItemModel: (Int, T?) -> LoungeModel, 147 | ) { 148 | pagedListRowOf( 149 | headerData = headerData, 150 | pagedList = pagedList, 151 | key = key, 152 | presenter = presenter, 153 | buildItemModel = buildItemModel, 154 | buildModels = { +it } 155 | ) 156 | } 157 | 158 | /** 159 | * Adds a [ListRowModel] that works with [PagedList] to this scope. 160 | * Builds the model on each item of the [PagedList], providing sequential index with the element. 161 | * Either [name] or [key] must be provided to properly set the [ListRowModel.key]. 162 | * 163 | * @param name if provided, set a [HeaderItem] with the name to the [ListRow]. 164 | * @param key if provided, set it as the [ListRowModel.key]. 165 | * @param presenter the [ListRowPresenter] for the [ListRow]. 166 | * @param buildItemModel build a item in [PagedList] into a [LoungeModel]. 167 | */ 168 | suspend fun LoungeBuildModelScope.pagedListRowForIndexed( 169 | name: String? = null, 170 | pagedList: PagedList?, 171 | key: Any? = null, 172 | presenter: ListRowPresenter = ListRowModel.DefaultListRowPresenter, 173 | buildItemModel: (Int, T?) -> LoungeModel, 174 | ) { 175 | pagedListRowForIndexed( 176 | headerData = name?.let { HeaderData(it) }, 177 | pagedList = pagedList, 178 | key = key, 179 | presenter = presenter, 180 | buildItemModel = buildItemModel, 181 | ) 182 | } 183 | --------------------------------------------------------------------------------