自定义控件实现弹性旋转的圆形菜单
- 写这个之前参考了一下其他类似的控件,自己实现了一下并做如下记录
- 使用到的包含“事件拦截”、“三角函数”、“View测量”、“View布局”
- 控件的效果图如下,旋转动画、弹性旋转、item点击
流程梳理
- 首先要实现一个圆形的菜单控件我们选择继承ViewGroup;
- 第一步考虑在onMeasure中对所有Child进行测量,测量完成后onLayout才可以获取到Child的测量宽高;
- 第二步考虑Child排版问题,也就是核心代码中onLayout的过程;
- 在这个过程中,定义了“当前旋转角度”、“旋转中心”、旋转半径;
- 获取容器内部Child个数,计算相邻Child角度间距;
- 在已知旋转中心、半径、当前旋转角度、相邻Child角度间距后就可以为Child排版啦;
- 这里根据角度及其所在象限,计算出该角度相对于每个象限的角度并计算其正切值;
- 在已知半径、正切值就可以通过三角函数tan(A)=a边/b边、勾股定理a²+b²=c²计算出a边长、b边长;
- 根据Child所在的象限,我们可以利用a边、b边计算出该Child中心点所在的坐标;
- 知道这样就可以对该Child进行布局了,即调用Child.onLayout(l,t,r,b),布局时需要使用Child的测量宽高;
- 第三步考虑触摸事件的拦截,这里使用容器onInterceptTouchEvent 方法在滑动距离大于系统touchSlop时进行拦截;
- 一旦ViewGroup决定拦截该事件,那么后续的事件都会调用容器onTouchEvent;
- 我们在onTouchEvent 的Move时,计算前后2次事件的偏转角度来更改“当前选中角度”并要求容器重新布局 requestLayout(); 以此达到旋转的目的;
- 我们在onTouchEvent 的Up时计算本次旋转在1秒内旋转过的角度是否达到弹性旋转
- 如果需要弹性旋转则根据当前的速度,设定Runnable 进行弹性旋转;
代码块
控件的代码如下:
public class RotateView extends ViewGroup {
private static final String TAG = "RotateView";
/**
* 当前已旋转的角度 ,当改变该角度时,并重新布局则达到旋转的效果
*/
private float mCurAngle = 0f;
/**
* 记录每次旋转开始时的角度
*/
private float mStartRotateAngle;
/**
* 当前ViewGroup 旋转的中心点坐标
*/
private PointF mCenterPoint;
/**
* 围绕中心点旋转的半径
*/
private double mR;
/**
* 缓存每个Child 布局时所在的位置
*/
private PointF mChildPoint = new PointF();
/**
* 系统可检测的最小滑动距离
*/
private int touchSlop;
/**
* 记录每次MotionEvent 的坐标值
*/
private float mLastX;
private float mLastY;
/**
* 记录开始滑动的时间
*/
private long mStartRotateTime;
/**
* 触发弹性旋转的边界值
*/
private static final float ROTATE_RATE = 500;
/**
* 弹性旋转Runnable
*/
private RotateRunnable action;
/**
* 当前是否处于弹性旋转状态
*/
private boolean isFling;
public RotateView(Context context) {
this(context, null);
}
public RotateView(Context context, AttributeSet attrs) {
super(context, attrs);
mCenterPoint = new PointF();
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//1、对每个Child进行测量 在测量之后才能获取到Child的 MeasureHeight 和MeasureWidth
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
measureChild(getChildAt(i), widthMeasureSpec, heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int width = getWidth();
int height = getHeight();
//2、初始化旋转的中心点
mCenterPoint.x = width / 2;
mCenterPoint.y = height / 2;
//3、初始化旋转的半径
mR = ((Math.min(width, height) / 2d) * 0.6d);
Log.i(TAG, "Angle=" + mCurAngle + " Cx=" + mCenterPoint.x + " Cy=" + mCenterPoint.y);
if (getChildCount() > 0) {
//4、每相邻的Child的角度间距
float mPerAngle = 360f / getChildCount();
//5、根据角度开启每个Child 排版过程
for (int i = 0; i < getChildCount(); i++) {
//6、计算当前Child角度 保证其在Child在 360>angle>=0
float angle = (mCurAngle + mPerAngle * i) % 360f;
if (angle < 0) { //比如Child当前角度为-30,那么它其实就是 360-30=330度是一样的
angle = 360f - Math.abs(angle) % 360f;
}
View child = getChildAt(i);
//7、计算当前Child位于哪个象限
int quadrant = getQuadrant(angle);
//8、计算该角度正切值 可能为0
double tanA = getTanA(quadrant, angle);
//9、根据正切值获取A边边长
double edgeA = getEdgeA(tanA);//A边
double edgeB = edgeA == 0 ? mR : edgeA / tanA;//B边
Log.i(TAG, "child=" + i + " 象限=" + quadrant);
//10、根据child所在象限 及 A边 B边 计算Child中心点位置坐标
computeChildLocation(quadrant, edgeA, edgeB);
//11、根据Child中心点坐标及Child大小 进行布局
layoutChild(child);
}
}
}
/**
* 根据Child中心点位置进行布局
*/
private void layoutChild(View child) {
int measuredHeight = child.getMeasuredHeight();
int measuredWidth = child.getMeasuredWidth();
int left = (int) (mChildPoint.x - measuredWidth / 2);
int top = (int) (mChildPoint.y - measuredHeight / 2);
int right = (int) (mChildPoint.x + measuredWidth / 2);
int bottom = (int) (mChildPoint.y + measuredHeight / 2);
child.layout(left, top, right, bottom);
}
/**
* 根据child所在象限 及A边 B边 计算其所在位置
*/
private void computeChildLocation(int quadrant, double edgeA, double edgeB) {
//1象限 tan A= y/x; 2象限 tan A =x/y ;3象限 y/x ;4象限 x/y
switch (quadrant) {
case 1://第一象限
mChildPoint.x = (float) (mCenterPoint.x + edgeB);
mChildPoint.y = (float) (mCenterPoint.y - edgeA);
break;
case 2://第二象限
mChildPoint.x = (float) (mCenterPoint.x - edgeA);
mChildPoint.y = (float) (mCenterPoint.y - edgeB);
break;
case 3://第三象限
mChildPoint.x = (float) (mCenterPoint.x - edgeB);
mChildPoint.y = (float) (mCenterPoint.y + edgeA);
break;
default://第四象限
mChildPoint.x = (float) (mCenterPoint.x + edgeA);
mChildPoint.y = (float) (mCenterPoint.y + edgeB);
break;
}
}
/**
* 根据正切值获取A边长度
*/
private double getEdgeA(double tanA) {
if (tanA == 0) {//如果正切值=0 ,那边A边长度=0,B边长=半径
return 0;
} else {//否则根据直角三角函数 a²+b²=c² tan(A)= a/b
return Math.sqrt((mR * mR * tanA * tanA) / (1 + tanA * tanA));
}
}
/**
* 角度转弧度并获取正切值
*
* @param quadrant 象限
* @param angle 角度
* @return 正切值
*/
public double getTanA(int quadrant, float angle) {
float A;
switch (quadrant) {
case 1:
A = angle;
break;
case 2:
A = angle - 90;
break;
case 3:
A = angle - 180;
break;
default:
A = angle - 270;
break;
}
//角度转弧度 求正切值
return Math.tan(Math.toRadians(A));
}
/**
* 获取象限
*/
public int getQuadrant(float rAngle) {
if (rAngle >= 0 && rAngle < 90) {
return 1;
}
if (rAngle >= 90 && rAngle < 180) {
return 2;
}
if (rAngle >= 180 && rAngle < 270) {
return 3;
}
return 4;
}
public void setMCurAngle(float mCurAngle) {
this.mCurAngle = mCurAngle;
requestLayout();
}
public void startAnim() {
ObjectAnimator animator = ObjectAnimator.ofFloat(this, "MCurAngle", mCurAngle, mCurAngle + 720f);
animator.setDuration(5 * 1000);
animator.start();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
float x = ev.getX();
float y = ev.getY();
boolean intercepted = false;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//Down首先判断当前是否处于弹性旋转状态
if (isFling) {//如果是则重置状态,并移除弹性旋转,也就是按下立刻停止旋转
isFling = false;
removeCallbacks(action);
return true;
}
break;
case MotionEvent.ACTION_MOVE: {
//判断滑动的距离 只有大于系统的可识别滑动距离则容器拦截事件
float diffX = Math.abs(x - mLastX);
float diffY = Math.abs(y - mLastY);
if (diffX >= touchSlop || diffY >= touchSlop) {
//记录开始旋转的角度
mStartRotateAngle = mCurAngle;
//记录开始旋转的时间
mStartRotateTime = System.currentTimeMillis();
intercepted = true;
}
}
break;
case MotionEvent.ACTION_UP:
break;
}
mLastX = x;
mLastY = y;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE: {
//计算上一次事件所在的角度
float startAngle = getAngle(mLastX, mLastY);
//计算本次事件所在的角度
float endAngle = getAngle(x, y);
float changeAngle = startAngle - endAngle;
//获取当前事件所在的象限
int quadrant = getQuadrant(x, y);
float curAngle;
if (quadrant == 1 || quadrant == 4) {
curAngle = mCurAngle + changeAngle;
} else {
curAngle = mCurAngle - changeAngle;
}
//设置当前旋转角度并重新布局
setMCurAngle(curAngle);
}
break;
case MotionEvent.ACTION_UP: {
long rotateDuration = System.currentTimeMillis() - mStartRotateTime;
float sweepAngle = mCurAngle - mStartRotateAngle;
//计算每秒活动的角度
float speed = sweepAngle * 1000 / rotateDuration;
Log.i(TAG, "speed=" + speed);
if (Math.abs(speed) > ROTATE_RATE) {
action = new RotateRunnable(speed);
post(action);
}
}
break;
}
mLastX = x;
mLastY = y;
return true;
}
private float getAngle(float xTouch, float yTouch) {
double x = xTouch - mCenterPoint.x;
double y = yTouch - mCenterPoint.y;
return (float) (Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI);
}
public int getQuadrant(float x, float y) {
if (x >= mCenterPoint.x) {
return y >= mCenterPoint.y ? 1 : 4;
} else {
return y < mCenterPoint.y ? 2 : 3;
}
}
public class RotateRunnable implements Runnable {
RotateRunnable(float speed) {
this.speed = speed;
}
float speed;
@Override
public void run() {
if (Math.abs(speed) < 20) {
isFling = false;
return;
}
float addAngle = mCurAngle + (speed / 30);
setMCurAngle(addAngle);
isFling = true;
speed /= 1.0666F;
postDelayed(this, 30);
}
}
}
- 代码在GitHub上:https://github.com/yushilei1218/MyApp2.git;
Br