一、前言:
我之前很早的时候,写过一篇《左右滑出菜单》的文章:
用的是对View的LeftMargin / RightMargin进行不断的计算,并且用AsynTask来完成动画,性能不是很好,大家也在资源下载中有评论,因此,本篇文件,将会采用ViewGroup的方式来自定义控件,且支持文章标题中的两种滑动方式的展现,也希望大家多多评论。(可惜,大家都去下载资源,在资源中评论了!呜呜~~)。
二、实现:
2.1 核心程序及知识点:
本次,采用ViewGroup来管理整个的Child,并且采用scrollTo / scrollBy,以及 Scroller 这么个系统方法来完成这些事。先来上主要代码:
package com.chris.apps.uiscroll;import com.chris.apps.uiscroll.R;import android.content.Context;import android.content.res.TypedArray;import android.util.AttributeSet;import android.util.Log;import android.view.MotionEvent;import android.view.VelocityTracker;import android.view.View;import android.view.ViewConfiguration;import android.view.ViewGroup;import android.widget.Scroller;public class UIScrollLayout extends ViewGroup { private final static String TAG = "UIScrollLayout"; private int mCurScreen = 0; private final static String ATTR_NAVIGATOR = "navigator"; private final static String ATTR_SLIDEMENU = "slidemenu"; public final static int VIEW_NAVIGATOR = 0; public final static int VIEW_MAIN_SLIDEMENU = 1; private int mViewType = VIEW_NAVIGATOR; private int mTouchSlop = 0; private int mLastX = 0; private VelocityTracker mVelocityTracker = null; private final static int VELOCITY_X_DISTANCE = 1000; private Scroller mScroller = null; public UIScrollLayout(Context context) { this(context, null); } public UIScrollLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public UIScrollLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.UIScroll); String type = a.getString(R.styleable.UIScroll_view_type); a.recycle(); Log.d(TAG, "type = " + type); if(type.equals(ATTR_NAVIGATOR)){ mViewType = VIEW_NAVIGATOR; }else if(type.equals(ATTR_SLIDEMENU)){ mViewType = VIEW_MAIN_SLIDEMENU; } mScroller = new Scroller(context); mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); Log.d(TAG, "mTouchSlop = " + mTouchSlop); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if(mViewType == VIEW_NAVIGATOR){ for(int i = 0; i < getChildCount(); i ++){ getChildAt(i).measure(widthMeasureSpec, heightMeasureSpec); } }else if(mViewType == VIEW_MAIN_SLIDEMENU){ for(int i = 0; i < getChildCount(); i ++){ View child = getChildAt(i); LayoutParams lp = child.getLayoutParams(); int widthSpec = 0; if(lp.width > 0){ widthSpec = MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY); }else{ widthSpec = widthMeasureSpec; } child.measure(widthSpec, heightMeasureSpec); } } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { if(changed){ int n = getChildCount(); View child = null; int childLeft = 0; mCurScreen = 0; for(int i = 0; i < n; i ++){ child = getChildAt(i); child.layout(childLeft, 0, childLeft + child.getMeasuredWidth(), child.getMeasuredHeight()); childLeft += child.getMeasuredWidth(); } if(mViewType == VIEW_MAIN_SLIDEMENU){ if(n > 3){ Log.d(TAG, "error: Main SlideMenu num must <= 3"); return; } if(getChildAt(0).getMeasuredWidth() < getMeasuredWidth()){ mCurScreen = 1; scrollTo(getChildAt(0).getMeasuredWidth(), 0); }else{ mCurScreen = 0; } } Log.d(TAG, "mCurScreen = " + mCurScreen); } } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { switch(ev.getAction()){ case MotionEvent.ACTION_DOWN: mLastX = (int) ev.getX(); break; case MotionEvent.ACTION_MOVE: int x = (int) ev.getX(); if(Math.abs(x - mLastX) > mTouchSlop){ return true; } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: // TODO: clean or reset break; } return super.onInterceptTouchEvent(ev); } /** * 使用VelocityTracker来记录每次的event, * 并在ACTION_UP时computeCurrentVelocity, * 得出X,Y轴方向上的移动速率 * velocityX > 0 向右移动, velocityX < 0 向左移动 */ @Override public boolean onTouchEvent(MotionEvent event) { if(mVelocityTracker == null){ mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(event); switch(event.getAction()){ case MotionEvent.ACTION_DOWN: mLastX = (int) event.getX(); break; case MotionEvent.ACTION_MOVE: int deltaX = mLastX - (int)event.getX(); // delta > 0向右滚动 mLastX = (int) event.getX(); scrollChild(deltaX, 0); break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: mVelocityTracker.computeCurrentVelocity(VELOCITY_X_DISTANCE); int velocityX = (int) mVelocityTracker.getXVelocity(); animateChild(velocityX); if(mVelocityTracker != null){ mVelocityTracker.recycle(); mVelocityTracker = null; } break; } return true; } private void scrollChild(int distanceX, int distanceY){ int firstChildPosX = getChildAt(0).getLeft() - getScrollX(); int lastChildPosX = getChildAt(getChildCount()-1).getLeft() - getScrollX(); if(mViewType == VIEW_MAIN_SLIDEMENU){ lastChildPosX -= (getWidth() - getChildAt(getChildCount()-1).getWidth()); } if(firstChildPosX != 0 && Math.abs(firstChildPosX) < Math.abs(distanceX)){ distanceX = firstChildPosX; }else if(lastChildPosX != 0 && Math.abs(lastChildPosX) < Math.abs(distanceX)){ distanceX = lastChildPosX; } if(firstChildPosX == 0 && distanceX < 0){ return; }else if(lastChildPosX == 0 && distanceX > 0){ return; } scrollBy(distanceX, 0); } private void animateChild(int velocityX){ int width = 0; int offset = 0; if(mViewType == VIEW_NAVIGATOR){ width = getWidth(); }else if(mViewType == VIEW_MAIN_SLIDEMENU){ // 默认左右两页菜单宽度一致 width = getChildAt(0).getWidth(); } /* * velocityX > 0, 向右滚动; velocityX < 0, 向左滚动 */ if(velocityX > VELOCITY_X_DISTANCE && mCurScreen > 0){ offset = (--mCurScreen) * width - getScrollX(); }else if(velocityX < -VELOCITY_X_DISTANCE && mCurScreen < getChildCount()-1){ offset = (++mCurScreen) * width - getScrollX(); }else{ mCurScreen = (getScrollX() + width/2) / width; offset = mCurScreen * width - getScrollX(); } //Log.d(TAG, "offset = " + offset); mScroller.startScroll(getScrollX(), 0, offset, 0, Math.abs(offset)); invalidate(); } @Override public void computeScroll() { if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); postInvalidate(); } super.computeScroll(); }}
这篇文章除了以上介绍,还用到了以下知识点:
1. VelocityTracker类来跟踪手指滑动速率;(网上有很多,使用也很简单)
2. 自定义XML属性;(可以看看这篇讲解:)
3. onIntercepterTouchEvent,事件拦截(可以参考这篇:)
2.2 代码解读:2.2.1 初始化
public UIScrollLayout(Context context) { this(context, null); } public UIScrollLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public UIScrollLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.UIScroll); String type = a.getString(R.styleable.UIScroll_view_type); a.recycle(); Log.d(TAG, "type = " + type); if(type.equals(ATTR_NAVIGATOR)){ mViewType = VIEW_NAVIGATOR; }else if(type.equals(ATTR_SLIDEMENU)){ mViewType = VIEW_MAIN_SLIDEMENU; } mScroller = new Scroller(context); mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); Log.d(TAG, "mTouchSlop = " + mTouchSlop); }
查找自定义属性有没有,然后设置当前使用的类型,初始化Scroller,并使用ViewConfiguration来获取系统设置(这里用来判断当Touch时,是水平滚动,还是上下滚动,若含有ListView时,需要通过onInterceptTouchEvent来判断)。
2.2.2 测量child
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if(mViewType == VIEW_NAVIGATOR){ for(int i = 0; i < getChildCount(); i ++){ getChildAt(i).measure(widthMeasureSpec, heightMeasureSpec); } }else if(mViewType == VIEW_MAIN_SLIDEMENU){ for(int i = 0; i < getChildCount(); i ++){ View child = getChildAt(i); LayoutParams lp = child.getLayoutParams(); int widthSpec = 0; if(lp.width > 0){ widthSpec = MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY); }else{ widthSpec = widthMeasureSpec; } child.measure(widthSpec, heightMeasureSpec); } } }
根据VIEW类型,来逐个测量child大小。
2.2.3 调整child位置:
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { if(changed){ int n = getChildCount(); View child = null; int childLeft = 0; mCurScreen = 0; for(int i = 0; i < n; i ++){ child = getChildAt(i); child.layout(childLeft, 0, childLeft + child.getMeasuredWidth(), child.getMeasuredHeight()); childLeft += child.getMeasuredWidth(); } if(mViewType == VIEW_MAIN_SLIDEMENU){ if(n > 3){ Log.d(TAG, "error: Main SlideMenu num must <= 3"); return; } if(getChildAt(0).getMeasuredWidth() < getMeasuredWidth()){ mCurScreen = 1; scrollTo(getChildAt(0).getMeasuredWidth(), 0); }else{ mCurScreen = 0; } } Log.d(TAG, "mCurScreen = " + mCurScreen); } }
onMeasure和onLayout都是有ViewRoot来调用,并且是在draw之前,然后,开始显示各个child。
2.2.4 消息拦截处理:
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { switch(ev.getAction()){ case MotionEvent.ACTION_DOWN: mLastX = (int) ev.getX(); break; case MotionEvent.ACTION_MOVE: int x = (int) ev.getX(); if(Math.abs(x - mLastX) > mTouchSlop){ return true; } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: // TODO: clean or reset break; } return super.onInterceptTouchEvent(ev); }
当child中,有ListView, GridView或ScrollView时,DOWN/MOVE/UP等消息是不会跑到当前ViewGroup的onTouchEvent中的,只有当在onInterceptTouchEvent中返回true之后,才会收到消息,因为,需要在ACTION_DOWN时,记住X点坐标,并在ACTION_MOVE中判断是否需要拦截。
2.2.5 滚动消息处理:
/** * 使用VelocityTracker来记录每次的event, * 并在ACTION_UP时computeCurrentVelocity, * 得出X,Y轴方向上的移动速率 * velocityX > 0 向右移动, velocityX < 0 向左移动 */ @Override public boolean onTouchEvent(MotionEvent event) { if(mVelocityTracker == null){ mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(event); switch(event.getAction()){ case MotionEvent.ACTION_DOWN: mLastX = (int) event.getX(); break; case MotionEvent.ACTION_MOVE: int deltaX = mLastX - (int)event.getX(); // delta > 0向右滚动 mLastX = (int) event.getX(); scrollChild(deltaX, 0); break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: mVelocityTracker.computeCurrentVelocity(VELOCITY_X_DISTANCE); int velocityX = (int) mVelocityTracker.getXVelocity(); animateChild(velocityX); if(mVelocityTracker != null){ mVelocityTracker.recycle(); mVelocityTracker = null; } break; } return true; }
在ACTION_MOVE中,计算每次移动的距离,调用scrollChild来随手滚动:
private void scrollChild(int distanceX, int distanceY){ int firstChildPosX = getChildAt(0).getLeft() - getScrollX(); int lastChildPosX = getChildAt(getChildCount()-1).getLeft() - getScrollX(); if(mViewType == VIEW_MAIN_SLIDEMENU){ lastChildPosX -= (getWidth() - getChildAt(getChildCount()-1).getWidth()); } if(firstChildPosX != 0 && Math.abs(firstChildPosX) < Math.abs(distanceX)){ distanceX = firstChildPosX; }else if(lastChildPosX != 0 && Math.abs(lastChildPosX) < Math.abs(distanceX)){ distanceX = lastChildPosX; } if(firstChildPosX == 0 && distanceX < 0){ return; }else if(lastChildPosX == 0 && distanceX > 0){ return; } scrollBy(distanceX, 0); }
这个方法,主要是判断当然是否超过边界,若本次移动的距离超过边界,则计算滚动的距离最大不超过边界,并调用系统scrollBy方法,这个方法最终会调用scrollTo方法。
2.2.6 完成自动滚动:
private void animateChild(int velocityX){ int width = 0; int offset = 0; if(mViewType == VIEW_NAVIGATOR){ width = getWidth(); }else if(mViewType == VIEW_MAIN_SLIDEMENU){ // 默认左右两页菜单宽度一致 width = getChildAt(0).getWidth(); } /* * velocityX > 0, 向右滚动; velocityX < 0, 向左滚动 */ if(velocityX > VELOCITY_X_DISTANCE && mCurScreen > 0){ offset = (--mCurScreen) * width - getScrollX(); }else if(velocityX < -VELOCITY_X_DISTANCE && mCurScreen < getChildCount()-1){ offset = (++mCurScreen) * width - getScrollX(); }else{ mCurScreen = (getScrollX() + width/2) / width; offset = mCurScreen * width - getScrollX(); } //Log.d(TAG, "offset = " + offset); mScroller.startScroll(getScrollX(), 0, offset, 0, Math.abs(offset)); invalidate(); }
在收到ACTION_UP/ACTION_CANCEL消息后,就表明本次交互完成,判断当前界面滚动的距离,以及手势速度,然后调用Scroller.startScroll方法并最终通过invalidate来完成滚动。
光有startScroll是无法完成,还必需继承computeScroll,并不断的invalidate,直到Scroller移动到终点。
@Override public void computeScroll() { if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); postInvalidate(); } super.computeScroll(); }
三、Demo:
例子下载地址:
通过设置view_type属性来显示不同UI。 ("navigator" 或 "slidemenu")