Android添加自定義下拉刷新布局阻尼滑動懸停彈動畫效果
Android 對現(xiàn)有布局添加下拉刷新
先直接上效果,如下GIF所示
一、簡述
對現(xiàn)有布局添加一個下拉刷新,并且這個動畫的效果如上GIF所示
1、下拉階段
下拉過程中,有阻尼滑動效果
2、下拉松手階段
(1)、進行高度判斷,若大于指定的高度后,先回彈到指定的高度后,做懸停動畫效果,再然后做回彈動畫回彈到原始位置
(2)、若沒有大于指定的高度,則直接回彈到原始位置
(3)刷新的時機,可以自由選擇,例如在松手時,即發(fā)起刷新邏輯。
二、現(xiàn)有布局
如前面的GIF所示,藍色區(qū)域是內(nèi)容區(qū)域,即是添加下拉刷新前的現(xiàn)有布局
三、添加下拉刷新
從GIF圖可以看出,添加下拉刷新,需要兩個控件:一個響應下拉操作的父容器控件、一個是刷新頭部控件
下拉刷新的主要思路:
頁面布局:將響應下拉操作的父容器控件包裹紅色下拉刷新頭部區(qū)域 和 藍色內(nèi)容區(qū)域,其中藍色內(nèi)容區(qū)域覆蓋在紅色下拉刷新頭部區(qū)域的上面。
下拉操作:下拉時,動態(tài)地改變紅色下拉刷新頭部區(qū)域的高度,以及動態(tài)改變藍色內(nèi)容區(qū)域的marginTop值
然后,就是動畫操作,也是動態(tài)地改變紅色下拉刷新頭部區(qū)域的高度 和 藍色內(nèi)容區(qū)域的marginTop值。
1、一個響應下拉操作的父容器控件
為寫起來簡單,直接繼承RelativeLayout,重點重寫onInterceptTouchEvent 和 onTouchEvent方法。
(1)onInterceptTouchEvent
攔截事件方法:
首先,判斷該事件是否需要攔截;
然后,若攔截該事件:在down事件時,將之前操作紅色下拉刷新頭部區(qū)域 及 藍色內(nèi)容區(qū)域都重置下
然后,在move事件時,判斷當前移動的距離是否 > mTouchSlop(表示滑動的最小距離) ,當大于時,認為此時產(chǎn)生了拖拽滑動
最后,在up\cancel事件時,將拖拽標志 重置回來
@Override public boolean onInterceptTouchEvent(MotionEvent event) { if (不攔截事件的判斷條件) { return false; } if (若此時正在執(zhí)行動畫,則攔截該事件) { return true; } final int action = event.getActionMasked();//獲取觸控手勢 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) { //判斷是否時產(chǎn)生了拖拽 mIsDragging = true; } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: mIsDragging = false; break; default: break; } return mIsDragging; }
(2)onTouchEvent
觸摸事件處理方法:
若此時沒有發(fā)生拖拽,或者此時正在動畫中: 不處理該事件
當在move事件時:計算阻尼滑動距離,然后更新給紅色的下拉刷新頭部區(qū)域 及 藍色的內(nèi)容區(qū)域
當在up/cancel事件時: 開啟動畫邏輯
@SuppressLint("ClickableViewAccessibility") @Override public boolean onTouchEvent(MotionEvent event) { if (!mIsDragging || mIsAnimation) { return super.onTouchEvent(event); } //獲取觸控手勢 final int action = event.getActionMasked(); switch (action) { case MotionEvent.ACTION_MOVE: { //獲取移動距離 float eventY = event.getY(); float yDiff = eventY - mDownY; float scrollTop = yDiff * 0.5; //計算實際需要被拖拽產(chǎn)生的移動百分比 mDragPercent = scrollTop / mDp330; if (mDragPercent < 0) { return false; } //計算阻尼滑動的距離 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; }
阻尼滑動的計算方式:
/*計算阻尼滑動距離*/ 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)高度 及 藍色的內(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ù)自己的需求去構建下拉刷新頭部區(qū)域的布局,例如添加Lottie動畫等
代碼示例,是比較簡單的一個 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ū)域 引入到 響應下拉操作的父容器控件中
布局:響應下拉操作的父容器控件包裹著下拉刷新頭部及內(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>
在響應下拉操作的父容器控件初始化時,在onFinishInflate中將下拉刷新頭部、內(nèi)容區(qū)域分別進行賦值
@Override protected void onFinishInflate() { super.onFinishInflate(); ensureTargetView(); } //尋找需要控制滑動的內(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、回彈懸停動畫
回彈懸停動畫是指:先回彈到指定位置,然后開始懸停一段時間后,再開啟一個新的動畫
回彈動作:是指將 下拉刷新頭部 及 內(nèi)容區(qū)域 回彈至指定位置,可以在一個時間段中,通過監(jiān)聽0到100變化的,進而動態(tài)計算改變下拉刷新頭部及內(nèi)容區(qū)域的高度并更新
懸停動作:在回彈結(jié)束后,其實此時懸停是指回彈動畫結(jié)束后,就保持當前位置不動了,此時使用Handler發(fā)一個延時任務去執(zhí)行 一個新的回彈動畫(將下拉刷新及內(nèi)容區(qū)域回彈至原始位置),這個中間的過程給出的視覺效果是一個懸停的效果
private ValueAnimator mHoverAnimator;//回彈懸停動畫 private final Handler mHoverHandler = new Handler(Looper.getMainLooper()); private void animateToHover() { // 這里是內(nèi)容區(qū)域marginTop的距離 final int startPosition = mOffsetTop; // 這里是動畫結(jié)束的位置,要保留一個下拉刷新頭部高度距離 final int totalDistance = startPosition - mDp54; // 設置懸停動畫的一些初始化東西 if (mHoverAnimator == null) { mHoverAnimator = ValueAnimator.ofFloat(0f, 100f); mHoverAnimator.setInterpolator(new DecelerateInterpolator(2.0f)); } else { mHoverAnimator.removeAllUpdateListeners(); mHoverAnimator.removeAllListeners(); mHoverAnimator.end(); } // 在動畫監(jiān)聽過程中,通過updateHeightAndMargin移動下拉刷新及內(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)聽此動畫開始 和 結(jié)束點 mHoverAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { mIsAnimation = true; } // 在該動畫結(jié)束后,在1.6s后,做一個回彈動畫,因此在1.6s的時間內(nèi)就是一個懸停效果 // 可以在這個懸停的期間干些事情,例如播放Lottie動畫等 @Override public void onAnimationEnd(Animator animation) { mHoverHandler.removeCallbacksAndMessages(null); mHoverHandler.postDelayed(() -> { if (isAttachedToWindow()) { // 例如在這個播放Lottie動畫 ensureTargetView(); // 回彈動畫 animateToPeak(); } }, 1600); } }); // 此動畫設置一下時間 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、回彈到頂部的動畫
這個回彈到頂部的操作是指:將下拉刷新頭部 及 內(nèi)容區(qū)域 在一定時間內(nèi) 回到頂部
private ValueAnimator mPeakAnimator;//回彈動畫 private void animateToPeak() { float startDragPercent = mDragPercent; //松手后開始從此位置滑動 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); // 滑動到頂部的時間 mPeakAnimator.setDuration((long) (800 * ratio)); mPeakAnimator.start(); }
6、在某些時機下,進行回調(diào)
可以結(jié)合自己的需求寫一個接口,例如下面這樣:
public interface PullRefreshBehavior { // 移動的高度 void onMove(int height); // 手指抬起 void onUp(); // 懸停 void onHover(); // 回彈 void onSpringBack(); // 完成 void onComplete(); }
然后在下拉操作的過程中 去選擇性地調(diào)用 上面接口中的方法,這樣在實現(xiàn)該接口的具體實現(xiàn)類中,就能根據(jù)當前下拉操作的不同時機來去做一些想做的事情
四、遇到的問題
- 1、在下拉操作時,在onInterceptTouchEvent方法時僅響應down事件,move事件不響應
導致該問題的主要原因是:響應下拉操作的父容器內(nèi)包裹的子控件沒有消耗down事件,所以后續(xù)收不到move事件
- 2、看下ViewGroup中的事件分發(fā)這段代碼
可以看到下面代碼中: 是down事件,或者 mFirstTouchTarget != null
若父容器包裹的子控件沒有消耗down事件,則mFirstTouchTarget == null,那么當move事件到來是,即不滿足條件,則不會調(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; }
如何解決呢
在子控件中,加一個消耗down事件的操作即可,例如在子控件布局中,添加一個clickable屬性為 true 即可
因為可點擊事件,是消耗down事件的
<RelativeLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/color_415fff" android:gravity="center" android:clickable="true">
以上就是Android添加自定義下拉刷新布局阻尼滑動懸停彈動畫效果的詳細內(nèi)容,更多關于Android添加下拉刷新布局的資料請關注腳本之家其它相關文章!
相關文章
Android編程中FileOutputStream與openFileOutput()的區(qū)別分析
這篇文章主要介紹了Android編程中FileOutputStream與openFileOutput()的區(qū)別,結(jié)合實例形式分析了FileOutputStream與openFileOutput()的功能,使用技巧與用法區(qū)別,需要的朋友可以參考下2016-02-02Android實現(xiàn)仿excel數(shù)據(jù)表格效果
這篇文章主要介紹了Android實現(xiàn)仿excel數(shù)據(jù)表格效果的實現(xiàn)代碼,非常不錯具有參考借鑒價值,需要的朋友可以參考下2016-10-10詳解Android中ViewPager的PagerTabStrip子控件的用法
這篇文章主要介紹了Android中ViewPager的PagerTabStrip子控件的用法,PagerTabStrip與PagerTitleStrip的用法基本相同,文中舉了兩個詳細的例子,需要的朋友可以參考下2016-03-03Android編程實現(xiàn)橫豎屏切換時不銷毀當前activity和鎖定屏幕的方法
這篇文章主要介紹了Android編程實現(xiàn)橫豎屏切換時不銷毀當前activity和鎖定屏幕的方法,涉及Android屬性設置及activity操作的相關技巧,需要的朋友可以參考下2015-11-11Android RetainFragment狀態(tài)保存的方法
本篇文章主要介紹了Android RetainFragment狀態(tài)保存的方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-02-02Android?RecyclerView曝光采集的實現(xiàn)方法
這篇文章主要為大家詳細介紹了Android?RecyclerView曝光采集的實現(xiàn)方法,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-01-01