Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import com.ichi2.anki.libanki.NoteId
import com.ichi2.anki.libanki.withCollapsedWhitespace
import com.ichi2.anki.model.CardStateFilter
import com.ichi2.anki.snackbar.showSnackbar
<<<<<<< fix-scroll-thomb
import com.ichi2.anki.ui.attachFastScroller
=======
import com.ichi2.anki.utils.ext.requireParcelable
>>>>>>> main
import com.ichi2.ui.AccessibleSearchView
import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown
import com.ichi2.utils.TagsUtil
Expand Down Expand Up @@ -230,6 +234,7 @@ class TagsDialog : AnalyticsDialogFragment {

tagsArrayAdapter = TagsArrayAdapter(tags) { binding.root.showMaxTagSelectedNotice(tags) }
binding.tagsList.adapter = tagsArrayAdapter
binding.tagsList.attachFastScroller(R.id.tags_scroller)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is scroller ID is not found ? It may crash
worth adding a null safe

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thank you. I made attachFastScroller null safe

if (tags.isEmpty) {
binding.noTagsTextView.visibility = View.VISIBLE
}
Expand Down
175 changes: 158 additions & 17 deletions AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,68 @@ class RecyclerFastScroller

private var hideOverride = false
private var adapter: RecyclerView.Adapter<*>? = null

private var cachedHandleHeight = 0
private var cachedHandleHeightItemCount = RecyclerView.NO_POSITION
private var cachedHandleHeightBarHeight = 0
private var cachedVisibleItemCount = 0
private var cachedVisibleItemCountItemCount = RecyclerView.NO_POSITION
private var cachedVisibleItemCountBarHeight = 0

private fun resetCachedVisibleItemCount() {
cachedVisibleItemCount = 0
cachedVisibleItemCountItemCount = RecyclerView.NO_POSITION
cachedVisibleItemCountBarHeight = 0
}

private fun resetCachedHandleHeight() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document it

cachedHandleHeight = 0
cachedHandleHeightItemCount = RecyclerView.NO_POSITION
cachedHandleHeightBarHeight = 0
}

/**
* Cached scroll metrics are tied to the current adapter contents and viewport size.
* They are reset when adapter data changes so the thumb can be recalculated for the
* new list, but stay stable during normal scrolling.
*/
private fun resetCachedScrollMetrics() {
resetCachedHandleHeight()
resetCachedVisibleItemCount()
}

private val adapterObserver: RecyclerView.AdapterDataObserver =
object : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
resetCachedScrollMetrics()
requestLayout()
}

override fun onItemRangeChanged(
positionStart: Int,
itemCount: Int,
) {
super.onItemRangeChanged(positionStart, itemCount)
resetCachedScrollMetrics()
requestLayout()
}

override fun onItemRangeInserted(
positionStart: Int,
itemCount: Int,
) {
super.onItemRangeInserted(positionStart, itemCount)
resetCachedScrollMetrics()
requestLayout()
}

override fun onItemRangeRemoved(
positionStart: Int,
itemCount: Int,
) {
super.onItemRangeRemoved(positionStart, itemCount)
resetCachedScrollMetrics()
requestLayout()
}
}
Expand Down Expand Up @@ -253,6 +311,7 @@ class RecyclerFastScroller
this.adapter?.unregisterAdapterDataObserver(adapterObserver)
adapter?.registerAdapterDataObserver(adapterObserver)
this.adapter = adapter
resetCachedScrollMetrics()
}

/**
Expand Down Expand Up @@ -394,33 +453,115 @@ 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
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
val itemCount = recyclerView.adapter?.itemCount ?: return
if (itemCount == 0) return

val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
if (firstVisiblePosition == RecyclerView.NO_POSITION || lastVisiblePosition == RecyclerView.NO_POSITION) return

val barHeight = bar.height
val ratio = scrollOffset.toFloat() / (verticalScrollRange - barHeight)
val visibleItemCount =
getCachedVisibleItemCount(
barHeight,
itemCount,
firstVisiblePosition,
lastVisiblePosition,
)
val calculatedHandleHeight = getCachedHandleHeight(barHeight, itemCount, visibleItemCount)

val ratio =
getScrollProportion(
recyclerView,
layoutManager,
itemCount,
visibleItemCount,
firstVisiblePosition,
)

var calculatedHandleHeight = (barHeight.toFloat() / verticalScrollRange * barHeight).toInt()
if (calculatedHandleHeight < minScrollHandleHeight) {
calculatedHandleHeight = minScrollHandleHeight
hideOverride = false

val y = ratio * (barHeight - calculatedHandleHeight)
handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + calculatedHandleHeight)
}

/**
* Calculates thumb position from adapter positions so the scroll progress remains
* stable when rows have different heights. The fractional offset within the first
* visible row keeps the thumb moving smoothly between adapter positions.
*/
private fun getScrollProportion(
recyclerView: RecyclerView,
layoutManager: LinearLayoutManager,
itemCount: Int,
visibleItemCount: Int,
firstVisiblePosition: Int,
): Float {
if (!recyclerView.canScrollVertically(-1)) return 0f
if (!recyclerView.canScrollVertically(1)) return 1f

val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePosition) ?: return 0f
val hiddenHeight = (recyclerView.paddingTop - firstVisibleView.top).coerceAtLeast(0)
val firstItemFraction = hiddenHeight.toFloat() / firstVisibleView.height.coerceAtLeast(1)

val scrollableItems = (itemCount - visibleItemCount).coerceAtLeast(1)
return ((firstVisiblePosition + firstItemFraction) / scrollableItems).coerceIn(0f, 1f)
}

/**
* Keeps the visible item count stable during scrolling. Recomputing it for every
* layout pass could change the thumb size as rows with different heights enter or
* leave the viewport.
*/
private fun getCachedVisibleItemCount(
barHeight: Int,
itemCount: Int,
firstVisiblePosition: Int,
lastVisiblePosition: Int,
): Int {
if (itemCount != cachedVisibleItemCountItemCount || barHeight != cachedVisibleItemCountBarHeight) {
resetCachedVisibleItemCount()
cachedVisibleItemCountItemCount = itemCount
cachedVisibleItemCountBarHeight = barHeight
}

if (calculatedHandleHeight >= barHeight) {
translationX = hiddenTranslationX.toFloat()
hideOverride = true
return
if (cachedVisibleItemCount == 0) {
cachedVisibleItemCount =
(lastVisiblePosition - firstVisiblePosition + 1)
.coerceAtLeast(1)
.coerceAtMost(itemCount)
}

hideOverride = false
return cachedVisibleItemCount
}

val y = ratio * (barHeight - calculatedHandleHeight)
/**
* Calculates thumb height from the visible item count and total item count so the
* size remains stable while scrolling through rows with different heights.
*/
private fun getCachedHandleHeight(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document it

barHeight: Int,
itemCount: Int,
visibleItemCount: Int,
): Int {
if (itemCount != cachedHandleHeightItemCount || barHeight != cachedHandleHeightBarHeight) {
resetCachedHandleHeight()
cachedHandleHeightItemCount = itemCount
cachedHandleHeightBarHeight = barHeight
}

handle.layout(handle.left, y.toInt(), handle.right, y.toInt() + calculatedHandleHeight)
if (cachedHandleHeight == 0) {
cachedHandleHeight =
(barHeight.toFloat() * visibleItemCount / itemCount)
.toInt()
.coerceAtLeast(minScrollHandleHeight)
.coerceAtMost(barHeight)
}

return cachedHandleHeight
}

companion object {
Expand Down
22 changes: 17 additions & 5 deletions AnkiDroid/src/main/res/layout/dialog_tags.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,25 @@
</LinearLayout>


<androidx.recyclerview.widget.RecyclerView android:id="@+id/tags_list"
android:scrollbars="vertical"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you explain why ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wrapper needs to fill the available list area because it now contains both the RecyclerView and the overlay fast scroller. Keeping wrap_content would size the wrapper from its children instead of the available viewport, so the scroller track could end up with the wrong height.

android:layout_above="@id/options_group"
android:layout_below="@id/toolbar"
tools:listitem="@layout/item_tag" />
android:layout_below="@id/toolbar">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tags_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="none"
tools:listitem="@layout/item_tag" />

<com.ichi2.anki.ui.RecyclerFastScroller
android:id="@+id/tags_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_alignParentEnd="true" />
</RelativeLayout>

<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="wrap_content"
Expand Down
Loading