Android自定义控件之钟摆菜单

以前在搞一个私人小项目,对方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宽高测量:

代码如下

@Override
protected 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进行位置定位处理):

代码如下

@Override
protected 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值