Skip to content

Commit

Permalink
Add DpadScroller to scroll a specific distance on each key event
Browse files Browse the repository at this point in the history
  • Loading branch information
rubensousa committed Nov 25, 2023
1 parent 23e636b commit 702479e
Show file tree
Hide file tree
Showing 11 changed files with 394 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package com.rubensousa.dpadrecyclerview

import android.view.KeyEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView

/**
* A helper class that allows scrolling a [DpadRecyclerView] based on specific scroll distances,
* ignoring the default alignment behavior.
*
* A typical use case for this class is a terms & conditions page,
* where a large amount of text is displayed, which the user isn't expected to interact with
*/
class DpadScroller(
private val calculator: ScrollDistanceCalculator = DefaultScrollDistanceCalculator(),
) {

private var recyclerView: DpadRecyclerView? = null
private val keyListener = KeyListener()
private var smoothScrollEnabled = true

/**
* Attaches this [DpadScroller] to a new [DpadRecyclerView] to start observing key events.
* If you no longer need this behavior, call [detach]
*
* @param recyclerView The RecyclerView that will be scrolled discretely
*/
fun attach(recyclerView: DpadRecyclerView) {
detach()
this.recyclerView = recyclerView
recyclerView.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
recyclerView.setOnKeyInterceptListener(keyListener)
}

/**
* Stops observing key events to scroll the current attached [DpadRecyclerView], if any exists
*/
fun detach() {
recyclerView?.descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS
recyclerView?.setOnKeyInterceptListener(null)
recyclerView = null
}

/**
* Enables or disables smooth scrolling on key events
*/
fun setSmoothScrollEnabled(enabled: Boolean) {
smoothScrollEnabled = enabled
}

private inner class KeyListener : DpadRecyclerView.OnKeyInterceptListener {

override fun onInterceptKeyEvent(event: KeyEvent): Boolean {
val currentRecyclerView = recyclerView ?: return false
when (event.action) {
KeyEvent.ACTION_DOWN -> {
return if (currentRecyclerView.getOrientation() == RecyclerView.VERTICAL) {
scrollVertically(currentRecyclerView, event)
} else {
scrollHorizontally(currentRecyclerView, event)
}
}
}
return false
}

private fun scrollVertically(recyclerView: DpadRecyclerView, event: KeyEvent): Boolean {
val scrollDistance = calculator.calculateScrollDistance(recyclerView, event)
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_DOWN -> {
if (smoothScrollEnabled) {
recyclerView.smoothScrollBy(0, scrollDistance)
} else {
recyclerView.scrollBy(0, scrollDistance)
}
return true
}

KeyEvent.KEYCODE_DPAD_UP -> {
if (smoothScrollEnabled) {
recyclerView.smoothScrollBy(0, -scrollDistance)
} else {
recyclerView.scrollBy(0, -scrollDistance)
}
return true
}
}
return false
}

private fun scrollHorizontally(recyclerView: DpadRecyclerView, event: KeyEvent): Boolean {
val scrollDistance = calculator.calculateScrollDistance(recyclerView, event)
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT -> {
if (smoothScrollEnabled) {
recyclerView.smoothScrollBy(0, scrollDistance)
} else {
recyclerView.scrollBy(0, scrollDistance)
}
return true
}

KeyEvent.KEYCODE_DPAD_LEFT -> {
if (smoothScrollEnabled) {
recyclerView.smoothScrollBy(0, -scrollDistance)
} else {
recyclerView.scrollBy(0, -scrollDistance)
}
return true
}
}
return false
}

}


interface ScrollDistanceCalculator {
/**
* @return the number of pixels we should scroll for this [event]
*/
fun calculateScrollDistance(recyclerView: DpadRecyclerView, event: KeyEvent): Int
}

private class DefaultScrollDistanceCalculator : ScrollDistanceCalculator {
override fun calculateScrollDistance(
recyclerView: DpadRecyclerView,
event: KeyEvent
): Int {
return if (recyclerView.getOrientation() == RecyclerView.VERTICAL) {
recyclerView.height / 4
} else {
recyclerView.width / 4
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class MainViewModel : ViewModel() {
buildNestedFeatureList(),
buildGridFeatureList(),
buildComposeFeatureList(),
buildFocusFeatureList(),
buildScrollingFeatureList(),
buildAnimationsFeatureList(),
)
}
Expand Down Expand Up @@ -106,13 +106,17 @@ class MainViewModel : ViewModel() {
)
}

private fun buildFocusFeatureList(): FeatureList {
private fun buildScrollingFeatureList(): FeatureList {
return FeatureList(
title = "Focus",
title = "Scrolling features",
destinations = listOf(
ScreenDestination(
direction = MainFragmentDirections.openTextScrolling(),
title = "Long text scrolling"
),
ScreenDestination(
direction = MainFragmentDirections.openHorizontalLeanback(),
title = "Searching for unknown pivot"
title = "Searching for next view"
)
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.rubensousa.dpadrecyclerview.sample.ui.screen.text

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.rubensousa.dpadrecyclerview.sample.databinding.AdapterItemTextBinding


class TextAdapter : ListAdapter<String, TextAdapter.TextItemViewHolder>(DIFF_CALLBACK) {

companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: String, newItem: String): Boolean {
return oldItem == newItem
}
}
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TextItemViewHolder {
return TextItemViewHolder(
AdapterItemTextBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}

override fun onBindViewHolder(holder: TextItemViewHolder, position: Int) {
holder.bind(getItem(position))
}

class TextItemViewHolder(
val binding: AdapterItemTextBinding
) : RecyclerView.ViewHolder(binding.root) {

fun bind(text: String) {
binding.textView.text = text
}

}


}

Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.rubensousa.dpadrecyclerview.sample.ui.screen.text

import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import com.rubensousa.dpadrecyclerview.DpadRecyclerView
import com.rubensousa.dpadrecyclerview.DpadScroller
import com.rubensousa.dpadrecyclerview.sample.R
import com.rubensousa.dpadrecyclerview.sample.databinding.ScreenTextScrollingBinding
import com.rubensousa.dpadrecyclerview.sample.ui.viewBinding
import com.rubensousa.dpadrecyclerview.spacing.DpadLinearSpacingDecoration

class TextScrollingFragment : Fragment(R.layout.screen_text_scrolling) {

private val binding by viewBinding(ScreenTextScrollingBinding::bind)
private val scroller = DpadScroller(object : DpadScroller.ScrollDistanceCalculator {
override fun calculateScrollDistance(
recyclerView: DpadRecyclerView,
event: KeyEvent
): Int {
return recyclerView.height / 5
}
})

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = TextAdapter()
val text = view.resources.getString(R.string.placeholder_text)
val list = mutableListOf<String>()
repeat(50) {
list.add(text)
}
adapter.submitList(list)
binding.recyclerView.apply {
this.adapter = adapter
addItemDecoration(
DpadLinearSpacingDecoration.create(
itemSpacing = resources.getDimensionPixelOffset(R.dimen.grid_item_spacing)
)
)
}
scroller.attach(binding.recyclerView)
binding.backButton.setOnClickListener {
findNavController().popBackStack()
}
binding.recyclerView.requestFocus()
}

override fun onDestroyView() {
super.onDestroyView()
scroller.detach()
}

}
6 changes: 6 additions & 0 deletions sample/src/main/res/color/list_text_background.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">

<item android:color="#2B2B2B" android:state_focused="true" />
<item android:color="@color/transparent" />
</selector>
5 changes: 5 additions & 0 deletions sample/src/main/res/drawable/ic_back.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="#000000" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,11H7.83l4.88,-4.88c0.39,-0.39 0.39,-1.03 0,-1.42 -0.39,-0.39 -1.02,-0.39 -1.41,0l-6.59,6.59c-0.39,0.39 -0.39,1.02 0,1.41l6.59,6.59c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L7.83,13H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1z"/>
</vector>
8 changes: 8 additions & 0 deletions sample/src/main/res/layout/adapter_item_text.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:duplicateParentState="true"
android:paddingHorizontal="24dp"
android:textColor="@color/white" />
Loading

0 comments on commit 702479e

Please sign in to comment.