zoukankan      html  css  js  c++  java
  • [UI]抽屉菜单DrawerLayout分析(一)

    本文转载于:http://www.cnblogs.com/avenwu/archive/2014/04/16/3669367.html

    侧拉菜单作为常见的导航交互控件,最开始在没有没有android官方控件时,很多时候都是使用开源的SlidingMenu,一直没机会分析侧拉菜单的实现机理,本文将分析android.support.v4.widget.DrawerLayout的使用及实现。

    Device 2014 04 16 191818

        官方介绍

    DrawerLayout acts as a top-level container for window content that allows for interactive "drawer" views to be pulled out from the edge of the window.

    Drawer positioning and layout is controlled using the android:layout_gravity attribute on child views corresponding to which side of the view you want the drawer to emerge from: left or right. (Or start/end on platform versions that support layout direction.)

    To use a DrawerLayout, position your primary content view as the first child with a width and height of match_parent. Add drawers as child views after the main content view and set the layout_gravity appropriately. Drawers commonly use match_parent for height with a fixed width.

    DrawerLayout.DrawerListener can be used to monitor the state and motion of drawer views. Avoid performing expensive operations such as layout during animation as it can cause stuttering; try to perform expensive operations during the STATE_IDLE state. DrawerLayout.SimpleDrawerListener offers default/no-op implementations of each callback method.

    As per the Android Design guide, any drawers positioned to the left/start should always contain content for navigating around the application, whereas any drawers positioned to the right/end should always contain actions to take on the current content. This preserves the same navigation left, actions right structure present in the Action Bar and elsewhere

    DrawerLayout直译的事抽屉布局的意思,作为视窗内的顶层容器,它允许用户通过抽屉式的推拉操作,从而把视图视窗外边缘拉到屏幕内,如右图:

    抽屉菜单的摆放和布局通过android:layout_gravity属性来控制,可选值为left、right或start、end。通过xml来布局的话,需要把DrawerLayout作为父容器,组界面布局作为其第一个子节点,抽屉布局则紧随其后作为第二个子节点,这样就做就已经把内容展示区和抽屉菜单区独立开来,只需要分别非两个区域设置内容即可。android提供了一些实用的监听器,重载相关的回调方法可以在菜单的交互过程中书写逻辑业务。下面是一个demo布局:

     

    <android.support.v4.widget.DrawerLayout

        xmlns:android="http://schemas.android.com/apk/res/android"

        xmlns:tools="http://schemas.android.com/tools"

        android:id="@+id/drawer_layout"

        android:layout_width="match_parent"

        android:layout_height="match_parent"

        tools:context="com.aven.myapplication2.app.MainActivity">

     

        <FrameLayout

            android:id="@+id/container"

            android:layout_width="match_parent"

            android:layout_height="match_parent"/>

     

        <fragmentandroid:id="@+id/navigation_drawer"

            android:layout_width="@dimen/navigation_drawer_width"

            android:layout_height="match_parent"

            android:layout_gravity="start"

            android:name="com.aven.myapplication2.app.NavigationDrawerFragment"

            tools:layout="@layout/fragment_navigation_drawer"/>

     

    </android.support.v4.widget.DrawerLayout>

     
     
    所以DrawerLayout的使用非常简单,和很多容器类布局一样,它本身也继承自ViewGroup,只是在内部实现中会默认将第一个子节点作为内容区,第二个作为抽屉菜单,所以写布局的事后必须牢记,好在现在的IDE已经非常智能,通过引导来创建Drawerlayout时,会自动生成Activity和xml layout布局,比如使用AndroidStudio就非常方便。
     

    源码分析

    DrawerLayout实例化相关辅助类

    既然DrawerLayout使用是作为顶层布局layout,那先看看他的构造函数:

    public DrawerLayout(Context context, AttributeSet attrs, int defStyle) {

        super(context, attrs, defStyle);

        //根据屏幕分辨率密度计算最小的边距

        final float density = getResources().getDisplayMetrics().density;

        mMinDrawerMargin = (int) (MIN_DRAWER_MARGIN * density + 0.5f);

        final float minVel = MIN_FLING_VELOCITY * density;

        //实例化视图滑动的回调接口,包括左右两边

        mLeftCallback = new ViewDragCallback(Gravity.LEFT);

        mRightCallback = new ViewDragCallback(Gravity.RIGHT);

        //创建滑动手势的的辅助类,负责具体的滑动监听实现

        mLeftDragger = ViewDragHelper.create(this, TOUCH_SLOP_SENSITIVITY, mLeftCallback);

        mLeftDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);

        mLeftDragger.setMinVelocity(minVel);

        mLeftCallback.setDragger(mLeftDragger);

     

        mRightDragger = ViewDragHelper.create(this, TOUCH_SLOP_SENSITIVITY, mRightCallback);

        mRightDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_RIGHT);

        mRightDragger.setMinVelocity(minVel);

        mRightCallback.setDragger(mRightDragger);

     

        // So that we can catch the back button

        setFocusableInTouchMode(true);

     

        ViewCompat.setAccessibilityDelegate(this, new AccessibilityDelegate());

        ViewGroupCompat.setMotionEventSplittingEnabled(this,false);

    }

    从构造函数中,我们发现有两个关键的类ViewDragCallback, ViewDragHelper,命名上来看前者和滑动的回调相关,后者和view的滑动操作实现有关,所以先看ViewDragHelper。

     

    ViewDragHelper负责实现drag操作

    从它的类注释信息中可以看到,这个helper是个辅助类,里面封装了一些便于用户拖动ViewGroup内子view的操作及状态记录方法。

    /**

     * ViewDragHelper is a utility class for writing custom ViewGroups. It offers a number

     * of useful operations and state tracking for allowing a user to drag and reposition

     * views within their parent ViewGroup.

     */

     
    现在来看看这个helper到底是怎么封装的滑动操作,从上面的实例化我们知道这个helper通过工厂方法来构造实例,工厂方法有两个如下:

    /**

     * Factory method to create a new ViewDragHelper.

     *

     * @param forParent Parent view to monitor

     * @param cb Callback to provide information and receive events

     * @return a new ViewDragHelper instance

     */

    public static ViewDragHelper create(ViewGroup forParent, Callback cb) {

        return new ViewDragHelper(forParent.getContext(), forParent, cb);

    }

     

    /**

     * Factory method to create a new ViewDragHelper.

     *

     * @param forParent Parent view to monitor

     * @param sensitivity Multiplier for how sensitive the helper should be about detecting

     *                    the start of a drag. Larger values are more sensitive. 1.0f is normal.

     * @param cb Callback to provide information and receive events

     * @return a new ViewDragHelper instance

     */

    public static ViewDragHelper create(ViewGroup forParent, float sensitivity, Callback cb) {

        final ViewDragHelper helper = create(forParent, cb);

        helper.mTouchSlop = (int) (helper.mTouchSlop * (1 / sensitivity));

        return helper;

    }

     

    这第二个工厂方法create就是刚才看到的上层调用来创建helper实例的,我们传入了一个viewgroup,也就是说helper将持有我们的DrawerLayout实例引用,第二是一个浮点数,和drag操作的敏感性相关,数值越大表示drag操作更易被监听,最后是一个Callback,即ViewDragCallback实例,它本身继承自ViewDragHelper.Callback,现在来看helper的构造方法:

    /**

     * Apps should use ViewDragHelper.create() to get a new instance.

     * This will allow VDH to use internal compatibility implementations for different

     * platform versions.

     *

     * @param context Context to initialize config-dependent params from

     * @param forParent Parent view to monitor

     */

    private ViewDragHelper(Context context, ViewGroup forParent, Callback cb) {

        if (forParent == null) {

            throw new IllegalArgumentException("Parent view may not be null");

        }

        if (cb == null) {

            throw new IllegalArgumentException("Callback may not be null");

        }

     

        mParentView = forParent;

        mCallback = cb;

     

        final ViewConfiguration vc = ViewConfiguration.get(context);

        finalfloat density = context.getResources().getDisplayMetrics().density;

        mEdgeSize = (int) (EDGE_SIZE * density + 0.5f);

     

        mTouchSlop = vc.getScaledTouchSlop();

        mMaxVelocity = vc.getScaledMaximumFlingVelocity();

        mMinVelocity = vc.getScaledMinimumFlingVelocity();

        mScroller = ScrollerCompat.create(context, sInterpolator);

    }

    首先需要检测我们传入的DrawerLayout和回调Callback,不允许为空。接下来从ViewConfiguration中获取一些view的默认配置,

    vc.getScaledTouchSlop是获取一个pix为单位的距离,代表view在滑动的值;

    vc.getScaledMaximumFlingVelocity获取触发view fling的最大每秒滚动的距离,也是pix为单位;

    获取view fling的最小每秒滚动距离,同样pix为单位;

    这里有scroll和fling,我的理解是scroll表示手没有离开屏幕产生的滑动效果,二fling则是用力一划,然后view自己开始滚动的效果。

    最后实例化一个Scroller,这是专门用来处理滚动的一个类,这里用的是扩展包里的campact类做版本兼容。

    到此DrawerLayout已经准备好所有资源,接下来就是手势分发时候的各种调用,这一部分留到下一篇文章在做分析

     

    Source:

    git clone https://github.com/avenwu/DrawerDemo.git

     

     

    作者:小文字
    本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利.

    继续分析DrawerLayout的手势分发部分

    谈到手势分发,这本身就是个好话题,DrawerLayout作为继承自ViewGroup得布局他可以拦截手势也可以分发给子view,也就是在onInterceptTouchEvent中做的操作,但是他的下面还有一个onTouchEvent方法,先看哪个呢?追溯代码我们可以知道ViewGroup继承自View,而onTouchEvent是View的方法

    NewImage

    我们还是先花点时间把两者的关系先确认再继续。

    onInterceptTouchEvent和onTouchEvent---鸡和蛋?

    定位到ViewGroup,可以发现onInterceptTouchEvent分定义如下,从它前面一段非常长的注释就可以看出其重要性和复杂,默认的返回是false

    /**

     * Implement this method to intercept all touch screen motion events.  This

     * allows you to watch events as they are dispatched to your children, and

     * take ownership of the current gesture at any point.

     *

     * <p>Using this function takes some care, as it has a fairly complicated

     * interaction with {@link View#onTouchEvent(MotionEvent)

     * View.onTouchEvent(MotionEvent)}, and using it requires implementing

     * that method as well as this one in the correct way.  Events will be

     * received in the following order:

     *

     * <ol>

     * <li> You will receive the down event here.

     * <li> The down event will be handled either by a child of this view

     * group, or given to your own onTouchEvent() method to handle; this means

     * you should implement onTouchEvent() to return true, so you will

     * continue to see the rest of the gesture (instead of looking for

     * a parent view to handle it).  Also, by returning true from

     * onTouchEvent(), you will not receive any following

     * events in onInterceptTouchEvent() and all touch processing must

     * happen in onTouchEvent() like normal.

     * <li> For as long as you return false from this function, each following

     * event (up to and including the final up) will be delivered first here

     * and then to the target's onTouchEvent().

     * <li> If you return true from here, you will not receive any

     * following events: the target view will receive the same event but

     * with the action {@link MotionEvent#ACTION_CANCEL}, and all further

     * events will be delivered to your onTouchEvent() method and no longer

     * appear here.

     * </ol>

     *

     * @param ev The motion event being dispatched down the hierarchy.

     * @return Return true to steal motion events from the children and have

     * them dispatched to this ViewGroup through onTouchEvent().

     * The current target will receive an ACTION_CANCEL event, and no further

     * messages will be delivered here.

     */

    public boolean onInterceptTouchEvent(MotionEvent ev) {

        return false;

    }

     

    前两段告诉我们,复写onInterceptTouchEvent方法,可以实现监听所有的动作事件MotionEvent,在向子view传递事件前做我们需要的操作,当然这指的是和这个viewgroup相关的事件;同时我们需要慎重处理该函数,因为他和onTouchEvent关系非常紧密,下面是事件接收的顺序:

    首先接收的的事按下事件,down事件,他可以被view处理也可以在自身的onTouchEvent里处理,所以实现onTouchEvent并且返回true,这样onTouchEvent继续才能收到down之后的其他事件,同时onInterceptTouchEvent不会在收到后续事件,因为已经转移到onTouchEvent处理了。

    那么什么时候onInterceptTouchEvent会把后续事件转移到他的onTouchEvent呢?这取决于onInterceptTouchEvent的返回值,如果返回false,所有事件都会先分发到这里,然后再到目标view的onTouchEvent;相反如果返回true,那么onInterceptTouchEvent将不再收到后续事件,并且目标view会收到cancel事件,接着自身的onTouchEvent几首后续的事件。

    这其实从名字来看是比较好理解的onInterceptTouchEvent表示在截取触摸事件的被调用的方法,既然是截取就可以直接吧事件截下来后不再往后传递,这是就是上面的第二种情况,返回true,即我们自己消耗了触摸事件,子view将没有机会得到唤醒。

    OnInterceptTouchEvent

    大致意思就是如果希望自身消耗掉改事件就可以直接返回true,这一点和onTouchEvent的返回类似目的。

    博客园有篇文章对这些事件分发做了很好的分析:http://www.cnblogs.com/sunzn/archive/2013/05/10/3064129.html

    详细的阐述了了dispatchTouchEvent,onInterceptTouchEvent以及onTouchEvent之间的关系

    现在我们回过头来看DrawerLayout里的分发是如何写的:

    重写了后面两个方法,先看onInterceptTouchEvent:

    @Override

    public boolean onInterceptTouchEvent(MotionEvent ev) {

        final int action = MotionEventCompat.getActionMasked(ev);

     

        // "|" used deliberately here; both methods should be invoked.

        final boolean interceptForDrag = mLeftDragger.shouldInterceptTouchEvent(ev) |

                mRightDragger.shouldInterceptTouchEvent(ev);

     

        boolean interceptForTap = false;

     

        switch (action) {

            case MotionEvent.ACTION_DOWN: {

                final float x = ev.getX();

                final float y = ev.getY();

                mInitialMotionX = x;

                mInitialMotionY = y;

                if (mScrimOpacity > 0 &&

                        isContentView(mLeftDragger.findTopChildUnder((int) x, (int) y))) {

                    interceptForTap = true;

                }

                mDisallowInterceptRequested = false;

                mChildrenCanceledTouch = false;

                break;

            }

     

            case MotionEvent.ACTION_MOVE: {

                // If we cross the touch slop, don't perform the delayed peek for an edge touch.

                if (mLeftDragger.checkTouchSlop(ViewDragHelper.DIRECTION_ALL)) {

                    mLeftCallback.removeCallbacks();

                    mRightCallback.removeCallbacks();

                }

                break;

            }

     

            case MotionEvent.ACTION_CANCEL:

            case MotionEvent.ACTION_UP: {

                closeDrawers(true);

                mDisallowInterceptRequested = false;

                mChildrenCanceledTouch = false;

            }

        }

     

        return interceptForDrag || interceptForTap || hasPeekingDrawer() || mChildrenCanceledTouch;

    }

     

    1.首先从touch event里面获取当前具体的action动作,MotionEventCompat.getActionMasked(ev),内部实际上做了一次按位于操作event.getAction() & ACTION_MASK;

    2.检查当前是否满足截取drag状态,用于决定onInterceptTouchEvent返回值,这里有个注解说是故意用了|或,而不是||或,两者区别在于||只要第一个条件满足就不在执行第二个检查,二|不同,无论如何都会将两个条件检查一遍;

    3.接下来是几个case,根据当前的action做处理;

    ACTION_DOWN,当按下时记录按下点的x,y坐标值,根据条件设置当前是否满足tap状态,具体条件有两个,一是mScrimOpacity,表示子view中在屏幕上占据的最大宽度(0-1),二时根据坐标点的位置取得改点对应的最上层view对象,如果是预定义的content view即DrawerLayout里的主内容展示view,也就是同时满足view在屏幕上且点击的位置直接落在了content view上。

    ACTION_MOVE,当手按下后开始在屏幕上移动时,如果垂直和水平上的位移差量达到了drag helper的阀值则一处左右两边的回调接口

    ACTION_CANCLE和ACTION_UP,手势结束后,关闭菜单

    最后结合几个状态来那个来决定onInterceptTouchEvent返回true还是false,

    未完待续

    在[UI]抽屉菜单DrawerLayout分析(一)和[UI]抽屉菜单DrawerLayout分析(二)中分别介绍了DrawerLayout得基本框架结构和ViewDragerHelper的作用以及手势分发,本文一起来分析其中的Scroller的使用情况。

          在ViewDragerHelper中可以发现private ScrollerCompat mScroller;说明抽屉菜单的具体滑动也是依赖于Scroller的使用,检索一下mScroller的引用,定位到forceSettleCapturedViewAt,这个方法回调用Scroller的startScroll来计算位移,它本身适用于计算和保存位移在特定时间的变化情况,最终的在绘制view时我可以获取其保存的x,y坐标值。

    /**

     * Settle the captured view at the given (left, top) position.

     *

     * @param finalLeft Target left position for the captured view

     * @param finalTop Target top position for the captured view

     * @param xvel Horizontal velocity

     * @param yvel Vertical velocity

     * @return true if animation should continue through {@link #continueSettling(boolean)} calls

     */

    private boolean forceSettleCapturedViewAt(int finalLeft, int finalTop, int xvel, int yvel) {

        final int startLeft = mCapturedView.getLeft();

        final int startTop = mCapturedView.getTop();

        final int dx = finalLeft - startLeft;

        final int dy = finalTop - startTop;

     

        if (dx == 0 && dy == 0) {

            // Nothing to do. Send callbacks, be done.

            mScroller.abortAnimation();

            setDragState(STATE_IDLE);

            returnfalse;

        }

     

        final int duration = computeSettleDuration(mCapturedView, dx, dy, xvel, yvel);

        mScroller.startScroll(startLeft, startTop, dx, dy, duration);

     

        setDragState(STATE_SETTLING);

        returntrue;

    }

          这里用的是v4扩展包里的ScrollerCompat用于低版本兼容,它继承自ScrollerCompatImpl,可以看到里面主要的方法声明:

    interface ScrollerCompatImpl{

        Object createScroller(Context context, Interpolator interpolator);

        boolean isFinished(Object scroller);

        int getCurrX(Object scroller);

        int getCurrY(Object scroller);

        float getCurrVelocity(Object scroller);

        boolean computeScrollOffset(Object scroller);

        void startScroll(Object scroller, int startX, int startY, int dx, int dy);

        void startScroll(Object scroller, int startX, int startY, int dx, int dy, int duration);

        void fling(Object scroller, int startX, int startY, int velX, int velY,

                int minX, int maxX, int minY, int maxY);

        void fling(Object scroller, int startX, int startY, int velX, int velY,

                int minX, int maxX, int minY, int maxY, int overX, int overY);

        void abortAnimation(Object scroller);

        void notifyHorizontalEdgeReached(Object scroller, int startX, int finalX, int overX);

        void notifyVerticalEdgeReached(Object scroller, int startY, int finalY, int overY);

        boolean isOverScrolled(Object scroller);

        int getFinalX(Object scroller);

        int getFinalY(Object scroller);

    }

    DragActionMethodFlow

    从Scroller一直往上追溯,可以得到如图的调用流程。

    当滑动屏幕时,DrawerLayout中的手势分发被触发,先执行onInterceptTouchEvent根据返回结果确定是否执行onTouchEvent,之后就是一些和ViewDragHelper之间的回调接口处理。

    接下来追踪一下什么时候从Scroller中取出x,y来使用:

    Scroller

    在View里面有一个实现为空的computeScroll,DrawerLayout对它进行重写,这个方法应该是在view自动重绘是会被调用,回到continueSettling:

    /**

     * Move the captured settling view by the appropriate amount for the current time.

     * If <code>continueSettling</code> returns true, the caller should call it again

     * on the next frame to continue.

     *

     * @param deferCallbacks true if state callbacks should be deferred via posted message.

     *                       Set this to true if you are calling this method from

     *                       {@link android.view.View#computeScroll()} or similar methods

     *                       invoked as part of layout or drawing.

     * @return true if settle is still in progress

     */

    public boolean continueSettling(boolean deferCallbacks) {

        if (mDragState == STATE_SETTLING) {

            boolean keepGoing = mScroller.computeScrollOffset();

            final int x = mScroller.getCurrX();

            final int y = mScroller.getCurrY();

            final int dx = x - mCapturedView.getLeft();

            final int dy = y - mCapturedView.getTop();

     

            if (dx != 0) {

                mCapturedView.offsetLeftAndRight(dx);

            }

            if (dy != 0) {

                mCapturedView.offsetTopAndBottom(dy);

            }

     

            if (dx != 0 || dy != 0) {

                mCallback.onViewPositionChanged(mCapturedView, x, y, dx, dy);

            }

     

            if (keepGoing && x == mScroller.getFinalX() && y == mScroller.getFinalY()) {

                // Close enough. The interpolator/scroller might think we're still moving

                // but the user sure doesn't.

                mScroller.abortAnimation();

                keepGoing = mScroller.isFinished();

            }

     

            if (!keepGoing) {

                if (deferCallbacks) {

                    mParentView.post(mSetIdleRunnable);

                } else {

                    setDragState(STATE_IDLE);

                }

            }

        }

     

        return mDragState == STATE_SETTLING;

    }

     

    当状态处于STATE_SETTLING时开始获取Scroller中的x,y值,结合当前运动view的left,top位置,计算出偏移量,通过offsetLeftAndRight设置,里面是一些具体的位置改变,挺复杂的。

    /**

     * Offset this view's horizontal location by the specified amount of pixels.

     *

     * @param offset the number of pixels to offset the view by

     */

    public void offsetLeftAndRight(int offset) {

        if (offset != 0) {

            updateMatrix();

            final boolean matrixIsIdentity = mTransformationInfo == null

                    || mTransformationInfo.mMatrixIsIdentity;

            if (matrixIsIdentity) {

                if (mDisplayList != null) {

                    invalidateViewProperty(false, false);

                } else {

                    final ViewParent p = mParent;

                    if (!= null && mAttachInfo != null) {

                        final Rect r = mAttachInfo.mTmpInvalRect;

                        int minLeft;

                        int maxRight;

                        if (offset < 0) {

                            minLeft = mLeft + offset;

                            maxRight = mRight;

                        } else {

                            minLeft = mLeft;

                            maxRight = mRight + offset;

                        }

                        r.set(0, 0, maxRight - minLeft, mBottom - mTop);

                        p.invalidateChild(this, r);

                    }

                }

            } else {

                invalidateViewProperty(false, false);

            }

     

            mLeft += offset;

            mRight += offset;

            if (mDisplayList != null) {

                mDisplayList.offsetLeftAndRight(offset);

                invalidateViewProperty(false, false);

            } else {

                if (!matrixIsIdentity) {

                    invalidateViewProperty(false, true);

                }

                invalidateParentIfNeeded();

            }

        }

    }

    小结

    至此DrawerLayout的基本工作流程分析完毕,简单做一个总结,v4包提供了ViewDragHelper类,里面封装了对scroller合view的位移操作,和Callback接口,通过DrawerLayout内的onInterceptTouchEvent和onTouchEvent的重载,触发ViewDragHelper内的相关方法,同时在DrawerLayout内实现ViewDragHelp.Callback.

     

    作者:小文字
    本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利.

  • 相关阅读:
    Dynamic 365 中创建编码规则
    程序员和产品经理之间的恩怨情仇
    Scrum已经俘获中国开发者的心? ——从《2017年开发者调查报告》看真相!
    不懂营销的产品经理不是好的产品经理
    关于程序猿之间丧心病狂的鄙视链——编辑器篇
    国内五款好用的开源建站系统
    程序员听到bug后的N种反应,太形象了
    功能至上!国内外最实用的协作类软件盘点
    关于程序员之间丧心病狂的鄙视链——编程语言篇
    结对编程体会
  • 原文地址:https://www.cnblogs.com/runwind/p/4454630.html
Copyright © 2011-2022 走看看