Skip to content

Instantly share code, notes, and snippets.

@grandstaish
Last active February 19, 2021 15:50
Show Gist options
  • Save grandstaish/e1d8d5caa2855c7e09b3ff9dfd4251a4 to your computer and use it in GitHub Desktop.
Save grandstaish/e1d8d5caa2855c7e09b3ff9dfd4251a4 to your computer and use it in GitHub Desktop.
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