Last active
February 19, 2021 15:50
-
-
Save grandstaish/e1d8d5caa2855c7e09b3ff9dfd4251a4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.monzo.design.nosymbol | |
import android.content.Context | |
import android.util.AttributeSet | |
import android.view.HapticFeedbackConstants.VIRTUAL_KEY | |
import android.view.MotionEvent | |
import android.view.View | |
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT | |
import android.view.accessibility.AccessibilityEvent | |
import androidx.recyclerview.widget.LinearSnapHelper | |
import androidx.recyclerview.widget.RecyclerView | |
import androidx.recyclerview.widget.RecyclerView.LayoutParams | |
import androidx.recyclerview.widget.RecyclerView.Recycler | |
import androidx.recyclerview.widget.RecyclerView.State | |
import com.monzo.commonui.recyclerview.CenterTargetSmoothScroller | |
import com.monzo.commonui.recyclerview.SnapOnScrollListener | |
import com.monzo.commonui.recyclerview.SnapOnScrollListener.Behavior.NOTIFY_ON_SCROLL_STATE_IDLE | |
import kotlin.math.floor | |
/** | |
* An infinitely scrollable [RecyclerView] where child views are laid out horizontally and [RecyclerView.Adapter] items | |
* are repeated cyclically. | |
* | |
* Supports snapping such that child views will always snap to the center when scrolling is idle. | |
* | |
* Tapping on a child view will smooth scroll that child to the center of this view. | |
* | |
* Note: Does not support item animations or margins! | |
* Also does not support smooth scrolling to a position off-screen (requires SmoothScroller.ScrollVectorProvider to be | |
* correctly implemented). | |
*/ | |
class CyclicRecyclerView : RecyclerView { | |
private var lastScrollSource: ScrollSource? = null | |
/** | |
* Callback for whenever the snap position changes. | |
*/ | |
var onSnapListener: ((position: Int) -> Unit)? = null | |
/** | |
* Callback for whenever a scroll becomes idle. This passes the source of the scroll, e.g. whether it was | |
* started from the user dragging, or a tap. | |
* | |
* Useful for analytics. | |
*/ | |
var idleSnapListener: ((position: Int, scrollSource: ScrollSource) -> Unit)? = null | |
constructor(context: Context) : super(context) | |
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) | |
init { | |
isHapticFeedbackEnabled = true | |
itemAnimator = null | |
layoutManager = CyclicLayoutManager() | |
val snapHelper = LinearSnapHelper() | |
snapHelper.attachToRecyclerView(this) | |
var skipFirstHaptic = true | |
addOnScrollListener(SnapOnScrollListener(snapHelper) { position -> | |
if (!skipFirstHaptic) { | |
performHapticFeedback(VIRTUAL_KEY) | |
} | |
skipFirstHaptic = false | |
onSnapListener?.invoke(position) | |
}) | |
addOnScrollListener(SnapOnScrollListener(snapHelper, NOTIFY_ON_SCROLL_STATE_IDLE) { position -> | |
lastScrollSource?.let { | |
idleSnapListener?.invoke(position, it) | |
} | |
}) | |
} | |
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { | |
lastScrollSource = ScrollSource.DRAG | |
return super.dispatchTouchEvent(ev) | |
} | |
override fun onChildAttachedToWindow(child: View) { | |
child.setOnClickListener { | |
lastScrollSource = ScrollSource.TAP | |
smoothScrollToPosition(getChildAdapterPosition(child)) | |
} | |
} | |
enum class ScrollSource { | |
TAP, | |
DRAG | |
} | |
} | |
private class CyclicLayoutManager : RecyclerView.LayoutManager() { | |
private var scrollOffset = 0 | |
private var childWidth = 0 | |
private var pendingScrollPosition = 0 | |
// This is the index for the item that is initially selected, before any manual scrolling begins. It is used for | |
// defining the range of items that are considered important for accessibility. | |
private var startItemIndex = 0 | |
override fun generateDefaultLayoutParams(): LayoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT) | |
override fun canScrollHorizontally(): Boolean = true | |
override fun isAutoMeasureEnabled(): Boolean = true | |
override fun scrollToPosition(position: Int) { | |
pendingScrollPosition = position | |
startItemIndex = position | |
requestLayout() | |
} | |
override fun smoothScrollToPosition(recyclerView: RecyclerView, state: State, position: Int) { | |
startSmoothScroll(CenterTargetSmoothScroller(recyclerView.context, 100f).apply { | |
targetPosition = position | |
}) | |
} | |
override fun onLayoutChildren(recycler: Recycler, state: State) { | |
if (itemCount == 0) { | |
detachAndScrapAttachedViews(recycler) | |
return | |
} | |
if (childCount == 0) { | |
// We assume that every child is the same width. This just measures the first view and | |
// then recycles it afterwards. | |
val scrap = recycler.getViewForPosition(0) | |
addView(scrap) | |
measureChild(scrap, 0, 0) | |
childWidth = getDecoratedMeasuredWidth(scrap) | |
} | |
if (pendingScrollPosition != -1) { | |
scrollOffset += pendingScrollPosition * childWidth - (width - childWidth) / 2 | |
pendingScrollPosition = -1 | |
} | |
fill(recycler) | |
} | |
override fun scrollHorizontallyBy(dx: Int, recycler: Recycler, state: State): Int { | |
scrollOffset += dx | |
fill(recycler) | |
return dx | |
} | |
private fun fill(recycler: Recycler) { | |
detachAndScrapAttachedViews(recycler) | |
val firstVisiblePosition = findFirstVisiblePosition() | |
val lastVisiblePosition = findLastVisiblePosition() | |
for (index in firstVisiblePosition..lastVisiblePosition) { | |
var adapterPosition = index % itemCount | |
if (adapterPosition < 0) { | |
adapterPosition += itemCount | |
} | |
val view = recycler.getViewForPosition(adapterPosition) | |
view.importantForAccessibility = getImportantForAccessibility(index) | |
addView(view) | |
layoutChild(index, view) | |
} | |
val scrapListCopy = recycler.scrapList.toList() | |
scrapListCopy.forEach { | |
recycler.recycleView(it.itemView) | |
} | |
} | |
private fun layoutChild(i: Int, view: View) { | |
measureChild(view, 0, 0) | |
val left = i * childWidth - scrollOffset | |
val right = left + childWidth | |
val top = 0 | |
val bottom = top + getDecoratedMeasuredHeight(view) | |
layoutDecorated(view, left, top, right, bottom) | |
} | |
override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) { | |
super.onInitializeAccessibilityEvent(event) | |
if (childCount > 0) { | |
event.fromIndex = findFirstVisiblePosition() | |
event.toIndex = findLastVisiblePosition() | |
} | |
} | |
override fun computeHorizontalScrollRange(state: State): Int { | |
// This is typically used for scrollbars (which we don't use) but it's also used for TalkBack support. We want | |
// to provide the accessibility framework with enough information to know if we can scroll, and if we can, which | |
// direction. | |
// | |
// Scroll range is how far you can scroll (in arbitrary units). | |
// | |
// We use 2 as the scroll range because it allows us to specify 3 different offsets: | |
// 0 -> The start, cannot scroll left | |
// 1 -> The middle, can scroll left and right | |
// 2 -> The end, cannot scroll right | |
// | |
// See computeHorizontalScrollOffset for this implementation. | |
return 2 | |
} | |
override fun computeHorizontalScrollOffset(state: State): Int { | |
// This is typically used for scrollbars (which we don't use) but it's also used for TalkBack support. We want | |
// to provide the accessibility framework with enough information to know if we can scroll, and if we can, which | |
// direction. | |
// | |
// Scroll offset is how far you have scrolled in the current range. | |
// | |
// Since our scroll range is only 2 units, we can only have 3 possible offsets: 0, 1, and 2. | |
val firstVisiblePosition = findFirstVisiblePosition() | |
val lastVisiblePosition = findLastVisiblePosition() | |
val canScrollLeft = firstVisiblePosition > startItemIndex | |
if (!canScrollLeft) { | |
// Can't scroll left, so ensure that the scroll offset is at the start of our arbitrary range of 2 "units" | |
return 0 | |
} | |
val canScrollRight = lastVisiblePosition < (startItemIndex + itemCount) | |
if (!canScrollRight) { | |
// Can't scroll right, so ensure that the scroll offset is at the end of our arbitrary range of 2 "units" | |
return 2 | |
} | |
// Can scroll in both directions, so ensure that the scroll offset is in the middle of our arbitrary range of | |
// 2 "units" | |
return 1 | |
} | |
override fun computeHorizontalScrollExtent(state: State): Int { | |
// This is typically used for scrollbars (which we don't use) but it's also used for TalkBack support. We want | |
// to provide the accessibility framework with enough information to know if we can scroll, and if we can, which | |
// direction. | |
// | |
// Scroll extent is how much of the scroll range we can see on screen right now. | |
// | |
// Our scroll range is only 2 units, so if we can see all items on screen right now, then we can see the entire | |
// range (i.e. we should return 2 from here). If we cannot see all items on screen, then we just need to return | |
// something less than 2. | |
val firstVisiblePosition = findFirstVisiblePosition() | |
val lastVisiblePosition = findLastVisiblePosition() | |
val areAllAccessibleItemsVisible = firstVisiblePosition <= startItemIndex && | |
lastVisiblePosition >= (startItemIndex + itemCount) | |
return if (areAllAccessibleItemsVisible) 2 else 0 | |
} | |
private fun findFirstVisiblePosition(): Int { | |
return floor(scrollOffset.toDouble() / childWidth.toDouble()).toInt() | |
} | |
private fun findLastVisiblePosition(): Int { | |
return (scrollOffset + width) / childWidth | |
} | |
private fun getImportantForAccessibility(index: Int): Int { | |
// To ensure that accessibility users don't get stuck in a never ending list, we mark itemCount items as | |
// important for accessibility, starting from the initially selected item. | |
return if (index < startItemIndex || index >= (startItemIndex + itemCount)) { | |
View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS | |
} else { | |
View.IMPORTANT_FOR_ACCESSIBILITY_AUTO | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment