一、必备基础
二、入门
三、进阶
四、优化
上一篇文章介绍了 事件分发机制 和 滑动冲突的解决方案,本篇文章开启自定义下拉刷新之旅。首先,我们看效果图。
在自定义下拉刷新时,我们通过使用Scroller 来滑动布局。接下来,我们先了解Scroller的使用。
Scroller
这篇文章郭霖 完全解析Scroller,详细地介绍了Scroller。
使用Scroller的步骤非常简单:
创建Scroller的实例 调用startScroll()方法来初始化滚动数据并刷新界面 重写computeScroll()方法,并在其内部完成平滑滚动的逻辑
同时我们要注意到ScrollTo 表示滚动到指定位置,ScrollBy表示每次滚动一段距离。
我们自定义一个ScrollerLayout来模拟ViewPager的滑动切换效果。
布局如下,在ScrollerLayout中嵌套了三个Button
<?xml version="1.0" encoding="utf-8"?><.myapplication.view.ScrollerLayout xmlns:android="/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"><Button android:layout_width="match_parent"android:layout_height="100dp"android:text="This is first child view" /><Button android:layout_width="match_parent"android:layout_height="100dp"android:text="This is second child view" /><Button android:layout_width="match_parent"android:layout_height="100dp"android:text="This is third child view" /></.myapplication.view.ScrollerLayout>
由于Button是可点击的,它会消费点击事件,导致ScrollerLayout 不能调用OnTouchEvent。根据上篇文章介绍的 事件拦截机制和滑动冲突解决方案,我们必须在自定义ScrollerLayout添加拦截事件,即在ScrollerLayout滑动时进行拦截
@Overridepublic boolean onInterceptTouchEvent(MotionEvent ev) {int x = (int) ev.getRawX();switch (ev.getAction()) {case MotionEvent.ACTION_DOWN:mLastX = x;break;case MotionEvent.ACTION_MOVE:int deltaX = x - mLastX;//拦截横向滑动事件if (Math.abs(deltaX) > mTouchSlop) {return true;}break;case MotionEvent.ACTION_UP:break;}return false;}
左边界
当我们处于第一个界面,向右滑动
很明显
scrolledX = LastX - X < 0
getScroller()+ScrolledX < 0
if (getScrollX() + scrolledX < mLeftBorder) { //左边界scrollTo(mLeftBorder, 0);return true;}
右边界
当我们处于最后一个界面,向左滑动
scrolledX = LastX - X > 0
我们要控制
getScroller + scrolledX + getWidth > rightBorder
if (getScrollX() + getWidth() + scrolledX > mRightBorder) {// 右边界scrollTo(mRightBorder - getWidth(), 0);return true;}
完整代码
public class ScrollerLayout extends ViewGroup {private static String TAG = "ScrollerLayout";private Scroller mScroller;private int mTouchSlop;//最小的滑动距离private int mLeftBorder;private int mRightBorder;public ScrollerLayout(Context context, AttributeSet attrs) {super(context, attrs);mScroller = new Scroller(context);mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();Log.d(TAG, "最小滑动距离: TouchSlop " + mTouchSlop);}@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);int childCount = getChildCount();for (int i = 0; i < childCount; i++) {View child = getChildAt(i);measureChild(child, widthMeasureSpec, heightMeasureSpec);//测量子View}}@Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) {if (changed) {int childCount = getChildCount();for (int i = 0; i < childCount; i++) {View child = getChildAt(i);child.layout(i * child.getMeasuredWidth(), 0, (i + 1) * child.getMeasuredWidth(), child.getMeasuredHeight());}//初始化左右边界的值mLeftBorder = getChildAt(0).getLeft();mRightBorder = getChildAt(childCount - 1).getRight();}}private int mLastX;@Overridepublic boolean onInterceptTouchEvent(MotionEvent ev) {int x = (int) ev.getRawX();switch (ev.getAction()) {case MotionEvent.ACTION_DOWN:mLastX = x;break;case MotionEvent.ACTION_MOVE:int deltaX = x - mLastX;//拦截横向滑动事件if (Math.abs(deltaX) > mTouchSlop) {return true;}break;case MotionEvent.ACTION_UP:break;}return false;}@Overridepublic boolean onTouchEvent(MotionEvent event) {int x = (int) event.getRawX();switch (event.getAction()) {case MotionEvent.ACTION_MOVE:int scrolledX = mLastX - x;//mLast是拦截时 ACTION_DOWN 的值,向右滑动时为负数,向左滑动为正数if (getScrollX() + scrolledX < mLeftBorder) { //左边界scrollTo(mLeftBorder, 0);return true;} else if (getScrollX() + getWidth() + scrolledX > mRightBorder) {// 右边界scrollTo(mRightBorder - getWidth(), 0);return true;}scrollBy(scrolledX, 0);mLastX = x;break;case MotionEvent.ACTION_UP://根据当前滚动值来判定哪个子控件的界面int targetIndex = (getScrollX() + getWidth() / 2) / getWidth(); //滑动到屏幕的1/2进行切换int dx = targetIndex * getWidth() - getScrollX();mScroller.startScroll(getScrollX(), 0, dx, 0);invalidate();//刷新break;}return super.onTouchEvent(event);}@Overridepublic void computeScroll() {if (puteScrollOffset()) {scrollTo(mScroller.getCurrX(), mScroller.getCurrY());invalidate();}}}
getScrollerX/Y
使用Scroller的过程中反复地使用到了getScrollerX()/getScrollerY()
下面我们以getScrollerY为例进行解释,getScrollerY获取的到底是什么值。
/*** Return the scrolled top position of this view. This is the top edge of* the displayed part of your view. You do not need to draw any pixels above* it, since those are outside of the frame of your view on screen.** @return The top edge of the displayed part of your view, in pixels.*/public final int getScrollY() {return mScrollY;}
源码中给了其中的解释,意思是 View顶部和显示界面的距离。下面我们通过一张图片更直观地理解到底getScrollY获取的是什么值。
当上滑时,超出屏幕的距离就是 getScroller的值,为正数
当下滑时,超出屏幕的距离也是 getScroller的值,为负数
那么Scroller.startScroll(0, getScrollerY(), 0, dy),这里 dy 起到的作用是什么呢?
通过这句代码,我们实现的操作是
getScrollerY+dy
假设 getScrollerY 值为 200, dy的值为200 ,执行这句代码后我们的变化如下:
注意
执行Scroller.startScroll(0, getScrollerY(), 0, dy)后要 调用invalidate进行刷新。
computeScroll()
这个函数的作用是什么呢?为什么要重写? 实际上它才是决定 我们调用Scroller.startScroll(0, getScrollerY(), 0, dy) 实现滑动的决定因素。
@Overridepublic void computeScroll() {if (puteScrollOffset()) {scrollTo(0, mScroller.getCurrY());invalidate();}}
注意这行代码,
scrollTo(0, mScroller.getCurrY());
mScroller.getCurrY 的值为前面我们计算的getScrollerY+dy 值。 scrollTo()表示移动到指定位置。 所以当我们使用Scroller.startScroll() 后会自动调用computeScroll() 来实现我们的滑动效果。
因此,我们在使用Scroller的时候要重写computeScroll(),在使用后一定要记得 invalid 进行重绘
自定义简单的下拉刷新组件
思路
初始化时,我们的屏幕显示的是 带颜色的这块内容。当我们向下滑动的时候显示头部内容,向上滑动时显示底部内容。
所以在自定的 SimpleRefreshLayout时,我们动态添加了头部和底部。
public SimpleRefreshLayout(Context context, AttributeSet attrs) {super(context, attrs);mHeader = LayoutInflater.from(context).inflate(R.layout.item_header_layout, null);pullText = mHeader.findViewById(R.id.srl_tv_pull_down);mFooter = LayoutInflater.from(context).inflate(R.layout.item_footer_layout, null);mLayoutScroller = new Scroller(context);}@Overrideprotected void onFinishInflate() {super.onFinishInflate();RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);mHeader.setLayoutParams(params);mFooter.setLayoutParams(params);addView(mHeader);addView(mFooter);}
注意我们添加头部和顶部是在 onFinishInflate()这个函数中。
onFinishInflate()何时调用?为什么要用onFinishInflate()?
在我们使用View.inflate(context,R.layout.view_layout,null); View中的所有控件被映射成xml,在加载完成xml后,就会执行这个方法。也就是初始化布局后执行。
在此处使用OnFinishInflate() 是为了保证 头部和底部 布局已经被初始化后再添加到 SimplerRefreshLayout中。
测量
重写OnMeasure 来测量子类
@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);//测量子类for (int i = 0; i < getChildCount(); i++) {View child = getChildAt(i);measureChild(child, widthMeasureSpec, heightMeasureSpec);}}
布局
为了在初始状态只显示我们的 内容界面,
header的位置为 (0,-height,getWidth,0)
footer的位置为 (0,getHeight,getWidth, getHeight+height)
//布局@Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) {mLayoutContentHeight = 0;for (int i = 0; i < getChildCount(); i++) {View child = getChildAt(i);if (child == mHeader) {child.layout(0, 0 - child.getMeasuredHeight(), child.getMeasuredWidth(), 0);} else if (child == mFooter) {child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), child.getMeasuredHeight() + mLayoutContentHeight);} else {//内容child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());mLayoutContentHeight += child.getMeasuredHeight();}}}
滑动
根据滑动的方向,我们来 切换滑动效果
int dy = mLastMoveY - y;
向下滑动时,dy < 0;
向上滑动时,dy > 0;
为了控制顶部最多只能滑动到头部高度的一半 我们使用了下面判断
if (Math.abs(getScrollY()) <= mHeader.getMeasuredHeight() / 2) {scrollBy(0, dy);}
我们还可以设置 有效距离effectiveScrollY,当未超过effectiveScrollY, 不显示头部,这个操作主要是在ACTION_UP做处理:
if (Math.abs(getScrollY()) >= effectiveScrollY) {mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY() - effectiveScrollY); //显示一部分头部invalidate();} else {mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY()); //回到原来位置invalidate();}
设置回调监听
public interface onRefreshListener {void onRefresh();void onBottomRefresh();}public void setRefreshListener(onRefreshListener listener) {mRefreshListener = listener;}
完整代码
public class SimpleRefreshLayout extends ViewGroup {private View mHeader;private View mFooter;private TextView pullText;private onRefreshListener mRefreshListener;private int mLastMoveY;private int effectiveScrollY = 100;private Scroller mLayoutScroller;private boolean isPullDown = false;private int mLayoutContentHeight;public SimpleRefreshLayout(Context context, AttributeSet attrs) {super(context, attrs);mHeader = LayoutInflater.from(context).inflate(R.layout.item_header_layout, null);pullText = mHeader.findViewById(R.id.srl_tv_pull_down);mFooter = LayoutInflater.from(context).inflate(R.layout.item_footer_layout, null);mLayoutScroller = new Scroller(context);}@Overrideprotected void onFinishInflate() {super.onFinishInflate();RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);mHeader.setLayoutParams(params);mFooter.setLayoutParams(params);addView(mHeader);addView(mFooter);}@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);//测量子类for (int i = 0; i < getChildCount(); i++) {View child = getChildAt(i);measureChild(child, widthMeasureSpec, heightMeasureSpec);}}//布局@Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) {mLayoutContentHeight = 0;for (int i = 0; i < getChildCount(); i++) {View child = getChildAt(i);if (child == mHeader) {child.layout(0, 0 - child.getMeasuredHeight(), child.getMeasuredWidth(), 0);} else if (child == mFooter) {child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), child.getMeasuredHeight() + mLayoutContentHeight);} else {//内容child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());mLayoutContentHeight += child.getMeasuredHeight();}}}@Overridepublic boolean onTouchEvent(MotionEvent event) {int y = (int) event.getY();switch (event.getAction()) {case MotionEvent.ACTION_DOWN:mLastMoveY = y;break;case MotionEvent.ACTION_MOVE:int dy = mLastMoveY - y;if (dy < 0) {//下拉isPullDown = true;if (Math.abs(getScrollY()) <= mHeader.getMeasuredHeight() / 2) {scrollBy(0, dy);if (Math.abs(getScrollY()) >= effectiveScrollY) {pullText.setText("松开刷新");}}} else {//上滑if (Math.abs(getScrollY()) + Math.abs(dy) < mFooter.getMeasuredHeight() / 2) {scrollBy(0, dy);isPullDown = false;}}break;case MotionEvent.ACTION_UP:if (isPullDown) {if (Math.abs(getScrollY()) >= effectiveScrollY) {if (mRefreshListener != null) {mRefreshListener.onRefresh();}mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY() - effectiveScrollY);invalidate();} else {mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());invalidate();}} else {if (Math.abs(getScrollY()) >= effectiveScrollY) {if (mRefreshListener != null) {mRefreshListener.onBottomRefresh();}mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY() + effectiveScrollY);invalidate();} else {mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());invalidate();}}break;}mLastMoveY = y;return true;}@Overridepublic void computeScroll() {puteScroll();if (puteScrollOffset()) {scrollTo(0, mLayoutScroller.getCurrY());}invalidate();}public void stopRefresh() {mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());invalidate();}public interface onRefreshListener {void onRefresh();void onBottomRefresh();}public void setRefreshListener(onRefreshListener listener) {mRefreshListener = listener;}}
头部布局item_header_layout
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"android:paddingBottom="5dp"><TextView android:id="@+id/srl_tv_pull_down"android:layout_width="wrap_content"android:layout_height="@dimen/srl_pull_tv_height"android:layout_alignParentBottom="true"android:layout_centerHorizontal="true"android:drawableLeft="@drawable/srl_arrow_down"android:gravity="center_vertical"android:text="@string/srl_keep_pull_down"android:textColor="@color/srl_text_color"android:textSize="@dimen/srl_text_size" /></RelativeLayout>
底部布局item_footer_layout
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="/apk/res/android"android:layout_width="match_parent"android:layout_height="match_parent"android:background="@color/srl_pull_background"android:paddingTop="@dimen/srl_footer_padding_top"><RelativeLayout android:layout_centerHorizontal="true"android:layout_width="wrap_content"android:layout_height="wrap_content"><ProgressBar android:id="@+id/bottom_progress"android:layout_width="30dp"android:layout_height="30dp"/><TextView android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_centerHorizontal="true"android:layout_toRightOf="@id/bottom_progress"android:text="加载更多"android:textSize="18sp" /></RelativeLayout></RelativeLayout>
主布局activity_refresh_layout
<?xml version="1.0" encoding="utf-8"?><.myapplication.view.SimpleRefreshLayout xmlns:android="/apk/res/android"android:id="@+id/refresh_layout"android:layout_width="match_parent"android:layout_height="match_parent"><ImageViewandroid:id="@+id/image"android:layout_width="match_parent"android:layout_height="match_parent"android:background="@drawable/f" /></.myapplication.view.SimpleRefreshLayout>
主代码
public class RefreshActivity extends AppCompatActivity {private SimpleRefreshLayout simpleRefreshLayout;private ImageView imageView;private Handler mHandler = new Handler();@Overrideprotected void onCreate(@Nullable Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_refresh_layout);simpleRefreshLayout = findViewById(R.id.refresh_layout);imageView = findViewById(R.id.image);simpleRefreshLayout.setRefreshListener(new SimpleRefreshLayout.onRefreshListener() {@Overridepublic void onRefresh() {mHandler.postDelayed(new Runnable() {@Overridepublic void run() {simpleRefreshLayout.stopRefresh();imageView.setBackgroundResource(R.drawable.d);}}, 2000);}@Overridepublic void onBottomRefresh() {mHandler.postDelayed(new Runnable() {@Overridepublic void run() {simpleRefreshLayout.stopRefresh();}}, 2000);}});}
本篇给出了自定义下拉刷新组件的思路,并给出了非常简单的小例子。实现的只是简单的 全屏图片时,下拉刷新上拉加载的效果。下一篇,我们将会 加入 嵌套布局时,滑动冲突判断,实现更加有意义的下拉刷新组件。
参考文章:
自定义下拉刷新组件