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 DpadSelectionSnapHelper for proper touch event support #215

Merged
merged 2 commits into from
Jun 1, 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
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
Loading