Android添加自定義下拉刷新布局阻尼滑動(dòng)懸停彈動(dòng)畫(huà)效果
Android 對(duì)現(xiàn)有布局添加下拉刷新
先直接上效果,如下GIF所示

一、簡(jiǎn)述
對(duì)現(xiàn)有布局添加一個(gè)下拉刷新,并且這個(gè)動(dòng)畫(huà)的效果如上GIF所示
1、下拉階段
下拉過(guò)程中,有阻尼滑動(dòng)效果
2、下拉松手階段
(1)、進(jìn)行高度判斷,若大于指定的高度后,先回彈到指定的高度后,做懸停動(dòng)畫(huà)效果,再然后做回彈動(dòng)畫(huà)回彈到原始位置
(2)、若沒(méi)有大于指定的高度,則直接回彈到原始位置
(3)刷新的時(shí)機(jī),可以自由選擇,例如在松手時(shí),即發(fā)起刷新邏輯。
二、現(xiàn)有布局
如前面的GIF所示,藍(lán)色區(qū)域是內(nèi)容區(qū)域,即是添加下拉刷新前的現(xiàn)有布局
三、添加下拉刷新
從GIF圖可以看出,添加下拉刷新,需要兩個(gè)控件:一個(gè)響應(yīng)下拉操作的父容器控件、一個(gè)是刷新頭部控件
下拉刷新的主要思路:
頁(yè)面布局:將響應(yīng)下拉操作的父容器控件包裹紅色下拉刷新頭部區(qū)域 和 藍(lán)色內(nèi)容區(qū)域,其中藍(lán)色內(nèi)容區(qū)域覆蓋在紅色下拉刷新頭部區(qū)域的上面。
下拉操作:下拉時(shí),動(dòng)態(tài)地改變紅色下拉刷新頭部區(qū)域的高度,以及動(dòng)態(tài)改變藍(lán)色內(nèi)容區(qū)域的marginTop值
然后,就是動(dòng)畫(huà)操作,也是動(dòng)態(tài)地改變紅色下拉刷新頭部區(qū)域的高度 和 藍(lán)色內(nèi)容區(qū)域的marginTop值。
1、一個(gè)響應(yīng)下拉操作的父容器控件
為寫(xiě)起來(lái)簡(jiǎn)單,直接繼承RelativeLayout,重點(diǎn)重寫(xiě)onInterceptTouchEvent 和 onTouchEvent方法。
(1)onInterceptTouchEvent
攔截事件方法:
首先,判斷該事件是否需要攔截;
然后,若攔截該事件:在down事件時(shí),將之前操作紅色下拉刷新頭部區(qū)域 及 藍(lán)色內(nèi)容區(qū)域都重置下
然后,在move事件時(shí),判斷當(dāng)前移動(dòng)的距離是否 > mTouchSlop(表示滑動(dòng)的最小距離) ,當(dāng)大于時(shí),認(rèn)為此時(shí)產(chǎn)生了拖拽滑動(dòng)
最后,在up\cancel事件時(shí),將拖拽標(biāo)志 重置回來(lái)
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
if (不攔截事件的判斷條件) {
return false;
}
if (若此時(shí)正在執(zhí)行動(dòng)畫(huà),則攔截該事件) {
return true;
}
final int action = event.getActionMasked();//獲取觸控手勢(shì)
switch (action) {
case MotionEvent.ACTION_DOWN:
// 重置操作
updateHeightAndMargin(0);
mIsDragging = false;
// 手指按下的距離
this.mDownY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
final float y = event.getY();
final float yDiff = y - this.mDownY;
if (yDiff > mTouchSlop) {
//判斷是否時(shí)產(chǎn)生了拖拽
mIsDragging = true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIsDragging = false;
break;
default:
break;
}
return mIsDragging;
}
(2)onTouchEvent
觸摸事件處理方法:
若此時(shí)沒(méi)有發(fā)生拖拽,或者此時(shí)正在動(dòng)畫(huà)中: 不處理該事件
當(dāng)在move事件時(shí):計(jì)算阻尼滑動(dòng)距離,然后更新給紅色的下拉刷新頭部區(qū)域 及 藍(lán)色的內(nèi)容區(qū)域
當(dāng)在up/cancel事件時(shí): 開(kāi)啟動(dòng)畫(huà)邏輯
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mIsDragging || mIsAnimation) {
return super.onTouchEvent(event);
}
//獲取觸控手勢(shì)
final int action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_MOVE: {
//獲取移動(dòng)距離
float eventY = event.getY();
float yDiff = eventY - mDownY;
float scrollTop = yDiff * 0.5;
//計(jì)算實(shí)際需要被拖拽產(chǎn)生的移動(dòng)百分比
mDragPercent = scrollTop / mDp330;
if (mDragPercent < 0) {
return false;
}
//計(jì)算阻尼滑動(dòng)的距離
int targetY = (int) (computeTargetY(scrollTop, mDragPercent, mDp330) + 0.5f);
updateHeightAndMargin(targetY);
break;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL: {
final float upDiffY = event.getY() - mDownY;
final float overScrollTop = upDiffY * DEFAULT_DRAG_RATE;
mIsDragging = false;
if (overScrollTop > mDp54) {
animateToHover();
} else {
animateToPeak();
}
mExtraDrag = 0;
mPullRefreshBehavior.onUp();
return false;
}
default:
break;
}
return true;
}
阻尼滑動(dòng)的計(jì)算方式:
/*計(jì)算阻尼滑動(dòng)距離*/
public int computeTargetY(float scrollTop, float dragPercent, float maxDragDistance) {
float boundedDragPercent = Math.min(1.0f, Math.abs(dragPercent));
float extraOS = Math.abs(scrollTop) - maxDragDistance;
float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, maxDragDistance * 2) / maxDragDistance);
float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow((tensionSlingshotPercent / 4), 2)) * 2f;
float extraMove = (maxDragDistance) * tensionPercent / 2;
return (int) ((maxDragDistance * boundedDragPercent) + extraMove);
}
更新紅色頭部區(qū)域(mPullRefreshHeadView)高度 及 藍(lán)色的內(nèi)容區(qū)域(mTarget)
private void updateHeightAndMargin(int offsetTop) {
if (mPullRefreshHeadView == null || mTarget == null) {
return;
}
// 更新下拉刷新的頭部高度
ViewGroup.LayoutParams headViewLayoutParams = mPullRefreshHeadView.getLayoutParams();
if (headViewLayoutParams != null) {
headViewLayoutParams.height = Math.max(offsetTop, mDp54);
}
// 更新 mTarget view 的 topMargin
MarginLayoutParams targetLayoutParams = (MarginLayoutParams) mTarget.getLayoutParams();
if (targetLayoutParams != null) {
targetLayoutParams.topMargin = offsetTop;
}
mOffsetTop = offsetTop;
mPullRefreshBehavior.onMove(mOffsetTop);
// 刷新界面
requestLayout();
}
2、下拉刷新頭部區(qū)域
這里可以根據(jù)自己的需求去構(gòu)建下拉刷新頭部區(qū)域的布局,例如添加Lottie動(dòng)畫(huà)等
代碼示例,是比較簡(jiǎn)單的一個(gè) Textview + 背景展示下
public class PullRefreshHeadView extends RelativeLayout {
private View mHeaderView;
public PullRefreshHeadView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
Resources resources = context.getResources();
mHeaderView = LayoutInflater.from(context).inflate(R.layout.vivoshop_classify_pull_refresh_head, this, false);
LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, context.getResources().getDimensionPixelSize(R.dimen.dp54));
params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
params.addRule(RelativeLayout.CENTER_HORIZONTAL);
params.bottomMargin = resources.getDimensionPixelSize(R.dimen.dp9);
addView(mHeaderView, params);
}
}
3、將下拉刷新頭部 及 內(nèi)容區(qū)域 引入到 響應(yīng)下拉操作的父容器控件中
布局:響應(yīng)下拉操作的父容器控件包裹著下拉刷新頭部及內(nèi)容區(qū)域
<?xml version="1.0" encoding="utf-8"?>
<com.qlli.pulllayout.PullRefreshLayout
android:id="@+id/pull_layout"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:background="@color/teal_700">
<com.qlli.pulllayout.PullRefreshHeadView
android:id="@+id/pull_header"
android:layout_width="match_parent"
android:layout_height="@dimen/dp54"
android:background="@color/red"/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/color_415fff"
android:gravity="center"
android:clickable="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="20sp"
android:text="這里是內(nèi)容區(qū)域, 下拉試試看"/>
</RelativeLayout>
</com.qlli.pulllayout.PullRefreshLayout>
在響應(yīng)下拉操作的父容器控件初始化時(shí),在onFinishInflate中將下拉刷新頭部、內(nèi)容區(qū)域分別進(jìn)行賦值
@Override
protected void onFinishInflate() {
super.onFinishInflate();
ensureTargetView();
}
//尋找需要控制滑動(dòng)的內(nèi)容區(qū)域的父容器
private void ensureTargetView() {
if (mTarget != null || getChildCount() <= 0) {
return;
}
for (int index = 0; index < getChildCount(); index++) {
View child = getChildAt(index);
if (child instanceof PullRefreshHeadView) {
mPullRefreshHeadView = (PullRefreshHeadView) child;
continue;
}
if (child != mPullRefreshHeadView) {
mTarget = child;
break;
}
}
}
4、回彈懸停動(dòng)畫(huà)
回彈懸停動(dòng)畫(huà)是指:先回彈到指定位置,然后開(kāi)始懸停一段時(shí)間后,再開(kāi)啟一個(gè)新的動(dòng)畫(huà)
回彈動(dòng)作:是指將 下拉刷新頭部 及 內(nèi)容區(qū)域 回彈至指定位置,可以在一個(gè)時(shí)間段中,通過(guò)監(jiān)聽(tīng)0到100變化的,進(jìn)而動(dòng)態(tài)計(jì)算改變下拉刷新頭部及內(nèi)容區(qū)域的高度并更新
懸停動(dòng)作:在回彈結(jié)束后,其實(shí)此時(shí)懸停是指回彈動(dòng)畫(huà)結(jié)束后,就保持當(dāng)前位置不動(dòng)了,此時(shí)使用Handler發(fā)一個(gè)延時(shí)任務(wù)去執(zhí)行 一個(gè)新的回彈動(dòng)畫(huà)(將下拉刷新及內(nèi)容區(qū)域回彈至原始位置),這個(gè)中間的過(guò)程給出的視覺(jué)效果是一個(gè)懸停的效果
private ValueAnimator mHoverAnimator;//回彈懸停動(dòng)畫(huà)
private final Handler mHoverHandler = new Handler(Looper.getMainLooper());
private void animateToHover() {
// 這里是內(nèi)容區(qū)域marginTop的距離
final int startPosition = mOffsetTop;
// 這里是動(dòng)畫(huà)結(jié)束的位置,要保留一個(gè)下拉刷新頭部高度距離
final int totalDistance = startPosition - mDp54;
// 設(shè)置懸停動(dòng)畫(huà)的一些初始化東西
if (mHoverAnimator == null) {
mHoverAnimator = ValueAnimator.ofFloat(0f, 100f);
mHoverAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
} else {
mHoverAnimator.removeAllUpdateListeners();
mHoverAnimator.removeAllListeners();
mHoverAnimator.end();
}
// 在動(dòng)畫(huà)監(jiān)聽(tīng)過(guò)程中,通過(guò)updateHeightAndMargin移動(dòng)下拉刷新及內(nèi)容區(qū)域的距離
mHoverAnimator.addUpdateListener(animation -> {
Object value = animation.getAnimatedValue();
if (value instanceof Float) {
float percent = ((float) value) / 100f;
int targetTop = startPosition - (int) (totalDistance * percent);
updateHeightAndMargin(targetTop);
}
});
// 監(jiān)聽(tīng)此動(dòng)畫(huà)開(kāi)始 和 結(jié)束點(diǎn)
mHoverAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
mIsAnimation = true;
}
// 在該動(dòng)畫(huà)結(jié)束后,在1.6s后,做一個(gè)回彈動(dòng)畫(huà),因此在1.6s的時(shí)間內(nèi)就是一個(gè)懸停效果
// 可以在這個(gè)懸停的期間干些事情,例如播放Lottie動(dòng)畫(huà)等
@Override
public void onAnimationEnd(Animator animation) {
mHoverHandler.removeCallbacksAndMessages(null);
mHoverHandler.postDelayed(() -> {
if (isAttachedToWindow()) {
// 例如在這個(gè)播放Lottie動(dòng)畫(huà)
ensureTargetView();
// 回彈動(dòng)畫(huà)
animateToPeak();
}
}, 1600);
}
});
// 此動(dòng)畫(huà)設(shè)置一下時(shí)間
float animationPercent = Math.min(1.0f, Math.abs(totalDistance) * 1.0f / mDp54);
long duration = Math.abs((long) (ANIMATION_DURATION_300 * animationPercent));
mHoverAnimator.setDuration(duration);
mHoverAnimator.start();
}
5、回彈到頂部的動(dòng)畫(huà)
這個(gè)回彈到頂部的操作是指:將下拉刷新頭部 及 內(nèi)容區(qū)域 在一定時(shí)間內(nèi) 回到頂部
private ValueAnimator mPeakAnimator;//回彈動(dòng)畫(huà)
private void animateToPeak() {
float startDragPercent = mDragPercent;
//松手后開(kāi)始從此位置滑動(dòng)
final int totalDistance = mOffsetTop;
if (mPeakAnimator == null) {
mPeakAnimator = ValueAnimator.ofFloat(0f, 100f);
mPeakAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
} else {
mPeakAnimator.removeAllListeners();
mPeakAnimator.removeAllUpdateListeners();
mPeakAnimator.end();
}
mPeakAnimator.addUpdateListener(animation -> {
Object value = animation.getAnimatedValue();
if (value instanceof Float) {
float percent = ((float) value) / 100f;
int targetTop = (int) (totalDistance * (1.0f - percent));
updateHeightAndMargin(targetTop);
}
});
mPeakAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
mIsAnimation = true;
}
@Override
public void onAnimationEnd(Animator animation) {
mIsAnimation = false;
updateHeightAndMargin(0);
}
});
float ratio = Math.abs(startDragPercent);
// 滑動(dòng)到頂部的時(shí)間
mPeakAnimator.setDuration((long) (800 * ratio));
mPeakAnimator.start();
}
6、在某些時(shí)機(jī)下,進(jìn)行回調(diào)
可以結(jié)合自己的需求寫(xiě)一個(gè)接口,例如下面這樣:
public interface PullRefreshBehavior {
// 移動(dòng)的高度
void onMove(int height);
// 手指抬起
void onUp();
// 懸停
void onHover();
// 回彈
void onSpringBack();
// 完成
void onComplete();
}
然后在下拉操作的過(guò)程中 去選擇性地調(diào)用 上面接口中的方法,這樣在實(shí)現(xiàn)該接口的具體實(shí)現(xiàn)類(lèi)中,就能根據(jù)當(dāng)前下拉操作的不同時(shí)機(jī)來(lái)去做一些想做的事情
四、遇到的問(wèn)題
- 1、在下拉操作時(shí),在onInterceptTouchEvent方法時(shí)僅響應(yīng)down事件,move事件不響應(yīng)
導(dǎo)致該問(wèn)題的主要原因是:響應(yīng)下拉操作的父容器內(nèi)包裹的子控件沒(méi)有消耗down事件,所以后續(xù)收不到move事件
- 2、看下ViewGroup中的事件分發(fā)這段代碼
可以看到下面代碼中: 是down事件,或者 mFirstTouchTarget != null
若父容器包裹的子控件沒(méi)有消耗down事件,則mFirstTouchTarget == null,那么當(dāng)move事件到來(lái)是,即不滿(mǎn)足條件,則不會(huì)調(diào)用到 onInterceptTouchEvent方法。
// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
如何解決呢
在子控件中,加一個(gè)消耗down事件的操作即可,例如在子控件布局中,添加一個(gè)clickable屬性為 true 即可
因?yàn)榭牲c(diǎn)擊事件,是消耗down事件的
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/color_415fff"
android:gravity="center"
android:clickable="true">以上就是Android添加自定義下拉刷新布局阻尼滑動(dòng)懸停彈動(dòng)畫(huà)效果的詳細(xì)內(nèi)容,更多關(guān)于Android添加下拉刷新布局的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android普通應(yīng)用升級(jí)為系統(tǒng)應(yīng)用并獲取系統(tǒng)權(quán)限的操作
這篇文章主要介紹了Android普通應(yīng)用升級(jí)為系統(tǒng)應(yīng)用并獲取系統(tǒng)權(quán)限的操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-03-03
Android實(shí)現(xiàn)系統(tǒng)的桌面圖標(biāo)文字的雙行顯示效果
這篇文章主要介紹了Android實(shí)現(xiàn)系統(tǒng)的桌面圖標(biāo)文字的雙行顯示效果,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友參考下吧2018-10-10
Android中TabLayout添加小紅點(diǎn)的示例代碼
本篇文章主要介紹了Android中TabLayout添加小紅點(diǎn)的示例代碼,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-12-12
Android實(shí)現(xiàn)多媒體之播放音樂(lè)
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)多媒體之播放音樂(lè)的相關(guān)資料,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-02-02
Android連接MySQL數(shù)據(jù)庫(kù)實(shí)現(xiàn)方法詳解
這篇文章主要介紹了Android連接MySQL數(shù)據(jù)庫(kù)實(shí)現(xiàn)方法,在Android應(yīng)用程序中連接MySQL數(shù)據(jù)庫(kù)可以幫助開(kāi)發(fā)人員實(shí)現(xiàn)更豐富的數(shù)據(jù)管理功能,而且在Android中操作數(shù)據(jù)庫(kù)真的太智能了,需要的朋友可以參考下2024-02-02
Android 中解決Viewpage調(diào)用notifyDataSetChanged()時(shí)界面無(wú)刷新的問(wèn)題
這篇文章主要介紹了Android 中解決Viewpage調(diào)用notifyDataSetChanged()時(shí)界面無(wú)刷新的問(wèn)題的相關(guān)資料,這里提供相應(yīng)的解決辦法,需要的朋友可以參考下2017-08-08
Android編程獲取網(wǎng)絡(luò)連接方式及判斷手機(jī)卡所屬運(yùn)營(yíng)商的方法
這篇文章主要介紹了Android編程獲取網(wǎng)絡(luò)連接方式及判斷手機(jī)卡所屬運(yùn)營(yíng)商的方法,涉及Android針對(duì)網(wǎng)絡(luò)的判斷及本機(jī)信息的獲取技巧,需要的朋友可以參考下2016-01-01
Android自定義StepView仿外賣(mài)配送進(jìn)度
這篇文章主要為大家詳細(xì)介紹了Android自定義StepView仿外賣(mài)配送進(jìn)度,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-05-05

