Android:一步步开发一个高度可定制化的扩展菜单

效果图

这里写图片描述

这里写图片描述

本文想试着从头开始讲解,中间贴的代码只是部分的,如果需要全部代码请翻到最后,有造好的轮子和源码.

需求:

如效果图所示的效果大家应该见过很多了,但是很多都是把每个菜单的按钮的样式基本上固定了,虽然可以用但是对于不同的项目来说风格真的能搭配上吗?能不能做到每个菜单样式都能自己定义而且不用太过于麻烦?

实现思路:

1.自定义ViewGroup,用户只需要往这个组件里面添加按钮即可,组件负责处理菜单按钮的功能,显示,动画等等

2.添加菜单项要是能从xml文件中添加就更好了,方便预览菜单按钮的效果

详细思路:

之前了解过其他类似的项目的内部实现方式,有的是默认把所有按钮叠加在一起 ,让展开按钮覆盖后面的菜单按钮,点击展开按钮的时候用ObjectAnimation将其他组件移动到位置,个人觉得这样实现起来是否太过于复杂,为何不能先把菜单按钮放置到展开之后的位置,然后通过动画来做位移的效果,配合按钮的显示与隐藏也能达到同样的效果,而且菜单项本身是没有发生位置变化的.

代码实现:

1. 第一步,自定义一个ViewGroup,能够让添加到其中的View按照效果图摆放

设计思路:第一个ChildView和最后一个ChildView分别当作点击展开和关闭的按钮,都放置到右下角,其他的菜单按照自动换行的效果摆放,如下图
这里写图片描述

代码实现:

创建自定义ViewGroup,继承ViewGroup,重写构造方法:
public class ExpandableMenu extends ViewGroup {
    

    public ExpandableMenu(@NonNull Context context) {
        this(context, null);
    }

    public ExpandableMenu(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
    }

这时候需要重写onMeasure方法来测量子组件并且规定父组件的大小

循环测量子组件,因为一般菜单的使用场景就是覆盖到顶部,所以父组件的大小就干脆都设置为match_parent

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        for (int i = 0; i < getChildCount(); i++) {
            View childView = getChildAt(i);
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }
        setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
    }

初次之外还需要重写onLayout方法来设置子View的位置

传入的参数依次为isChanged,left,top,right,bottom,其中的int值为父组件的四个方向的位置,就是我们用于放置子组件的依据
 @Override
protected void onLayout(boolean b, int i, int i1, int i2, int i3) {

}
规定子View的位置的方法为:
childView.layout(left, top, right, bottom);
能获取到childView的宽高,那么只需要确定其left,top的值即可获取到位置的四个参数了

如何获取子Viewleft,top?

这里写图片描述
简要的画了个图说明一下,原本是应该考虑每个组件的magin的,后来发现可以按照简单的方法来,不考虑magin,这样onLayout的实现会简单很多,而且可以用padding来达到和magin同样的效果
为了达到自动换行的效果,需要设置一个X方向和Y方向的标志位,每放置一个子View需要对X,Y的值进行变化,X的值是每次减少一个子View的宽度,Y不变,直到X<=0,即需要换行,此时X恢复,Y需要变化,具体的实现代码如下:
@Override
    protected void onLayout(boolean b, int i, int i1, int i2, int i3) {
        int left = i2;
        int top = i3;
        int x = 0;
        int y = 0;

        for (int j = 0; j < getChildCount(); j++) {
            View childView = getChildAt(j);
            int width = childView.getMeasuredWidth();
            int height = childView.getMeasuredHeight();
            left = left - width;
            x++;
            if (top == i3) {
                top = i3 -  height;
            }
            if (left < 0) {
                y++;
                x = 1;
                left = i2 -  width;
                top = top -  height;
            }
            if (j == getChildCount() - 1) {
                // 最后一个,放置在第一个的位置
                childView.layout(i2 - width, i3 - height, i2, i3);
            } else {
                childView.layout(left, top, left + width, top + height);
            }
        }
    }

之后需要编写展开菜单和隐藏菜单的动画,这部分很简单,所以直接放代码,封装了两个方法,处理动画和菜单项的显示隐藏

其中位移动画是从第一个View的位置移动到当前的位置,位移的距离及是当前Viewleft,top值与第一个子View的对应参数的差.
/**
     * 展开菜单
     */
    private void expand() {
        // 隐藏第一个按钮
        getChildAt(0).setVisibility(GONE);
        // 显示最后一个按钮
        getChildAt(getChildCount() - 1).setVisibility(VISIBLE);
        isExpend = true;
        for (int i = 1; i < getChildCount() - 1; i++) {
            View childView = getChildAt(i);
            TranslateAnimation animation = new TranslateAnimation(
                    getChildAt(0).getLeft() - childView.getLeft(), 0.0f, getChildAt(0).getTop() - childView.getTop(), 0.0f
            );
            animation.setInterpolator(mInterpolator);
            childView.setVisibility(VISIBLE);
            animation.setDuration(mDuration);
            childView.startAnimation(animation);
        }
    }

对应的隐藏菜单的方法

执行与展开相反的动画,并且在动画结束的时候把菜单项隐藏
 /**
     * 关闭菜单
     */
    private void close() {
        // 显示第一个按钮
        getChildAt(0).setVisibility(VISIBLE);
        // 隐藏最后一个按钮
        getChildAt(getChildCount() - 1).setVisibility(GONE);
        // 收回菜单
        isExpend = false;
        for (int i = 1; i < getChildCount() - 1; i++) {
            final View childView = getChildAt(i);
            TranslateAnimation animation = new TranslateAnimation(
                    0.0f, getChildAt(0).getLeft() - childView.getLeft(), 0.0f, getChildAt(0).getTop() - childView.getTop()
            );
            animation.setInterpolator(mInterpolator);
            animation.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {

                }

                @Override
                public void onAnimationEnd(Animation animation) {
                    childView.setVisibility(GONE);
                }

                @Override
                public void onAnimationRepeat(Animation animation) {

                }
            });
            childView.setVisibility(VISIBLE);
            animation.setDuration(mDuration);
            childView.startAnimation(animation);
        }
    }

之后需要给按钮设置展开和关闭的点击事件,同时默认隐藏其他按钮,代码如下:

private void init() {
        for (int i = 0; i < getChildCount(); i++) {
            View childView = getChildAt(i);
            if (i != 0) {
                childView.setVisibility(GONE);
            }
        }
        // 设置点击事件
        getChildAt(0).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                expand();
            }
        });
        getChildAt(getChildCount() - 1).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                close();
            }
        });
    }
init()方法需要在子View已经添加进来之后再调用,所以我将init方法放置在了onLayout之后,保证获取到的子View不会为空,但是onLayout在被调用很多次,所以加了个标志位,如下:
if (!isInited) {
     init();
     isInited = true;
}

至此组件的编写就完了,没有多余的方法,要添加菜单可以直接在xml内添加,也可以用代码添加,只要注意菜单项的大小应当相同,并且第一个和最后一个view是用于展开和关闭的,至于点击事件,在添加到View之前设置即可,不用给第一个和最后一个view设置点击事件,因为设置了也会被覆盖掉.

xml使用方式如下:


    <com.brioal.view.ExpandableMenu
        android:id="@+id/expandableMenu"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom">
        <!--展开按钮-->
        <ImageButton
            android:layout_width="105dp"
            android:layout_height="105dp"
            android:background="@android:color/transparent"
            android:padding="5dp"
            android:scaleType="centerCrop"
            android:src="@drawable/ic_add_black"/>
        <!--菜单按钮-->
        <ImageButton
            android:layout_width="105dp"
            android:layout_height="105dp"
            android:background="@android:color/transparent"
            android:padding="5dp"
            android:scaleType="centerCrop"
            android:src="@mipmap/ic_launcher_round"/>
        <!--菜单按钮-->
        <ImageButton
            android:layout_width="105dp"
            android:layout_height="105dp"
            android:background="@android:color/transparent"
            android:padding="5dp"
            android:scaleType="centerCrop"
            android:src="@mipmap/ic_launcher_round"/>
        <!--菜单按钮-->
        <ImageButton
            android:layout_width="105dp"
            android:layout_height="105dp"
            android:background="@android:color/transparent"
            android:padding="5dp"
            android:scaleType="centerCrop"
            android:src="@mipmap/ic_launcher_round"/>
        <!--菜单按钮-->
        <ImageButton
            android:layout_width="105dp"
            android:layout_height="105dp"
            android:background="@android:color/transparent"
            android:padding="5dp"
            android:scaleType="centerCrop"
            android:src="@mipmap/ic_launcher_round"/>
        <!--菜单按钮-->
        <ImageButton
            android:layout_width="105dp"
            android:layout_height="105dp"
            android:background="@android:color/transparent"
            android:padding="5dp"
            android:scaleType="centerCrop"
            android:src="@mipmap/ic_launcher_round"/>
        <!--结束按钮-->
        <ImageButton
            android:layout_width="105dp"
            android:layout_height="105dp"
            android:background="@android:color/transparent"
            android:padding="5dp"
            android:scaleType="centerCrop"
            android:src="@drawable/ic_close_black"/>
    </com.brioal.view.ExpandableMenu>
然后是整个ExpandableMenu的代码
package com.brioal.view;

import android.content.Context;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.Animation;
import android.view.animation.Interpolator;
import android.view.animation.TranslateAnimation;

/**
 * email:brioal@foxmail.com
 * github:https://github.com/Brioal
 * Created by Brioal on 2018/3/27.
 */

public class ExpandableMenu extends ViewGroup {
    private Context mContext;
    // 动画的间隔
    private int mDuration = 500;
    // 插补器
    private Interpolator mInterpolator = new AccelerateDecelerateInterpolator();
    // 菜单是否展开了
    private boolean isExpend = false;
    // 是否已经初始化了
    private boolean isInited = false;

    public ExpandableMenu(@NonNull Context context) {
        this(context, null);
    }

    public ExpandableMenu(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        mContext = context;

    }

    /**
     * 设置动画间隔
     *
     * @param duration
     */
    public ExpandableMenu setDuration(int duration) {
        mDuration = duration;
        return this;
    }

    /**
     * 设置插补器
     *
     * @param interpolator
     */
    public ExpandableMenu setInterpolator(Interpolator interpolator) {
        mInterpolator = interpolator;
        return this;
    }



    @Override
    protected void onLayout(boolean b, int i, int i1, int i2, int i3) {
        int left = i2;
        int top = i3;
        int x = 0;
        int y = 0;

        for (int j = 0; j < getChildCount(); j++) {
            View childView = getChildAt(j);
            int width = childView.getMeasuredWidth();
            int height = childView.getMeasuredHeight();
            left = left - width;
            x++;
            if (top == i3) {
                top = i3 -  height;
            }
            if (left < 0) {
                y++;
                x = 1;
                left = i2 -  width;
                top = top -  height;
            }
            if (j == getChildCount() - 1) {
                // 最后一个,放置在第一个的位置
                childView.layout(i2 - width, i3 - height, i2, i3);
            } else {
                childView.layout(left, top, left + width, top + height);
            }
        }
        if (!isInited) {
            init();
            isInited = true;
        }
    }

    private void init() {
        for (int i = 0; i < getChildCount(); i++) {
            View childView = getChildAt(i);
            if (i != 0) {
                childView.setVisibility(GONE);
            }
        }
        // 设置点击事件
        getChildAt(0).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                expand();
            }
        });
        getChildAt(getChildCount() - 1).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View view) {
                close();
            }
        });
    }

    /**
     * 菜单是否是展开的
     * @return
     */
    public boolean isExpend() {
        return isExpend;
    }


    /**
     * 关闭菜单
     */
    private void close() {
        // 显示第一个按钮
        getChildAt(0).setVisibility(VISIBLE);
        // 隐藏最后一个按钮
        getChildAt(getChildCount() - 1).setVisibility(GONE);
        // 收回菜单
        isExpend = false;
        for (int i = 1; i < getChildCount() - 1; i++) {
            final View childView = getChildAt(i);
            TranslateAnimation animation = new TranslateAnimation(
                    0.0f, getChildAt(0).getLeft() - childView.getLeft(), 0.0f, getChildAt(0).getTop() - childView.getTop()
            );
            animation.setInterpolator(mInterpolator);
            animation.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {

                }

                @Override
                public void onAnimationEnd(Animation animation) {
                    childView.setVisibility(GONE);
                }

                @Override
                public void onAnimationRepeat(Animation animation) {

                }
            });
            childView.setVisibility(VISIBLE);
            animation.setDuration(mDuration);
            childView.startAnimation(animation);
        }
    }

    /**
     * 展开菜单
     */
    private void expand() {
        // 隐藏第一个按钮
        getChildAt(0).setVisibility(GONE);
        // 显示最后一个按钮
        getChildAt(getChildCount() - 1).setVisibility(VISIBLE);
        isExpend = true;
        for (int i = 1; i < getChildCount() - 1; i++) {
            View childView = getChildAt(i);
            TranslateAnimation animation = new TranslateAnimation(
                    getChildAt(0).getLeft() - childView.getLeft(), 0.0f, getChildAt(0).getTop() - childView.getTop(), 0.0f
            );
            animation.setInterpolator(mInterpolator);
            childView.setVisibility(VISIBLE);
            animation.setDuration(mDuration);
            childView.startAnimation(animation);
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        for (int i = 0; i < getChildCount(); i++) {
            View childView = getChildAt(i);
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }
        setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
    }

}

轮子地址:ExpandableMenu

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

推荐阅读更多精彩内容