标签: Android
前言
学习Android的时候,总是对自定义View心心念念,但作为一个小白,奈何实力有限。最近又学习了一些与View知识体系相关的事情,心血来潮,想自己自定义一个下拉刷新的控件,也当做对近日理论知识的学习做一次实践,但是限于实力这里只做主体功能。
计划
第一步:我们都知道Android原生有一套下拉刷新的控件(SwipeRefreshLayout),它继承自ViewGroup,所以我们自定义的控件继承自ViewGroup。
第二步:我们知道ViewGroup的onLayout()方法是需要自己覆写的,同样也考虑测量的问题,我们覆写onMeasure()方法和onLayout()完成自定义控件及其子控件的测量和定位
第三步:这里假设我们的子View是个RecyclerView,所以要考虑滑动冲突,覆写onIntercepetEvent()处理滑动冲突
第四步:我觉得加个刷新头部(Header)效果会更好,所以我们添加一个刷新头部,但是为了方(tou)便(lan),这里只用一个TextView。
第五步:我们需要解决触控事件,所以这里我们覆写onTouchEvent()方法解决具体的触控事件。
ps:这里的步骤不一定按顺序,实力有限,有错误还请同志指点指点
创建Header的xml文件
这里我创建了一个xml文件用于写刷新的布局:header.xml。只包含一个简单的TextView
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="18dp"
android:layout_gravity="center_horizontal"
android:gravity="center_horizontal"/>
</LinearLayout>
自定义下拉刷新文件
- 这里创建RefreshWithHeader继承ViewGroup,然后创建它的类构造器,具体代码如下:
public RefreshWithHeader(Context context) {
super(context);
}
public RefreshWithHeader(Context context, AttributeSet attrs) {
this(context,attrs,0);
}
public RefreshWithHeader(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//这里创建的ViewGroup不需要绘制,调用这个方法提升性能
setWillNotDraw(true);
//填充View拿到头布局
mHeader = LayoutInflater.from(this.getContext()).inflate(R.layout.header,this,false);
mState = mHeader.findViewById(R.id.state);
mState.setText("下拉刷新");
//将头布局添加到当前View中,并设置为第一个子View
addView(mHeader,0);
touchSlop = ViewConfiguration.getTouchSlop();
mRefreshListeners = new ArrayList<>();
}
- 接下来,我们覆写ViewGroup的onMeasure()方法,重新定义测量过程:
我们自定义的控件作用是下拉刷新,所以我们在测量的时候着重点在于高度(Height)的测量。
为了简便这里没有对当前ViewGroup的padding属性做处理,也没有对子View的layout_margin做处理,但是实际项目中应该做处理。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measureWidth = 0;
int measureHeight = 0;
final int childrenCount = getChildCount();
//首先通知子View去测量自己的尺寸。
measureChildren(widthMeasureSpec,heightMeasureSpec);
// 获取自己的测量宽度
int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
// 获取自己测量宽度用的测量模式
int widthSpaceMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpaceMode = MeasureSpec.getMode(heightMeasureSpec);
if(childrenCount == 0){
//若当前ViewGroup没有子View,则设置自己宽高的默认值为0
setMeasuredDimension(0,0);
//若有子ViewGroup,且子View的宽高都是wrap_content
}else if(widthSpaceMode == MeasureSpec.AT_MOST && heightSpaceMode == MeasureSpec.AT_MOST){
//遍历子View
for(int i = 0;i<childrenCount;i++){
if(getChildAt(i)!=null) {
//我么设置当前ViewGroup的高度为所有子View的高度之和
measureHeight += getChildAt(i).getMeasuredHeight();
//我们设置当前ViewGroup的宽度为所有子View中最宽的
measureWidth = Math.max(measureWidth, getChildAt(i).getMeasuredWidth());
}
}
//这里把我们的宽度和高度设置成默认尺寸
setMeasuredDimension(measureWidth,measureHeight);
//如果只有宽度为wrap_content
}else if(widthSpaceMode == MeasureSpec.AT_MOST){
for (int i = 0;i < childrenCount;i++){
if(getChildAt(i)!=null)
measureWidth = Math.max(measureWidth,getChildAt(i).getMeasuredWidth());
}
setMeasuredDimension(measureWidth,heightSpaceSize);
//如果只有高度为wrap_content
}else if(heightSpaceMode == MeasureSpec.AT_MOST){
for(int i = 0; i<childrenCount;i++){
if(getChildAt(i)!=null)
measureHeight += getChildAt(i).getMeasuredHeight();
}
setMeasuredDimension(widthSpaceSize,measureHeight);
}
}
MeasureSpec是一个32位的int值,前两位对应View的测量模式,后三十位对应View的测量大小。测量模式和测量大小分别可由MeasureSpec的静态方法getSize()和getMode()获得。
- 测量完了之后,我们要确定当前ViewGroup的位置,这里简称定位,这里我们要关注的点是:我们的刷新头是默认隐藏的。接下来我们看看定位的详细代码:
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int childrenCount = getChildCount();
//我们要隐藏刷新头,所以把第一个子View的顶部设置为负的高度
int childrenTop = -mHeader.getMeasuredHeight();
//遍历子View,一个一个定位子View
for(int i = 0;i < childrenCount;i++){
final View child = getChildAt(i);
//确认子View是可见的
if(child.getVisibility() != GONE){
child.layout(0,childrenTop,child.getMeasuredWidth(),
childrenTop+child.getMeasuredHeight());
childrenTop += child.getMeasuredHeight();
}
}
//我们在这里拿到他的会引起滑动冲突的子View
mChild = (RecyclerView) getChildAt(childrenCount-1);
}
- 定位完成了之后我们开始解决滑动冲突,我们尽量列出我们能想到的会引起滑动冲突的点:
- RecyclerView滑到顶部item且还在继续往上滑的时候,需要当且的ViewGroup拦截事件
- RecyclerView往下滑,但是刷新头还没有被隐藏时,依旧需要当前ViewGroup拦截事件
- 其余情况可以交给子RecyclerView处理事件,当前ViewGrouop不拦截事件
接下来看看详细代码:
public boolean onInterceptTouchEvent(MotionEvent ev) {
//设置一个是否拦截的标志位
boolean intercepted = false;
//计算竖直方向上的距离差
float deltaY = ev.getY() - mLastInterceptY;
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
//down事件默认不拦截
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
//判断竖直方向滑动大于默认最小滑动且子RecyclerView到达顶部
if (deltaY>touchSlop && !mChild.canScrollVertically(-1)){
//上面讨论的第一种情况,需要拦截
intercepted = true;
//判断正在往下滑且刷新有还没有被隐藏
}else if(deltaY < 0 && mHeader.getY()>-mHeader.getHeight()) {
//上面讨论的第二种情况,需要拦截
intercepted = true;
}else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
// up事件默认不拦截
intercepted = false;
break;
}
mLastInterceptY = ev.getY();
return intercepted;
}
- 接下来我们来接觉触控事件,覆写onTouchEvent()方法。详细代码如下:
public boolean onTouchEvent(MotionEvent event) {
//我们不做多点触控
if(event.getPointerCount()>1){
//还原位置,及标志
mFlags = -1;
mLastY = 0;
mHeader.layout(0, -mHeader.getHeight(), mHeader.getWidth(), 0);
mChild.layout(0, 0, getWidth(), mChild.getHeight());
return false;
}
float y = event.getY();
//计算竖直方向的高度差
float deltaX = y - mLastY;
//设置一个标志位决定是否消费事件
boolean result = false;
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
mLastY = y;
result = false;
break;
case MotionEvent.ACTION_MOVE:
//判断微小滑动或者往下滑
if(mFlags==REFRESHING){
return false;
}
if(deltaX<touchSlop){
//刷新头隐藏或者没有完全拉出来
if(mHeader.getY() < 0){
mFlags = PULL_TO_REFRESH;
//设置刷新头的标题
updateHeaderTitle("下拉刷新");
result = false;
}
result = false;
//判断当前没有处于正在刷新状态且刷新头完全显示出来了
}else if(mFlags != REFRESHING&& mHeader.getY()>0) {
//改变刷新头状态
updateHeaderTitle("松手刷新");
//设置释放刷新标志
mFlags = RELEASE_TO_REFRESH;
}
if(mLastY!=0&&event.getPointerCount()==1) {
//通过layout()方法让view随着用户的滑动而移动
mHeader.layout(0, (int) mHeader.getY() + (int) deltaX/2, mHeader.getWidth()
, (int) mHeader.getY() + (int) deltaX/2 + mHeader.getHeight());
mChild.layout(0, (int) mChild.getY() + (int) deltaX/2, mChild.getWidth()
, (int) mChild.getY() + (int) deltaX/2 + mChild.getHeight());
}
mLastY = y;
result = false;
break;
case MotionEvent.ACTION_UP:
//判断刷新标志符
if (mFlags == RELEASE_TO_REFRESH){
updateHeaderTitle("松手刷新");
//松手时处于RELEASE_TO_REFRESH状态就去刷新
refresh();
result = true;
}
if(!result) {
mHeader.layout(0, -mHeader.getHeight(), mHeader.getWidth(), 0);
mChild.layout(0, 0, getWidth(), mChild.getHeight());
}
//松手了就初始化标志符
mFlags = -1;
mLastY = 0;
break;
}
return result;
}
6.触控事件处理完了,接下来看看如何刷新的,在刷新的时候,用户会做一些其他的事情,我们需要给他们提供接口,监听若是ViewGroup处于刷新的状态下,则调用用户想做的事情。下面是详细代码:
private void refresh(){
//刷新的动画
mFlags = REFRESHING;
refreshAnimation();
//处于刷新状态则调用用户要做的事情(回调)
if(mRefreshListeners!=null&&mRefreshListeners.size()>0){
for(RefreshListener refreshListener :mRefreshListeners){
refreshListener.onRefresh();
}
}
updateHeaderTitle("正在刷新");
//这是为了显示效果,用handler做一个延时操作
@SuppressLint("HandlerLeak") Handler handler = new Handler(){
@Override
public void handleMessage(Message msg) {
if (msg.what == 1){
initState();
Log.d(TAG,"refresh 正在刷新:" +distance);
}
}
};
handler.sendEmptyMessageDelayed(1,2000);
}
private void refreshAnimation(){
// 这里用属性动画做一个平滑的过渡
ObjectAnimator animator = ObjectAnimator.ofFloat(mHeader,"Y",
mHeader.getY(),0);
ObjectAnimator animator2 = ObjectAnimator.ofFloat(mChild,"Y",
mChild.getY(),mHeader.getHeight());
AnimatorSet set = new AnimatorSet();
set.play(animator).with(animator2);
set.setDuration(500);
set.start();
// 属性动画没有真正改变View的位置,所以我们再手动调整一次位置
mHeader.layout(0,0, mHeader.getWidth(), mHeader.getHeight());
mChild.layout(0, mHeader.getHeight(),getWidth(),mChild.getHeight()+ mHeader.getHeight());
}
/**
*初始化View的状态和一些标志符
**/
private void initState(){
mFlags = -1;
mLastY = 0;
ObjectAnimator animator = ObjectAnimator.ofFloat(mHeader,"Y",
mHeader.getY(),-mHeader.getHeight());
ObjectAnimator animator2 = ObjectAnimator.ofFloat(mChild,"Y",
mChild.getY(),0);
AnimatorSet set = new AnimatorSet();
set.play(animator).with(animator2);
set.setDuration(500);
set.start();
mHeader.layout(0,-mHeader.getHeight(), mHeader.getWidth(),0);
mChild.layout(0,0,getWidth(),mChild.getHeight());
}
到这里自定义的下拉刷新控件的主题内容基本就结束了。
我们来看看展示效果(虽然很简陋,还是展示一下)
作者是初学者,如有错误希望大神指点,还有谢谢能看到这的同学。