
聲明:本文內(nèi)容根據(jù)《Android開發(fā)藝術(shù)探索》的思路,基于 API 26 進(jìn)行總結(jié)
一、Android View基礎(chǔ)知識(shí)
背景:
- 常用的系統(tǒng)控件很多時(shí)候不能滿足需求,因此需要根據(jù)具體需求自定義新的控件。
- 一般都是通過繼承某View來重寫核心方法,重新設(shè)置屬性來完成控件的自定義。
- Android手機(jī)屬于移動(dòng)設(shè)備,特點(diǎn)是通過屏幕進(jìn)行一系列操作,比如滑動(dòng)切換。由于不同層級(jí)的Veiw都可以響應(yīng)滑動(dòng),所以就帶來了滑動(dòng)沖突的問題,詳細(xì)了解View的事件分發(fā)機(jī)制就可以根據(jù)其特性來解決這個(gè)問題。
定義:
- View是 Android 中所有控件的基類,常用的各種控件包括RelativeLayout等都是View的子類。
- ViewGroup 可翻譯為控件組,也繼承了View。特點(diǎn)是包含一個(gè)或多個(gè)View、ViewGroup的子View同樣可以是ViewGroup。
1.1 View 位置參數(shù):
1.1.1 View 基本參數(shù)
- View的位置由它的四個(gè)頂點(diǎn)來決定:top、left、right、bottom。
-
top:上邊距離父容器(ViewGroup)距離。
public final int getTop(){ return mTop; }
left:左邊距離父容器距離。
public final int getLeft(){ return mLeft; }
right:右邊距離父容器距離。
public final int getRight(){ return mRight; }
bottom:下邊距離父容器距離。
public final int getBottom(){ return mBottom; }
View的寬度:width = right - left;
View的高度:height = bottom - top。
- Android3.0開始增加額外參數(shù):
x 和 y :View左上角坐標(biāo);
translationX 和 translationY:View左上角相對(duì)于父容器的偏移量(默認(rèn)為 0)。
同樣提供 get/set 方法,注意平移過程中 top 和 left 并不會(huì)改變,發(fā)生變化的是 x、y、translationX、translationY。
x = left + translationX
y = right + translationY

1.1.2 MotionEvent 和 TouchSlop
- MotionEvent (移動(dòng)事件)
- ACTION_DOWN: 手指放下,接觸屏幕
- ACTION_MOVE: 手指在屏幕移動(dòng)
- ACTION_UP: 手指離開屏幕的瞬間
- 點(diǎn)擊后離開會(huì)經(jīng)歷:ACTION_DOWN --> ACTION_UP
- 點(diǎn)擊后滑動(dòng)再離開:ACTION_DOWN --> ACTION_MOVE --> ACTION_MOVE ... --> ACTION_UP
通過 MotionEvent 對(duì)象可以得到點(diǎn)擊事件發(fā)生的坐標(biāo) x 和 y 。
getX/getY: 指相對(duì)于當(dāng)前 View 左上角的 x/y 坐標(biāo)。
getRawX/getRawY: 相對(duì)于屏幕左上角的 x/y 坐標(biāo)。
-
TouchSlop (最小滑動(dòng))
TouchSlop 定義系統(tǒng)能夠識(shí)別的最小滑動(dòng)距離。
是一個(gè)常量,如果手指滑動(dòng)小于這個(gè)距離,系統(tǒng)則不認(rèn)為是在滑動(dòng)。不同設(shè)備上可能值不相同。
獲取ViewConfiguration.get(getContext()).getScaledTouchSlop()
源碼目錄:frameworks/base/core/res/res/values/config.xml
1.1.3 VelocityTracker 、GestureDetector 和 Scroller
-
VelocityTracker (速度追蹤)
速度追蹤,用于追蹤手指在滑動(dòng)中的速度,包括水平和垂直的速度。步驟如下:
(1) 在 View 的 onTouchEvent 方法中追蹤當(dāng)前點(diǎn)擊事件的速度:
VelocityTracker velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);
(2) 獲取當(dāng)前事件的速度后,獲取在一定時(shí)間內(nèi),手指劃過的像素
velocityTracker.computeCurrentVelocity(1000);
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();
獲取速度前必須先計(jì)算速度:先調(diào)用 computeCurrentVelocity() 方法。
一定時(shí)間劃過的像素?cái)?shù):上面參數(shù) 1000 ,通過 getXVelocity 獲得的就是1000 ms 內(nèi)手指劃過的 x 像素值。
速度 = (終點(diǎn)位置 - 起點(diǎn)位置)/ 時(shí)間段
(3) 釋放并回收內(nèi)存
velocityTracker.clear();
velocityTracker.recycle();
-
GestureDetector (手勢(shì)檢測(cè))
用于檢測(cè)用戶單擊、滑動(dòng)、長(zhǎng)按、雙擊等。使用過程:
(1) 創(chuàng)建 GestureDetector 對(duì)象并實(shí)現(xiàn) OnGestureListener 接口,實(shí)現(xiàn) OnDoubleTapListener 監(jiān)聽雙擊行為:
GestureDetector mGestureDetector = new GestureDetector(this);
// 解決長(zhǎng)按屏幕后無法拖動(dòng)
mGestureDetector.setIsLongpressEnabled(false);
(2) 在需要監(jiān)聽 View 的 onTouchEvent 方法中添加:
boolean consume = mGestureDetector.onTouchEvent(event);
return super.onTouchEvent(event);
(3) 根據(jù)需要有選擇地實(shí)現(xiàn) OnGestureListener 和 OnDoubleTapListener 中的方法。
| 方法名 | 描述 | 所述接口 |
|---|---|---|
| onDown(MotionEvent e) | 手指觸摸屏幕瞬間,由一個(gè)ACTION_DOWN觸發(fā) | OnGestureListener |
| onShowPress(MotionEvent e) | 手指輕觸屏幕,尚未松開或拖動(dòng) | OnGestureListener |
| onSingleTapUp(MotionEvent e) | 手指輕觸屏幕后松開,伴隨一個(gè)ACTION_UP觸發(fā),單擊行為 | OnGestureListener |
| onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) | 手指按下屏幕并移動(dòng),一個(gè) ACTION_DOWN,多個(gè)ACTION_MOVE 觸發(fā),是拖動(dòng)行為 | OnGestureListener |
| onLongPress(MotionEvent e) | 長(zhǎng)按行為 | OnGestureListener |
| onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) | 用戶觸摸屏幕、快速滑動(dòng)后松開,由一個(gè) ACTION_DOWN、多個(gè) ACTION_MOVE 和一個(gè) ACTION_UP 觸發(fā)。 | OnGestureListener |
| onDoubleTap(MotionEvent e) | 雙擊,由兩次連續(xù)的單擊組成,不可能和 onSingleTapConfirmed 共存 | OnDoubleTapListener |
| onSingleTapConfirmed(MotionEvent e) | 嚴(yán)格的單擊行為,如果在一定時(shí)間內(nèi)再次點(diǎn)擊,則不會(huì)觸發(fā)此方法 | OnDoubleTapListener |
| onDoubleTapEvent(MotionEvent e) | 表示發(fā)生了雙擊行為,在此期間, ACTION_DOWN、ACTION_MOVE 和 ACTION_UP 都會(huì)觸發(fā)此回調(diào) | OnDoubleTapListener |
在實(shí)際開發(fā)中,可以不使用 GestureDetector ,完全可以在 View 的 onTouchEvent 方法中實(shí)現(xiàn)所需監(jiān)聽。如果只是監(jiān)聽滑動(dòng)相關(guān)的,可在 onTouchEvent 實(shí)現(xiàn),如果監(jiān)聽雙擊的話,用 GestureDetector。
-
Scroller(彈性滑動(dòng)對(duì)象)
用于實(shí)現(xiàn) View 的彈性滑動(dòng)。使用 View 的 scrollTo/scrollBy 方法來滑動(dòng)時(shí),過程是瞬間完成的,使用 Scroller 和 View 的 computeScroll 方法配合來完成彈性滑動(dòng)。
Scroller scroller = new Scroller(getContext());
private void smoothScrollTo(int destX, int destY){
int scrollX = getScrollX();
int delta = destX - scrollX;
// 1000ms內(nèi)慢慢滑動(dòng)至 destX
scroller.startScroll(scrollX, 0, delta, 0, 1000);
invalidate();
}
@Override
public void computeScroll() {
if(scroller.computeScrollOffset()){
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
二、View 滑動(dòng)
常見的三種方式實(shí)現(xiàn) View 的滑動(dòng):
- 通過 View 本身提供的 scrollTo/scrollBy 方法。
- 通過動(dòng)畫給 View 施加平移效果。
- 改變 View 的 LayoutParams 使得 View 重新布局。
2.1 使用 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);
}
注意:
- scrollTo 和 scrollBy 只能改變 View 內(nèi)容的位置,而不能改變 View 在布局中的位置
-
mScrollX 表示 View 左邊緣和 View 內(nèi)容左邊緣水平的距離,左邊為正,右邊為負(fù),單位為像素
mScrollY 表示 View 上邊緣和 View 內(nèi)容上邊緣垂直的距離,上邊為正,下邊為負(fù),單位像素 - 也就是說,假設(shè) View 的內(nèi)容向左滑動(dòng)100px, mScrollX 為 100px。View 的內(nèi)容向下滑動(dòng) 50px ,mScrollY 為 -50px。
2.2 使用動(dòng)畫
通過操作 View 的 translationX 和 translationY 屬性,可以使用 View 動(dòng)畫(包括幀動(dòng)畫(Frame Animation)和補(bǔ)間動(dòng)畫(Tweened Animation))或?qū)傩詣?dòng)畫(3.0以下需要兼容動(dòng)畫庫 nineoldandroids)。
View 動(dòng)畫向 100ms 右下角平移 100 像素。
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="true"
android:zAdjustment="normal">
<!-- android:fillAfter="true":移動(dòng)完畢后保存狀態(tài) -->
<!-- duration:時(shí)常 -->
<!-- interpolator:動(dòng)畫效果:linear_interpolator 表示常量速率變化 -->
<translate
android:duration = "100"
android:fromXDelta="0"
android:fromYDelta="0"
android:interpolator="@android:anim/linear_interpolator"
android:toXDelta="100"
android:toYDelta="100"/>
</set>
屬性動(dòng)畫 100ms 向右平移 100 像素。
ObjectAnimator.ofFloat(new MyView(this),"translationX", 0, 100).setDuration(100).start();
設(shè)置 android:fillAfter 屬性為false,View 移動(dòng)后會(huì)瞬間回去,true 會(huì)保存移動(dòng)狀態(tài)。
使用 View 動(dòng)畫不會(huì)真正地改變 View 的位置參數(shù),包括寬/高。所以 View 使用 View動(dòng)畫,其內(nèi)的控件如 Button 位置不會(huì)改變,可事先在目標(biāo)位置設(shè)置 Button,待移動(dòng)完成隱藏原來 Button。
Android 3.0以上使用屬性動(dòng)畫則沒有這個(gè)問題。
2.3 改變布局參數(shù)
通過改變某 View 的 LayoutParams。
比如想使一個(gè) Button 向右平移 100px,只需要設(shè)置其 LayoutParams 的 marginLeft 參數(shù)增加 100px即可。
還可在 Button 左邊放置一個(gè)空 View,Button 需要移動(dòng)時(shí)設(shè)置空 View 的寬度,在 LinearLayout 的水平方向布局里 Button 就會(huì)被擠壓到右邊一定的寬度。
ViewGroup.MarginLayoutParams marginLayoutParams = (ViewGroup.MarginLayoutParams) mButton.getLayoutParams();
// 寬度增加100px
marginLayoutParams.width += 100;
// 左間距增加100px
marginLayoutParams.leftMargin += 100;
// mButton應(yīng)用修改
mButton.requestLayout();
//或者 mButton.setLayoutParams(marginLayoutParams);
三種移動(dòng)動(dòng)畫特點(diǎn):
- scrollTo/scrollBy: 操作簡(jiǎn)單,適合 View 內(nèi)容的滑動(dòng);
- 動(dòng)畫:操作簡(jiǎn)單,用于沒有交互或復(fù)雜動(dòng)畫效果的實(shí)現(xiàn);
- 改變布局參數(shù):操作稍微復(fù)雜,適用于有交互的 View。
小Demo:自定義 View 實(shí)現(xiàn)跟隨手指在屏幕上移動(dòng)
public ScreenMoveView(Context context) {
super(context);
}
// 必須實(shí)現(xiàn)這個(gè)構(gòu)造函數(shù)
public ScreenMoveView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getRawX();
int y = (int) event.getRawY();
switch (event.getAction()){
// 手指落下
case MotionEvent.ACTION_DOWN:
break;
// 手指移動(dòng)
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
// ViewHelper 是 nineoldandroids 提供的動(dòng)畫兼容庫,可在github下載
int translationX = (int) (ViewHelper.getTranslationX(this) + deltaX);
int translationY = (int) (ViewHelper.getTranslationY(this) + deltaY);
ViewHelper.setTranslationX(this,translationX);
ViewHelper.setTranslationY(this,translationY);
break;
// 手指抬起
case MotionEvent.ACTION_UP:
break;
default:
break;
}
mLastX = x;
mLastY = y;
// true攔截父類傳遞
return true;
}
三、彈性滑動(dòng)
3.1 使用 Scroller 實(shí)現(xiàn)彈性滑動(dòng)
// step1:實(shí)例化 Scroller 對(duì)象
Scroller mScroller = new Scroller(mContext);
private void smoothScrollTo(int destX, int destY){
// getScrollX獲取View在屏幕上從初始點(diǎn)偏移的值
int scrollX = getScrollX();
int deltaX = destX - scrollX;
// step2:開始滑動(dòng) 1000ms 平滑滑向destX
mScroller.startScroll(scrollX,0,deltaX,0,1000);
invalidate(); // --> 重繪 View 調(diào)用 draw 方法
}
// step3:draw方法調(diào)用該方法
@Override
public void computeScroll() {
// step4:判斷是否滑動(dòng)完畢
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
startScroll() 函數(shù)源碼:
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;
}
該函數(shù)僅僅保存了傳遞的幾個(gè)參數(shù),startX 和 startY 表示滑動(dòng)的起點(diǎn),dx 和 dy 表示的是要滑動(dòng)的距離,duration 表示滑動(dòng)的時(shí)間。這里的滑動(dòng)是 View 內(nèi)容的滑動(dòng)而并非 View 本身位置的改變。
僅僅調(diào)用 startScroll 是無法讓 View 進(jìn)行滑動(dòng)的,實(shí)際讓 View 實(shí)現(xiàn)彈性滑動(dòng)的是 invalidate(),該方法會(huì)導(dǎo)致 View 重繪,在 View 的 draw 方法又會(huì)調(diào)用 computeScroll 方法。computeScroll 方法是 View 的一個(gè)空實(shí)現(xiàn),需要自己去實(shí)現(xiàn)。
原理:View 重繪 --> draw 方法調(diào)用 computeScroll --> computeScroll 向Scroller 獲取當(dāng)前的 scrollX 和 scrollY --> 通過 scrollTo 方法實(shí)現(xiàn)滑動(dòng) --> 調(diào)用 postInvalidate 方法二次重繪 --> 依然調(diào)用 computeScroll 方法 --> 繼續(xù)獲取 scrollX 和 scrollY 并通過 scrollTo 方法滑動(dòng)到新位置直到結(jié)束。
step4: mScroller.computeScrollOffset() 源碼:
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
// 滑動(dòng)動(dòng)畫過去的時(shí)間
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
// 滑動(dòng)的時(shí)間小于設(shè)定的總滑動(dòng)時(shí)間
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
// 根據(jù)時(shí)間的流逝的百分比來算出 scrollX 和 scrollY 改變的百分比
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
// 再根據(jù)百分比來計(jì)算出當(dāng)前的值
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
...
return true;
}
最后返回 true 表明動(dòng)畫還沒結(jié)束需要繼續(xù)滑動(dòng),false 則表明滑動(dòng)完成。
Scroller 滑動(dòng)原理:Scroller 配合 View 的 computeScroll 方法完成彈性滑動(dòng),該方法不斷讓 View 重繪,每次重繪根據(jù)時(shí)間間隔來計(jì)算出 View 當(dāng)前滑動(dòng)的位置并使用 scrollTo 方法完成 View 的滑動(dòng)。
3.2 通過動(dòng)畫
final int startX = 0;
final int deltaX = 100;
ValueAnimator animator = ValueAnimator.ofInt(0,1).setDuration(1000);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float fraction = animation.getAnimatedFraction();
mButton.scrollTo(startX + (int)(deltaX * fraction),0);
}
});
animator.start();
上述代碼僅僅是完成一個(gè) 1000ms 的動(dòng)畫,同時(shí)設(shè)定動(dòng)畫刷新監(jiān)聽,再按照比例通過 scrollTo 方法來完成某個(gè) View 的動(dòng)畫。由于 scrollTo 針對(duì)的是 View 的內(nèi)容而非本身,所以這里只能變動(dòng) View 內(nèi)容并非本身。
3.3 使用延時(shí)策略
// msg.what
private static final int MESSAGE_SCROLL_TO = 1;
// 總共更新次數(shù)
private static final int FRAME_COUNT = 30;
// 每一次移動(dòng)間隔
private static final int DELAYED_TIME = 33;
// 記錄移動(dòng)數(shù)量,要小于總數(shù)
private int mCount = 0;
private Handler mHandler = new Handler(){
@Override
public void handleMessage(Message msg) {
switch (msg.what){
case MESSAGE_SCROLL_TO:
mCount++;
if(mCount <= FRAME_COUNT){
// 計(jì)算當(dāng)前移動(dòng)比例
float fraction = mCount/(float)FRAME_COUNT;
int scrollx = (int) (fraction * 100);
mButton.scrollTo(scrollx,0);
// 再次發(fā)送消息移動(dòng)
mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME);
}
break;
}
}
};
使用 Handler 或 View 的 postDelayed 方法來循環(huán)發(fā)送動(dòng)畫消息,來完成 View 的緩慢移動(dòng)效果。