需求

产品:咱们需求一个类似于浏览器的翻滚条,用户能够拖拽快速定位,你来完成一个。

我:原生又不支持,做不到

产品:我不要你觉得,我要我觉得,快做

我:(╯‵□′)╯︵┻━┻

演示作用

写在前面

纯干货,希望您能看完

  • 自定义可拖拽翻滚条,适用于ScrollView,RecyclerView,ListView
  • 纯Canvas完成,相同依据原逻辑,也能够更换成任何View,原理相同
  • 完成比原生体会更好的交互,如动画,按压拖拽等
  • 调用原生原有API,省去大量核算带来的卡顿及方位错乱的问题

基本原理

可拖拽滚动条 - 产品瞎提需求怎么办
画了个简易示意图(字丑见谅) 关于一个可翻滚布局来说,分为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的翻滚条定制。

  • ScrollView不存在布局复用的问题,且翻滚彻底依赖于Scroller及特点scrollY,直接getScrollY()就能够获取到翻滚间隔。
  • ScrollView官方要求,内部最多只能有1个容器,那么经过getChildAt(0) 即可获取到child
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的值

告诉容器翻滚

要点来了,关于不同的容器,翻滚的办法是不同的。下面分类进行介绍。

  • ScrollView 调用最为简略,scrollBy(x,y)办法即可,每次翻滚y间隔,因为咱们拖拽时的回调频率很高,不需求运用smoothScroll播映动画。

  • RecyclerView 与ScrollView相同的办法,scrollBy(x,y)办法即可触发翻滚

  • ListView 办法略有不同,因为ListView并不是经过scrollY进行翻滚,调用scrollBy并没有用,关于该布局改用scrollListBy(x,y)办法进行翻滚

信任大家此刻关于咱们的流程有点概念了,简略总结下 主动/被动改动翻滚状况 -> 翻滚间隔改动回调 -> 更新视图方位,尺度 咱们在拖拽时依然不会主动改动翻滚条的制作,仅在回调中触发,这样能够有效防止拖拽时更新方位->回调中更新方位导致的颤动问题

细节优化

体会过网页翻滚条的同学会发现,拖拽仅仅在按压到翻滚条上才会触发,关于翻滚条咱们需求完成以下功用

  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;
        }
    }
}

已同步发布在ccc2.icu/archives/20…