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">

        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="start"
            android:text="开始滚动" />


        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="abort"
            android:text="停止滚动" />
    </LinearLayout>
</LinearLayout>

Activity代码如下:

public class MainActivity15 extends AppCompatActivity {

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

        mScrollerViewGroup = findViewById(R.id.scrollview);
    }

    public void start(View view) {
        mScrollerViewGroup.start();
    }

    public void abort(View view) {
        mScrollerViewGroup.abort();
    }
}

效果图:
在这里插入图片描述

3.3 平滑滚动的工作原理

从上一节中我们可以总结出平滑滚动的基本工作流程:

  1. 调用scroller的startScroll()方法定义滚动的起始位置和滚动的距离;
  2. 通过invalidate()或postInvalidate()方法刷新,调用draw(Canvas)方法重绘组件;
  3. 调用computeScroll()计算下一个位置的坐标;
  4. 再次调用invalidate()或postInvalidate()方法刷新重绘;
  5. 判断computeScroll()方法的返回值,如果为 false 表示结束滚动,为 true 表示继续滚动。

上面的步骤其实构建了一个方法调用循环:1)->2) ->3) ->4) ->5) ->3) ->4) ->5)……,3)->
4)->5)就是一个循环,该循环用于不断计算下一个位置,并通过重绘移动到该位置,这样就产生了动画效果。

下面通过阅读源码的方式进一步了解平滑滚动的工作原理。当调用invalidate()方法或postInvalidate()方法后,将重绘请求发送到ViewRoot,再分发到对应的组件,调用draw(Canvas canvas)方法。

public void draw(Canvas canvas){

    // ……

    // Step 4,draw the children

    dispatchDraw(canvas);

    // ……

}

在draw(Canvas canvas)方法中又调用dispatchDraw(Canvas canvas)方法,该方法负责将绘制请求分发给子组件。

protected void dispatchDraw(Canvas canvas) {
    // ……
    more |= drawChild(canvas, child, drawingTime);
    // ……
}

dispatchDraw(Canvas canvas)方法又调用了drawChild()方法完成子组件的绘制工作。

protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
    // ……
    child.computeScroll();
    // ……
}

重点来了,drawChild()方法调用了child.computeScroll()方法,但是该方法是一个空方法,什么也没有,我们需要重写该方法才能实现平滑滚动。

四、案例-触摸滑屏

4.1 触摸滑屏技术分析

Android 手机一个最明显的标识就是进入桌面后可以左右滑屏查看 App 应用图标,和Windows 的桌面有显著区别,这也给有限的桌面带来无限的空间,和垂直滑动显示内容相比,左右滑动更方便用户的手势操作,带来更好的用户体验,并获得用户的一致认可。

触摸滑屏分为两个过程:一是手指在屏幕上滑动时屏幕跟随一起滑动,滑动速度与手指速度相同,现在的硬件质量有了很大提升,基本上是很粘手的,以前硬件没有到达现在这个水平时,总有一些延迟,二是手指松开后,根据手指的速度、已滑动距离判断屏幕是要回滚还是滑动到下一屏。这两个过程共同构成了滑屏的基本动作。

触摸滑动的操作在 public boolean onTouchEvent(MotionEvent event)方法中完成,手指按下时,判断是否正在滑屏中,如果是,则马上停止,同时记下手指的初始坐标。手指移动过程中,获取手指移动的距离,并让容器内容以相同的方向移动相同的距离。手指松开后,根据手指移动速度和已移动的距离判断是要回滚还是移动到下一屏。

ViewGroup 的内容区域是无限大的,我们可以将无数组件都放进去,但因为屏幕空间有限,所以只能看到一部分内容。就像运行中游戏,场景很大,但是看到的却很少。要实现触摸分屏,必须将容器内的每个子组件设置成与屏幕大小相同,但一次只显示其中的一个,如下图所示:
在这里插入图片描述
容器的总宽度是容器可见宽度乘以子元素的个数,而高度则为可见高度大小。

4.2 速度追踪器VelocityTracker

VelocityTracker主要用于跟踪触摸屏事件(flinging 事件和其他 gestures 手势事件)的速率。当我们要跟踪一个touch事件的时候,使用obtain()方法得到这个类的实例,然后addMovement(MotionEvent)函数将你接受到的 motionEvent加入到VelocityTracker类实例中。

当我们需要使用到速率时,使用computeCurrentVelocity(int)初始化速率的单位,并获得当前的事件的速率,然后使用 getXVelocity()或 getYVelocity()获得横向和竖向的速率。另外,通过VelocityTracker 还可以知道手指的滑动方向。

VelocityTracker 的基本使用如下:

@Override
public boolean onTouchEvent(MotionEvent ev) {
    if (velocityTracker == null) {
        //创建 velocityTracker 对象
        velocityTracker = VelocityTracker.obtain();
    }
    // 必须每次都关联事件对象,否则获取不到某次的速度
    velocityTracker.addMovement(ev);
    switch (ev.getAction()) {
        case MotionEvent.ACTION_MOVE:
            // 手指移动过程中(ACTION_MOVE),计算速率
            velocityTracker.computeCurrentVelocity(1000);
            // 获取 x、y 两个方向的速率:
            int velocityX = (int) velocityTracker.getXVelocity(); // 正数向右运动,负数向左运动
            int velocityY = (int) velocityTracker.getYVelocity(); // 正数向下运动,负数向上运动
            break;
        case MotionEvent.ACTION_UP:
            //释放 VelocityTracker 资源
            if (velocityTracker != null) {
                velocityTracker.clear();
                velocityTracker.recycle();
                velocityTracker = null;
            }
            break;
    }
    return true;
}

4.3 触摸滑屏的分步实现

定义一个容器类 MultiLauncher,继承自 ViewGroup,容器类中的子组件将与容器大小相同。
第一步:初始化。平滑滚动需要使用 Scroller 对象,另外还需要给定一个最小滑动距离,通过 ViewConfiguration.get(context).getScaledTouchSlop()可以获取到当前手机上默认的最小滑动距离。

第二步:测量容器宽度与高度。每个子组件与容器相同,容器的layout_width值虽然为 MeasureSpec.EXACTLY,但容器大小=父容器的宽度 *子组件的个数,高度与父容器相同。

第三步:定位子组件。默认情况下,屏幕出现第一个子组件,子组件占满容器的可见区域,其他子组件以相同大小依次排列在后面。

第四步:判断滚动状态,状态为分两种:停止状态和滑动状态。容器根据状态决定是否截拦事件。

第五步:惯性滚屏,我们需要计算滚动到具体某一屏时还需滚动多少距离,例如:
int dx = curScreen * childWidth - scrollX,获得当前屏的索引curScreen(从 0 开始),乘以一屏的宽度,减去容器滚过的距离,得到的值就是剩下的惯性距离。另外手指滑动距离如果超过容器一半或者滑动速度足够快,则进入下一屏(或者上一屏),如果没有超过一半或速度很慢则回滚到初始位置,例如:
int toScreen = (getScrollX() + childWidth / 2 )/childWidth

第六步:定义moveToNext()和 moveToPrevious()简化滑屏调用。

第七步:响应用户手指的按下、移动和松开事件,这是整个滑动的关键,特别是松开后,要判断滚屏还是回滚。为了支持上一屏和下一屏,需要辨别手指滑动的方向,VelocityTracker类可。以获取 x 方向的速率,其正值代表向左滑动,负值代表向右滑动
如果 x 方向的速率在[-SNAP_VELOCITY,SNAP_VELOCITY]之间,则要根据用户滑动的距离(滑动距离是否超过一屏的1/2)决定是要继续滚屏还是回滚到初始状态。

public class MultiLauncher extends ViewGroup {
    private int childWidth; // 子View的宽度
    private Scroller mScroller;
    private int maxScrollX; // 向左滑动的最大值(左滑动是正数)
    private VelocityTracker velocityTracker;

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

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

    public MultiLauncher(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mScroller = new Scroller(context, new LinearInterpolator());
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 先测量子Vie
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        this.childWidth = MeasureSpec.getSize(widthMeasureSpec);
        int width = getChildCount() * childWidth;
        int height = MeasureSpec.getSize(heightMeasureSpec);
        setMeasuredDimension(width, height);
        this.maxScrollX = width - childWidth;
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            int left = i * childWidth;
            child.layout(left, 0, left + childWidth, getHeight());
        }
    }

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

    private int mLastX; // 上一次记录的x坐标

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        mLastX = (int) event.getX();
        if (event.getAction() == MotionEvent.ACTION_MOVE || event.getAction() == MotionEvent.ACTION_UP) {
            // 滚动时拦截,交给自己onTouchEvent处理
            return true;
        }
        return super.onInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int currX = (int) event.getX();
        if (velocityTracker == null) {
            //创建 velocityTracker 对象
            velocityTracker = VelocityTracker.obtain();
        }
        //关联事件对象
        velocityTracker.addMovement(event);

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 按下时如果还在滚动将停止滚动
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }

                break;
            case MotionEvent.ACTION_MOVE:
                // 每次移动的变化量,内容向左移动,变化量是负数,反之是正数
                int detalX = currX - mLastX;

                // 由于scrollTo的特殊性,传入负数,内容其实是向右移动的,这与我们向左移动相违背,
                // 所以这里要将变化量取反再和上一次滚动的距离相加得出新的滚动距离(水平方向滚动距离为正表示内容在向左滚动)
                int newScrollX = getScrollX() - detalX; // 可理解为 getScrollX() + (-detalX);
                if (newScrollX > maxScrollX) {
                    // 向左移动的mScrollX最大值不能超过getWidth() - childWidth
                    newScrollX = maxScrollX;
                }
                if (newScrollX < 0) {
                    // 向右边移动的mScrollX最小值是0,也就是第一个View完全显示的效果
                    newScrollX = 0;
                }
                // 传入正数,内容向左移动,反之向右移动
                scrollTo(newScrollX, 0);
                break;
            case MotionEvent.ACTION_UP:
                // 松手后平滑滚动
                smoothScroll();
                break;
        }
        // 实时更新上次的x坐标
        mLastX = currX;
        return true;

    }

    private int curScreen;    //当前屏

    /**
     * 滚动到某一屏
     *
     * @param whichScreen
     */
    public void moveToScreen(int whichScreen) {
        curScreen = whichScreen;
        if (curScreen > getChildCount() - 1)
            curScreen = getChildCount() - 1;
        if (curScreen < 0) curScreen = 0;

        int scrollX = getScrollX();

        // 剩余要移动的距离 = 总共要移动的距离 - 当前已移动的距离
        int dx = curScreen * childWidth - scrollX;
        //开始移动
        mScroller.startScroll(scrollX, 0, dx, 0, Math.abs(dx));
        invalidate();
    }

    /**
     * 滚动到目标屏
     */
    public void moveToDestination() {
        // 判断是回滚还是进入下一分屏,括号内的运算需要好好理解下,getScrollX()已经包含了当前屏的滚动距离和前面几屏的距离,
        // 前面几屏的距离除以屏幕宽度肯定是一个正数,
        // 所以我们只需要判断当前屏的滚动距离再加屏幕一半的距离是否大于屏幕的宽度childWidth,
        // 如果大于,那么再除以childWidth后得到的就大于等于1的数,那就说明是要切屏,否则复原当前屏
        int toScreen = (getScrollX() + childWidth / 2) / childWidth;
        // 移动到目标分屏
        moveToScreen(toScreen);
    }

    /**
     * 滚动到下一屏
     */
    public void moveToNext() {
        moveToScreen(curScreen + 1);
    }

    /**
     * 滚动到上一屏
     */
    public void moveToPrevious() {
        moveToScreen(curScreen - 1);
    }

    private void smoothScroll() {
        velocityTracker.computeCurrentVelocity(1000);
        float velocityX = velocityTracker.getXVelocity();
        //通过 velocityX 的正负值可以判断滑动方向,正数向右运动,负数向左运动
        if (velocityX > 600 && curScreen > 0) {
            moveToPrevious();
        } else if (velocityX < -600 && curScreen < (getChildCount() - 1)) {
            moveToNext();
        } else {
            moveToDestination();
        }
        //释放 VelocityTracker 资源
        if (velocityTracker != null) {
            velocityTracker.clear();
            velocityTracker.recycle();
            velocityTracker = null;
        }
    }
}

布局如下:

<com.mchenys.viewmodel.MultiLauncher
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/scrollview"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#ff0000" />

    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#00ff00" />

    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#0000ff" />
</com.mchenys.viewmodel.MultiLauncher>

效果图:
在这里插入图片描述

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android自定义View是指基于Android原生控件的一种扩展,可以根据自己的需求和设计规范来创建更加个性化和独特的控件。而歌词控件是一种针对音乐播放器或者视频播放器等应用场景中的需求,用于显示音乐或者视频的歌词的控件。 Android自定义View歌词控件的实现思路如下: 1. 首先需要自定义一个View,并继承自View或者其子类,如TextView。 2. 在自定义View中重写onDraw方法,在其中实现绘制歌词的逻辑。 3. 在onDraw方法中,使用Canvas对象进行绘制,可以使用drawText方法绘制歌词文本,也可以使用drawBitmap方法绘制图片背景等。 4. 可以通过自定义属性,如字体大小、字体颜色、歌词滚动速度等,来对歌词控件进行配置。 5. 如果需要实现歌词的滚动效果,可以使用ValueAnimator或者Scroller来实现歌词的平滑滚动。 6. 如果需要实现点击歌词跳转播放进度的功能,可以通过添加点击事件监听器,在触摸事件中判断点击位置对应的歌词行,并根据歌词的时间戳跳转到指定的播放进度。 总结来说,Android自定义View歌词控件的实现需要重写onDraw方法进行绘制,可以通过Canvas对象进行绘制文本或者图像,通过自定义属性进行配置,使用动画或者滚动实现歌词的平滑滚动,通过监听触摸事件实现点击歌词跳转播放进度的功能。通过以上步骤,我们可以创建一个个性化的歌词控件,满足不同应用场景的需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值