Android自定义控件之钟摆菜单

来源:互联网 发布:mac cad安装教程 编辑:程序博客网 时间:2024/04/28 07:37

以前在搞一个私人小项目,对方UI设计了一个钟摆菜单,在github找了一圈,没有满意的,自己动手丰衣足食嘛,

最后的效果(如下图):

首先分析一下钟摆菜单需要实现哪些功能:随机自由摆动、简单的碰撞检测、菜单项点击事件

现在就来一步一步的实现上面的功能:(绘制界面->界面实现自由钟摆->界面实现碰撞检测->实现菜单项点击)

一、绘制界面

Android studio中新建项目,然后新建菜单控件基类PendulumMenu,并且继承View(基于主线程更新画面)

注:其实此处可以继承SurfaceView(基于新的线程更新画面),此处暂用View

在values目录下新建PendulumMenu.xml,作为自定义控件的自定义属性,代码如下:

<?xml version="1.0"encoding="utf-8"?><resources><declare-styleable name="PendulumMenu"><!--摆动因子即为角度变化基本单位--><attr name="speed"format="float"></attr><!--摆动速度与摆动因子相关--><attrname="speedduration" format="integer"></attr><!--子菜单圆图的大小--><attrname="circlesize" format="dimension"></attr><!--线条长度--><attr name="stroke"format="dimension"></attr><!--线条长度--><attrname="strokesize" format="dimension"></attr></declare-styleable><!--定义资源引用,用于主题资源进行默认值的赋值--><attr name="PendulumMenuDefalut" format="reference"></attr><!--定义主题资源引用时的默认值--><style name="PendulumMenuDefalutValues">        <item name="speedduration">20</item>        <item name="circlesize">50dp</item>        <item name="stroke">100dp</item>        <item name="strokesize">2dp</item>        <item name="graph">circle</item>    </style></resources>


PendulumMenu类主要代码如下(包括自定义属性值的获取):

public class PendulumMenu extends View { private float speed = 0.3f;private int speedduration = 20;private int graph = 0;private int circlesize = 0;//图形尺寸(正方形)private int stroke = 0;//线条长度private int strokesize = 0;//线条宽度    public PendulumMenu(Context context) {        this(context, null);    }    public PendulumMenu(Context context,AttributeSet attrs) {        this(context, attrs,R.attr.PendulumMenuDefalut);    }public PendulumMenu(Context context, AttributeSetattrs, int defStyleAttr) {        super(context, attrs, defStyleAttr);            TypedArray tp=context.obtainStyledAttributes(attrs,R.styleable.PendulumMenu);              speed =tp.getFloat(R.styleable.PendulumMenu_speed, speed);        graph =tp.getInt(R.styleable.PendulumMenu_graph, 0);        speedduration =tp.getInt(R.styleable.PendulumMenu_speedduration, speedduration);        circlesize =tp.getDimensionPixelOffset(R.styleable.PendulumMenu_circlesize, 20);       stroke =tp.getDimensionPixelOffset(R.styleable.PendulumMenu_stroke, 20);        strokesize = tp.getDimensionPixelOffset(R.styleable.PendulumMenu_strokesize,2);        //必须释放        tp.recycle();}}

接着来实现PendulumMenu控件的onMeasure宽高测量:

代码如下

@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {    int widthmode = MeasureSpec.getMode(widthMeasureSpec);    width = MeasureSpec.getSize(widthMeasureSpec);    int heightmode = MeasureSpec.getMode(heightMeasureSpec);    height = MeasureSpec.getSize(heightMeasureSpec);    //不为精确匹配时直接赋值屏幕宽度    if (widthmode != MeasureSpec.EXACTLY) {        width = getScreenWidth();    } else//精确模式时,取内部图形总宽度与控件宽度之间的最大值        width = width > circlesize * getChildCount() ? width : circlesize * getChildCount();    //高度计算(精确模式时,取内部图形与控件高度之间的最大值)    if (heightmode == MeasureSpec.EXACTLY)        height = height > (circlesize + stroke) ? height : (circlesize + stroke);    else//高度为不精确模式时,取内部图形高度        height = circlesize + stroke;//(子菜单(圆形)直径+摆线长度)    setMeasuredDimension(width, height);}

控件自身的宽高测量设置完成以后,由于存在多个子菜单,所以针对于子菜单创建一个子菜单实例类,代码 如下

public class CircleCollision {    private int iniX;//初始化时记录的坐标    private int iniY;//初始化时最起初的Y坐标    private float centerX;    private float centerY;    private float radius;//自身半径bitmap图形    private float tickradius;//钟摆半径    private float conheight;    private float conwidth;    private double radians;//当前度数    private boolean right;//向右摆动    private Bitmap bitmap;//当前图形}


接着来实现界面的绘制(注:这里的子menu是通过onDraw的canvas绘制的,并没有进行相对应的布局定位,所以没有使用onlayout进行位置定位处理):

代码如下

@Overrideprotected void onDraw(Canvas canvas) {    super.onDraw(canvas);    for (int i = 0; i < getChildCount(); i++) {// getPoint(i)后面会贴出代码详细解释,此处只需要知道根据子项索引返回子菜单实例即可        CircleCollision pp = getPoint(i);        canvas.drawLine(pp.getIniX(), pp.getIniY(), pp.getCenterX(), pp.getCenterY(), getLinePaint(i));        canvas.drawBitmap(pp.getBitmap(), pp.getCenterX() - circlesize / 2, pp.getCenterY() - circlesize / 2, new Paint());    }}

上面geiPoint()方法可以暂时不用更多纠结,暂时理解返回子menu的对应CircleCollision实例类即可。

此时已经完全实现了界面的显示,只是界面是静止的,现在就开始实现随机摆动,说一下思路:由于是使用Draw进行子menu的绘制,所以只需要变更绘画时子menu在PendulumMenu控件中XY坐标,从而计算出对应的left、top值进行绘制bitmap,重复调用Draw可以通过重复调用invalidate()来实现,这样修改子menu坐标的同时调用invalidate()进行界面刷新,此时子menu就在界面中实现了摆动效果。

主要代码如下(里面包含了碰撞的检测,此处只需要了解重复调用Draw刷新界面的原理即可,注意红色部分,注册一个Handler ,通过重复调用Handler实现):

private Handler handler = new Handler() {        @Override        public void handleMessage(Message msg) {            super.handleMessage(msg);            if (msg.what == 1) {                invalidate();                if (collision == null)                    collision = CircleCollisionTesting.getInstance();//碰撞检测监听                collision.setonCollisionListener(new CircleCollisionTesting.onCollisionListener() {                    @Override                    public void onCollision(int index, boolean isCollision) {                        if (isCollision)//进行了碰撞                            collisionOprate(index);                    }                });//设置监听列表数据源                collision.setCollisionList(listc);                handler.sendEmptyMessageDelayed(1, speedduration);            }        }    };


现在子menu能够实现自由摆动了,那么现在就进行碰撞检测的实现。

二、碰撞检测

原理,menu与menu之间实现碰撞,可以通过两个menu中心点的距离是否大于两个menu的半径之和,若是大于则未碰撞,反之则碰撞。采用对每个menu进行的遍历来实现的,新建CircleCollisionTesting碰撞检测类,主要代码如下:

/** * 碰撞检测 * Created by Brian on 2016-10-17. */public class CircleCollisionTesting {    private SparseArray<CircleCollision> listc;    private onCollisionListener monCollisionListener;    public static CircleCollisionTesting getInstance() {        return new CircleCollisionTesting();    }    private CircleCollisionTesting() {    }    public void setCollisionList(SparseArray<CircleCollision> listc) {        this.listc = listc;        startCollisionTesting();    }    /**     * 开始检测     */    public void startCollisionTesting() {        if (listc == null) return;        for (int i = 0; i < listc.size(); i++) {            //进行边界检测            boolean isside = listc.get(i).getCenterX() <= listc.get(i).getRadius()                    || listc.get(i).getCenterY() <= listc.get(i).getRadius()                    || (listc.get(i).getConwidth() - listc.get(i).getCenterX()) <= listc.get(i).getRadius()                    || (listc.get(i).getConheight() - listc.get(i).getCenterY()) <= listc.get(i).getRadius();            if (monCollisionListener != null) {                monCollisionListener.onCollision(i, isside);            }            for (int j = 0; j < listc.size(); j++) {                if (i == j) continue;                float x = listc.get(i).getCenterX() - listc.get(j).getCenterX();                float y = listc.get(i).getCenterY() - listc.get(j).getCenterY();                float c = listc.get(i).getRadius() + listc.get(j).getRadius();                if (monCollisionListener != null) {                    //进行碰撞的检测机制                    monCollisionListener.onCollision(j, x * x + y * y <= c * c);//第二个参数即为是否进行了碰撞                }            }        }    }    public void setonCollisionListener(onCollisionListener monCollisionListener) {        this.monCollisionListener = monCollisionListener;    }    public interface onCollisionListener {        /**         * 碰撞回调         *         * @param index       修改索引         * @param isCollision 是否碰撞         */        void onCollision(int index, boolean isCollision);    }}
这样在PendulumMenu类中的Handler如上代码进行碰撞的检测的注册监听,然后collisionOprate(i)中对碰撞项进行相关操作,

代码如下(备注很详细):

/**     * 碰撞检测操作     */    private void collisionOprate(int index) {        //排除刚好位于最大角度时,此时的摆动标志right会自动更改(若此处在人为进行修改,则会出现角度出现大于degress的情况)        if (index > listc.size() || index == -1 || Math.abs(listc.get(index).getRadians()) >= degress)            return;//操作索引判定        //确定为碰撞后,更改图形摆动方向        listc.get(index).setRight(!listc.get(index).isRight());    }
上面已经涉及到修改menu项的摆动方向setRight,那么现在就来解析,onDraw中getPoint(i)方法了,原理都是操作listc里面的子元素CircleCollision

类,主要代码如下:

/**     * 根据子菜单索引获取对应的坐标     *     * @param index     * @return     */    private CircleCollision getPoint(int index) {        CircleCollision pp;        //对当前控件进行缓存处理        if (listc.get(index) == null) {            pp = new CircleCollision();            Bitmap bmp = getBitmap(arrres.get(index));//此处为根据用户设定的图片资源            pp.setBitmap(bmp);            //不摆动时的垂直位置            pp.setIniX(getXLocation(index));            pp.setCenterX(getXLocation(index));            pp.setIniY(5);            pp.setCenterY(getRealLineSize() + 5);            Random randow = new Random();            pp.setTickradius(getRealLineSize());//            pp.setTickradius(randow.nextInt(getRealLineSize() / 2) + getRealLineSize() / 2);//随机钟摆半径(介于最大钟摆半径~与一半钟摆半径之间)            //随机定向摆动            pp.setRight(randow.nextInt(2) == 1);//产生0,1来进行随机左右摇摆            Log.e("setRight", pp.isRight() + "");            //每个索引项对应生成一个初期的随机角度(尚未转换为弧度)(介于正负degress区间)            pp.setRadius(circlesize / 2);            pp.setRadians(0);//(double) (randow.nextInt(degress * 2) - degress);            pp.setConwidth(width);            pp.setConheight(height);            listc.put(index, pp);        } else//获取缓存信息            pp = listc.get(index);        if (pp.isRight())//向右            pp.setRadians(pp.getRadians() + speed);        else            pp.setRadians(pp.getRadians() - speed);        if (Math.abs(pp.getRadians()) >= degress)//没有摆动到最大角度            pp.setRight(!pp.isRight());        //根据角度计算xy当前坐标        pp.setCenterX(pp.getIniX() + Math.round((float) (Math.sin(Math.toRadians(pp.getRadians())) * pp.getTickradius())));        pp.setCenterY(pp.getIniY() + Math.round((float) (Math.cos(Math.toRadians(pp.getRadians())) * pp.getTickradius())));        return pp;    }

此处代码注意pp.setCenterX(pp.getIniX() + Math.round((float) (Math.sin(Math.toRadians(pp.getRadians())) * pp.getTickradius())));

是根据摆动的角度[pp.getRadians()]转为弧度[Math.toRadians],然后进行Math.sin三角函数的处理,得到当前子菜单圆形中心点的XY坐标

最后来实现menu的点击功能。

三、Menu点击

原理:只需要判定手指点击/抬起时的坐标是否在某一个子menu内即可,

主要代码如下(PendulumMenu里面重载onTouchEvent):

@Override    public boolean onTouchEvent(MotionEvent event) {        if (event.getAction() == MotionEvent.ACTION_UP) {            if (monMenuItemListener != null)                monMenuItemListener.onMenuClick(getTouchItem(event.getX(), event.getY()));        }        //此处只有直接返回true才能对手指抬起的坐标点进行获取        return true;    }/**     * 根据传入的xy坐标返回对应的点击项     *     * @param touchx     * @param touchy     * @return     */    private int getTouchItem(float touchx, float touchy) {        int index = -1;        for (int i = 0; i < listc.size(); i++) {            float xdis = listc.get(i).getCenterX() - touchx;            float ydis = listc.get(i).getCenterY() - touchy;            //如果点击点位于bitmap的圆形区域内,则出发对应的点击事件(两点间距离进行判断)            if (xdis * xdis + ydis * ydis <= listc.get(i).getRadius() * listc.get(i).getRadius()) {                index = i;                break;            }        }        return index;    }


这样就实现了子menu的点击事件,然后新建一个Activity进行新控件的演示,主要代码如下:

<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"    xmlns:tools="http://schemas.android.com/tools"    android:id="@+id/activity_main"    android:layout_width="match_parent"    android:layout_height="match_parent"    android:paddingBottom="@dimen/activity_vertical_margin"    android:paddingLeft="@dimen/activity_horizontal_margin"    android:paddingRight="@dimen/activity_horizontal_margin"    android:paddingTop="@dimen/activity_vertical_margin"    tools:context="com.yung.demo.MainActivity">    <com.yung.widget.PendulumMenu        android:id="@+id/pendulummenuid"        android:layout_width="match_parent"        android:layout_height="match_parent" /></RelativeLayout>

public class MainActivity extends AppCompatActivity {    PendulumMenu pendulummenuid;    private int[] imgRes;    private int[] linecos;    @Override    protected void onCreate(Bundle savedInstanceState) {        super.onCreate(savedInstanceState);        setContentView(R.layout.activity_main);        ini();    }    public void ini() {        pendulummenuid = (PendulumMenu) findViewById(R.id.pendulummenuid);        imgRes = new int[]{R.mipmap.a, R.mipmap.b, R.mipmap.c, R.mipmap.d, R.mipmap.e};        linecos = new int[]{Color.parseColor("#ffbe00"), Color.parseColor("#ff9642"), Color.parseColor("#a8e968"), Color.parseColor("#63d4fe"), Color.parseColor("#ff8383")};        pendulummenuid.setTextsAndImages(imgRes, linecos);        pendulummenuid.setonMenuItemListener(new PendulumMenu.onMenuItemListener() {            @Override            public void onMenuClick(int index) {                if (index > -1)//不再bitmap点击区域内时,返回-1                    Toast.makeText(MainActivity.this, "第" + (index + 1) + "个子菜单被点击", Toast.LENGTH_SHORT).show();            }        });        pendulummenuid.start();    }}


综上:一个简单的钟摆菜单就实现了。其中注意事项:

1、自定义控件内部机制的了解,如invalidate()触发onDraw

2、利用三角函数进行坐标系的建立转换

3、碰撞的检测原理即是两点间距离,其实可以采用Region也能解决,此处只是为了简单处理。Region网上的使用方法有很多。

源代码地址:https://github.com/BrianYung/PendulumMenu

0 0
原创粉丝点击