自定义 View 之 Scroller 与 View 滑动相关完全总结

前言: 一个人学到的最重要的东西是学习的方法。——尼尔·波兹曼《娱乐至死》

在讲 Scroller 之前,咱们需要先了解一下 View 的滑动问题,看一下常见的几种让 View 能随着手指滑动起来的几种方式。

1、常见的几种可滑动 View 的方式

有如下几种:

  • 通过 View 的 ScrollBy 和 ScrollTo 方法实现滑动。
  • 通过动画给 View 施加位移效果来实现滑动。
  • 通过改变 View 的 LayoutParams 使 View 重新布局从而实现滑动。

接下来对这三种方式一一进行讲解。

1、通过 View 的 ScrollBy 和 ScrollTo 方法实现滑动

这两个方法都是用于对 View 进行滚动的,那么它们之间有什么区别呢?简单点讲,scrollBy() 方法是让 View 相对于当前的位置滚动某段距离,而 scrollTo() 方法则是让 View 相对于初始的位置滚动某段距离。我们通过查看 scrollBy() 方法的源码可以发现,其方法内部其实同样调用的 scrolllTo() 方法:

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

通过查看源码我们知道,scrollBy() 方法内部其实就是调用了 scrollTo() 方法,只不过参数中多加了 mScrollX 和 mScrollY 这两个变量,那么这2个变量究竟是什么呢?

mScrollX 和 mScrollY 我们可以理解为 View 的偏移量,初始值都为0。当 View 发生移动时,比如说,View 往左横向移动了100px,那么这时,mScrollX 的值则为100。相反,如果是往右移动100px,那么此时值为-100。mScrollY 则是如果往上垂直移动100px,mScrollY的值则为100,否则为-100。具体如下图所示:

自定义 View 之 Scroller 与 View 滑动相关完全总结_第1张图片
image

这样讲大家理解起来可能有点费劲,我们来通过例子实验一下就知道了。

  • 布局中定义好 Layout,并且包裹好两个 button


    

外层我们使用了一个 LinearLayout,然后在里面包含了两个按钮,一个用于触发 scrollTo 逻辑,一个用于触发 scrollBy 逻辑。

  • 修改 MainActivity 代码:
public class MainActivity extends AppCompatActivity {

    private LinearLayout mLayout;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mLayout = (LinearLayout) findViewById(R.id.layout);
        findViewById(R.id.btn_scrollto).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.e("test", "点击前getScrollX值:" + mLayout.getScrollX());
                Log.e("test", "点击前getScrollY值:" + mLayout.getScrollY());
                mLayout.scrollTo(-50, -50);
                Log.e("test", "点击后getScrollX值:" + mLayout.getScrollX());
                Log.e("test", "点击后getScrollY值:" + mLayout.getScrollY());
            }
        });

        findViewById(R.id.btn_scrollby).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mLayout.scrollBy(-50, -50);
            }
        });
    }
}

没错,代码就是这么简单。当点击了 scrollTo 按钮时,我们调用了 LinearLayout 的 scrollTo() 方法,当点击了 scrollBy 按钮时,调用了 LinearLayout 的 scrollBy() 方法。
这里一定要注意,不管是 scrollTo() 还是 scrollBy() 方法,滚动的都是该 View 内部的内容,而LinearLayout 中的内容就是我们的两个 Button,如果你直接调用 button 的 scroll 方法的话,那结果一定不是你想看到的。
另外还有一点需要注意,就是两个 scroll 方法中传入的参数,第一个参数 x 表示相对于当前位置横向移动的距离,正值向左移动,负值向右移动,单位是像素。第二个参数 y 表示相对于当前位置纵向移动的距离,正值向上移动,负值向下移动,单位是像素。
运行结果:

自定义 View 之 Scroller 与 View 滑动相关完全总结_第2张图片
image

运行的log:

自定义 View 之 Scroller 与 View 滑动相关完全总结_第3张图片
image

通过上面的实践可以得知:
当我们执行 scrollTo() 方法,Layout 中的 View,只会移动到 scrollTo() 方法参数指定的位置上。而 scrollBy() 方法则是加上了 scrollX 和 scrollY 的值,所以每点击一次,都会动态根据当前位置移动。
现在我们再来回头看一下这两个方法的区别,scrollTo() 方法是让 View 相对于初始的位置滚动某段距离,由于 View 的初始位置是不变的,因此不管我们点击多少次 scrollTo 按钮滚动到的都将是同一个位置。而 scrollBy() 方法则是让 View 相对于当前的位置滚动某段距离,那每当我们点击一次 scrollBy 按钮,View 的当前位置都进行了变动,因此不停点击会一直向右下方移动。

2、通过动画给 View 施加位移效果来实现滑动

这种方式后面咱们会详解(在自定义 View 动画篇章讲解),这里先简单介绍一下:
通过动画来实现 View 的滑动,主要是操作 View 的 translationX 和 translationY 这两个属性。我们既可以使用补间动画也可以使用属性动画。下面分别实现下用补间和属性两种动画实现将 Button 往右移动 100px:(补间动画已经很少使用了)

  • 补间动画
TranslateAnimation anim = new TranslateAnimation(0, 100, 0, 0);
anim.setDuration(300);
anim.start();
btn.setAnimation(anim);

  • 属性动画
ObjectAnimator anim = ObjectAnimator.ofFloat(btn, "translationX", 0, 100);
anim.setDuration(300);
anim.start();

使用动画移动 View 时,需要注意的是 View 动画是对 View 的影像做操作的。移动后其实并不能真正改变 View 的位置参数,包括宽/高。并且使用补间动画时,View 并不能响应移动后位置的事件,属性动画可以响应移动后的位置事件。

3、通过改变 View 的 LayoutParams 使 View 重新布局从而实现滑动

这种方式其实我们在案例以及文章中已经无意间讲完了,因为很多自定义 View 都使用 LayoutParams 来改变位置或者其他的参数属性。这里也只是简单介绍:

通过改变 View 的布局的参数实现 View 的滑动,其用到的类是:LayoutParams。这个类相当于一个 Layout 的信息包,里面封装了关于 Layout 的宽、高等信息。如果我们想将一个 View 往右移动,那么就可以不断的增加 View 的 leftMargin:

ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
lp.leftMargin += 100;
view.setLayoutParams(lp);

通过这种方式实现的 View 的移动,View 移动后的位置就是其真实的位置。
看一个案例来讲解一个什么叫 View 移动后的位置就是其真实的位置:

下面这个实例,自定义了一个 View 继承自 ImageView,该 ImageView 可以跟随手指的触摸移动。同时在布局中还定义了一个 Button,点击该 Button 修改 ImageView 的背景。
View:

public class MoveView extends ImageView{

    public MoveView(Context context, AttributeSet attrs) {
        super(context, attrs);

    }

    int lastX = 0;
    int lastY = 0;

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        int action = event.getAction();
        switch (action){
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                  layout(getLeft() + offsetX, getTop() + offsetY,
                        getRight() + offsetX , getBottom() + offsetY);
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }
}

注意这里使用 layout() 来改变自定义 ImageView 的位置。

布局:



点击事件:

public void set(View v) {
    mMoveView.setBackgroundColor(Color.RED);
}

运行结果:

自定义 View 之 Scroller 与 View 滑动相关完全总结_第4张图片
image

通过运行结果我们可以看到,该 ImageView 可以跟随手指的移动,但是当我们重新设置 ImageView 的背景后,该 ImageView 立马又回到原来位置了?

出现这个情况,其实就是因为 View 虽然移动了,但是其位置属性依然还在原来的位置,这时候如果想要在更改 ImageView 背景后,View 依然在原地,那么就可以使用上面第三种方法实现 View 的移动了。

我们将 ACTION_MOVE 中移动 View 的代码修改为:

case MotionEvent.ACTION_MOVE:
    int offsetX = x - lastX;
    int offsetY = y - lastY;
 /*  
    layout(getLeft() + offsetX, getTop() + offsetY,
            getRight() + offsetX , getBottom() + offsetY);*/

    ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) getLayoutParams();
    lp.leftMargin = getLeft() + offsetX;
    lp.topMargin = getTop() + offsetY;
    setLayoutParams(lp);
    break;

运行结果:

自定义 View 之 Scroller 与 View 滑动相关完全总结_第5张图片
image

总结
上面介绍了三种常见的 View 移动方式,这三种都可以实现 View 的移动。其中:

  • scrollTo()/scrollBy():操作简单,但是只能够对View内容进行移动。
  • 动画:使用简单,主要用于没有交互的View和实现复杂的动画效果。
  • 改变布局参数:操作稍复杂,适用于有交互的View。

在具体的使用中,可以根据具体使用情况选择适合的方式进行View的移动。


2、Scroller完全解析

Scroller的基本用法其实还是比较简单的,主要可以分为以下几个步骤:

  • 第一步:创建 Scroller 对象。
Scroller mScroller = new Scroller(context);

  • 第二步:调用 startScroll() 方法。
mScroller.startScroll(getScrollX(), getScrollY() , -100, -100);
invalidate();

  • 第三步:重写 View 的 computeScroll() 方法,实现具体滑动逻辑。
@Override
public void computeScroll() {
    if(mScroller.computeScrollOffset()){
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        invalidate();
    }
}

下面以一个简单的例子演示下 Scroller 这个类的用法:
首先自定义一个 LinearLayout,并在构造中创建了 Scroller 对象,提供了一个 startScroll()
方法,并且重写了 computeScroll() 方法:

public class ScrollerLinearLayout extends LinearLayout{

    private final Scroller mScroller;

    public ScrollerLinearLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        mScroller = new Scroller(context);
    }

   //触发mScroller的startScroll。
    public void startScroll(){
        mScroller.startScroll(getScrollX(), getScrollY() , -50, -50);
        invalidate();
    }

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

在布局中用该自定义 LinearLayout 包裹了一个 Button:




    

在代码中调用了其 startScroll() 方法:

public class MainActivity extends AppCompatActivity {

    private ScrollerLinearLayout mLayout;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mLayout = (ScrollerLinearLayout) findViewById(R.id.layout);
    }

    public void start(View v){
        mLayout.startScroll();
    }
}

运行结果:

自定义 View 之 Scroller 与 View 滑动相关完全总结_第6张图片
image

通过上面的例子,简单实现了用 Scroller 实现了 View 的弹性滑动。但是这一切都是怎么一个过程呢?首先来看看Scroller的startScroll()方法:

public void startScroll(int startX, int startY, int dx, int dy, int duration) {
    mMode = SCROLL_MODE;
    mFinished = false;
    mDuration = duration;
    mStartTime = AnimationUtils.currentAnimationTimeMillis();
    mStartX = startX;
    mStartY = startY;
    mFinalX = startX + dx;
    mFinalY = startY + dy;
    mDeltaX = dx;
    mDeltaY = dy;
    mDurationReciprocal = 1.0f / (float) mDuration;
}

通过查看 startScroll() 方法的源码可以发现,其内部只是做了赋值的操作,并没有调用其他的方法,那么这个弹性滑动的效果又是从哪里来的呢?
答案就在于,我们在调用 startScroll() 方法后又调用了 invalidate() 方法使 View 进行了重绘。那么这时就会走 View 的 draw 方法,在 View 的 draw 方法中又会去调用 View 的 computeScroll() 方法,而该方法在 View 中是一个空实现。这就解释了我们为什么需要复写 computeScroll() 方法。
在 computeScroll() 方法中,我们调用了 computeScrollOffset() 方法进行判断,该方法内部会根据时间的流逝来计算出 scrollX 和 scrollY 改变的百分比并计算出当前的值。这个方法的返回值也很重要,返回 true 表示滑动还未结束,false 表示滑动已经结束。所以在这里,我们进行了判断,当其返回 true 时,就调用 scrollTo() 方法使 View 滑动,并调用 invalidate() 重绘,只要滑动没有完成就继续递归下去。

到这里 Scroller 的工作原理就很清晰了,Scroller 并不能单独完成 View 的弹性滑动,而是要配合 View 的 computeScroll() 方法,不断的通过 invalidate() 方法重绘 View,计算 Scroller 的 X 和 Y 值, 并通过 scrollTo() 方法一点点的移动,连在一起,就形成了弹性滑动。

接下来这个案例出自郭霖的 Scroller 案例 自定义简易的 ViewPager ,可以作为 Scroller 进阶知识,而且并不难理解。

新建一个 ScrollerLayout 并让它继承自 ViewGroup 来作为我们的简易 ViewPager 布局,代码如下所示:

public class ScrollerLayout extends ViewGroup {

    /**
     * 用于完成滚动操作的实例
     */
    private Scroller mScroller;

    /**
     * 判定为拖动的最小移动像素数
     */
    private int mTouchSlop;

    /**
     * 手机按下时的屏幕坐标
     */
    private float mXDown;

    /**
     * 手机当时所处的屏幕坐标
     */
    private float mXMove;

    /**
     * 上次触发ACTION_MOVE事件时的屏幕坐标
     */
    private float mXLastMove;

    /**
     * 界面可滚动的左边界
     */
    private int leftBorder;

    /**
     * 界面可滚动的右边界
     */
    private int rightBorder;

    public ScrollerLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 第一步,创建Scroller的实例
        mScroller = new Scroller(context);
        ViewConfiguration configuration = ViewConfiguration.get(context);
        // 获取TouchSlop值
        mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);
            // 为ScrollerLayout中的每一个子控件测量大小
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (changed) {
            int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                View childView = getChildAt(i);
                // 为ScrollerLayout中的每一个子控件在水平方向上进行布局
                childView.layout(i * childView.getMeasuredWidth(), 0, (i + 1) * childView.getMeasuredWidth(), childView.getMeasuredHeight());
            }
            // 初始化左右边界值
            leftBorder = getChildAt(0).getLeft();
            rightBorder = getChildAt(getChildCount() - 1).getRight();
        }
    }

    @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 > mTouchSlop) {
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                mXMove = event.getRawX();
                int scrolledX = (int) (mXLastMove - mXMove);
                if (getScrollX() + scrolledX < leftBorder) {
                    scrollTo(leftBorder, 0);
                    return true;
                } else if (getScrollX() + getWidth() + scrolledX > rightBorder) {
                    scrollTo(rightBorder - getWidth(), 0);
                    return true;
                }
                scrollBy(scrolledX, 0);
                mXLastMove = mXMove;
                break;
            case MotionEvent.ACTION_UP:
                // 当手指抬起时,根据当前的滚动值来判定应该滚动到哪个子控件的界面
                int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
                int dx = targetIndex * getWidth() - getScrollX();
                // 第二步,调用startScroll()方法来初始化滚动数据并刷新界面
                mScroller.startScroll(getScrollX(), 0, dx, 0);
                invalidate();
                break;
        }
        return super.onTouchEvent(event);
    }

    @Override
    public void computeScroll() {
        // 第三步,重写computeScroll()方法,并在其内部完成平滑滚动的逻辑
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }
}

首先在 ScrollerLayout 的构造函数里面我们进行了上述步骤中的第一步操作,即创建 Scroller 的实例,由于 Scroller 的实例只需创建一次,因此我们把它放到构造函数里面执行。另外在构建函数中我们还初始化的 TouchSlop 的值,这个值在后面将用于判断当前用户的操作是否是拖动。
接着重写 onMeasure() 方法和 onLayout() 方法,在 onMeasure() 方法中测量 ScrollerLayout 里的每一个子控件的大小,在 onLayout() 方法中为 ScrollerLayout 里的每一个子控件在水平方向上进行布局。
接着重写 onInterceptTouchEvent() 方法, 在这个方法中我们记录了用户手指按下时的X坐标位置,以及用户手指在屏幕上拖动时的X坐标位置,当两者之间的距离大于 TouchSlop 值时,就认为用户正在拖动布局,然后我们就将事件在这里拦截掉,阻止事件传递到子控件当中。
那么当我们把事件拦截掉之后,就会将事件交给 ScrollerLayout 的 onTouchEvent() 方法来处理。如果当前事件是 ACTION_MOVE,说明用户正在拖动布局,那么我们就应该对布局内容进行滚动从而影响拖动事件,实现的方式就是使用我们刚刚所学的 scrollBy() 方法,用户拖动了多少这里就 scrollBy 多少。另外为了防止用户拖出边界这里还专门做了边界保护,当拖出边界时就调用 scrollTo() 方法来回到边界位置。
如果当前事件是 ACTION_UP 时,说明用户手指抬起来了,但是目前很有可能用户只是将布局拖动到了中间,我们不可能让布局就这么停留在中间的位置,因此接下来就需要借助 Scroller 来完成后续的滚动操作。首先这里我们先根据当前的滚动位置来计算布局应该继续滚动到哪一个子控件的页面,然后计算出距离该页面还需滚动多少距离。接下来我们就该进行上述步骤中的第二步操作,调用 startScroll() 方法来初始化滚动数据并刷新界面。startScroll() 方法接收四个参数,第一个参数是滚动开始时X的坐标,第二个参数是滚动开始时Y的坐标,第三个参数是横向滚动的距离,正值表示向左滚动,第四个参数是纵向滚动的距离,正值表示向上滚动。紧接着调用invalidate()方法来刷新界面。
现在前两步都已经完成了,最后我们还需要进行第三步操作,即重写 computeScroll() 方法,并在其内部完成平滑滚动的逻辑 。在整个后续的平滑滚动过程中,computeScroll() 方法是会一直被调用的,因此我们需要不断调用 Scroller 的 computeScrollOffset() 方法来进行判断滚动操作是否已经完成了,如果还没完成的话,那就继续调用 scrollTo() 方法,并把 Scroller 的 curX 和 curY 坐标传入,然后刷新界面从而完成平滑滚动的操作。
现在 ScrollerLayout 已经准备好了,接下来我们修改 activity_main.xml 布局中的内容,如下所示:




    

可以看到,这里我们在 ScrollerLayout 中放置了三个按钮用来进行测试,其实这里不仅可以放置按钮,放置任何控件都是没问题的。
最后 MainActivity 当中删除掉之前测试的代码:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

运行一下程序来看一看效果:

自定义 View 之 Scroller 与 View 滑动相关完全总结_第7张图片
image

其实借助 Scroller,很多漂亮的滚动效果都可以轻松完成,比如实现图片轮播之类的特效。当然就目前这一个例子来讲,我们只是借助它来学习了一下 Scroller 的基本用法,例子本身有很多的功能点都没有去实现,比如说 ViewPager 会根据用户手指滑动速度的快慢来决定是否要翻页,这个功能在我们的例子中并没有体现出来。


3、其它弹性滑动效果

  • 动画
    动画上面已经介绍过了,对于更详细的知识,在《自定义 View 动画篇》进行详解。
  • 延时策略
    这种方式也不常见,这里作为了解内容。
    延时策略就是通过发送一系列的延时消息从而达到一种渐进式的效果。我们可以使用 Handler 的 postDelayed 方法,也可以使用其他的延迟消息方法。这里我们就用 Handler 来实现一个简单的弹性滑动效果:
    PS:在自定义 View 里面能不使用 Handler 就尽量不使用。
rivate int mStartX = 0;
Handler mHandler = new Handler(){
    @Override
    public void handleMessage(Message msg) {
        switch (msg.what){
            case 0:
                if(mStartX > -100){
                    mLayout.scrollTo(mStartX, 0);
                    mStartX --;
                    mHandler.sendEmptyMessageDelayed(0, 5);
                }
                break;
        }
    }
};

点击按钮时,发送延时消息:

mHandler.sendEmptyMessageDelayed(0, 5);

运行结果:

自定义 View 之 Scroller 与 View 滑动相关完全总结_第8张图片
image

总结
通过了解以上几种实现弹性滑动的方法,知道了可以用 Scroller 实现 View 内的弹性滑动,使用动画可以实现 View 的弹性滑动。 在这几种方式中,其实更重要的是实现思想。只要掌握了其思想,那么就可以灵活运用起来,实现更多复杂的效果。

你可能感兴趣的:(自定义 View 之 Scroller 与 View 滑动相关完全总结)