Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add built-in support for saving and restoring scroll and view states #213

Merged
merged 7 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions dpadrecyclerview/api/dpadrecyclerview.api
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,26 @@ public abstract interface class com/rubensousa/dpadrecyclerview/spacing/DpadSpac
public abstract fun shouldApplySpacing (Landroidx/recyclerview/widget/RecyclerView$ViewHolder;I)Z
}

public final class com/rubensousa/dpadrecyclerview/state/DpadScrollState {
public final fun clear ()V
public final fun clear (Ljava/lang/String;)V
public final fun restore (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView;Ljava/lang/String;Landroidx/recyclerview/widget/RecyclerView$Adapter;)V
public final fun save (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView;Ljava/lang/String;Z)V
public static synthetic fun save$default (Lcom/rubensousa/dpadrecyclerview/state/DpadScrollState;Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView;Ljava/lang/String;ZILjava/lang/Object;)V
}

public final class com/rubensousa/dpadrecyclerview/state/DpadStateRegistry {
public fun <init> (Landroidx/savedstate/SavedStateRegistryOwner;)V
public final fun getScrollState ()Lcom/rubensousa/dpadrecyclerview/state/DpadScrollState;
public final fun getViewHolderState ()Lcom/rubensousa/dpadrecyclerview/state/DpadViewHolderState;
public final fun setSaveInstanceStateEnabled (Z)V
}

public final class com/rubensousa/dpadrecyclerview/state/DpadViewHolderState {
public final fun clear ()V
public final fun clear (Ljava/lang/String;)V
public final fun restoreState (Landroidx/recyclerview/widget/RecyclerView$ViewHolder;Ljava/lang/String;Z)V
public static synthetic fun restoreState$default (Lcom/rubensousa/dpadrecyclerview/state/DpadViewHolderState;Landroidx/recyclerview/widget/RecyclerView$ViewHolder;Ljava/lang/String;ZILjava/lang/Object;)V
public final fun saveState (Landroidx/recyclerview/widget/RecyclerView$ViewHolder;Ljava/lang/String;)V
}

1 change: 1 addition & 0 deletions dpadrecyclerview/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ android {
dependencies {
api libs.androidx.recyclerview
implementation libs.androidx.collection
implementation libs.androidx.fragment

// Required for dependency resolution
debugImplementation libs.guava
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import com.rubensousa.dpadrecyclerview.DpadRecyclerView
import com.rubensousa.dpadrecyclerview.OnViewFocusedListener
import com.rubensousa.dpadrecyclerview.state.DpadScrollState
import com.rubensousa.dpadrecyclerview.state.DpadStateRegistry
import com.rubensousa.dpadrecyclerview.test.tests.AbstractTestAdapter
import com.rubensousa.dpadrecyclerview.testfixtures.DpadFocusEvent

Expand All @@ -34,6 +36,7 @@ class TestNestedListFragment : Fragment(R.layout.dpadrecyclerview_test_container
itemLayoutId = R.layout.dpadrecyclerview_item_horizontal,
numberOfItems = 200
)
private val stateRegistry = DpadStateRegistry(this)
private val parentFocusEvents = arrayListOf<DpadFocusEvent>()
private val childFocusEvents = arrayListOf<DpadFocusEvent>()
private val parentFocusListener = object : OnViewFocusedListener {
Expand All @@ -52,7 +55,11 @@ class TestNestedListFragment : Fragment(R.layout.dpadrecyclerview_test_container
childFocusEvents.add(DpadFocusEvent(parent, child, parent.layoutPosition))
}
}
private val nestedAdapter = NestedAdapter(configuration, childFocusListener)
private val nestedAdapter = NestedAdapter(
configuration = configuration,
onViewFocusedListener = childFocusListener,
scrollState = stateRegistry.getScrollState()
)

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Expand All @@ -70,48 +77,63 @@ class TestNestedListFragment : Fragment(R.layout.dpadrecyclerview_test_container

class NestedAdapter(
private val configuration: TestAdapterConfiguration,
private val onViewFocusedListener: OnViewFocusedListener
private val onViewFocusedListener: OnViewFocusedListener,
private val scrollState: DpadScrollState,
) : AbstractTestAdapter<ListViewHolder>(configuration) {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
return ListViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.dpadrecyclerview_nested_list, parent, false),
configuration,
onViewFocusedListener
onViewFocusedListener,
)
}

override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
holder.bind(position)
scrollState.restore(
recyclerView = holder.recyclerView,
key = position.toString(),
adapter = holder.adapter
)
}

}
override fun onViewRecycled(holder: ListViewHolder) {
super.onViewRecycled(holder)
scrollState.save(
recyclerView = holder.recyclerView,
key = holder.absoluteAdapterPosition.toString(),
detachAdapter = true
)
}

}

class ListViewHolder(
val view: View,
val configuration: TestAdapterConfiguration,
onViewFocusedListener: OnViewFocusedListener,
) : RecyclerView.ViewHolder(view) {

private val adapter = TestAdapter(
val adapter = TestAdapter(
adapterConfiguration = configuration,
onViewHolderSelected = { position -> },
onViewHolderDeselected = { position -> }
)
private val textView = view.findViewById<TextView>(R.id.textView)
private val recyclerView = view.findViewById<DpadRecyclerView>(R.id.nestedRecyclerView)

val recyclerView = view.findViewById<DpadRecyclerView>(R.id.nestedRecyclerView)

init {
recyclerView.adapter = adapter
recyclerView.addOnViewFocusedListener(onViewFocusedListener)
}

fun bind(position: Int) {
recyclerView.tag = position
textView.text = "List $position"
textView.freezesText = true
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,52 +16,48 @@

package com.rubensousa.dpadrecyclerview.test.tests.selection

import androidx.recyclerview.widget.RecyclerView
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.test.espresso.Espresso
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withTagValue
import com.google.common.truth.Truth.assertThat
import com.rubensousa.dpadrecyclerview.ChildAlignment
import com.rubensousa.dpadrecyclerview.ParentAlignment
import com.rubensousa.dpadrecyclerview.ParentAlignment.Edge
import com.rubensousa.dpadrecyclerview.test.TestLayoutConfiguration
import com.rubensousa.dpadrecyclerview.test.TestNestedListFragment
import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusPosition
import com.rubensousa.dpadrecyclerview.test.helpers.assertOnRecyclerView
import com.rubensousa.dpadrecyclerview.test.helpers.getItemViewBounds
import com.rubensousa.dpadrecyclerview.test.helpers.getRecyclerViewBounds
import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest
import com.rubensousa.dpadrecyclerview.test.helpers.waitForCondition
import com.rubensousa.dpadrecyclerview.testing.KeyEvents
import com.rubensousa.dpadrecyclerview.testing.R
import com.rubensousa.dpadrecyclerview.testing.assertions.DpadRecyclerViewAssertions
import com.rubensousa.dpadrecyclerview.testing.rules.DisableIdleTimeoutRule
import org.hamcrest.Matchers
import org.hamcrest.Matchers.allOf
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class SaveRestoreStateTest : DpadRecyclerViewTest() {
class SaveRestoreStateTest {

@get:Rule
val idleTimeoutRule = DisableIdleTimeoutRule()

override fun getDefaultLayoutConfiguration(): TestLayoutConfiguration {
return TestLayoutConfiguration(
spans = 1,
orientation = RecyclerView.VERTICAL,
parentAlignment = ParentAlignment(
edge = Edge.MIN_MAX
),
childAlignment = ChildAlignment(offset = 0)
)
}
private lateinit var fragmentScenario: FragmentScenario<TestNestedListFragment>

@Before
fun setup() {
launchFragment()
fragmentScenario = launchFragment()
}

@Test
fun testSelectionStateIsSavedAndRestored() {
KeyEvents.pressDown(times = 5)
assertFocusPosition(5)

recreateFragment()
fragmentScenario.recreate()

val recyclerViewBounds = getRecyclerViewBounds()
val viewBounds = getItemViewBounds(position = 5)
Expand All @@ -70,4 +66,52 @@ class SaveRestoreStateTest : DpadRecyclerViewTest() {
assertFocusPosition(5)
}

@Test
fun testSelectionStateAcrossNestedListsIsSaved() {
// given
KeyEvents.pressRight(times = 5)

// when
KeyEvents.pressDown(times = 25)
KeyEvents.pressUp(times = 25)

// then
Espresso.onView(
allOf(
withId(com.rubensousa.dpadrecyclerview.test.R.id.nestedRecyclerView),
withTagValue(Matchers.`is`(0))
)
).check(DpadRecyclerViewAssertions.isSelected(position = 5))
}

@Test
fun testSelectionStateAcrossNestedListsSurvivesConfigurationChanges() {
// given
KeyEvents.pressRight(times = 5)
KeyEvents.pressDown(times = 25)
KeyEvents.pressUp(times = 25)

// when
fragmentScenario.recreate()

// then
Espresso.onView(
allOf(
withId(com.rubensousa.dpadrecyclerview.test.R.id.nestedRecyclerView),
withTagValue(Matchers.`is`(0))
)
).check(DpadRecyclerViewAssertions.isSelected(position = 5))
}

private fun launchFragment(): FragmentScenario<TestNestedListFragment> {
return launchFragmentInContainer<TestNestedListFragment>(
themeResId = R.style.DpadRecyclerViewTestTheme
).also {
fragmentScenario = it
waitForCondition("Waiting for layout pass") { recyclerView ->
!recyclerView.isLayoutRequested
}
}
}

}
Loading
Loading