目录
- Android 对现有布局添加下拉刷新
- 一、简述
- 1、下拉阶段
- 2、下拉松手阶段
- 二、现有布局
- 三、添加下拉刷新
- 1、一个响应下拉操作的父容器控件
- (1)onInterceptTouchEvent
- (2)onTouchEvent
- 2、下拉刷新头部区域
- 3、将下拉刷新头部 及 内容区域 引入到 响应下拉操作的父容器控件中
- 4、回弹悬停动画
- 5、回弹到顶部的动画
- 6、在某些时机下,进行回调
- 四、遇到的问题
- 如何解决呢
Android 对现有布局添加下拉刷新
先直接上效果,如下GIF所示
一、简述
对现有布局添加一个下拉刷新,并且这个动画的效果如上GIF所示
1、下拉阶段
下拉过程中,有阻尼滑动效果
2、下拉松手阶段
(1)、进行高度判断,若大于指定的高度后,先回弹到指定的高度后,做悬停动画效果,再然后做回弹动画回弹到原始位置
(2)、若没有大于指定的高度,则直接回弹到原始位置
(3)刷新的时机,可以自由选择,例如在松手时,即发起刷新逻辑。
二、现有布局
如前面的GIF所示,蓝色区域是内容区域,即是添加下拉刷新前的现有布局
三、添加下拉刷新
从GIF图可以看出,添加下拉刷新,需要两个控件:一个响应下拉操作的父容器控件、一个是刷新头部控件
下拉刷新的主要思路:
页面布局:将响应下拉操作的父容器控件包裹红色下拉刷新头部区域 和 蓝色内容区域,其中蓝色内容区域覆盖在红色下拉刷新头部区域的上面。
下拉操作:下拉时,动态地改变红色下拉刷新头部区域的高度,以及动态改变蓝色内容区域的marginTop值
然后,就是动画操作,也是动态地改变红色下拉刷新头部区域的高度 和 蓝色内容区域的marginTop值。
1、一个响应下拉操作的父容器控件
为写起来简单,直接继承RelativeLayout,重点重写onInterceptTouchEvent 和 onTouchEvent方法。
(1)onInterceptTouchEvent
拦截事件方法:
首先,判断该事件是否需要拦截;
然后,若拦截该事件:在down事件时,将之前操作红色下拉刷新头部区域 及 蓝色内容区域都重置下
然后,在move事件时,判断当前移动的距离是否 > mTouchSlop(表示滑动的最小距离) ,当大于时,认为此时产生了拖拽滑动
最后,在up\cancel事件时,将拖拽标志 重置回来
public boolean onInterceptTouchEvent(MotionEvent event) { | |
if (不拦截事件的判断条件) { | |
return false; | |
} | |
if (若此时正在执行动画,则拦截该事件) { | |
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) { | |
//判断是否时产生了拖拽 | |
mIsDragging = true; | |
} | |
break; | |
case MotionEvent.ACTION_UP: | |
case MotionEvent.ACTION_CANCEL: | |
mIsDragging = false; | |
break; | |
default: | |
break; | |
} | |
return mIsDragging; | |
} |
(2)onTouchEvent
触摸事件处理方法:
若此时没有发生拖拽,或者此时正在动画中: 不处理该事件
当在move事件时:计算阻尼滑动距离,然后更新给红色的下拉刷新头部区域 及 蓝色的内容区域
当在up/cancel事件时: 开启动画逻辑
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; | |
//计算实际需要被拖拽产生的移动百分比 | |
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); | |
} |
更新红色头部区域(mPullRefreshHeadView)高度 及 蓝色的内容区域(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、下拉刷新头部区域
这里可以根据自己的需求去构建下拉刷新头部区域的布局,例如添加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、将下拉刷新头部 及 内容区域 引入到 响应下拉操作的父容器控件中
布局:响应下拉操作的父容器控件包裹着下拉刷新头部及内容区域
<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="这里是内容区域, 下拉试试看"/> | |
</RelativeLayout> | |
</com.qlli.pulllayout.PullRefreshLayout> |
在响应下拉操作的父容器控件初始化时,在onFinishInflate中将下拉刷新头部、内容区域分别进行赋值
protected void onFinishInflate() { | |
super.onFinishInflate(); | |
ensureTargetView(); | |
} | |
//寻找需要控制滑动的内容区域的父容器 | |
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、回弹悬停动画
回弹悬停动画是指:先回弹到指定位置,然后开始悬停一段时间后,再开启一个新的动画
回弹动作:是指将 下拉刷新头部 及 内容区域 回弹至指定位置,可以在一个时间段中,通过监听0到100变化的,进而动态计算改变下拉刷新头部及内容区域的高度并更新
悬停动作:在回弹结束后,其实此时悬停是指回弹动画结束后,就保持当前位置不动了,此时使用Handler发一个延时任务去执行 一个新的回弹动画(将下拉刷新及内容区域回弹至原始位置),这个中间的过程给出的视觉效果是一个悬停的效果
private ValueAnimator mHoverAnimator;//回弹悬停动画 | |
private final Handler mHoverHandler = new Handler(Looper.getMainLooper()); | |
private void animateToHover() { | |
// 这里是内容区域marginTop的距离 | |
final int startPosition = mOffsetTop; | |
// 这里是动画结束的位置,要保留一个下拉刷新头部高度距离 | |
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(); | |
} | |
// 在动画监听过程中,通过updateHeightAndMargin移动下拉刷新及内容区域的距离 | |
mHoverAnimator.addUpdateListener(animation -> { | |
Object value = animation.getAnimatedValue(); | |
if (value instanceof Float) { | |
float percent = ((float) value) / 100f; | |
int targetTop = startPosition - (int) (totalDistance * percent); | |
updateHeightAndMargin(targetTop); | |
} | |
}); | |
// 监听此动画开始 和 结束点 | |
mHoverAnimator.addListener(new AnimatorListenerAdapter() { | |
public void onAnimationStart(Animator animation) { | |
mIsAnimation = true; | |
} | |
// 在该动画结束后,在1.6s后,做一个回弹动画,因此在1.6s的时间内就是一个悬停效果 | |
// 可以在这个悬停的期间干些事情,例如播放Lottie动画等 | |
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、回弹到顶部的动画
这个回弹到顶部的操作是指:将下拉刷新头部 及 内容区域 在一定时间内 回到顶部
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() { | |
public void onAnimationStart(Animator animation) { | |
mIsAnimation = true; | |
} | |
public void onAnimationEnd(Animator animation) { | |
mIsAnimation = false; | |
updateHeightAndMargin(0); | |
} | |
}); | |
float ratio = Math.abs(startDragPercent); | |
// 滑动到顶部的时间 | |
mPeakAnimator.setDuration((long) (800 * ratio)); | |
mPeakAnimator.start(); | |
} |
6、在某些时机下,进行回调
可以结合自己的需求写一个接口,例如下面这样:
public interface PullRefreshBehavior { | |
// 移动的高度 | |
void onMove(int height); | |
// 手指抬起 | |
void onUp(); | |
// 悬停 | |
void onHover(); | |
// 回弹 | |
void onSpringBack(); | |
// 完成 | |
void onComplete(); | |
} |
然后在下拉操作的过程中 去选择性地调用 上面接口中的方法,这样在实现该接口的具体实现类中,就能根据当前下拉操作的不同时机来去做一些想做的事情
四、遇到的问题
- 1、在下拉操作时,在onInterceptTouchEvent方法时仅响应down事件,move事件不响应
导致该问题的主要原因是:响应下拉操作的父容器内包裹的子控件没有消耗down事件,所以后续收不到move事件
- 2、看下ViewGroup中的事件分发这段代码
可以看到下面代码中: 是down事件,或者 mFirstTouchTarget != null
若父容器包裹的子控件没有消耗down事件,则mFirstTouchTarget == null,那么当move事件到来是,即不满足条件,则不会调用到 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"> |