前言
上一個周沒有寫博客,是我太懶,無法堅持。在上一個周,除去工作的任務(wù)(迭代版本,修復(fù)BUG)之外,我一直在模仿一個UI效果。我呢,算是一個米粉,我前面的博客,有一些效果就是來自MIUI。在MIUI中,很多的列表都具有彈性和粘性,個人覺得這個效果不錯,于是就模仿了一下。
本來開始之初是為了將這個效果封裝成為一個單獨的UI控件,結(jié)果寫著寫著就發(fā)現(xiàn)這樣是不合理的,于是就放在一旁等待解決方案,先看看實際的效果吧。(前面兩個是我項目中的實際效果)



怎么樣,看上去效果還是挺可以的吧,不得不說,MIUI在一些小細節(jié)上面做得非常不錯,很多效果都值得我們深入的進行學(xué)習(xí)。
注意
- 本博客最主要的是為了向大家展示一種解決思路,文章中的類表效果用到實際項目中還是有些許問題。
- 為了方便起見,本文中使用的動畫效果來自于JakeWharton大神的NineOldAndroids的支持庫,個人非常不建議新手直接就來使用開源庫,最起碼應(yīng)該熟悉一些基礎(chǔ)。
分析
剛開始的時候,我一直在網(wǎng)上找類似的效果,一直是沒有找到。直到我學(xué)習(xí)完屬性動畫之后才發(fā)現(xiàn),其實這個效果實現(xiàn)起來是非常的簡單。
-
整個效果看上去分為上拉和下來,上拉和下拉的時候進行縮放。
1. 下拉: 將View的中心點移到(width/2,0)中進行Scale縮放 2. 上拉 將View的中心點移到(width/2,height)中進行Scale縮放 松手之后會有一個回彈效果,使用ValueAnimator來進行散發(fā)scale值,采用OvershootInterpolator差值器就能達到這樣的效果。
編碼
1. 選擇繼承
自定義View又幾種方式:
- 繼承自View實現(xiàn)效果。
- 繼承原生控件進行拓展。
- 組合控件。
很明顯,效果圖中都是包含了子控件的,可以選擇繼承ViewGroup來實現(xiàn),但是我根本不關(guān)心子控件的一些測量和layout,所以需要繼承已經(jīng)實現(xiàn)的ViewGroup。最后我選定的是使用ScrollView,原因是為了兼容滾動,并且需要監(jiān)聽是否已經(jīng)滾動到了底部。
2.準備工作
- 創(chuàng)建項目
- 引用開源庫 compile 'com.nineoldandroids:library:2.4.0'
- 創(chuàng)建自定義控件類繼承ScrollView,實現(xiàn)三個構(gòu)造方法,并且在xml中引用
3. 自定義屬性
首先思考我們需要哪一些屬性,比方說手指抬起后回彈的速度,回彈的效果方式(其實就是不同的差值器),能夠進行果凍縮放的方式,只能是頂部、底部或者不限制。
在value文件夾中創(chuàng)建attr.xml
<attr name="BouncingDuration" format="integer" />
<attr name="BouncingInterpolator" format="enum">
<enum name="OvershootInterpolator" value="1" />
<enum name="BounceInterpolator" value="2" />
<enum name="LinearInterpolator" value="3" />
<enum name="AccelerateDecelerateInterpolator" value="4" />
</attr>
<attr name="BouncingType" format="enum">
<enum name="none" value="0" />
<enum name="top" value="1" />
<enum name="bottom" value="2" />
<enum name="both" value="3" />
</attr>
<declare-styleable name="BouncingJellyScrollView">
<attr name="BouncingDuration" />
<attr name="BouncingInterpolator" />
<attr name="BouncingType" />
</declare-styleable>
將attr獨立出來的原因是我還有幾個控件需要使用相同的一些屬性。
5. 初始化
在構(gòu)造方法中初始化一些常量值和屬性。
其它的一些工具類方法
public class BouncingType {
public static final int NONE = 0;
public static final int TOP = 1;
public static final int BOTTOM = 2;
public static final int BOTH = 3;
}
public class BouncingInterpolatorType {
public static final int OVERSHOOT_INTERPOLATOR = 1;
public static final int BOUNCE_INTERPOLATOR = 2;
public static final int LINEAR_INTERPOLATOR = 3;
public static final int ACCELERATE_DECELERATE_INTERPOLATOR = 4;
/**
* 獲取彈跳類型
*
* @return
*/
public static TimeInterpolator getTimeInterpolator(int type) {
TimeInterpolator mTimeInterpolator = null;
switch (type) {
case OVERSHOOT_INTERPOLATOR:
mTimeInterpolator = new OvershootInterpolator();
break;
case BOUNCE_INTERPOLATOR:
mTimeInterpolator = new BounceInterpolator();
break;
case LINEAR_INTERPOLATOR:
mTimeInterpolator = new LinearInterpolator();
break;
case ACCELERATE_DECELERATE_INTERPOLATOR:
mTimeInterpolator = new AccelerateDecelerateInterpolator();
break;
}
return mTimeInterpolator;
}
}
初始化屬性
/**
* @param attrs
*/
private void initAttr(AttributeSet attrs) {
if (attrs != null) {
TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.BouncingJellyScrollView);
//差值器
mTimeInterpolator = BouncingInterpolatorType.getTimeInterpolator(typedArray.getInteger(
R.styleable.BouncingJellyScrollView_BouncingInterpolator
, BouncingInterpolatorType.OVERSHOOT_INTERPOLATOR));
//回彈速度
mBouncingDuration = typedArray.getInteger(R.styleable.BouncingJellyScrollView_BouncingDuration, mBouncingDuration);
//果凍類型
mBouncingType = typedArray.getInt(R.styleable.BouncingJellyScrollView_BouncingType, BouncingType.BOTH);
typedArray.recycle();
//獲取是差值 整個屏幕的三倍大小
bouncingOffset=ScreenUtils.getScreenHeight(getContext()) * 3;
}
}
onSizeChanged中驗證模式
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
//判斷可滾動的內(nèi)容是不是小于整個屏幕的高度,以防底部進行所動
int contentHeight = getChildAt(0).getHeight();
if (contentHeight > 0 && contentHeight <= ScreenUtils.getScreenHeight(getContext())) {
mBouncingType = BouncingType.TOP;
}
}
4. 開始編碼
因為我們繼承的是ViewGroup,子View還需要攔截事件,所以我們需要重寫dispatchTouchEvent方法,并且在其中攔截事件分發(fā)和做縮放效果。
先實現(xiàn)在頂部進行滑動的時候隨著手指移動而進行縮放
/**
* 從頂部開始滑動
*/
public void bouncingTo() {
//設(shè)置X坐標點
ViewHelper.setPivotX(this, getWidth() / 2);
//設(shè)置Y坐標點
ViewHelper.setPivotY(this, 0);
//進行縮放
ViewHelper.setScaleY(this, 1.0f + offsetScale);
}
/**
* 從頂部開始滑動
*/
public void bouncingBottom() {
//設(shè)置X坐標點
ViewHelper.setPivotX(this, getWidth() / 2);
//設(shè)置Y坐標點
ViewHelper.setPivotY(this, getHeight());
ViewHelper.setScaleY(this, 1.0f + offsetScale);
}
在ACTION_DOWN記錄按下的坐標,用于計算縮放值和進行回彈。因為ACTION_DOWN事件必定會傳遞到子view的,所以不能直接返回true。
//移動坐標
dowY = (int) event.getRawY();
//按下坐標 用于計算縮放值
dowY2 = (int) event.getRawY();
在ACTION_MOVE中進行事件分發(fā)和縮放。
-
實現(xiàn)頂部滑動縮放,主要原理是判斷當(dāng)前是不是滾動到了頂部,獲取手指移動的方向和距離。
moveX = (int) event.getRawX();
moveY = (int) event.getRawY();
//dy值 判斷方向
int dy = moveY - dowY;
dowY = moveY;
//頂部
if (dy > 0 && getScrollY() == 0) {
//判斷果凍的類型
if (mBouncingType == BouncingType.TOP || mBouncingType == BouncingType.BOTH) {
//獲取現(xiàn)在坐標與按下坐標的差值
int abs = moveY - dowY2;
//計算縮放值
offsetScale = (Math.abs(abs) / bouncingOffset);
if (offsetScale > 0.3f) {
offsetScale = 0.3f;
}
isTop = true;
bouncingTo();
return true;
}
}
實現(xiàn)第一步效果如下:

- 回拉恢復(fù)
效果是出來了,從頂部下拉的時候慢慢的縮放了,但是如果在下拉一定距離后上拉會是怎么樣的呢?應(yīng)該是慢慢的縮回去,然后再進行滾動。 需要在頂部if后面再加上判斷
if (getScrollY() == 0 && dy < 0 && offsetScale > 0) {//為頂部 并且dy為下拉 并且縮放值大于0
if (mBouncingType == BouncingType.TOP || mBouncingType == BouncingType.BOTH) {
//獲取現(xiàn)在坐標與按下坐標的差值
int abs = moveY - dowY2;
//計算縮放值
offsetScale = (Math.abs(abs) / bouncingOffset);
if (offsetScale > 0.3f) {
offsetScale = 0.3f;
}
if (abs <= 0) {
offsetScale = 0;
dowY2 = moveY;
}
isTop = true;
bouncingTo();
return true;
}
}
效果如下:

- 手指抬起進行回彈
前面兩步完成了整個拉取的過程,現(xiàn)在只要加上手機抬起的時候進行回彈就可以了。整個回彈過程是有一個時間段,并且還有一個效果。采用ValueAnimator來散發(fā)offsetScale值來不斷的改變縮放值就能達到效果。
ACTION_UP代碼
if (mBouncingType != BouncingType.NONE) {
if (offsetScale > 0) {
backBouncing(offsetScale, 0);
return true;
}
}
/**
* 進行回彈
*
* @param from
* @param to
*/
private void backBouncing(final float from, final float to) {
//初始化
if (animator != null && animator.isRunning()) {
animator.cancel();
animator = null;
offsetScale = 0;
bouncingTo();
}
if (mTimeInterpolator == null) {
mTimeInterpolator = new OvershootInterpolator();
}
//散發(fā)值
animator = ValueAnimator.ofFloat(from, to).setDuration(mBouncingDuration);
animator.setInterpolator(mTimeInterpolator);//差值器
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//獲取動畫階段的值
offsetScale = (float) animation.getAnimatedValue();
if (isTop) {//回彈到頂部
bouncingTo();
} else {//回彈到底部
bouncingBottom();
}
}
});
animator.start();
}
效果如下:

其實到這里,整個果凍視圖就已經(jīng)算是完成了,至于底部滑動,縮放都是一樣的,只是方向,值相反而已。判斷是否已經(jīng)滾動到了底部,判斷方向等。以下附上dispatchTouchEvent的代碼,代碼量有些冗余,只是為了每個部分的清晰而已。
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//移動坐標
dowY = (int) event.getRawY();
//按下坐標 用于計算縮放值
dowY2 = (int) event.getRawY();
break;
case MotionEvent.ACTION_MOVE:
if (mBouncingType != BouncingType.NONE) {
moveX = (int) event.getRawX();
moveY = (int) event.getRawY();
//dy值 判斷方向
int dy = moveY - dowY;
dowY = moveY;
//頂部
if (dy > 0 && getScrollY() == 0) {
//判斷果凍的類型
if (mBouncingType == BouncingType.TOP || mBouncingType == BouncingType.BOTH) {
//獲取現(xiàn)在坐標與按下坐標的差值
int abs = moveY - dowY2;
//計算縮放值
offsetScale = (Math.abs(abs) / bouncingOffset);
if (offsetScale > 0.3f) {
offsetScale = 0.3f;
}
isTop = true;
bouncingTo();
return true;
}
} else if (getScrollY() == 0 && dy < 0 && offsetScale > 0) {//為頂部 并且dy為下拉 并且縮放值大于0
if (mBouncingType == BouncingType.TOP || mBouncingType == BouncingType.BOTH) {
//獲取現(xiàn)在坐標與按下坐標的差值
int abs = moveY - dowY2;
//計算縮放值
offsetScale = (Math.abs(abs) / bouncingOffset);
if (offsetScale > 0.3f) {
offsetScale = 0.3f;
}
if (abs <= 0) {
offsetScale = 0;
dowY2 = moveY;
}
isTop = true;
bouncingTo();
return true;
}
}
//底部
if (dy < 0 && getScrollY() + getHeight() >= computeVerticalScrollRange()) {//滾動到底部
if (mBouncingType == BouncingType.BOTTOM || mBouncingType == BouncingType.BOTH) {
int abs = moveY - dowY2;
offsetScale = (Math.abs(abs) / bouncingOffset);
if (offsetScale > 0.3f) {
offsetScale = 0.3f;
}
isTop = false;
bouncingBottom();
}
} else if (dy > 0 && getScrollY() + getHeight() >= computeVerticalScrollRange() && offsetScale > 0) {
if (mBouncingType == BouncingType.BOTTOM || mBouncingType == BouncingType.BOTH) {
int abs = moveY - dowY2;
offsetScale = (Math.abs(abs) / bouncingOffset);
if (offsetScale > 0.3f) {
offsetScale = 0.3f;
}
if (abs >= 0) {
offsetScale = 0;
dowY2 = moveY;
}
isTop = false;
bouncingBottom();
return true;
}
}
}
break;
case MotionEvent.ACTION_UP:
if (mBouncingType != BouncingType.NONE) {
if (offsetScale > 0) {
backBouncing(offsetScale, 0);
return true;
}
}
break;
}
return super.dispatchTouchEvent(event);
}
來一個整體完成的效果圖:

其它View
- RecyclerView,ListVIew實現(xiàn)的原理都是一樣的,判斷是否在頂部,滑動方向等,再進行縮放即可。另外我實現(xiàn)了一個RecycerView的demo,代碼和上面的基本上一致。
最后
最后,這暫時是一個最基本的,等有時間我會繼續(xù)完成這個自定義View的。如果可能,希望大家能夠給我一個star。
微信公眾號
