體驗(yàn)RecyclerView的滑動以及滾動的實(shí)現(xiàn)源碼
一侣滩、應(yīng)用場景
在自定義View中君珠,常常會用到滾動策添,但是出于某些原因不能直接繼承ScrollView,這時候就很有必要來看看他們滾動都是怎么實(shí)現(xiàn)的了苦丁。
本文只關(guān)注拖動和慣性滑動的效果實(shí)現(xiàn)产上。以RecyclerView的代碼為示例(和ScrollView相比蒂秘,在滾動上的實(shí)現(xiàn)方式一樣,在慣性滑動的實(shí)現(xiàn)上蒲牧,用的插值器(Interpolator)不同冰抢,下文會講到)挎扰,抽出RecyclerView中的手指拖動和手指離開后的慣性滑動的代碼遵倦。
二似谁、效果展示
繼承ViewGroup,實(shí)現(xiàn)RecyclerView的滑動效果巩踏,如圖:
三塞琼、核心效果概述
- 單指拖動
- 多指操作時彪杉,以新加入的手指為準(zhǔn)進(jìn)行拖動
- 手指松開時的慣性滑動
- 滑動到邊緣時的處理
四在讶、效果實(shí)現(xiàn)
首先构哺,先在onLayout里面加上20個View用來展示拖動的效果(這一部分和滑動無關(guān)曙强,只為效果展示碟嘴,可跳過)娜扇,這里給出效果圖:
共有20個Item雀瓢,由于還沒加滑動刃麸,暫時只能顯示前兩個Item。
4.1 單指拖動
針對用戶操作把沼,這時候自然就要用到onTouchEvent()了饮睬,區(qū)分開用戶的按下,移動宦搬,抬起操作间校。
在這之前需要先定義一個常亮mTouchSlop
憔足,當(dāng)手指移動大于這個常量滓彰,便表示手指開始拖動揭绑,否則菇存,表示手指僅僅只是按下邦蜜,這樣可以更好的區(qū)別出用戶的意圖贱迟。
final ViewConfiguration vc = ViewConfiguration.get(context);
mTouchSlop = vc.getScaledTouchSlop();
下面放出單指拖動的onTouchEnevt()的代碼:
public static final int SCROLL_STATE_IDLE = 0;
public static final int SCROLL_STATE_DRAGGING = 1;
private int mLastTouchY;
@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = MotionEventCompat.getActionMasked(event);
switch (action) {
case MotionEvent.ACTION_DOWN: {
mScrollState = SCROLL_STATE_IDLE;
mLastTouchY = (int) (event.getY() + 0.5f);
break;
}
case MotionEvent.ACTION_MOVE: {
int y = (int) (event.getY() + 0.5f);
int dy = mLastTouchY - y;
if (mScrollState != SCROLL_STATE_DRAGGING) {
boolean startScroll = false;
if (Math.abs(dy) > mTouchSlop) {
if (dy > 0) {
dy -= mTouchSlop;
} else {
dy += mTouchSlop;
}
startScroll = true;
}
if (startScroll) {
mScrollState = SCROLL_STATE_DRAGGING;
}
}
if (mScrollState == SCROLL_STATE_DRAGGING) {
mLastTouchY = y;
scrollBy(0, dy);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
}
return true;
}
上面的代碼和變量都是來自RecyclerView的onTouchEvent方法关筒,當(dāng)然我剔除了單指拖動以外的部分。稍微解釋一下主要的思路:
在用戶事件:DOWN -> MOVE -> MOVE -> ... -> MOVE -> UP中萍肆,首先在DOWN中記錄下按下的位置包雀,在每一個MOVE事件中計算和DOWN之間的位置差亲铡,當(dāng)有一個MOVE的位置差大于最小移動距離(mTouchSlop)時赞草,表示拖動開始吆鹤,開始位移沾凄。之后的MOVE事件也無需再次和mTouchSlop比較知允,直接進(jìn)行拖動位移温鸽,直到UP事件觸發(fā)。
這時候就存在一個問題配椭,如圖:
存在兩個手指時,以第一個手指操作為準(zhǔn)敦姻,當(dāng)?shù)谝粋€手指松開時,會跳到第二個手指按下時的位置迷守。
4.2 多指操作
多指滑動時就需要指明,控件到底該聽誰的茵瘾。這里就需要有個約束:
- 以新加入的手指的滑動為準(zhǔn)
- 當(dāng)有一個手指抬起時圣絮,以剩下的手指的滑動為準(zhǔn)
要做到上面的約束扮匠,就不可避免的需要區(qū)分出屏幕上的手指棒搜。MotionEvent
提供getPointerId()
方法帮非,用于返回每一個手指的ID。
先奉上添加了多指操作后的onTouchEvent方法的代碼:
private static final int INVALID_POINTER = -1;
private int mScrollPointerId = INVALID_POINTER;
@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = MotionEventCompat.getActionMasked(event);
final int actionIndex = MotionEventCompat.getActionIndex(event);
switch (action) {
case MotionEvent.ACTION_DOWN: {
setScrollState(SCROLL_STATE_IDLE);
mScrollPointerId = event.getPointerId(0);
mLastTouchY = (int) (event.getY() + 0.5f);
break;
}
case MotionEventCompat.ACTION_POINTER_DOWN: {
mScrollPointerId = event.getPointerId(actionIndex);
mLastTouchY = (int) (event.getY(actionIndex) + 0.5f);
break;
}
case MotionEvent.ACTION_MOVE: {
final int index = event.findPointerIndex(mScrollPointerId);
if (index < 0) {
Log.e("zhufeng", "Error processing scroll; pointer index for id " + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
return false;
}
final int y = (int) (event.getY(index) + 0.5f);
int dy = mLastTouchY - y;
if (mScrollState != SCROLL_STATE_DRAGGING) {
boolean startScroll = false;
if (Math.abs(dy) > mTouchSlop) {
if (dy > 0) {
dy -= mTouchSlop;
} else {
dy += mTouchSlop;
}
startScroll = true;
}
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}
if (mScrollState == SCROLL_STATE_DRAGGING) {
mLastTouchY = y;
scrollBy(0, dy);
}
break;
}
case MotionEventCompat.ACTION_POINTER_UP: {
if (event.getPointerId(actionIndex) == mScrollPointerId) {
// Pick a new pointer to pick up the slack.
final int newIndex = actionIndex == 0 ? 1 : 0;
mScrollPointerId = event.getPointerId(newIndex);
mLastTouchY = (int) (event.getY(newIndex) + 0.5f);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
}
return true;
}
添加了一個新的變量mScrollPointerId
,用于指定當(dāng)前移動遵循的是哪一個手指的操作,在有新的手指加入時游盲,設(shè)置mScrollPointerId為新的手指益缎。在有手指離開的時候莺奔,設(shè)置mScrollPointerId為剩下的那個手指令哟。
添加了ACTION_POINTER_DOWN和ACTION_POINTER_UP兩個事件屏富,在已有DOWN事件后狠半,新增手指點(diǎn)擊便會出發(fā)ACTION_POINTER_DOWN事件神年,ACTION_POINTER_DOWN和ACTION_POINTER_UP類似于DOWN和UP事件,都是成對出現(xiàn)昂验。區(qū)別在于捂敌,DOWN和UP是第一個手指,ACTION_POINTER_DOWN和ACTION_POINTER_UP既琴,只要有一個新的手指加入占婉,就會觸發(fā)一次。
核心的就是明確當(dāng)前實(shí)際操作的手指(mScrollPointerId)甫恩,計算位置信息都使用mScrollPointerId的手指即可保證位移信息的正確性逆济。
也來給出個應(yīng)有的效果:
4.3 慣性滑動
要做到慣性滑動,我們需要做到:
- 得到手指抬起時的速度
- 將速度轉(zhuǎn)換成具體的位移
4.3.1 獲取速度
首先磺箕,關(guān)于如何在ACTION_UP
中得到速度奖慌。VelocityTrackerCompat
的getYVelocity
可以獲得指定ID的手指當(dāng)前Y軸上的速度雕欺。向上為負(fù),向下為正夏志。關(guān)于VelocityTrackerCompat和VelocityTracker的使用,這里直接貼出:
private VelocityTracker mVelocityTracker;
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
boolean eventAddedToVelocityTracker = false;
final MotionEvent vtev = MotionEvent.obtain(event);
...
case MotionEvent.ACTION_UP: {
mVelocityTracker.addMovement(vtev);
eventAddedToVelocityTracker = true;
mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
float yVelocity = -VelocityTrackerCompat.getYVelocity(mVelocityTracker, mScrollPointerId);
...
}
if (!eventAddedToVelocityTracker) {
mVelocityTracker.addMovement(vtev);
}
vtev.recycle();
...
}
4.3.2 將速度反應(yīng)到滑動上
重點(diǎn)說一下如何將UP時的速度宇色,反應(yīng)到控件的滾動上。根據(jù)OverScroller的fling方法,我們不去探尋這個方法具體是如何實(shí)現(xiàn)的,只需要知道坞古,調(diào)用了這個方法之后奶陈,便可以不停的去調(diào)用getCurrY()方法,詢問當(dāng)前移動到哪兒了,知道這次的滑動停止(computeScrollOffset方法返回false)。
這樣我們要做的就是:
- 在UP的時候獲取Y軸上的移動速度
- 判斷時候需要慣性滑動
- 需要慣性滑動的時候調(diào)用
OverScroller
的fling
方法,進(jìn)行模擬滑動計算 - 在滑動停止之前不停的詢問當(dāng)前按照計算來說應(yīng)該滑動到哪兒了去設(shè)置控件的位置
需要明確的一個概念幅聘,OverScroller方法葛超,只涉及到滑動位置的計算,根據(jù)輸入的值侥涵,計算在什么時間應(yīng)該滑動到什么位置,具體的控件的移動還是需要調(diào)用View的ScrollTo或者ScrollBy方法。
落實(shí)到代碼中就是:
private class ViewFlinger implements Runnable {
private int mLastFlingY = 0;
private OverScroller mScroller;
private boolean mEatRunOnAnimationRequest = false;
private boolean mReSchedulePostAnimationCallback = false;
public ViewFlinger() {
mScroller = new OverScroller(getContext(), sQuinticInterpolator);
}
@Override
public void run() {
disableRunOnAnimationRequests();
final OverScroller scroller = mScroller;
if (scroller.computeScrollOffset()) {
final int y = scroller.getCurrY();
int dy = y - mLastFlingY;
mLastFlingY = y;
scrollBy(0, dy);
postOnAnimation();
}
enableRunOnAnimationRequests();
}
public void fling(int velocityY) {
mLastFlingY = 0;
setScrollState(SCROLL_STATE_SETTLING);
mScroller.fling(0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
postOnAnimation();
}
public void stop() {
removeCallbacks(this);
mScroller.abortAnimation();
}
private void disableRunOnAnimationRequests() {
mReSchedulePostAnimationCallback = false;
mEatRunOnAnimationRequest = true;
}
private void enableRunOnAnimationRequests() {
mEatRunOnAnimationRequest = false;
if (mReSchedulePostAnimationCallback) {
postOnAnimation();
}
}
void postOnAnimation() {
if (mEatRunOnAnimationRequest) {
mReSchedulePostAnimationCallback = true;
} else {
removeCallbacks(this);
ViewCompat.postOnAnimation(CustomScrollView.this, this);
}
}
}
這里用的是RecyclerView中的慣性滑動的代碼,剔除了一些不需要的部分。
從public void fling(int velocityY)
方法可以看到,它是從(0,0)坐標(biāo)開始的菠红。表示扔傅,不管當(dāng)前的View移動到哪兒了,這里的Scroller計算的是铝量,根據(jù)參數(shù)傳遞的速度,如果從(0,0)處開始,應(yīng)該移動到哪兒了。
還有個ViewCompat.postOnAnimation(view, runable);
這句等同于view.postDelayed(runable, 10);
淋肾。
4.3.3 插值器(Interpolator)
在初始化OverScroller的時候简识,用到了一個sQuinticInterpolator
膳灶。具體的定義如下:
//f(x) = (x-1)^5 + 1
private static final Interpolator sQuinticInterpolator = new Interpolator() {
@Override
public float getInterpolation(float t) {
t -= 1.0f;
return t * t * t * t * t + 1.0f;
}
};
這里用的是一個自定義的插值器。上面提過一下荷逞,RecyclerView和ScrollView在慣性滑動上的一部分區(qū)別筏养,RecyclerView用的是這個自定義的插值器宪郊,ScrollView用的是默認(rèn)的Scroller.ViscousFluidInterpolator
店枣。
插值器的直觀的效果之一就是RecyclerView的慣性滑動。也就是剛開始很快,之后慢慢變慢直到停止的效果弱匪。
差值器的主要方法getInterpolation(float t)
哑诊。參數(shù)t為滑行時間的百分比,從0到1丢氢。返回值為滑行距離的百分比比驻,可以小于0论皆,可以大于1。
用RecyclerView的插值器來舉例闰挡。如果說根據(jù)手指抬起時的速度之拨,最終需要5秒滑動1000像素。根據(jù)上面的sQuinticInterpolator
插值器,在滑行了2秒的時候蛙粘,t的值為2/5=0.4
豹缀。getInterpolation(0.4)=0.92
表示已經(jīng)滑動了0.92*1000=920
個像素了叮雳。更直觀的可以通過插值器在[0,1]上的曲線來表達(dá):
橫坐標(biāo)表示時間棺弊,縱坐標(biāo)表示已經(jīng)完成了總路程的百分比僧凤。如圖所示验懊,RecyclerView的插值器形成的效果就是在很短時間內(nèi)首先完成了大部分的路程。正式我們看到的前期很快,后來很慢的效果蹂析。
也附上Scroller.ViscousFluidInterpolator
在[0俺祠,1]上的曲線圖:
感覺沒什么太大的區(qū)別脑漫。
4.4 邊緣處理
滑動到上下兩邊的時候還是能滑動告希,不妥榴鼎,需要進(jìn)行約束。直接貼一下代碼就好:
private void constrainScrollBy(int dx, int dy) {
Rect viewport = new Rect();
getGlobalVisibleRect(viewport);
int height = viewport.height();
int width = viewport.width();
int scrollX = getScrollX();
int scrollY = getScrollY();
//右邊界
if (mWidth - scrollX - dx < width) {
dx = mWidth - scrollX - width;
}
//左邊界
if (-scrollX - dx > 0) {
dx = -scrollX;
}
//下邊界
if (mHeight - scrollY - dy < height) {
dy = mHeight - scrollY - height;
}
//上邊界
if (scrollY + dy < 0) {
dy = -scrollY;
}
scrollBy(dx, dy);
}
將代碼中的scrollBy都改成添加約束的constrainScrollBy()即可缺猛。
五曙搬、 給出自定義View的源碼
package com.rajesh.scrolldemo;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Rect;
import android.support.v4.view.MotionEventCompat;
import android.support.v4.view.VelocityTrackerCompat;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.animation.Interpolator;
import android.widget.OverScroller;
import android.widget.TextView;
/**
* Created by zhufeng on 2017/7/26.
*/
public class CustomScrollView extends ViewGroup {
private Context mContext;
private int SCREEN_WIDTH = 0;
private int SCREEN_HEIGHT = 0;
private int mWidth = 0;
private int mHeight = 0;
private static final int INVALID_POINTER = -1;
public static final int SCROLL_STATE_IDLE = 0;
public static final int SCROLL_STATE_DRAGGING = 1;
public static final int SCROLL_STATE_SETTLING = 2;
private int mScrollState = SCROLL_STATE_IDLE;
private int mScrollPointerId = INVALID_POINTER;
private VelocityTracker mVelocityTracker;
private int mLastTouchY;
private int mTouchSlop;
private int mMinFlingVelocity;
private int mMaxFlingVelocity;
private final ViewFlinger mViewFlinger = new ViewFlinger();
//f(x) = (x-1)^5 + 1
private static final Interpolator sQuinticInterpolator = new Interpolator() {
@Override
public float getInterpolation(float t) {
t -= 1.0f;
return t * t * t * t * t + 1.0f;
}
};
public CustomScrollView(Context context) {
this(context, null);
}
public CustomScrollView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CustomScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int top = 0;
for (int i = 0; i < 20; i++) {
int width = SCREEN_WIDTH;
int height = SCREEN_HEIGHT / 2;
int left = 0;
int right = left + width;
int bottom = top + height;
//撐大邊界
if (bottom > mHeight) {
mHeight = bottom;
}
if (right > mWidth) {
mWidth = right;
}
TextView textView = new TextView(mContext);
if (i % 2 == 0) {
textView.setBackgroundColor(Color.CYAN);
} else {
textView.setBackgroundColor(Color.GREEN);
}
textView.setText("item:" + i);
addView(textView);
textView.layout(left, top, right, bottom);
top += height;
top += 20;
}
}
private void init(Context context) {
this.mContext = context;
final ViewConfiguration vc = ViewConfiguration.get(context);
mTouchSlop = vc.getScaledTouchSlop();
mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
DisplayMetrics metric = context.getResources().getDisplayMetrics();
SCREEN_WIDTH = metric.widthPixels;
SCREEN_HEIGHT = metric.heightPixels;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
boolean eventAddedToVelocityTracker = false;
final int action = MotionEventCompat.getActionMasked(event);
final int actionIndex = MotionEventCompat.getActionIndex(event);
final MotionEvent vtev = MotionEvent.obtain(event);
switch (action) {
case MotionEvent.ACTION_DOWN: {
setScrollState(SCROLL_STATE_IDLE);
mScrollPointerId = event.getPointerId(0);
mLastTouchY = (int) (event.getY() + 0.5f);
break;
}
case MotionEventCompat.ACTION_POINTER_DOWN: {
mScrollPointerId = event.getPointerId(actionIndex);
mLastTouchY = (int) (event.getY(actionIndex) + 0.5f);
break;
}
case MotionEvent.ACTION_MOVE: {
final int index = event.findPointerIndex(mScrollPointerId);
if (index < 0) {
Log.e("zhufeng", "Error processing scroll; pointer index for id " + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
return false;
}
final int y = (int) (event.getY(index) + 0.5f);
int dy = mLastTouchY - y;
if (mScrollState != SCROLL_STATE_DRAGGING) {
boolean startScroll = false;
if (Math.abs(dy) > mTouchSlop) {
if (dy > 0) {
dy -= mTouchSlop;
} else {
dy += mTouchSlop;
}
startScroll = true;
}
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}
if (mScrollState == SCROLL_STATE_DRAGGING) {
mLastTouchY = y;
constrainScrollBy(0, dy);
}
break;
}
case MotionEventCompat.ACTION_POINTER_UP: {
if (event.getPointerId(actionIndex) == mScrollPointerId) {
// Pick a new pointer to pick up the slack.
final int newIndex = actionIndex == 0 ? 1 : 0;
mScrollPointerId = event.getPointerId(newIndex);
mLastTouchY = (int) (event.getY(newIndex) + 0.5f);
}
break;
}
case MotionEvent.ACTION_UP: {
mVelocityTracker.addMovement(vtev);
eventAddedToVelocityTracker = true;
mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
float yVelocity = -VelocityTrackerCompat.getYVelocity(mVelocityTracker, mScrollPointerId);
Log.i("zhufeng", "速度取值:" + yVelocity);
if (Math.abs(yVelocity) < mMinFlingVelocity) {
yVelocity = 0F;
} else {
yVelocity = Math.max(-mMaxFlingVelocity, Math.min(yVelocity, mMaxFlingVelocity));
}
if (yVelocity != 0) {
mViewFlinger.fling((int) yVelocity);
} else {
setScrollState(SCROLL_STATE_IDLE);
}
resetTouch();
break;
}
case MotionEvent.ACTION_CANCEL: {
resetTouch();
break;
}
}
if (!eventAddedToVelocityTracker) {
mVelocityTracker.addMovement(vtev);
}
vtev.recycle();
return true;
}
private void resetTouch() {
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
}
private void setScrollState(int state) {
if (state == mScrollState) {
return;
}
mScrollState = state;
if (state != SCROLL_STATE_SETTLING) {
mViewFlinger.stop();
}
}
private class ViewFlinger implements Runnable {
private int mLastFlingY = 0;
private OverScroller mScroller;
private boolean mEatRunOnAnimationRequest = false;
private boolean mReSchedulePostAnimationCallback = false;
public ViewFlinger() {
mScroller = new OverScroller(getContext(), sQuinticInterpolator);
}
@Override
public void run() {
disableRunOnAnimationRequests();
final OverScroller scroller = mScroller;
if (scroller.computeScrollOffset()) {
final int y = scroller.getCurrY();
int dy = y - mLastFlingY;
mLastFlingY = y;
constrainScrollBy(0, dy);
postOnAnimation();
}
enableRunOnAnimationRequests();
}
public void fling(int velocityY) {
mLastFlingY = 0;
setScrollState(SCROLL_STATE_SETTLING);
mScroller.fling(0, 0, 0, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
postOnAnimation();
}
public void stop() {
removeCallbacks(this);
mScroller.abortAnimation();
}
private void disableRunOnAnimationRequests() {
mReSchedulePostAnimationCallback = false;
mEatRunOnAnimationRequest = true;
}
private void enableRunOnAnimationRequests() {
mEatRunOnAnimationRequest = false;
if (mReSchedulePostAnimationCallback) {
postOnAnimation();
}
}
void postOnAnimation() {
if (mEatRunOnAnimationRequest) {
mReSchedulePostAnimationCallback = true;
} else {
removeCallbacks(this);
ViewCompat.postOnAnimation(CustomScrollView.this, this);
}
}
}
private void constrainScrollBy(int dx, int dy) {
Rect viewport = new Rect();
getGlobalVisibleRect(viewport);
int height = viewport.height();
int width = viewport.width();
int scrollX = getScrollX();
int scrollY = getScrollY();
//右邊界
if (mWidth - scrollX - dx < width) {
dx = mWidth - scrollX - width;
}
//左邊界
if (-scrollX - dx > 0) {
dx = -scrollX;
}
//下邊界
if (mHeight - scrollY - dy < height) {
dy = mHeight - scrollY - height;
}
//上邊界
if (scrollY + dy < 0) {
dy = -scrollY;
}
scrollBy(dx, dy);
}
}