最后的效果(如下图):
首先分析一下钟摆菜单需要实现哪些功能:随机自由摆动、简单的碰撞检测、菜单项点击事件
现在就来一步一步的实现上面的功能:(绘制界面->界面实现自由钟摆->界面实现碰撞检测->实现菜单项点击)
一、绘制界面
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网上的使用方法有很多。