Android 酷炫自定义 View:高仿 QQ 窗帘菜单

之前写过一篇自定义View的文章,最近搬出来统一放在简书上。

介绍

不知道大家是否有印象,QQ 曾经有个版本用到了一种双向侧拉菜单,就像窗帘一样可以两边开合,并且伴有 3D 旋转效果,效果非常酷炫,吸引很多人模仿实现。

Android 系统提供了一个侧拉抽屉控件,叫 DrawerLayout,使用过的人都知道,效果不错并且有一定拓展性,基于 DrawerLayout 我们可以实现 QQ 的效果,但是今天我们要介绍的是另一个思路:自定义 HorizontalScrollView。

这个思路非常简单,并且你可以很方便地拓展出任何你想要的效果,说不定做的比 QQ 更酷炫哦。

效果

首先来看下最终实现的效果(gif 版)

2D 模式
enter image description here
3D 模式
enter image description here

自定义 View 基础

Android 自定义 View 是一个很大的主题,一篇文章肯定是讲不完的,GcsSloop 的自定义 View 系列文章写了十几篇都不能做到面面俱到,所以今天这篇文章我们就从一个小案例入手,讲讲如何实现双向侧拉菜单。

大家都知道 Android 自定义 View 分为两大类,一是自定义 View,二是自定义 ViewGroup,这篇文章要讲的显然是自定义 ViewGroup。

自定义 View 和 ViewGroup 的区别就是 ViewGroup 除了负责自身的显示效果外,里面还要包含其它的子 View,这必然带来复杂性增加,表现在代码里就是自定义 View 通常只需要复写 onDraw 和 onTouch,而自定义 ViewGroup 还要考虑子 View 的测量、子 View 的布局、子 View 的事件分发等等,涉及到的方法了 onMeasureonLayoutdispatchTouchEventonInterceptTouchEvent等。

其中事件分发是一个重点,而在自定义 View 种很重要的 onDraw 反而不是最重要的。

// 自定义View
class CustomView extends View {
    构造方法();
    onDraw();
    onTouch();
}

// 自定义ViewGroup
class CustomViewGroup extends <T instanceOf ViewGroup> {
    构造方法();
    onDraw();
    onTouch();
    onMeasure();
    onLayout();
    dispatchTouchEvent();
    onInterceptTouchEvent();
}

当然自定义 View 和自定义 ViewGroup 也有很多共通的,比如自定义属性,绘制函数等。那我们闲言少叙,开始动手实现吧。

实现思路

我们看上面的效果挺酷炫的,感觉无从下手,但是仔细观察你会发现,其实整个界面分为三部分:左边菜单、中间主布局、右边菜单。它们的位置关系是从左到右依次排列。再仔细观察菜单的切换你会发现,忽略缩放、透明度等动画,其实菜单切换的过程就是三部分滚动的过程,于是,我们就有了一个大体的思路:

用一个 HorizontalScrollView 包裹三个部分的试图,通过控制 HorizontalScrollView 的滚动距离来实现展示不同的部分。 (如下图)

enter image description here

当然,这只是一个思路,距离最终效果还差一些,我们基于这个思路,要解决以下几个问题:

(1)初始的时候要展示中间主布局。
(2)左右菜单区域的宽度要客配置。
(3)松手后,不能停在菜单的一半处,要能自动收起或打开菜单。
(4)左右菜单要是可配置的,因为用户可能只需要左侧菜单或者只需要右侧菜单。
(5)复杂的事件分发。
(6)菜单切换时的 3D 效果。

自定义 HorizontalScrollView

有了思路,我们就有了方向,废话不多说,开始撸代码。

(1)首先新建一个类,集成自 HorizontalScrollView
public class CurtainsLayout extends HorizontalScrollView {

    public CurtainsLayout(Context context) {
        this(context, null, 0);
    }

    public CurtainsLayout(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CurtainsLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    @Override
    protected void onFinishInflate() {

    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
    }

    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
    }

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        return super.onTouchEvent(e);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        return super.onInterceptTouchEvent(e);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent e) {
        return super.dispatchTouchEvent(e);
    }
}

架子出来了,现在往架子里填内容,先来获取 3 个子 View。

(2)获取子 View

通过上面的分析我们知道一共有三个子 View:左侧菜单、中间主体、右侧菜单,但是这三个子 View 不一定全有,如果用户只配置了左侧菜单,那右侧菜单子 View 就不存在。

if (左右菜单都有) {
    第0个子View是左侧菜单
    第1个子View是中间主体
    第2个子View是右侧菜单
} else if (只有左侧菜单) {
    第0个子View是左侧菜单
    第1个子View是中间主体
} else if (只有右侧菜单) {
    第0个子View是中间主体
    第1个子View是中间主体
}

首先我们要定义三种菜单类型常量,代表上面三种菜单类型:

public static final int MENU_TYPE_LEFT = 1;
public static final int MENU_TYPE_RIGHT = 1 << 1;
public static final int MENU_TYPE_BOTH = MENU_TYPE_LEFT | MENU_TYPE_RIGHT ;

然后根据菜单类型获取子 View:

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    LinearLayout wrapper = (LinearLayout) getChildAt(0);
    if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT
            && (mMenuType & MENU_TYPE_RIGHT) == MENU_TYPE_RIGHT) {
        mLeftMenu = (ViewGroup) wrapper.getChildAt(0);
        mContent = (ViewGroup) wrapper.getChildAt(1);
        mRightMenu = (ViewGroup) wrapper.getChildAt(2);
    } else if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
        mLeftMenu = (ViewGroup) wrapper.getChildAt(0);
        mContent = (ViewGroup) wrapper.getChildAt(1);
    } else {
        mContent = (ViewGroup) wrapper.getChildAt(0);
        mRightMenu = (ViewGroup) wrapper.getChildAt(1);
    }
}

(3)菜单宽度

获取到了三个子 View,下面就要设置子 View 的宽度。中间主体的宽度是屏幕宽度,这个没啥好说的。左右菜单的宽度是要窄一点的。

我们是这样定义的:左侧菜单是主菜单,显示的内容比较多,所有左侧菜单宽度我们是用屏幕宽度 - 右侧边距,而右侧菜单是次菜单,就显示一个按钮。所以右侧按钮宽度就由用户直接指定。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT
            && (mMenuType & MENU_TYPE_RIGHT) == MENU_TYPE_RIGHT) {
        // mLeftMenuRightPadding是由用户配置的
        mLeftMenuWidth = mScreenWidth - mLeftMenuRightPadding;
        mLeftMenu.getLayoutParams().width = mLeftMenuWidth;
        mRightMenuWidth = mRightMenu.getMeasuredWidth();
    } else if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
        mLeftMenuWidth = mScreenWidth - mLeftMenuRightPadding;
        mLeftMenu.getLayoutParams().width = mLeftMenuWidth;
    } else {
        mRightMenuWidth = mRightMenu.getMeasuredWidth();
    }

    mContentWidth = mScreenWidth;
    mContent.getLayoutParams().width = mContentWidth;
    mContentHeight = mContent.getMeasuredHeight();
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

(4)初始展示中间主体布局

这个就很简单了,HorizontalScrollView 默认的滚动位置是 0,所以就会展示左侧菜单,我们只要把滚动位置设置到左侧菜单宽度就行。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    super.onLayout(changed, l, t, r, b);
    if (changed) {
          if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
               this.scrollTo(mLeftMenuWidth, 0);
           } else {
               this.scrollTo(0, 0);
           }
    }
}

(5)自动回弹

下面就是重点了,很重很重的点。我们在滚动时,松手后应该能自动根据当前滚动位置关闭或者打开菜单。通常就是以菜单的一半作为分界线。

if(滚动距离 < 左侧菜单宽度一半) {
    打开左侧菜单
} else if(滚动距离 >= 左侧菜单宽度一半) {
    关闭左侧菜单
} else if(滚动距离 < 左侧菜单宽度 + 右侧菜单宽度一半) {
    关闭右侧菜单
} else if(滚动距离 >= 左侧菜单宽度 + 右侧菜单宽度一半) {
    打开右侧菜单
}

上面这段逻辑如果不明白的可以多看几遍,明白这个逻辑后才能看下面的代码实现。

@Override
public boolean onTouchEvent(MotionEvent e) {
    switch (e.getAction()) {
        case MotionEvent.ACTION_UP:
            int scrollX = getScrollX();
            if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT
                    && (mMenuType & MENU_TYPE_RIGHT) == MENU_TYPE_RIGHT) {
                if (scrollX <= mLeftMenuWidth / 2) {
                    this.smoothScrollTo(0, 0);
                    isOpen = true;
                    mState = STATE_OPEN_LEFT;
                } else if (scrollX > mLeftMenuWidth / 2 && scrollX <= mLeftMenuWidth){
                    this.smoothScrollTo(mLeftMenuWidth, 0);
                    isOpen = false;
                    mState = STATE_CLOSE;
                } else if (scrollX > mLeftMenuWidth && scrollX <= mLeftMenuWidth + mRightMenuWidth / 2) {
                    this.smoothScrollTo(mLeftMenuWidth, 0);
                    isOpen = false;
                    mState = STATE_CLOSE;
                } else {
                    this.smoothScrollTo(mLeftMenuWidth + mRightMenuWidth, 0);
                    isOpen = true;
                    mState = STATE_OPEN_RIGHT;
                }
            } else if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
                if (scrollX > mLeftMenuWidth / 2) {
                    this.smoothScrollTo(mLeftMenuWidth, 0);
                    isOpen = false;
                    mState = STATE_CLOSE;
                } else {
                    this.smoothScrollTo(0, 0);
                    isOpen = true;
                    mState = STATE_OPEN_LEFT;
                }
            } else {
                if (scrollX > mRightMenuWidth / 2) {
                    this.smoothScrollTo(mRightMenuWidth, 0);
                    isOpen = true;
                    mState = STATE_OPEN_RIGHT;
                } else {
                    this.smoothScrollTo(0, 0);
                    isOpen = false;
                    mState = STATE_CLOSE;
                }
            }
            return true;
        }
    return super.onTouchEvent(e);
}

霍,怎么这么多代码?原因是我们要考虑三种菜单类型,每种类型关闭菜单的滚动距离是不一样的。所以实现起来要分开考虑,代码自然就多了。

enter image description here
(6)事件分发

啊,终究逃不过这一关,自定义 ViewGroup 是一定要面对事件分发的。

我们的预期是这样的:

a、当菜单关闭(左右菜单都关闭,中间主体全屏展示)的时候,不拦截事件,用户可以点击页面元素,滑动列表。

b、当菜单打开(左右菜单都一样)的时候,点击中间主体区域时拦截事件,点击其它地方不拦截事件。也就是说当菜单打开时,主体区域的页面元素不可点击,列表也不可滑动,但是菜单区域的元素可以点击。

这里需要两个判断条件:菜单是否打开、是否点击在中间主体区域。

菜单是否打开很简单,我们设置一个变量 isOpen,每次打开菜单置为 true,关闭菜单置为 false。

是否点击在中间主体区域稍微复杂一点,我们首先要获取手指点击相对于屏幕的坐标值。

int rawX = (int)e.getRawX();
int rawY = (int)e.getRawY();

然后我们要获取中间主体 View 所占的区域:

int[] location = new  int[2] ;
mContent.getLocationOnScreen(location);
int left = location[0];
int top = location[1];
int right = left + (int)(mContentWidth * SCALE_CONTENT);
int bottom = top + (int)(mContentHeight * SCALE_CONTENT);
Rect rect = new Rect(left, top, right, bottom);

这里为什么要乘以一个 SCALE_CONTENT 呢?这个值是主体区域在动画过程中的缩放比例,乘以这个缩放比例就可以得到缩放后的宽高。

有了这两步,判断是否点击在中间主体区域就很简单了

rect.contains(rawX, rawY);

完整代码:

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    switch (e.getAction()) {
        case MotionEvent.ACTION_DOWN:
            if (!isOpen) {
                return super.onInterceptTouchEvent(e);
            }
            int rawX = (int)e.getRawX();
            int rawY = (int)e.getRawY();
            if (mFingerPoint == null) {
                mFingerPoint = new Point(rawX, rawY);
            } else {
                mFingerPoint.set(rawX, rawY);
            }
            int[] location = new  int[2] ;
            mContent.getLocationOnScreen(location);
            int left = location[0];
            int top = location[1];
            int right = left + (int)(mContentWidth * SCALE_CONTENT);
            int bottom = top + (int)(mContentHeight * SCALE_CONTENT);
            Rect rect = new Rect(left, top, right, bottom);
            mTapContains = rect.contains(rawX, rawY);
            return mTapContains || super.onInterceptTouchEvent(e);
    }
    return super.onInterceptTouchEvent(e);
}

(7)3D 动画

这个菜单的效果全靠这个动画撑起来的,看似复杂,其实动画是最简单的。

我们根据左右菜单拉出的百分比计算各个 View 的平移、缩放、alpha 动画值,如图在 3D 模式下,再加上一个旋转。旋转我们只针对左侧菜单和中间主体,右侧菜单不旋转。

@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
    super.onScrollChanged(l, t, oldl, oldt);
    if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT
            && (mMenuType & MENU_TYPE_RIGHT) == MENU_TYPE_RIGHT) {
        if (l <= mLeftMenuWidth) {
            float openPercent = 1.0f - l * 1.0f / mLeftMenuWidth;
            animLeft(openPercent);
        } else {
            float openPercent = (l - mLeftMenuWidth) * 1.0f / mRightMenuWidth;
            animRight(openPercent);
        }
    } else if ((mMenuType & MENU_TYPE_LEFT) == MENU_TYPE_LEFT) {
        float openPercent = 1.0f - l * 1.0f / mLeftMenuWidth;
        animLeft(openPercent);
    } else {
        float openPercent = l * 1.0f / mRightMenuWidth;
        animRight(openPercent);
    }
}

private void animLeft(float openPercent) {
    if(openPercent < 0) {
        openPercent = 0;
    }
    if (openPercent > 1) {
        openPercent = 1;
    }

    float menuScale = SCALE_LEFT_MENU + (1 - SCALE_LEFT_MENU) * openPercent;
    float contentScale = 1 - openPercent * (1 - SCALE_CONTENT);

    mLeftMenu.setScaleX(menuScale);
    mLeftMenu.setScaleY(menuScale);
    mLeftMenu.setAlpha(openPercent);
    mLeftMenu.setTranslationX(mLeftMenuWidth * (1 - openPercent) * TRANS_LEFT_MENU);
    if (mWith3D) {
        mLeftMenu.setRotationY((1 - openPercent) * -mMenuRotate);
    } else if (mLeftMenu.getRotationY() != 0) {
        mLeftMenu.setRotationY(0);
    }

    mContent.setPivotX(0);
    mContent.setPivotY(mContent.getHeight() / 2);
    mContent.setScaleX(contentScale);
    mContent.setScaleY(contentScale);
    if (mWith3D) {
        mContent.setRotationY(openPercent * mContentRotate);
    } else if (mContent.getRotationY() != 0) {
        mContent.setRotationY(0);
    }

    if (mCurtainsListener != null) {
        mCurtainsListener.onLeftOpen(openPercent);
    }
}

private void animRight(float openPercent) {
    if (openPercent < 0) {
        openPercent = 0;
    }
    if (openPercent > 1) {
        openPercent = 1;
    }

    float menuScale = SCALE_RIGHT_MENU + (1 - SCALE_RIGHT_MENU) * openPercent;
    float contentScale = 1 - openPercent * (1 - SCALE_CONTENT);

    mRightMenu.setScaleX(menuScale);
    mRightMenu.setScaleY(menuScale);
    mRightMenu.setAlpha(openPercent);
    mRightMenu.setTranslationX(-1 * mRightMenuWidth * (1 - openPercent) * TRANS_RIGHT_MENU);

    mContent.setPivotX(mContentWidth);
    mContent.setPivotY(mContent.getHeight() / 2);
    mContent.setScaleX(contentScale);
    mContent.setScaleY(contentScale);

    if (mCurtainsListener != null) {
        mCurtainsListener.onRightOpen(openPercent);
    }
}

自定义属性

好了,整个窗帘菜单基本已经实现了,但是要完善一下自定义属性,方便用户配置。

// attr.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    // 左侧菜单的右边距
    <attr name="rightPadding" format="dimension" />
    // 菜单类型
    <attr name="menuType" format="enum">
        <enum name="leftMenu" value="0x1" />
        <enum name="rightMenu" value="0x2" />
        <enum name="doubleMenu" value="0x3" />
    </attr>
    // 是否打开3D模式
    <attr name="with3D" format="boolean" />
    // 3D模式下菜单的旋转角度
    <attr name="menuRotate" format="integer" />
    // 3D模式下内容区域的旋转角度
    <attr name="contentRotate" format="integer" />

    <declare-styleable name="CurtainsLayout">
        <attr name="rightPadding" />
        <attr name="menuType" />
        <attr name="with3D" />
        <attr name="menuRotate" />
        <attr name="contentRotate" />
    </declare-styleable>
</resources>

使用

自定义封装好了,当然就要给别人用啦,使用很简单。

<com.makeunion.curtainslayout.CurtainsLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:curtains="http://schemas.android.com/apk/res-auto"
    android:id="@+id/id_menu"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:background="@drawable/menu_bg"
    android:overScrollMode="never"
    android:scrollbars="none"
    curtains:rightPadding="100dp"
    curtains:menuType="doubleMenu"
    curtains:with3D="true"
    curtains:contentRotate="15"
    curtains:menuRotate="20">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal" >

        <include layout="@layout/layout_left_menu" />

        <include layout="@layout/layout_content" />

        <include layout="@layout/layout_right_menu" />
    </LinearLayout>
</com.makeunion.curtainslayout.CurtainsLayout>

总结

至此,自定义窗帘菜单我们就讲完了,看完你可能还是觉得一脸懵逼。很正常,上面讲的是思路和主要方法实现,除此之外还有很多边缘性的东西,要想看完整的实现请移步源码。如有错误或者疑问,请在讨论区提出。

码云 git:
https://gitee.com/makeunion/CurtainsLayout

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,519评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,842评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,544评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,742评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,646评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,027评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,513评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,169评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,324评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,268评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,299评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,996评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,591评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,667评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,911评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,288评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,871评论 2 341

推荐阅读更多精彩内容