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 support for scrollbars & discrete scrolling #170

Merged
merged 4 commits into from
Nov 25, 2023
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
2 changes: 1 addition & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Pull requests

env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false"
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g"

on:
pull_request:
Expand Down
11 changes: 11 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Version 1.2.0

### 1.2.0-alpha01

2023-11-25

#### New Features

- Added support for scrollbars
- Added `DpadScroller` for scrolling without any alignment. Typical use case is for long text displays (terms & conditions and consent pages).

## Version 1.1.0

### 1.1.0
Expand Down
19 changes: 19 additions & 0 deletions dpadrecyclerview/api/dpadrecyclerview.api
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,19 @@ public abstract interface class com/rubensousa/dpadrecyclerview/DpadRecyclerView
public abstract fun configSmoothScrollByInterpolator (II)Landroid/view/animation/Interpolator;
}

public final class com/rubensousa/dpadrecyclerview/DpadScroller {
public fun <init> ()V
public fun <init> (Lcom/rubensousa/dpadrecyclerview/DpadScroller$ScrollDistanceCalculator;)V
public synthetic fun <init> (Lcom/rubensousa/dpadrecyclerview/DpadScroller$ScrollDistanceCalculator;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun attach (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView;)V
public final fun detach ()V
public final fun setSmoothScrollEnabled (Z)V
}

public abstract interface class com/rubensousa/dpadrecyclerview/DpadScroller$ScrollDistanceCalculator {
public abstract fun calculateScrollDistance (Lcom/rubensousa/dpadrecyclerview/DpadRecyclerView;Landroid/view/KeyEvent;)I
}

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 @@ -378,6 +391,12 @@ public final class com/rubensousa/dpadrecyclerview/layoutmanager/PivotLayoutMana
public final fun clearOnViewHolderSelectedListeners ()V
public fun collectAdjacentPrefetchPositions (IILandroidx/recyclerview/widget/RecyclerView$State;Landroidx/recyclerview/widget/RecyclerView$LayoutManager$LayoutPrefetchRegistry;)V
public fun collectInitialPrefetchPositions (ILandroidx/recyclerview/widget/RecyclerView$LayoutManager$LayoutPrefetchRegistry;)V
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 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
public final fun findFirstCompletelyVisibleItemPosition ()I
public final fun findFirstVisibleItemPosition ()I
public final fun findLastCompletelyVisibleItemPosition ()I
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright 2023 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.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 @@ -23,7 +23,6 @@ import android.os.Parcelable
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.recyclerview.widget.RecyclerView
import com.rubensousa.dpadrecyclerview.ChildAlignment
Expand All @@ -41,6 +40,7 @@ import com.rubensousa.dpadrecyclerview.layoutmanager.focus.SpanFocusFinder
import com.rubensousa.dpadrecyclerview.layoutmanager.layout.LayoutInfo
import com.rubensousa.dpadrecyclerview.layoutmanager.layout.LayoutPrefetchCollector
import com.rubensousa.dpadrecyclerview.layoutmanager.layout.PivotLayout
import com.rubensousa.dpadrecyclerview.layoutmanager.scroll.DpadScrollbarHelper
import com.rubensousa.dpadrecyclerview.layoutmanager.scroll.LayoutScroller

/**
Expand Down Expand Up @@ -169,6 +169,73 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager()
state: RecyclerView.State
): Int = pivotLayout.scrollVerticallyBy(dy, recycler, state)

override fun computeHorizontalScrollOffset(state: RecyclerView.State): Int {
return computeScrollOffset(state)
}

override fun computeVerticalScrollOffset(state: RecyclerView.State): Int {
return computeScrollOffset(state)
}

override fun computeHorizontalScrollExtent(state: RecyclerView.State): Int {
return computeScrollExtent(state)
}

override fun computeVerticalScrollExtent(state: RecyclerView.State): Int {
return computeScrollExtent(state)
}

override fun computeHorizontalScrollRange(state: RecyclerView.State): Int {
return computeScrollRange(state)
}

override fun computeVerticalScrollRange(state: RecyclerView.State): Int {
return computeScrollRange(state)
}

private fun computeScrollOffset(state: RecyclerView.State): Int {
if (childCount == 0) {
return 0
}
return DpadScrollbarHelper.computeScrollOffset(
state = state,
orientationHelper = layoutInfo.orientationHelper,
startChild = layoutInfo.findFirstVisibleChild(),
endChild = layoutInfo.findLastVisibleChild(),
lm = this,
smoothScrollbarEnabled = true,
reverseLayout = configuration.reverseLayout
)
}

private fun computeScrollExtent(state: RecyclerView.State): Int {
if (childCount == 0) {
return 0
}
return DpadScrollbarHelper.computeScrollExtent(
state = state,
orientationHelper = layoutInfo.orientationHelper,
startChild = layoutInfo.findFirstVisibleChild(),
endChild = layoutInfo.findLastVisibleChild(),
lm = this,
smoothScrollbarEnabled = true,
)
}

private fun computeScrollRange(state: RecyclerView.State): Int {
if (childCount == 0) {
return 0
}
return DpadScrollbarHelper.computeScrollRange(
state = state,
orientationHelper = layoutInfo.orientationHelper,
startChild = layoutInfo.findFirstVisibleChild(),
endChild = layoutInfo.findLastVisibleChild(),
lm = this,
smoothScrollbarEnabled = true,
)
}

override fun scrollToPosition(position: Int) {
scroller.scrollToPosition(position)
}
Expand Down Expand Up @@ -571,9 +638,4 @@ class PivotLayoutManager(properties: Properties) : RecyclerView.LayoutManager()
}
}

@VisibleForTesting
internal fun getFocusFinderSpanCount(): Int {
return spanFocusFinder.spanCount
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,22 @@ internal class LayoutInfo(
)
}

fun findFirstVisibleChild(): View? {
val childPosition = findFirstVisiblePosition()
if (childPosition == RecyclerView.NO_POSITION) {
return null
}
return layout.findViewByPosition(childPosition)
}

fun findLastVisibleChild(): View? {
val childPosition = findLastVisiblePosition()
if (childPosition == RecyclerView.NO_POSITION) {
return null
}
return layout.findViewByPosition(childPosition)
}

/**
* @param startIndex index at which the search should start
* @param endIndex index at which the search should stop (not inclusive)
Expand Down
Loading
Loading