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类需要如下三个步骤:
- 初始化Scroller
首先通过构造方法来创建一个Scroller对象
//初始化Scroller
mScroller = new Scroller(context);
- 重写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()方法来获取当前的滑动坐标。
- 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,基本可以实现各种不同的滑动,拖动需求。
- 初始化ViewDragHelper
通常定义在一个ViewGroup的内部,并通过静态工厂方法进行初始化。
private void initView(){
mViewDragHelper = ViewDragHelper.create(this,callback);
}
- 拦截事件
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mViewDragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//将触摸事件传递给ViewDragHelper,此操作必不可少
mViewDragHelper.processTouchEvent(event);
return true;
}
- 处理computeScroll()
@Override
public void computeScroll() {
if (mViewDragHelper.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
- 处理回调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 属性动画
由于属性动画将在之后的博客中进行详细阐述,这里就先不赘述了。
完