Scroller 的使用细节与心得

以前写过一篇关于 Scroller 的文章,链接 https://blog.csdn.net/Deaht_Huimie/article/details/52389348 ,这篇文章写的比较浅,现在想往深处挖挖,提供一些使用思路,给大伙起到抛砖引玉的作用。

之前的文章介绍过了,Scroller 不具备滑动的功能, View 本省提供了 scrollBy() 和 scrollTo() 方法,它移动的不是view,而是 view 里面的内容。我们看看这两个方法

View:

    public void scrollBy(int x, int y) {
        scrollTo(mScrollX + x, mScrollY + y);
    }

    public void scrollTo(int x, int y) {
        if (mScrollX != x || mScrollY != y) {
            int oldX = mScrollX;
            int oldY = mScrollY;
            mScrollX = x;
            mScrollY = y;
            invalidateParentCaches();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            if (!awakenScrollBars()) {
                postInvalidateOnAnimation();
            }
        }
    }

最终还是调用 scrollTo() 方法,把要移动到的位置传递进去,改变的 mScrollX 和 mScrollY 的值,刷新 View,改变内容的位置;invalidate() 和 postInvalidate() 这两个方法都是请求视图重新绘制,区别是 invalidate() 必须在主线程中调用,而 postInvalidate() 可以在非主线程中调用;同时,invalidate()  执行时,它会检查上一次请求的UI重绘是否完成,如果没有完成的话,那么它就什么都不做。

我们需要说明一点,scrollTo() 移动的是 view 的内容,同时传入的参数移动内容时,负数向右和向下;正数向左和向上,这个和 offsetLeftAndRight() 方法中的值的位移方向相反,要谨记。同时,我们如果直接调用 View.scrollBy(-100, -100) 方法,view中的内容移动是瞬间移动过去,我们看起来很突兀,如果每帧移动个 10px,我们开起来的就是位移而不是瞬移了。比如把 100px 分解成50次,每次移动 2px,也就是说分解,比如写个for循环

    void test1(){
        for(int i = 0; i< 50; i++){
            view.scrollBy(-2, -2);
        }
    }

但是由于这个for循环也是瞬间执行完的,和 View.scrollBy(-100, -100) 没区别,所以如果让for循环中,每次都间隔10毫秒,我们就能看到位移效果了

    void test2(){
        view.post(runnable);
    }

    int count = 0;
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            if(count > 50){
                count = 0;
                return;
            }
            count++;
            view.scrollBy(-2, -2);
            view.postDelayed(this, 10);
        }
    };

下面的写法,使用了延迟执行,运行后,果然看到内容向右下放移动了。    

如果每次执行都这么写,是不是很麻烦,有没有封装好的方法呢? Scroller 出现了,就是为了解决数据计算的问题,之前的文章分析过 Scroller 中是如何计算数据的,根据当前的位置和最终的位置算出距离,然后根据位移所需的时间和传入的插值器来计算当前这个时间节点所需位移的距离,用法比如

    Scroller mScroller = new Scroller(context);
    void test3(){
        mScroller.startScroll(getScrollX(), getScrollY(), -100, -100);
        invalidate();
    }

同时,重写 View 的 computeScroll() 方法

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }

为什么要重写 computeScroll() 方法呢? mScroller.startScroll() 后面为什么要跟 invalidate() 方法? 这其实是同一个问题,invalidate() 方法调用是,会重新绘制view,调用 draw() 方法,//Step 2, save the canvas' layers 这行注释下的代码, 这里面 int left = mScrollX + paddingLeft; int top = mScrollY + getFadeTop(offsetRequired); 等代码,都用到了 mScrollX 和 mScrollY,说明它不停的绘制内容;view 如果是个容器,里面包含子view,那么会调用到 drawChild() 方法,然后调用 draw(canvas, parent, drawingTime)方法,

 boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
    ...
    computeScroll();
    ...
 }

看到这就明白了,原来调用了 invalidate() 方法会执行到 view 的 computeScroll() 方法,所以我们是在 computeScroll() 方法中对位移的距离进行了分段,然后重新调用 invalidate() 方法,有点像递归,直至到达目的地。

startScroll() 方法是辅助滑向某个位置,是固定的;但如果我们想想 ListView 那样向上滑动时,手指松开了, ListView 会根据我们松开手指的瞬间时,向上滑动的速度来决定 ListView 接下来会继续滑动多少距离,如果想实现这个功能,怎么办? Scroller 还提供了一个 fling() 方法,由 VelocityTracker 这个工具类监测手指滑动的速度,包含方向,设置滑动的时间,滑动的边界值等,这样就可以了,其他写法和 startScroll() 方法一样,都需要调用 invalidate() 和重写 computeScroll() 方法。

关于容器与空间的嵌套,比如 ViewGroup 为 A,View 为 B,A 是 B 的父容器,我们想移动B,思路一:可以调用 B 的 offsetLeftAndRight() 或 layout() 或者根据 LayoutParams 来定义它在父容器中的位置;思路二:可以调用 A 的  scrollBy() 来移动它的内容,也就是移动了 B。比如说 ListView 中的item的横滑,出现删除按钮,这个功能使用上面两种思路都可以实现;自定义一个容器,实现像ViewPager的功能,可以左右滑动,显示出子view,上面两种思路都可以实现,一般这时候思路二会更好一点,关于这种场景,郭神已经有一篇文章,讲解的很清楚,链接 https://blog.csdn.net/guolin_blog/article/details/48719871 ,这里引用一下。有大神的文章在上,我就不班门弄斧了,这里说说自己的一点对 Scroller 的使用心得。

比如说重写容器A的 onTouchEvent() 方法,在 ACTION_DOWN 时记录手指距离屏幕左顶点的位置, ACTION_MOVE 时再记录一下位置,然后相减,再取它的负数,把当前的手指值赋给上一次,然后调用 scrollBy() 方法,这样A里面的内容就随手指一动了。
注意点一:我们获取相对位置,不管是使用 event.getRawX() 或 event.getX(),前者是相对屏幕左上角,后者是针对当前容器A的左上角,我们在 ACTION_MOVE 中,计算完位移差值后,一定要把当前的坐标值赋给上一次,更新一下位置,这样每次调用 scrollBy() 方法传进去的值才是每次位移的小段的距离值,否则的话传进去的就是累加的值,A容器的内容会移动越来越快,造成bug;
注意点二:ACTION_MOVE 中,我们要进行边界判断,比如A的内容可以向左或向右的边界值,如果到了边界,就不能继续滑动了,或者直接让内容滑到边界,结束当前触摸事件;
注意点三:如果像ViewPager一样,在手指抬起时,里面的控件显示了一半,此时需要计算出内容滑动的距离,进而判断出要显示出控件全貌所需的剩余距离,然后调用 Scroller 的滑动方法,进行位移。
注意点四:如果容器A的父容器也是 SlidingPaneLayout,本身也具备滑动功能,此时我们在 ACTION_DOWN 的时候,需要调用 getParent().requestDisallowInterceptTouchEvent(true) 方法让当前控件获取到触摸事件,然后在 ACTION_MOVE 中的边界情况下,根据逻辑决定是否调用 getParent().requestDisallowInterceptTouchEvent(false) 方法,把触摸事件重新交给
父容器。

Scroller 是个计算位置的辅助工具类,具体的位移还是调用 View 本省的方法,最终都是通过触摸事件来控制。各种三方jar或框架,无不是对它的封装,所谓万变不离其宗,了解 View 内容的位移及用法,就可以坦然面对各种情况了,写功能最终要的是思路和原理,把理想通了,功能就容易了。
 

我们知道qq中有个view横滑出现置顶和删除按钮的功能,向右滑动就隐藏消失。我们现在实现以下此功能

package com.cn.desigin.view;

public class SwipeLayout2 extends LinearLayout {

    private final static int STATE_CLOSE = 1;
    private final static int STATE_OPEN = 2;

    private View mDragView;
    private View mHideView;
    private int mDragSlop;
    private int mWidth;
    private int mHeight;
    private int mDragDistance;
    private int mState = STATE_CLOSE;

    private Scroller mScroller;

    public SwipeLayout2(Context context) {
        this(context, null);
    }

    public SwipeLayout2(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public SwipeLayout2(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initView();
    }

    private void initView() {
        mScroller = new Scroller(getContext());
        mDragSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(ViewConfiguration.get(getContext()));
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        mDragView.layout(getPaddingLeft(), getPaddingTop(), mWidth - getPaddingRight(), mHeight - getPaddingBottom());
        mHideView.layout(mWidth - getPaddingRight(), getPaddingTop(), mWidth - getPaddingRight() + mDragDistance, mHeight - getPaddingBottom());
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        if(getChildCount() != 2){
            throw new IllegalStateException("必须有两个子view");
        }
        mDragView = getChildAt(0);
        mHideView = getChildAt(1);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        mWidth = w;
        mHeight = h;
        mDragDistance = mHideView.getMeasuredWidth();
    }

    private float mXDown;//手机按下时的屏幕坐标
    private float mXMove;//手机当时所处的屏幕坐标
    private float mXLastMove;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mXDown = ev.getRawX();
                mXLastMove = mXDown;
                break;
            case MotionEvent.ACTION_MOVE:
                mXMove = ev.getRawX();
                float diff = Math.abs(mXMove - mXDown);
                mXLastMove = mXMove;
                // 当手指拖动值大于TouchSlop值时,认为应该进行滚动,拦截子控件的事件
                if (diff > mDragSlop) {
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                mXMove = event.getRawX();
                //注意用老的值减去新的值,是按照Scroller的坐标来走的,正常情况下是
                //最新的坐标减去老的坐标得到的数正负刚好是View坐标系下的正负。
                int scrolledX = (int) (mXLastMove - mXMove);
                if (getScrollX() + scrolledX < 0) {
                    scrollTo(0, 0);
                    getParent().requestDisallowInterceptTouchEvent(false);
                    return true;
                } else if (getScrollX() + scrolledX > mDragDistance) {
                    scrollTo(mDragDistance, 0);
                    return true;
                }
                scrollBy(scrolledX, 0);
                mXLastMove = mXMove;

                break;
            case MotionEvent.ACTION_UP:
                // 当手指抬起时,根据当前的滚动值来判定开或者关
                float targetIndex = (getScrollX() * 1f) / mDragDistance;
                int distance = targetIndex > 0.5 ? mDragDistance : 0;
                // 调用startScroll()方法来初始化滚动数据并刷新界面
                int dx = distance - getScrollX();
                mScroller.startScroll(getScrollX(), 0, dx, 0);
                invalidate();
                break;
            case MotionEvent.ACTION_DOWN:
                if (getParent() != null) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                }
                break;
        }
        return true;
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        } else {
            setState(mScroller.getCurrX() == 0 ? STATE_CLOSE : STATE_OPEN);
        }
    }


    private void setState(int state){
        this.mState = state;
    }


    public void close(){
        if(mState == STATE_OPEN){
            scrollTo(0, 0);
            mState = STATE_CLOSE;
        }
    }

    public void open(){
        if(mState == STATE_CLOSE){
            scrollTo(mDragDistance, 0);
            mState = STATE_OPEN;
        }
    }
}

Activity 中 xml 布局

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.cn.desigin.view.SwipeLayout2
        android:layout_marginTop="20dp"
        android:layout_width="match_parent"
        android:layout_height="60dp">

        <TextView
            android:id="@+id/tv_content"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@android:color/holo_blue_bright"
            android:gravity="center_vertical"
            android:text="我是一个一个一个一个一个一个" />

        <LinearLayout
            android:layout_width="120dp"
            android:layout_height="match_parent"
            android:orientation="horizontal">

            <TextView
                android:id="@+id/tv_top"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="@android:color/holo_orange_light"
                android:gravity="center"
                android:text="置顶" />

            <TextView
                android:id="@+id/tv_delete"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="@android:color/holo_green_light"
                android:gravity="center"
                android:text="删除" />
        </LinearLayout>
    </com.cn.desigin.view.SwipeLayout2>

</FrameLayout>


这样,就实现了。
 

发布了176 篇原创文章 · 获赞 11 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/Deaht_Huimie/article/details/100036527