diff --git a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api index 7398182a..fb0c355b 100644 --- a/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api +++ b/dpadrecyclerview-compose/api/dpadrecyclerview-compose.api @@ -1,3 +1,17 @@ +public final class com/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeFocusViewHolderKt { + public static final field INSTANCE Lcom/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeFocusViewHolderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$dpadrecyclerview_compose_release ()Lkotlin/jvm/functions/Function3; +} + +public final class com/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeViewHolderKt { + public static final field INSTANCE Lcom/rubensousa/dpadrecyclerview/compose/ComposableSingletons$DpadComposeViewHolderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getLambda-1$dpadrecyclerview_compose_release ()Lkotlin/jvm/functions/Function4; +} + public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeExtensionsKt { public static final fun dpadClickable (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; } @@ -7,6 +21,7 @@ public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewH public fun (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;ZLkotlin/jvm/functions/Function3;)V public synthetic fun (Landroid/view/ViewGroup;Landroidx/compose/ui/platform/ViewCompositionStrategy;ZLkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getItem ()Ljava/lang/Object; + public final fun setContent (Lkotlin/jvm/functions/Function3;)V public final fun setFocusable (Z)V public final fun setItemState (Ljava/lang/Object;)V } @@ -16,6 +31,7 @@ public final class com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder public fun (Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function4;)V public synthetic fun (Landroid/view/ViewGroup;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLandroidx/compose/ui/platform/ViewCompositionStrategy;Lkotlin/jvm/functions/Function4;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getItem ()Ljava/lang/Object; + public final fun setContent (Lkotlin/jvm/functions/Function3;)V public final fun setItemState (Ljava/lang/Object;)V } diff --git a/dpadrecyclerview-compose/build.gradle b/dpadrecyclerview-compose/build.gradle index 7c18ebc0..139993c0 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) + alias(libs.plugins.kotlin.compose) id 'org.jetbrains.dokka' } @@ -22,16 +23,13 @@ android { buildTypes { debug { - testCoverageEnabled true + enableUnitTestCoverage true + enableAndroidTestCoverage true } release { } } - buildFeatures { - compose true - } - compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -41,10 +39,6 @@ android { jvmTarget = '1.8' } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() - } - publishing { singleVariant('release') { withSourcesJar() diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt index f16b46f6..cb477c9d 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeFocusViewHolder.kt @@ -47,7 +47,7 @@ class DpadComposeFocusViewHolder( parent: ViewGroup, compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, isFocusable: Boolean = true, - private val content: @Composable (item: T) -> Unit + private val content: @Composable (item: T) -> Unit = {} ) : RecyclerView.ViewHolder(ComposeView(parent.context)) { private val itemState = mutableStateOf(null) @@ -57,10 +57,14 @@ class DpadComposeFocusViewHolder( composeView.apply { this@DpadComposeFocusViewHolder.setFocusable(isFocusable) setViewCompositionStrategy(compositionStrategy) - setContent { - itemState.value?.let { item -> - content(item) - } + setContent(content) + } + } + + fun setContent(content: @Composable (item: T) -> Unit) { + composeView.setContent { + itemState.value?.let { item -> + content(item) } } } diff --git a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder.kt b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder.kt index 187264dc..464bfa11 100644 --- a/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder.kt +++ b/dpadrecyclerview-compose/src/main/java/com/rubensousa/dpadrecyclerview/compose/DpadComposeViewHolder.kt @@ -60,14 +60,14 @@ class DpadComposeViewHolder( onLongClick: ((item: T) -> Boolean)? = null, isFocusable: Boolean = true, compositionStrategy: ViewCompositionStrategy = RecyclerViewCompositionStrategy.DisposeOnRecycled, - private val content: @Composable (item: T, isFocused: Boolean) -> Unit + private val content: @Composable (item: T, isFocused: Boolean) -> Unit = { _, _ -> } ) : RecyclerView.ViewHolder(DpadComposeView(parent.context)) { private val focusState = mutableStateOf(false) private val itemState = mutableStateOf(null) + private val composeView = itemView as DpadComposeView init { - val composeView = itemView as DpadComposeView composeView.apply { setFocusConfiguration( isFocusable = isFocusable, @@ -96,6 +96,14 @@ class DpadComposeViewHolder( } } + fun setContent(content: @Composable (item: T) -> Unit) { + composeView.setContent { + itemState.value?.let { item -> + content(item) + } + } + } + fun setItemState(item: T?) { itemState.value = item } diff --git a/dpadrecyclerview-testing/build.gradle b/dpadrecyclerview-testing/build.gradle index 8405bb39..1700baa5 100644 --- a/dpadrecyclerview-testing/build.gradle +++ b/dpadrecyclerview-testing/build.gradle @@ -23,7 +23,8 @@ android { buildTypes { debug { - testCoverageEnabled true + enableUnitTestCoverage true + enableAndroidTestCoverage true } release { } diff --git a/dpadrecyclerview/api/dpadrecyclerview.api b/dpadrecyclerview/api/dpadrecyclerview.api index 0c0b06fe..5f00f602 100644 --- a/dpadrecyclerview/api/dpadrecyclerview.api +++ b/dpadrecyclerview/api/dpadrecyclerview.api @@ -30,6 +30,25 @@ public final class com/rubensousa/dpadrecyclerview/ChildAlignment$CREATOR : andr public synthetic fun newArray (I)[Ljava/lang/Object; } +public final class com/rubensousa/dpadrecyclerview/DpadDragHelper { + public fun (Lcom/rubensousa/dpadrecyclerview/DpadDragHelper$DragAdapter;Lcom/rubensousa/dpadrecyclerview/DpadDragHelper$DragCallback;Ljava/util/Set;)V + public synthetic fun (Lcom/rubensousa/dpadrecyclerview/DpadDragHelper$DragAdapter;Lcom/rubensousa/dpadrecyclerview/DpadDragHelper$DragCallback;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun attachToRecyclerView (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView;)V + public final fun detachFromRecyclerView ()V + public final fun isDragging ()Z + public final fun startDrag (I)Z + public final fun stopDrag ()V +} + +public abstract interface class com/rubensousa/dpadrecyclerview/DpadDragHelper$DragAdapter { + public abstract fun getMutableItems ()Ljava/util/List; +} + +public abstract interface class com/rubensousa/dpadrecyclerview/DpadDragHelper$DragCallback { + public abstract fun onDragStarted (Landroidx/recyclerview/widget/RecyclerView$ViewHolder;)V + public abstract fun onDragStopped ()V +} + public final class com/rubensousa/dpadrecyclerview/DpadLoopDirection : java/lang/Enum { public static final field MAX Lcom/rubensousa/dpadrecyclerview/DpadLoopDirection; public static final field MIN_MAX Lcom/rubensousa/dpadrecyclerview/DpadLoopDirection; @@ -401,7 +420,7 @@ public final class com/rubensousa/dpadrecyclerview/layoutmanager/DpadLayoutParam public final class com/rubensousa/dpadrecyclerview/layoutmanager/DpadLayoutParams$Companion { } -public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager : androidx/recyclerview/widget/RecyclerView$LayoutManager, androidx/recyclerview/widget/RecyclerView$SmoothScroller$ScrollVectorProvider { +public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager : androidx/recyclerview/widget/RecyclerView$LayoutManager, androidx/recyclerview/widget/ItemTouchHelper$ViewDropHandler, androidx/recyclerview/widget/RecyclerView$SmoothScroller$ScrollVectorProvider { public fun (Landroidx/recyclerview/widget/RecyclerView$LayoutManager$Properties;)V public final fun addOnLayoutCompletedListener (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView$OnLayoutCompletedListener;)V public final fun addOnViewFocusedListener (Lcom/rubensousa/dpadrecyclerview/OnViewFocusedListener;)V @@ -464,6 +483,7 @@ public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutMana public fun onRestoreInstanceState (Landroid/os/Parcelable;)V public fun onSaveInstanceState ()Landroid/os/Parcelable; public fun performAccessibilityAction (Landroidx/recyclerview/widget/RecyclerView$Recycler;Landroidx/recyclerview/widget/RecyclerView$State;ILandroid/os/Bundle;)Z + public fun prepareForDrop (Landroid/view/View;Landroid/view/View;II)V public final fun removeOnLayoutCompletedListener (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView$OnLayoutCompletedListener;)V public final fun removeOnViewFocusedListener (Lcom/rubensousa/dpadrecyclerview/OnViewFocusedListener;)V public final fun removeOnViewHolderSelectedListener (Lcom/rubensousa/dpadrecyclerview/OnViewHolderSelectedListener;)V diff --git a/dpadrecyclerview/build.gradle b/dpadrecyclerview/build.gradle index c25f8455..a6cb1026 100644 --- a/dpadrecyclerview/build.gradle +++ b/dpadrecyclerview/build.gradle @@ -22,7 +22,8 @@ android { buildTypes { debug { - testCoverageEnabled true + enableUnitTestCoverage true + enableAndroidTestCoverage true } release { diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManagerTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManagerTest.kt deleted file mode 100644 index fb1d7f5b..00000000 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManagerTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.rubensousa.dpadrecyclerview.layoutmanager - -import androidx.recyclerview.widget.RecyclerView.LayoutManager.Properties -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class PivotLayoutManagerTest { - - @Test - fun testDefaultSpanCountIsSetThroughConstructor() { - val properties = Properties() - properties.spanCount = 5 - val pivotLayoutManager = PivotLayoutManager(properties) - assertThat(pivotLayoutManager.getSpanCount()).isEqualTo(properties.spanCount) - } - -} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt index c019258b..709d2f31 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/RecyclerViewFragment.kt @@ -26,7 +26,11 @@ class RecyclerViewFragment : Fragment(R.layout.dpadrecyclerview_test_container) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - view.findViewById(R.id.recyclerView).requestFocus() + getRecyclerView().requestFocus() + } + + fun getRecyclerView(): DpadRecyclerView { + return requireView().findViewById(R.id.recyclerView) } } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestAdapter.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestAdapter.kt index 9d58900f..71dbab6a 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestAdapter.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/TestAdapter.kt @@ -76,4 +76,27 @@ class TestAdapter( } + fun assertContents(predicate: (index: Int) -> Int) { + for (i in 0 until itemCount) { + val item = getItem(i) + val expectedItem = predicate(i) + if (expectedItem != item) { + throw AssertionError("Expected item $expectedItem at position $i but got $item instead. Adapter contents: ${getAdapterContentString()}") + } + } + } + + private fun getAdapterContentString(): String { + val builder = StringBuilder() + builder.append("[") + for (i in 0 until itemCount) { + builder.append(getItem(i)) + if (i < itemCount - 1) { + builder.append(", ") + } + } + builder.append("]") + return builder.toString() + } + } diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/AbstractTestAdapter.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/AbstractTestAdapter.kt index 3350ca8a..263dc3be 100644 --- a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/AbstractTestAdapter.kt +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/AbstractTestAdapter.kt @@ -21,13 +21,14 @@ import android.os.Looper import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil.DiffResult import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadDragHelper import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration import java.util.Collections import java.util.concurrent.Executors abstract class AbstractTestAdapter( adapterConfiguration: TestAdapterConfiguration -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter(), DpadDragHelper.DragAdapter { companion object { private val EMPTY_LIST = ArrayList(0) @@ -43,6 +44,8 @@ abstract class AbstractTestAdapter( private var currentVersion = 0 private var id = items.size + override fun getMutableItems(): MutableList = items + fun submitList(newList: MutableList, commitCallback: Runnable? = null) { val version = ++currentVersion if (items === newList) { diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/PivotLayoutManagerTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/PivotLayoutManagerTest.kt new file mode 100644 index 00000000..5a283aad --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/PivotLayoutManagerTest.kt @@ -0,0 +1,44 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.test.tests + +import androidx.recyclerview.widget.RecyclerView.LayoutManager.Properties +import com.google.common.truth.Truth.assertThat +import com.rubensousa.dpadrecyclerview.layoutmanager.PivotLayoutManager +import org.junit.Test + +class PivotLayoutManagerTest { + + @Test + fun testDefaultSpanCountIsSetThroughConstructor() { + val properties = Properties() + properties.spanCount = 5 + val pivotLayoutManager = PivotLayoutManager(properties) + assertThat(pivotLayoutManager.getSpanCount()).isEqualTo(properties.spanCount) + } + + @Test + fun testDefaultFocusOutIsEnabledForAllProperties() { + val pivotLayoutManager = PivotLayoutManager(Properties()) + val config = pivotLayoutManager.getConfig() + assertThat(config.focusOutFront).isTrue() + assertThat(config.focusOutBack).isTrue() + assertThat(config.focusOutSideFront).isTrue() + assertThat(config.focusOutSideBack).isTrue() + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperGridTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperGridTest.kt new file mode 100644 index 00000000..7651e4bb --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperGridTest.kt @@ -0,0 +1,235 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.test.tests.drag + +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import androidx.test.platform.app.InstrumentationRegistry +import com.rubensousa.dpadrecyclerview.DpadDragHelper +import com.rubensousa.dpadrecyclerview.spacing.DpadGridSpacingDecoration +import com.rubensousa.dpadrecyclerview.test.RecyclerViewFragment +import com.rubensousa.dpadrecyclerview.test.TestAdapter +import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView +import com.rubensousa.dpadrecyclerview.test.helpers.waitForCondition +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.R +import org.junit.Before +import org.junit.Test + +class DragHelperGridTest { + + + private lateinit var fragmentScenario: FragmentScenario + private lateinit var dragHelper: DpadDragHelper + private lateinit var testAdapter: TestAdapter + private val numberOfItems = 50 + private val spanCount = 5 + private val dragStarted = mutableListOf() + private var dragStopCount = 0 + + @Before + fun setup() { + fragmentScenario = launchFragment() + onRecyclerView("Setup RecyclerView ") { recyclerView -> + testAdapter = TestAdapter( + adapterConfiguration = TestAdapterConfiguration( + itemLayoutId = R.layout.dpadrecyclerview_test_item_grid, + numberOfItems = numberOfItems + ), + onViewHolderSelected = { + + }, + onViewHolderDeselected = { + + } + ) + recyclerView.apply { + adapter = testAdapter + setSpanCount(spanCount) + addItemDecoration( + DpadGridSpacingDecoration.create( + itemSpacing = resources.getDimensionPixelSize( + com.rubensousa.dpadrecyclerview.test.R.dimen.dpadrecyclerview_grid_spacing + ) + ) + ) + } + dragHelper = DpadDragHelper( + adapter = testAdapter, + callback = object : DpadDragHelper.DragCallback { + override fun onDragStarted(viewHolder: RecyclerView.ViewHolder) { + dragStarted.add(viewHolder) + } + + override fun onDragStopped() { + dragStopCount++ + } + } + ) + dragHelper.attachToRecyclerView(recyclerView) + } + } + + @Test + fun testDragStartRowToEndRow() { + // given + val endRowPosition = spanCount - 1 + startDragging(position = 0) + + // when + KeyEvents.pressRight(times = spanCount) + + // then + assertFocusAndSelection(position = endRowPosition) + testAdapter.assertContents { index -> + when { + index < spanCount - 1 -> index + 1 + index == spanCount - 1 -> 0 + else -> index + } + } + } + + @Test + fun testDragEndRowToStartRow() { + // given + val endRowPosition = spanCount - 1 + startDragging(position = endRowPosition) + + // when + KeyEvents.pressLeft(times = spanCount) + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> + when { + index == 0 -> endRowPosition + index < spanCount -> index - 1 + else -> index + } + } + } + + @Test + fun testDragColumnTopToColumnBottom() { + // given + val topColumnPosition = spanCount - 1 + val bottomColumnPosition = topColumnPosition + spanCount + startDragging(position = topColumnPosition) + + // when + KeyEvents.pressDown(times = 1) + + // then + assertFocusAndSelection(position = bottomColumnPosition) + testAdapter.assertContents { index -> + when { + index < topColumnPosition -> index + index < bottomColumnPosition -> index + 1 + index == bottomColumnPosition -> topColumnPosition + else -> index + } + } + } + + @Test + fun testDragColumnBottomToColumnTop() { + // given + val topColumnPosition = spanCount - 1 + val bottomColumnPosition = topColumnPosition + spanCount + startDragging(position = bottomColumnPosition) + + // when + KeyEvents.pressUp(times = 1) + + // then + assertFocusAndSelection(position = topColumnPosition) + testAdapter.assertContents { index -> + when { + index < topColumnPosition -> index + index == topColumnPosition -> bottomColumnPosition + index <= bottomColumnPosition -> index - 1 + else -> index + } + } + } + + @Test + fun testDragStartToEnd() { + // given + startDragging(position = 0) + + // when + KeyEvents.pressDown(times = numberOfItems / spanCount) + KeyEvents.pressRight(times = spanCount) + + // then + assertFocusAndSelection(position = numberOfItems - 1) + testAdapter.assertContents { index -> + when { + index < numberOfItems - 1 -> index + 1 + else -> 0 + } + } + } + + @Test + fun testDragEndToStart() { + // given + startDragging(position = numberOfItems - 1) + + // when + KeyEvents.pressUp(times = numberOfItems / spanCount) + KeyEvents.pressLeft(times = spanCount) + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> + when { + index == 0 -> numberOfItems - 1 + else -> index - 1 + } + } + } + + private fun startDragging(position: Int) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.startDrag(position) + } + } + + private fun stopDragging() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.stopDrag() + } + } + + private fun launchFragment(): FragmentScenario { + return launchFragmentInContainer( + themeResId = R.style.DpadRecyclerViewTestTheme + ).also { + fragmentScenario = it + waitForCondition("Waiting for layout pass") { recyclerView -> + !recyclerView.isLayoutRequested + } + } + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperLinearTest.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperLinearTest.kt new file mode 100644 index 00000000..1ef42569 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperLinearTest.kt @@ -0,0 +1,313 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.test.tests.drag + +import android.view.KeyEvent +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import com.rubensousa.dpadrecyclerview.DpadDragHelper +import com.rubensousa.dpadrecyclerview.spacing.DpadLinearSpacingDecoration +import com.rubensousa.dpadrecyclerview.test.RecyclerViewFragment +import com.rubensousa.dpadrecyclerview.test.TestAdapter +import com.rubensousa.dpadrecyclerview.test.TestAdapterConfiguration +import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection +import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView +import com.rubensousa.dpadrecyclerview.test.helpers.waitForCondition +import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState +import com.rubensousa.dpadrecyclerview.testing.KeyEvents +import com.rubensousa.dpadrecyclerview.testing.R +import org.junit.Before +import org.junit.Test + +class DragHelperLinearTest { + + private lateinit var fragmentScenario: FragmentScenario + private lateinit var dragHelper: DpadDragHelper + private lateinit var testAdapter: TestAdapter + private val numberOfItems = 10 + private val dragStarted = mutableListOf() + private var dragStopCount = 0 + + @Before + fun setup() { + fragmentScenario = launchFragment() + onRecyclerView("Setup RecyclerView ") { recyclerView -> + testAdapter = TestAdapter( + adapterConfiguration = TestAdapterConfiguration( + itemLayoutId = R.layout.dpadrecyclerview_test_item_horizontal, + numberOfItems = numberOfItems + ), + onViewHolderSelected = { + + }, + onViewHolderDeselected = { + + } + ) + recyclerView.apply { + adapter = testAdapter + setOrientation(RecyclerView.HORIZONTAL) + addItemDecoration( + DpadLinearSpacingDecoration.create( + itemSpacing = resources.getDimensionPixelSize( + com.rubensousa.dpadrecyclerview.test.R.dimen.dpadrecyclerview_grid_spacing + ) + ) + ) + } + dragHelper = DpadDragHelper( + adapter = testAdapter, + callback = object : DpadDragHelper.DragCallback { + override fun onDragStarted(viewHolder: RecyclerView.ViewHolder) { + dragStarted.add(viewHolder) + } + + override fun onDragStopped() { + dragStopCount++ + } + } + ) + dragHelper.attachToRecyclerView(recyclerView) + } + } + + @Test + fun testDetachRecyclerViewStopsDragging() { + // given + startDragging(position = 0) + + // when + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.detachFromRecyclerView() + } + + // then + assertThat(dragStopCount).isEqualTo(1) + } + + @Test + fun testDragStartCallback() { + // given + val position = 0 + + // when + startDragging(position) + + // then + val viewHolder = dragStarted.first() + assertThat(viewHolder.absoluteAdapterPosition).isEqualTo(position) + } + + @Test + fun testDragStartsInAnyPosition() { + // given + val position = numberOfItems - 1 + + // when + startDragging(position) + + // then + assertFocusAndSelection(position) + val viewHolder = dragStarted.first() + assertThat(viewHolder.absoluteAdapterPosition).isEqualTo(position) + } + + @Test + fun testDragIsCanceledOnCertainKeyEvents() { + // given + val cancelKeyCodes = setOf( + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_BACK, + ) + + // when + cancelKeyCodes.forEach { keyCode -> + startDragging(position = 0) + KeyEvents.pressKey(keyCode) + Espresso.onIdle() + } + + // then + assertThat(dragStopCount).isEqualTo(cancelKeyCodes.size) + } + + @Test + fun testDragStopRestoresFocusScrolling() { + // given + startDragging(position = 0) + + // when + stopDragging() + KeyEvents.pressRight() + waitForIdleScrollState() + + // then + assertFocusAndSelection(position = 1) + } + + @Test + fun testIsDraggingIsTrueAfterStartDragging() { + // when + startDragging(position = 0) + + // then + assertThat(dragHelper.isDragging).isTrue() + } + + @Test + fun testIsDraggingIsFalseAfterStop() { + // given + startDragging(position = 0) + + // when + stopDragging() + + // then + assertThat(dragHelper.isDragging).isFalse() + } + + @Test + fun testDragMoveForward() { + // given + startDragging(position = 0) + + // when + KeyEvents.pressRight() + + // then + assertFocusAndSelection(position = 1) + testAdapter.assertContents { index -> + when (index) { + 0 -> 1 + 1 -> 0 + else -> index + } + } + } + + @Test + fun testDragMoveBackward() { + // given + startDragging(position = 1) + + // when + KeyEvents.pressLeft() + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> + when (index) { + 0 -> 1 + 1 -> 0 + else -> index + } + } + } + + @Test + fun testDragStartEdgeDoesNothing() { + // given + startDragging(position = 0) + + // when + KeyEvents.pressLeft() + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> index } + } + + @Test + fun testDragEndEdgeDoesNothing() { + // given + startDragging(position = numberOfItems - 1) + + // when + KeyEvents.pressRight() + + // then + assertFocusAndSelection(numberOfItems - 1) + testAdapter.assertContents { index -> index } + } + + @Test + fun testDragFromStartToEnd() { + // given + startDragging(position = 0) + + // when + KeyEvents.pressRight(times = numberOfItems) + waitForIdleScrollState() + + // then + assertFocusAndSelection(position = numberOfItems - 1) + testAdapter.assertContents { index -> + when (index) { + numberOfItems - 1 -> 0 + else -> index + 1 + } + } + } + + @Test + fun testDragFromEndToStart() { + // given + startDragging(position = numberOfItems - 1) + + // when + KeyEvents.pressLeft(times = numberOfItems) + waitForIdleScrollState() + + // then + assertFocusAndSelection(position = 0) + testAdapter.assertContents { index -> + when (index) { + 0 -> numberOfItems - 1 + else -> index - 1 + } + } + } + + private fun startDragging(position: Int) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.startDrag(position) + } + } + + private fun stopDragging() { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + dragHelper.stopDrag() + } + } + + private fun launchFragment(): FragmentScenario { + return launchFragmentInContainer( + themeResId = R.style.DpadRecyclerViewTestTheme + ).also { + fragmentScenario = it + waitForCondition("Waiting for layout pass") { recyclerView -> + !recyclerView.isLayoutRequested + } + } + } + +} diff --git a/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperTestFragment.kt b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperTestFragment.kt new file mode 100644 index 00000000..e96430d2 --- /dev/null +++ b/dpadrecyclerview/src/androidTest/kotlin/com/rubensousa/dpadrecyclerview/test/tests/drag/DragHelperTestFragment.kt @@ -0,0 +1,40 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.test.tests.drag + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import com.rubensousa.dpadrecyclerview.DpadRecyclerView +import com.rubensousa.dpadrecyclerview.testing.R + +class DragHelperTestFragment : Fragment(R.layout.dpadrecyclerview_test_container) { + + var recyclerView: DpadRecyclerView? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + recyclerView = view.findViewById(R.id.recyclerView) + recyclerView?.requestFocus() + } + + override fun onDestroyView() { + super.onDestroyView() + recyclerView = null + } + +} diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadDragHelper.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadDragHelper.kt new file mode 100644 index 00000000..3bb09ed3 --- /dev/null +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadDragHelper.kt @@ -0,0 +1,254 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview + +import android.view.KeyEvent +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import java.util.Collections + +/** + * A helper class for re-ordering the contents of a [DpadRecyclerView]. + * + * To use this, your adapter needs to implement [DpadDragHelper.DragAdapter] + * and expose the mutable collection via [DpadDragHelper.DragAdapter.getMutableItems]. + */ +class DpadDragHelper( + private val adapter: DragAdapter, + private val callback: DragCallback, + private val cancelKeyCodes: Set = setOf( + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_BACK + ) +) { + + /** + * True if the attached [DpadRecyclerView] is currently in drag mode, false otherwise + */ + var isDragging: Boolean = false + private set + + private var currentRecyclerView: DpadRecyclerView? = null + private var previousKeyInterceptListener: DpadRecyclerView.OnKeyInterceptListener? = null + private var keyInterceptListener = object : DpadRecyclerView.OnKeyInterceptListener { + override fun onInterceptKeyEvent(event: KeyEvent): Boolean { + if (!isDragging) { + return false + } + if (cancelKeyCodes.contains(event.keyCode)) { + stopDrag() + return true + } + if (event.action == KeyEvent.ACTION_UP) { + return false + } + return onKeyEvent(event) + } + } + + /** + * Attaches the [DpadRecyclerView] that will be dragged. + * This is required before calling [startDrag] + */ + fun attachToRecyclerView(recyclerView: DpadRecyclerView) { + if (currentRecyclerView === recyclerView) { + return + } + detachFromRecyclerView() + currentRecyclerView = recyclerView + } + + /** + * Detaches the previously attached [DpadRecyclerView] and stops dragging + */ + fun detachFromRecyclerView() { + stopDrag() + currentRecyclerView = null + } + + + /** + * Starts the dragging action for the ViewHolder at [position]. + * + * [DragCallback.onDragStarted] will be called after this method if this returns true + * + * @param position the position of the item to be dragged + * @return true if the dragging action was started, false otherwise + */ + fun startDrag(position: Int): Boolean { + if (isDragging) { + return true + } + val recyclerView = currentRecyclerView + ?: throw IllegalStateException( + "RecyclerView not attached. Please use attachRecyclerView before calling startDrag" + ) + val adapter = recyclerView.adapter ?: return false + if (position < 0 || position >= adapter.itemCount) { + return false + } + if (recyclerView.getSelectedPosition() != position) { + recyclerView.setSelectedPosition(position, object : ViewHolderTask() { + override fun execute(viewHolder: RecyclerView.ViewHolder) { + startDrag(recyclerView, viewHolder) + } + }) + return true + } else { + recyclerView.findViewHolderForAdapterPosition(position)?.let { + startDrag(recyclerView, it) + return true + } + } + return false + } + + private fun startDrag( + recyclerView: DpadRecyclerView, + viewHolder: RecyclerView.ViewHolder + ) { + isDragging = true + previousKeyInterceptListener = recyclerView.getOnKeyInterceptListener() + recyclerView.setOnKeyInterceptListener(keyInterceptListener) + recyclerView.isFocusable = true + recyclerView.isFocusableInTouchMode = true + recyclerView.requestFocus() + callback.onDragStarted(viewHolder) + } + + /** + * Cancels the current ongoing dragging action + * [DragCallback.onDragStopped] will be called after this method + */ + fun stopDrag() { + if (!isDragging) { + return + } + currentRecyclerView?.let { recyclerView -> + previousKeyInterceptListener?.let { listener -> + recyclerView.setOnKeyInterceptListener(listener) + } + } + isDragging = false + callback.onDragStopped() + } + + private fun onKeyEvent(event: KeyEvent): Boolean { + val recyclerView = currentRecyclerView ?: return false + val direction = getFocusDirection(event) ?: return false + val view = recyclerView.focusSearch(direction) ?: return false + val targetViewHolder = recyclerView.findContainingViewHolder(view) ?: return false + val selectedViewHolder = recyclerView.findViewHolderForAdapterPosition( + recyclerView.getSelectedPosition() + ) ?: return false + + if (selectedViewHolder.absoluteAdapterPosition == RecyclerView.NO_POSITION + || targetViewHolder.absoluteAdapterPosition == RecyclerView.NO_POSITION + ) { + return false + } + val currentAdapter = recyclerView.adapter ?: return false + if (recyclerView.getSpanCount() == 1) { + moveLinear( + adapter = currentAdapter, + items = adapter.getMutableItems(), + srcIndex = selectedViewHolder.bindingAdapterPosition, + targetIndex = targetViewHolder.bindingAdapterPosition + ) + } else { + moveGrid( + adapter = currentAdapter, + items = adapter.getMutableItems(), + srcIndex = selectedViewHolder.bindingAdapterPosition, + targetIndex = targetViewHolder.bindingAdapterPosition + ) + } + return true + } + + private fun getFocusDirection(event: KeyEvent): Int? { + return when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> View.FOCUS_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> View.FOCUS_RIGHT + KeyEvent.KEYCODE_DPAD_UP -> View.FOCUS_UP + KeyEvent.KEYCODE_DPAD_DOWN -> View.FOCUS_DOWN + else -> null + } + } + + /** + * A linear move just swaps positions + */ + private fun moveLinear( + adapter: RecyclerView.Adapter<*>, + items: MutableList, + srcIndex: Int, + targetIndex: Int + ) { + Collections.swap(items, srcIndex, targetIndex) + adapter.notifyItemMoved(srcIndex, targetIndex) + } + + /** + * A grid move needs to remove the element at the given position + * and insert it in the new position, otherwise order is not kept + */ + private fun moveGrid( + adapter: RecyclerView.Adapter<*>, + items: MutableList, + srcIndex: Int, + targetIndex: Int + ) { + val item = items.removeAt(srcIndex) + items.add(targetIndex, item) + adapter.notifyItemMoved(srcIndex, targetIndex) + /** + * Now notify the range that was affected + * If src < target -> notify all indexes from src until target + * If src > target -> notify all indexes from target until src + */ + if (srcIndex < targetIndex) { + adapter.notifyItemRangeChanged(srcIndex, targetIndex - srcIndex) + } else { + adapter.notifyItemRangeChanged(targetIndex, srcIndex - targetIndex) + } + } + + interface DragAdapter { + /** + * @return the mutable collection of items backing the adapter + */ + fun getMutableItems(): MutableList + } + + interface DragCallback { + + /** + * Indicates that the dragging action has started. [DpadRecyclerView] will receive focus + * + * @param viewHolder the view holder that is being dragged + */ + fun onDragStarted(viewHolder: RecyclerView.ViewHolder) + + /** + * Indicates that the dragging action has stopped + */ + fun onDragStopped() + } + +} diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt index 11a68111..e90c00f1 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/DpadRecyclerView.kt @@ -341,7 +341,7 @@ open class DpadRecyclerView @JvmOverloads constructor( final override fun focusSearch(direction: Int): View? { val currentLayout = pivotLayoutManager - if (isFocused && currentLayout != null) { + if (currentLayout != null) { val view = currentLayout.findViewByPosition(currentLayout.getSelectedPosition()) return if (view != null) { focusSearch(view, direction) diff --git a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt index 64acfa57..f9d5004f 100644 --- a/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt +++ b/dpadrecyclerview/src/main/java/com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager.kt @@ -25,6 +25,7 @@ import android.util.AttributeSet import android.view.View import android.view.ViewGroup import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.rubensousa.dpadrecyclerview.ChildAlignment import com.rubensousa.dpadrecyclerview.DpadLoopDirection @@ -51,7 +52,7 @@ import com.rubensousa.dpadrecyclerview.layoutmanager.scroll.LayoutScroller * It behaves similarly to `GridLayoutManager` with the main difference being how focus is handled. */ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager(), - RecyclerView.SmoothScroller.ScrollVectorProvider { + ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider { private var layoutDirection: Int = View.LAYOUT_DIRECTION_LTR private val configuration = LayoutConfiguration(properties) @@ -127,6 +128,13 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager(), override fun supportsPredictiveItemAnimations(): Boolean = !layoutInfo.isLoopingAllowed + override fun prepareForDrop(view: View, target: View, x: Int, y: Int) { + val targetPos = getPosition(target) + if (targetPos != RecyclerView.NO_POSITION) { + scroller.scrollToPosition(targetPos, 0) + } + } + override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { // If we have focus, save it temporarily since the views will change and we might lose it hadFocusBeforeLayout = hasFocus() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 14b27971..2671565c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,17 @@ [versions] -android-gradle-plugin = "8.3.0" -androidx-appcompat = "1.6.1" +android-gradle-plugin = "8.4.2" +androidx-appcompat = "1.7.0" androidx-collection = "1.4.0" -androidx-concurrent-futures = "1.1.0" +androidx-concurrent-futures = "1.2.0" androidx-compose-compiler = "1.5.10" androidx-compose-material3 = '1.2.1' -androidx-compose-ui = "1.7.0-alpha04" +androidx-compose-ui = "1.7.0-beta03" androidx-constraintlayout = "2.1.4" androidx-customview = "1.1.0" androidx-fragment = "1.7.1" androidx-interpolator = "1.0.0" androidx-leanback = "1.0.0-alpha03" -androidx-lifecycle = "2.7.0" +androidx-lifecycle = "2.8.1" androidx-navigation = "2.7.7" androidx-paging = "3.2.1" androidx-poolingcontainer = "1.0.0" @@ -29,9 +29,8 @@ androidx-test-uiautomator = '2.3.0' guava = "33.1.0-android" timber = "5.0.1" - decorator = "2.1.0" -kotlin = "1.9.22" +kotlin = "2.0.0" kover = "0.7.6" jacoco = "0.8.11" test-junit = '4.13.2' @@ -43,6 +42,7 @@ android-application = { id = "com.android.application", version.ref = "android-g android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } androidx-navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "androidx-navigation" } jacoco = { id = "org.jacoco.core", version.ref = "jacoco" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } @@ -64,6 +64,7 @@ androidx-fragment-testing-manifest = { module = "androidx.fragment:fragment-test androidx-interpolator = { module = "androidx.interpolator:interpolator", version.ref = "androidx-interpolator" } androidx-leanback-grid = { module = "androidx.leanback:leanback-grid", version.ref = "androidx-leanback" } androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "androidx-navigation" } androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "androidx-navigation" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1a465415..d5ff6337 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Mar 12 23:29:23 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/sample/build.gradle b/sample/build.gradle index 3beb74ba..71c21b68 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.androidx.navigation.safeargs) + alias(libs.plugins.kotlin.compose) } android { @@ -37,13 +38,7 @@ android { } buildFeatures { viewBinding true - compose true } - - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() - } - } dependencies { @@ -63,6 +58,7 @@ dependencies { implementation libs.androidx.navigation.ui implementation libs.androidx.compose.material3 implementation libs.androidx.compose.ui.tooling.preview + implementation libs.androidx.lifecycle.runtime.compose debugImplementation libs.androidx.compose.ui.tooling diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAdapter.kt new file mode 100644 index 00000000..a58f4023 --- /dev/null +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAdapter.kt @@ -0,0 +1,89 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag + +import android.view.ViewGroup +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadDragHelper +import com.rubensousa.dpadrecyclerview.compose.DpadComposeFocusViewHolder +import com.rubensousa.dpadrecyclerview.sample.ui.model.ListTypes +import com.rubensousa.dpadrecyclerview.sample.ui.widgets.common.MutableListAdapter +import com.rubensousa.dpadrecyclerview.sample.ui.widgets.item.MutableGridAdapter +import kotlinx.coroutines.flow.StateFlow + +class DragAdapter( + private val dragState: StateFlow, + private val onDragStart: (viewHolder: RecyclerView.ViewHolder) -> Unit, + private val gridLayout: Boolean = false +) : MutableListAdapter>(MutableGridAdapter.DIFF_CALLBACK), + DpadDragHelper.DragAdapter { + + override fun getMutableItems(): MutableList = items + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): DpadComposeFocusViewHolder { + val viewHolder = DpadComposeFocusViewHolder(parent) + viewHolder.setContent { item -> + if (gridLayout) { + DraggableGridItem( + item = item, + isDragging = dragState.collectAsStateWithLifecycle().value == item, + onClick = { + onDragStart(viewHolder) + } + ) + } else { + DraggableItem( + item = item, + isDragging = dragState.collectAsStateWithLifecycle().value == item, + onClick = { + onDragStart(viewHolder) + } + ) + } + } + return viewHolder + } + + override fun onBindViewHolder(holder: DpadComposeFocusViewHolder, position: Int) { + val item = getItem(position) + holder.setItemState(item) + holder.itemView.contentDescription = item.toString() + } + + + override fun getItemViewType(position: Int): Int { + return ListTypes.ITEM + } + + override fun toString(): String { + val builder = StringBuilder() + builder.append("[") + for (i in 0 until itemCount) { + builder.append(getItem(i)) + if (i < itemCount - 1) { + builder.append(", ") + } + } + builder.append("]") + return builder.toString() + } + +} \ No newline at end of file diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAndDropFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAndDropFragment.kt new file mode 100644 index 00000000..aa784dbe --- /dev/null +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAndDropFragment.kt @@ -0,0 +1,112 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag + +import android.os.Bundle +import android.view.View +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadDragHelper +import com.rubensousa.dpadrecyclerview.sample.R +import com.rubensousa.dpadrecyclerview.sample.databinding.ScreenDragDropBinding +import com.rubensousa.dpadrecyclerview.sample.ui.dpToPx +import com.rubensousa.dpadrecyclerview.sample.ui.viewBinding +import com.rubensousa.dpadrecyclerview.spacing.DpadLinearSpacingDecoration +import kotlinx.coroutines.flow.MutableStateFlow + +class DragAndDropFragment : Fragment(R.layout.screen_drag_drop) { + + private val binding by viewBinding(ScreenDragDropBinding::bind) + private val dragState = MutableStateFlow(null) + private val dragAdapter = DragAdapter( + dragState = dragState, + onDragStart = { viewHolder -> + startDrag(viewHolder) + } + ) + private val dragHelper = DpadDragHelper( + adapter = dragAdapter, + callback = object : DpadDragHelper.DragCallback { + override fun onDragStarted(viewHolder: RecyclerView.ViewHolder) { + dragState.value = dragAdapter.getItem(viewHolder.bindingAdapterPosition) + } + override fun onDragStopped() { + dragState.value = null + } + } + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupRecyclerView() + setDragButtonContent() + dragAdapter.submitList(List(20) { it }.toMutableList()) + } + + private fun setupRecyclerView() { + binding.recyclerView.apply { + adapter = dragAdapter + dragHelper.attachToRecyclerView(this) + itemAnimator = DefaultItemAnimator().apply { + // For faster moves + moveDuration = 100 + } + setOrientation(RecyclerView.HORIZONTAL) + addItemDecoration( + DpadLinearSpacingDecoration.create( + itemSpacing = dpToPx(16.dp), + edgeSpacing = dpToPx(48.dp), + perpendicularEdgeSpacing = dpToPx(48.dp) + ) + ) + } + } + + private fun setDragButtonContent() { + binding.dragButton.setContent { + val focusRequester = remember { FocusRequester() } + DragButtonItem( + isDragging = dragState.collectAsStateWithLifecycle().value != null, + onStartDragClick = { startDrag() }, + onStopDragClick = { stopDrag() }, + focusRequester = focusRequester + ) + LaunchedEffect(key1 = Unit) { + focusRequester.requestFocus() + } + } + } + + private fun startDrag(viewHolder: RecyclerView.ViewHolder) { + dragHelper.startDrag(viewHolder.absoluteAdapterPosition) + } + + private fun startDrag() { + dragHelper.startDrag(position = binding.recyclerView.getSelectedPosition()) + } + + private fun stopDrag() { + dragHelper.stopDrag() + } + +} diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAndDropGridFragment.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAndDropGridFragment.kt new file mode 100644 index 00000000..c57ea9c7 --- /dev/null +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragAndDropGridFragment.kt @@ -0,0 +1,111 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag + +import android.os.Bundle +import android.view.View +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView +import com.rubensousa.dpadrecyclerview.DpadDragHelper +import com.rubensousa.dpadrecyclerview.sample.R +import com.rubensousa.dpadrecyclerview.sample.databinding.ScreenDragDropBinding +import com.rubensousa.dpadrecyclerview.sample.ui.dpToPx +import com.rubensousa.dpadrecyclerview.sample.ui.viewBinding +import com.rubensousa.dpadrecyclerview.spacing.DpadGridSpacingDecoration +import kotlinx.coroutines.flow.MutableStateFlow + +class DragAndDropGridFragment : Fragment(R.layout.screen_drag_drop_grid) { + + private val binding by viewBinding(ScreenDragDropBinding::bind) + private val dragState = MutableStateFlow(null) + private val dragAdapter = DragAdapter( + dragState = dragState, + onDragStart = { viewHolder -> + startDrag(viewHolder) + }, + gridLayout = true + ) + private val dragHelper = DpadDragHelper( + adapter = dragAdapter, + callback = object : DpadDragHelper.DragCallback { + override fun onDragStarted(viewHolder: RecyclerView.ViewHolder) { + dragState.value = dragAdapter.getItem(viewHolder.bindingAdapterPosition) + } + override fun onDragStopped() { + dragState.value = null + } + } + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupRecyclerView() + setDragButtonContent() + dragAdapter.submitList(List(15) { it }.toMutableList()) + } + + private fun setupRecyclerView() { + binding.recyclerView.apply { + adapter = dragAdapter + dragHelper.attachToRecyclerView(this) + itemAnimator = DefaultItemAnimator().apply { + // For faster moves + moveDuration = 100 + } + addItemDecoration( + DpadGridSpacingDecoration.create( + itemSpacing = dpToPx(16.dp), + edgeSpacing = dpToPx(48.dp), + ) + ) + } + } + + private fun setDragButtonContent() { + binding.dragButton.setContent { + val focusRequester = remember { FocusRequester() } + DragButtonItem( + isDragging = dragState.collectAsStateWithLifecycle().value != null, + onStartDragClick = { startDrag() }, + onStopDragClick = { stopDrag() }, + focusRequester = focusRequester + ) + LaunchedEffect(key1 = Unit) { + focusRequester.requestFocus() + } + } + } + + private fun startDrag(viewHolder: RecyclerView.ViewHolder) { + dragHelper.startDrag(viewHolder.absoluteAdapterPosition) + } + + private fun startDrag() { + dragHelper.startDrag(position = binding.recyclerView.getSelectedPosition()) + } + + private fun stopDrag() { + dragHelper.stopDrag() + } + +} 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 new file mode 100644 index 00000000..38da6dcb --- /dev/null +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DragButtonItem.kt @@ -0,0 +1,134 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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 +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.rubensousa.dpadrecyclerview.compose.dpadClickable +import com.rubensousa.dpadrecyclerview.sample.R + +@Composable +fun DragButtonItem( + isDragging: Boolean, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, + onStartDragClick: () -> Unit, + onStopDragClick: () -> Unit, +) { + var isFocused by remember { mutableStateOf(false) } + val backgroundColor = if (isFocused) { + Color.White + } else { + Color.Black + } + val textColor = if (isFocused) { + Color.Black + } else { + Color.White + } + Row( + modifier = modifier + .height(100.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused = focusState.hasFocus + } + .focusTarget() + .dpadClickable { + if (isDragging) { + onStopDragClick() + } else { + onStartDragClick() + } + } + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(64.dp), + painter = if (!isDragging) { + painterResource(id = R.drawable.ic_drag_handle) + } else { + painterResource(id = R.drawable.ic_done) + }, + contentDescription = null, + tint = textColor + ) + Text( + style = MaterialTheme.typography.titleLarge, + text = if (isDragging) { + "Stop dragging" + } else { + "Start dragging" + }, + color = textColor + ) + } +} + +@Preview +@Composable +fun PreviewDragButtonNotFocused() { + DragButtonItem( + isDragging = false, + onStartDragClick = {}, + onStopDragClick = {} + ) +} + +@Preview +@Composable +fun PreviewDragButtonFocused() { + val focusRequester = remember { FocusRequester() } + + DragButtonItem( + isDragging = true, + focusRequester = focusRequester, + onStartDragClick = {}, + onStopDragClick = {} + ) + + SideEffect { + focusRequester.requestFocus() + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..1bd1dce2 --- /dev/null +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableGridItem.kt @@ -0,0 +1,91 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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 +import androidx.compose.ui.unit.sp +import com.rubensousa.dpadrecyclerview.compose.dpadClickable + +@Composable +fun DraggableGridItem( + item: Int, + isDragging: Boolean, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, + onClick: () -> Unit = {}, +) { + 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 + .fillMaxWidth() + .aspectRatio(1f) + .then( + if (isDragging) { + Modifier.border(8.dp, Color.Blue, RoundedCornerShape(8.dp)) + } else { + Modifier + } + ) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused = focusState.hasFocus + } + .focusTarget() + .dpadClickable { + onClick() + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = item.toString(), + color = textColor, + fontSize = 35.sp + ) + } +} 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 new file mode 100644 index 00000000..467e326b --- /dev/null +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/drag/DraggableItem.kt @@ -0,0 +1,121 @@ +/* + * 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.rubensousa.dpadrecyclerview.sample.ui.screen.drag + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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 +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rubensousa.dpadrecyclerview.compose.dpadClickable + +@Composable +fun DraggableItem( + item: Int, + isDragging: Boolean, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, + onClick: () -> Unit = {}, +) { + 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 + .size(200.dp) + .then( + if (isDragging) { + Modifier.border(8.dp, Color.Blue, RoundedCornerShape(8.dp)) + } else { + Modifier + } + ) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused = focusState.hasFocus + } + .focusTarget() + .dpadClickable { + onClick() + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = item.toString(), + color = textColor, + fontSize = 35.sp + ) + } +} + +@Preview +@Composable +fun PreviewItemNotFocused() { + DraggableItem(item = 0, isDragging = false) +} + +@Preview +@Composable +fun PreviewItemFocused() { + val focusRequester = remember { FocusRequester() } + + DraggableItem(item = 0, isDragging = false, focusRequester = focusRequester) + + SideEffect { + focusRequester.requestFocus() + } +} + +@Preview +@Composable +fun PreviewItemDragging() { + val focusRequester = remember { FocusRequester() } + + DraggableItem(item = 0, isDragging = true, focusRequester = focusRequester) + + SideEffect { + focusRequester.requestFocus() + } +} \ No newline at end of file diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt index 79fd76c5..c2a78987 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/screen/main/MainViewModel.kt @@ -54,12 +54,16 @@ class MainViewModel : ViewModel() { title = "Nested" ), ScreenDestination( - direction = MainFragmentDirections.openShortList(), - title = "Short list" + direction = MainFragmentDirections.openDragDrop(), + title = "Drag and drop" ), ScreenDestination( direction = MainFragmentDirections.openList(enableLooping = true), - title = "Infinite lists" + title = "Infinite lists (loop)" + ), + ScreenDestination( + direction = MainFragmentDirections.openShortList(), + title = "Short list" ), ScreenDestination( direction = MainFragmentDirections.openFadingEdge(), @@ -88,13 +92,17 @@ class MainViewModel : ViewModel() { return FeatureList( title = "Grids", destinations = listOf( + ScreenDestination( + direction = MainFragmentDirections.openGrid(evenSpans = false), + title = "Different span sizes" + ), ScreenDestination( direction = MainFragmentDirections.openGrid(), - title = "Default" + title = "Default span size" ), ScreenDestination( - direction = MainFragmentDirections.openGrid(evenSpans = false), - title = "Different span sizes" + direction = MainFragmentDirections.openDragDropGrid(), + title = "Drag and drop" ), ScreenDestination( direction = MainFragmentDirections.openSpanHeader(), diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt index f5be4d55..7b5b9fe4 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/ComposePlaceholderAdapter.kt @@ -46,7 +46,7 @@ class ComposePlaceholderAdapter( return DpadComposeViewHolder( parent = parent, isFocusable = false - ) { _, _ -> + ) { _, _, -> composable() } } diff --git a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt index 7ffa8f49..19b2639f 100644 --- a/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt +++ b/sample/src/main/java/com/rubensousa/dpadrecyclerview/sample/ui/widgets/common/MutableListAdapter.kt @@ -34,7 +34,7 @@ abstract class MutableListAdapter( private val MAIN_THREAD_HANDLER = Handler(Looper.getMainLooper()) } - private var items: MutableList = Collections.emptyList() + protected var items: MutableList = Collections.emptyList() private var currentVersion = 0 @SuppressLint("NotifyDataSetChanged") @@ -128,12 +128,6 @@ abstract class MutableListAdapter( } - fun move(from: Int, to: Int) { - currentVersion++ - Collections.swap(items, from, to) - notifyItemMoved(from, to) - } - fun addAt(index: Int, item: T) { currentVersion++ items.add(index, item) diff --git a/sample/src/main/res/drawable/ic_done.xml b/sample/src/main/res/drawable/ic_done.xml new file mode 100644 index 00000000..de36d257 --- /dev/null +++ b/sample/src/main/res/drawable/ic_done.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sample/src/main/res/drawable/ic_drag_handle.xml b/sample/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 00000000..1b044a33 --- /dev/null +++ b/sample/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sample/src/main/res/layout/screen_drag_drop.xml b/sample/src/main/res/layout/screen_drag_drop.xml new file mode 100644 index 00000000..a742ac24 --- /dev/null +++ b/sample/src/main/res/layout/screen_drag_drop.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/sample/src/main/res/layout/screen_drag_drop_grid.xml b/sample/src/main/res/layout/screen_drag_drop_grid.xml new file mode 100644 index 00000000..d8ca364c --- /dev/null +++ b/sample/src/main/res/layout/screen_drag_drop_grid.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + diff --git a/sample/src/main/res/navigation/nav_graph.xml b/sample/src/main/res/navigation/nav_graph.xml index 7883140a..9d8cfb3b 100644 --- a/sample/src/main/res/navigation/nav_graph.xml +++ b/sample/src/main/res/navigation/nav_graph.xml @@ -80,6 +80,14 @@ android:id="@+id/open_short_list" app:destination="@id/short_list_fragment" /> + + + + @@ -198,6 +206,14 @@ android:id="@+id/short_list_fragment" android:name="com.rubensousa.dpadrecyclerview.sample.ui.screen.list.ShortListFragment" /> + + + +