简介 官方文档
SwipeRefreshLayout
是一个下拉刷新控件,几乎可以包裹一个任何可以滚动的内容(ListView GridView ScrollView RecyclerView),可以自动识别垂直滚动手势。使用起来非常方便。
1.将需要下拉刷新的空间包裹起来
1 2 3 4 5 6 7 8 9 10 <android.support.v4.widget.SwipeRefreshLayout android:layout_width ="match_parent" android:layout_height ="match_parent" > <android.support.v7.widget.RecyclerView android:id ="@+id/recyclerView" android:layout_width ="match_parent" android:layout_height ="match_parent" /> </android.support.v4.widget.SwipeRefreshLayout >
2.设置刷新动画的触发回调
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 mySwipeRefreshLayout.setProgressViewOffset(true , 50 , 200 ); mySwipeRefreshLayout.setSize(SwipeRefreshLayout.LARGE); mySwipeRefreshLayout.setColorSchemeResources( android.R.color.holo_blue_bright, android.R.color.holo_green_light, android.R.color.holo_orange_light, android.R.color.holo_red_light); mySwipeRefreshLayout.setEnabled(false ); mSwipeLayout.setProgressBackgroundColor(R.color.red); mySwipeRefreshLayout.setOnRefreshListener( new SwipeRefreshLayout .OnRefreshListener() { @Override public void onRefresh () { } } );
通过 setRefreshing(false)
和 setRefreshing(true)
来手动调用刷新的动画。
onRefresh
的回调只有在手势下拉的情况下才会触发,通过 setRefreshing
只能调用刷新的动画是否显示。 SwipeRefreshLayout 也可放在 CoordinatorLayout 内共同处理滑动冲突,有兴趣可以尝试。
SwipeRefreshLayout 源码分析
本文基于 v4 版本 23.2.0
extends ViewGroup
implements NestedScrollingParent
NestedScrollingChild
1 2 3 4 java.lang.Object ↳ android.view.View ↳ android.view.ViewGroup ↳ android.support.v4.widget.SwipeRefreshLayout
SwipeRefreshLayout 的分析分为两个部分:自定义 ViewGroup 的部分 ,处理和子视图的嵌套滚动部分 。
SwipeRefreshLayout extends ViewGroup 其实就是一个自定义的 ViewGroup ,结合我们自己平时自定义 ViewGroup 的步骤:
初始化变量
onMeasure
onLayout
处理交互 (dispatchTouchEvent
onInterceptTouchEvent
onTouchEvent
)
接下来就按照上面的步骤进行分析。
1.初始化变量 SwipeRefreshLayout
内部有 2 个 View,一个圆圈(mCircleView)
,一个内部可滚动的 View(mTarget)
。除了 View,还包含一个 OnRefreshListener
接口,当刷新动画被触发时回调。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public SwipeRefreshLayout (Context context, AttributeSet attrs) { super (context, attrs); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); mMediumAnimationDuration = getResources().getInteger( android.R.integer.config_mediumAnimTime); setWillNotDraw(false ); mDecelerateInterpolator = new DecelerateInterpolator (DECELERATE_INTERPOLATION_FACTOR); final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); setEnabled(a.getBoolean(0 , true )); a.recycle(); final DisplayMetrics metrics = getResources().getDisplayMetrics(); mCircleWidth = (int ) (CIRCLE_DIAMETER * metrics.density); mCircleHeight = (int ) (CIRCLE_DIAMETER * metrics.density); createProgressView(); ViewCompat.setChildrenDrawingOrderEnabled(this , true ); mSpinnerFinalOffset = DEFAULT_CIRCLE_TARGET * metrics.density; mTotalDragDistance = mSpinnerFinalOffset; mNestedScrollingParentHelper = new NestedScrollingParentHelper (this ); mNestedScrollingChildHelper = new NestedScrollingChildHelper (this ); setNestedScrollingEnabled(true ); }
// 创建刷新动画的圆圈
1 2 3 4 5 6 7 8 private void createProgressView () { mCircleView = new CircleImageView (getContext(), CIRCLE_BG_LIGHT, CIRCLE_DIAMETER/2 ); mProgress = new MaterialProgressDrawable (getContext(), this ); mProgress.setBackgroundColor(CIRCLE_BG_LIGHT); mCircleView.setImageDrawable(mProgress); mCircleView.setVisibility(View.GONE); addView(mCircleView); }
初始化的时候创建一个出来一个 View (下拉刷新的圆圈)。可以看出使用背景圆圈是 v4 包里提供的 CircleImageView
控件,中间的是 MaterialProgressDrawable
进度条。 另一个 View 是在 xml 中包含的可滚动视图。
2.onMeasure onMeasure 确定子视图的大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @Override public void onMeasure (int widthMeasureSpec, int heightMeasureSpec) { super .onMeasure(widthMeasureSpec, heightMeasureSpec); if (mTarget == null ) { ensureTarget(); } if (mTarget == null ) { return ; } mTarget.measure(MeasureSpec.makeMeasureSpec( getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(mCircleHeight, MeasureSpec.EXACTLY)); if (!mUsingCustomStart && !mOriginalOffsetCalculated) { mOriginalOffsetCalculated = true ; mCurrentTargetOffsetTop = mOriginalOffsetTop = -mCircleView.getMeasuredHeight(); } mCircleViewIndex = -1 ; for (int index = 0 ; index < getChildCount(); index++) { if (getChildAt(index) == mCircleView) { mCircleViewIndex = index; break ; } } }
这个步骤确定了 mCircleView 和 SwipeRefreshLayout 的子视图的大小。
3.onLayout onLayout 主要负责确定各个子视图的位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Override protected void onLayout (boolean changed, int left, int top, int right, int bottom) { final int width = getMeasuredWidth(); final int height = getMeasuredHeight(); if (getChildCount() == 0 ) { return ; } if (mTarget == null ) { ensureTarget(); } if (mTarget == null ) { return ; } final View child = mTarget; final int childLeft = getPaddingLeft(); final int childTop = getPaddingTop(); final int childWidth = width - getPaddingLeft() - getPaddingRight(); final int childHeight = height - getPaddingTop() - getPaddingBottom(); child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); int circleWidth = mCircleView.getMeasuredWidth(); int circleHeight = mCircleView.getMeasuredHeight(); mCircleView.layout((width / 2 - circleWidth / 2 ), mCurrentTargetOffsetTop, (width / 2 + circleWidth / 2 ), mCurrentTargetOffsetTop + circleHeight); }
在 onLayout 中放置了 mCircleView 的位置,注意 顶部位置是 mCurrentTargetOffsetTop ,mCurrentTargetOffsetTop 初始距离是-mCircleView.getMeasuredHeight()
,所以是在 SwipeRefreshLayout 外。
经过以上几个步骤,SwipeRefreshLayout 创建了子视图,确定他们的大小、位置,现在所有视图可以显示在界面了。
处理与子视图的滚动交互 下拉刷新控件的主要功能是当子视图下拉到最顶部时,继续下拉可以出现刷新动画。而子视图可以滚动时需要将所有滚动事件都交给子视图。借助 Android 提供的 NestedScrolling 机制,使得 SwipeRefreshLayout 很轻松的解决了与子视图的滚动冲突问题。 SwipeRefreshLayout 通过实现 NestedScrollingParent
和 NestedScrollingChild
接口来处理滚动冲突。SwipeRefreshLayout 作为 Parent 嵌套一个可以滚动的子视图,那么就需要了解一下 NestedScrollingParent 接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 public interface NestedScrollingParent { public boolean onStartNestedScroll (View child, View target, int nestedScrollAxes) ; public void onNestedScrollAccepted (View child, View target, int nestedScrollAxes) ; public void onNestedPreScroll (View target, int dx, int dy, int [] consumed) ; public void onNestedScroll (View target, int dxConsumed, int dyConsumed,int dxUnconsumed, int dyUnconsumed) ; public void onStopNestedScroll (View target) ; public boolean onNestedPreFling (View target, float velocityX, float velocityY) ; public boolean onNestedFling (View target, float velocityX, float velocityY, boolean consumed) ; public int getNestedScrollAxes () ; }
看一下 SwipeRefreshLayout 实现 NestedScrollingParent 的相关方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Override public boolean onStartNestedScroll (View child, View target, int nestedScrollAxes) { return isEnabled() && !mReturningToStart && !mRefreshing && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0 ; } @Override public void onNestedScrollAccepted (View child, View target, int axes) { mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes); }
SwipeRefreshLayout 只接受竖直方向(Y轴)的滚动,并且在刷新动画进行中不接受滚动。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @Override public void onNestedPreScroll (View target, int dx, int dy, int [] consumed) { if (dy > 0 && mTotalUnconsumed > 0 ) { if (dy > mTotalUnconsumed) { consumed[1 ] = dy - (int ) mTotalUnconsumed; mTotalUnconsumed = 0 ; } else { mTotalUnconsumed -= dy; consumed[1 ] = dy; } moveSpinner(mTotalUnconsumed); } } @Override public void onNestedScroll (final View target, final int dxConsumed, final int dyConsumed, final int dxUnconsumed, final int dyUnconsumed) { final int dy = dyUnconsumed + mParentOffsetInWindow[1 ]; if (dy < 0 && !canChildScrollUp()) { mTotalUnconsumed += Math.abs(dy); moveSpinner(mTotalUnconsumed); } }
SwipeRefreshLayout 通过 NestedScrollingParent 接口完成了处理子视图的滚动的冲突,中间省略了一些 SwipeRefreshLayout作为 child 的相关代码,这种情况是为了兼容将 SwipeRefreshLayout 作为子视图放在知识嵌套滚动的父布局的情况,这里不做深入讨论。但是下拉刷新需要判断手指在屏幕的状态来进行一个刷新的动画,所以我们还需要处理触摸事件,判断手指在屏幕中的状态。
首先是 onInterceptTouchEvent,返回 true 表示拦截触摸事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 @Override public boolean onInterceptTouchEvent (MotionEvent ev) { ensureTarget(); final int action = MotionEventCompat.getActionMasked(ev); if (mReturningToStart && action == MotionEvent.ACTION_DOWN) { mReturningToStart = false ; } if (!isEnabled() || mReturningToStart || canChildScrollUp() || mRefreshing || mNestedScrollInProgress) { return false ; } switch (action) { case MotionEvent.ACTION_DOWN: setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true ); mActivePointerId = MotionEventCompat.getPointerId(ev, 0 ); mIsBeingDragged = false ; final float initialDownY = getMotionEventY(ev, mActivePointerId); if (initialDownY == -1 ) { return false ; } mInitialDownY = initialDownY; break ; case MotionEvent.ACTION_MOVE: if (mActivePointerId == INVALID_POINTER) { Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id." ); return false ; } final float y = getMotionEventY(ev, mActivePointerId); if (y == -1 ) { return false ; } final float yDiff = y - mInitialDownY; if (yDiff > mTouchSlop && !mIsBeingDragged) { mInitialMotionY = mInitialDownY + mTouchSlop; mIsBeingDragged = true ; mProgress.setAlpha(STARTING_PROGRESS_ALPHA); } break ; case MotionEventCompat.ACTION_POINTER_UP: onSecondaryPointerUp(ev); break ; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: mIsBeingDragged = false ; mActivePointerId = INVALID_POINTER; break ; } return mIsBeingDragged; }
可以看到源码也就是进行简单处理,DOWN 的时候记录一下位置,MOVE 时判断移动的距离,返回值 mIsBeingDragged 为 true 时, 即 onInterceptTouchEvent 返回true,SwipeRefreshLayout 拦截触摸事件,不分发给 mTarget,然后把 MotionEvent 传给 onTouchEvent 方法。其中有一个判断子View的是否还可以滚动的方法 canChildScrollUp
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public boolean canChildScrollUp () { if (android.os.Build.VERSION.SDK_INT < 14 ) { if (mTarget instanceof AbsListView) { final AbsListView absListView = (AbsListView) mTarget; return absListView.getChildCount() > 0 && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0 ) .getTop() < absListView.getPaddingTop()); } else { return ViewCompat.canScrollVertically(mTarget, -1 ) || mTarget.getScrollY() > 0 ; } } else { return ViewCompat.canScrollVertically(mTarget, -1 ); } }
当SwipeRefreshLayout 拦截了触摸事件之后( mIsBeingDragged 为 true ),将 MotionEvent 交给 onTouchEvent 处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 @Override public boolean onTouchEvent (MotionEvent ev) { switch (action) { case MotionEvent.ACTION_DOWN: mActivePointerId = MotionEventCompat.getPointerId(ev, 0 ); mIsBeingDragged = false ; break ; case MotionEvent.ACTION_MOVE: { pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); final float y = MotionEventCompat.getY(ev, pointerIndex); final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; if (mIsBeingDragged) { if (overscrollTop > 0 ) { moveSpinner(overscrollTop); } else { return false ; } } break ; } case MotionEvent.ACTION_UP: { pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); if (pointerIndex < 0 ) { Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id." ); return false ; } final float y = MotionEventCompat.getY(ev, pointerIndex); final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; mIsBeingDragged = false ; finishSpinner(overscrollTop); mActivePointerId = INVALID_POINTER; return false ; } } return true ; }
在手指滚动过程中通过判断 mIsBeingDragged 来移动刷新的圆圈(对应的是 moveSpinner ),手指松开将圆圈移动到正确位置(初始位置或者刷新动画的位置,对应的是 finishSpinner 方法)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 private void moveSpinner (float overscrollTop) { mProgress.showArrow(true ); float originalDragPercent = overscrollTop / mTotalDragDistance; float dragPercent = Math.min(1f , Math.abs(originalDragPercent)); float adjustedPercent = (float ) Math.max(dragPercent - .4 , 0 ) * 5 / 3 ; float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; float slingshotDist = mUsingCustomStart ? mSpinnerFinalOffset - mOriginalOffsetTop : mSpinnerFinalOffset; float tensionSlingshotPercent = Math.max(0 , Math.min(extraOS, slingshotDist * 2 ) / slingshotDist); float tensionPercent = (float ) ((tensionSlingshotPercent / 4 ) - Math.pow( (tensionSlingshotPercent / 4 ), 2 )) * 2f ; float extraMove = (slingshotDist) * tensionPercent * 2 ; int targetY = mOriginalOffsetTop + (int ) ((slingshotDist * dragPercent) + extraMove); if (mCircleView.getVisibility() != View.VISIBLE) { mCircleView.setVisibility(View.VISIBLE); } if (!mScale) { ViewCompat.setScaleX(mCircleView, 1f ); ViewCompat.setScaleY(mCircleView, 1f ); } if (mScale) { setAnimationProgress(Math.min(1f , overscrollTop / mTotalDragDistance)); } if (overscrollTop < mTotalDragDistance) { if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA && !isAnimationRunning(mAlphaStartAnimation)) { startProgressAlphaStartAnimation(); } } else { if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) { startProgressAlphaMaxAnimation(); } } float strokeStart = adjustedPercent * .8f ; mProgress.setStartEndTrim(0f , Math.min(MAX_PROGRESS_ANGLE, strokeStart)); mProgress.setArrowScale(Math.min(1f , adjustedPercent)); float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2 ) * .5f ; mProgress.setProgressRotation(rotation); setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop, true ); }
刷新圆圈的移动过程也是有好几种状态,看上面的注释基本上就比较清楚了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private void finishSpinner (float overscrollTop) { if (overscrollTop > mTotalDragDistance) { setRefreshing(true , true ); } else { mRefreshing = false ; mProgress.setStartEndTrim(0f , 0f ); animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener); mProgress.showArrow(false ) } }
可以看到调用 setRefresh(true,true) 方法触发刷新动画并进行回调,但是这个方法是 private 的。前面提到我们自己调用 setRefresh(true) 只能产生动画,而不能回调刷新函数,那么我们就可以用反射调用 2 个参数的 setRefresh 函数。 或者手动调 setRefreshing(true)+ OnRefreshListener.onRefresh 方法。
setRefresh 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public void setRefreshing (boolean refreshing) { if (refreshing && mRefreshing != refreshing) { mRefreshing = refreshing; int endTarget = 0 ; if (!mUsingCustomStart) { endTarget = (int ) (mSpinnerFinalOffset + mOriginalOffsetTop); } else { endTarget = (int ) mSpinnerFinalOffset; } setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop, true ); mNotify = false ; startScaleUpAnimation(mRefreshListener); } else { setRefreshing(refreshing, false ); } }
startScaleUpAnimation 开启一个动画,然后在动画结束后回调 onRefresh 方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private Animation.AnimationListener mRefreshListener = new Animation .AnimationListener() { @Override public void onAnimationEnd (Animation animation) { if (mRefreshing) { mProgress.setAlpha(MAX_ALPHA); mProgress.start(); if (mNotify) { if (mListener != null ) { mListener.onRefresh(); } } mCurrentTargetOffsetTop = mCircleView.getTop(); } else { reset(); } } };
总结 分析 SwipeRefreshLayout 的流程就是按照平时我们自定义 ViewGroup
的流程,但是其中也有好多需要我们借鉴的地方,如何使用 NestedScrolling相关机制 ,多点触控的处理,onMeasure 中减去了 padding,如何判断子 View 是否可滚动,如何确定 ViewGroup 中某一个 View 的索引等。 此外,一个好的下拉刷新框架不仅仅要兼容各种滚动的子控件,还要考虑自己要兼容 NestedScrollingChild 的情况,比如放到 CooCoordinatorLayout 的情况,目前大多数开源的下拉刷新好像都没有达到这个要求,一般都是只考虑了内部嵌套滚动子视图的情况,没有考虑自己作为滚动子视图的情况。
文章来自: https://hanks.pub