一、前言
Android 问世以来,抽屉布局在2016年之前一直是热度很高的效果,随着官方提供了DrawerLayout、SlidingTabLayout 等组件之后,一度引起开发者热捧。不过随着app开发技术的成熟,这类布局热度已经降下来了,不过作为技术方案,有很多值得我们学习的地方。
要解决的问题:
- 1、事件传递
- 2、事件重新分发
- 3、Scroller 使用
二、代码实现
2.1 核心逻辑
在Android 中,滑动分为2类,一类以ScrollView为代表布局,通过子View实现布局超出视区(ViewPort)之后,进行Scroll操作的,另一类事以修改Offset为代表的Recycler类,前者实时保持最大高度。形像的理解为前者是“齿轮传动派”,后者是“滑板派”,两派都有过出风头的时候,即便是个派弟子如NestedScrollView和RecyclerView争的你死我活,不过总体上齿轮传动派占在弱势地位。不过android的改版,让他们做了很多和平相处的事情,不如NestedScrolling机制的支持,让他们想传动就传动,想滑翔就滑翔。
之前有篇文章我们了解了滑板派,这次我们来了解下齿轮传动派。
齿轮传动派是滑动父View本身,主要调用scrollXXX方法
难点实际上并不多,主要难点是Scroller和computeScroll的相互参考关系。我们知道Scroller本身和View没有直接的关系,但是他能提供一些滑动的阻尼计算,让我们的滑动没有那么呆板,唯一强调的是computeScroll种必须要触发draw方法(利用invalidate或者postInvalidate),这样才能相互带动调用关系,Scroller类似参考对象,为滑动提供顾问服务。
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int oldX = getScrollX();
int oldY = getScrollY();
int x = mScroller.getCurrX();
int y = mScroller.getCurrY();
if (oldX != x || oldY != y) {
scrollTo(x,y); //scroll x,y相同时,无法invalidate的问题
}else {
postInvalidate(); //如果位移一致,强制刷新,才能再次调用computeScroll
}
Log.d("computeScroll","computeScrollX="+mScroller.getCurrX()+",mScroller="+mScroller.isFinished());
}
}
2.2 事件处理
事件是此类View定义的最重要的部分,这里我们用dispatchTouchEvent去处理事件,一个重要的原因是,RecyclerView和ListView默认会捕获事件,因此dispatchTouchEvent 是必要的,除非你再自定义一个RecyclerView去兼容。
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if(mContentChildView==null || mMenuChildView==null){
return super.dispatchTouchEvent(ev);
}
int actionMasked = ev.getActionMasked();
switch (actionMasked){
case MotionEvent.ACTION_DOWN:
if(!mScroller.isFinished()){
break;
}
isSlideMoving = false;
mPoint.x = ev.getX();
mPoint.y = ev.getY();
Log.d("onTouchEvent","Action_Down");
break;
case MotionEvent.ACTION_MOVE:
getParent().requestDisallowInterceptTouchEvent(true);
float cx = ev.getX();
float cy = ev.getY();
float dx = Math.abs(mPoint.x - cx);
float dy = Math.abs(mPoint.y - cy);
if(dy>dx && dy>=mTouchSlop){
mPoint.x = cx;
mPoint.y = cy;
isSlideMoving = false;
}else if (dx >= mTouchSlop && !isSlideMoving) {
isSlideMoving = true;
//这个判断很关键,不然会有事件冲突问题
}
if(isSlideMoving) {
offsetX = (int) (getScrollX() + (mPoint.x - cx));
if (offsetX < 0) {
offsetX = Math.max(offsetX, 0);
} else {
offsetX = Math.min(offsetX, mMenuChildView.getWidth());
}
mPoint.x = cx;
mPoint.y = cy;
scrollTo(offsetX, 0);
return true; //防止纵向滑动被传递给子View
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
Log.e("onTouchEvent","ACTION_UP isSlideMoving=false");
int scrollX = getScrollX();
if(scrollX>=mMenuChildView.getWidth()/2){
smoothScrollTo(mMenuChildView.getWidth(),0);
}else{
smoothScrollTo(0,0);
}
getParent().requestDisallowInterceptTouchEvent(false);
if( isSlideMoving) {
isSlideMoving = false;
return true;
//一旦出现了滑动,不在传递dispatch事件,否则造成事件传递异常
}
break;
}
return super.dispatchTouchEvent(ev);
}
2.3 核心代码
下面是完整代码,我们这里支持两种滑动,参见文章开头的效果。
public class SlindingDrawer extends ViewGroup {
PointF mPoint = new PointF();
boolean isSlideMoving = false;
private View mMenuChildView = null;
private View mContentChildView = null;
private int offsetX = Integer.MIN_VALUE;
private int mTouchSlop = 0;
static final int ANIMATED_SCROLL_GAP = 250;
private Scroller mScroller; //用于平滑scroll
private boolean isDrawerOpen = false;
private int STYLE_MODE_SLIDE = 1;
private int STYLE_MODE_PULL = 0;
private int mStyleModel = STYLE_MODE_SLIDE;
public SlindingDrawer(Context context) {
this(context,null);
}
public SlindingDrawer(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public SlindingDrawer(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if(attrs==null) return;
final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SlindingDrawer);
mStyleModel = a.getInt(R.styleable.SlindingDrawer_drawer_style, STYLE_MODE_SLIDE);
a.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if(mTouchSlop==0){
mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
}
if(mScroller==null) {
mScroller = new Scroller(getContext());
}
int childCount = getChildCount();
if(childCount==0) return;
if(childCount!=2) throw new RuntimeException("the child count must be 2");
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int width = 0;
int height = 0;
View menuChildView = null;
View contentChildView = null;
for (int i=0;i<childCount;i++){
View child = getChildAt(i);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
if(lp.layoutType== LayoutParams.LAYOUT_MENU) {
menuChildView = child;
}
if(lp.layoutType== LayoutParams.LAYOUT_CONTENT) {
contentChildView = child;
}
}
if(menuChildView==null){
throw new RuntimeException("unspecified menu child");
}
if(contentChildView==null){
throw new RuntimeException("unspecified content child");
}
ViewGroup.LayoutParams menuParams = menuChildView.getLayoutParams();
if(menuParams!=null){
menuParams.width = Math.min(menuParams.width,displayMetrics.widthPixels);
}
ViewGroup.LayoutParams contentParams = contentChildView.getLayoutParams();
if(contentParams!=null) {
if(widthMode==MeasureSpec.EXACTLY){
contentParams.width = Math.min(widthSize, displayMetrics.widthPixels);
}else{
//本布局如果宽度不确定,那么尽量让content等于屏幕宽度
contentParams.width = Math.min(contentParams.width, displayMetrics.widthPixels);
}
}
measureChildren(widthMeasureSpec,heightMeasureSpec);
if(menuChildView.getMeasuredWidth()>contentChildView.getMeasuredWidth()){
throw new RuntimeException("menu max width should less than content");
}
//本布局最大宽度不能超过屏幕宽度
width = Math.min(displayMetrics.widthPixels,contentChildView.getMeasuredWidth());
if(heightMode!=MeasureSpec.EXACTLY){
for(int i=0;i<childCount;i++){
View child = getChildAt(i);
height = Math.max(height,Math.min(displayMetrics.heightPixels,child.getMeasuredHeight()));
//每个面不能超过屏幕最大宽度
}
}else{
height = heightSize;
}
for (int i=0;i<childCount;i++){
View child = getChildAt(i);
if(child.getMeasuredHeight()==height) continue;
ViewGroup.LayoutParams layoutParams = child.getLayoutParams();
if(layoutParams!=null){
layoutParams.height = height;
}
//重新测量child,使得其高度一致
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(child.getMeasuredWidth(), MeasureSpec.EXACTLY);
int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(child.getMeasuredHeight(), MeasureSpec.EXACTLY);
child.measure(childWidthMeasureSpec,childHeightMeasureSpec);
}
setMeasuredDimension(width,height);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
if(count==0 || !changed) return;
for (int i=0;i<count;i++) {
View child = getChildAt(i);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
if(lp.layoutType== LayoutParams.LAYOUT_MENU) {
mMenuChildView = child;
}
if(lp.layoutType== LayoutParams.LAYOUT_CONTENT) {
mContentChildView = child;
}
}
int menuWidth = mMenuChildView.getMeasuredWidth();
int menuHeight = mMenuChildView.getMeasuredHeight();
mMenuChildView.layout(0,0,menuWidth,menuHeight);
int left = menuWidth;
int right = menuWidth +mContentChildView.getMeasuredWidth();
mContentChildView.layout(left,0,right,mContentChildView.getMeasuredHeight());
if(mStyleModel==STYLE_MODE_PULL){
bringChildToFront(mMenuChildView);
}else{
bringChildToFront(mContentChildView);
}
if(offsetX==Integer.MIN_VALUE) {
offsetX = left;
}
scrollTo(offsetX,0);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if(mContentChildView==null || mMenuChildView==null){
return super.dispatchTouchEvent(ev);
}
int actionMasked = ev.getActionMasked();
switch (actionMasked){
case MotionEvent.ACTION_DOWN:
if(!mScroller.isFinished()){
break;
}
isSlideMoving = false;
mPoint.x = ev.getX();
mPoint.y = ev.getY();
Log.d("onTouchEvent","Action_Down");
break;
case MotionEvent.ACTION_MOVE:
getParent().requestDisallowInterceptTouchEvent(true);
float cx = ev.getX();
float cy = ev.getY();
float dx = Math.abs(mPoint.x - cx);
float dy = Math.abs(mPoint.y - cy);
if(dy>dx && dy>=mTouchSlop){
mPoint.x = cx;
mPoint.y = cy;
isSlideMoving = false;
}else if (dx >= mTouchSlop && !isSlideMoving) {
isSlideMoving = true;
//这个判断很关键,不然会有事件冲突问题
}
if(isSlideMoving) {
offsetX = (int) (getScrollX() + (mPoint.x - cx));
if (offsetX < 0) {
offsetX = Math.max(offsetX, 0);
} else {
offsetX = Math.min(offsetX, mMenuChildView.getWidth());
}
mPoint.x = cx;
mPoint.y = cy;
scrollTo(offsetX, 0);
return true; //防止纵向滑动被传递给子View
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
Log.e("onTouchEvent","ACTION_UP isSlideMoving=false");
int scrollX = getScrollX();
if(scrollX>=mMenuChildView.getWidth()/2){
smoothScrollTo(mMenuChildView.getWidth(),0);
}else{
smoothScrollTo(0,0);
}
getParent().requestDisallowInterceptTouchEvent(false);
if( isSlideMoving) {
isSlideMoving = false;
return true;
//一旦出现了滑动,不在传递dispatch事件,否则造成事件传递异常
}
break;
}
return super.dispatchTouchEvent(ev);
}
public final void smoothScrollTo(int x, int y) {
smoothScrollBy(x - getScrollX(), y - getScrollY());
}
public final void smoothScrollBy(int dx, int dy) {
if (getChildCount() == 0) {
return;
}
final int height = getHeight() - getPaddingBottom() - getPaddingTop();
final int bottom = getChildAt(0).getHeight();
final int maxY = Math.max(0, bottom - height);
final int scrollY = getScrollY();
final int scrollX = getScrollX();
dy = Math.max(0, Math.min(scrollY + dy, maxY)) - scrollY;
if(!mScroller.isFinished()){
mScroller.abortAnimation();
}
mScroller.startScroll(scrollX, scrollY, dx, dy,ANIMATED_SCROLL_GAP);
postInvalidateOnAnimation();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int oldX = getScrollX();
int oldY = getScrollY();
int x = mScroller.getCurrX();
int y = mScroller.getCurrY();
if (oldX != x || oldY != y) {
scrollTo(x,y); //scroll x,y相同时,无法invalidate的问题
}else {
postInvalidate(); //如果位移一致,强制刷新,才能再次调用computeScroll
}
Log.d("computeScroll","computeScrollX="+mScroller.getCurrX()+",mScroller="+mScroller.isFinished());
}
}
public void postInvalidateOnAnimation() {
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.JELLY_BEAN) {
super.postInvalidateOnAnimation();
}else{
postInvalidate();
}
}
@Override
public void scrollTo(@Px int sx, @Px int sy) {
super.scrollTo(sx, sy);
if(mMenuChildView==null || mContentChildView==null) return;
int maxScrollSize = mMenuChildView.getWidth();
if(sx==0){
isDrawerOpen = true;
}else if(sx==maxScrollSize){
isDrawerOpen = false;
}
float delta = sx * 0.5f / maxScrollSize;
mContentChildView.setAlpha(0.5f+delta);
Log.d("XLeft",""+sx);
if(mStyleModel==STYLE_MODE_PULL){
mContentChildView.setTranslationX(sx-maxScrollSize);
}
}
@Override
protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.WRAP_CONTENT,LayoutParams.WRAP_CONTENT,LayoutParams.LAYOUT_CONTENT);
}
@Override
protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new LayoutParams(p);
}
@Override
public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(),attrs);
}
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return (p instanceof LayoutParams);
}
public boolean isDrawerOpen() {
return isDrawerOpen;
}
public void openDrawer() {
smoothScrollTo(0, 0);
}
public void closeDrawer() {
if(mMenuChildView==null) return;
smoothScrollTo(mMenuChildView.getWidth(), 0);
}
public static class LayoutParams extends ViewGroup.LayoutParams{
public static final int LAYOUT_MENU = 0;
public static final int LAYOUT_CONTENT = 1;
public int layoutType = LAYOUT_CONTENT;
public LayoutParams(@NonNull Context c, @Nullable AttributeSet attrs) {
super(c, attrs);
final TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.SlindingDrawer);
layoutType = a.getInt(R.styleable.SlindingDrawer_drawer_layoutType, LAYOUT_CONTENT);
a.recycle();
}
public LayoutParams(int width, int height,int gravity) {
super(width, height);
}
public LayoutParams(ViewGroup.LayoutParams source) {
super(source);
if(source instanceof LayoutParams){
layoutType = ((LayoutParams) source).layoutType;
}
}
}
}
2.4 attr.xml定义
定义layout_type和drawer_style,前者用于标记child类型,后者用于标记布局滑动效果
<declare-styleable name="SlindingDrawer">
<attr name="drawer_layoutType" format="enum">
<enum name="content" value="1" />
<enum name="menu" value="0" />
</attr>
<attr name="drawer_style">
<enum name="slide" value="1" />
<enum name="pull" value="0" />
</attr>
</declare-styleable>
三、用法
3.1 布局文件
layout_type和drawer_style 是需要明确标记的,不同的View扮演不同的角色,不同的style会展示不同的效果。
<com.bgm.view.SlindingDrawer xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/slindingDrawer"
app:drawer_style="pull"
>
<LinearLayout
android:id="@+id/menu"
android:layout_width="200dp"
android:layout_height="match_parent"
app:drawer_layoutType="menu"
android:background="@color/color_d3ddff"
android:orientation="vertical"
>
<Button
android:id="@+id/left"
android:text="关闭"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:id="@+id/content"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="100dp"
app:drawer_layoutType="content"
android:gravity="center"
>
<Button
android:id="@+id/right"
android:text="打开"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<ImageView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:src="@drawable/pic_rule"
/>
</LinearLayout>
</com.bgm.view.SlindingDrawer>
3.2 注意事项
content的宽度需要大于menu,否则可能展示出奇怪的效果。
四、总结
自定义布局其实难点主要在滑动和事件处理上,相比Canvas的绘制,这里对算法的要求其实并不高,但是我们要掌握滑动方向处理,fling事件、scroller事件、volecity、scrolling机制,也需要一定的练习才行。