diff --git a/README.MD b/README.MD index 91fed953..77843079 100644 --- a/README.MD +++ b/README.MD @@ -1,12 +1,44 @@ # DpadRecyclerView -A RecyclerView built for Android TV as a replacement for Leanback's BaseGridView. +A RecyclerView built for Android TV as a replacement for [Leanback's](https://developer.android.com/jetpack/androidx/releases/leanback) BaseGridView. See the project website for more information: https://rubensousa.github.io/DpadRecyclerView -Motivation for this library: https://rubensousa.com/2022/11/08/dpadrecyclerview/ +Why should you use this library? + +1. Leanback hasn't received any significant update for years +2. Compose support for TV is still in its early stages +3. RecyclerView is stable and works well with Compose +4. You need to maintain an existing TV app and wish to introduce Compose in an incremental way +5. Contains useful Espresso testing helpers for your TV UI tests +6. More feature complete: + +| Feature | DpadRecyclerView | Leanback | Compose TV | +|----------------------------------|------------------|----------|------------| +| Custom scrolling speeds | ✅ | ✅ | ❌ | +| Edge alignment preference | ✅ | ✅ | ❌ | +| Sub position selections | ✅ | ✅ | ❌ | +| Fading edges | ✅ | ✅ | ❌ | +| Alignment listener | ✅ | ✅ | ❌ | +| Grids with uneven span sizes | ✅ | ❌ | ✅ | +| Extra layout space | ✅ | ❌ | ✅ | +| Prefetching upcoming items | ✅ | ❌ | ✅ | +| Reverse layout | ✅ | ❌ | ✅ | +| Testing library | ✅ | ❌ | ✅ | +| Drag and Drop | ✅ | ❌ | ❌ | +| Infinite layout with loop | ✅ | ❌ | ❌ | +| Smooth alignment changes | ✅ | ❌ | ❌ | +| Child focus observer | ✅ | ❌ | ❌ | +| Circular and continuous focus | ✅ | ❌ | ❌ | +| Throttling scroll events | ✅ | ❌ | ❌ | +| Scrolling without animation | ✅ | ❌ | ❌ | +| Scrolling in secondary directory | ❌ | ✅ | ❌ | + + +Background story for this library is available in my [blog](https://rubensousa.com/2022/11/08/dpadrecyclerview/) in case you're interested. + +Check the sample app for a complete example of integration of this library -Check the sample app for a complete example of integration of this library: ![sample](https://github.com/rubensousa/DpadRecyclerView/blob/master/assets/sample_cover.png?raw=true) @@ -26,23 +58,7 @@ androidTestImplementation "com.rubensousa.dpadrecyclerview:dpadrecyclerview-test Check the official website for more information and recipes: https://rubensousa.github.io/DpadRecyclerView -## New Features compared to Leanback's `BaseGridView` - -### Layout - -- Supports grids with different span sizes -- Supports infinite/endless scrolling -- Supports reverse layout -- XML attributes for easier configuration - -### Scrolling and focus - -- Supports changing the alignment configuration smoothly -- Supports limiting the number of pending alignments -- Supports non smooth scroll changes -- Supports continuous and circular grid focus - -### Easier Compose Integration +## Easier Compose Integration Documentation: https://rubensousa.github.io/DpadRecyclerView/compose/ diff --git a/docs/changelog.md b/docs/changelog.md index 55e4bc33..68dbb68e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,25 @@ ## Version 1.3.0 +### 1.3.0-beta01 + +2024-06-17 + +#### Dependency updates + +- Updated library to Kotlin 2.0 +- Updated Compose ui libraries to `1.7.0-beta03` + +#### New Features + +- Added `DpadDragHelper` for drag and drop support ([#216](https://github.com/rubensousa/DpadRecyclerView/pull/216)). Documentation available [here](recipes/dragdrop.md). +- Now `recyclerView.setFocusableDirection(FocusableDirection.CIRCULAR)` can also be used in linear layouts that don't fill the entire space. ([#225](https://github.com/rubensousa/DpadRecyclerView/pull/225) + +#### Improvements + +- Now `focusOutFront` and `focusOutBack` are enabled by default due to feedback from library users ([#223](https://github.com/rubensousa/DpadRecyclerView/pull/223)) +- Improved focus behavior for grids with uneven spans that have incomplete rows ([#224](https://github.com/rubensousa/DpadRecyclerView/pull/224)) + ### 1.3.0-alpha04 2024-06-04 diff --git a/docs/compose.md b/docs/compose.md index 974d66ca..c43c0de5 100644 --- a/docs/compose.md +++ b/docs/compose.md @@ -59,7 +59,7 @@ fun ItemComposable( .onFocusChanged { focusState -> isFocused = focusState.hasFocus } - .focusTarget() + .focusable() .dpadClickable { onClick() }, diff --git a/docs/getting_started.md b/docs/getting_started.md index eacf8627..efe404c0 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -90,7 +90,7 @@ fun ItemComposable( .onFocusChanged { focusState -> isFocused = focusState.hasFocus } - .focusTarget() + .focusable() .dpadClickable { onClick() }, diff --git a/docs/index.md b/docs/index.md index 35f4ee41..b788b410 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,27 +1,44 @@ # DpadRecyclerView -A RecyclerView built for Android TV as a replacement for [Leanback's](https://developer.android.com/jetpack/androidx/releases/leanback) BaseGridView. +A RecyclerView built for Android TV as a replacement +for [Leanback's](https://developer.android.com/jetpack/androidx/releases/leanback) BaseGridView. Proceed to [Getting started](getting_started.md) to start adding `DpadRecyclerView` to your application. -Motivation for this library is available in my [blog](https://rubensousa.com/2022/11/08/dpadrecyclerview/) in case you're interested. - -## New Features compared to Leanback's `BaseGridView` - -### Layout - -- Grids with different span sizes -- Infinite loop -- Reverse layout -- XML attributes for easier configuration - -### Scrolling and focus - -- Changing the alignment configuration smoothly -- Limiting the number of pending alignments -- Non smooth scroll changes -- Continuous and circular grid focus +Why should you use this library? + +1. Leanback hasn't received any significant update for years +2. Compose support for TV is still in its early stages +3. RecyclerView is stable and works well with Compose +4. You need to maintain an existing TV app and wish to introduce Compose in an incremental way +5. Contains useful Espresso testing helpers for your TV UI tests +6. More feature complete: + +| Feature | DpadRecyclerView | Leanback | Compose TV | +|-----------------------------------|------------------|----------|------------| +| Custom scrolling speeds | ✅ | ✅ | ❌ | +| Edge alignment preference | ✅ | ✅ | ❌ | +| Sub position selections | ✅ | ✅ | ❌ | +| Fading edges | ✅ | ✅ | ❌ | +| Alignment listener | ✅ | ✅ | ❌ | +| Grids with uneven span sizes | ✅ | ❌ | ✅ | +| Extra layout space | ✅ | ❌ | ✅ | +| Prefetching upcoming items | ✅ | ❌ | ✅ | +| Reverse layout | ✅ | ❌ | ✅ | +| Testing library | ✅ | ❌ | ✅ | +| Drag and Drop | ✅ | ❌ | ❌ | +| Infinite layout with loop | ✅ | ❌ | ❌ | +| Smooth alignment changes | ✅ | ❌ | ❌ | +| Discrete scrolling for text pages | ✅ | ❌ | ❌ | +| Child focus observer | ✅ | ❌ | ❌ | +| Circular and continuous focus | ✅ | ❌ | ❌ | +| Throttling scroll events | ✅ | ❌ | ❌ | +| Scrolling without animation | ✅ | ❌ | ❌ | +| Scrolling in secondary directory | ❌ | ✅ | ❌ | + + +Background story for this library is available in my [blog](https://rubensousa.com/2022/11/08/dpadrecyclerview/) in case you're interested. ## License diff --git a/docs/recipes/dragdrop.md b/docs/recipes/dragdrop.md new file mode 100644 index 00000000..4521c5e8 --- /dev/null +++ b/docs/recipes/dragdrop.md @@ -0,0 +1,74 @@ +# Drag and Drop Recipe + +Sometimes, users need to arrange the order of some collection. +The APIs mentioned here should assist you in developing such a feature. + +## Make your adapter mutable + +`DpadDragHelper` requires a `DpadDragHelper.DragAdapter` that exposes the mutable collection backing the adapter contents. +This allows `DpadDragHelper` to change the order of the elements for you automatically. + +You just need to implement `DpadDragHelper.DragAdapter` for this step: + + +```kotlin linenums="1" hl_lines="8" +class ExampleAdapter( + private val adapterConfig: AdapterConfig +) : RecyclerView.Adapter(), + DpadDragHelper.DragAdapter { + + private val items = ArrayList() + + override fun getMutableItems(): MutableList = items + +``` + +## Create a `DpadDragHelper` + +Now that you have a `DragAdapter` setup, just create a `DpadDragHelper` like so: + +```kotlin linenums="1" +private val adapter = ExampleAdapter() +private val dragHelper = DpadDragHelper( + adapter = dragAdapter, + callback = object : DpadDragHelper.DragCallback { + override fun onDragStarted(viewHolder: RecyclerView.ViewHolder) { + // ViewHolder is now being dragged + } + override fun onDragStopped() { + // Dragging was cancelled either by user or programmatically + } + } +) + +``` + +Then attach it to your `DpadRecyclerView`: + +```kotlin +dragHelper.attachToRecyclerView(dpadRecyclerView) +``` + +!!! note + This only supports drag and drop for linear and grid layouts with the same number of spans. + + +## Start and stop dragging + +Now that `DpadDragHelper` is setup, you can start dragging by using: + +```kotlin +dragHelper.startDrag(position = 0) +``` + +If the position passed in the method above is not currently selected, a selection will be triggered. + +To cancel dragging for any reason, use: + +```kotlin +dragHelper.stopDrag() +``` + +!!! note + Users can also stop dragging by pressing the following keys: `KeyEvent.KEYCODE_DPAD_CENTER`, + `KeyEvent.KEYCODE_ENTER`, `KeyEvent.KEYCODE_BACK`. These are customizable in the constructor of `DpadDragHelper` diff --git a/docs/recipes/scrolling.md b/docs/recipes/scrolling.md index 580f7d24..45bae324 100644 --- a/docs/recipes/scrolling.md +++ b/docs/recipes/scrolling.md @@ -70,12 +70,23 @@ 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 +## Enabling layout during scrolling events -If you want to postpone layout changes until the scroll state is idle, use this: +If you want to enable layout changes while `DpadRecyclerView` is still scrolling, use: ```kotlin -recyclerView.setLayoutWhileScrollingEnabled(false) +recyclerView.setLayoutWhileScrollingEnabled(true) ``` +## Long text scrolling +In some cases, you might need to show pages with text that should be scrollable (e.g terms & conditions). + +For this use case, you can use `DpadScroller`: + +```kotlin +val scroller = DpadScroller() +scroller.attach(dpadRecyclerView) +``` + +`DpadScroller` will scroll the page for you on key event presses without any alignment so that the user can read the entire content \ No newline at end of file diff --git a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt index bf13e836..eb89a9ce 100644 --- a/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt +++ b/dpadrecyclerview-compose/src/androidTest/java/com/rubensousa/dpadrecyclerview/compose/TestComposable.kt @@ -18,6 +18,7 @@ package com.rubensousa.dpadrecyclerview.compose import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -32,7 +33,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.SemanticsPropertyKey @@ -98,7 +98,7 @@ fun TestComposableFocus( .onFocusChanged { focusState -> isFocused = focusState.isFocused } - .focusTarget() + .focusable() .background(backgroundColor) .clickable { onClick() diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadDragHelper.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadDragHelper.kt index 3bb09ed3..18a9add5 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadDragHelper.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadDragHelper.kt @@ -96,7 +96,7 @@ class DpadDragHelper( } val recyclerView = currentRecyclerView ?: throw IllegalStateException( - "RecyclerView not attached. Please use attachRecyclerView before calling startDrag" + "RecyclerView not attached. Please use attachToRecyclerView before calling startDrag" ) val adapter = recyclerView.adapter ?: return false if (position < 0 || position >= adapter.itemCount) { diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt index e90c00f1..c994cc49 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt @@ -139,10 +139,12 @@ open class DpadRecyclerView @JvmOverloads constructor( ) layout.setFocusOutSideAllowed( throughFront = typedArray.getBoolean( - R.styleable.DpadRecyclerView_dpadRecyclerViewFocusOutSideFront, config.focusOutSideFront + R.styleable.DpadRecyclerView_dpadRecyclerViewFocusOutSideFront, + config.focusOutSideFront ), throughBack = typedArray.getBoolean( - R.styleable.DpadRecyclerView_dpadRecyclerViewFocusOutSideBack, config.focusOutSideBack + R.styleable.DpadRecyclerView_dpadRecyclerViewFocusOutSideBack, + config.focusOutSideBack ) ) layout.setFocusableDirection( @@ -848,7 +850,7 @@ open class DpadRecyclerView @JvmOverloads constructor( * @see [setReverseLayout] */ fun isLayoutReversed(): Boolean { - return requireLayout().getConfig().reverseLayout + return requireLayout().isLayoutReversed } /** diff --git a/gradle.properties b/gradle.properties index fd84374e..21387c72 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.3.0-alpha04 \ No newline at end of file +LIBRARY_VERSION=1.3.0-beta01 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 24a3adda..39113265 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,7 +24,7 @@ theme: extra: dpadrecyclerview: - version: '1.3.0-alpha04' + version: '1.3.0-beta01' social: - icon: 'fontawesome/brands/github' link: 'https://github.com/rubensousa/DpadRecyclerView' @@ -65,5 +65,6 @@ nav: - 'Alignment': recipes/alignment.md - 'Focus': recipes/focus.md - 'Scrolling': recipes/scrolling.md + - 'Drag and drop': recipes/dragdrop.md - 'Changelog': changelog.md - 'API': api/ \ No newline at end of file diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragButtonItem.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragButtonItem.kt index 38da6dcb..3e3183e1 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragButtonItem.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragButtonItem.kt @@ -17,6 +17,7 @@ package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -36,7 +37,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource @@ -73,7 +73,7 @@ fun DragButtonItem( .onFocusChanged { focusState -> isFocused = focusState.hasFocus } - .focusTarget() + .focusable() .dpadClickable { if (isDragging) { onStopDragClick() diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableGridItem.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableGridItem.kt index 1bd1dce2..62d83b3c 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableGridItem.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableGridItem.kt @@ -18,6 +18,7 @@ package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -33,7 +34,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -76,7 +76,7 @@ fun DraggableGridItem( .onFocusChanged { focusState -> isFocused = focusState.hasFocus } - .focusTarget() + .focusable() .dpadClickable { onClick() }, diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableItem.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableItem.kt index 467e326b..6a3cebf7 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableItem.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableItem.kt @@ -18,6 +18,7 @@ package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -33,7 +34,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview @@ -76,7 +76,7 @@ fun DraggableItem( .onFocusChanged { focusState -> isFocused = focusState.hasFocus } - .focusTarget() + .focusable() .dpadClickable { onClick() }, diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt index 3fef9290..5fb406fe 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/item/ItemComposable.kt @@ -21,6 +21,7 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -38,7 +39,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.SemanticsPropertyKey @@ -85,7 +85,7 @@ fun ItemComposable( .onFocusChanged { focusState -> isFocused = focusState.hasFocus } - .focusTarget() + .focusable() .dpadClickable { onClick() },