Android自定义View Scroller与平滑滚动

Posted mChenys

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android自定义View Scroller与平滑滚动相关的知识,希望对你有一定的参考价值。

目录

一、什么是Scroller

Scroller 译为“滚动器”,是 ViewGroup 类中原生支持的一个功能。我们经常有这样的体验:
打开联系人,手指向上滑动,联系人列表也会跟着一起滑动,但是,当我们松手之后,滑动并不会因此而停止,而是伴随着一段惯性继续滑动,最后才慢慢停止。这样的用户体验完全照顾了人 的习惯和对事物的感知,是一种非常舒服自然的操作。要实现这样的功能,需要 Scroller 类的支持。

Scroller 类并不负责“滚动”这个动作,只是根据要滚动的起始位置和结束位置生成中间的过渡位置,从而形成一个滚动的动画。这一点至关重要。

所谓的“滚动”,事实上就是一个持续不断刷新 View 的绘图区域的过程,给定一个起始位置、结束位置、滚动的持续时间,Scroller自动计算出中间位置和滚动节奏,再调用 invalidate()方法不断刷新,从这点看,好像也不是那么复杂。

还有一点需要强调的是,一个 View 的滚动不是自身发起的动作,而是由父容器驱动子组件来完成,换句话说,需要 Scroller 和 ViewGroup 的配合才能产生滚动这个过程。所以,我们不要误以为是 View 自己在滚动,显然不是,而是容器让子组件滚动,主动权在 ViewGroup 手中。

当然View 也可以滚动,但是滚动的不是自己,而是 View 中的内容。

滚动往往分别两个阶段:第一个阶段是手指在屏幕上滑动,容器内的子组件跟随手指的速率一起滑动,当手指松开后,进入第二个阶段—惯性滚动,滚动不会马上停止,而是给出一个负的加速度,滚动速度会越来越慢,直到最后处于静态状态。这符合 android 中很多组件的使用场景。

二、认识scrollTo和scrollBy方法

View 类中有两个与滚动有关的方法—scrollTo()和 scrollBy(),这两个方法的源码如下:

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();
        
    


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

scrollTo(int x,int y)方法中,参数 x、y 是目标位置,方法先判断新的滚动位置是否确实发生了变化,如果是,先保存上一次的位置,再应用这一次的新位置(x,y)接着调用 onScrollChanged()方法,并刷新 View 组件。scrollTo()方法表示“滚动到……”之意。

scrollBy(int x, int y)方法则不同,是要原来的基础上水平方向滚动 x 个距离,垂直方向滚动 y个距离,最终还是调用了scrollTo(int x,int y)方法。本质上,这两个方法是一样的。scrollBy()方法表示“滚动了……”之意。在View中,还定义了获取滚动距离的方法,方法原型如下:

// 返回x方向滚动过的距离(和scrollView的一样),也是当前view的左上角相对于父视图的左上角的x轴偏移量,
// 也就是mScrollX的值,它的值为正数递增时,说明内容在由右往左移动,方向:⬅️
public final int getScrollX()
    return mScrollX;

// 返回y方向滚动过的距离(和scrollView的一样),也是当前view的左上角相对于父视图的左上角的y轴偏移量,
// 也就是mScrollY的值,它的值为正数递增时,说明内容在由下往上移动,方向:⬆️
public final int getScrollY()
    return mScrollY;

2.1 scrollTo、scrollBy对View内容的影响

我们写一个简单的案例来说明 scrollTo()和 scrollBy()的基本使用,并了解这两个方法给组件带来的影响。定义一个 TextView 组件,并放两个 Button,两个按钮分别调用 scrollTo()和 scrollBy()两个方法,并实现相同的功能。布局如下:

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

    <TextView
        android:id="@+id/tv"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="#99CCCCCC"
        android:text="Android" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="scrollBy"
            android:text="scrollBy" />

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="scrollTo"
            android:text="scrollTo" />
    </LinearLayout>
</LinearLayout>

Activity代码如下:

public class MainActivity15 extends AppCompatActivity 
    private TextView tv;

    @Override
    protected void onCreate(Bundle savedInstanceState) 
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main15);
        tv = findViewById(R.id.tv);
    

    public void scrollBy(View view) 
        tv.scrollBy(-5, 0);
    

    public void scrollTo(View view) 
        int x = tv.getScrollX();
        int y = tv.getScrollY();
        tv.scrollTo(x - 5, y);
    

Activity 类的 scrollBy()方法是第一个按钮的事件响应方法,调用了 tv.scrollBy(-5, 0)语句,表示 x 方向每次移动 5 个单位距离,y 不变;scrollTo()方法是第二个按钮的事件响应方法,先调用 tv.getScrollX()和 tv.getScrollY()获取当前 tv 对象的滚动距离,再通过 tv.scrollTo(x- 5,y)方法在 x 方向移动 5 个单位距离,y 不变。这两个方法实现的功能是相同的。运行结果如图:

仔细观察运行结果,可以得出以下几个结论:
1)移动的并不是 View 组件自身,而是组件的内容,当我们点击按钮时,文字“Android”的位置向右开始移动;

2)因为移动的是 View 组件的内容,所以,我们发现其方向与图形坐标系相反,也就是说,scrollBy()方法的在x 方向上参数为负时,向右移动,为正时,向左移动,y 方向
上参数为负时,向下移动,为正时,向上移动。scrollTo()方法的新坐标比原坐标小,x 方向向右移动,y 方向向下移动,反之亦然。

2.2 思考为什么移动负数距离会向坐标正方向移动?

我们可能会疑惑为什么滚动子组件的时候方向与我们的习惯是相反的,其实通过阅读源码能够有帮助我们理解。启动滚动后,调用 invalidate()方法刷新绘制,在该方法中,有如下的实现:

public void invalidate(int l, int t, int r, int b) 
    final int scrollX = mScrollX;
    final int scrollY = mScrollY;
    // 这里传入的l、t、r、b都是使用减法操作的
    invalidateInternal(l - scrollX, t - scrollY, r - scrollX, b - scrollY, true, false);

通过一个减法运算来定义新的矩形区域,这就是为什么子组件滚动方向相反的原因,因为left和top代表左上角,减去负数相当于加上整数,那么就会往坐标系右下角移动。

2.3 scrollTo、scrollBy对布局容器的影响

接下来再来演示 scrollTo()和 scrollBy()方法对布局容器的影响。定义布局文件,内容如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/linearlayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Android 自定义组件开发详解" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="scrollBy"
            android:text="scrollBy" />


        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="scrollTo"
            android:text="scrollTo" />
    </LinearLayout>
</LinearLayout>

现在对根布局LinearLayout进行scrollBy和scrollTo操作,Activity代码修改如下:

public class MainActivity15 extends AppCompatActivity 
    private LinearLayout linearlayout;

    @Override
    protected void onCreate(Bundle savedInstanceState) 
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main15);
        linearlayout = findViewById(R.id.linearlayout);
    

    public void scrollBy(View view) 
        linearlayout.scrollBy(-5, 0);
    

    public void scrollTo(View view) 
        int x = linearlayout.getScrollX();
        int y = linearlayout.getScrollY();
        linearlayout.scrollTo(x - 5, y);
    

效果如下:

由此可见移动LinearLayout时,并不是移动LinearLayout本身,而是移动LinearLayout中的子组件,一个TextView、两个Button共3个子组件发生了整体水平移动。

三、Scroller类

Scroller类在滚动过程的的几个主要作用如下:
1)启动滚动动作
2)根据提供的滚动目标位置和持续时间计算出中间的过渡位置
3)判断滚动是否结束
4)介入View或ViewGroup的重绘流程,从而形成滚动动画

3.1 相关方法介绍

Scroller类虽然对滑动作用非同小可,但定义的的方法并不多,我们最好是能阅读该类的源码,了解Scroller的工作原理。下面是Scroller的方法说明。

// 1. 构造方法,interpolator指定插速器,如果没有指定,默认插速器为ViscousFluidInterpolator,flywheel参数为true 可以提供类似“飞轮”的行为
public Scroller(Context context)
public Scroller(Context context, Interpolator interpolator)
public Scroller(Context context, Interpolator interpolator, boolean flywheel)

// 2.设置一个摩擦系数,默认为0.015f,摩擦系数决定惯性滑行的距离
public final void setFriction(float friction)

// 3. 返回起始x坐标值
public final int getStartX()

// 4. 返回起始y坐标值
public final int getStartY()

// 5.返回结束x坐标值
public final int getFinalX()

// 6.返回结束y坐标值
public final int getFinalY()

// 7.返回滚动过程中的 x 坐标值,滚动时会提供startX(起始)和finalX(结束),currX根据这两个值计算而来
public final int getCurrX()

// 8.返回滚动过程中的 y 坐标值,滚动时会提供startY(起始)和 finalY(结束),currY根据这两个值计算而来
public final int getCurrY()

// 9.计算滚动偏移量,必调方法之一。主要负责计算currX和currY两个值,其返回值为
// true表示滚动尚未完成,为false表示滚动已结束
public boolean computeScrollOffset()

// 10.启动滚动行为,startX 和 startY 表示起始位置,dx、dy 表示要滚动的 x、y 方向的距离,负数是右下角方向,duration 表示持续时间,默认时间为 250 毫秒
public void startScroll(int startX, int startY, int dx, int dy)
public void startScroll(int startX, int startY, int dx, int dy, int duration)

// 11.判断滚动是否已结束,返回 true 表示已结束
public final boolean isFinished()

// 12.强制结束滚动,currX、currY 即为当前坐标
public final void forceFinished(boolean finished)

// 13.与 forceFinished 功用类似,停止滚动,但 currX、currY 设置为终点坐标
public void abortAnimation()

// 14.延长滚动时间
public void extendDuration(int extend)

// 15.返回滚动已耗费的时间,单位为毫秒
public int timePassed()

// 16.设置终止位置的 x 坐标,可能需要调用extendDuration()延长或缩短动画时间
public void setFinalX(int newX)

// 17.设置终止位置的 y 坐标,可能需要调用 extendDuration()延长或缩短动画时间
public void setFinalY(int newY)

上面的方法中,常用的主要有 startScroll()、computeScrollOffset()、getCurrX()、getCurrY()和abortAnimation()等几个方法,下面我们通过一个简单的案例来演示 Scroller 类的基本使用。

3.2 scroller的基本使用

下面我们通过一个简单的案例来演示 Scroller 类的基本使用,定义一个名称为ScrollerViewGroup 的类,继承自ViewGroup,在该类中使用代码(非配置)定义一个子组件 Button。为了将重点放在Scroller类的使用上,ScrollerViewGroup在定义时做了大量简化,比如 layout_width 和 layout_height 不支持 wrap_content、Button 直接加入容器、onLayout()方法中将 Button 的位置固定死等等。

public class ScrollerViewGroup extends ViewGroup 
    private Scroller scroller;
    private Button btnAndroid;

    public ScrollerViewGroup(Context context) 
        this(context, null);
    

    public ScrollerViewGroup(Context context, AttributeSet attrs) 
        this(context, attrs, 0);
    

    public ScrollerViewGroup(Context context, AttributeSet attrs, int defStyleAttr) 
        super(context, attrs, defStyleAttr);
        scroller = new Scroller(context);
        btnAndroid = new Button(context);
        btnAndroid.setText("Android 自定义组件");
        addView(btnAndroid, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
    

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) 
        if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST
                || MeasureSpec.getMode(heightMeasureSpec)== MeasureSpec.AT_MOST)
            throw new IllegalStateException("Must be MeasureSpec.EXACTLY.");

        measureChildren(widthMeasureSpec, heightMeasureSpec);

        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));

    

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) 
        btnAndroid.layout(10, 10, btnAndroid.getMeasuredWidth() + 10, btnAndroid.getMeasuredHeight() + 10);
    

    /**
     * 实现平滑滚动
     */
    @Override
    public void computeScroll() 
        if (scroller.computeScrollOffset()) 
            //设置容器内组件的新位置
            this.scrollTo(scroller.getCurrX(), scroller.getCurrY());
            //重绘以刷新产生动画
            postInvalidate();
        
    

    /**
     * 开始滚动,外部调用
     */
    public void start() 
        //从当前位置开始滚动,x 方向向右滚动 900,所以要传入负数,y 方向不变,也就是水平滚动
        scroller.startScroll(this.getScrollX(), this.getScrollY(), -900, 0, 10000);
        //重绘
        postInvalidate();
    

    /**
     * 取消滚动,直接到达目的地
     */
    public void abort() 
        scroller.abortAnimation();
    

我们首先定义了一个 Scroller 类型的成员变量 scroller,并在构造方法中进行了实例化。重点是重写了 ViewGroup 的 computeScroll()方法,该方法的默认实现是空方法,在绘制 View 时调用。在 computeScroll()方法中,调用 scroller.computeScrollOffset()方法计算下一个位置的坐标值(currX,currY),再通过 this.scrollTo(scroller.getCurrX(), scroller.getCurrY())语句移动到该坐标位置,特别要注意的是一定要调用 invadate()或 postInvalidate()方法重绘,一旦 computeScrollOffset()方法返回false 表示滚动结束,停止重绘。

另外,我们还定义了两个用来与外部交互的方法:start()和 abort()。start()方法用于启动滚动动作,执行了 scroller.startScroll(this.getScrollX(),this.getScrollY(),- 900,0,10000)语句,其中参数this.getScrollX()和 this.getScrollY()是容器内容的初始位置,x 方向向右移动 900 个单位距离(为负才表示向右),y 方向不变,也就是水平向右移动,为了更好的查看动画过程,将滚动持续时间设 为 10 秒。和上面一样,就算调用了 startScroll()方法,也需要调用 invadate()或 postInvalidate()方 法进行重绘。在 abort()方法中调用了 scroller.abortAnimation()方法,用来停止滚动。

下面在修改Activity的布局

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/linearlayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.mchenys.viewdemo.ScrollerViewGroup
        android:id="@+id/scrollview"
        android:layout_width="match_parent"
       android:layout_height="200dp" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:orientation="horizontal">

        以上是关于Android自定义View Scroller与平滑滚动的主要内容,如果未能解决你的问题,请参考以下文章

站在源码的肩膀上全解Scroller工作机制

[Android Pro] Scroller使用分析

Android自定义LinearLayout实现左右侧滑菜单,完美兼容ListViewScrollViewViewPager等滑动控件

深入探讨Android异步精髓Handler

谷哥的小弟学后台(02)——MySQL

谷哥的小弟学后台(01)——MySQL