recycler: redesign fast scroller

- Use new "bump" design
- Base off fundamental RV primitives over custom item
calculations
- Make possible to use by non-home views
This commit is contained in:
Alexander Capehart 2024-11-07 20:52:48 -07:00
parent 8ec61c9388
commit fe6c07a342
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 115 additions and 443 deletions

View file

@ -1,185 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* FastScrollPopupView.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.fastscroll
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet
import android.view.Gravity
import androidx.core.widget.TextViewCompat
import com.google.android.material.R as MR
import com.google.android.material.textview.MaterialTextView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.isRtl
/**
* A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
*
* @author Alexander Capehart (OxygenCobalt), Hai Zhang
*/
class FastScrollPopupView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
MaterialTextView(context, attrs, defStyleRes) {
init {
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_mid_huge)
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_large)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
setTextColor(context.getAttrColorCompat(MR.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
alpha = 0f
elevation = context.getDimenPixels(MR.dimen.m3_sys_elevation_level2).toFloat()
background = FastScrollPopupDrawable(context)
}
private class FastScrollPopupDrawable(context: Context) : Drawable() {
private val paint: Paint =
Paint().apply {
isAntiAlias = true
color =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorSecondary)
.defaultColor
style = Paint.Style.FILL
}
private val path = Path()
private val matrix = Matrix()
private val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
private val paddingEnd = context.getDimenPixels(R.dimen.spacing_mid_huge)
override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint)
}
override fun onBoundsChange(bounds: Rect) {
updatePath()
}
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
updatePath()
return true
}
@Suppress("DEPRECATION")
override fun getOutline(outline: Outline) {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
// Paths don't need to be convex on android Q, but the API was mislabeled and so
// we still have to use this method.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
else ->
if (!path.isConvex) {
// The outline path must be convex before Q, but we may run into floating
// point errors caused by calculations involving sqrt(2) or OEM differences,
// so in this case we just omit the shadow instead of crashing.
super.getOutline(outline)
}
}
}
override fun getPadding(padding: Rect): Boolean {
if (isRtl) {
padding[paddingEnd, 0, paddingStart] = 0
} else {
padding[paddingStart, 0, paddingEnd] = 0
}
return true
}
override fun isAutoMirrored(): Boolean = true
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: ColorFilter?) {}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
private fun updatePath() {
val r = bounds.height().toFloat() / 2
val w = (r + SQRT2 * r).coerceAtLeast(bounds.width().toFloat())
path.apply {
reset()
// Draw the left pill shape
val o1X = w - SQRT2 * r
arcToSafe(r, r, r, 90f, 180f)
arcToSafe(o1X, r, r, -90f, 45f)
// Draw the right arrow shape
val point = r / 5
val o2X = w - SQRT2 * point
arcToSafe(o2X, r, point, -45f, 90f)
arcToSafe(o1X, r, r, 45f, 45f)
close()
}
matrix.apply {
reset()
if (isRtl) setScale(-1f, 1f, w / 2, 0f)
postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
}
path.transform(matrix)
}
private fun Path.arcToSafe(
centerX: Float,
centerY: Float,
radius: Float,
startAngle: Float,
sweepAngle: Float
) {
arcTo(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius,
startAngle,
sweepAngle,
false)
}
}
private companion object {
// Pre-calculate sqrt(2)
const val SQRT2 = 1.4142135f
}
}

View file

@ -29,12 +29,12 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music

View file

@ -27,12 +27,12 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music

View file

@ -27,11 +27,11 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.GenreViewHolder import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre

View file

@ -26,11 +26,11 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music

View file

@ -28,11 +28,11 @@ import java.util.Formatter
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music

View file

@ -16,31 +16,25 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.home.fastscroll package org.oxycblt.auxio.list.recycler
import android.animation.Animator import android.animation.Animator
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView import org.oxycblt.auxio.ui.MaterialSlider
import org.oxycblt.auxio.ui.MaterialFader
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.isRtl import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -67,6 +61,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Variable names are no longer prefixed with m * - Variable names are no longer prefixed with m
* - Added drag listener * - Added drag listener
* - Added documentation * - Added documentation
* - Completely new design
* *
* @author Hai Zhang, Alexander Capehart (OxygenCobalt) * @author Hai Zhang, Alexander Capehart (OxygenCobalt)
* *
@ -78,14 +73,12 @@ class FastScrollRecyclerView
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) { AuxioRecyclerView(context, attrs, defStyleAttr) {
// Thumb // Thumb
private val thumbView = private val thumbSize = context.getDimenPixels(R.dimen.size_touchable_small)
View(context).apply { private val slider = MaterialSlider(context, thumbSize)
scaleX = 0f private var thumbAnimator: Animator? = null
background = context.getDrawableCompat(R.drawable.ui_scroll_thumb)
}
private val thumbWidth = thumbView.background.intrinsicWidth private val thumbView =
private val thumbHeight = thumbView.background.intrinsicHeight context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { slider.jumpOut(this) }
private val thumbPadding = Rect(0, 0, 0, 0) private val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0 private var thumbOffset = 0
@ -96,27 +89,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
} }
// Popup
private val popupView =
FastScrollPopupView(context).apply {
scaleX = 0f
scaleY = 0f
alpha = 0f
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply {
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
marginEnd = context.getDimenPixels(R.dimen.spacing_small)
}
}
private val fader = MaterialFader.quickLopsided(context)
private var thumbAnimator: Animator? = null
private var popupAnimator: Animator? = null
private var showingPopup = false
// Touch // Touch
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small) private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
@ -144,23 +116,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (field) { if (field) {
removeCallbacks(hideThumbRunnable) removeCallbacks(hideThumbRunnable)
showScrollbar() showScrollbar()
showPopup()
} else { } else {
postAutoHideScrollbar() postAutoHideScrollbar()
hidePopup()
} }
listener?.onFastScrollingChanged(field) listener?.onFastScrollingChanged(field)
} }
private val tRect = Rect()
var popupProvider: PopupProvider? = null var popupProvider: PopupProvider? = null
var listener: Listener? = null var listener: Listener? = null
init { init {
overlay.add(thumbView) overlay.add(thumbView)
overlay.add(popupView)
addItemDecoration( addItemDecoration(
object : ItemDecoration() { object : ItemDecoration() {
@ -192,85 +159,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
updateScrollbarState() updateScrollbarState()
thumbView.layoutDirection = layoutDirection thumbView.layoutDirection = layoutDirection
popupView.layoutDirection = layoutDirection thumbView.measure(
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY))
val thumbTop = thumbPadding.top + thumbOffset
val thumbLeft = val thumbLeft =
if (isRtl) { if (isRtl) {
thumbPadding.left thumbPadding.left
} else { } else {
width - thumbPadding.right - thumbWidth width - thumbPadding.right - thumbSize
} }
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize)
val thumbTop = thumbPadding.top + thumbOffset
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
val child = getChildAt(0)
val firstAdapterPos =
if (child != null) {
layoutManager?.getPosition(child) ?: NO_POSITION
} else {
NO_POSITION
}
val popupText: String
val provider = popupProvider
if (firstAdapterPos != NO_POSITION && provider != null) {
popupView.isInvisible = false
// Get the popup text. If there is none, we default to "?".
popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else {
// No valid position or provider, do not show the popup.
popupView.isInvisible = true
popupText = ""
}
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupView.text != popupText) {
popupView.text = popupText
val widthMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
thumbPadding.left +
thumbPadding.right +
thumbWidth +
popupLayoutParams.leftMargin +
popupLayoutParams.rightMargin,
popupLayoutParams.width)
val heightMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
thumbPadding.top +
thumbPadding.bottom +
popupLayoutParams.topMargin +
popupLayoutParams.bottomMargin,
popupLayoutParams.height)
popupView.measure(widthMeasureSpec, heightMeasureSpec)
}
val popupWidth = popupView.measuredWidth
val popupHeight = popupView.measuredHeight
val popupLeft =
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin
} else {
width - thumbPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth
}
val popupAnchorY = popupHeight / 2
val thumbAnchorY = thumbView.paddingTop
val popupTop =
(thumbTop + thumbAnchorY - popupAnchorY)
.coerceAtLeast(thumbPadding.top + popupLayoutParams.topMargin)
.coerceAtMost(
height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
} }
override fun onScrolled(dx: Int, dy: Int) { override fun onScrolled(dx: Int, dy: Int) {
@ -295,26 +194,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun updateScrollbarState() { private fun updateScrollbarState() {
if (scrollRange <= height || childCount == 0) {
return
}
// Combine the previous item dimensions with the current item top to find our scroll
// position
getDecoratedBoundsWithMargins(getChildAt(0), tRect)
val child = getChildAt(0)
val firstAdapterPos =
when (val mgr = layoutManager) {
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
is LinearLayoutManager -> mgr.getPosition(child)
else -> 0
}
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
// Then calculate the thumb position, which is just: // Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range] // [proportion of scroll position to scroll range] * [total thumb range]
thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt() val offsetY = computeVerticalScrollOffset()
if (computeVerticalScrollRange() < height || childCount == 0) {
return
}
val extentY = computeVerticalScrollExtent()
val fraction = (offsetY).toFloat() / (computeVerticalScrollRange() - extentY)
thumbOffset = (thumbOffsetRange * fraction).toInt()
} }
private fun onItemTouch(event: MotionEvent): Boolean { private fun onItemTouch(event: MotionEvent): Boolean {
@ -331,10 +219,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) { if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else { } else if (eventX > thumbView.right - thumbSize / 4) {
dragStartThumbOffset = dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} else {
return false
} }
dragging = true dragging = true
@ -349,8 +238,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else { } else {
dragStartY = eventY dragStartY = eventY
dragStartThumbOffset = dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} }
@ -371,44 +259,19 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun scrollToThumbOffset(thumbOffset: Int) { private fun scrollToThumbOffset(thumbOffset: Int) {
val clampedThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange) val rangeY = computeVerticalScrollRange() - computeVerticalScrollExtent()
val previousThumbOffset = this.thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val scrollOffset = val previousOffsetY = rangeY * (previousThumbOffset / thumbOffsetRange.toFloat())
(scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() - val newThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
paddingTop val newOffsetY = rangeY * (newThumbOffset / thumbOffsetRange.toFloat())
if (newOffsetY == 0f) {
scrollTo(scrollOffset) // Hacky workaround to drift in vertical scroll offset where we just snap
} // to the top if the thumb offset hit zero.
scrollToPosition(0)
private fun scrollTo(offset: Int) {
if (childCount == 0) {
return return
} }
val dy = newOffsetY - previousOffsetY
stopScroll() scrollBy(0, max(dy.roundToInt(), -computeVerticalScrollOffset()))
val trueOffset = offset - paddingTop
val itemHeight = itemHeight
val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight)
val firstItemTop = firstItemPosition * itemHeight - trueOffset
scrollToPositionWithOffset(firstItemPosition, firstItemTop)
}
private fun scrollToPositionWithOffset(position: Int, offset: Int) {
var targetPosition = position
val trueOffset = offset - paddingTop
when (val mgr = layoutManager) {
is GridLayoutManager -> {
targetPosition *= mgr.spanCount
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
}
is LinearLayoutManager -> {
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
}
}
} }
// --- SCROLLBAR APPEARANCE --- // --- SCROLLBAR APPEARANCE ---
@ -425,7 +288,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
showingThumb = true showingThumb = true
thumbAnimator?.cancel() thumbAnimator?.cancel()
thumbAnimator = fader.fadeIn(thumbView).also { it.start() } thumbAnimator = slider.slideIn(thumbView).also { it.start() }
} }
private fun hideScrollbar() { private fun hideScrollbar() {
@ -435,79 +298,16 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
showingThumb = false showingThumb = false
thumbAnimator?.cancel() thumbAnimator?.cancel()
thumbAnimator = fader.fadeOut(thumbView).also { it.start() } thumbAnimator = slider.slideOut(thumbView).also { it.start() }
}
private fun showPopup() {
if (showingPopup) {
return
}
popupView.scaleX = 0f
popupView.scaleY = 0f
popupView.alpha = 1f
showingPopup = true
popupAnimator?.cancel()
popupAnimator = fader.fadeIn(popupView).also { it.start() }
}
private fun hidePopup() {
if (!showingPopup) {
return
}
showingPopup = false
popupAnimator?.cancel()
popupAnimator = fader.fadeOut(popupView).also { it.start() }
} }
// --- LAYOUT STATE --- // --- LAYOUT STATE ---
private val thumbOffsetRange: Int private val thumbOffsetRange: Int
get() { get() {
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight return height - thumbPadding.top - thumbPadding.bottom - thumbSize
} }
private val scrollRange: Int
get() {
val itemCount = itemCount
if (itemCount == 0) {
return 0
}
val itemHeight = itemHeight
return if (itemHeight != 0) {
paddingTop + itemCount * itemHeight + paddingBottom
} else {
0
}
}
private val scrollOffsetRange: Int
get() = scrollRange - height
private val itemHeight: Int
get() {
if (childCount == 0) {
return 0
}
val itemView = getChildAt(0)
getDecoratedBoundsWithMargins(itemView, tRect)
return tRect.height()
}
private val itemCount: Int
get() =
when (val mgr = layoutManager) {
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
is LinearLayoutManager -> mgr.itemCount
else -> 0
}
/** An interface to provide text to use in the popup when fast-scrolling. */ /** An interface to provide text to use in the popup when fast-scrolling. */
interface PopupProvider { interface PopupProvider {
/** /**
@ -531,6 +331,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private companion object { private companion object {
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500 const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 500
} }
} }

View file

@ -200,3 +200,25 @@ class MaterialFlipper(context: Context) {
return AnimatorSet().apply { playTogether(outAnimator, inAnimator) } return AnimatorSet().apply { playTogether(outAnimator, inAnimator) }
} }
} }
class MaterialSlider(context: Context, private val x: Int) {
private val outConfig =
AnimConfig.of(context, AnimConfig.EMPHASIZED_ACCELERATE, AnimConfig.SHORT3)
private val inConfig =
AnimConfig.of(context, AnimConfig.EMPHASIZED_DECELERATE, AnimConfig.MEDIUM1)
fun jumpOut(view: View) {
view.translationX = x.toFloat()
}
fun slideOut(view: View): Animator {
val animator =
outConfig.genericFloat(view.translationX, x.toFloat()) { view.translationX = it }
return animator
}
fun slideIn(view: View): Animator {
val animator = inConfig.genericFloat(view.translationX, 0f) { view.translationX = it }
return animator
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,840L300,660L358,602L480,724L602,602L660,660L480,840ZM358,362L300,304L480,124L660,304L602,362L480,240L358,362Z"/>
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:tint="?attr/colorSecondary">
<corners android:radius="16dp" />
<size
android:width="56dp"
android:height="56dp" />
<solid android:color="@android:color/white" />
</shape>

View file

@ -3,14 +3,9 @@
android:shape="rectangle" android:shape="rectangle"
android:tint="?attr/colorSecondary"> android:tint="?attr/colorSecondary">
<corners android:radius="8dp" /> <corners android:topLeftRadius="24dp" android:bottomLeftRadius="24dp" />
<padding
android:bottom="4dp"
android:left="2dp"
android:right="2dp"
android:top="4dp" />
<size <size
android:width="8dp" android:width="48dp"
android:height="52dp" /> android:height="48dp" />
<solid android:color="@android:color/white" /> <solid android:color="@android:color/white" />
</shape> </shape>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView xmlns:android="http://schemas.android.com/apk/res/android" <org.oxycblt.auxio.list.recycler.FastScrollRecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/home_recycler" android:id="@+id/home_recycler"
style="@style/Widget.Auxio.RecyclerView.Grid.WithAdaptiveFab" style="@style/Widget.Auxio.RecyclerView.Grid.WithAdaptiveFab"

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:background="@drawable/ui_scroll_thumb">
<ImageView
android:layout_width="@dimen/size_touchable_small"
android:layout_height="@dimen/size_touchable_small"
app:tint="?attr/colorOnSecondary"
android:scaleType="centerInside"
android:src="@drawable/ic_scroll_24" />
</FrameLayout>

View file

@ -25,6 +25,9 @@
<dimen name="size_icon_large">40dp</dimen> <dimen name="size_icon_large">40dp</dimen>
<dimen name="size_icon_huge">48dp</dimen> <dimen name="size_icon_huge">48dp</dimen>
<dimen name="width_scroll_thumb">48dp</dimen>
<dimen name="height_scroll_thumb">48dp</dimen>
<!-- Misc --> <!-- Misc -->
<dimen name="m3_shape_corners_large">16dp</dimen> <dimen name="m3_shape_corners_large">16dp</dimen>
<dimen name="m3_shape_corners_full">128dp</dimen> <dimen name="m3_shape_corners_full">128dp</dimen>