diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e2b08b3e..40244291 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,13 +16,23 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 with: python-version: 3.x - - uses: actions/cache@v2 + + - uses: actions/setup-java@v3 with: - key: ${{ github.ref }} - path: .cache + distribution: 'zulu' + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build docs + run: | + ./gradlew dokkaHtmlMultiModule + mv ./build/dokka/htmlMultiModule docs/api - name: Install dependencies run: | diff --git a/.github/workflows/docs_pr.yml b/.github/workflows/docs_pr.yml index 79d4be79..71773574 100644 --- a/.github/workflows/docs_pr.yml +++ b/.github/workflows/docs_pr.yml @@ -16,13 +16,23 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 with: python-version: 3.x - - uses: actions/cache@v2 + + - uses: actions/setup-java@v3 with: - key: ${{ github.ref }} - path: .cache + distribution: 'zulu' + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build docs + run: | + ./gradlew dokkaHtmlMultiModule + mv ./build/dokka/htmlMultiModule docs/api - name: Install dependencies run: | diff --git a/README.MD b/README.MD index 6899051a..91fed953 100644 --- a/README.MD +++ b/README.MD @@ -17,7 +17,7 @@ Add the following dependency to your app's `build.gradle`: ```groovy implementation "com.rubensousa.dpadrecyclerview:dpadrecyclerview:$latestVersion" -// Optional: If you want to use Compose together with DpadRecyclerView +// Recommended: To use Compose together with DpadRecyclerView implementation "com.rubensousa.dpadrecyclerview:dpadrecyclerview-compose:$latestVersion" // Optional: Espresso test helpers for your instrumented tests: @@ -42,6 +42,39 @@ Check the official website for more information and recipes: https://rubensousa. - Supports non smooth scroll changes - Supports continuous and circular grid focus +### Easier Compose Integration + +Documentation: https://rubensousa.github.io/DpadRecyclerView/compose/ + +```kotlin +class ComposeItemAdapter( + private val onItemClick: (Int) -> Unit +) : ListAdapter>(Item.DIFF_CALLBACK) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): DpadComposeFocusViewHolder { + return DpadComposeFocusViewHolder(parent) { item -> + ItemComposable( + item = item, + onClick = { + onItemClick(item) + } + ) + } + } + + override fun onBindViewHolder( + holder: DpadComposeFocusViewHolder, + position: Int + ) { + holder.setItemState(getItem(position)) + } + +} +``` + ## Sample app Nested lists: @@ -57,7 +90,7 @@ Grid with different span sizes: ## License - Copyright 2023 Rúben Sousa + Copyright 2024 Rúben Sousa Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/build.gradle b/build.gradle index 97df539a..58f37266 100644 --- a/build.gradle +++ b/build.gradle @@ -13,6 +13,7 @@ plugins { alias libs.plugins.kotlin.android apply false alias libs.plugins.kotlin.kover apply false alias libs.plugins.androidx.navigation.safeargs apply false + id 'org.jetbrains.dokka' version '1.9.20' id 'org.jetbrains.kotlinx.binary-compatibility-validator' version "0.14.0" } diff --git a/docs/changelog.md b/docs/changelog.md index 77ba1591..0bf23fec 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,22 @@ # Changelog +## Version 1.3.0 + +### 1.3.0-alpha01 + +2024-03-17 + +#### New Features + +- Added `DpadComposeFocusViewHolder` that allows sending the focus state down to Composables ([#193](https://github.com/rubensousa/DpadRecyclerView/issues/193)) +- Added `Modifier.dpadClickable` for playing the click sound after clicking on a Composable. Fix for: ([/b/268268856](https://issuetracker.google.com/issues/268268856)) +- Allow skipping layout requests during scroll with `setLayoutWhileScrollingEnabled(false)` ([#196](https://github.com/rubensousa/DpadRecyclerView/issues/196)) +- New `addOnViewFocusedListener` to observe focus changes independently from selection changes. ([#197](https://github.com/rubensousa/DpadRecyclerView/issues/197)) + +#### API Changes + +- `DpadAbstractComposeViewHolder` is now removed. Replace it with either `DpadComposeFocusViewHolder` or `DpadComposeViewHolder`. + ## Version 1.2.0 ### 1.2.0 @@ -8,6 +25,14 @@ - No changes since 1.2.0-rc01 +#### Important changes since 1.1.0 + +- Added new `RecyclerViewCompositionStrategy.DisposeOnRecycled` for compose interop + to re-use compositions when views are detached and attached from the window again. +- Added new `setSelectedSubPosition` that allows passing a callback for the target alignment ([#43](https://github.com/rubensousa/DpadRecyclerView/issues/43)) +- Added support for scrollbars +- Added `DpadScroller` for scrolling without any alignment. Typical use case is for long text displays (terms & conditions and consent pages). + ### 1.2.0-rc01 2024-02-03 diff --git a/docs/compose.md b/docs/compose.md index 48417a6e..974d66ca 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -2,31 +2,67 @@ The `dpadrecyclerview-compose` module contains the following: -- `DpadAbstractComposeViewHolder`: ViewHolder that exposes a `Content` function to render a Composable -- `DpadComposeViewHolder`: simple implementation of `DpadAbstractComposeViewHolder` that forwards a lambda to the `Content` function and handles clicks +- `DpadComposeFocusViewHolder`: ViewHolder that exposes a function to render a Composable and sends the focus directly to Composables. +- `DpadComposeViewHolder`: ViewHolder that exposes a function to render a Composable but keeps the focus state in the View system +- `RecyclerViewCompositionStrategy.DisposeOnRecycled`: a custom `ViewCompositionStrategy` that only disposes compositions when ViewHolders are recycled -You can use these to easily render composables in your `RecyclerView`. +## Compose ViewHolder -The focus is kept in the `itemView` and not actually sent to the Composables inside due to these issues: +### Receive focus inside Composables -1. Focus is not sent correctly from Views to Composables: [b/268248352](https://issuetracker.google.com/issues/268248352) -2. Clicking on a focused Composable does not trigger the standard audio feedback: [b/268268856](https://issuetracker.google.com/issues/268268856) +Use `DpadComposeFocusViewHolder` to let your Composables receive the focus state. -!!! note - If you plan to use compose animations, check the performance during fast scrolling and consider - throttling key events using the APIs explained [here](recipes/scrolling.md#limiting-number-of-pending-alignments) +```kotlin linenums="1" +class ComposeItemAdapter( + private val onItemClick: (Int) -> Unit +) : ListAdapter>(Item.DIFF_CALLBACK) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): DpadComposeFocusViewHolder { + return DpadComposeFocusViewHolder(parent) { item -> + ItemComposable( + item = item, + onClick = { + onItemClick(item) + } + ) + } + } -## DpadComposeViewHolder -Example: `ItemComposable` that should render a text and different colors based on the focus state + override fun onBindViewHolder( + holder: DpadComposeFocusViewHolder, + position: Int + ) { + holder.setItemState(getItem(position)) + } + +} +``` -```kotlin linenums="1" +Then use the standard focus APIs to react to focus changes: + +```kotlin linenums="1", hl_lines="13-16" @Composable -fun ItemComposable(item: Int, isFocused: Boolean) { +fun ItemComposable( + item: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var isFocused by remember { mutableStateOf(false) } val backgroundColor = if (isFocused) Color.White else Color.Black val textColor = if (isFocused) Color.Black else Color.White Box( - modifier = Modifier.background(backgroundColor), + modifier = modifier + .background(backgroundColor) + .onFocusChanged { focusState -> + isFocused = focusState.hasFocus + } + .focusTarget() + .dpadClickable { + onClick() + }, contentAlignment = Alignment.Center, ) { Text( @@ -38,6 +74,10 @@ fun ItemComposable(item: Int, isFocused: Boolean) { } ``` +### Keep focus inside the view system + +If you want to keep the focus inside the View system, use `DpadComposeViewHolder` instead: + ```kotlin linenums="1" class ComposeItemAdapter( private val onItemClick: (Int) -> Unit @@ -50,7 +90,7 @@ class ComposeItemAdapter( return DpadComposeViewHolder( parent, onClick = onItemClick - ) { item, isFocused, isSelected -> + ) { item, isFocused -> ItemComposable(item, isFocused) } } @@ -65,73 +105,36 @@ class ComposeItemAdapter( } ``` -New compositions will be triggered whenever the following happens: - -- New item is bound in `onBindViewHolder` -- Focus state changes -- Selection state changes - -## Dpad AbstractComposeViewHolder - -Extending from this class directly gives you more flexibility for customizations: +In this case, you receive the focus state as an input that you can pass to your Composables: ```kotlin linenums="1" -class ComposeItemAdapter( - private val onItemClick: (Int) -> Unit -) : ListAdapter(Item.DIFF_CALLBACK) { - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): ComposeItemViewHolder { - return ComposeItemViewHolder(parent, onItemClick) - } - - override fun onBindViewHolder( - holder: ComposeItemViewHolder, - position: Int +@Composable +fun ItemComposable(item: Int, isFocused: Boolean) { + val backgroundColor = if (isFocused) Color.White else Color.Black + val textColor = if (isFocused) Color.Black else Color.White + Box( + modifier = Modifier.background(backgroundColor), + contentAlignment = Alignment.Center, ) { - holder.setItemState(getItem(position)) - } - - override fun onViewRecycled(holder: ComposeItemViewHolder) { - holder.onRecycled() + Text( + text = item.toString(), + color = textColor, + fontSize = 35.sp + ) } } ``` -```kotlin linenums="1" -class ComposeItemViewHolder( - parent: ViewGroup, - onClick: (Int) -> Unit -) : DpadAbstractComposeViewHolder(parent) { +## Handle clicks with sound - private val itemAnimator = ItemAnimator(itemView) +Use `Modifier.dpadClickable` instead of `Modifier.clickable` because of this issue: +[/b/268268856](https://issuetracker.google.com/issues/268268856) - init { - itemView.setOnClickListener { - getItem()?.let(onItemClick) - } - } +## Performance optimizations - @Composable - override fun Content(item: Int, isFocused: Boolean, isSelected: Boolean) { - ItemComposable(item, isFocused) - } +- If you plan to use compose animations, check the performance during fast scrolling and consider throttling key events using the APIs explained [here](recipes/scrolling.md#limiting-number-of-pending-alignments) +- Consider using `dpadRecyclerView.setLayoutWhileScrollingEnabled(false)` to discard layout requests during scroll events. +This will skip unnecessary layout requests triggered by some compose animations. - override fun onFocusChanged(hasFocus: Boolean) { - if (hasFocus) { - itemAnimator.startFocusGainAnimation() - } else { - itemAnimator.startFocusLossAnimation() - } - } - - fun onRecycled() { - itemAnimator.cancel() - } - -} -``` Check the sample on [Github](https://github.com/rubensousa/DpadRecyclerView/) for more examples that include simple animations. \ No newline at end of file diff --git a/docs/getting_started.md b/docs/getting_started.md index ca2dfc79..eacf8627 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -5,7 +5,7 @@ Add the following dependency to your app's `build.gradle`: ```groovy implementation "com.rubensousa.dpadrecyclerview:dpadrecyclerview:{{ dpadrecyclerview.version }}" -// Optional: If you want to use Compose together with DpadRecyclerView +// Recommended: To use Compose together with DpadRecyclerView implementation "com.rubensousa.dpadrecyclerview:dpadrecyclerview-compose:{{ dpadrecyclerview.version }}" // Optional: Espresso test helpers for your instrumented tests: @@ -27,11 +27,93 @@ Since `DpadRecyclerView` is a custom view that extends from `RecyclerView`, you !!! warning Don't set a `LayoutManager` because `DpadRecyclerView` already assigns one internally. -Follow the [official RecyclerView guides](https://developer.android.com/develop/ui/views/layout/recyclerview) to render Views on the screen or use any RecyclerView library as you would for mobile apps. +Follow the [official RecyclerView guides](https://developer.android.com/develop/ui/views/layout/recyclerview) to render Views on the screen +or use any RecyclerView library as you would for mobile apps. -## Recipes +You can also render Composables inside using the `dpadrecyclerview-compose` library. -Take a look at the sections inside "Recipes" on this website to customise `DpadRecyclerView` according to your needs. + +## Observe selection changes + +You can observe selection changes using the following: + +```kotlin linenums="1" +recyclerView.addOnViewHolderSelectedListener(object : OnViewHolderSelectedListener { + override fun onViewHolderSelected( + parent: RecyclerView, + child: RecyclerView.ViewHolder?, + position: Int, + subPosition: Int + ) {} + + override fun onViewHolderSelectedAndAligned( + parent: RecyclerView, + child: RecyclerView.ViewHolder?, + position: Int, + subPosition: Int + ) {} +}) +``` + +## Observe focus changes + +To react to focus changes, use this: + +```kotlin linenums="1" +recyclerView.addOnViewFocusedListener(object : OnViewFocusedListener { + override fun onViewFocused( + parent: RecyclerView.ViewHolder, + child: View, + ) { + // Child is now focused + } +}) +``` + +## How to use with Compose + +Check [this](compose.md) page to see more some examples with Compose + +```kotlin linenums="1", hl_lines="13-16" +@Composable +fun ItemComposable( + item: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var isFocused by remember { mutableStateOf(false) } + val backgroundColor = if (isFocused) Color.White else Color.Black + val textColor = if (isFocused) Color.Black else Color.White + Box( + modifier = modifier + .background(backgroundColor) + .onFocusChanged { focusState -> + isFocused = focusState.hasFocus + } + .focusTarget() + .dpadClickable { + onClick() + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = item.toString(), + color = textColor, + fontSize = 35.sp + ) + } +} +``` + +## More customizations + +Check the following recipes: + +1. [Layout](recipes/layout.md): for defining the type of layout (linear or grid) or to enable infinite carousels +2. [Spacing](recipes/spacing.md): add spacing between items +3. [Alignment](recipes/alignment.md): align items to different regions of the screen +4. [Focus](recipes/focus.md): configure how focus is handled +5. [Scrolling](recipes/scrolling.md): configure the scrolling speed ## Sample diff --git a/docs/index.md b/docs/index.md index 7ab960d6..35f4ee41 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,17 +23,9 @@ Motivation for this library is available in my [blog](https://rubensousa.com/202 - Non smooth scroll changes - Continuous and circular grid focus - -### Upcoming features - -- [Looping adapter contents](https://github.com/rubensousa/DpadRecyclerView/issues/20) -- [State saving/restoring helper](https://github.com/rubensousa/DpadRecyclerView/issues/45) -- [Drag and drop helper](https://github.com/rubensousa/DpadRecyclerView/issues/12) - - ## License - Copyright 2023 Rúben Sousa + Copyright 2024 Rúben Sousa Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/docs/recipes/focus.md b/docs/recipes/focus.md index c22ad2a0..9abf0e02 100644 --- a/docs/recipes/focus.md +++ b/docs/recipes/focus.md @@ -1,5 +1,22 @@ # Focus Recipes +## Observing child focus + +Use this to react to a child getting focus: + +```kotlin linenums="1" +recyclerView.addOnViewFocusedListener(object : OnViewFocusedListener { + override fun onViewFocused( + parent: RecyclerView.ViewHolder, + child: View, + ) { + // Child is now focused + } +}) +``` +!!! note + If you set this in a vertical RecyclerView that contains multiple horizontal RecyclerViews, the parent will also receive this callback + ## Disabling focus changes You might want to temporarily disable focus changes and prevent other views from being selected diff --git a/docs/recipes/scrolling.md b/docs/recipes/scrolling.md index 9f5e70e0..580f7d24 100644 --- a/docs/recipes/scrolling.md +++ b/docs/recipes/scrolling.md @@ -70,6 +70,12 @@ The code above translates to this behavior: In the right scenario, there's just one next focusable view since the focused view still isn't aligned to the keyline. +## Disabling layout during scrolling events +If you want to postpone layout changes until the scroll state is idle, use this: + +```kotlin +recyclerView.setLayoutWhileScrollingEnabled(false) +``` diff --git a/dpadrecyclerview-compose/build.gradle b/dpadrecyclerview-compose/build.gradle index 72a8ed97..7c18ebc0 100644 --- a/dpadrecyclerview-compose/build.gradle +++ b/dpadrecyclerview-compose/build.gradle @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + id 'org.jetbrains.dokka' } apply from: "$rootProject.projectDir/gradle/publish.gradle" diff --git a/dpadrecyclerview-testing/build.gradle b/dpadrecyclerview-testing/build.gradle index 4acad5bb..8405bb39 100644 --- a/dpadrecyclerview-testing/build.gradle +++ b/dpadrecyclerview-testing/build.gradle @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + id 'org.jetbrains.dokka' } apply from: "$rootProject.projectDir/gradle/publish.gradle" diff --git a/dpadrecyclerview/build.gradle b/dpadrecyclerview/build.gradle index 4564e14d..9d38b82b 100644 --- a/dpadrecyclerview/build.gradle +++ b/dpadrecyclerview/build.gradle @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + id 'org.jetbrains.dokka' } apply from: "$rootProject.projectDir/gradle/publish.gradle" diff --git a/gradle.properties b/gradle.properties index 8f69c68d..625f86d8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,4 +22,4 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.enableR8.fullMode=true -LIBRARY_VERSION=1.2.0 \ No newline at end of file +LIBRARY_VERSION=1.3.0-alpha01 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index b3f2f527..f5892e7c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,7 +4,7 @@ site_author: 'Rúben Sousa' site_url: 'https://rubensousa.github.io/dpadrecyclerview/' remote_branch: gh-pages edit_uri: "" -copyright: 'Copyright © 2023 Rúben Sousa' +copyright: 'Copyright © 2024 Rúben Sousa' repo_name: 'DpadRecyclerView' repo_url: 'https://github.com/rubensousa/DpadRecyclerView' @@ -24,7 +24,7 @@ theme: extra: dpadrecyclerview: - version: '1.2.0' + version: '1.3.0-alpha01' social: - icon: 'fontawesome/brands/github' link: 'https://github.com/rubensousa/DpadRecyclerView' @@ -65,4 +65,5 @@ nav: - 'Alignment': recipes/alignment.md - 'Focus': recipes/focus.md - 'Scrolling': recipes/scrolling.md - - 'Changelog': changelog.md \ No newline at end of file + - 'Changelog': changelog.md + - 'API': api/ \ No newline at end of file