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类代码较多,但是我们改动的地方很少,这里就不贴了,详见源码。
最后,我们实现的效果如下:
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。