今天偶尔看到鸿洋博客实现建行的圆形菜单,效果看起来还不错。
原文在这里实现建行圆形菜单
公司正好需要做一个 星球旋转的菜单,于是就在基础上修改了一下,先看效果图
静态图是这样的,公司的网不允许上传视频,只能传个截图了看看效果了。
1.看下简单的使用
MainActivity
package com.safewaychina.circlemenulayout;
import android.os.Bundle;
import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;
public class MainActivity extends AppCompatActivity {
private OvalMenuLayout mOvalMenuLayout;
private static int[] imageIds = {
R.mipmap.home_star_practice,
R.mipmap.home_star_extension,
R.mipmap.home_star_assessment,
R.mipmap.home_star_learning
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mOvalMenuLayout = (OvalMenuLayout) findViewById(R.id.id_menulayout);
OvalMenuAdapter menuAdapter = new OvalMenuAdapter(imageIds);
menuAdapter.setOnItemClickListener(new OvalMenuAdapter.OnMenuItemClickListener() {
@Override
public void itemClick(View view, int position) {
Toast.makeText(MainActivity.this, imageIds[position], Toast.LENGTH_LONG).show();
}
});
mOvalMenuLayout.setMenuAdapter(menuAdapter);
}
}
布局文件
<?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:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
>
<com.safewaychina.circlemenulayout.OvalMenuLayout
android:id="@+id/id_menulayout"
android:layout_width="400dp"
android:layout_height="300dp"
android:clickable="true"
android:focusable="true"
android:layout_centerInParent="true"
android:clipChildren="false"
>
</com.safewaychina.circlemenulayout.OvalMenuLayout>
</RelativeLayout>
1.主活动中,我们主要调用OvalMenuLayout中的setMenuAdapter()方法,看一下干了什么。
/**
* 菜单的个数
*/
private int mMenuItemCount;
public void setMenuAdapter(AbstractMenuAdapter menuAdapter) {
this.mMenuAdapter = menuAdapter;
if (menuAdapter != null) {
addMenuItems();
}
}
private void addMenuItems() {
LayoutInflater mInflater = LayoutInflater.from(getContext());
mMenuItemCount = mMenuAdapter.getCount();
/**
* 根据用户设置的参数,初始化view
*/
for (int i = 0; i < mMenuItemCount; i++) {
View v = mMenuAdapter.onCreateView(mInflater, this);
mMenuAdapter.onViewBinder(v, i);
// 添加view到容器中
addView(v);
}
}
2.可以看到,先取的adapter中数据,然后循环遍历,通过适配器的onCreateView()方法创建出的视图,将视图addView到OvalMenuLayout中。
视图添加进来了,那OvalMenuLayout怎么把子view进行测量和布局的呢,这就要看我们ViewGroup的onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法和onLayout(boolean changed, int l, int t, int r, int b)方法了
private static final double OVAL_A = 340;
private static final double OVAL_B = 180;
private int mRadiusX;
private int mRadiusY;
/**
* 该容器内child item的默认尺寸
*/
private static final float RADIO_DEFAULT_CHILD_DIMENSION = 1 / 2f;
/**
* 该容器的内边距,无视padding属性,如需边距请用该变量
*/
private static final float RADIO_PADDING_LAYOUT = 1 / 12f;
/**
* 该容器的内边距,无视padding属性,如需边距请用该变量
*/
private float mPadding;
/**
* 每个菜单的间隔角度
*/
private float angleDelay;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int resWidth = 0;
int resHeight = 0;
/**
* 根据传入的参数,分别获取测量模式和测量值
*/
int width = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
/**
* 如果宽或者高的测量模式非精确值
*/
if (widthMode != MeasureSpec.EXACTLY
|| heightMode != MeasureSpec.EXACTLY) {
// 主要设置为背景图的高度
resWidth = getSuggestedMinimumWidth();
// 如果未设置背景图片,则设置为屏幕宽高的默认值
resWidth = resWidth == 0 ? getDefaultWidth() : resWidth;
resHeight = getSuggestedMinimumHeight();
// 如果未设置背景图片,则设置为屏幕宽高的默认值
resHeight = resHeight == 0 ? getDefaultWidth() : resHeight;
} else {
// 如果都设置为精确值,则直接取小值;
resWidth = width;
resHeight = height;
}
setMeasuredDimension(resWidth, resHeight);
// 获得半径
mRadiusX = getMeasuredWidth();
mRadiusY = getMeasuredHeight();
// menu item数量
final int count = getChildCount();
// menu item尺寸
int childSize = (int) (Math.min(mRadiusX, mRadiusY) * RADIO_DEFAULT_CHILD_DIMENSION);
// menu item测量模式
int childMode = MeasureSpec.EXACTLY;
// 迭代测量
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (child.getVisibility() == GONE) {
continue;
}
// 计算menu item的尺寸;以及和设置好的模式,去对item进行测量
int makeMeasureSpec = -1;
makeMeasureSpec = MeasureSpec.makeMeasureSpec(childSize,
childMode);
child.measure(makeMeasureSpec, makeMeasureSpec);
}
mPadding = RADIO_PADDING_LAYOUT * mRadiusX;
}
private int getDefaultWidth() {
WindowManager wm = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics outMetrics = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(outMetrics);
return Math.min(outMetrics.widthPixels, outMetrics.heightPixels);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int layoutRadius = Math.min(mRadiusX, mRadiusY);
// Laying out the child views
final int childCount = getChildCount();
int left, top;
// menu item 的尺寸
int cWidth = (int) (layoutRadius * RADIO_DEFAULT_CHILD_DIMENSION);
// 根据menu item的个数,计算角度
angleDelay = 360 / (childCount == 0 ? -1 : childCount);
// 遍历去设置menuitem的位置
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
if (child.getVisibility() == GONE) {
continue;
}
mStartAngle %= 360;
// tmp cosa 即menu item中心点的横坐标
left = (int) (mRadiusX
/ 2
+ Math.ceil(getXInOval(mStartAngle)) - 1 / 2f
* cWidth);
// tmp sina 即menu item的纵坐标
top = (int) (mRadiusY
/ 2
- Math.ceil(getYInOval(mStartAngle)) - 1 / 2f
* cWidth);
child.layout(left, top, left + cWidth, top + cWidth);
if (mMenuAdapter != null) {
mMenuAdapter.upDateView(child, mStartAngle, i);
}
// 叠加尺寸
mStartAngle += angleDelay;
}
}
private double getYInOval(double degress) {
double a = OVAL_A;
double b = OVAL_B;
double y = (a * b)
/ (Math.sqrt((Math.pow(b, 2)
* Math.pow(Math.tan(Math.toRadians(degress)), 2) + Math.pow(a, 2)
)));
if (degress > 90 && degress < 270) {
y = -y;
}
return y;
}
private double getXInOval(double degress) {
double a = OVAL_A;
double b = OVAL_B;
double x = (a * b) / (Math.sqrt((Math.pow(a, 2)
/ Math.pow(Math.tan(Math.toRadians(degress)), 2) + Math.pow
(b, 2))));
if (degress < 360 && degress > 180) {
x = -x;
}
return x;
}
这里面就是数学的计算了,要计算子view的布局,我们通过角度来确定子view的位置。数学关系:通过计算函数y=cotanx 与椭圆 xx / (aa) + y y/(b b) = 1的交点。**
3.处理手势
在dispatchTouchEvent(MotionEvent event) 中处理手势事件。
/**
* 当每秒移动角度达到该值时,认为是快速移动
*/
private static final int FLINGABLE_VALUE = 300;
/**
* 如果移动角度达到该值,则屏蔽点击
*/
private static final int NOCLICK_VALUE = 3;
/**
* 当每秒移动角度达到该值时,认为是快速移动
*/
private int mFlingableValue = FLINGABLE_VALUE;
private float mLastX, mLastY;
private Runnable mRunnable;
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastX = x;
mLastY = y;
mDownTime = System.currentTimeMillis();
mTmpAngle = 0;
removeCallbacks(mRunnable);
break;
case MotionEvent.ACTION_MOVE:
/**
* 获得开始的角度
*/
float start = getAngle(mLastX, mLastY);
/**
* 获得当前的角度
*/
float end = getAngle(x, y);
// 如果是一、四象限,则直接end-start,角度值都是正值
if (getQuadrant(x, y) == 1 || getQuadrant(x, y) == 4) {
mStartAngle += end - start;
mTmpAngle += end - start;
} else
// 二、三象限,色角度值是付值
{
mStartAngle += start - end;
mTmpAngle += start - end;
}
// 重新布局
requestLayout();
mLastX = x;
mLastY = y;
break;
case MotionEvent.ACTION_UP:
// 计算,每秒移动的角度
float anglePerSecond = mTmpAngle * 1000
/ (System.currentTimeMillis() - mDownTime);
if (Math.abs(anglePerSecond) > mFlingableValue) {
// // TODO: 2018/9/18 快速滚动 post一个任务,不断减速滚动到指定位置
Log.d("LyjLog", " 快速滚动:" + anglePerSecond);
post(mRunnable = new AutoFlingRunnable(anglePerSecond));
return true;
} else {
Log.d("LyjLog", " 缓慢滚动:" + anglePerSecond);
// // TODO: 2018/9/18 缓慢滚动 去自动滚动到指定位置
// mRunnable = new AutoFlingRunnable(mStartAngle);
// post(mRunnable);
rollToNearPosition(mStartAngle, anglePerSecond > 0);
}
// 如果当前旋转角度超过NOCLICK_VALUE屏蔽点击
if (Math.abs(mTmpAngle) > NOCLICK_VALUE) {
return true;
}
break;
default:
break;
}
return super.dispatchTouchEvent(event);
}
private float getAngle(float x, float y) {
double tmpx = x - mRadiusX / 2d;
double tmpy = y - mRadiusY / 2d;
return (float) (Math.asin(tmpy / Math.hypot(tmpx, tmpy)) * 180 / Math.PI);
}
private int getQuadrant(float x, float y) {
int tmpX = (int) (x - mRadiusX / 2);
int tmpY = (int) (y - mRadiusY / 2);
if (tmpX >= 0) {
return tmpY >= 0 ? 4 : 1;
} else {
return tmpY >= 0 ? 3 : 2;
}
}
private void rollToNearPosition(double velocity, boolean upOrDown) {
mStartAngle %= 360;
Log.d("LyjLog", " rollToNearPosition: mStartAngle= " + mStartAngle);
float targetAngle = getNearAngle(velocity, upOrDown);
ValueAnimator valueAnimator = ValueAnimator.ofFloat((float) mStartAngle, targetAngle);
valueAnimator.setInterpolator(new DecelerateInterpolator());
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float d = (float) animation.getAnimatedValue();
mStartAngle = d;
requestLayout();
Log.d("LyjLog", " rollToNearPosition: targetAngle= " + mStartAngle);
}
});
valueAnimator.start();
}
private float getNearAngle(double velocity, boolean upOrDown) {
velocity %= 360;
float per = 1 / 3;
float targetAngle = (float) mStartAngle;
childPositions = new float[getChildCount()];
for (int i = 0; i < childPositions.length; i++) {
childPositions[i] = i * angleDelay;
}
if (upOrDown) {
//向前旋转
if (velocity > childPositions[childPositions.length - 1] + angleDelay * per) {
targetAngle = 360;
} else {
for (int i = 0; i < childPositions.length; i++) {
if (velocity < childPositions[i] + angleDelay * per) {
targetAngle = childPositions[i];
break;
}
}
}
} else {
//向后旋转
if (velocity < childPositions[0] + angleDelay * (1 - per)) {
targetAngle = 0;
} else {
for (int i = childPositions.length - 1; i >= 0; i--) {
if (velocity > childPositions[i] + angleDelay * (1 - per)) {
targetAngle = childPositions[i] + angleDelay;
break;
}
}
}
}
return targetAngle;
}
private class AutoFlingRunnable implements Runnable {
private double angelPerSecond;
/**
* 默认每秒旋转角度
*/
private final double DEFAULT_ANGLE = 80f;
/**
* 刷新间隔,一秒60帧
*/
private final long DURATION = 1000 / 70;
/**
* 加速度
*/
private final float DECELERATION = 3;
public AutoFlingRunnable(float velocity) {
this.angelPerSecond = velocity;
}
@Override
public void run() {
if (Math.abs(angelPerSecond) < DEFAULT_ANGLE) {
rollToNearPosition(mStartAngle, angelPerSecond > 0);
removeCallbacks(mRunnable);
return;
} else {
//当前每秒旋转角度大于默认旋转角度
mStartAngle += angelPerSecond / DURATION;
if (angelPerSecond > 0) {
angelPerSecond -= DECELERATION;
} else {
angelPerSecond += DECELERATION;
}
}
Log.d("LyjLog", "angelPerSecond:" + angelPerSecond + " mStartAngle:" + mStartAngle);
requestLayout();
postDelayed(this, DURATION);
}
}
处理手势的逻辑,
1.快速滑动,不断旋转减速停在离自己最近的位置。
2.缓慢旋转,停留在离自己最近的位置
缓慢旋转通过ValueAnimator来实现,快速滑动使用了Runnable。
如果需要增加公转的功能,修改下Runnable里的逻辑就好了。不断的让他自己重新布局增加mStartAngle就可以实现。
最后在构造方法里面设置几个属性
public OvalMenuLayout(Context context) {
this(context, null);
}
public OvalMenuLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public OvalMenuLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setPadding(0, 0, 0, 0);
setClickable(true);
setClipChildren(false);
}
基本我们布局和操作都完成了。最后再看看我们实现了AbstractMenuAdapter的
OvalMenuAdapter类里面,简单的适配器。主要确定数据的逻辑处理和创建出每个View。模仿RecyclerView的适配器。
public class OvalMenuAdapter extends AbstractMenuAdapter {
/**
* 菜单项的图标
*/
private int[] mItemImags;
public OvalMenuAdapter(int[] mItemImags) {
// 参数检查
this.mItemImags = mItemImags;
mItemCount = mItemImags.length;
}
@Override
public int getCount() {
return mItemCount;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container) {
View view = inflater.inflate(R.layout.circle_menu_item, container,
false);
return view;
}
@Override
public void onViewBinder(View itemView, int position) {
final int i = position;
ImageView iv = (ImageView) itemView
.findViewById(R.id.id_circle_menu_item_image);
if (iv != null) {
iv.setImageResource(mItemImags[i]);
iv.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mOnMenuItemClickListener != null) {
mOnMenuItemClickListener.itemClick(v, i);
}
}
}
);
}
}
@Override
public void upDateView(View v, double angle, int position) {
/**
* don't see
* 0.8*-abs(cos(1/2*(3.1415*(sin(1/2*x)))))+1.2
*/
float d = (float) (0.8f
* -Math.abs(Math.cos(0.5f * Math.PI * Math.sin(Math.toRadians(angle) / 2))) + 1.2);
v.setScaleY(d);
v.setScaleX(d);
if (d > 1) {
d = 1;
}
v.setAlpha(d);
}
public interface OnMenuItemClickListener {
void itemClick(View view, int pos);
}
private OvalMenuAdapter.OnMenuItemClickListener mOnMenuItemClickListener;
public void setOnItemClickListener(OvalMenuAdapter.OnMenuItemClickListener l) {
this.mOnMenuItemClickListener = l;
}
}
布局文件 circle_menu_item.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/id_circle_menu_item_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="visible"/>
</LinearLayout>
好了,到这里我们的菜单基本上就完成了。基本上就是数学关系的处理,文章就结束了~~