星球旋轉選單
今天偶爾看到鴻洋部落格實現建行的圓形選單,效果看起來還不錯。 原文在這裡實現建行圓形選單 公司正好需要做一個 星球旋轉的選單,於是就在基礎上修改了一下,先看效果圖 靜態圖是這樣的,公司的網不允許上傳視訊,只能傳個截圖了看看效果了。
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>
好了,到這裡我們的選單基本上就完成了。基本上就是數學關係的處理,文章就結束了~~