Android Scroll分析

Android 4.X版本开始,滑动操作就大量出现在了Android系统中,很多第三方应用也竞相模仿这种效果。

1. 滑动效果是如何产生的

滑动一个View本质上来说是移动一个View。改变其当前所处的位置,它的原理与动画效果的实现非常相似,都是通过不断地改变View的坐标来实现这一效果。所以,要实现View的滑动就必须监听用户的触摸事件,并根据事件传入的坐标动态且不断地改变View的坐标,从而实现View跟随用户触摸的滑动而滑动。

1.1 Android坐标系

在Android中,将屏幕最左上角的顶点Android坐标系的原点。从这个点向右是X轴正方向,从这个点向下是Y轴正方向。
在这里插入图片描述
系统提供了getLocationOnScreen(int location[])方法来获取Android坐标系中点的位置,即该视图左上角在Android坐标系中的坐标。在触控事件中使用getRawX()、getRawY()方法所获得的坐标同样是Android坐标系中的坐标。

1.2 视图坐标系

在Android中除了上面说的坐标系之外,还有一个视图坐标系,它描述了子视图在父视图中的位置关系。这两种坐标系的作用相辅相成。与Android坐标系类似,视图坐标系同样是以原点向右为X轴正方向,以原点向下为Y轴正方向,只不过在视图坐标系中原点不再是Android坐标系中的屏幕最左上角,而是以父视图左上角为坐标原点。
在这里插入图片描述
在触控事件中,通过getX()、getY()所获得的坐标就是视图坐标系中的坐标。

1.3 触控事件——MotionEvent

触控事件MotionEvent中封装的一些常用的事件常量:

// 单击触摸按下动作
public static final int ACTION_DOWN = 0;
// 点击触摸离开动作
public static final int ACTION_UP = 1;
// 触摸点移动动作
public static final int ACTION_MOVE = 2;
// 触摸动作取消
public static final int ACTION_CANCEL = 3;
// 触摸动作超出边界
public static final int ACTION_OUTSIDE = 4;
// 多点触摸按下动作
public static final int ACTION_POINTER_DOWN = 5;
// 多点离开动作
public static final int ACTION_POINTER_UP = 6;

通常情况下,我们会在 onTouchEvent(MotionEvent event)方法中通过event.getAction()方法来获取触控事件的类型,并使用 switch-case 方法来进行筛选,这个代码的模式基本固定:

@Override
public boolean onTouchEvent(MotionEvent event){
	//获取当前输入点的X、Y坐标(视图坐标)
	int x = (int) event.getX();
	int y = (int) event.getY();
	switch(event.getAction()){
		case MotionEvent.ACTION_DOWN:
			//处理输入的按下事件
			break;
		case MotionEvent.ACTION_MOVE:
			//处理输入的移动事件
			break;
		case MotionEvent.ACTION_UP:
			//处理输入的离开事件
			break;
	}
	return true;
}

在不涉及多点操作的情况下,通常可以使用以上代码来完成触控事件的监听。
在Android中,系统提供了很多方法来获取坐标值、相对距离等。
下面总结一些API,结合Android坐标系来看看该如何使用它们。
在这里插入图片描述

这些方法可以分成如下两个类别:
· View提供的获取坐标方法

  • getTop():获取到的是View自身的顶部到其父布局顶边的距离
  • getLeft():获取到的是View自身的左边到其父布局左边的距离
  • getRight():获取到的是View自身的右边到其父布局左边的距离
  • getBottom():获取到的是View自身的底边到其父布局顶边的距离

· MotionEvent提供的方法

  • getX():获取点击事件距离控件左边的距离,即视图坐标
  • getY():获取点击事件距离控件顶边的距离,即视图坐标
  • getRawX():获取点击事件距离整个屏幕左边的距离,即绝对坐标
  • getRawY():获取点击事件距离整个屏幕顶边的距离,即绝对坐

2. 实现滑动的七种方式

下面再来看看如何使用系统提供的API来实现动态地修改一个View的坐标,即实现滑动效果。不管采用哪一种方法,其实现的四项基本是一致的,当触摸View时,系统几下当前触摸点坐标;当手指移动时,系统记下移动后的触摸点坐标,从而获取到相对于前一次坐标点的偏移量,并通过偏移量来修改View的坐标,这样不断重复,从而实现滑动过程。

效果展示:

滑动

2.1 layout方法

在View进行绘制时,会调用onLayout()方法来设置显示的位置。同样,可以通过修改View的left、top、right、bottom四个属性来控制View的坐标。在每次回调onTouchEvent的时候我们都获取一下触摸点的坐标,接着在ACTION_DOWN事件中记录触摸点的坐标,最后再在ACTION_MOVE事件中计算偏移量,并将偏移量作用到Layout方法中,这样每次移动后,View都会调用Layout方法来对自己重新布局,从而达到移动View的效果。

先将一个Button置于LinearLayout中,实现一个简单的布局(activity_scroll.xml):

<?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">
    
    <com.swpuiot.test.test5.MyView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="#000000"/>
        
</LinearLayout>

MyView.class

package com.swpuiot.test.test5;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.Nullable;

/**
 * Time: 2021/4/16
 * Author: lenovo
 * Description: 这是自定义的MyView1类,layout方式---绝对坐标方式
 */
public class MyView extends View {

    private int lastX = 0,lastY = 0;

    public MyView(Context context) {
        super(context);
    }

    public MyView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public MyView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int rawX = (int) event.getRawX();
        int rawY = (int) event.getRawY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                //记录触摸点坐标
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算偏移量
                int offsetX = rawX - lastX;
                int offsetY = rawY - lastY;
                //在当前left、top、right、bottom的基础上加上偏移量
                layout(getLeft() + offsetX,getTop() + offsetY,
                        getRight() + offsetX,getBottom() + offsetY);
                //重新设置初始坐标
                lastX = rawX;
                lastY = rawY;
                break;
        }
        return true;
    }
}

ScrollTest.java

package com.swpuiot.test.test5;

import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import com.swpuiot.test.R;

/**
 * Time: 2021/4/16
 * Author: lenovo
 * Description: 加载布局的活动类
 */
public class ScrollTest extends AppCompatActivity {

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

2.2 offsetLeftAndRight() 与offsetTopAndBottom()

这个方法相当于系统提供的一个对左右、上下移动的API的封装。当计算出偏移量后,只需要使用如下代码就可以完成View的重新布局,效果与使用Layout()方法

	@Override
    public boolean onTouchEvent(MotionEvent event) {
        int rawX = (int) event.getRawX();
        int rawY = (int) event.getRawY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                //记录触摸点坐标
                lastX = rawX;
                lastY = rawY;
                break;
            case MotionEvent.ACTION_MOVE:
                //计算偏移量
                int offsetX = rawX - lastX;
                int offsetY = rawY - lastY;
                //同时对left和right进行偏移
                offsetLeftAndRight(offsetX);
                //同时对top和bottom进行偏移
                offsetTopAndBottom(offsetY);
                //重新设置初始坐标
                lastX = rawX;
                lastY = rawY;
                break;
        }
        return true;
    }

2.3 LayoutParams

LayoutParams保存了一个View的布局参数。因此可以通过改变LayoutParams来动态地修改一个布局的位置参数,从而达到改变View位置的效果。我们可以使用getLayoutParams()来获取一个View的LayoutParams。当然,计算偏移量的方法与在Layout方法中计算offset也是一样的。当获取到偏移量之后,就可以通过setLayoutParams来改变其LayoutParams。

LinearLayoutCompat.LayoutParams layoutParams = (LinearLayoutCompat.LayoutParams) getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);

不过,这里需要注意的是,通过getLayoutParams()获取LayoutParams时,需要根据View所在的父布局的类型来设置不同的类型,比如这里将View放在LinearLayout中,那么就可以使用LinearLayout.LayoutParams。类似地,如果在RelativeLayout中,就要使用RelativeLayout.LayoutParams。

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

我们可以发现,使用ViewGroup.MarginLayoutParams更加方便,不需要考虑父布局的类型,当然它们的本质都是一样的。

2.4 scrollTo与scrollBy

在一个View中,系统日工了scrollTo、scrollBy两种方式来改变一个View的位置。这两个方法的区别还是好理解的。scrollTo(x,y)表示移动到一个具体的坐标点(x,y),而scrollBy(dx,dy)表示移动的增量为dx、dy。

case MotionEvent.ACTION_MOVE:
	//计算偏移量
    int offsetX = rawX - lastX;
    int offsetY = rawY - lastY;
    ((View) getParent()).scrollBy(-offsetX,-offsetY);
    //重新设置初始坐标的值
    lastX = rawX;
    lastY = rawY;
    break;

2.5 Scroller

Scroller类与scrollBy、scrollTo方法是十分相似的。不管使用scrollTo还是scrollBy方法,子View的平移都是瞬间发生的,在事件执行的时候平移就已经完成了,Scroller类可以实现平滑移动的效果,而不再是瞬间完成的移动。

一般情况下,使用Scroller类需要如下三个步骤:

  1. 初始化Scroller
    首先通过构造方法来创建一个Scroller对象
//初始化Scroller
mScroller = new Scroller(context);
  1. 重写computeScroll()方法,实现模拟滑动
    computeScroll()方法是使用Scroller类的核心,系统在绘制View的时候会在draw()方法中调用该方法。
    通常情况下,computeScroll()的代码可以利用如下模板:
@Override
public void computeScroll(){
	super.computeScroll();
	//判断Scroller是否执行完毕
	if(mScroller.computeScrollOffset()){
		((View) getParent()).scrollTo(mScroller.getCurrX),mScroller.getCurrY());
		//通过重绘来不断调用computeScroll
		invalidate();
	}
}

上述代码中,Scroller类提供了computeScrollOffset()方法来判断是否完成了整个滑动,同时提供了getCurrX()、getCurrY()方法来获取当前的滑动坐标。

  1. startScroll开启模拟过程

我们需要在使用平滑移动的时间中,使用Scroller类的startScroll()方法来开启平滑移动过程。

startScroll()方法具有两个重载方法:

  • public void startScroll(int startX,int startY,int dx,int dy,int duration)
  • public void startScroll(int startX,int startY, int dx,int dy)
case MotionEvent.ACTION_UP:
	// 手指离开时,执行滑动过程
    View viewGroup = ((View) getParent());
    mScroller.startScroll(
		viewGroup.getScrollX(),
        viewGroup.getScrollY(),
        -viewGroup.getScrollX(),
        -viewGroup.getScrollY());
    invalidate();
    break;

2.6 ViewDragHelper

通过ViewDragHelper,基本可以实现各种不同的滑动,拖动需求。

  1. 初始化ViewDragHelper
    通常定义在一个ViewGroup的内部,并通过静态工厂方法进行初始化。
private void initView(){
	mViewDragHelper = ViewDragHelper.create(this,callback);
}
  1. 拦截事件
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
	return mViewDragHelper.shouldInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
	//将触摸事件传递给ViewDragHelper,此操作必不可少
	mViewDragHelper.processTouchEvent(event);
	return true;
}
  1. 处理computeScroll()
@Override
public void computeScroll() {
	if (mViewDragHelper.continueSettling(true)) {
		ViewCompat.postInvalidateOnAnimation(this);
	}
}
  1. 处理回调Callback
private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
// 何时开始检测触摸事件
@Override
public boolean tryCaptureView(View child, int pointerId) {
	//如果当前触摸的child是mMainView时开始检测
	return mMainView == child;
}

当手指离开屏幕后,子View滑动回到初始位置。通过onViewReleased()来实现

// 拖动结束后调用
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
	super.onViewReleased(releasedChild, xvel, yvel);
	//手指抬起后缓慢移动到指定位置
	if (mMainView.getLeft() < 500) {
		//关闭菜单
		//相当于Scroller的startScroll方法
		mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
		ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
	} else {
		//打开菜单
		mViewDragHelper.smoothSlideViewTo(mMainView, 300, 0);
		ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
	}
}

在onSizeChange()方法中获取View的宽度,根据View宽度处理滑动后的效果

//加载完布局文件后调用
@Override
protected void onFinishInflate() {
	super.onFinishInflate();
	mMenuView = getChildAt(0);
	mMainView = getChildAt(1);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
	super.onSizeChanged(w, h, oldw, oldh);
	mWidth = mMenuView.getMeasuredWidth();
}

效果展示:

侧边栏滑动

完整代码示例:

package com.swpuiot.test.test6;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.customview.widget.ViewDragHelper;

/**
 * Time: 2021/4/16
 * Author: lenovo
 * Description:
 */
public class DragViewGroup extends FrameLayout {

    private ViewDragHelper mViewDragHelper;
    private View mMenuView,mMainView;
    private int mWidth;

    public DragViewGroup(@NonNull Context context) {
        super(context);
        initView();
    }

    public DragViewGroup(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initView();
    }

    public DragViewGroup(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initView();
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mMenuView = getChildAt(0);
        mMainView = getChildAt(1);
    }

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

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return mViewDragHelper.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //将触摸事件传递给ViewDragHelper,此操作必不可少
        mViewDragHelper.processTouchEvent(event);
        return true;
    }

    private void initView() {
        mViewDragHelper = ViewDragHelper.create(this,callback);
    }

    private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {

        //何时开始检测触摸事件
        @Override
        public boolean tryCaptureView(@NonNull View child, int pointerId) {
            //如果当前触摸的child是mMainView时开始检测
            return mMainView == child;
        }

        //触摸到View后回调
        @Override
        public void onViewCaptured(@NonNull View capturedChild, int activePointerId) {
            super.onViewCaptured(capturedChild, activePointerId);
        }

        //当拖拽状态改变,比如idle,dragging

        @Override
        public void onViewPositionChanged(@NonNull View changedView, int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
        }

        //处理垂直滑动
        @Override
        public int clampViewPositionVertical(@NonNull View child, int top, int dy) {
            return 0;
        }

        //处理水平滑动
        @Override
        public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
            return left;
        }

        //拖动结束后调用

        @Override
        public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
            //手指抬起后缓慢移动到指定位置
            if(mMainView.getLeft() < 500){
                //关闭菜单
                //相当于Scroller的startScroll方法
                mViewDragHelper.smoothSlideViewTo(mMainView,0,0);
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            }else{
                //打开菜单
                mViewDragHelper.smoothSlideViewTo(mMainView,300,0);
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            }
        }
    };

    @Override
    public void computeScroll() {
        if(mViewDragHelper.continueSettling(true)){
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }
}

package com.swpuiot.test.test6;

import android.os.Bundle;

import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;

import com.swpuiot.test.R;

/**
 * Time: 2021/4/16
 * Author: lenovo
 * Description:
 */
public class ViewDragHelperTest extends AppCompatActivity {

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

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.swpuiot.test.test6.DragViewGroup
        android:id="@+id/view"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@android:color/holo_blue_light">

            <Button
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Menu"
                android:textAllCaps="false"/>
        </FrameLayout>

        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@android:color/holo_orange_dark">

            <Button
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Main"
                android:textAllCaps="false"/>
        </FrameLayout>

    </com.swpuiot.test.test6.DragViewGroup>
</RelativeLayout>

2.7 属性动画

由于属性动画将在之后的博客中进行详细阐述,这里就先不赘述了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值