自定義View——彈性滑動
滑動是Android開發中非常重要的UI效果,幾乎所有應用都包含了滑動效果,而本文將對滑動的使用以及原理進行介紹。
一、scrollTo與ScrollBy
View提供了專門的方法用于實現滑動效果,分別為scrollTo與scrollBy。先來看看它們的源碼:
/**
* Set the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
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();
}
}
}
/**
* Move the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the amount of pixels to scroll by horizontally
* @param y the amount of pixels to scroll by vertically
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
從源碼中可以看出scrollBy實際上是調用了scrollTo函數來實現它的功能。scrollBy實現的是輸入參數的相對滑動,而scrollTo是絕對滑動。需要說明的是mScrollX、mScrollY這兩個View的屬性,這兩個屬性可以通過getScrollX、getScrollY獲得。
- mScrollX : View的左邊緣與View內容的左邊緣在水平方向上的距離,即從右向左滑動時,為正值,反之為負值。
- mScrollY : View的上邊緣與View內容的上邊緣在豎直方向上的距離,即從下向上滑動時,為正值,反之為負值。
- 下面我們來實現一個滑動的效果:
public class HorizontalScroller extends ViewGroup {
private int mTouchSlop;
private float mLastXIntercept=0;
private float mLastYIntercept=0;
private float mLastX=0;
private float mLastY=0;
private int leftBorder;
private int rightBorder;
public HorizontalScroller(Context context) {
super(context);
init(context);
}
public HorizontalScroller(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public HorizontalScroller(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context){
ViewConfiguration configuration = ViewConfiguration.get(context);
// 獲取TouchSlop值
mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
float xIntercept = ev.getX();
float yIntercept = ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
float deltaX = xIntercept-mLastXIntercept;
float deltaY = yIntercept-mLastYIntercept;
// 當水平方向的滑動距離大于豎直方向的滑動距離,且手指拖動值大于TouchSlop值時,攔截事件
if (Math.abs(deltaX)>Math.abs(deltaY) && Math.abs(deltaX)>mTouchSlop) {
intercept=true;
}else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
default:
break;
}
mLastX = xIntercept;
mLastY = yIntercept;
mLastXIntercept = xIntercept;
mLastYIntercept = yIntercept;
return intercept;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
float xTouch = event.getX();
float yTouch = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
float deltaX = xTouch-mLastX;
float deltaY = yTouch-mLastY;
float scrollByStart = deltaX;
if (getScrollX() - deltaX < leftBorder) {
scrollByStart = getScrollX()-leftBorder;
} else if (getScrollX() + getWidth() - deltaX > rightBorder) {
scrollByStart = rightBorder-getWidth()-getScrollX();
}
scrollBy((int) -scrollByStart, 0);
break;
case MotionEvent.ACTION_UP:
// 當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面
int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
int dx = targetIndex * getWidth() - getScrollX();
scrollTo(getScrollX()+dx,0);
break;
default:
break;
}
mLastX = xTouch;
mLastY = yTouch;
return super.onTouchEvent(event);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
// 測量每一個子控件的大小
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (changed) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
// 在水平方向上對子控件進行布局
childView.layout(i * getMeasuredWidth(), 0, i * getMeasuredWidth()+childView.getMeasuredWidth()+getPaddingLeft(), childView.getMeasuredHeight());
}
// 初始化左右邊界值
leftBorder = 0;
rightBorder = getChildCount()*getMeasuredWidth();
}
}
}
現在我們來分析下這段代碼:
- 首先在構造函數中獲取了最小滑動距離TouchSlop。
- 重寫onInterceptTouchEvent攔截事件,記錄當前坐標。點下時,默認不攔截,只有當滑動還未完成的情況下,才繼續攔截。在移動時,對滑動沖突進行了處理,當水平方向的移動距離大于豎直方向的移動距離,并且移動距離大于最小滑動距離時,我們判斷此時為水平滑動,攔截事件自己處理;否則不攔截,交由子View處理。提起手指時,同樣不攔截事件。
- 重寫onTouchEvent處理事件,記錄當前坐標。在手指按下時,與攔截事件時做相似處理。在ACTION_MOVE時,向左滑動,如果滑動距離超過左邊界,則對滑動距離進行處理,相對的滑動距離超出又邊界,也是一樣處理,之后把滑動的距離交給scrollBy進行處理。當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面,然后使用scrollTo滑動到那個子控件。
- 重寫了onMeasure和onLayout方法,在onMeasure中測量每一個子控件的大小值,在onLayout中對每一個子view在水平方向上進行布局。子view的layout的right增加父類的paddingLeft參數,來處理設置padding的情況。這兩個函數的流程分析將會放在之后的文章中詳細說明。
這個類的使用方法如下 :
<?xml version="1.0" encoding="utf-8"?>
<com.idtk.customscroll.HorizontalScroller
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"
android:padding="10dp"
tools:context="com.idtk.customscroll.MainActivity">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@drawable/zhiqinchun"
android:clickable="true"/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/hanzhan"
android:clickable="true"/>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@drawable/shengui"
android:clickable="true"/>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:src="@drawable/dayu"
android:clickable="true"/>
</com.idtk.customscroll.HorizontalScroller>
HorizontalScroller設置全屏,padding為10dp。使用4個ImageView作為子View,并且都設置為可點擊狀態。示例效果圖如下:
二、Scroller
可以看到上面使用scrollTo與ScrollBy方法的滑動都是瞬時完成的,這有些無法滿足我們在切換子view時的需求。我們希望切換子View時,可以擁有滑動過程的效果,而Scroller正好可以完成這一點。
Scroller的使用方法:
* 1、創建Scroller實例
* 2、使用startScroll方法,對其進行初始化
* 3、重寫computeScroll()方法,在其內部調用scrollTo或ScrollBy方法,完成滑動過程。
//創建實例
mScroller = new Scroller(context);
public void smoothScrollTo(){
int ScrollX = getScrollX();
int ScrollY = getScrollY();
//初始化,1000ms內緩慢滑動到deltaX
mScroller.startScroll(ScrollX, 0, 0, deltaX, 1000);
invalidate();
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
int currX = mScroller.getCurrX();
int currY = mScroller.getCurrY();
scrollTo(currX, currY);
postInvalidate();
}
}
上面的代碼是Scroller的典型用法,也就是傳說中的套路。當時Scroller使用startScroll方法時,只是對一系列參數進行了初始化。我們從下面的源碼中可以看出。
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
mDurationReciprocal = 1.0f / (float) mDuration;
}
參數中,startX、startY是滑動的起點,dx、dy是滑動的距離,duration是滑動的時間系統設置為250ms。我們可以看到startScroll只是進行了滑動時間、是否滑動完成、起點、終點、滑動距離等的參數的設置,那么是如何調用computeScroll()函數的呢?其實computeScroll()的調用是由之后的invalidate()函數來完成的,invalidate可以請求View重繪,在View重繪時會調用draw方法,draw方法又會去調用computeScroll函數。但computeScroll()函數在view中是一個空的函數,需要我們去實現它。
computeScroll()函數的實現已經在上面給出了,有了computeScroll方法之后,就可以實現View的彈性滑動了。來看下computeScroll()的實現過程,首先要進行computeScrollOffset()的判斷,來看下它的源碼 :
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
case FLING_MODE:
final float t = (float) timePassed / mDuration;
final int index = (int) (NB_SAMPLES * t);
float distanceCoef = 1.f;
float velocityCoef = 0.f;
if (index < NB_SAMPLES) {
final float t_inf = (float) index / NB_SAMPLES;
final float t_sup = (float) (index + 1) / NB_SAMPLES;
final float d_inf = SPLINE_POSITION[index];
final float d_sup = SPLINE_POSITION[index + 1];
velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
distanceCoef = d_inf + (t - t_inf) * velocityCoef;
}
mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
// Pin to mMinX <= mCurrX <= mMaxX
mCurrX = Math.min(mCurrX, mMaxX);
mCurrX = Math.max(mCurrX, mMinX);
mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
// Pin to mMinY <= mCurrY <= mMaxY
mCurrY = Math.min(mCurrY, mMaxY);
mCurrY = Math.max(mCurrY, mMinY);
if (mCurrX == mFinalX && mCurrY == mFinalY) {
mFinished = true;
}
break;
}
}
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
computeScrollOffset()首先檢測scroller是否完成滑動,完成則返回false,未完成則繼續AnimationUtils.currentAnimationTimeMillis獲取當前的毫秒值,減去之前startScroll方法時獲得毫秒值,就是當前滑動的執行時間。之后判斷執行時間是否小于設置的總時間,如果小于,根據startScroll時設置的模式SCROLL_MODE,然后根據Interpolator計算出當前滑動的mcurrX、mcurrY(順便提一下在實例化scroller的時候,是可以設置動畫插值器。);如果執行時間大于或者等于設置的總時間,則直接設置mcurrX、mcurrY為終點值,并且設置mFinished,表示動畫已經完成。
Scroller彈性滑動的流程如下
現在使用Scroller方法來更改一下上面的代碼,當ACTION_UP時,子View的滑動可以有一個過程,而不是瞬時完成。
private Scroller mScroller;
...
private void init(Context context){
// 第一步,創建Scroller的實例
mScroller = new Scroller(context);
...
}
@Override
public boolean onTouchEvent(MotionEvent event) {
float xTouch = event.getX();
float yTouch = event.getY();
switch (event.getAction()) {
...
case MotionEvent.ACTION_UP:
// 當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面
int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
int dx = targetIndex * getWidth() - getScrollX();
// 第二步,使用startScroll方法,對其進行初始化
mScroller.startScroll(getScrollX(), 0, dx, 0);
invalidate();
break;
default:
break;
}
mLastX = xTouch;
mLastY = yTouch;
return super.onTouchEvent(event);
}
...
@Override
public void computeScroll() {
// 第三步,重寫computeScroll()方法,在其內部調用scrollTo或ScrollBy方法,完成滑動過程
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
}
上面就是代碼中需要增加和修改的部分,我們來簡單分析下。
- 在構造函數中增加對 Scroller進行了實例化 。
- 替換onTouchEvent中手指抬起后的方法,改為 使用startScroll方法,對mScroller進行初始化 ,之后invalidate請求重繪。
- 增加 重寫的computeScroll()方法 ,在其內部調用scrollTo或ScrollBy方法,完成滑動過程,之后使用postInvalidate()請求view重繪。
示例效果圖如下 :
三、回彈效果
從上面的效果圖可以看出,我們已經實現了view的平滑滾動,滑動位置超過當前view的1/2時,松手之后變會自動滑出此item的View。可是如果想要在首位兩端實現回彈效果,該如何做呢?其實只要修改onTouchEvent方法即可。
@Override
public boolean onTouchEvent(MotionEvent event) {
float xTouch = event.getX();
float yTouch = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished())
mScroller.abortAnimation();
break;
case MotionEvent.ACTION_MOVE:
float deltaX = xTouch-mLastX;
float deltaY = yTouch-mLastY;
float scrollByStart = deltaX;
//如果超出邊界,則把滑動距離縮小到1/3
if (getScrollX() - deltaX < leftBorder) {
scrollByStart = deltaX/3;
} else if (getScrollX() + getWidth() - deltaX > rightBorder) {
scrollByStart = deltaX/3;
}
scrollBy((int) -scrollByStart, 0);
break;
case MotionEvent.ACTION_UP:
// 當手指抬起時,根據當前的滾動值來判定應該滾動到哪個子控件的界面
int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
//如果超過右邊界,則回彈到最后一個View
if (targetIndex>getChildCount()-1){
targetIndex = getChildCount()-1;
//如果超過左邊界,則回彈到第一個View
}else if (targetIndex<0){
targetIndex =0;
}
int dx = targetIndex * getWidth() - getScrollX();
// 第二步,使用startScroll方法,對其進行初始化
mScroller.startScroll(getScrollX(), 0, dx, 0);
invalidate();
break;
default:
break;
}
mLastX = xTouch;
mLastY = yTouch;
return super.onTouchEvent(event);
}
來簡單分析下修改的onTouchEvent方法:
在滑動的過程中,如果滑動的位置超過了試圖的左、右邊界,則縮小View的滑動距離,使之為手指滑動距離的1/3。當手指離開時,如果通過view寬度獲得的當前inder小與0,則index為第一個View;如果獲得的當前index超過了子View的數量-1,則index為最后一個View。View的回彈效果如下:
四、小結
本文介紹彈性滑動的實現方法,并對彈性滑動的過程進行了詳細分析。在之后通過例子實現了view的彈性滑動以及回彈效果,但 最后還留有兩個問題,即invalidate與postInvalidate的區別又在哪里呢?invalidate是如何調用computeScroll()函數的呢? ,這些問題我將在下一篇文章中進行詳細的分析。
來自:http://www.idtkm.com/customview/customview8/