1. 程式人生 > >星球旋轉選單

星球旋轉選單

今天偶爾看到鴻洋部落格實現建行的圓形選單,效果看起來還不錯。 原文在這裡實現建行圓形選單 公司正好需要做一個 星球旋轉的選單,於是就在基礎上修改了一下,先看效果圖 靜態圖是這樣的,公司的網不允許上傳視訊,只能傳個截圖了看看效果了。

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>

好了,到這裡我們的選單基本上就完成了。基本上就是數學關係的處理,文章就結束了~~