天天看点

Android 圆盘旋转/飞转菜单(高度定制化)

看了建行的圆盘菜单,效果还不错,于是也动手试试做一个,目标——高度定制化,数量、样式及动画。

为什么要用适配器做成定制化?你知道的,UI那边总是动不动就改的,加点什么啊,删点什么啊,而且有多态机器需要适配,底色不一样就算了,数量和Item也不一样,怎么搞啊?我们总不能每一次都改一大串吧,改一大串和重复类似工作对我们来说简直就是折磨,所以,需要定制化。当然,如果第二次就直接淘汰圆盘了,那另当别论,。

工程代码:https://github.com/aknew123/CircleMenu  点击打开链接

效果如下:

Android 圆盘旋转/飞转菜单(高度定制化)

程序架构的UML图,如下:

Android 圆盘旋转/飞转菜单(高度定制化)

一、圆盘菜单自定义控件的使用

网上查看了一下,看一下他们的实现方式千篇一律,功能都写在一个文件里,阅读难度稍大,于是采用适配器模式做一个,把view和实现逻辑分离,就是简单的MVC,代码结构如下图:

Android 圆盘旋转/飞转菜单(高度定制化)

当然,这只是一个Library,把DefaultMenuAdapter删了,重新编译就可以直接用jar包了,测试模块写在另一个工程,使用示例如下:

package com.example.circlemenutest;

import java.util.ArrayList;
import java.util.List;

import android.animation.ObjectAnimator;
import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.ImageView;
import android.widget.Toast;

import com.pan.tanglang.circlemenu.model.CircleMenuStatus;
import com.pan.tanglang.circlemenu.view.CircleMenu;
import com.pan.tanglang.circlemenu.view.CircleMenu.OnMenuItemClickListener;
import com.pan.tanglang.circlemenu.view.CircleMenu.OnMenuStatusChangedListener;

public class MainActivity extends Activity {

	public static final String TAG = "MainActivity";

	private String[] mItemTexts = new String[] { "安全中心", "特殊服务", "投资理财", "转账汇款", "我的账户", "信用卡", "腾讯", "阿里", "百度" };
	private int[] mItemImgs = new int[] { R.drawable.foreign01, R.drawable.foreign02, R.drawable.foreign03,
			R.drawable.foreign04, R.drawable.foreign05, R.drawable.foreign06, R.drawable.foreign07,
			R.drawable.foreign08, R.drawable.foreign09 };

	private CircleMenu mCircleMenu;
	private ImageView ivCenter;

	private float startRotate;
	private float startFling;

	ObjectAnimator animRotate = null;
	ObjectAnimator animFling = null;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		mCircleMenu = (CircleMenu) findViewById(R.id.cm_main);
		ivCenter = (ImageView) findViewById(R.id.iv_center_main);
		ivCenter.setOnClickListener(new OnClickListener() {

			@Override
			public void onClick(View v) {
				Toast.makeText(MainActivity.this, "圆盘中心", Toast.LENGTH_SHORT).show();

			}
		});
		mCircleMenu.setOnItemClickListener(new OnMenuItemClickListener() {

			@Override
			public void onClick(View view, int position) {
				Toast.makeText(MainActivity.this, mItemTexts[position], Toast.LENGTH_SHORT).show();
			}
		});
		mCircleMenu.setOnStatusChangedListener(new OnMenuStatusChangedListener() {

			@Override
			public void onStatusChanged(CircleMenuStatus status, double rotateAngle) {
				// TODO 可在此处定制各种动画
				odAnimation(status, (float)rotateAngle);
			}

		});
		List<ItemInfo> data = new ArrayList<>();
		ItemInfo item = null;
		for (int i = 0; i < mItemTexts.length; i++) {
			item = new ItemInfo(mItemImgs[i], mItemTexts[i]);
			data.add(item);
		}

		mCircleMenu.setAdapter(new CircleMenuAdapter(data));

	}

	private void odAnimation(CircleMenuStatus status, float rotateAngle) {

		switch (status) {
		case IDLE:
			Log.i(TAG, "--- -IDLE-----");
			animRotate.cancel();
			animRotate.cancel();
			break;
		case START_ROTATING:
			Log.i(TAG, "--- -START_ROTATING-----");
			break;
		case ROTATING:
			animRotate = ObjectAnimator.ofFloat(ivCenter, "rotation", startRotate, startRotate + rotateAngle);
			animRotate.setDuration(200).start();
			startRotate += rotateAngle;
			// Log.i(TAG, "--- -ROTATING-----");
			break;
		case STOP_ROTATING:
			Log.i(TAG, "--- -STOP_ROTATING-----");
			break;
		case START_FLING:
			Log.i(TAG, "--- -START_FLING-----");
			break;

		case FLING:
			// Log.i(TAG, "--- -FLING-----");
			animFling = ObjectAnimator.ofFloat(ivCenter, "rotation", startFling, startFling + rotateAngle);
			animFling.setDuration(200).start();
			startFling += rotateAngle;
			break;
		case STOP_FLING:
			Log.i(TAG, "--- -STOP_FLING-----");

			break;

		default:
			break;
		}

	}
}
           

后面的是用户定制化动画实现(提供诸多状态,爱怎么折腾怎么折腾)。好了,我们来看看实现原理。

二、实现原理

1.先看需要做什么

(1).圆盘菜单CircleMenu是一个转盘,装有各种Item,是一个容器,那理所当然是继承ViewGroup,为了方便实现飞转,所以监听用户手势OnGestureListener;

(2).菜单项CircleItemView,虽是一个item,但为了可定制化,当然也是一个容器,也是为了方便实现飞转,重写onFling方法,所以这里继承LinearLayout,纯属为了方便布局,若有特殊需求,可在布局的时候,在外层添加一个FrameLayout容器,爱怎么搞怎么搞;

(3).Adapter和Model,既然有Item那肯定也有Adapter和数据model,Adapter继承BaseAdapter即可,就跟ListView一样。

好了,就这3个玩意儿,另外几个都是从上面这两个抽出来的。

2.旋转原理

来看一下转动分析图,

Android 圆盘旋转/飞转菜单(高度定制化)

图中圆心的坐标应为( mRadius, mRadius),则圆盘半径为mRadius,按照大多数人的习惯右手向下滑动,圆盘也就跟着顺时针转动(或者说滚动),当然不是圆盘在转,是菜单项在滚动,滚动了弧度为a,那途中Item的x、y坐标应为

x = tmp *cos a;

y = tmp*sin a;

这是相对于圆心的坐标,再加上圆盘的半径,就是圆盘中的坐标了,子View只管在父容器中的坐标,父容器布局时会加上自身的left和top,这样逐层往上推就是在屏幕中的坐标了)。

我们将Item强制为正方形,itemWidth为Item的宽度,当 tmp  = mRaiuds - itemWidth / Math.sqrt(2) 时,那Item的外直角就走在圆盘圆周上,再大则要出边界了,所以,Item的中心点位于mRaiuds/2和mRaiuds - itemWidth / Math.sqrt(2)之间最为合理,那么,item的x、y为

final int childCount = getChildCount();
		int left, top, halfDiagonal;
		// 限制Item的宽高
		int itemWidth = (int) (mRadius * RADIO_DEFAULT_CHILD_DIMENSION);
		float angleDelay = 360 / childCount;
		for (int i = 0; i < childCount; i++) {
			final View child = getChildAt(i);
			if (child.getVisibility() == View.GONE) {
				continue;
			}
			mStartAngle %= 360;
			// 取Item对角线的一半为Item中心到圆盘圆周的距离
			halfDiagonal = (int) (itemWidth / Math.sqrt(2));
			float distanceFromCenter = mRadius - halfDiagonal - mPadding;
			left = mRadius + (int) Math.round(distanceFromCenter * Math.cos(Math.toRadians(mStartAngle)) - 1 / 2f * itemWidth);
			top = mRadius + (int) Math.round(distanceFromCenter * Math.sin(Math.toRadians(mStartAngle)) - 1 / 2f * itemWidth);
			// 重新Layout
			child.layout(left, top, left + itemWidth, top + itemWidth);
			mStartAngle += angleDelay;
		}
           

left 为 x, top 为y,mStartAngle为间隔角度,一个for循环就可以把所有Item按角度Layout出来。要让他们滚动起来,只要在ACTION_MOVE的时候给一个角度就行。那转动原理就这样了。

3.测量和布局

Android View 的绘制过程,如下图

Android 圆盘旋转/飞转菜单(高度定制化)

过程为 measure() ---> layout () ---> draw(),measure() 中会掉用onMeasure()方法, layout () 中调用onLayout()方法,draw()我们就不管了,因为还是SDK继承而来,他可以自己绘制。

onMeasure方法实现如下:

@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// 测量自身
		// measureMyself(widthMeasureSpec, heightMeasureSpec);
		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
		// 测量子View
		measureChildViews();

	}

	private void measureChildViews() {
		if (mAdapter.getCount() <= 0) {
			return;
		}

		// 获取半径,
		mRadius = Math.max(getMeasuredWidth(), getMeasuredHeight()) / 2;
		final int count = getChildCount();
		// 取mRadius/2为Item宽度
		int childSize = (int) (mRadius * RADIO_DEFAULT_CHILD_DIMENSION);
		int childMode = MeasureSpec.EXACTLY;
		int makeMeasureSpec = -1;
		for (int i = 0; i < count; i++) {
			final View child = getChildAt(i);
			if (child.getVisibility() == View.GONE) {
				continue;
			}
			makeMeasureSpec = MeasureSpec.makeMeasureSpec(childSize, childMode);
			// 设置为正方形
			child.measure(makeMeasureSpec, makeMeasureSpec);
		}
		// 取mRadius/10为默认内边距
		if (mPadding == -1) {
			mPadding = RADIO_PADDING_LAYOUT * mRadius;
		}
	}
           

onLayout()在父类里是一个抽象方法,没有返回值,所以其中没有改变任何成员变量的话,是没有效果的。

@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {
		if (mAdapter.getCount() <= 0) {
			return;
		}
		final int childCount = getChildCount();
		int left, top, halfDiagonal;
		// 限制Item的宽高
		int itemWidth = (int) (mRadius * RADIO_DEFAULT_CHILD_DIMENSION);
		float angleDelay = 360 / childCount;
		for (int i = 0; i < childCount; i++) {
			final View child = getChildAt(i);
			if (child.getVisibility() == View.GONE) {
				continue;
			}
			mStartAngle %= 360;
			// 取Item对角线的一半为Item中心到圆盘圆周的距离
			halfDiagonal = (int) (itemWidth / Math.sqrt(2));
			float distanceFromCenter = mRadius - halfDiagonal - mPadding;
			left = mRadius
					+ (int) Math.round(distanceFromCenter * Math.cos(Math.toRadians(mStartAngle)) - 1 / 2f * itemWidth);
			top = mRadius
					+ (int) Math.round(distanceFromCenter * Math.sin(Math.toRadians(mStartAngle)) - 1 / 2f * itemWidth);
			// 重新Layout
			child.layout(left, top, left + itemWidth, top + itemWidth);
			mStartAngle += angleDelay;
		}

	}
           

onLayout里就是把所有的Iitem逐个布局,这样就显示一个静态的圆盘了。

4.圆盘滚动

圆盘的滚动,可以在onTouchEvent(MotionEvent event)方法里实现,但这意味着你要同时在CircleMenu和CircleItemView的onTouchEvent方法里都要实现,当然两套代码可以提出来(但有可能子View和父View的x、y坐标不同,有麻烦),由于我们知道Android事件分发机制,Touch事件首先是父View的dispatchTouchEvent(MotionEvent event)先获得,然后往下分发,所以在dispatchTouchEvent方法实现Item转动一举两得,还有个原因是我们还要监听手势,需要重写onTouchEvent方法,so。。。。Item的滚动通过重写dispatchTouchEvent实现,代码如下:

// 因为父view的dispatchTouchEvent先获得事件,所以在这里可以连带子view的滚动事件一起处理
	@Override
	public boolean dispatchTouchEvent(MotionEvent event) {
		if (mRotate != null) {
			mRotate.onCircleMenuTouch(event, mRadius, this);
		}
		return super.dispatchTouchEvent(event);

	}
           

简单吧,呵呵,提出来了,提到了RotateEngine.java里实现,如下:

package com.pan.tanglang.circlemenu.control;

import android.util.Log;
import android.view.MotionEvent;

import com.pan.tanglang.circlemenu.model.CircleMenuStatus;
import com.pan.tanglang.circlemenu.view.CircleMenu;
import com.pan.tanglang.circlemenu.view.CircleMenu.RotateDirection;

/**
 * 随手势旋转引擎
 */
public class RotateEngine {

	public static final String TAG = "RotateEngine";
	private static RotateEngine instance = null;
	private float startX;
	private float startY;
	/** 请求重新布局的起始角度 **/
	private double mStartAngle;

	private RotateEngine() {
	}

	public static RotateEngine getInstance() {
		if (instance == null) {
			synchronized (RotateEngine.class) {
				if (instance == null) {
					instance = new RotateEngine();
				}
			}
		}
		return instance;
	}

	// 通过dispatch实现
	public void onCircleMenuTouch(MotionEvent ev, int radius, CircleMenu mCircleMenu) {
		switch (ev.getAction()) {
		case MotionEvent.ACTION_DOWN:
			startX = ev.getX();
			startY = ev.getY();
			break;
		case MotionEvent.ACTION_MOVE:
			double start = getAngle(startX, startY, radius);
			if (mCircleMenu.getStatus() == CircleMenuStatus.STOP_FLING
					|| mCircleMenu.getStatus() == CircleMenuStatus.IDLE) {
				// 开始滚动
				mCircleMenu.startRotate();
				// 拿到Fling停下(自然停或按停)之后的开始角度
				mStartAngle = mCircleMenu.getmStartAngle();
			}
			float x = ev.getX();
			float y = ev.getY();
			toCircleMenuScroll(mCircleMenu, radius, start, ev);
			startX = x;
			startY = y;
			break;
		case MotionEvent.ACTION_UP:
			CircleMenuStatus status = mCircleMenu.getStatus();
			Log.i(TAG, "---ACTION_UP---status = " + status);
			if (status == CircleMenuStatus.ROTATING || status == CircleMenuStatus.PAUSE_ROTATING) {
				mCircleMenu.stopRotate();
			}

			break;

		default:
			break;
		}
	}

	public void toCircleMenuScroll(CircleMenu mCircleMenu, int radius, double start, MotionEvent e2) {
		float x = e2.getX();
		float y = e2.getY();
		// 一个连续的角度差值,用于判断滑动方向,得出顺时针、逆时针
		float directionAngle = 0;
		double end = getAngle(x, y, radius);
		// 如果是一、四象限,则直接end-start,角度值都是正值
		if (getQuadrant(x, y, radius) == 1 || getQuadrant(x, y, radius) == 4) {
			mStartAngle += end - start;
			directionAngle += end - start;
		} else {
			// 二、三象限,角度值是负值
			mStartAngle += start - end;
			directionAngle += start - end;
		}

		if (directionAngle > 0) {//得到旋转方向---顺时针,用于Fling飞转
			mCircleMenu.setmDirection(RotateDirection.CLOCKWISE);
		} else {
			mCircleMenu.setmDirection(RotateDirection.ANTICLOCKWISE);
		}
		// 旋转角度, 请求重新布局
		mCircleMenu.relayoutMenu(mStartAngle);
		if (startX != x || startY != y) {
			mCircleMenu.onRotating(directionAngle);
		} else {
			mCircleMenu.onPauseRotate();
		}
	}

	public double getAngle(float xTouch, float yTouch, int radius) {
		double x = xTouch - radius;
		double y = yTouch - radius;
		return (double) (Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI);
	}

	public int getQuadrant(float x, float y, int radius) {
		int tmpX = (int) (x - radius);
		int tmpY = (int) (y - radius);
		if (tmpX >= 0) {
			return tmpY >= 0 ? 4 : 1;
		} else {
			return tmpY >= 0 ? 3 : 2;
		}

	}

}
           

之所以代码这么多,是因为要处理诸多逻辑和 诸多状态,主要思路就是得到x、y差值,计算角度差值,根据象限给加减角度,看ACTION_MOVE这段即可当然里面还牵扯到后面3个方法也差不多看完了。

5.圆盘飞转

飞转显然就是Fling,肯定是要在onFling()方法里面实现的,那么要实现OnGestureListener接口,同时,重写dispatchTouchEvent(MotionEvent event)方法,代码如下:

@Override
	public boolean onTouchEvent(MotionEvent event) {
		boolean result = mDetector.onTouchEvent(event);
		if (event.getAction() == MotionEvent.ACTION_UP) {
			if (mStatus != CircleMenuStatus.START_FLING && mStatus != CircleMenuStatus.FLING) {
				idle();
			}
		}
		return result;
	}
           

再来看onFling方法

@Override
	public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
		startFling(e1, e2, velocityX, velocityY);
		return false;
	}


	/**
	 * 开始飞转
	 * @param e1
	 * @param e2
	 * @param velocityX
	 * @param velocityY
	 */
	public void startFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
		if (mStatus == CircleMenuStatus.STOP_ROTATING) {
			startMenuFling();
			float velocity = Math.abs(velocityX) > Math.abs(velocityY) ? velocityX : velocityY;
			double start = mRotate.getAngle(e2.getX(), e2.getY(), mRadius);
			if (mFlingEngine == null) {
				mFlingEngine = new FlingEngine();
			}
			mFlingEngine.start(this, velocity, start);
		}
	}
           

也很简单,都提出来的,看FlingEngine.java。

package com.pan.tanglang.circlemenu.control;

import com.pan.tanglang.circlemenu.model.CircleMenuStatus;
import com.pan.tanglang.circlemenu.view.CircleMenu;
import com.pan.tanglang.circlemenu.view.CircleMenu.RotateDirection;


/**
 * @description 描    述:飞转引擎
 */
public class FlingEngine implements Runnable {

	private static final String TAG = "FlingEngine";
	private int mVelocity;
	private int DELAY = 10;
	private double startAngle;
	private CircleMenu mCircleMenu;

	public void start(CircleMenu circleMenu, float velocity, double start) {
		mCircleMenu = circleMenu;
		mVelocity = (int) Math.abs(velocity);
		startAngle = start;
		if (circleMenu.getStatus() == CircleMenuStatus.START_FLING) {
			circleMenu.post(this);
		}
	}

	@Override
	public void run() {
		// 如果小于20,则停止
		if (mCircleMenu.getStatus() == CircleMenuStatus.STOP_FLING || mVelocity <= 0) {
			//叫停或自动停止
			mCircleMenu.idle();
			return;
		}
		double preStartAngle = startAngle;
		// 顺时针
		if (mCircleMenu.getmDirection() == RotateDirection.CLOCKWISE) {

			flingSlowDownByClockwise();
		} else if (mCircleMenu.getmDirection() == RotateDirection.ANTICLOCKWISE) {
			flingSlowDownByAnticlockwise();
		}
		mCircleMenu.relayoutMenu(startAngle);
		mCircleMenu.onMenuFling(startAngle - preStartAngle);
		mCircleMenu.postDelayed(this, DELAY);
	}

	// 逆时针减速
	private void flingSlowDownByAnticlockwise() {

		if (mVelocity > 10000) {
			mVelocity -= 1000;
			startAngle -= 10;
		} else if (mVelocity > 5000) {
			mVelocity -= 100;
			startAngle -= 8;
		} else if (mVelocity > 1000) {
			mVelocity -= 50;
			startAngle -= 6;
		} else if (mVelocity > 500) {
			mVelocity -= 10;
			startAngle -= 4;
		} else if (mVelocity > 100) {
			mVelocity -= 5;
			startAngle -= 2;
		} else {
			mVelocity--;
			startAngle--;
		}
	}

	// 顺时针减速
	private void flingSlowDownByClockwise() {

		if (mVelocity > 10000) {
			mVelocity -= 1000;
			startAngle += 10;
		} else if (mVelocity > 5000) {
			mVelocity -= 100;
			startAngle += 8;
		} else if (mVelocity > 1000) {
			mVelocity -= 50;
			startAngle += 6;
		} else if (mVelocity > 500) {
			mVelocity -= 10;
			startAngle += 4;
		} else if (mVelocity > 100) {
			mVelocity -= 5;
			startAngle += 2;
		} else {
			mVelocity--;
			startAngle++;
		}
	}

	public int getQuadrant(float angle, int radius) {
		float x = Math.round(radius * Math.cos(Math.toRadians(angle)));
		float y = Math.round(radius * Math.sin(Math.toRadians(angle)));
		int tmpX = (int) (x - radius / 2);
		int tmpY = (int) (y - radius / 2);
		if (tmpX >= 0) {
			return tmpY >= 0 ? 4 : 1;
		} else {
			return tmpY >= 0 ? 3 : 2;
		}

	}

}
           

之所以要提出 startFling方法是因为Item也要用到,通过getParent强转之后调用,所以, CircleItemView.java的代码就相当简单了,如下:

package com.pan.tanglang.circlemenu.view;

import android.annotation.SuppressLint;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.GestureDetector;
import android.view.View;
import android.view.GestureDetector.OnGestureListener;
import android.view.MotionEvent;
import android.widget.LinearLayout;

import com.pan.tanglang.circlemenu.model.CircleMenuStatus;
import com.pan.tanglang.circlemenu.model.UserEvent;

/**
 * @description 描 述:Item项自定义控件,主要是为了实现Item的onFling
 */
public class CircleItemView extends LinearLayout implements OnGestureListener {

	public static final String TAG = "CircleItemView";
	private GestureDetector mDetector;
	private CircleMenu mParent;

	public CircleItemView(Context context, AttributeSet attrs) {
		super(context, attrs);
		setClickable(true);
		mDetector = new GestureDetector(context, this);

	}

	@SuppressLint("ClickableViewAccessibility")
	@Override
	public boolean onTouchEvent(MotionEvent event) {
		mDetector.onTouchEvent(event);
		//将滑动状态从 STOP_ROTATING 置为 IDLE,多一个STOP_ROTATING,是ROTATING之后,TART_FLING之前的一个状态,目前意义不大,主要看用户怎么利用
		if (event.getAction() == MotionEvent.ACTION_UP) {
			CircleMenuStatus status = mParent.getStatus();
			if (status != CircleMenuStatus.IDLE &&status != CircleMenuStatus.START_FLING && status != CircleMenuStatus.FLING) {
				mParent.idle();
			}
		}
		return super.onTouchEvent(event);// 防止点击事件丢失
	}

	@Override
	public boolean onDown(MotionEvent e) {
		if (mParent == null) {
			mParent = (CircleMenu) getParent();
		}
		if (mParent != null) {
			CircleMenuStatus status = mParent.getStatus();
			if (status == CircleMenuStatus.FLING) {
				mParent.stopFling();
			}
			if (status == CircleMenuStatus.ROTATING) {
				mParent.stopRotate();
			}
		}
		return true;
	}

	@Override
	public void onShowPress(MotionEvent e) {
	}

	@Override
	public boolean onSingleTapUp(MotionEvent e) {
		return false;
	}

	@Override
	public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
		return false;
	}

	@Override
	public void onLongPress(MotionEvent e) {
	}

	@Override
	public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
		if (mParent != null) {
			mParent.startFling(e1, e2, velocityX, velocityY);
		}
		return false;
	}
	
}
           

CircleMenu.java的代码就不贴出来了,上https://github.com/aknew123/CircleMenu上downloade吧,里面有状态控制,其实代码也不到400行,只是贴进来显得文长(想想还是贴吧)。

package com.pan.tanglang.circlemenu.view;

import android.annotation.SuppressLint;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.GestureDetector;
import android.view.GestureDetector.OnGestureListener;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListAdapter;

import com.pan.tanglang.circlemenu.control.FlingEngine;
import com.pan.tanglang.circlemenu.control.RotateEngine;
import com.pan.tanglang.circlemenu.model.CircleMenuStatus;
import com.pan.tanglang.circlemenu.model.UserEvent;

/**
 * @description 描 述:圆盘菜单容器,圆盘菜单功能的核心文件
 * @author 作 者:SergioPan
 */
public class CircleMenu extends ViewGroup implements OnGestureListener {

	public static final String TAG = "CircleMenu";
	/** 圆盘半径,那么圆心为(mRadius, mRadius) **/
	public int mRadius;
	private static final float RADIO_DEFAULT_CHILD_DIMENSION = 1 / 2f;
	private static final float RADIO_PADDING_LAYOUT = 1 / 20f;
	/** 内边距,默认为mRadius/20 **/
	public float mPadding = -1;
	private double mStartAngle = 0;
	private OnMenuItemClickListener mListener;
	private ListAdapter mAdapter;
	private CircleMenuStatus mStatus = CircleMenuStatus.IDLE;
	private OnMenuStatusChangedListener mStatusListener;
	private GestureDetector mDetector;
	private RotateEngine mRotate;
	private RotateDirection mDirection = RotateDirection.CLOCKWISE;
	private FlingEngine mFlingEngine;
	// 用户输入事件,默认为无用行为,主要用于解决Fling后的按停,此时屏蔽点击事件
	private UserEvent mUserEvent = UserEvent.USELESS_ACTION;

	public CircleMenu(Context context, AttributeSet attrs) {
		super(context, attrs);
		setPadding(0, 0, 0, 0);
		setClickable(true);
		mDetector = new GestureDetector(context, this);
		mRotate = RotateEngine.getInstance();

	}

	/** 转动方向 **/
	public enum RotateDirection {
		/** 顺时针 **/
		CLOCKWISE,
		/** 逆时针 **/
		ANTICLOCKWISE;
	}

	//依附到窗口上
	@Override
	protected void onAttachedToWindow() {
		if (mAdapter != null) {
			buildMenuItems();
		}
		super.onAttachedToWindow();
	}

	/**
	 * 菜单重新布局
	 * 
	 * @param startAngle
	 */
	public void relayoutMenu(double startAngle) {
		mStartAngle = startAngle;
		requestLayout();
	}

	// 构建菜单项
	@SuppressLint("NewApi")
	private void buildMenuItems() {
		if (mAdapter.getCount() <= 0) {
			return;
		}
		for (int i = 0; i < mAdapter.getCount(); i++) {

			CircleItemView itemView = (CircleItemView) mAdapter.getView(i, null, this);
			final int position = i;
			itemView.setClickable(true);
			itemView.setOnClickListener(new OnClickListener() {

				@Override
				public void onClick(View v) {
					if (mUserEvent == UserEvent.FLING) {
						// 正在飞转时,接收用户的点击事件,则视为停止飞转动作,而屏蔽点击事件
						mUserEvent = UserEvent.USELESS_ACTION;
						return;
					}
					// 非飞转时响应点击事件
					if (mListener != null) {
						mListener.onClick(v, position);
					}

				}
			});
			addView(itemView);
		}
	}

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// 测量自身
		// measureMyself(widthMeasureSpec, heightMeasureSpec);
		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
		// 测量子View
		measureChildViews();

	}

	private void measureChildViews() {
		if (mAdapter.getCount() <= 0) {
			return;
		}

		// 获取半径,
		mRadius = Math.max(getMeasuredWidth(), getMeasuredHeight()) / 2;
		final int count = getChildCount();
		// 取mRadius/2为Item宽度
		int childSize = (int) (mRadius * RADIO_DEFAULT_CHILD_DIMENSION);
		int childMode = MeasureSpec.EXACTLY;
		int makeMeasureSpec = -1;
		for (int i = 0; i < count; i++) {
			final View child = getChildAt(i);
			if (child.getVisibility() == View.GONE) {
				continue;
			}
			makeMeasureSpec = MeasureSpec.makeMeasureSpec(childSize, childMode);
			// 设置为正方形
			child.measure(makeMeasureSpec, makeMeasureSpec);
		}
		// 取mRadius/10为默认内边距
		if (mPadding == -1) {
			mPadding = RADIO_PADDING_LAYOUT * mRadius;
		}
	}

	@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {
		if (mAdapter.getCount() <= 0) {
			return;
		}
		final int childCount = getChildCount();
		int left, top, halfDiagonal;
		// 限制Item的宽高
		int itemWidth = (int) (mRadius * RADIO_DEFAULT_CHILD_DIMENSION);
		float angleDelay = 360 / childCount;
		for (int i = 0; i < childCount; i++) {
			final View child = getChildAt(i);
			if (child.getVisibility() == View.GONE) {
				continue;
			}
			mStartAngle %= 360;
			// 取Item对角线的一半为Item中心到圆盘圆周的距离
			halfDiagonal = (int) (itemWidth / Math.sqrt(2));
			float distanceFromCenter = mRadius - halfDiagonal - mPadding;
			left = mRadius
					+ (int) Math.round(distanceFromCenter * Math.cos(Math.toRadians(mStartAngle)) - 1 / 2f * itemWidth);
			top = mRadius
					+ (int) Math.round(distanceFromCenter * Math.sin(Math.toRadians(mStartAngle)) - 1 / 2f * itemWidth);
			// 重新Layout
			child.layout(left, top, left + itemWidth, top + itemWidth);
			mStartAngle += angleDelay;
		}
		
	}

	@SuppressLint("ClickableViewAccessibility")
	@Override
	public boolean onTouchEvent(MotionEvent event) {
		boolean result = mDetector.onTouchEvent(event);
		if (event.getAction() == MotionEvent.ACTION_UP) {
			if (mStatus != CircleMenuStatus.START_FLING && mStatus != CircleMenuStatus.FLING) {
				idle();
			}
		}
		return result;
	}

	// 因为父view的dispatchTouchEvent先获得事件,所以在这里可以连带子view的滚动事件一起处理
	@Override
	public boolean dispatchTouchEvent(MotionEvent event) {
		if (mRotate != null) {
			mRotate.onCircleMenuTouch(event, mRadius, this);
		}
		return super.dispatchTouchEvent(event);

	}

	public void startMenuFling() {
		mStatus = CircleMenuStatus.START_FLING;
		if (mStatusListener != null) {
			mStatusListener.onStatusChanged(mStatus, 0);
		}
	}

	public void stopFling() {
		mStatus = CircleMenuStatus.STOP_FLING;
		if (mStatusListener != null) {
			mStatusListener.onStatusChanged(mStatus, 0);
		}
	}

	public void onMenuFling(double angle) {
		mStatus = CircleMenuStatus.FLING;
		if (mStatusListener != null) {
			mStatusListener.onStatusChanged(mStatus, angle);
			mUserEvent = UserEvent.FLING;
		}
	}

	public void startRotate() {
		mStatus = CircleMenuStatus.START_ROTATING;
		if (mStatusListener != null) {
			mStatusListener.onStatusChanged(mStatus, 0);
		}
	}

	public void onRotating(float angle) {
		mStatus = CircleMenuStatus.ROTATING;
		if (mStatusListener != null) {
			mStatusListener.onStatusChanged(mStatus, angle);
		}
	}

	public void onPauseRotate() {
		mStatus = CircleMenuStatus.PAUSE_ROTATING;
		if (mStatusListener != null) {
			mStatusListener.onStatusChanged(mStatus, 0);
		}
	}

	public void stopRotate() {
		mStatus = CircleMenuStatus.STOP_ROTATING;
		if (mStatusListener != null) {
			mStatusListener.onStatusChanged(mStatus, 0);
		}
	}

	public void idle() {
		mStatus = CircleMenuStatus.IDLE;
		if (mStatusListener != null) {
			mStatusListener.onStatusChanged(mStatus, 0);
		}
	}

	@Override
	public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
		return true;
	}

	@Override
	public boolean onDown(MotionEvent e) {
		if (mStatus == CircleMenuStatus.FLING) {
			stopFling();
		}
		return true;
	}

	@Override
	public void onShowPress(MotionEvent e) {
	}

	@Override
	public boolean onSingleTapUp(MotionEvent e) {
		return true;
	}

	@Override
	public void onLongPress(MotionEvent e) {
	}

	@Override
	public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
		startFling(e1, e2, velocityX, velocityY);
		return false;
	}

	/**
	 * 开始飞转
	 * @param e1
	 * @param e2
	 * @param velocityX
	 * @param velocityY
	 */
	public void startFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
		if (mStatus == CircleMenuStatus.STOP_ROTATING) {
			startMenuFling();
			float velocity = Math.abs(velocityX) > Math.abs(velocityY) ? velocityX : velocityY;
			double start = mRotate.getAngle(e2.getX(), e2.getY(), mRadius);
			if (mFlingEngine == null) {
				mFlingEngine = new FlingEngine();
			}
			mFlingEngine.start(this, velocity, start);
		}
	}

	public RotateDirection getmDirection() {
		return mDirection;
	}

	public void setmDirection(RotateDirection mDirection) {
		this.mDirection = mDirection;
	}

	public void setStatus(CircleMenuStatus status) {
		mStatus = status;
	}

	public CircleMenuStatus getStatus() {
		return mStatus;
	}

	public void setAdapter(ListAdapter adapter) {
		mAdapter = adapter;
	}

	public ListAdapter getAdapter() {
		return mAdapter;
	}

	public void setOnItemClickListener(OnMenuItemClickListener listener) {
		this.mListener = listener;
	}

	public void setOnStatusChangedListener(OnMenuStatusChangedListener statusListener) {
		this.mStatusListener = statusListener;
	}

	public UserEvent getmUserEvent() {
		return mUserEvent;
	}

	public void setmUserEvent(UserEvent mUserEvent) {
		this.mUserEvent = mUserEvent;
	}

	public double getmStartAngle() {
		return mStartAngle;
	}

	public float getmPadding() {
		return mPadding;
	}

	public void setmPadding(float mPadding) {
		this.mPadding = mPadding;
	}

	/**
	 * 圆盘菜单状态改变监听器,用于定制外围或圆心动画
	 * 
	 * @author SergioPan
	 */
	public interface OnMenuStatusChangedListener {

		/**
		 * @param status
		 *            状态
		 * @param rotateAngle
		 *            旋转量(角度)
		 */
		public void onStatusChanged(CircleMenuStatus status, double rotateAngle);

	}

	/**
	 * Item点击事件监听器
	 * 
	 * @author SergioPan
	 */
	public interface OnMenuItemClickListener {

		public void onClick(View view, int position);
	}

}
           

下面说说适配器模式,按开发的角度,应该一开始就该说了,要不怎么调试啊,好!马上来!

6.适配器模式

想要定制化就要用适配器模式,这样才人性化,要不只能写一些只能用一次的逻辑代码而已。适配器模式也是一个小的MVC模式,

Modle ----- DefaultItem

View ---- CircleItemView

Controller ---- Adapter

用起来,其实挺简单,当然这是简单的MVC,父容器CircleMenu要实现2个方法,如下:

public void setAdapter(ListAdapter adapter) {
		mAdapter = adapter;
	}

           
//依附到窗口上
	@Override
	protected void onAttachedToWindow() {
		if (mAdapter != null) {
			buildMenuItems();
		}
		super.onAttachedToWindow();
	}
           
// 构建菜单项
	@SuppressLint("NewApi")
	private void buildMenuItems() {
		if (mAdapter.getCount() <= 0) {
			return;
		}
		for (int i = 0; i < mAdapter.getCount(); i++) {

			CircleItemView itemView = (CircleItemView) mAdapter.getView(i, null, this);
			final int position = i;
			itemView.setClickable(true);
			itemView.setOnClickListener(new OnClickListener() {

				@Override
				public void onClick(View v) {
					if (mUserEvent == UserEvent.FLING) {
						// 正在飞转时,接收用户的点击事件,则视为停止飞转动作,而屏蔽点击事件
						mUserEvent = UserEvent.USELESS_ACTION;
						return;
					}
					// 非飞转时响应点击事件
					if (mListener != null) {
						mListener.onClick(v, position);
					}

				}
			});
			addView(itemView);
		}
	}
           

其实就是装饰(装饰模式)了一下Adapter,然后调用 Adapter的getView方法,即

mAdapter.getView(i, null, this);

然后,Adapter就更简单了,跟ListView一样,继承BaseAdapter,再重写这4个方法,DefaultMenuAdapter.java代码如下

package com.pan.tanglang.circlemenu.adapter;

import java.util.List;

import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;

import com.pan.tanglang.circlemenu.R;
import com.pan.tanglang.circlemenu.model.DefaultItem;


/**
 * @description 描    述:圆盘菜单默认适配器,如有其他需求,用户自行定义适配器,继承BaseAdapter就可以,自行编写xml文件
 */
public class DefaultMenuAdapter extends BaseAdapter {

	private List<DefaultItem> items;

	public DefaultMenuAdapter(List<DefaultItem> data) {
		items = data;
	}

	@Override
	public int getCount() {
		if (items == null) {
			return 0;
		}
		return items.size();
	}

	@Override
	public Object getItem(int position) {

		return items.get(position);
	}

	@Override
	public long getItemId(int position) {
		return position;
	}

	@Override
	public View getView(int position, View convertView, ViewGroup parent) {
		ViewHolder holder = null;
		if (convertView == null) {
			convertView = View.inflate(parent.getContext(), R.layout.item_default, null);
			holder = new ViewHolder();
			holder.iv = (ImageView) convertView.findViewById(R.id.iv_default_circle_menu_item);
			holder.tv = (TextView) convertView.findViewById(R.id.tv_default_circle_menu_item);
			convertView.setTag(holder);
		}
		holder = (ViewHolder) convertView.getTag();
		DefaultItem item = items.get(position);
		if (item != null) {
			holder.iv.setImageResource(item.getImgId());
			holder.tv.setText(item.getText());
		}
		return convertView;
	}

	class ViewHolder {
		ImageView iv;
		TextView tv;
	}

}
           

这样就做到,高度定制化。

7.状态模式定制动画

可根据圆盘状态,定制与之相匹配的动画,想要多炫有多炫,只要监听setOnStatusChangedListener方法即可实现。

public enum CircleMenuStatus {
	/** 静止 **/
	IDLE,
	/** 开始旋转 **/
	START_ROTATING,
	/** 正在旋转 **/
	ROTATING,
	/** 暂停旋转 **/
	PAUSE_ROTATING,
	/** 叫停旋转,是ROTATING之后,START_FLING之前的一个状态,本项目中意义不大,看用户怎么利用 **/
	STOP_ROTATING,
	/** 开始飞转 **/
	START_FLING,
	/**正在飞转 **/
	FLING,
	/** 叫停飞转 **/
	STOP_FLING;
}
           
mCircleMenu.setOnStatusChangedListener(new OnMenuStatusChangedListener() {

			@Override
			public void onStatusChanged(CircleMenuStatus status, double rotateAngle) {
				// TODO 可在此处定制各种动画
				odAnimation(status, (float)rotateAngle);
			}

		});

	private void odAnimation(CircleMenuStatus status, float rotateAngle) {

		switch (status) {
		case IDLE:
			Log.i(TAG, "--- -IDLE-----");
			animRotate.cancel();
			animRotate.cancel();
			break;
		case START_ROTATING:
			Log.i(TAG, "--- -START_ROTATING-----");
			break;
		case ROTATING:
			animRotate = ObjectAnimator.ofFloat(ivCenter, "rotation", startRotate, startRotate + rotateAngle);
			animRotate.setDuration(200).start();
			startRotate += rotateAngle;
			// Log.i(TAG, "--- -ROTATING-----");
			break;
		case STOP_ROTATING:
			Log.i(TAG, "--- -STOP_ROTATING-----");
			break;
		case START_FLING:
			Log.i(TAG, "--- -START_FLING-----");
			break;

		case FLING:
			// Log.i(TAG, "--- -FLING-----");
			animFling = ObjectAnimator.ofFloat(ivCenter, "rotation", startFling, startFling + rotateAngle);
			animFling.setDuration(200).start();
			startFling += rotateAngle;
			break;
		case STOP_FLING:
			Log.i(TAG, "--- -STOP_FLING-----");

			break;

		default:
			break;
		}

	}
           

是不是很炫啊

Android 圆盘旋转/飞转菜单(高度定制化)

完!