Skip to content

Commit

Permalink
Merge pull request #215 from rubensousa/snap_helper
Browse files Browse the repository at this point in the history
Add DpadSelectionSnapHelper for proper touch event support
  • Loading branch information
rubensousa authored Jun 1, 2024
2 parents 81b7dba + 20e0dc9 commit fbeacc6
Show file tree
Hide file tree
Showing 9 changed files with 277 additions and 11 deletions.
11 changes: 10 additions & 1 deletion dpadrecyclerview/api/dpadrecyclerview.api
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ public abstract interface class com/rubensousa/dpadrecyclerview/DpadScroller$Scr
public abstract fun calculateScrollDistance (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView;Landroid/view/KeyEvent;)I
}

public final class com/rubensousa/dpadrecyclerview/DpadSelectionSnapHelper : androidx/recyclerview/widget/LinearSnapHelper {
public fun <init> ()V
public fun attachToRecyclerView (Landroidx/recyclerview/widget/RecyclerView;)V
public fun calculateDistanceToFinalSnap (Landroidx/recyclerview/widget/RecyclerView$LayoutManager;Landroid/view/View;)[I
public fun findSnapView (Landroidx/recyclerview/widget/RecyclerView$LayoutManager;)Landroid/view/View;
}

public abstract class com/rubensousa/dpadrecyclerview/DpadSpanSizeLookup {
public fun <init> ()V
public static final fun findFirstKeyLessThan$dpadrecyclerview_release (Landroid/util/SparseIntArray;I)I
Expand Down Expand Up @@ -394,7 +401,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 {
public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutManager : androidx/recyclerview/widget/RecyclerView$LayoutManager, androidx/recyclerview/widget/RecyclerView$SmoothScroller$ScrollVectorProvider {
public fun <init> (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
Expand All @@ -410,6 +417,7 @@ public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutMana
public fun computeHorizontalScrollExtent (Landroidx/recyclerview/widget/RecyclerView$State;)I
public fun computeHorizontalScrollOffset (Landroidx/recyclerview/widget/RecyclerView$State;)I
public fun computeHorizontalScrollRange (Landroidx/recyclerview/widget/RecyclerView$State;)I
public fun computeScrollVectorForPosition (I)Landroid/graphics/PointF;
public fun computeVerticalScrollExtent (Landroidx/recyclerview/widget/RecyclerView$State;)I
public fun computeVerticalScrollOffset (Landroidx/recyclerview/widget/RecyclerView$State;)I
public fun computeVerticalScrollRange (Landroidx/recyclerview/widget/RecyclerView$State;)I
Expand Down Expand Up @@ -437,6 +445,7 @@ public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutMana
public fun isAutoMeasureEnabled ()Z
public final fun isFocusSearchDisabled ()Z
public final fun isLayoutEnabled ()Z
public fun isLayoutReversed ()Z
public fun onAdapterChanged (Landroidx/recyclerview/widget/RecyclerView$Adapter;Landroidx/recyclerview/widget/RecyclerView$Adapter;)V
public fun onAddFocusables (Landroidx/recyclerview/widget/RecyclerView;Ljava/util/ArrayList;II)Z
public fun onAttachedToWindow (Landroidx/recyclerview/widget/RecyclerView;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@ package com.rubensousa.dpadrecyclerview.test.tests.touch
import androidx.recyclerview.widget.RecyclerView
import com.google.common.truth.Truth.assertThat
import com.rubensousa.dpadrecyclerview.ChildAlignment
import com.rubensousa.dpadrecyclerview.DpadSelectionSnapHelper
import com.rubensousa.dpadrecyclerview.ParentAlignment
import com.rubensousa.dpadrecyclerview.test.TestLayoutConfiguration
import com.rubensousa.dpadrecyclerview.test.helpers.assertFocusAndSelection
import com.rubensousa.dpadrecyclerview.test.helpers.getItemViewBounds
import com.rubensousa.dpadrecyclerview.test.helpers.getRecyclerViewBounds
import com.rubensousa.dpadrecyclerview.test.helpers.getRelativeItemViewBounds
import com.rubensousa.dpadrecyclerview.test.helpers.onRecyclerView
import com.rubensousa.dpadrecyclerview.test.helpers.selectPosition
import com.rubensousa.dpadrecyclerview.test.helpers.swipeVerticallyBy
import com.rubensousa.dpadrecyclerview.test.helpers.waitForIdleScrollState
import com.rubensousa.dpadrecyclerview.test.tests.DpadRecyclerViewTest
import com.rubensousa.dpadrecyclerview.testfixtures.ColumnLayout
import com.rubensousa.dpadrecyclerview.testfixtures.LayoutConfig
Expand Down Expand Up @@ -85,4 +90,60 @@ class VerticalTouchScrollTest : DpadRecyclerViewTest() {
assertChildrenPositions(column)
}

@Test
fun testSwipeDownSnapsToNextItem() {
// given
val startPosition = 10
selectPosition(position = startPosition)
val startItemBounds = getItemViewBounds(position = startPosition)
applySnapHelper()

// when
swipeVerticallyBy(-startItemBounds.height() / 2)
waitForIdleScrollState()

// then
assertFocusAndSelection(startPosition + 1)
assertThat(getItemViewBounds(position = startPosition + 1)).isEqualTo(startItemBounds)
}

@Test
fun testSwipeUpSnapsToPreviousItem() {
// given
val startPosition = 10
selectPosition(position = startPosition)
val startItemBounds = getItemViewBounds(position = startPosition)
applySnapHelper()

// when
swipeVerticallyBy(startItemBounds.height() / 2)
waitForIdleScrollState()

// then
assertFocusAndSelection(startPosition - 1)
assertThat(getItemViewBounds(position = startPosition - 1)).isEqualTo(startItemBounds)
}

@Test
fun testLongSwipeSnapsToKeyline() {
// given
val startPosition = 10
selectPosition(position = startPosition)
val startItemBounds = getItemViewBounds(position = startPosition)
applySnapHelper()

// when
swipeVerticallyBy(-getRecyclerViewBounds().height() / 2)
waitForIdleScrollState()

// then
assertFocusAndSelection(16)
assertThat(getItemViewBounds(16)).isEqualTo(startItemBounds)
}

private fun applySnapHelper() {
onRecyclerView("Apply SnapHelper") {
DpadSelectionSnapHelper().attachToRecyclerView(it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,10 @@ open class DpadRecyclerView @JvmOverloads constructor(
return super.dispatchTouchEvent(event)
}

final override fun dispatchKeyEvent(event: KeyEvent): Boolean {
final override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
if (event == null) {
return false
}
if (keyInterceptListener?.onInterceptKeyEvent(event) == true) {
return true
}
Expand Down Expand Up @@ -1304,6 +1307,8 @@ open class DpadRecyclerView @JvmOverloads constructor(
layoutWhileScrollingEnabled = enabled
}

internal fun isScrollingFromTouch() = startedTouchScroll

@VisibleForTesting
internal fun detachFromWindow() {
onDetachedFromWindow()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* 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.util.DisplayMetrics
import android.view.View
import android.view.ViewGroup
import androidx.core.view.forEach
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.LinearSnapHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider
import androidx.recyclerview.widget.SnapHelper
import com.rubensousa.dpadrecyclerview.layoutmanager.PivotLayoutManager
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

/**
* A [SnapHelper] that scrolls Views to their alignment configuration
* and performs selections automatically.
* Use this only if you need to support touch event handling,
* as [DpadRecyclerView] by default does not handle selection on touch events.
*/
class DpadSelectionSnapHelper : LinearSnapHelper() {

private val maxScrollOnFlingDurationMs = 500
private val millisecondsPerInch = 100f
private var currentRecyclerView: DpadRecyclerView? = null

override fun attachToRecyclerView(recyclerView: RecyclerView?) {
super.attachToRecyclerView(recyclerView)
if (recyclerView is DpadRecyclerView) {
currentRecyclerView = recyclerView
return
}
if (recyclerView != null) {
throw IllegalArgumentException("Only DpadRecyclerView can be used with DpadSnapHelper")
}
}

override fun calculateDistanceToFinalSnap(
layoutManager: RecyclerView.LayoutManager, targetView: View
): IntArray {
val distance = intArrayOf(0, 0)
if (layoutManager !is PivotLayoutManager) {
return distance
}
val scrollOffset = layoutManager.getScrollOffset(targetView)
layoutManager.select(targetView)
if (layoutManager.isHorizontal()) {
distance[0] = scrollOffset
} else {
distance[1] = scrollOffset
}
return distance
}

override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
if (layoutManager !is PivotLayoutManager) {
return null
}
var nearestView: View? = null
var nearestOffset: Int = Int.MAX_VALUE
for (i in 0 until layoutManager.childCount) {
val child = layoutManager.getChildAt(i) ?: continue
val offset = abs(layoutManager.getScrollOffset(child))
if (offset < nearestOffset && hasFocusableChild(child)) {
nearestOffset = offset
nearestView = child
}
}
return nearestView
}

private fun hasFocusableChild(view: View): Boolean {
if (view.isFocusable || view.isFocusableInTouchMode) {
return true
}
val viewGroup = view as? ViewGroup ?: return false
viewGroup.forEach { child ->
if (hasFocusableChild(child)) {
return true
}
}
return false
}

override fun createScroller(
layoutManager: RecyclerView.LayoutManager
): RecyclerView.SmoothScroller? {
val recyclerView = currentRecyclerView ?: return null
if (layoutManager !is ScrollVectorProvider) {
return null
}
return object : LinearSmoothScroller(recyclerView.context) {

override fun onTargetFound(
targetView: View, state: RecyclerView.State, action: Action
) {
val snapDistances = calculateDistanceToFinalSnap(layoutManager, targetView)
val dx = snapDistances[0]
val dy = snapDistances[1]
val time = calculateTimeForDeceleration(
max(abs(dx.toDouble()), abs(dy.toDouble())).toInt()
)
if (time > 0) {
action.update(dx, dy, time, mDecelerateInterpolator)
}
}

override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float {
return millisecondsPerInch / displayMetrics.densityDpi
}

override fun calculateTimeForScrolling(dx: Int): Int {
return min(
maxScrollOnFlingDurationMs.toDouble(),
super.calculateTimeForScrolling(dx).toDouble()
).toInt()
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.rubensousa.dpadrecyclerview.layoutmanager

import android.content.Context
import android.graphics.PointF
import android.graphics.Rect
import android.os.Bundle
import android.os.Parcelable
Expand Down Expand Up @@ -49,7 +50,8 @@ 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() {
class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager(),
RecyclerView.SmoothScroller.ScrollVectorProvider {

private var layoutDirection: Int = View.LAYOUT_DIRECTION_LTR
private val configuration = LayoutConfiguration(properties)
Expand Down Expand Up @@ -119,6 +121,8 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager()

override fun canScrollVertically(): Boolean = configuration.isVertical()

override fun isLayoutReversed(): Boolean = configuration.reverseLayout

override fun isAutoMeasureEnabled(): Boolean = true

override fun supportsPredictiveItemAnimations(): Boolean = !layoutInfo.isLoopingAllowed
Expand Down Expand Up @@ -198,6 +202,24 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager()
return computeScrollRange(state)
}

override fun computeScrollVectorForPosition(targetPosition: Int): PointF? {
if (childCount == 0) {
return null
}
val firstChild = layoutInfo.getChildAt(0) ?: return null
val firstChildPos = getPosition(firstChild)
val direction = if (targetPosition < firstChildPos != isLayoutReversed) {
-1
} else {
1
}
return if (isHorizontal()) {
PointF(direction.toFloat(), 0f)
} else {
PointF(0f, direction.toFloat())
}
}

private fun computeScrollOffset(state: RecyclerView.State): Int {
if (childCount == 0) {
return 0
Expand Down Expand Up @@ -420,6 +442,24 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager()

internal fun getConfig() = configuration

internal fun isHorizontal() = configuration.isHorizontal()

internal fun getScrollOffset(view: View): Int {
return layoutAlignment.calculateScrollToTarget(view)
}

internal fun notifyNestedChildFocus(view: View) {
pivotSelector.notifyNestedChildFocus(view)
}

internal fun select(view: View) {
val position = layoutInfo.getAdapterPositionOf(view)
if (position == RecyclerView.NO_POSITION) {
return
}
selectPosition(position = position, subPosition = 0, smooth = true)
}

internal fun setScrollingFromTouchEvent(isTouching: Boolean) {
configuration.setKeepLayoutAnchor(isTouching)
isScrollingFromTouchEvent = isTouching
Expand Down Expand Up @@ -612,10 +652,6 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager()
pivotSelector.clearOnViewHolderFocusedListeners()
}

internal fun notifyNestedChildFocus(view: View) {
pivotSelector.notifyNestedChildFocus(view)
}

fun selectPosition(position: Int, subPosition: Int, smooth: Boolean) {
scroller.scrollToPosition(position, subPosition, smooth)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ internal class LayoutAlignment(
* Return the scroll delta required to make the view selected and aligned.
* If the returned value is 0, there is no need to scroll.
*/
private fun calculateScrollToTarget(view: View): Int {
fun calculateScrollToTarget(view: View): Int {
return parentAlignmentCalculator.calculateScrollOffset(getAnchor(view), parentAlignment)
}

Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ androidx-lifecycle = "2.7.0"
androidx-navigation = "2.7.7"
androidx-paging = "3.2.1"
androidx-poolingcontainer = "1.0.0"
androidx-recyclerview = "1.3.2"
androidx-recyclerview = "1.4.0-alpha01"
androidx-test-core = '1.5.0'
androidx-test-espresso = '3.5.1'
androidx-test-espressoJunit = '1.1.5'
Expand Down
Loading

0 comments on commit fbeacc6

Please sign in to comment.