RecyclerView包含不同高度的项目:Scrollbar

时间:2017-09-04 08:51:06

标签: android android-recyclerview scrollbar

我有一个RecyclerView,其中包含不同高度的项目和滚动条。 由于项目的高度不同,滚动条会更改其垂直大小,具体取决于当前显示的项目(请参见屏幕截图)。 我创建了一个显示问题here的示例项目。

  1. 有没有人遇到同样的问题并修好了?
  2. 如何覆盖滚动条高度和位置的计算以提出自己的实现?
  3. 编辑:滚动条的位置和高度可以通过覆盖RecyclerViews computeVerticalScrollOffsetcomputeVerticalScrollRangecomputeVerticalScrollExtent来控制。 我不知道如何实现这些以使滚动条与动态项目高度正常工作。

    我认为,问题是RecyclerView根据当前可见的项目估算所有项目的总高度,并相应地设置滚动条的位置和高度。解决这个问题的一种方法可能是更好地估计所有物品的总高度。

    large scrollbar small scrollbar

4 个答案:

答案 0 :(得分:5)

处理这种情况的最佳方法可能是以某种方式根据每个项目的大小计算滚动条范围。这可能不实际或不可取。取而代之的是,这是一个简单的自定义RecyclerView实现,您可以使用它来尝试获得您想要的东西。它将向您展示如何使用各种滚动方法来控制滚动条。它将根据显示的项目数量将拇指的大小固定为初始大小。要记住的关键是滚动范围是任意的,但所有其他测量(范围,偏移)必须使用相同的单位。

请参阅computeVerticalScrollRange()的文档。

以下是结果视频。

enter image description here

更新:代码已更新以纠正一些问题:拇指的移动不那么生涩,拇指现在会在RecyclerView滚动时停留在底部至底部。在代码之后还有一些警告。

MyRecyclerView.java(已更新)

public class MyRecyclerView extends RecyclerView {
    // The size of the scroll bar thumb in our units.
    private int mThumbHeight = UNDEFINED;

    // Where the RecyclerView cuts off the views when the RecyclerView is scrolled to top.
    // For example, if 1/4 of the view at position 9 is displayed at the bottom of the RecyclerView,
    // mTopCutOff will equal 9.25. This value is used to compute the scroll offset.
    private float mTopCutoff = UNDEFINED;

    public MyRecyclerView(Context context) {
        super(context);
    }

    public MyRecyclerView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public MyRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    /**
     * Retrieves the size of the scroll bar thumb in our arbitrary units.
     *
     * @return Scroll bar thumb height
     */
    @Override
    public int computeVerticalScrollExtent() {
        return (mThumbHeight == UNDEFINED) ? 0 : mThumbHeight;
    }

    /**
     * Compute the offset of the scroll bar thumb in our scroll bar range.
     *
     * @return Offset in scroll bar range.
     */
    @Override
    public int computeVerticalScrollOffset() {
        return (mTopCutoff == UNDEFINED) ? 0 : (int) ((getCutoff() - mTopCutoff) * ITEM_HEIGHT);
    }

    /**
     * Computes the scroll bar range. It will simply be the number of items in the adapter
     * multiplied by the given item height. The scroll extent size is also computed since it
     * will not vary. Note: The RecyclerView must be positioned at the top or this method
     * will throw an IllegalStateException.
     *
     * @return The scroll bar range
     */
    @Override
    public int computeVerticalScrollRange() {
        if (mThumbHeight == UNDEFINED) {
            LinearLayoutManager lm = (LinearLayoutManager) getLayoutManager();
            int firstCompletePositionw = lm.findFirstCompletelyVisibleItemPosition();

            if (firstCompletePositionw != RecyclerView.NO_POSITION) {
                if (firstCompletePositionw != 0) {
                    throw (new IllegalStateException(ERROR_NOT_AT_TOP_OF_RANGE));
                } else {
                    mTopCutoff = getCutoff();
                    mThumbHeight = (int) (mTopCutoff * ITEM_HEIGHT);
                }
            }
        }
        return getAdapter().getItemCount() * ITEM_HEIGHT;
    }

    /**
     * Determine where the RecyclerVIew display cuts off the list of views. The range is
     * zero through (getAdapter().getItemCount() - 1) inclusive.
     *
     * @return The position in the RecyclerView where the displayed views are cut off. If the
     * bottom view is partially displayed, this will be a fractional number.
     */
    private float getCutoff() {
        LinearLayoutManager lm = (LinearLayoutManager) getLayoutManager();
        int lastVisibleItemPosition = lm.findLastVisibleItemPosition();

        if (lastVisibleItemPosition == RecyclerView.NO_POSITION) {
            return 0f;
        }

        View view = lm.findViewByPosition(lastVisibleItemPosition);
        float fractionOfView;

        if (view.getBottom() < getHeight()) { // last visible position is fully visible
            fractionOfView = 0f;
        } else { // last view is cut off and partially displayed
            fractionOfView = (float) (getHeight() - view.getTop()) / (float) view.getHeight();
        }
        return lastVisibleItemPosition + fractionOfView;
    }

    private static final int ITEM_HEIGHT = 1000; // Arbitrary, make largish for smoother scrolling
    private static final int UNDEFINED = -1;
    private static final String ERROR_NOT_AT_TOP_OF_RANGE
            = "RecyclerView must be positioned at the top of its range.";
}

<强>注意事项 根据实施情况,可能需要解决以下问题。

示例代码仅适用于垂直滚动。示例代码还假定RecyclerView的内容是静态的。对RecyclerView支持的数据的任何更新都可能导致滚动问题。如果进行任何影响RecyclerView的第一个完整屏幕上显示的任何视图的高度的更改,则滚动将关闭。下面的更改可能会正常工作。这是由于代码如何计算滚动偏移量。

要确定滚动偏移的基本值(变量mTopCutOff),必须在第一次调用computeVerticalScrollRange()时将RecyclerView滚动到顶部,以便可以测量视图;否则,代码将以“IllegalStateException”停止。如果RecyclerView完全滚动,则在方向更改时尤其麻烦。解决这个问题的一个简单方法是禁止恢复滚动位置,以便在方向更改时默认为顶部。

(以下可能不是最佳解决方案......)

var lm: LinearLayoutManager = object : LinearLayoutManager(this) {
    override fun onRestoreInstanceState(state: Parcelable?) {
        // Don't restore
    }
}

我希望这会有所帮助。 (顺便说一下,你的MCVE让这变得容易多了。)

答案 1 :(得分:1)

使用项目位置作为滚动进度的指标。这将导致您的滚动指示器变得有点跳跃,但至少它将保持固定大小。

RecyclerView有多种自定义滚动指示器实现。大多数是快速滚动的双倍。

以下是my own implementation,基于RecyclerViewFastScroller library。基本上,必须创建一个自定义View子类,它将被动画化,类似于ScrollView和DrawerLayout:

  • 存储当前偏移
  • 通过View#offset*来电
  • 查看动画偏移位置
  • 基于当前偏移的布局设置位置。

你现在可能不想开始学习所有魔法,只需使用一些现有的快速滚动库(RecyclerViewFastScroller或其中一个克隆)。

答案 2 :(得分:1)

Cheticamp's solution的启发,我设法旋转了自己的RecyclerView扩展名,该扩展名没有computeVerticalScrollRange限制。 实际上,这种替代解决方案根本不需要扩展computeVerticalScrollRange

通过跨度推理,我设法想到了一种不依赖于计算RecyclerView中任何项目的高度的解决方案。

列表中的每个项目的跨度为1,我将滚动条的拇指大小固定为一定的跨度(这意味着滚动条不会随着用户滚动而改变其高度)

现在考虑以下事项:

  • rangeSpanCount是跨接的数量,也就是适配器中的项目数量
  • firstSpan是第一个可见范围的位置(如果有的话,第一个完全可见;否则,第一个部分可见)
  • lastSpan为最后一个可见范围的位置(如果有的话,最后一个完全可见,否则为部分可见)
  • visibleSpanCount等于lastSpan - firstSpan,是屏幕上当前可见的跨度数
  • remainingSpanCount等于rangeSpanCount - 1 - visibleSpanCount,即RecyclerView中剩余的跨度数

然后出于解释的考虑,假设我们有9个跨度的列表,并且在任何给定时间都只能看到其中的3个(尽管逻辑保持不变,即使在给定时刻可见跨度的数量是动态的) :

0 1 2 3 4 5 6 7 8 9

0 1 2{------------}
                |    the size of this range is:
{--}2 3 4       |===========>  rangeSpanCount - 1 - visibleSpanCount

{-----}3 4 5

{-------}4 5 6

{-----------}6 7 8

{-------------}7 8 9
       |   you can see that this range is simply computed as:
       |===========> firstSpan - 0

然后注意我们如何使用随着上下滚动发生的变化范围 在任何给定时刻不可见的跨度范围来计算滚动进度在整个RecyclerView中。

首先,我们确定增长范围增长了多少:

partialProgress = (firstSpan - 0) / remainingSpanCount

(从0%一直到firstSpan == remainingSpanCount时为100%)

然后,我们计算可见范围中的哪个范围更好地表示整个RecyclerView中的滚动进度。基本上,我们要确保当RecyclerView位于最顶部时选择第一个跨度(位置0),而当到达最底部时选择最后一个跨度(位置rangeSpanCount - 1) 。这很重要,否则到达这些边缘时滚动将关闭。

progressSpan = firstSpan + (visibleSpanCount * partialProgress)

最后,您可以使用此选定跨度的位置和跨度总数来确定RecyclerView上的实际进度百分比,并使用实际计算的滚动范围来确定滚动条的最佳偏移量:

scrollProgress = progressSpan / rangeSpanCount

scrollOffset = scrollProgress * super.computeVerticalScrollRange()

就是这样!该解决方案可以适应水平轴,因此它不会带来Cheticamp替代产品的任何警告。

它有一个警告,:滚动条拇指的运动是离散的,沿着轴不连续,这意味着从一个位置跳到另一个位置很明显。不过,这是一致的,在用户执行向任何方向滚动时,切勿自发晃动/来回移动。

关于适配器中的项目数量,可以通过使用更大范围的跨度(例如,每个项目具有多个跨度)来解决这一警告,但是我现在并没有考虑太多。 / p>

我希望我的解释很明确...并且感谢大家为我提供的答案,这确实为我指明了正确的方向!

下面您可以查看完整的解决方案和源代码:

package cz.nn.calllog.view.utils.recyclerview

import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView


class SmartScrollbarRecyclerView(
    context: Context,
    attributeSet: AttributeSet?,
    defaultStyleAttribute: Int
) : RecyclerView(context, attributeSet, defaultStyleAttribute) {

    constructor(
        context: Context,
        attributeSet: AttributeSet
    ) : this(context, attributeSet, 0)

    constructor(
        context: Context
    ) : this(context, null, 0)

    override fun computeVerticalScrollExtent(): Int {
        return checkCalculationPrerequisites(
            onFailure = {
                super.computeVerticalScrollExtent()
            },
            onSuccess = { _, rangeSpan, scrollRange ->

                val extentSpanCount = 1.5F
                val scrollExtent = (extentSpanCount / rangeSpan)

                (scrollExtent * scrollRange).toInt()
            }
        )
    }

    override fun computeVerticalScrollOffset(): Int {
        return checkCalculationPrerequisites(
            onFailure = {
                super.computeVerticalScrollOffset()
            },
            onSuccess = { layoutManager, rangeSpanCount, scrollRange ->

                val firstSpanPosition = calculateFirstVisibleItemPosition(layoutManager)
                val lastSpanPosition = calculateLastVisibleItemPosition(layoutManager)
                val visibleSpanCount = lastSpanPosition - firstSpanPosition

                val remainingSpanCount = rangeSpanCount - 1 - visibleSpanCount

                val partialProgress = (firstSpanPosition / remainingSpanCount)
                val progressSpanPosition = firstSpanPosition + (visibleSpanCount * partialProgress)
                val scrollProgress = progressSpanPosition / rangeSpanCount

                (scrollProgress * scrollRange).toInt()
            }
        )
    }

    private fun calculateFirstVisibleItemPosition(layoutManager: LinearLayoutManager): Int {
        val firstCompletelyVisibleItemPosition = layoutManager.findFirstCompletelyVisibleItemPosition()
        return if (firstCompletelyVisibleItemPosition == -1) {
            layoutManager.findFirstVisibleItemPosition()
        } else {
            firstCompletelyVisibleItemPosition
        }
    }

    private fun calculateLastVisibleItemPosition(layoutManager: LinearLayoutManager): Int {
        val lastCompletelyVisibleItemPosition = layoutManager.findLastCompletelyVisibleItemPosition()
        return if (lastCompletelyVisibleItemPosition == -1) {
            layoutManager.findLastVisibleItemPosition()
        } else {
            lastCompletelyVisibleItemPosition
        }
    }

    private fun checkCalculationPrerequisites(
        onFailure: () -> Int,
        onSuccess: (LinearLayoutManager, Float, Int) -> Int
    ): Int {

        val layoutManager = layoutManager

        if (layoutManager !is LinearLayoutManager) {
            return onFailure.invoke()
        }

        val scrollRange = computeVerticalScrollRange()
        if (scrollRange < height) {
            return 0
        }

        val rangeSpanCount = calculateRangeSpanCount()
        if (rangeSpanCount == 0F) {
            return 0
        }

        return onSuccess.invoke(layoutManager, rangeSpanCount, scrollRange)
    }

    private fun calculateRangeSpanCount(): Float {
        val recyclerAdapter = adapter ?: return 0F
        return recyclerAdapter.itemCount.toFloat()
    }
}

答案 3 :(得分:0)

如果我没弄错,属性android:scollBarSize="Xdp"应该对你有用。将其添加到RecyclerView xml。

这样你决定了大小,它将保持不变。