RecyclerView实现QQ空间和微信朋友圈头部刷新效果
老规矩先上图
本篇主要讲RecyclerView实现QQ空间和微信朋友圈头部刷新效果,如果想了解ListView如何实现,请查看上篇:ListView实现QQ空间和微信朋友圈头部刷新效果
这是Demo地址
按照套路,实现上述效果需要重新自定义一个RecyclerView,但是依照不重复定义轮子的原则(前提是了解实现原理),我选择在LRecyclerView的基础上二次改造实现上面效果。
PS: LRecyclerView的主要原理是内部封装了一个warpAdapter,将真正的用户写的adapter包装成innerAdapter,warpAdapter可以添加Header、Footer等类型的Item,根据添加的数量做position的偏移,再将偏移后的数据传给真正的adapter(innerAdapter),就如使用原生Adapter一样,可以理解为做了一个中间的适配器。
LRecyclerView的改造
主要修改的内容:
一. 重写了滑动过度方法overScrollBy
(RecyclerView的overScrollBy
方法不像ListView在父类AbsListView中有重写,实际上不起作用,可以不必重写,随便添加个方法就行,这里为了和上篇对应)。
@Override protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX,int maxOverScrollY, boolean isTouchEvent) {if (deltaY != 0 && isTouchEvent) {mRefreshHeader.onMove(deltaY, sumOffSet);}return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent);}
二. 重写了onTouchEvent方法,增加了多点触摸的判断。判读并调用上面的滑动过度方法overScrollBy
。
修改前后对比图:
代码:
private int mActivePointerId;@Override public boolean onTouchEvent(MotionEvent ev) {if (mLastY == -1) { // 如果adapter设置了setOnItemClickListener点击事件,则RecyclerView的ACTION_DOWN事件被拦截,这里通过这种方式获取起始坐标。mLastY = ev.getY();mActivePointerId = ev.getPointerId(0);sumOffSet = 0;}switch (ev.getActionMasked()) {case MotionEvent.ACTION_DOWN:mLastY = ev.getY();mActivePointerId = ev.getPointerId(0);sumOffSet = 0;break;case MotionEvent.ACTION_POINTER_DOWN:final int index = ev.getActionIndex();mActivePointerId = ev.getPointerId(index);mLastY = (int) ev.getY(index);break;case MotionEvent.ACTION_MOVE:int pointerIndex = ev.findPointerIndex(mActivePointerId);if (pointerIndex == -1) {pointerIndex = 0;mActivePointerId = ev.getPointerId(pointerIndex);}final int moveY = (int) ev.getY(pointerIndex);final float deltaY = (mLastY - moveY) / DRAG_RATE;mLastY = moveY;sumOffSet += deltaY;if (isOnTop() && mPullRefreshEnabled && !mRefreshing && (appbarState == AppBarStateChangeListener.State.EXPANDED)) {if (deltaY < 0 && !canScrollVertically(-1) || deltaY > 0 && !canScrollVertically(1)) { //判断无法下拉和无法上拉(item过少的情况)overScrollBy(0, (int) deltaY, 0, 0, 0, 0, 0, (int) sumOffSet, true);}}break;case MotionEvent.ACTION_UP:mLastY = -1; // resetmActivePointerId = -1;if (isOnTop() && mPullRefreshEnabled && !mRefreshing/*&& appbarState == AppBarStateChangeListener.State.EXPANDED*/) {if (mRefreshHeader != null && mRefreshHeader.onRelease()) {if (mRefreshListener != null) {mRefreshing = true;mFootView.setVisibility(GONE);mRefreshListener.onRefresh();}}}break;}return super.onTouchEvent(ev);}
三. 修改onScrolled方法,添加上推缩小的方法:
@Override public void onScrolled(int dx, int dy) {super.onScrolled(dx, dy);...if (isOnTop() && mPullRefreshEnabled && !mRefreshing && (appbarState == AppBarStateChangeListener.State.EXPANDED)) {if (dy > 0) {mRefreshHeader.onMove(dy, mScrolledYDistance);}}}
PS:这里在onScrolled
中添加上推缩小的方法而不是直接在onTouchEvent
中使用是因为super.onTouchEvent(ev)
会调用startInterceptRequestLayout()
阻断子View的布局请求,然后通过RecyclerView
当前LayoutManager
对RecyclerView
执行滑动操作,接着执行stopInterceptRequestLayout()
停止阻断子View的布局请求,最终会执行onScrolled
方法。如果直接在onTouchEvent
中上推缩小则会因为前面的原因产生Header抖动。如果判断条件在ACTION_MOVE返回true不调用父类方法super.onTouchEvent(ev)
则又会因为super.onTouchEvent(ev)
中ACTION_MOVE拿不到实时位置,最终会产生抖动~。
重点在这里:
主要原理:
1. 给LRecyclerview添加刷新视图(setRefreshHeader
)。
2. 头部视图主要由一个头图(android:scaleType="centerCrop"
用来缩放)和提示刷新状态的图片构成。
3. 重写滑动过度回调方法overScrollBy
,并在onTouchEvent
方法中判断是否下拉过度(canScrollVertically(-1)
),然后调用overScrollBy
让头图的布局高度增加并请求重新布局requestLayout()
,实现头图拉伸。
4. 头部拉伸后上推,如果此时LRecyclerview的高度超过屏幕则通过onScrolled
判断滑动距离(dy > 0 向上滑动 dy < 0 向下滑动,这里需要 dy > 0)。
5. 头部拉伸后上推,如果此时LRecyclerview的高度不够一屏,则LRecyclerview无法滑动,不能通过onScrolled
判断,需要在onTouchEvent
方法中判断是否上拉过度(canScrollVertically(1)
),然后请求重新布局,减少头图高度,实现头图高度缩小。
6. 随着头部的拉伸和收缩实现刷新状态图片的位置变化和旋转,旋转角度和方向跟随头图每次拉伸和收缩的增量(负数下拉,正数上推),限制图片跟随头图下拉的高度。
通过上面的步骤实现LRecyclerview的头图高度的缩放,由于头图缩放模式为centerCrop
则图片会根据ImageView的尺寸按中心点进行缩放,实现QQ空间的效果。
如果直接控制头部的高度而不是头图的高度就可以实现朋友圈刷新效果,具体见下面的代码实现。
下面使用的IRefreshHeader 来自LRecyclerView。
QQ空间头部刷新效果实现
Recyclerview的QQ空间头部刷新效果实现和ListView是一样的步骤。
首先是QQ空间的头部布局,qzone_header.xml:
<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="/apk/res/android"android:layout_width="match_parent"android:layout_height="wrap_content"><ImageView android:id="@+id/iv_header" android:layout_width="match_parent" android:layout_height="180dp" android:layout_marginBottom="40dp"android:scaleType="centerCrop" android:src="@drawable/img_header"/><ImageView android:id="@+id/iv_refresh" android:layout_width="30dp" android:layout_height="30dp" android:layout_marginStart="30dp"android:src="@drawable/refresh"/><ImageView android:id="@+id/iv_icon" android:layout_width="80dp" android:layout_height="80dp" android:layout_gravity="end|bottom"android:layout_marginEnd="20dp" android:src="@drawable/img_avatar"/></FrameLayout>
注意头部布局中头图的缩放方式。这里的布局和上篇ListView使用的同一个布局。
然后是QQ空间头部自定义View代码:
public class QzoneRefreshHeader extends FrameLayout implements IRefreshHeader {private ImageView mHeaderView; // 头图private int mHeaderViewHeight; // 头图高度private int mDeltaHeight; // 头图和头部布局的差值private ImageView mRefreshView; // 旋转刷新的图片private float mRefreshHideTranslationY; // 刷新图片上移的最大距离private float mRefreshShowTranslationY; // 刷新图片下拉的最大移动距离private float mRotateAngle; // 旋转角度private int mState = STATE_NORMAL;private WeakHandler mHandler = new WeakHandler();public QzoneRefreshHeader(Context context) {super(context);initView();}public QzoneRefreshHeader(Context context, AttributeSet attrs) {super(context, attrs);initView();}private void initView() {LayoutParams lp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);lp.setMargins(0, 0, 0, 0);this.setLayoutParams(lp);this.setPadding(0, 0, 0, 0);inflate(getContext(), R.layout.qzone_header, this);mHeaderView = findViewById(R.id.iv_header);mRefreshView = findViewById(R.id.iv_refresh);measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);mHeaderViewHeight = mHeaderView.getMeasuredHeight();mDeltaHeight = getMeasuredHeight() - mHeaderViewHeight;mRefreshHideTranslationY = -mRefreshView.getMeasuredHeight() - 20;mRefreshShowTranslationY = mRefreshView.getMeasuredHeight();}public void setState(int state) {if (state == mState) return;if (state == STATE_REFRESHING) { // 显示进度mRefreshView.setTranslationY(mRefreshShowTranslationY);refreshing();} else if (state == STATE_DONE) {reset();}mState = state;}@Override public void refreshComplete() {mHandler.postDelayed(new Runnable() {public void run() {setState(STATE_DONE);}}, 200);}@Override public View getHeaderView() {return this;}@Override public int getVisibleHeight() {return getHeight();}private int getHeaderViewHeight() {return mHeaderView.getHeight();}private void setHeaderViewHeight(int height) {if (height < mHeaderViewHeight) height = mHeaderViewHeight;mHeaderView.getLayoutParams().height = height;mHeaderView.requestLayout();}//垂直滑动时该方法不实现@Override public int getVisibleWidth() {return 0;}@Override public void onReset() {setState(STATE_NORMAL);}@Override public void onPrepare() {setState(STATE_RELEASE_TO_REFRESH);}@Override public void onRefreshing() {setState(STATE_REFRESHING);}@Override public void onMove(float offSet, float sumOffSet) {int top = getTop();// 相对父容器recyclerview的顶部位置 负数表示向上划出父容器的距离int currentHeight = getHeaderViewHeight();int targetHeight = currentHeight - (int) offSet;if (offSet < 0 && top == 0) {setHeaderViewHeight(targetHeight);refreshTranslation(currentHeight, offSet);} else if (offSet > 0 && currentHeight > mHeaderViewHeight) {layout(getLeft(), 0, getRight(), targetHeight + mDeltaHeight); //重新布局让header显示在顶端,直到不再缩小图片setHeaderViewHeight(targetHeight);refreshTranslation(currentHeight, offSet);}}/*** refreshView在刷新区间内相对位移并跟随位移速度旋转*/private void refreshTranslation(int currentHeight, float offSet) {if ((currentHeight - mHeaderViewHeight) / 2 < mRefreshShowTranslationY - mRefreshHideTranslationY) { // 判断是否在非刷新区间float translationY = mRefreshView.getTranslationY() - offSet / 2; // 布局高度增加offset 相当于距离上边距offSet / 2if (translationY > mRefreshShowTranslationY) {translationY = mRefreshShowTranslationY;} else if (translationY < mRefreshHideTranslationY) {translationY = mRefreshHideTranslationY;}if (Math.abs(translationY) != mRefreshView.getTranslationY()) {mRefreshView.setTranslationY(translationY);}}mRefreshView.setRotation(mRotateAngle -= offSet);//旋转,角度大小跟随偏移量}@Override public boolean onRelease() {boolean isOnRefresh = false;int currentHeight = mHeaderView.getLayoutParams().height;// 使用 mHeaderView.getLayoutParams().height 可以防止快速快速下拉的时候图片不回弹if (currentHeight > mHeaderViewHeight) {if ((currentHeight - mHeaderViewHeight) / 2 > mRefreshShowTranslationY - mRefreshHideTranslationY && mState < STATE_REFRESHING) {setState(STATE_REFRESHING);isOnRefresh = true;}headerRest();}if (!isOnRefresh && mRefreshView.getTranslationY() != mRefreshHideTranslationY) {refreshRest();}return isOnRefresh;}public void reset() {refreshRest();mHandler.postDelayed(new Runnable() {public void run() {setState(STATE_NORMAL);}}, 500);}private void headerRest() {ValueAnimator animator = ValueAnimator.ofInt(mHeaderView.getLayoutParams().height, mHeaderViewHeight);animator.setDuration(300).start();animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Override public void onAnimationUpdate(ValueAnimator animation) {if (mHeaderView.getLayoutParams().height == mHeaderViewHeight) { // 停止动画,防止快速上划松手后动画产生抖动animation.cancel();} else {setHeaderViewHeight((Integer) animation.getAnimatedValue());}}});}private void refreshing() {mHandler.postDelayed(new Runnable() {@Override public void run() {if (mState == STATE_REFRESHING) {mRefreshView.setRotation(mRotateAngle += 8);mHandler.post(this);}}}, 50);}private void refreshRest() {ValueAnimator animator = ValueAnimator.ofFloat(mRefreshView.getTranslationY(), mRefreshHideTranslationY);animator.setStartDelay(60);animator.setDuration(300).start();animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Override public void onAnimationUpdate(ValueAnimator animation) {if (mRefreshView.getTranslationY() == mRefreshHideTranslationY) {animation.cancel();} else {mRefreshView.setTranslationY((Float) animation.getAnimatedValue());}}});}}
微信朋友圈头部刷新效果实现
实现步骤和QQ空间效果实现类似,不同点主要在布局和操控的View,QQ空间效果主要操控头图的高度,而朋友圈效果主要操作Header本身的高度。贴代码看下区别:
首先是朋友圈头部布局moments_header.xml:
<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="/apk/res/android"android:layout_width="match_parent"android:layout_height="wrap_content"android:clickable="false"><View android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginBottom="200dp" android:background="#ff000000"/><ImageView android:id="@+id/iv_header" android:layout_width="match_parent" android:layout_height="300dp" android:layout_gravity="bottom"android:layout_marginTop="-100dp" android:layout_marginBottom="40dp" android:scaleType="centerCrop" android:src="@drawable/img_header1"/><ImageView android:id="@+id/iv_refresh" android:layout_width="30dp" android:layout_height="30dp" android:layout_marginStart="30dp"android:src="@drawable/refresh"/><ImageView android:id="@+id/iv_icon" android:layout_width="80dp" android:layout_height="80dp" android:layout_gravity="end|bottom"android:layout_marginEnd="20dp" android:src="@drawable/img_avatar1"/></FrameLayout>
注意:朋友圈这里让头图显示在布局外android:layout_marginTop="-100dp"
,头图大小不会在代码里改变。
朋友圈效果的Header代码:
public class MomentsRefreshHeader extends FrameLayout implements IRefreshHeader {private int mHeaderViewHeight; // 头部高度private ImageView mRefreshView; // 旋转刷新的图片private float mRefreshHideTranslationY; // 刷新图片上移的最大距离private float mRefreshShowTranslationY; // 刷新图片下拉的最大移动距离private float mRotateAngle; // 旋转角度private int mState = STATE_NORMAL;private WeakHandler mHandler = new WeakHandler();public MomentsRefreshHeader(Context context) {super(context);initView();}public MomentsRefreshHeader(Context context, AttributeSet attrs) {super(context, attrs);initView();}private void initView() {LayoutParams lp = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);lp.setMargins(0, 0, 0, 0);this.setLayoutParams(lp);this.setPadding(0, 0, 0, 0);inflate(getContext(), R.layout.moments_header, this);mRefreshView = findViewById(R.id.iv_refresh);measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);mHeaderViewHeight = getMeasuredHeight();mRefreshHideTranslationY = -mRefreshView.getMeasuredHeight() - 20;mRefreshShowTranslationY = mRefreshView.getMeasuredHeight();}public void setState(int state) {if (state == mState) return;if (state == STATE_REFRESHING) { // 显示进度mRefreshView.setTranslationY(mRefreshShowTranslationY);refreshing();} else if (state == STATE_DONE) {reset();}mState = state;}@Override public void refreshComplete() {mHandler.postDelayed(new Runnable() {public void run() {setState(STATE_DONE);}}, 200);}@Override public View getHeaderView() {return this;}@Override public int getVisibleHeight() {return getHeight();}private int getHeaderViewHeight() {return getHeight();}private void setHeaderViewHeight(int height) {if (height < mHeaderViewHeight) height = mHeaderViewHeight;getLayoutParams().height = height;requestLayout();}//垂直滑动时该方法不实现@Override public int getVisibleWidth() {return 0;}@Override public void onReset() {setState(STATE_NORMAL);}@Override public void onPrepare() {setState(STATE_RELEASE_TO_REFRESH);}@Override public void onRefreshing() {setState(STATE_REFRESHING);}@Override public void onMove(float offSet, float sumOffSet) {int top = getTop();// 相对父容器recyclerview的顶部位置 负数表示向上划出父容器的距离int currentHeight = getHeaderViewHeight();int targetHeight = currentHeight - (int) offSet;if (offSet < 0 && top == 0) {setHeaderViewHeight(targetHeight);refreshTranslation(currentHeight, offSet);} else if (offSet > 0 && currentHeight > mHeaderViewHeight) {layout(getLeft(), 0, getRight(), targetHeight); //重新布局让header显示在顶端,直到不再缩小图片setHeaderViewHeight(targetHeight);refreshTranslation(currentHeight, offSet);}}/*** refreshView在刷新区间内相对位移并跟随位移速度旋转*/private void refreshTranslation(int currentHeight, float offSet) {if ((currentHeight - mHeaderViewHeight) / 2 < mRefreshShowTranslationY - mRefreshHideTranslationY) { // 判断是否在非刷新区间float translationY = mRefreshView.getTranslationY() - offSet / 2; // 布局高度增加offset 相当于距离上边距offSet / 2if (translationY > mRefreshShowTranslationY) {translationY = mRefreshShowTranslationY;} else if (translationY < mRefreshHideTranslationY) {translationY = mRefreshHideTranslationY;}if (Math.abs(translationY) != mRefreshView.getTranslationY()) {mRefreshView.setTranslationY(translationY);}}mRefreshView.setRotation(mRotateAngle -= offSet);//旋转,角度大小跟随偏移量}@Override public boolean onRelease() {boolean isOnRefresh = false;int currentHeight = getLayoutParams().height;// 使用 mHeaderView.getLayoutParams().height 可以防止快速快速下拉的时候图片不回弹if (currentHeight > mHeaderViewHeight) {if ((currentHeight - mHeaderViewHeight) / 2 > mRefreshShowTranslationY - mRefreshHideTranslationY && mState < STATE_REFRESHING) {setState(STATE_REFRESHING);isOnRefresh = true;}headerRest();}if (!isOnRefresh && mRefreshView.getTranslationY() != mRefreshHideTranslationY) {refreshRest();}return isOnRefresh;}public void reset() {refreshRest();mHandler.postDelayed(new Runnable() {public void run() {setState(STATE_NORMAL);}}, 500);}private void headerRest() {ValueAnimator animator = ValueAnimator.ofInt(getLayoutParams().height, mHeaderViewHeight);animator.setDuration(300).start();animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Override public void onAnimationUpdate(ValueAnimator animation) {if (getLayoutParams().height == mHeaderViewHeight) { // 停止动画,防止快速上划松手后动画产生抖动animation.cancel();} else {setHeaderViewHeight((Integer) animation.getAnimatedValue());}}});}private void refreshing() {mHandler.postDelayed(new Runnable() {@Override public void run() {if (mState == STATE_REFRESHING) {mRefreshView.setRotation(mRotateAngle += 8);mHandler.post(this);}}}, 50);}private void refreshRest() {ValueAnimator animator = ValueAnimator.ofFloat(mRefreshView.getTranslationY(), mRefreshHideTranslationY);animator.setStartDelay(60);animator.setDuration(300).start();animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Override public void onAnimationUpdate(ValueAnimator animation) {if (mRefreshView.getTranslationY() == mRefreshHideTranslationY) {animation.cancel();} else {mRefreshView.setTranslationY((Float) animation.getAnimatedValue());}}});}}
再放两张内容不足一屏的效果:
Demo地址
补充:该效果已提交到LRecyclerView
并被采纳 Pull Requests