android自定义View——实现Dribbble的[Open & Close]设计

1、提要

Open & Close 在Dribbble的Popular程度能排在所有Shots的首页。而且设计比较简洁,实现起来的难度也相对较小,可以拿来练练手。本文源码猛击:Roujiamo

2、分析

动画开始前是经典的hamburger,由上中下三条直线组成,以l1、l2、l3表示,动画结束后变成了关闭按钮。

关闭的“X“是由hamburger的l1、l3经过旋转变换而来。其中l1绕右端点逆时针旋转45°,l3绕右端点顺时针旋转45°。旋转后,l1左右端点的y坐标分别与l3左右端点的坐标相同,并且交点位于整个画面的中心。这说明不仅仅有旋转变换,还发生了x轴负方向的位移变换,否则交点肯定是偏右的。为了保证旋转后各点y坐标相同,对单条直线长度lineLength和l1、l3之间的距离height做一定限制。根据三角函数的相关知识可以很快算出,height = lineLength  * sin(45°)。同样根据三角函数得知,直线旋转后,其在x轴的映射为lineLength * cos(45°),也就是说向x轴的负方向平移了lineLength * (1- cos(45°))  / 2的距离。

然后再分析l2和外接圆的变化,l2向右平移并逐渐变换为接近外接圆弧的曲线,当l2开始变成圆弧时,圆弧的弧度开始逐渐变大同时逆时针旋转。这个直线逐渐变换为曲线的动画我还没想到具体要怎么去实现,可以参考一下这篇博客:Making a SVG HTML Burger Button。他是用svg来实现的,粗略判断其中曲线变换部分其实只是移动旋转一张图片,这张图片就是一条过度曲线(如有错误,欢迎指出)。本文不打算实现曲线变换这一部分,会以另一种方式来替代这段动画。首先让l2往x轴正方向平移,直到l2左端点到达外接圆,同时保持右端点不超过外接圆。当右端点到达外接圆时,外接圆变换开始,圆弧起点0°到360°,整个过程逆时针旋转135°。

最后,从gif图中可以看出,很多动画都不是线性变换的,那就要用到android中的插值器了。详细可参考Android中的Interpolator,对比文中给出的数学曲线可以很快找到我们需要的插值器。其中l1、l3可以用AnticipateOvershootInterpolator,l2使用AnticipateInterpolator,圆弧则用AccelerateDecelerateInterpolator。使用插值器就出现了下面这个问题,l2右端点具体什么时候到达圆弧,这个时间点比较不好算,需要解一元三次方程。本文给这个时间点设置为定值,虽然并不准确,但是误差很小,可以接受。

3、设计实现

根据上面的分析,对于要实现什么已经有了比较清晰的轮廓,但是除了“画”这个功能,我们还需要保存状态,当屏幕切换时,能够恢复到之前的状态。此外还要监听点击事件,触发动画。可以把“画”这个功能单独抽离出来,用Drawable来实现,其余的放在view里实现。这样做的好处是,最主要的功能可以不依赖于任何View,实际应用起来限制更少。在最后应用一节会认识到这样做的优势。

先从简单的部分开始入手,既然用了Drawable,View则选用ImageView,因为这个类可以通过setImageDrawable方法来设置Drawable,比较方便。监听状态很简单,略过。直接看状态保存,其中有两种状态:open和close,只要一个boolean的变量来保存。说到保存状态,自然而然的就可以想到Activity的onSaveInstanceState和onRestoreInstanceState。这两个方法在View里面也有。这样我们只需要分别在这两个方法中保存和恢复状态即可。可以定义一个类来充当数据model的角色,当然你也可以不这么做,直接往Bundle里writeInt。下面来看代码吧:

    @Override
    protected Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();

        SavedState ss = new SavedState(superState);

        //获取当前的状态
        ss.open = drawable.isOpen();

        return ss;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        if(!(state instanceof SavedState)) {
            super.onRestoreInstanceState(state);
            return;
        }

        final SavedState ss = (SavedState)state;
        super.onRestoreInstanceState(ss.getSuperState());

        post(new Runnable() {

            @Override
            public void run() {
                //设置当前状态并重绘,三个参数分别表示:当前状态,是否需要动画,是否重绘
                setOpen(ss.open, false, true);
            }
        });
    }

    private static class SavedState extends BaseSavedState {
        boolean open;

        SavedState(Parcelable superState) {
            super(superState);
        }

        private SavedState(Parcel in) {
            super(in);
            this.open = in.readInt() == 1;
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeInt(this.open ? 1 : 0);
        }

        public static final Parcelable.Creator<SavedState> CREATOR =
                new Parcelable.Creator<SavedState>() {
                    public SavedState createFromParcel(Parcel in) {
                        return new SavedState(in);
                    }

                    public SavedState[] newArray(int size) {
                        return new SavedState[size];
                    }
                };
    }
代码都很简单,没有太多注释,其中两个方法已经给出了说明,具体实现暂时不用管,知道它的功能就行了。需要提醒的是,android HONEYCOMB及之后的版本都有硬件加速的功能,最好在View里面禁用,否则在动画播放的时候旋转屏幕的话会出现bug。可以通过以下代码来禁用硬件减速:
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) {
        setLayerType(View.LAYER_TYPE_SOFTWARE, null);
    }

最后来看重头戏吧,本文只介绍主体的流程,细节上的分析读者可自行阅读源码。上面已经分析过了,无非就是画三条直线一条曲线嘛。由于l1、l3都是绕自己右端点来旋转的,除去为了保证居中而做的x轴负方向的位移外,我们可以只看做只有左端点在绕着右端点来旋转。这样就把线段的旋转转换成了点的旋转。那好,假设旋转之后的点的坐标以及x轴负方向的位移我们都知道了,那么要画出图形来就很简单了。

    @Override
    public void draw(Canvas canvas) {
        // translate and rotate  topStartRotated l1左端点旋转后的坐标,  topEnd l1右端点,  translateX x轴负方向的位移
        canvas.drawLine(topStartRotated.x - translateX, topStartRotated.y, topEnd.x - translateX, topEnd.y, paint);
        // just translate  middleTranslateStart l2位移后的左端点, middleTranslateEnd l2位移后的右端点
        canvas.drawLine(middleTranslateStart.x, middleTranslateStart.y, middleTranslateEnd.x, middleTranslateEnd.y, paint);
        // arc 外接圆的轮廓, arcStartAngle 圆弧的起始角度, arcSweepAngle 圆弧的角度
        canvas.drawArc(arc, arcStartAngle, arcSweepAngle, false, paint);
        // translate and rotate  bottomStartRotated l3左端点旋转后的坐标,  bottomEnd l3右端点
        canvas.drawLine(bottomStartRotated.x - translateX, bottomStartRotated.y, bottomEnd.x - translateX, bottomEnd.y, paint);
    }
刚才提到点的旋转,那么我们怎么计算某个点绕另一个点旋转后的坐标呢?这里给一个公式:
        // rotate
        // (x0,y0) is after (x,y) rotating around (rx0, ry0)
        // x0= (x - rx0)*cos(a) - (y - ry0)*sin(a) + rx0 ;
        // y0= (x - rx0)*sin(a) + (y - ry0)*cos(a) + ry0 ;
(x0,y0)即是点(x,y)绕点(rx0,ry0)旋转a度后的坐标。起始点(x,y)的坐标可以放到onBoundsChange方法中计算,只需保证整体居中,且l1、l3之间的高度height = lineLength  * sin(45°)。

关键点也介绍了,下面要说动画的流程了。说白了,动画就是通过不断的重绘来实现的。本文另起一个线程来做这部分工作。不断计算动画进行了多长时间来更新动画的进度,同时更新4条线的位置,最后通知View重绘。为了节省资源,大约20ms重绘一次。动画开始时,记录下当前时间作为动画的开始时间,每次循环都取系统当前时间减去动画开始时间,这就是动画的进度。当动画close时,其实就是open的倒带。只需简单的将动画开始时间减去当前时间并加上动画的时长。通知View重绘可以通过invalidateSelf方法,但是直接在非UI线程了调用这个方法是不行的,用scheduleSelf可以解决这个问题。

    private Runnable mInvalidateTask = new Runnable() {
        @Override
        public void run() {
            invalidateSelf();
        }
    };

    private void toggleAnim(){
        //动画进行进度比例,open为动画结束后,状态是否为open
        float percent = open ? 0 : 1;
        //动画进度
        int timeLapse;
        //当前时间
        long cur;
        float tmp;
        //动画开始时间
        long animStartTime = SystemClock.uptimeMillis();
        while(percent <= 1 && percent >= 0) {
            cur = SystemClock.uptimeMillis();
            if (open) {
                timeLapse = (int) (cur - animStartTime);
            } else {
                timeLapse = (int) (BurgerDrawable.DURATION + animStartTime - cur);
            }
            percent = (float) timeLapse / BurgerDrawable.DURATION;
            tmp = Math.min(1, percent);
            tmp = Math.max(0, tmp);
            // 更新四条曲线的动画进度,即更新其位置,详见源码
            setPercentage(tmp, false);
            scheduleSelf(mInvalidateTask, cur);
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        animating = false;
    }
注意到我们取系统时间用的是SystemClock.uptimeMillis()方法,而不是System.currentTimeMIllis()。前者取的是开机到现在为止的时间,而后者取的是系统设置的当前时间,后者有可能会被修改,而前者是不能被修改的。

4、应用

Burger按钮当然是要结合ActionBar来一起用啦,点击Burger时,左侧菜单弹出或收起。本文的左侧菜单用的是Android自带的DrawerLayout。新建一个Navigation Drawer Activity,Android Studio的步骤是右击源码目录--> new--> Activity--> Navigation Drawer Activity。该操作会自动生成Activity、Fragment等文件。直接打开NavigationDrawerFragment.java,注意到其中有一个类ActionBarDrawerToggle,它是用来控制HomeAsUp图标动画的,所以我们要做的工作跟它是一样的。这个类在support-v4包中,直接拷过来,发现缺少两个引用的类,ActionBarDrawerToggleHoneycomb和ActionBarDrawerToggleJellybeanMR2,一并拷出来。这两个类其实是为了兼容不同版本的setHomeAsUpIndicator方法,来将HomeAsUp图标设置为我们的Drawable。最后,把它的mSlider改成我们实现的Drawable,调用到的mSlider的方法也修改成我们的,比如设置状态。整个ActionBarDrawerToggle类代码较多,但是我们改动的地方很少,这里就不贴了,详见源码。

最后,我们实现的效果如下:

技术分享

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。