diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt index 7b94d2a6381f..82ac471132f8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt @@ -41,6 +41,7 @@ import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.libanki.withCollapsedWhitespace import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.ui.attachFastScroller import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.ui.AccessibleSearchView import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown @@ -230,6 +231,7 @@ class TagsDialog : AnalyticsDialogFragment { tagsArrayAdapter = TagsArrayAdapter(tags) { binding.root.showMaxTagSelectedNotice(tags) } binding.tagsList.adapter = tagsArrayAdapter + binding.tagsList.attachFastScroller(R.id.tags_scroller) if (tags.isEmpty) { binding.noTagsTextView.visibility = View.VISIBLE } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 59f093ac98c3..b99805581b5e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -54,8 +54,10 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.view.animation.LinearInterpolator import android.widget.FrameLayout import androidx.annotation.IdRes +import androidx.annotation.VisibleForTesting import androidx.core.graphics.drawable.toDrawable import androidx.core.view.GravityCompat import androidx.interpolator.view.animation.FastOutLinearInInterpolator @@ -85,8 +87,6 @@ class RecyclerFastScroller private val minScrollHandleHeight: Int = 48.dp.toPx(context) var onHandleTouchListener: OnTouchListener? = null - private var appBarLayoutOffset: Int = 0 - private var recyclerView: RecyclerView? = null private var animator: AnimatorSet? = null @@ -106,12 +106,65 @@ class RecyclerFastScroller private var hideOverride = false private var adapter: RecyclerView.Adapter<*>? = null + + // Thumb height and range, cached so the handle keeps a steady size and scale while + // scrolling rows of different heights. 0 means "recompute on the next layout". Item count, + // bar height and width invalidate it (width because rows can rewrap and change the total + // scroll range). + private var cachedHandleHeight = 0 + private var cachedScrollRange = 0 + private var cachedItemCount = RecyclerView.NO_POSITION + private var cachedBarHeight = 0 + private var cachedWidth = 0 + private var accumulatedScrollOffset = 0 + private var scrollOffsetInitialized = false + private var scrollRangeCalibrated = false + private var canCalibrateScrollRange = false + private var isDraggingHandle = false + private var handlePositionInitialized = false + private var wasAtBottom = false + private var isAnimatingHandleToBottom = false + private var handleAnimationTargetY = 0f + + private fun invalidateScrollMetrics() { + cachedHandleHeight = 0 + cachedScrollRange = 0 + cachedItemCount = RecyclerView.NO_POSITION + cachedBarHeight = 0 + cachedWidth = 0 + accumulatedScrollOffset = 0 + scrollOffsetInitialized = false + scrollRangeCalibrated = false + canCalibrateScrollRange = false + handlePositionInitialized = false + wasAtBottom = false + isAnimatingHandleToBottom = false + } + + private fun onAdapterDataChanged() { + invalidateScrollMetrics() + requestLayout() + } + + // We cache scroll thumb params to make it smooth on variable-height rows, + // since RecyclerView's scrollbar range is only an estimate. + // We update the cached values when the adapter changes or the bar height/width changes. private val adapterObserver: RecyclerView.AdapterDataObserver = object : RecyclerView.AdapterDataObserver() { - override fun onChanged() { - super.onChanged() - requestLayout() - } + override fun onChanged() = onAdapterDataChanged() + + // Content-only row updates, such as focus or selection changes, intentionally use + // the default no-op callback so they cannot move or resize the scroll thumb. + + override fun onItemRangeInserted( + positionStart: Int, + itemCount: Int, + ) = onAdapterDataChanged() + + override fun onItemRangeRemoved( + positionStart: Int, + itemCount: Int, + ) = onAdapterDataChanged() } /** @@ -241,6 +294,12 @@ class RecyclerFastScroller dy: Int, ) { super.onScrolled(recyclerView, dx, dy) + // Track normal list scrolling from real pixel deltas. While the handle is being dragged, + // the offset is set from the drag position instead, so do not apply RecyclerView's dy too. + if (!isDraggingHandle) { + val scrollablePixels = resolveScrollablePixels(recyclerView) + updateAccumulatedScrollOffset(recyclerView, dy, scrollablePixels) + } this@RecyclerFastScroller.show(true) } }, @@ -253,6 +312,7 @@ class RecyclerFastScroller this.adapter?.unregisterAdapterDataObserver(adapterObserver) adapter?.registerAdapterDataObserver(adapterObserver) this.adapter = adapter + invalidateScrollMetrics() } /** @@ -343,6 +403,8 @@ class RecyclerFastScroller // Force the handle to be selected since the user is touching the track (the parent container) and not the handle itself. handle.isPressed = true + isDraggingHandle = true + canCalibrateScrollRange = false // The valid scroll area is (height-handle.height), since the position of the handle is defined by it's top edge, we subtract it. val scrollableHeight = height - handle.height @@ -353,6 +415,11 @@ class RecyclerFastScroller ((event.y - handle.height / 2) / scrollableHeight.coerceAtLeast(1)) .coerceIn(0f, 1f) pendingScrollProportion = scrollProportion + recyclerView?.let { + val scrollablePixels = resolveScrollablePixels(it) + accumulatedScrollOffset = (scrollProportion * scrollablePixels).toInt().coerceIn(0, scrollablePixels) + scrollOffsetInitialized = scrollablePixels > 0 + } // Calculates the item index we want to go to by multiplying our ScrollProportion to the item count // e.g. if we are going to 50% then 0.5*itemcount gives us the index we need. // toInt prevents decimal values, and coerceIn here makes it so when we scroll all the way to the end, we don't get an out of bounds error. @@ -380,6 +447,8 @@ class RecyclerFastScroller recyclerView?.let { removeCallbacks(scrollTask) } scrollTask.run() } + handle.isPressed = false + isDraggingHandle = false false } else -> super.onTouchEvent(event) @@ -394,42 +463,264 @@ class RecyclerFastScroller bottom: Int, ) { super.onLayout(changed, left, top, right, bottom) - if (recyclerView == null) return - val scrollOffset = recyclerView!!.computeVerticalScrollOffset() + appBarLayoutOffset - val verticalScrollRange = ( - recyclerView!!.computeVerticalScrollRange() + - recyclerView!!.paddingBottom - ) + val recyclerView = recyclerView ?: return + // The adapter can be set after we were attached, so make sure our data observer is + // registered. Without it the cached thumb size never refreshes on a data change. + if (recyclerView.adapter !== adapter) attachAdapter(recyclerView.adapter) + + val itemCount = recyclerView.adapter?.itemCount ?: return + if (itemCount == 0) { + hideThumb() + return + } val barHeight = bar.height - val ratio = scrollOffset.toFloat() / (verticalScrollRange - barHeight) + if (barHeight == 0) return - var calculatedHandleHeight = (barHeight.toFloat() / verticalScrollRange * barHeight).toInt() - if (calculatedHandleHeight < minScrollHandleHeight) { - calculatedHandleHeight = minScrollHandleHeight + // The whole list fits, so there is nothing to scroll: keep the thumb hidden. + if (!recyclerView.canScrollVertically(-1) && !recyclerView.canScrollVertically(1)) { + hideThumb() + return } + hideOverride = false - if (calculatedHandleHeight >= barHeight) { - translationX = hiddenTranslationX.toFloat() - hideOverride = true + val measuredScrollRange = recyclerView.computeVerticalScrollRange() + recyclerView.paddingBottom + val scrollRange = resolveScrollRange(itemCount, barHeight, recyclerView.width, measuredScrollRange) + if (scrollRange <= barHeight) return + + val scrollablePixels = scrollRange - barHeight + updateAccumulatedScrollOffset(recyclerView, dy = 0, scrollablePixels) + + val handleHeight = resolveHandleHeight(barHeight, scrollRange) + val isAtBottom = !recyclerView.canScrollVertically(1) + val ratio = + computeDisplayScrollProportion( + scrollOffset = accumulatedScrollOffset, + scrollRange = cachedScrollRange, + barHeight = barHeight, + canScrollDown = !isAtBottom, + rangeCalibrated = scrollRangeCalibrated, + ) + + val y = ratio * (barHeight - handleHeight) + val animateToBottom = + shouldAnimateHandleToBottom( + wasAtBottom = wasAtBottom, + isAtBottom = isAtBottom, + isDraggingHandle = isDraggingHandle, + handlePositionInitialized = handlePositionInitialized, + ) + layoutHandle(y, handleHeight, animateToBottom) + wasAtBottom = isAtBottom + } + + private fun layoutHandle( + targetY: Float, + handleHeight: Int, + animateToBottom: Boolean, + ) { + handle.layout(handle.left, 0, handle.right, handleHeight) + + if (isAnimatingHandleToBottom && handleAnimationTargetY == targetY) return + + if (!handlePositionInitialized || isDraggingHandle || !animateToBottom) { + handle.animate().cancel() + isAnimatingHandleToBottom = false + handle.translationY = targetY + handlePositionInitialized = true return } - hideOverride = false + if (handle.translationY == targetY) return + handle.animate().cancel() + isAnimatingHandleToBottom = true + handleAnimationTargetY = targetY + handle + .animate() + .translationY(targetY) + .setDuration(HANDLE_POSITION_ANIMATION_DURATION_MS) + .setInterpolator(LinearInterpolator()) + .withEndAction { isAnimatingHandleToBottom = false } + .start() + } - val y = ratio * (barHeight - calculatedHandleHeight) + // Slides the scroller off screen and stops show() bringing it back while nothing scrolls. + private fun hideThumb() { + translationX = hiddenTranslationX.toFloat() + hideOverride = true + } + + private fun resolveScrollablePixels(recyclerView: RecyclerView): Int { + val itemCount = recyclerView.adapter?.itemCount ?: return 0 + val barHeight = bar.height + if (itemCount == 0 || barHeight == 0) return 0 - handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + calculatedHandleHeight) + val measuredScrollRange = recyclerView.computeVerticalScrollRange() + recyclerView.paddingBottom + val scrollRange = resolveScrollRange(itemCount, barHeight, recyclerView.width, measuredScrollRange) + return (scrollRange - barHeight).coerceAtLeast(0) + } + + private fun updateAccumulatedScrollOffset( + recyclerView: RecyclerView, + dy: Int, + scrollablePixels: Int, + ) { + if (scrollablePixels <= 0) { + accumulatedScrollOffset = 0 + scrollOffsetInitialized = false + return + } + + if (!scrollOffsetInitialized) { + accumulatedScrollOffset = recyclerView.computeVerticalScrollOffset().coerceAtLeast(0) + scrollOffsetInitialized = true + } else { + accumulatedScrollOffset = + computeScrollOffsetFromDelta( + currentOffset = accumulatedScrollOffset, + dy = dy, + canScrollUp = recyclerView.canScrollVertically(-1), + ) + } + + if (!recyclerView.canScrollVertically(-1)) { + accumulatedScrollOffset = 0 + canCalibrateScrollRange = !isDraggingHandle + } else if (!recyclerView.canScrollVertically(1) && canCalibrateScrollRange) { + cachedScrollRange = accumulatedScrollOffset + bar.height + scrollRangeCalibrated = true + } + } + + /** + * Scroll range for the current list, computed once and cached. RecyclerView estimates it + * from currently visible rows, so it can drift while scrolling variable-height rows. + * Recomputed when the data, bar height or width change (see [invalidateScrollMetrics]). + */ + private fun resolveScrollRange( + itemCount: Int, + barHeight: Int, + width: Int, + scrollRange: Int, + ): Int { + if (itemCount != cachedItemCount || barHeight != cachedBarHeight || width != cachedWidth) { + cachedHandleHeight = 0 + cachedScrollRange = 0 + cachedItemCount = itemCount + cachedBarHeight = barHeight + cachedWidth = width + accumulatedScrollOffset = 0 + scrollOffsetInitialized = false + scrollRangeCalibrated = false + canCalibrateScrollRange = false + handlePositionInitialized = false + wasAtBottom = false + isAnimatingHandleToBottom = false + } + + if (cachedScrollRange == 0) { + cachedScrollRange = scrollRange + } + + return cachedScrollRange + } + + private fun resolveHandleHeight( + barHeight: Int, + scrollRange: Int, + ): Int { + if (cachedHandleHeight == 0) { + cachedHandleHeight = computeThumbHeight(barHeight, scrollRange, minScrollHandleHeight) + } + return cachedHandleHeight } companion object { + private const val HANDLE_POSITION_ANIMATION_DURATION_MS = 100L private val DEFAULT_AUTO_HIDE_DELAY = 1500.milliseconds } } +/** + * Thumb height as the share of the content that fits on screen (bar height over the scroll + * range), clamped to a usable range. Independent of scroll position, so a cached value stays valid. + */ +@VisibleForTesting +internal fun computeThumbHeight( + barHeight: Int, + scrollRange: Int, + minHandleHeight: Int, +): Int = + (barHeight.toFloat() * barHeight / scrollRange.coerceAtLeast(1)) + .toInt() + .coerceAtLeast(minHandleHeight) + .coerceAtMost(barHeight) + +/** + * Scroll progress from 0f to 1f, measured in pixels so the thumb tracks the scroll smoothly on + * rows of different heights. Guards against a zero divisor when the list barely scrolls. + */ +@VisibleForTesting +internal fun computeScrollProportion( + scrollOffset: Int, + scrollRange: Int, + barHeight: Int, +): Float { + val scrollablePixels = (scrollRange - barHeight).coerceAtLeast(1) + return (scrollOffset.toFloat() / scrollablePixels).coerceIn(0f, 1f) +} + +@VisibleForTesting +internal fun computeScrollOffsetFromDelta( + currentOffset: Int, + dy: Int, + canScrollUp: Boolean, +): Int { + if (!canScrollUp) return 0 + return (currentOffset + dy).coerceAtLeast(0) +} + +/** + * Maps an uncalibrated range smoothly toward, but never onto, the end of the track. RecyclerView's + * first range estimate can be shorter than the real content, so a linear mapping would put the + * thumb at the bottom too early. Once a complete top-to-bottom scroll calibrates the range, the + * regular linear mapping is exact. + */ +@VisibleForTesting +internal fun computeDisplayScrollProportion( + scrollOffset: Int, + scrollRange: Int, + barHeight: Int, + canScrollDown: Boolean, + rangeCalibrated: Boolean, +): Float { + if (!canScrollDown) return 1f + + val scrollablePixels = (scrollRange - barHeight).coerceAtLeast(1) + val rawProportion = (scrollOffset.toFloat() / scrollablePixels).coerceAtLeast(0f) + if (rangeCalibrated) return computeScrollProportion(scrollOffset, scrollRange, barHeight) + if (rawProportion <= END_APPROACH_THRESHOLD) return rawProportion + + val tail = 1f - END_APPROACH_THRESHOLD + val excess = (rawProportion - END_APPROACH_THRESHOLD) / tail + return END_APPROACH_THRESHOLD + tail * excess / (1f + excess) +} + +private const val END_APPROACH_THRESHOLD = 0.9f + +@VisibleForTesting +internal fun shouldAnimateHandleToBottom( + wasAtBottom: Boolean, + isAtBottom: Boolean, + isDraggingHandle: Boolean, + handlePositionInitialized: Boolean, +): Boolean = handlePositionInitialized && !isDraggingHandle && !wasAtBottom && isAtBottom + fun RecyclerView.attachFastScroller( @IdRes id: Int, -) = (parent as ViewGroup) - .findViewById(id) - .attachRecyclerView(this) +) { + (parent as? ViewGroup) + ?.findViewById(id) + ?.attachRecyclerView(this) +} diff --git a/AnkiDroid/src/main/res/layout/dialog_tags.xml b/AnkiDroid/src/main/res/layout/dialog_tags.xml index 7625d3248b43..f66de7180aed 100644 --- a/AnkiDroid/src/main/res/layout/dialog_tags.xml +++ b/AnkiDroid/src/main/res/layout/dialog_tags.xml @@ -64,13 +64,25 @@ - + android:layout_below="@id/toolbar"> + + + + + +// SPDX-License-Identifier: GPL-3.0-or-later +package com.ichi2.anki.ui + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.closeTo +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.greaterThan +import org.hamcrest.Matchers.lessThan +import org.junit.Test + +class RecyclerFastScrollerTest { + @Test + fun `thumb height is the share of the content that fits on screen`() { + // a quarter of the content is visible, so the thumb is a quarter of the bar + assertThat(computeThumbHeight(barHeight = 1000, scrollRange = 4000, minHandleHeight = 48), equalTo(250)) + } + + @Test + fun `thumb height never drops below the minimum`() { + assertThat(computeThumbHeight(barHeight = 1000, scrollRange = 100_000, minHandleHeight = 48), equalTo(48)) + } + + @Test + fun `thumb height never exceeds the bar`() { + assertThat(computeThumbHeight(barHeight = 1000, scrollRange = 1000, minHandleHeight = 48), equalTo(1000)) + } + + @Test + fun `thumb height stays within the bar even when the minimum is taller than the bar`() { + assertThat(computeThumbHeight(barHeight = 30, scrollRange = 100_000, minHandleHeight = 48), equalTo(30)) + } + + @Test + fun `proportion is zero at the top of the list`() { + assertThat(computeScrollProportion(scrollOffset = 0, scrollRange = 4000, barHeight = 1000), equalTo(0f)) + } + + @Test + fun `proportion reaches one at the bottom of the list`() { + // the last screen starts at scrollRange - barHeight + assertThat(computeScrollProportion(scrollOffset = 3000, scrollRange = 4000, barHeight = 1000), equalTo(1f)) + } + + @Test + fun `proportion tracks scrolled pixels linearly`() { + // half the scrollable pixels means the thumb is at the middle: this is what keeps it + // smooth on uneven rows, since it follows pixels rather than item index + assertThat(computeScrollProportion(scrollOffset = 1500, scrollRange = 4000, barHeight = 1000).toDouble(), closeTo(0.5, 0.0001)) + assertThat(computeScrollProportion(scrollOffset = 750, scrollRange = 4000, barHeight = 1000).toDouble(), closeTo(0.25, 0.0001)) + } + + @Test + fun `proportion is clamped into range`() { + assertThat(computeScrollProportion(scrollOffset = 9999, scrollRange = 4000, barHeight = 1000), equalTo(1f)) + } + + @Test + fun `proportion does not divide by zero when the list barely scrolls`() { + // scrollRange == barHeight would be a zero divisor without the guard + assertThat(computeScrollProportion(scrollOffset = 0, scrollRange = 1000, barHeight = 1000), lessThan(1.0001f)) + } + + @Test + fun `scroll offset accumulates real pixel deltas`() { + assertThat( + computeScrollOffsetFromDelta( + currentOffset = 100, + dy = 25, + canScrollUp = true, + ), + equalTo(125), + ) + } + + @Test + fun `scroll offset can outgrow an estimate while the list can still scroll`() { + assertThat( + computeScrollOffsetFromDelta( + currentOffset = 995, + dy = 20, + canScrollUp = true, + ), + equalTo(1015), + ) + } + + @Test + fun `scroll offset is clamped at the start of the list`() { + assertThat( + computeScrollOffsetFromDelta( + currentOffset = 5, + dy = -20, + canScrollUp = true, + ), + equalTo(0), + ) + } + + @Test + fun `thumb does not reach the end while the list can still scroll`() { + assertThat( + computeDisplayScrollProportion( + scrollOffset = 3015, + scrollRange = 4000, + barHeight = 1000, + canScrollDown = true, + rangeCalibrated = false, + ), + lessThan(1f), + ) + } + + @Test + fun `thumb continues moving smoothly after passing the estimated range`() { + val atEstimate = + computeDisplayScrollProportion( + scrollOffset = 3000, + scrollRange = 4000, + barHeight = 1000, + canScrollDown = true, + rangeCalibrated = false, + ) + val pastEstimate = + computeDisplayScrollProportion( + scrollOffset = 4500, + scrollRange = 4000, + barHeight = 1000, + canScrollDown = true, + rangeCalibrated = false, + ) + + assertThat(pastEstimate, greaterThan(atEstimate)) + assertThat(pastEstimate, lessThan(1f)) + } + + @Test + fun `thumb reaches the end only at the real list edge`() { + assertThat( + computeDisplayScrollProportion( + scrollOffset = 4500, + scrollRange = 4000, + barHeight = 1000, + canScrollDown = false, + rangeCalibrated = false, + ), + equalTo(1f), + ) + } + + @Test + fun `calibrated range uses linear pixel progress`() { + assertThat( + computeDisplayScrollProportion( + scrollOffset = 1500, + scrollRange = 4000, + barHeight = 1000, + canScrollDown = true, + rangeCalibrated = true, + ), + equalTo(0.5f), + ) + } + + @Test + fun `scroll offset snaps to the start of the list`() { + assertThat( + computeScrollOffsetFromDelta(currentOffset = 100, dy = 25, canScrollUp = false), + equalTo(0), + ) + } + + @Test + fun `handle animation is used only when normal scrolling reaches the bottom`() { + assertThat( + shouldAnimateHandleToBottom( + wasAtBottom = false, + isAtBottom = true, + isDraggingHandle = false, + handlePositionInitialized = true, + ), + equalTo(true), + ) + assertThat( + shouldAnimateHandleToBottom( + wasAtBottom = false, + isAtBottom = false, + isDraggingHandle = false, + handlePositionInitialized = true, + ), + equalTo(false), + ) + assertThat( + shouldAnimateHandleToBottom( + wasAtBottom = false, + isAtBottom = true, + isDraggingHandle = true, + handlePositionInitialized = true, + ), + equalTo(false), + ) + } +}