可拖拽滚动条 - 产品瞎提需求怎么办
in Android with 0 comment

需求

产品:我们需要一个类似于浏览器的滚动条,用户可以拖拽快速定位,你来实现一个。
我:原生又不支持,做不到
产品:我不要你觉得,我要我觉得,快做
我:(╯‵□′)╯︵┻━┻

演示效果

f3e7955b-03ee-4945-94f7-914980ca93b1.gif

写在前面

纯干货,希望您能看完

基本原理

2023-07-26T01:01:27.png
画了个简易示意图(字丑见谅)
对于一个可滚动布局来说,分为3个部分

  1. 可见窗口
  2. 可滚动的布局整体
  3. 滚动条

思维转换一下,一个滚动布局,其实就是一个使一个窗口在滚动布局上滑动,使得我们可以看到的内容发生变化(实际逻辑也是如此,平移画布将不需要的内容移出)
而对于滚动条来说,尝试将滚动条宽度撑满,它就变成了一个可移动的窗口,原本的窗口就变成了可滚动布局。
很明显,他们Y方向的长度都是成等比例的关系的,

// 高度的比例关系及求值公式
scrollbar.height / screen.height = screen.height / child.height
scrollbar.heigth = screen.height^2 / child.height
// 位移的比例关系
scrollbar.y / screen.height = child.scrollY / child.height
scrollbar.y = screen.height * child.scrollY / child.height

计算方法

ScrollView

基于以上计算逻辑,接下来开始进行ScrollView的滚动条定制。

scrollBar.height = scrollView.height^2 / getChildAt(0).height;
scrollBar.y = scrollView.height * scrollView.getScrollY() / getChildAt(0).height;

RecyclerView

RecyclerView 相对于ScrollView来说存在复用的场景,无法计算出滚动区域的高度。
并且因为RecyclerView并不是通过scrollY 去控制内部平移的,之前的那一套完全无用,还有其它方法吗?

通过阅读源码发现RecyclerViewcomputeVerticalScrollOffsetcomputeVerticalScrollRange方法(当然,同样有用于计算竖向的computeHorizontalScrollOffsetcomputeHorizontalScrollRange方法)

computeVerticalScrollOffset
Compute the vertical offset of the vertical scrollbar's thumb within the vertical range. This value is used to compute the length of the thumb within the scrollbar's track.
计算垂直滚动条拇指在垂直范围内的垂直偏移量。 该值用于计算滚动条轨道内滑块的长度。

看来Google已经知道你们想要自己实现了,提前把接口都暴露出来了,我们直接使用即可。

scrollBar.height = recyclerView.height^2 / recyclerView.computeVerticalScrollRange;
scrollBar.y = recyclerView.height * recyclerView.computeVerticalScrollOffset / recyclerView.computeVerticalScrollRange;

ListView

ListView同样存在视图复用,有了RecyclerView的经验,这不手到擒来,简单看了一下看到ListView也有computeVerticalScrollOffset方法,照抄就行?
很遗憾,不行,RecyclerView重写了相关方法,而ListView只有基类的方法存在,我们看下基类的实现

@Override
protected int computeVerticalScrollOffset() {
    final int firstPosition = mFirstPosition;
    final int childCount = getChildCount();
    if (firstPosition >= 0 && childCount > 0) {
        if (mSmoothScrollbarEnabled) {
            final View view = getChildAt(0);
            final int top = view.getTop();
            int height = view.getHeight();
            if (height > 0) {
                // 看乐了,直接魔法值100搞上来了,这里直接默认每个View的高度是100了,如果直接使用你会发现滚动条总会差那么一点点
                return Math.max(firstPosition * 100 - (top * 100) / height +
                        (int)((float)mScrollY / getHeight() * mItemCount * 100), 0);
            }
        } else {
            // 这里更离谱,不装了,你们高度都是1
            int index;
            final int count = mItemCount;
            if (firstPosition == 0) {
                index = 0;
            } else if (firstPosition + childCount == count) {
                index = count;
            } else {
                index = firstPosition + childCount / 2;
            }
            return (int) (firstPosition + childCount * (index / (float) count));
        }
    }
    return 0;
}

这条路走不动,只能另寻他路,针对ListView还有一套笨方法,实在不推荐使用,如果有滚动条相关的需求建议切到RecyclerView

// 在onLayout时遍历adapter重新测量一遍所有子View用于获取每个item的高度及总高度
// 好消息是这里只会在子View的个数或子View本身高度发生变化才会触发,滚动时并不会触发,否则要命
// 坏消息是,我们并没有缓存的View,getView正常情况会导致重新创建一个出来,造成不必要的性能消耗
for(int i = 0; i < adapter.getCount(); ++i) {
    View view = adapter.getView(i, (View)null, this);
    view.measure(0, 0);
    this.heights[i] = view.getMeasuredHeight();
    this.childHeight += this.heights[i];
}
// 求得滚动距离
int first = this.getFirstVisiblePosition();
int scrollY = 0;
for(int i = 0; i < first; ++i) {
    scrollY += this.heights[i];
}
if (this.getChildAt(0) != null) {
    scrollY -= this.getChildAt(0).getTop();
}
// 好在计算方式相同
scrollBar.height = listView.height^2 / childHeight;
scrollBar.y = listView.height * scrollY / childHeight;

触发时机控制

前面我们已经能够实时的数据进行计算,得到滚动条的显示尺寸及位置,接下来我们需要知道什么时候应该更新(刷新信号),否则我们只能绘制出第一帧,并不能跟随页面一起滚动。

// 重写方法,滚动时必定回调该方法,在这里触发重绘即可
// 手动设置scrollChangeListener并不保险,不一定触发
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
    super.onScrollChanged(l, t, oldl, oldt);

}

拖拽控制

如果仅仅止步于此,我们的效果和原生自带的没有任何差别,造轮子的意义也就没有了,接下来我们尝试监听按压事件实现拖拽功能。

基础拖拽实现

获取拖拽移动幅度

这里就不贴代码了,感兴趣的可以直接看源码

  1. 每次DOWN和MOVE事件时,记录当前的rawY位置
  2. MOVE时计算差值,更新记录的rawY的值

通知容器滚动

重点来了,对于不同的容器,滚动的方式是不同的。下面分类进行介绍。

相信大家此时对于我们的流程有点概念了,简单总结下
主动/被动改变滚动状态 -> 滚动距离变化回调 -> 更新视图位置,尺寸
我们在拖拽时仍然不会主动改变滚动条的绘制,仅在回调中触发,这样可以有效避免拖拽时更新位置->回调中更新位置导致的抖动问题

细节优化

体验过网页滚动条的同学会发现,拖拽仅仅在按压到滚动条上才会触发,对于滚动条我们需要实现以下功能

  1. 仅在滚动条显示出来时能响应拖拽
    判断当前画笔的透明度,大于一定值响应拖拽。
  2. 拖拽过程中即使没有触发滚动,仍然不能让滚动条消失
    增加拖拽标识,把标识作为退出动画的判断条件
  3. 拖拽需要跟手移动
    拖拽会导致视图滚动,滚动后会更新滚动条位置,如果需要更新后的滚动条仍然在手下,那么实际视图的滚动距离需要和拖拽距离成比例关系。
  4. 松手一段时间后滚动条消失
    松手后修改拖拽标识
  5. 滑动冲突处理
    触发拖拽后应当拦截事件,不调用自身的super.onTouchEnvent方法可避免触发页面的滚动
  6. 滚动条不可被遮挡
    滚动条绘制逻辑放置在onDrawForeground里,有以下好处
  7. 因为是纯Canvas绘制,不会引入其他View,不会对当前View的树结构造成破坏,不影响逻辑
  8. 不需要额外设置setWillNotDraw,也就是说不会造成额外的onDraw引起额外绘制负担
  9. 这个方法是用来绘制foreground属性设置的图片的,是绘制在最上层的,省去了很多图层控制的困难

封装接口

上面提出了一些细节优化项,但是我没有给出代码,是因为直接给出代码会导致逻辑过于混乱,全部糅杂在了某个View中,对于扩展或定制将会是灾难。
接下来我们细分一下功能职责:
滚动容器:

  1. 计算滚动结果并告知滚动条
  2. 响应拖拽,并更新滚动
    滚动条:
  3. 根据当前的滚动距离绘制
  4. 根据一些状态判断当前应当的操作逻辑

抽象滚动条

public interface IScrollBar {
    // 根据对应的值做出相应处理(这里为什么不用scrollY?因为还有横向)
    void updateData(int scrollLength, int width, int height, int childLength);
    // 开始触摸,一般是down事件
    void startTouch(MotionEvent ev);
    // 停止触摸,一般是up或cancel事件
    void endTouch(MotionEvent ev);
    // 开始滚动,一般是在页面滚动回调里触发
    void startScroll();
    // 是否可以拖拽,该逻辑封装在scrollbar里自行判断
    boolean needDrag(MotionEvent ev);
    // 绘制自身(每次容器重绘都会触发)
    void onDraw(Canvas canvas);
    // 显示位置,给与更多显示的选择
    public static enum Gravity {
        LEFT(0),
        TOP(1),
        RIGHT(2),
        BOTTOM(3);

        int value;

        private Gravity(int value) {
            this.value = value;
        }

        public int getValue() {
            return this.value;
        }

        public static Gravity get(int value) {
            Gravity[] var1 = values();
            int var2 = var1.length;

            for(int var3 = 0; var3 < var2; ++var3) {
                Gravity mode = var1[var3];
                if (mode.getValue() == value) {
                    return mode;
                }
            }

            return RIGHT;
        }
    }
    // 显示模式,始终不显示,始终显示,仅滚动时显示(涉及到动画处理)
    public static enum ShowMode {
        NONE(0),
        ALWAYS(1),
        SCROLLING(2);

        int value;

        private ShowMode(int value) {
            this.value = value;
        }

        public int getValue() {
            return this.value;
        }

        public static ShowMode get(int value) {
            ShowMode[] var1 = values();
            int var2 = var1.length;

            for(int var3 = 0; var3 < var2; ++var3) {
                ShowMode mode = var1[var3];
                if (mode.getValue() == value) {
                    return mode;
                }
            }

            return NONE;
        }
    }
}

竖向滚动条实现

public class VerticalScrollBar implements IScrollBar {
    public static final int HIDE_MSG = 1;
    public static final int HIDE_ANIM_DELAY = 2000;
    public static final float TOUCH_SCALE = 1.17F;
    private boolean isDragScrollBar;
    private Rect rect;
    private int width;
    private Drawable drawable;
    private int drawableId;
    private IScrollBar.ShowMode showMode;
    private IScrollBar.Gravity gravity;
    private float alpha = 0.0F;
    private float curTouchScale = 0.0F;
    private WeakReference<View> attachView;
    protected Handler handler = new Handler(Looper.getMainLooper()) {
        public void handleMessage(@NonNull Message msg) {
            super.handleMessage(msg);
            switch (msg.what) {
                case 1:
                    VerticalScrollBar.this.manager.stopAllAnim();
                    VerticalScrollBar.this.manager.playAnim("key_scroll_bar_hide_anim", new float[]{VerticalScrollBar.this.alpha, 0.0F});
                default:
            }
        }
    };
    private ValueAnimatorManager manager;

    public VerticalScrollBar(View view, IScrollBar.ShowMode showMode, final IScrollBar.Gravity gravity, int width, int drawableId) {
        this.attachView = new WeakReference(view);
        this.showMode = showMode;
        this.gravity = gravity;
        this.width = width;
        this.drawableId = drawableId;
        this.rect = new Rect();
        this.manager = new ScrollBarAnimManager();
        // 动画回调,我进行了封装,这里可以理解为ValueAnimator
        this.manager.setAnimListener(new AnimListenerAdapter() {
            public void onAnimUpdate(String key, Object newValue) {
                super.onAnimUpdate(key, newValue);
                if (!"key_scroll_bar_show_anim".equals(key) && !"key_scroll_bar_hide_anim".equals(key)) {
                    VerticalScrollBar.this.curTouchScale = (Float)newValue;
                    if (gravity == Gravity.RIGHT) {
                        VerticalScrollBar.this.rect.set(VerticalScrollBar.this.rect.left, VerticalScrollBar.this.rect.top, (int)((float)VerticalScrollBar.this.rect.left + (float)VerticalScrollBar.this.width * VerticalScrollBar.this.curTouchScale), VerticalScrollBar.this.rect.bottom);
                    } else {
                        VerticalScrollBar.this.rect.set((int)((float)VerticalScrollBar.this.rect.right - (float)VerticalScrollBar.this.width * VerticalScrollBar.this.curTouchScale), VerticalScrollBar.this.rect.top, VerticalScrollBar.this.rect.right, VerticalScrollBar.this.rect.bottom);
                    }
                } else {
                    VerticalScrollBar.this.alpha = (Float)newValue;
                }

                VerticalScrollBar.this.invalidate();
            }
        });
    }

    public void onDraw(Canvas canvas) {
        // 如果不是滚动时显示,直接写死透明度
        if (this.showMode == ShowMode.ALWAYS) {
            this.alpha = 1.0F;
        } else if (this.showMode == ShowMode.NONE) {
            this.alpha = 0.0F;
        }
        // 使用计算好的bounds进行绘制
        if (this.drawable != null) {
            this.drawable.setBounds(this.rect);
            this.drawable.setAlpha((int)(255.0F * this.alpha));
            this.drawable.draw(canvas);
        }

    }

    public void updateData(int scrollY, int bodyWidth, int bodyHeight, int allChildLength) {
        // 根据对齐方式计算绘制的位置,这个rect重点,后面判断是否可拖拽也会用到它
        if (this.gravity == Gravity.RIGHT) {
            this.rect.set(bodyWidth - this.width, bodyHeight * scrollY / allChildLength, (int)((float)(bodyWidth - this.width) + (float)this.width * this.curTouchScale), bodyHeight * (scrollY + bodyHeight) / allChildLength);
        } else {
            this.rect.set((int)((float)this.width - (float)this.width * this.curTouchScale), bodyHeight * scrollY / allChildLength, this.width, bodyHeight * (scrollY + bodyHeight) / allChildLength);
        }

    }
    // 是否能够拖拽,外部根据该返回值决定是否拦截滚动
    public boolean needDrag(MotionEvent event) {
        return this.isDragScrollBar;
    }
    // 开始拖拽,如果拖拽就将滚动条加粗显示(文章第一个图里的按压变粗)
    public void startTouch(MotionEvent event) {
        this.handler.removeCallbacksAndMessages((Object)null);
        // 根据触摸点是否在滚动条范围内决定是否响应拖拽
        this.isDragScrollBar = this.alpha > 0.8F && this.rect.contains((int)event.getX(), (int)event.getY());
        if (this.isDragScrollBar) {
            this.manager.stopAllAnim();
            this.manager.playAnim("key_scroll_bar_show_anim", new float[]{this.alpha, 1.0F});
            this.manager.playAnim("key_scroll_bar_start_touch_anim", new float[]{this.curTouchScale, 1.17F});
        }

    }
    // 松手时,如果是仅滚动时显示,就要延迟隐藏滚动条
    public void endTouch(MotionEvent event) {
        if (this.showMode == ShowMode.SCROLLING) {
            this.handler.removeCallbacksAndMessages((Object)null);
            this.handler.sendEmptyMessageDelayed(1, 2000L);
        }

        this.manager.stopAnim(new String[]{"key_scroll_bar_end_touch_anim", "key_scroll_bar_start_touch_anim"});
        this.manager.playAnim("key_scroll_bar_end_touch_anim", new float[]{this.curTouchScale, 1.0F});
    }
    // 滚动时需要将滚动条显示出来,并且不停的去发延迟消息
    public void startScroll() {
        if (this.showMode == ShowMode.SCROLLING && this.alpha != 1.0F && !this.manager.isAnimRunning("key_scroll_bar_show_anim")) {
            this.manager.stopAnim(new String[]{"key_scroll_bar_show_anim", "key_scroll_bar_hide_anim"});
            this.manager.playAnim("key_scroll_bar_show_anim", new float[]{this.alpha, 1.0F});
        }

        if (this.showMode != ShowMode.ALWAYS && !this.isDragScrollBar) {
            this.handler.removeCallbacksAndMessages((Object)null);
            this.handler.sendEmptyMessageDelayed(1, 2000L);
        }

    }



}

RecyclerView实现样例

这里只给出RecyclerView的部分代码,其他布局大家可以自行思考实现,有问题可以评论区讨论

public class BaseRecyclerView extends RecyclerView {
    protected IScrollBar iScrollBar;
    protected float lastTouchedRawY;
    protected float lastTouchedRawX;
    protected Context context;
    protected int scrollbarWidth;
    protected int scrollbarRes;
    protected int scrollbarGravity;
    protected int scrollbarMode;

    public BaseRecyclerView(@NonNull Context context) {
        this(context, (AttributeSet)null);
    }

    public BaseRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public BaseRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.context = context;
        this.init(attrs, defStyleAttr, style.VerticalScrollBarStyle);
    }

    protected void init(@Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        TypedArray typedArray = this.context.obtainStyledAttributes(attrs, styleable.TouchableScrollBar, defStyleAttr, defStyleRes);
        this.scrollbarWidth = typedArray.getDimensionPixelOffset(styleable.TouchableScrollBar_touchableScrollBarWidth, 0);
        this.scrollbarRes = typedArray.getResourceId(styleable.TouchableScrollBar_touchableScrollBar, 0);
        this.scrollbarGravity = typedArray.getInt(styleable.TouchableScrollBar_touchableScrollBarGravity, 0);
        this.scrollbarMode = typedArray.getInt(styleable.TouchableScrollBar_touchableScrollBarMode, 0);
        typedArray.recycle();
    }

    public boolean dispatchTouchEvent(MotionEvent ev) {
        // touch里收不到down和cancel事件,部分逻辑只能放在这
        if (ev.getAction() == 0) {
            if (this.iScrollBar != null) {
                this.iScrollBar.startTouch(ev);
            }

            this.lastTouchedRawY = ev.getRawY();
            this.lastTouchedRawX = ev.getRawX();
        } else if ((ev.getAction() == 1 || ev.getAction() == 3) && this.iScrollBar != null) {
            this.iScrollBar.endTouch(ev);
        }

        return super.dispatchTouchEvent(ev);
    }

    public void setLayoutManager(@Nullable RecyclerView.LayoutManager layout) {
        super.setLayoutManager(layout);
        // 根据当前的布局模式决定初始化横向还是竖向的滚动条
        if (this.isVertical(layout)) {
            this.iScrollBar = new VerticalScrollBar(this, ShowMode.get(this.scrollbarMode), Gravity.get(this.scrollbarGravity), this.scrollbarWidth, this.scrollbarRes);
        } else {
            this.iScrollBar = new HorizontalScrollBar(this, ShowMode.get(this.scrollbarMode), Gravity.get(this.scrollbarGravity), this.scrollbarWidth, this.scrollbarRes);
        }

    }

    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case 1:
            case 2:
                if (this.iScrollBar != null && this.iScrollBar.needDrag(ev)) {
                    // 根据方向决定计算方式
                    boolean isVertical = this.isVertical(this.getLayoutManager());
                    int childLength = isVertical ? this.computeVerticalScrollRange() : this.computeHorizontalScrollRange();
                    if (isVertical) {
                        this.scrollBy(0, (int)(ev.getRawY() - this.lastTouchedRawY) * childLength / this.getHeight());
                    } else {
                        this.scrollBy((int)(ev.getRawX() - this.lastTouchedRawX) * childLength / this.getWidth(), 0);
                    }

                    this.lastTouchedRawY = ev.getRawY();
                    this.lastTouchedRawX = ev.getRawX();
                    return true;
                }
            default:
                return super.onTouchEvent(ev);
        }
    }

    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
        if (this.iScrollBar != null) {
            this.iScrollBar.startScroll();
        }

    }

    public void onDrawForeground(Canvas canvas) {
        super.onDrawForeground(canvas);
        if (this.iScrollBar != null) {
            boolean isVertical = this.isVertical(this.getLayoutManager());
            int childLength = isVertical ? this.computeVerticalScrollRange() : this.computeHorizontalScrollRange();
            int scrollLength = isVertical ? this.computeVerticalScrollOffset() : this.computeHorizontalScrollOffset();
            // 阻尼滚动时,可能为负值,此时需要将滚动布局的高度进行调整,并且滚动距离调整为0
            if (scrollLength < 0) {
                childLength -= scrollLength;
                scrollLength = 0;
            } else if (isVertical && scrollLength + this.getHeight() > childLength) {
                childLength = scrollLength + this.getHeight();
            } else if (!isVertical && scrollLength + this.getWidth() > childLength) {
                childLength = scrollLength + this.getWidth();
            }

            this.iScrollBar.updateData(scrollLength, this.getWidth(), this.getHeight(), childLength);
            this.iScrollBar.onDraw(canvas);
        }
    }

    protected boolean isVertical(RecyclerView.LayoutManager layoutManager) {
        if (layoutManager == null) {
            return true;
        } else if (layoutManager instanceof LinearLayoutManager) {
            return ((LinearLayoutManager)layoutManager).getOrientation() == 1;
        } else {
            return true;
        }
    }
}
Responses