转载请注明出处http://blog.csdn.net/crazy__chen/article/details/46334843
源码下载地址http://download.csdn.net/detail/kangaroo835127729/8765757
这次解析的控件DrawerArrowDrawable是一款侧拉抽屉效果的控件,在很多应用上我们都可以看到(例如知乎),控件的github地址为https://github.com/ChrisRenke/DrawerArrowDrawable
大家可以先来看一下控件的效果
这个控件的作者,也写过一篇文章对控件的制作过程做了说明,其中更多的是涉及箭头的变换具体算法,我在本文中将简化对算法的说明(因为比较复杂,我会提供给大家算法的思路)。如果大家对原文感兴趣,可以参考这个地址http://chrisrenke.com/drawerarrowdrawable/
另外还有一篇中文翻译http://www.eoeandroid.com/thread-561707-1-1.html?_dsign=e25beff0
下面我来说一下这个控件的具体制作方法。
首先我们可以看到,有一个侧拉抽屉的效果,这个效果是用android.support.v4包提供的android.support.v4.widget.DrawerLayout来实现的,对于这个控件,大家导入对应包,就可以使用。例如
<!-- Content --> <android.support.v4.widget.DrawerLayout android:id="@+id/drawer_layout" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" > <TextView android:id="@+id/view_content" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:textColor="#000000" android:text="@string/content_hint" android:background="#ffffff" /> <TextView android:id="@+id/drawer_content" android:layout_width="240dp" android:layout_height="match_parent" android:layout_gravity="start" android:gravity="center" android:text="@string/drawer_hint" android:textColor="@color/light_gray" android:background="@color/darker_gray" /> </android.support.v4.widget.DrawerLayout>上面的xml,其实就是定义了一个侧拉抽屉,其中在.DrawerLayout中的第一个控件,会被当成是抽屉,而第二个控件,会被当成主要内容。
由此可见,侧拉的效果是很容易实现(使用google提供的包)。
然而对比我们的DrawerArrowDrawable,会发现DrawerArrowDrawable有一个非常炫的效果,就是标题栏上的箭头变化。在初始状态,箭头是三条横线,当侧拉时,三条横线逐渐聚合成箭头,当侧拉返回时,又由箭头分散为三条横线。
本质上,这个箭头的实现,就是整个DrawerArrowDrawable的难点,大家可能一下子没有太好的思路。
我们先来看一下箭头变化的过程图
对于整个箭头整体,本质上是一个drawable,也就是说我们自定义一个drawable(这种方法我们在本专栏的其他文章也见过),修改它的ondraw方法,来实现一些复制的动画效果。
对于DrawerArrowDrawable,我们先关注三条横线中的第一条,对于第一条横线,有首尾两个点(这个两个点决定了这条横线)。下面的说明都是针对第一条横线而言(其他横线的原理和第一条是一样的)
横线在初始状态,有首尾两个点,称为a,b。a,b在整个箭头变化过程中,所在位置不断变化,从而构成一条轨迹(a,b各自一条)
我们将这个箭头状态分成三部分,如下
对于1,2,3三个状态,我们只考察a点,对于a点而言,状态1,到状态2,可以形成一个轨迹,是一个贝塞尔曲线(什么是贝塞尔曲线,大家可以自行百度,简而言之就是由一系列控制点(至少一个),可以确定两点之间的一条平滑曲线)。
有人会问,凭什么确定这是一条贝塞尔曲线呢,其实我们没有办法确定,但是我们可以确定一条贝塞尔曲线,使之近似等于a点的运动过轨,也就是说我们是把a点的轨迹抽象成函数,然后通过这个函数,我们就可以确定轨迹上每一点的坐标了。注意,这里的因果关系要弄明白,是现有轨迹,后有曲线,这个控件的作者,也是根据实际的轨迹,推算出轨迹的函数表达式的。
Ok,那么我们也容易知道,状态2到状态3,a点的轨迹,是另外一条贝塞尔曲线
同理,b点整个过程的轨迹,也就是两条贝塞尔曲线,而两点确定一条直线,根据a,b两个的轨迹,我们就能确定横线的轨迹了。
其他横线同理。所以要实现箭头的变换效果,我们只要根据贝塞尔曲线,不断绘制这个三天横线就可以了。
那么,对应到具体的java代码,我们应该怎么实现呢?下面开始结合源码进行说明。
首先来看构造函数和初始化
public DrawerArrowDrawable(Resources resources, boolean rounded) { this.rounded = rounded; float density = resources.getDisplayMetrics().density; float strokeWidthPixel = STROKE_WIDTH_DP * density; halfStrokeWidthPixel = strokeWidthPixel / 2; linePaint = new Paint(Paint.SUBPIXEL_TEXT_FLAG | Paint.ANTI_ALIAS_FLAG); /* 当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的图形样式,如圆形样式 * Cap.ROUND,或方形样式Cap.SQUARE */ linePaint.setStrokeCap(rounded ? Cap.ROUND : Cap.BUTT); //画笔颜色 linePaint.setColor(Color.BLACK); //设置画笔的样式,为FILL,FILL_OR_STROKE,或STROKE,也就是画轮廓,而fill是填充 linePaint.setStyle(Paint.Style.STROKE); //设置空心的边框宽度 linePaint.setStrokeWidth(strokeWidthPixel); int dimen = (int) (DIMEN_DP * density); bounds = new Rect(0, 0, dimen, dimen); Path first, second; JoinedPath joinedA, joinedB; // Top 第一条横线 first = new Path(); first.moveTo(5.042f, 20f); //实现贝塞尔曲线,(x1,y1) 为控制点,(x2,y2)为控制点,(x3,y3) 为结束点 first.rCubicTo(8.125f, -16.317f, 39.753f, -27.851f, 55.49f, -2.765f); second = new Path(); second.moveTo(60.531f, 17.235f); second.rCubicTo(11.301f, 18.015f, -3.699f, 46.083f, -23.725f, 43.456f); scalePath(first, density); scalePath(second, density); joinedA = new JoinedPath(first, second); first = new Path(); first.moveTo(64.959f, 20f); first.rCubicTo(4.457f, 16.75f, 1.512f, 37.982f, -22.557f, 42.699f); second = new Path(); second.moveTo(42.402f, 62.699f); second.cubicTo(18.333f, 67.418f, 8.807f, 45.646f, 8.807f, 32.823f); scalePath(first, density); scalePath(second, density); joinedB = new JoinedPath(first, second); topLine = new BridgingLine(joinedA, joinedB); // Middle 第二条 first = new Path(); first.moveTo(5.042f, 35f); first.cubicTo(5.042f, 20.333f, 18.625f, 6.791f, 35f, 6.791f); second = new Path(); second.moveTo(35f, 6.791f); second.rCubicTo(16.083f, 0f, 26.853f, 16.702f, 26.853f, 28.209f); scalePath(first, density); scalePath(second, density); joinedA = new JoinedPath(first, second); first = new Path(); first.moveTo(64.959f, 35f); first.rCubicTo(0f, 10.926f, -8.709f, 26.416f, -29.958f, 26.416f); second = new Path(); second.moveTo(35f, 61.416f); second.rCubicTo(-7.5f, 0f, -23.946f, -8.211f, -23.946f, -26.416f); scalePath(first, density); scalePath(second, density); joinedB = new JoinedPath(first, second); middleLine = new BridgingLine(joinedA, joinedB); // Bottom 第三条 first = new Path(); first.moveTo(5.042f, 50f); first.cubicTo(2.5f, 43.312f, 0.013f, 26.546f, 9.475f, 17.346f); second = new Path(); second.moveTo(9.475f, 17.346f); second.rCubicTo(9.462f, -9.2f, 24.188f, -10.353f, 27.326f, -8.245f); scalePath(first, density); scalePath(second, density); joinedA = new JoinedPath(first, second); first = new Path(); first.moveTo(64.959f, 50f); first.rCubicTo(-7.021f, 10.08f, -20.584f, 19.699f, -37.361f, 12.74f); second = new Path(); second.moveTo(27.598f, 62.699f); second.rCubicTo(-15.723f, -6.521f, -18.8f, -23.543f, -18.8f, -25.642f); scalePath(first, density); scalePath(second, density); joinedB = new JoinedPath(first, second); bottomLine = new BridgingLine(joinedA, joinedB); }
注意到,还计算了当前屏幕的密度,这个密度非常重要。为什么呢?
根据上面的说法,作者是根据轨迹,计算出曲线的,但是这个曲线的具体方程,跟作者用来计算的屏幕大小是有关的。例如作者屏幕上,状态2,a点的坐标是(10,10),那么在你的屏幕上,假设你的屏幕密度是作者的两倍,那么a的坐标,可能是(20,20),那么计算出来的曲线方程就不一样了。
所以这里记录了你的屏幕密度,和作者的屏幕密度相比,然后放大相应的倍数就可以了。
从源码中我们可以看到这样两个属性
/** * Paths were generated at a 3px/dp density; this is the scale factor for different densities. * 路径是在3px/dp密度下生成的,这将是不同屏幕密度的缩放因子 */ private final static float PATH_GEN_DENSITY = 3; /** * Paths were generated with at this size for {@link DrawerArrowDrawable#PATH_GEN_DENSITY}. * 在PATH_GEN_DENSITY密度下,将生成这个尺寸的路径 */ private final static float DIMEN_DP = 23.5f;这两个属性,就是作者的屏幕密度,和其密度下的尺寸大小,我们按比例缩放这个两个数字就可以了,下面会看到。
OK,初始化以后,开始设定曲线,我拿第一条横线做例子
// Top 第一条横线 first = new Path(); first.moveTo(5.042f, 20f); //实现贝塞尔曲线,(x1,y1) 为控制点,(x2,y2)为控制点,(x3,y3) 为结束点 first.rCubicTo(8.125f, -16.317f, 39.753f, -27.851f, 55.49f, -2.765f); second = new Path(); second.moveTo(60.531f, 17.235f); second.rCubicTo(11.301f, 18.015f, -3.699f, 46.083f, -23.725f, 43.456f); scalePath(first, density); scalePath(second, density); joinedA = new JoinedPath(first, second); first = new Path(); first.moveTo(64.959f, 20f); first.rCubicTo(4.457f, 16.75f, 1.512f, 37.982f, -22.557f, 42.699f); second = new Path(); second.moveTo(42.402f, 62.699f); second.cubicTo(18.333f, 67.418f, 8.807f, 45.646f, 8.807f, 32.823f); scalePath(first, density); scalePath(second, density); joinedB = new JoinedPath(first, second); topLine = new BridgingLine(joinedA, joinedB);
然后是second,其实就是状态2,到状态3了
接着调用scalePath()方法,其实是就是根据屏幕比例缩放了,上面已经提到过
/** * Scales the paths to the given screen density. If the density matches the * {@link DrawerArrowDrawable#PATH_GEN_DENSITY}, no scaling needs to be done. * 根据屏幕密度扩大路径尺寸 */ private static void scalePath(Path path, float density) { if (density == PATH_GEN_DENSITY) return; Matrix scaleMatrix = new Matrix(); scaleMatrix.setScale(density / PATH_GEN_DENSITY, density / PATH_GEN_DENSITY, 0, 0); path.transform(scaleMatrix); }
也即是JoinedPath保留了a的整个运动轨迹
/** * Joins two {@link Path}s as if they were one where the first 50% of the path is {@code * PathFirst} and the second 50% of the path is {@code pathSecond}. * 合并两个路径,前50%为路径1,后50%为路径2 */ private static class JoinedPath { private final PathMeasure measureFirst; private final PathMeasure measureSecond; private final float lengthFirst; private final float lengthSecond; private JoinedPath(Path pathFirst, Path pathSecond) { //PathMeasure类用于提供路径上的点坐标 measureFirst = new PathMeasure(pathFirst, false); measureSecond = new PathMeasure(pathSecond, false); lengthFirst = measureFirst.getLength(); lengthSecond = measureSecond.getLength(); } /** * Returns a point on this curve at the given {@code parameter}. * For {@code parameter} values less than .5f, the first path will drive the point. * For {@code parameter} values greater than .5f, the second path will drive the point. * For {@code parameter} equal to .5f, the point will be the point where the two * internal paths connect. * 根据参数(比例)返回曲线上的点 * 如果参数parameter小于0.5,使用第一条路径计算,大于0.5,使用第二条路径计算 * 等于0.5,该点为两条路径的连接点 */ private void getPointOnLine(float parameter, float[] coords) { if (parameter <= .5f) { parameter *= 2; /* * Pins distance to 0 <= distance <= getLength(), * and then computes the corresponding position and tangent. * Returns false if there is no path, or a zero-length path was specified, * in which case position and tangent are unchanged. * 根据距离(该距离范围在0到路径长度之间),计算路径上相应点的坐标和tan三角函数值,分别存储在 * 后两个参数之中(后两个参数都是拥有两个元素的一维数组) */ measureFirst.getPosTan(lengthFirst * parameter, coords, null); } else { parameter -= .5f; parameter *= 2; measureSecond.getPosTan(lengthSecond * parameter, coords, null); } } }有上面代码可以看到,JoinedPath中有两个PathMeasure对象,PathMeasure是android提供的,用来获取路径上点的坐标的一个类
例如我们有path路径a,长度是10(路径可能是曲线),我们用这个path创建一个PathMeasure对象,调用PathMeasure的getPosTan()方法,传入一个比例p(0-1),就可以得到在路径上,走了10*p距离的点的坐标。
那么对于a点,也就是说我们现在可以获得其轨迹上任意一点的坐标。
同理,对于b点
我们再次创建了first,second,然后合并出JoinedPath。
对于a,b两点的JoinedPath,我们又利用一个类来封装它们BridgingLine
topLine = new BridgingLine(joinedA, joinedB);来看BridgingLine
/** * Draws a line between two {@link JoinedPath}s at distance {@code parameter} along each path. * 根据两条路径上的点画一条直线 */ private class BridgingLine { private final JoinedPath pathA; private final JoinedPath pathB; private BridgingLine(JoinedPath pathA, JoinedPath pathB) { this.pathA = pathA; this.pathB = pathB; } /** * Draw a line between the points defined on the paths backing {@code measureA} and * {@code measureB} at the current parameter * 根据当前参数,利用在两条路径上的两个点,画一条直线 */ private void draw(Canvas canvas) { pathA.getPointOnLine(parameter, coordsA); pathB.getPointOnLine(parameter, coordsB); if (rounded) insetPointsForRoundCaps(); canvas.drawLine(coordsA[0], coordsA[1], coordsB[0], coordsB[1], linePaint); } /** * Insets the end points of the current line to account for the protruding * ends drawn for {@link Cap#ROUND} style lines. * */ private void insetPointsForRoundCaps() { vX = coordsB[0] - coordsA[0]; vY = coordsB[1] - coordsA[1]; magnitude = (float) Math.sqrt((vX * vX + vY * vY)); paramA = (magnitude - halfStrokeWidthPixel) / magnitude; paramB = halfStrokeWidthPixel / magnitude; coordsA[0] = coordsB[0] - (vX * paramA); coordsA[1] = coordsB[1] - (vY * paramA); coordsB[0] = coordsB[0] - (vX * paramB); coordsB[1] = coordsB[1] - (vY * paramB); } }BridgingLine没有太复杂的东西,其实就是提供了draw方法,用于画出a,b两点连成的横线。
到此位置,我们就可以画出a,b两点的横线了,但是a,b两个的坐标变化,是取决于parameter这个参数的
pathA.getPointOnLine(parameter, coordsA); pathB.getPointOnLine(parameter, coordsB);那么这个参数又是什么决定的呢?
其实这个参数是我们主动传进去的,而这个参数大小,就等于侧拉抽屉的显示比例(当前显示面积,除以总面积)
这个是可想而知的,当这个侧拉抽屉被拉出来时,parameter应该等于1,表示去a,b点轨迹的最后一个点
而完全没有被拉出是,parameter应该等于0,表示去a,b点轨迹的第一个点
我们来看在外部怎么调用
final DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout); final ImageView imageView = (ImageView) findViewById(R.id.drawer_indicator); final Resources resources = getResources(); drawerArrowDrawable = new DrawerArrowDrawable(resources); drawerArrowDrawable.setStrokeColor(resources.getColor(R.color.light_gray)); imageView.setImageDrawable(drawerArrowDrawable); drawer.setDrawerListener(new DrawerLayout.SimpleDrawerListener() { @Override /* * Called when a drawer's position changes.//抽屉变化时调用 * drawerView The child view that was moved//被移动的子控件 * slideOffset The new offset of this drawer within its range, from 0-1//移动的比例 */ public void onDrawerSlide(View drawerView, float slideOffset) { offset = slideOffset; // Sometimes slideOffset ends up so close to but not quite 1 or 0. //有时候移动停止时,slideOffset接近0或1,设置翻转 if (slideOffset >= .995) { flipped = true; drawerArrowDrawable.setFlip(flipped); } else if (slideOffset <= .005) { flipped = false; drawerArrowDrawable.setFlip(flipped); } drawerArrowDrawable.setParameter(offset); } });从上面我们可以看到,我们为imageview设置了DrawerArrowDrawable对象,然后为DrawerLayout设置了一个监听器
对于这个监听器SimpleDrawerListener的onDrawerSlide()方法,当侧拉时,就会调用,传入slideOffset,也就是侧拉比例
可以知道slideOffset其实就是我们的parameter。
到此为止,这个箭头的效果就被我们实现了,接下只要在DrawerArrowDrawable的ondraw()方法里面,不断的绘制这三条曲线就好了
另外,这里做了一些近似处理,有时候移动停止时,slideOffset接近0或1,设置翻转
为什么要翻转呢,注意到,抽屉被拉出,和抽屉被缩入,箭头旋转的方向是不一样的,前者是0到180°,后者是180°到上360°
怎么实现呢,来看ondraw()方法就知道了
@Override public void draw(Canvas canvas) { if (flip) {//是否翻转画布 canvas.save(); canvas.scale(1f, -1f, getIntrinsicWidth() / 2, getIntrinsicHeight() / 2);//中心点不变,y坐标对称 } topLine.draw(canvas); middleLine.draw(canvas); bottomLine.draw(canvas); if (flip) canvas.restore(); }
对称以后,同样的效果0到180°,就会在画布上显示180°到上360°了,然后再讲画布恢复正常就可以了。
OK,DrawerArrowDrawable源码解析到这里就结束,看似简单的功能,却有复杂的逻辑。其实最复杂的逻辑在贝塞尔曲线的确定,本文提供了确定的思路,没有做具体的实现,大家可以参考控件原作者的文章。