自定义控件:实现半圆滚动菜单效果

前言

本自定义控件参考自鸿洋大神的自定义控件(原文地址),基于原来的控件效果进行修改,着重实现了以下效果:位置自动修正以及滑动结束的回调。我们先来看看效果图:

《自定义控件:实现半圆滚动菜单效果》 控件展示

上面的图片是一个ImageView,与控件无关,是为了验证回调功能。接着是位置自动修正:

《自定义控件:实现半圆滚动菜单效果》 位置自动修正

位置自动修正的意思是说,每个item view经过滑动后,停留的位置不是随意的,而是固定在某个区域之内,就如每个item view装在一个个格子里面。

《自定义控件:实现半圆滚动菜单效果》 滑动结束回调

而滑动结束的回调是说,当滑动结束后,滑动到中央的item view会触发一次回调,用户可以利用该回调来进行别的逻辑处理,与别的控件进行交互,比如:某个item view滑动到中央,触发回调,让别的TextView或者ImageView来具体显示该item项的具体信息。

用法

只要在Activity中写上如下几行代码即可:

SemicircleMenu mSemicircleMenu = (SemicircleMenu) findViewById(R.id.circlemenu);
    mSemicircleMenu.setMenuItemIconsAndTexts(mItemImgs, mItemTexts);
    mSemicircleMenu.setOnMenuItemClickListener(new SemicircleMenu.OnMenuItemClickListener() {
        @Override
        public void itemClick(View view, int pos) {
            Toast.makeText(MainActivity.this, mItemTexts[pos], Toast.LENGTH_SHORT).show();

        }
    });

    mSemicircleMenu.setOnCentralItemCallback(new SemicircleMenu.OnCentralItemCallback() {
        @Override
        public void centralItemOperate(int pos) {
            imageView.setImageResource(mItemImgs[pos]);
        }
    });

布局文件:

<com.chenyu.semicirclemenu.SemicircleMenu
        android:id="@+id/circlemenu"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#72d8ff"
        android:clickable="true">
</com.chenyu.semicirclemenu.SemicircleMenu>

(注意:clickable应该为true。)

实现原理

其实关于测量、布局、甚至事件分发的实现原理在原文章都有很详细的说明了,有兴趣的读者可以先阅读原文,这里会作简要的说明,本文重点在于讲述位置修正即滑动结束回调的实现,本文所有代码均作了删减,读者可直接到GitHub(地址在文章末尾)处阅读源码。

Part 1 设置itemView的内容及加载itemView

/**
 * 每个Item之间相距的角度
 */
private float mAngleDelay;

/**
 *  设置菜单的文本信息
 */
public void setMenuItemIconsAndTexts(int[] resIds,String[] texts)
{
    mItemIcons = resIds;
    mItemTexts = texts;

    if(resIds == null && texts == null)
    {
        throw new IllegalArgumentException("菜单文本和图片必须设置其一");
    }

    //初始化mMenuItemCount
    mMenuItemCount = resIds == null ? texts.length : resIds.length;

    if(resIds != null && texts != null)
    {
        mMenuItemCount = Math.min(resIds.length,texts.length);
    }
    //计算每个Item之间相差的度数,该值直接影响后面的布局、滑动
    mAngleDelay = 360 / mMenuItemCount;
    addMenuItems();
}

private void addMenuItems() {
    LayoutInflater mInflater = LayoutInflater.from(getContext());

    /**
     *  初始化item view
     */
    for(int i = 0; i < mMenuItemCount; i++)
    {
        final int j = i;
        View view = mInflater.inflate(R.layout.circle_menu_item,this,false);
        view.setTag(i);  //为每个item view打上Tag
        ImageView iv = (ImageView) view.findViewById(R.id.id_circle_menu_item_image);
        TextView tv = (TextView) view.findViewById(R.id.id_circle_menu_item_text);

       //...

        addView(view);
    }
}

从以上代码来看,暴露了setMenuItemIconsAndTexts方法,用户可以通过该方法为该控件设置不同的Item的图像及其文本信息。接着根据设置item的数量,来计算每个item之间应相隔多少度,即mAngleDelay值,例如,如果是6个Item,那么mAngleDelay值就是60度,以此类推。接着,在addMenuItem方法内,是不断加载Item View,并且添加至当前ViewGroup内。(注意:形如R.id.id_circle_menu_item_image的id是定义在values文件夹下的id文件内的)。

Part 2 测量和布局

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
    //...

    //我们只需要半圆区域,因此把高度限制为一半
    setMeasuredDimension(resWidth,resHeight /2);

    mRadius = Math.max(getMeasuredWidth(),getMeasuredHeight());

    final  int count = getChildCount();
    int childSize = (int) (mRadius * DEFAULT_CHILD_DIMENSION);
    int childMode = MeasureSpec.EXACTLY;
    
    //遍历所有子View,对其进行测量
    for(int i = 0; i < count;i++)
    {
        final  View child = getChildAt(i);
        if(child.getVisibility() == GONE)
        {
            continue;
        }

        int makeMeasureSpec = MeasureSpec.makeMeasureSpec(childSize,childMode);
        child.measure(makeMeasureSpec,makeMeasureSpec);

    }

    mPadding = PADDING_LAYOUT * mRadius;
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b)
{
    int layoutRadius = mRadius;

    final  int childCount = getChildCount();

    int left,top;
    //每个item view的宽度
    int cWidth = (int) (layoutRadius * DEFAULT_CHILD_DIMENSION);
    //坐标原点到item view中心的距离
    float tmp = layoutRadius / 2f - cWidth / 2 - mPadding;

    for(int i =0;i<childCount;i++)
    {
        final View child = getChildAt(i);
        if(child.getId() == R.id.id_circle_menu_item_center)
            continue;
        if(child.getVisibility() ==GONE)
            continue;
        
        mStartAngle %= 360;

        left = (int) (layoutRadius/2 + Math.round(tmp*Math.cos(Math.toRadians(mStartAngle))-1/2f *cWidth));
        top = (int) (Math.round(tmp*Math.sin(Math.toRadians(mStartAngle))-1/2f * cWidth));

        child.layout(left,top,left+cWidth,top+cWidth);
        //把mAngleDelay累加到起始角度中,这样一次for循环就能把所有item view布局完毕
        mStartAngle -= mAngleDelay;
    }

    //布局结束的时候,如果不在滚动同时也不在被触摸的时候,触发滚动结束回调
    if(!isFling && !mTouchFlag )
    {
        mOnCentralItemCallback.centralItemOperate((Integer) findChildViewUnder(layoutRadius/2,tmp).getTag());
    }
}

在加载完item view后,我们便对所有item view进行测量、布局,其中布局流程中,我们根据mStartAngle这个角度来进行布局,通过计算三角关系把所有view的left、top坐标计算出来,然后布局,这样每个Item view就一一形成了。顺带一说,这里mStartAngle一开始等于90度,也就是说第一个选项出现的位置是正中央。在布局的最后,存在一个判断语句,判断我们在滑动结束后是否要进行回调,这个下面会详细说明。

Part 3 事件分发

在初始化布局完毕后,一个半圆的菜单便显示出来了,接下来我们需要对触摸事件进行处理,以便能进行滑动。我们先看看代码:

@Override
public boolean dispatchTouchEvent(MotionEvent ev)
{
    float x = ev.getX();
    float y = ev.getY();


    switch (ev.getAction())
    {
        case MotionEvent.ACTION_DOWN:
            mLastX = x;
            mLastY = y;
            mDownTime = System.currentTimeMillis();
            mTmpAngle = 0;
            mTouchFlag = true;

            //如果按下的时候,正在自动滚动状态,那么取消滚动,并且进行位置矫正
            if(isFling)
            {
                removeCallbacks(mFlingRunnable);
                isFling = false;
                mCorrectPositionFlag = true;
                post(mFlingRunnable = new AutoFlingRunnable(getCorrectAngle(mAutoFlingAngle % mAngleDelay)));
                return true;
            }
            break;

        case MotionEvent.ACTION_MOVE:
            float start = getAngle(mLastX,mLastY);
            float end = getAngle(x,y);
            if(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:
            mTouchFlag = false;
            float anglePerSecond = mTmpAngle * 1000 / (System.currentTimeMillis() - mDownTime);

            //如果角速度超过规定的值,那么认为是快速滚动,开启快速滚动任务
            //否则,直接进行位置矫正
            if(Math.abs(anglePerSecond) >= mFlingableValue && !isFling)
            {
                mAutoFlingAngle = mTmpAngle;
                post(mFlingRunnable = new AutoFlingRunnable(anglePerSecond));
                return true;
            }else if(Math.abs(anglePerSecond) < mFlingableValue)
            {
                float mDeltaAngle = mTmpAngle % mAngleDelay ;
                if(mDeltaAngle != 0)
                {
                    post(mFlingRunnable = new AutoFlingRunnable(getCorrectAngle(mDeltaAngle)));
                    return true;
                }
            }

            // 如果当前旋转角度超过NOCLICK_VALUE屏蔽点击
            if (Math.abs(mTmpAngle) > NOCLICK_VALUE)
            {
                return true;
            }

            break;
    }
    return super.dispatchTouchEvent(ev);
}

大体思路是这样的,当检测到ACTION_DOWN事件时,记录当前的触摸坐标,接着在ACTION_MOVE事件中,不断获取实时的触摸坐标,并计算角度,通过新的角度来累加到mStartAngle中,接着调用requestLayout()方法来重新布局,这样便实现了该菜单跟随手指而转动的效果,这是最基础的。但如果是快速滑动呢?那么我们在ACTION_UP事件中,通过判断滑动的距离与从触摸到松开手指的时间的比值来判断是否是快速滑动。如果是快速滑动,那么启动一个Runnable来完成快速滚动的事件,其实这也很简单,在Runnable中不断调用requestLayout()就可以实现快速滚动的效果了,这些在原文章都有详细说明。

Part 4 位置自动修正

但是,由于随手指滑动,或者快速滑动完毕后,其最后的滑动角度一般是一个随机的数值,这样就会造成item view出现在不应该出现的位置,比如正中央恰好没有item view出现,都出现了一定的偏移,这样对于菜单来说是非常不理想的,所以我们需要进行位置的矫正,使得每一个的滑动完成后,其Item View都能在正确的位置出现,而解决这个问题,我们可以从以下思路来解决:首先把总的滚动角度先算出来,那么这个总的滚动角度便直接决定了滚动完毕后各item的位置,既然我们不能影响其滚动过程,那么我们可以在滚动结束后,通过对总滚动角度进行一系列的判断来对最后的位置进行调整,并再一次requestLayout,使得位置得以矫正。
那么这个总的滚动角度是怎样与位置矫正联系起来的呢?在代码里面,总滚动角度用mTmpAngle或者mAutoFlingAngle来记录,我们可以先用它对mAngleDelay(该值上面提及,表示每个Item之间相隔的角度)求余,这样得出的结果是任一item的偏移量。举个例子:item view有6个,那么每个Item相隔60°,我们转动了80°,那么我们可以这样分解:先转了60°,此时每个Item的位置一定是正确的,再转20°,那么此时item就会留在不正确的位置了,我们所要做的就是对这个“20°”进行处理,那么以30°为分割线,没到30°的,让itemview往回转到正确的位置;如果超过了30°的,让Itemview转动到下一个位置,那么我们的问题便得以解决了。
下面的方法是计算还需要多少角度才能转到正确的位置的:

/**
 * 获取位置矫正所需的角度
 * @param angle 对mAngleDelay求余后的角度
 * @return
 */
private float getCorrectAngle(float angle)
{
    if(angle > 0 && angle <= mAngleDelay/2)
    {
        mCorrectPositionFlag = true;
        return -angle;
    }else if(angle >mAngleDelay/2)
    {
        mCorrectPositionFlag = true;
        return (mAngleDelay -angle);
    }else if(angle < 0 && Math.abs(angle) <= mAngleDelay/2)
    {
        mCorrectPositionFlag = true;
        return -angle;
    }else if(angle < 0 && Math.abs(angle) > mAngleDelay/2){
        mCorrectPositionFlag = true;
        return -(mAngleDelay -Math.abs(angle));
    }
    return 0;
}

在获取到需要修正的角度后,我们可以直接通过Runnable来重新布局一下,把该值作为需要转动的角度即可。
如下所示:

private class AutoFlingRunnable implements Runnable
{
    //...
    public void run()
    {
        if(mCorrectPositionFlag)
        {
            float angle = angelPerSecond;
            mStartAngle += angle;
            requestLayout();
            mCorrectPositionFlag = false;
        }else {
            // 如果小于20,则停止,同时进行位置矫正
            if ((int) Math.abs(angelPerSecond) < 20) {
                isFling = false;
                mCorrectPositionFlag = true;
                this.angelPerSecond = getCorrectAngle(mAutoFlingAngle % mAngleDelay);
                postDelayed(this,30);
                return;
            }
            isFling = true;
            // 不断改变mStartAngle,让其滚动,/30为了避免滚动太快
            mStartAngle += (angelPerSecond / 30);
            mAutoFlingAngle += (angelPerSecond / 30);
            // 逐渐减小这个值
            angelPerSecond /= 1.0666F;
            postDelayed(this, 30);
            // 重新布局
            requestLayout();
        }
    }
}

Part 5 滑动结束的回调

在滑动结束并且位置修正完毕后,在中央会有一个item view,有时候我们需要对该Item view进行交互操作,比如上面的演示图内,每滑动完毕,便把中央的item view的图片显示到上面ImageView中,那么我们就需要在滑动完毕的时候,判断出居于中央的item View到底是哪一个。以下是实现思路:首先通过一个方法findChildViewUnder来获取某个坐标点上的itemView的实例,如下所示:

/**
* 获取某个坐标上的子View
* @param x
* @param y
* @return View
*/
private View findChildViewUnder(float x,float y)
{
    final int count = getChildCount();
    for(int i = count - 1; i >= 0; i--)
    {
        final View child = getChildAt(i);
        if(x >= child.getLeft() && x <= child.getRight() && y>= child.getTop() && y <= child.getBottom())
            return child;
    }
    return null;
}

实现原理很简单,就是遍历所有的item View,来判断给定的x、y坐标在哪个Item View之内,提取到item View的实例后,我们再拿出该itemView的Tag(因为加载itemView的时候,给每个View都打上了不同的Tag),有了Tag,就知道了是哪一个itemView滑动到了最中央的位置,最后再利用回调的实现方法,来实现交互式操作。
上面提到,在onLayout方法的最后有如下语句:

//布局结束的时候,如果不在滚动同时也不在被触摸的时候,触发滚动结束回调
 if(!isFling && !mTouchFlag ) { 
    mOnCentralItemCallback.centralItemOperate((Integer) findChildViewUnder(layoutRadius/2,tmp).getTag());
 }

其中,OnCentralItemCallback是一个接口,类似于监听器接口,centralItemOperate是一个回调方法。在Activity中调用该方法能实时获取到滑动结束后中央位置的itemView的类型。

GitHub地址:https://github.com/chenyua1995/SemicircleMenu
欢迎各位star和fork,谢谢阅读!

点赞