前言

Emmmm,看标题大概就能猜到,这次我们要做的是一个射箭的效果。
在这篇文章中,同学们可以学到:

  • 在自定义Drawable里流畅地draw各种动画;
  • 画一条粗细不一的线段;
  • 一个炫酷的射箭效果;

来看两张效果图:

嗯,就是这样了,可以看到,我们等下要做的这个效果,还能用来当下拉刷新的动画,很炫酷。

这里有同学可能会说:“这些动画,叫UI用AE画一个,然后用Lottie加载不就行了?”
可以是可以,但是, 让UI画出来,那知识是人家UI的,到头来自己还是不会。 那下次遇到类似效果的时候,还是要去麻烦UI。
还有就是,Lottie动画只能控制播放进度,而不能动态更改里面的属性,比如我要把弓箭的颜色变成黑色,箭头放大2倍,这样的话,对于Lottie来说,就有点无能为力了。
当然了,Lottie还是有好多其他方面优势的,但本篇文章主题不是Lottie,所以就不多提了。

好,下面开始分析怎么用自定义Drawable来实现这个效果。

初步分析

先来看看茄子同学画的这张图:

像这种类型的,我们可以把它各个组成部分都拆出来:

  • 首先是弓,那弓要怎么画出来呢?其实就是一段二阶的贝塞尔曲线;
  • 接着看弓的中间部分,有一小段明显比较粗的线,像个握柄,那这个握柄可以截取弓线条的一部分,然后把画笔宽度加大,再draw出来;
  • 第三部分,弦,这个很简单,确定坐标后画线就行了;
  • 最后一个,箭,这个依然可以用Path来画;

那根据上面所拆分的部分,就有了以下几个属性:

  1. 弓的Path
  2. 握柄的Path
  3. 弦的起始点(Point)、中点(Point)、结束点(Point);
  4. 箭的Path

为什么弦要有三个点呢?不是只要一个起始点和一个结束点就行了?
因为要照顾后面的拉弓效果,那时候的弦会被箭羽分成两条的。

好,静止状态下画法是有了,接下来想想动态的要怎么做。
从上面的效果图可以看出,当拉弓的时候:

  • ,弯曲角度会渐渐增大;
  • 握柄,随着的变化而变化;
  • ,中点始终在箭羽的底部;
  • ,垂直降落;

那要怎么实现弓逐渐弯曲的效果呢?
上面说到,这个弓可以用二阶贝塞尔来画(Path的quadTo方法),这个方法接收4个参数,也就是控制点结束点各自的坐标(x,y)了,起始点就是Path上一次的落点(可以调用moveTo来调整)。
那么我们就可以通过改变这个起始点结束点的位置,来实现弓的弯曲效果。
怎么个改变法?
要是直接把坐标点垂直往下移动,那效果就不好了,因为弓会被越拉越长。
就像这样:

这样看上去就很不自然。
正确的方法应该是:
让这个坐标点,绕弓的中点旋转,半径就是弓长的一半,然后计算旋转后的值。
看图:

emmmm,其实也就是根据旋转角度求点在圆上的坐标了,其公式是:

x = 半径 * cos(弧度)
y = 半径 * sin(弧度)

当然了,最后还要加上圆心的坐标值的。
计算出坐标后,重新用Path的quadTo方法把它们连起来就行了。

的Path更新了之后,握柄自然也就好了(截取中间的一小段)。
的话,在下降的时候只需要改变中点的y轴坐标值。
一样很简单,甚至不用重新初始化箭的Path,只需要调用Path的offset方法来垂直偏移就行了。

那最后的发射动画,应该怎么做呢?
仔细观察刚开始的效果图,当拉满弓,把箭发射出去的时候会看到:

  • 先向下移动,直至超出可见范围,并且在移动过程中弯曲角度会慢慢变小;
  • 离弦之后,开始缩短(改变箭长后重画),并且箭的小尾巴慢慢出现;
  • 缩短到一定程度之后,开始上下移动;
  • 上下移动时,会出现一条条的竖线快速地往下掉;

不要被这么多步骤吓到了,其实每个步骤都很简单的。
比如弓向下移动的,我们只需要记录三样东西:

  1. 开始时间;
  2. 动画的时长;
  3. 要移动的总距离;

当每次更新帧(draw)的时候,先计算出已播放时长(当前时间-开始时间),然后用这个已播放时长/动画总时长得出动画当前播放进度,最后用动画当前播放进度*总距离得出偏移量(当前要偏移的距离)
那接下来,就可以把这个偏移量,应用到的Path上了(调用offset方法)。

其他的也是一样原理,只是操作的变量不同,比如的缩短动画: 用播放进度*要缩短的总长度得出当前要缩短的长度,然后用初始长度-当前要缩短的长度得出新的长度,再基于这个新的长度重新画就行了。

好啦,分析的差不多了,准备写代码咯。

自定义线条

细心的同学会发现,效果图的中,它的两端是比较细的,越接近中间就越粗,但这种效果在SDK中并没有提供直接的API。
那应该怎么做呢?
我们知道,在屏幕上看到的图像,是由一粒粒像素点组成的,那么,可不可以把一条线(Line)分解成一粒粒点(Point),然后改变每一个点的Width,再draw出来?
答案是肯定的。

分解线条,要怎么分解?
当然是借助强大的PathMeasure来分解了:
根据Path创建PathMeasure实例后,可以调用其getPosTan方法获得每一个点的坐标值,这些坐标值正是我们想要的东西。
看代码怎么写:

    /*** 分解Path* @return Path上的全部坐标点*/private float[] decomposePath(PathMeasure pathMeasure) {if (pathMeasure.getLength() == 0) {return new float[0];}final float pathLength = pathMeasure.getLength();final int precision = 1;int numPoints = (int) (pathLength / precision) + 1;float[] points = new float[numPoints * 2];final float[] position = new float[2];int index = 0;float distance;for (int i = 0; i < numPoints; ++i) {distance = (i * pathLength) / (numPoints - 1);pathMeasure.getPosTan(distance, position, null);points[index] = position[0];points[index + 1] = position[1];index += 2;}return points;}

没错了就是这样,当方法结束后会返回Path上的全部坐标点。

那怎么计算出来每个点的缩放比例?
至于计算平滑缩放比例,我们可以按照飞龙在天的思路:

来封装一个ScaleHelper类。
首先是构造方法:

    private float[] mScales;ScaleHelper(float... scales) {updateScales(scales);}/*** 更新平滑缩放比例,数组长度必须是偶数* 偶数索引表示要缩放的比例,奇数索引表示位置 (0~1)* 奇数索引必须要递增,即越往后的数值应越大* 例如:* [0.8, 0.5] 表示在50%处缩放到原来的80%* [0, 0, 1, 0.5, 0, 1]表示在起点处的比例是原来的0%,在50%处会恢复原样,到终点处会缩小到0%* * @param scales 每个位置上的缩放比例*/void updateScales(float... scales) {//如果没有指定缩放比例,默认不缩放if (scales.length == 0) {scales = new float[]{1, 0, 1, 1};}//检查是否存在负数for (float tmp : scales) {if (tmp < 0) {throw new IllegalArgumentException("Array value can not be negative!");}}if (!Arrays.equals(mScales, scales)) {//长度一定要为偶数if (scales.length < 2 || scales.length % 2 != 0) {throw new IllegalArgumentException("Array length no match!");}//最后赋值mScales = scales;}
}

有了缩放比例之后,接下来看看怎么计算任意位置上的缩放比例:

    /*** 获取指定位置的缩放比例* @param fraction 当前位置(0~1)*/float getScale(float fraction) {float minScale = 1;float maxScale = 1;float scalePosition;float minFraction = 0, maxFraction = 1;//顺序遍历,找到小于fraction的,最贴近的scalefor (int i = 1; i < mScales.length; i += 2) {scalePosition = mScales[i];if (scalePosition <= fraction) {minScale = mScales[i - 1];minFraction = mScales[i];} else {break;}}//倒序遍历,找到大于fraction的,最贴近的scalefor (int i = mScales.length - 1; i >= 1; i -= 2) {scalePosition = mScales[i];if (scalePosition >= fraction) {maxScale = mScales[i - 1];maxFraction = mScales[i];} else {break;}}//计算当前点fraction,在起始点minFraction与结束点maxFraction中的百分比fraction = solveTwoPointForm(minFraction, maxFraction, fraction);//最大缩放 - 最小缩放 = 要缩放的范围 float distance = maxScale - minScale;//缩放范围 * 当前位置 = 当前缩放比例float scale = distance * fraction;//加上基本的缩放比例float result = minScale + scale;//如果得出的数值不合法,则直接返回基本缩放比例return isFinite(result) ? result : minScale;}/*** 将基于总长度的百分比转换成基于某个片段的百分比 (解两点式直线方程)** @param startX   片段起始百分比* @param endX     片段结束百分比* @param currentX 总长度百分比* @return 该片段的百分比*/private float solveTwoPointForm(float startX, float endX, float currentX) {return (currentX - startX) / (endX - startX);}/*** 判断数值是否合法** @param value 要判断的数值* @return 合法为true,反之*/private boolean isFinite(float value) {return !Float.isNaN(value) && !Float.isInfinite(value);}

其实很简单:先找出输入位置到底在哪两个给定的位置之间,然后套个公式就行了。

好,现在来写代码测试下:

    //画一条斜线Path path = new Path();path.moveTo(0, 0);path.lineTo(900, 1000);//分解Pathfloat[] points = decomposePath(new PathMeasure(path, false));ScaleHelper scaleHelper = new ScaleHelper(.5F, 0F, /*在起始位置的缩放比例是50%*/.1F, .3F, /*在30%处的缩放比例是10%*/1.2F, .8F, /*在80%处的缩放比例是120%*/1F, 1F /*在终点位置的缩放比例是100%*/);final int length = points.length;//原始线宽为50final int baseLineWidth = 50;float fraction;float radius;//遍历分解后的点,然后画圆点for (int i = 0; i < length; i += 2) {fraction = ((float) i) / length;radius = baseLineWidth * scaleHelper.getScale(fraction) / 2;canvas.drawCircle(points[i], points[i + 1], radius, mPaint);}

我们在线条的起点处(0%),缩放了50%,在30%处缩放到了10%,而在80%处则放大到原始的120%,最后在终点处恢复正常大小
看看效果:

emmmm,效果还不错。

好了,现在基本的东西都已准备好,可以正式开始啦

创建Drawable

在日常开发中,自定义Drawable虽然没有自定义View和ViewGroup出现的频率高,但是它有View和ViewGroup都没有的优点,比如:

  • 它比View更轻量,可以嵌入到任何一个View上面,甚至还可以在SurfaceView里面直接draw
  • 更专注于draw,因为它没有像View或ViewGroup那样需要measurelayout

当然了,既然变得更轻量了,也代表着某些能力没有了,比如说处理触摸事件——在Drawable中可是不能像View那样可以直接接收到MotionEvent的。
如果同学们要做的效果不需要依赖触摸事件,只需要draw的话,可以优先考虑自定义Drawable,而不是View。
比如我们这次要做的效果,就选择了Drawable,名字呢,就叫做ArrowDrawable吧。
来看看初始的代码:

为了让更多还没开始学习Kotlin的同学能感受到Kotlin的魅力,所以这次的Demo代码也是使用Kotlin来写 (java版本的在文章最后会给出地址)

class ArrowDrawable private constructor(private var mWidth: Int, //Drawable的宽private var mHeight: Int //Drawable的高
) : Drawable() {private var mPaint = Paint()init {initPaint()}private fun initPaint() {mPaint.isAntiAlias = truemPaint.strokeCap = Paint.Cap.ROUNDmPaint.strokeJoin = Paint.Join.ROUND}override fun draw(canvas: Canvas) {}override fun getIntrinsicWidth() = mWidthoverride fun getIntrinsicHeight() = mHeightoverride fun getOpacity() = PixelFormat.TRANSLUCENToverride fun setAlpha(alpha: Int) {mPaint.alpha = alpha}override fun setColorFilter(colorFilter: ColorFilter?) {mPaint.colorFilter = colorFilter}
}

可以看到,现在只重写了几个基本的方法:

  • getIntrinsicWidthgetIntrinsicHeight这两个方法用来告诉外面,它内容的宽和高;
  • getOpacitysetAlphasetColorFilter,这三个是Drawable的抽象方法,大多数情况下像上面那样做就行了;
  • draw,最重要就是这个了,我们等下都在draw方法里画东西;

画弓

好,现在先来画弓。
上面说到:弓的结束点可以根据弯曲的角度,计算出绕中点旋转后的坐标,旋转半径就是弓长的一半。
那这个弓长,可以让外部来提供,这样更灵活。
来看看计算坐标的代码:

    private val mTempPoint = PointF()/*** 根据弓当前弯曲的角度计算新的端点坐标** @param angle 弓当前弯曲的角度* @return 新的端点坐标*/private fun getPointByAngle(angle: Float): PointF {//先把角度转成弧度val radian = angle * Math.PI / 180//半径 取 弓长的一半val radius = mBowLength / 2//x轴坐标值val x = (mCenterX + radius * cos(radian)).toFloat()//y轴坐标值val y = (radius * sin(radian)).toFloat()mTempPoint.set(x, y)return mTempPoint}

mCenterX就是宽度的一半,也就是弓的水平位置了。
细心的同学会发现,x坐标有加上mCenterX, 而y坐标却没有加上mCenterY,这是为什么呢?
因为考虑到等下的弓要从上往下移动的,所以如果一开始就加上了mCenterY的话,那弓就会直接出现在中心的位置上了。

好,有了结束点坐标值之后呢,就可以确定起点的坐标了,那弓的Path也能成形了,我们来定义一个updateBowPath方法,用来更新弓所对应的Path:

    /*** 初始化弓* @param currentAngle 弓弯曲的角度*/private fun updateBowPath(currentAngle: Float) {val stringPoint = getPointByAngle(currentAngle)//起始点的x坐标,直接镜像 结束点的x轴坐标val startX = mCenterX * 2 - stringPoint.x//起始点的y坐标,也就是结束点的y坐标了val startY = stringPoint.y//控制点x坐标,直接取宽度的一半,也就是中点了val controlX = mCenterX//控制点的y坐标,刚好跟两端的y坐标相反,这样的话,线条的中点位置就能保持不变val controlY = -stringPoint.y//结束点坐标,直接赋值,因为getPointByAngle计算的就是结束点坐标val endX = stringPoint.xval endY = stringPoint.ymBowPath.reset()//根据三点坐标画一条二届贝塞尔曲线mBowPath.moveTo(startX, startY)mBowPath.quadTo(controlX, controlY, endX, endY)}

mBowPath就是弓所对应的Path对象了。
updateBowPath调用了之后,就可以把它画出来啦,我们在draw方法中加上画弓的代码,看看效果:

    override fun draw(canvas: Canvas) {updateBowPath(30F)//因为画的是线条,所以要用STROKEmPaint.style = Paint.Style.STROKEcanvas.drawPath(mBowPath, mPaint)}

弯曲的角度我们传的是30度。
看看效果怎么样:

对哦,中间大,两端小的效果还没加上去呢,马上来封装一个drawBowPath方法:
我们一开始封装的那个ScaleHelper要派上用场了,先初始化:

    mScaleHelper = ScaleHelper(.2F, 0F,//起点处缩至20%1F, .05F,//5%处恢复正常2F, .5F,//50%处放大到200%1F, .95F,//95%处又恢复正常.2F, 1F//最后缩放到20%)

接着到drawBowPath方法:

    /*** 画弓*/private fun drawBowPath(canvas: Canvas) {mBowPathMeasure = PathMeasure(mBowPath, false)//分解弓PathmBowPathPoints = decomposePath(mBowPathMeasure)val length = mBowPathPoints.sizevar fraction: Floatvar radius: Floatvar i = 0//把每一个坐标点都画出来while (i < length) {fraction = i.toFloat() / lengthradius = mBowWidth * mScaleHelper.getScale(fraction) / 2canvas.drawCircle(mBowPathPoints[i], mBowPathPoints[i + 1], radius, mPaint)i += 2}}

mBowPathPoints用来装分解之后的点坐标,decomposePath方法就是刚刚封装的分解Path的方法,mBowWidth是弓的宽度。
这次看看效果怎么样:

emmmm,还差点什么?
没错,就是握柄了,上面分析过,握柄可以直接截取弓中间的一段然后加粗线条就行了,来看看代码怎么写:

    /*** 初始化握柄*/private fun updateHandlePath() {val bowPathLength = mBowPathMeasure.length//握柄长度取弓长度的1/5val handlePathLength = bowPathLength / 5//弓的中点val center = bowPathLength / 2//中点减去握柄长度的一半,得出起点位置val start = center - handlePathLength / 2mHandlePath.reset()//从弓的中间截取弓长的1/5作为握柄的PathmBowPathMeasure.getSegment(start, start + handlePathLength, mHandlePath, true)}

mBowPathMeasure就是刚刚初始化弓的时候创建的PathMeasure对象,mHandlePath就是握柄所对应的Path了。
Path初始化完成之后,接着到draw:

    /*** 画手柄*/private fun drawHandlePath(canvas: Canvas) {canvas.drawPath(mHandlePath, mPaint)}

好,draw方法里也加上drawHandlePath,看看现在的draw方法(为了更方便理解,一些线宽,线长都是先写死):

    override fun draw(canvas: Canvas) {//线宽为10mPaint.strokeWidth = 10F//黄色mPaint.color = Color.YELLOW//因为画的是实心圆,所以要用FILLmPaint.style = Paint.Style.FILL//初始化弓updateBowPath(30F)//画弓drawBowPath(canvas)//因为画的是线条,所以要用STROKEmPaint.style = Paint.Style.STROKE//线宽增大到原来的3倍,因为ScaleHelper最大是2倍mPaint.strokeWidth = mPaint.strokeWidth * 3F//初始化握柄updateHandlePath()//画握柄drawHandlePath(canvas)}

来看看效果:

可以啦。

画弦

相信同学们都注意到了,弦的两端点,它的位置都不是在弓的端点上,只是接近弓的端点。
要拿到那两个点的位置很简单,因为我们刚刚在画弓的Path时就已经留了一手:我们把弓分解之后的坐标点都保留着,所以等下可以直接通过索引来取了。
比如现在要拿弓Path上5%95%位置上的坐标:

    private fun updateStringPoints() {val length = mBowPathPoints.size//起始点索引var stringStartIndex = (length * .05F).toInt()//必须是偶数,如果不是,强行调整if (stringStartIndex % 2 != 0) {stringStartIndex--}//结束点索引var stringEndIndex = (length * .95F).toInt()//必须是偶数,如果不是,强行调整if (stringEndIndex % 2 != 0) {stringEndIndex--}//起始点坐标mStringStartPoint.x = mBowPathPoints[stringStartIndex]mStringStartPoint.y = mBowPathPoints[stringStartIndex + 1]//结束点坐标mStringEndPoint.x = mBowPathPoints[stringEndIndex]mStringEndPoint.y = mBowPathPoints[stringEndIndex + 1]//中间点坐标//x轴固定在中间mStringMiddlePoint.x = mCenterX//y轴呢,先跟起始点的y轴一样mStringMiddlePoint.y = mStringStartPoint.y}

mBowPathPoints,这个装点坐标的数组,里面都是[x,y]成对地存放的,所以拿x坐标的时候必须是偶数,y坐标则必须是奇数索引,不然的话就乱套了。
mStringStartPoint呢是弦的起始点坐标,它是PointF的实例,当然了,还有剩下的两个:mStringEndPoint弦的另一个端点(结束点)、mStringMiddlePoint(弦的中间点)。

接着到画弦了,我们在上面讲到过,要分成两条线来画:起点到中点,中点和结束点:

    /*** 画弦*/private fun drawString(canvas: Canvas) {//起点到中间点的线canvas.drawLine(mStringStartPoint.x, mStringStartPoint.y,mStringMiddlePoint.x, mStringMiddlePoint.y, mPaint)//中间点到结束点的线canvas.drawLine(mStringEndPoint.x, mStringEndPoint.y,mStringMiddlePoint.x, mStringMiddlePoint.y, mPaint)}

好,来看看效果:

太棒啦~

画箭

上面说过可以用Path来画,但是在画之前,必须要先确定好每一段线条的尺寸,比如箭羽高度啊,箭杆长度这些。
来看下茄子同学的这张图:

看那一段段绿色的线,可以看出,一共要定义7个尺寸,从上到下分别是:

  1. 箭嘴高度;
  2. 箭嘴宽度;
  3. 箭杆长度;
  4. 箭羽倾斜高度;
  5. 箭羽高度;
  6. 箭羽宽度;
  7. 箭杆宽度;

那么问题来了:
如果我要把弓长增加1倍,其他的尺寸肯定也要跟着加吧,那就是要设置7次尺寸咯?
这样的体验肯定是很差的,所以我们要把其他的尺寸,都依赖于弓长,那么,当弓长改动了之后,其他尺寸也跟着变了:

    //箭杆长度 取 弓长的一半mArrowBodyLength = mBowLength / 2//箭杆宽度 取 箭杆长度的 1/70mArrowBodyWidth = mArrowBodyLength / 70//箭羽高度 取 箭杆长度的 1/6mFinHeight = mArrowBodyLength / 6//箭羽宽度 取 箭羽高度 1/3mFinWidth = mFinHeight / 3//箭羽倾斜高度 = 箭羽宽度mFinSlopeHeight = mFinWidth//箭嘴宽度 = 箭羽宽度mArrowWidth = mFinWidth//箭嘴高度 取 箭杆长度的 1/8mArrowHeight = mArrowBodyLength / 8

有了尺寸之后,只需要把他们连起来就行了:

    /*** 初始化箭* @param arrowBodyLength 箭杆长度*/private fun initArrowPath(arrowBodyLength: Float) {mArrowPath.reset()//一开始定位到箭杆的底部偏向右边的位置mArrowPath.moveTo(mCenterX + mArrowBodyWidth, -mFinSlopeHeight)//向右下 画箭羽底部的斜线mArrowPath.rLineTo(mFinWidth, mFinSlopeHeight)//向上 画箭羽的竖线mArrowPath.rLineTo(0F, -mFinHeight)//向左上 画箭羽的顶部斜线mArrowPath.rLineTo(-mFinWidth, -mFinSlopeHeight)//向上 画箭杆mArrowPath.rLineTo(0F, -arrowBodyLength)//向右 画箭嘴 右边底部 的横线mArrowPath.rLineTo(mArrowWidth, 0F)//向左上 画箭嘴 右边 的斜线mArrowPath.rLineTo(-mArrowWidth - mArrowBodyWidth, -mArrowHeight)//向左下 画箭嘴 左边 的斜线mArrowPath.rLineTo(-mArrowWidth - mArrowBodyWidth, mArrowHeight)//向右 画箭嘴 左边底部 的横线mArrowPath.rLineTo(mArrowWidth, 0F)//向下 画箭杆mArrowPath.rLineTo(0F, arrowBodyLength)//向左下 画箭羽的顶部斜线mArrowPath.rLineTo(-mFinWidth, mFinSlopeHeight)//向下 画箭羽的竖线mArrowPath.rLineTo(0F, mFinHeight)//向右上 画箭羽底部的斜线mArrowPath.rLineTo(mFinWidth, -mFinSlopeHeight)//结束mArrowPath.close()}

mArrowPath就是箭所对应的Path了,之所以把箭杆长度放到参数里,是为了等下可以灵活地改变箭的长度。
有同学会说:这样一看,好抽象的样子,只看文字注释根本就想象不出来是怎么画的嘛。
没关系,动图早就准备好了,看几次图片的绘制顺序,再结合上面的代码和注释,就非常容易理解了:

细心的同学又发现问题了:为什么是从底部开始向上画,而不是从顶部开始向下画呢?
因为要照顾后面的动态效果咯,那时候箭是从Drawable的顶部慢慢向下移动的,所以就干脆把它画在Drawable可见范围的外面。

好啦,看看现在的样子(现在在布局中设置了clipChildrenfalse,所以能看到可见范围外的东西):

    override fun draw(canvas: Canvas) {............//箭是实心的mPaint.style = Paint.Style.FILLdrawArrow(canvas)}/*** 画箭*/private fun drawArrow(canvas: Canvas) {canvas.drawPath(mArrowPath, mPaint)}

箭的初始化方法,可以在箭的各个尺寸都确定好了之后调用,因为它不用每次都重新画,是可以重用的。
看看:

不错不错,就是这样了。不过现在的弓一开始还是在边界范围内,这是不对的,等下还要把弓给弄到上面去。

拉弓

静态的处理完之后,轮到动态的了。
想一想,在拉弓的时候,肯定不能无限往后拉的,弓有个最大的弯曲角度,而且还要记录一个progress,表示拉弓的进度,最小是0,最大是1。
那当progress变动的时候要怎么做呢?
我们的Drawable一开始是空白的,progress逐渐增大时,首先是从顶部慢慢向下移动,到了指定的最大距离之后停止,接着到向下移动,当箭羽y坐标比y坐标还要大时,证明已经开始拉弓了,那弦中点y坐标就要跟着箭羽的一起增大了,还有,这时弓的弯曲角度也要跟着增大,这样的拉弓效果,就出来了。

怎么把弓弄到顶部上面去呢?
可以调用所对应的Path的offset方法来进行偏移,偏移量就是负的端点的y坐标值,偏移之后,的两端点y坐标就刚好等于0。

如果弓和箭一开始都是不可见,那怎么分配滑动进度?
我们打算用0%~25% 来偏移25%~50% 用来偏移箭,50%~100% 用来拉弓,也就是箭和弦一起向下继续偏移。

好,来看看代码要怎么写:
首先是setProgress方法:

    fun setProgress(progress: Float) {mProgress = when {progress > 1 -> 1F //最大是1progress < 0 -> 0F //最小是0else -> progress}//请求容器重绘invalidateSelf()}

可以看到在progress变更时还请求重绘了,那就代表着每一次进度的更新,draw方法都会被回调。

接着看看弓要怎么偏移(因为弓的Path是在updateBowPath方法里面初始化的,所以现在可以直接在这个方法里面加上偏移的代码了):

    private fun updateBowPath(currentAngle: Float) {............//初始偏移量var offsetY = -mBaseBowOffset//根据滑动进度偏移//如果当前进度>25%,表示已经到了终点,所以总是返回1//如果<=25%,因为总距离也是只有25%,所以要用4倍速度赶上offsetY += mMaxBowOffset * if (mProgress <= .25F) mProgress * 4F else 1F//偏移弓mBowPath.offset(0F, offsetY)}

mBaseBowOffset,就是刚刚说的,弓一开始的偏移量(端点的y坐标值),它是这样得来的:

    //后面的 +mBowWidth,就是画笔(画弓)的宽度,这样才不会画出格mBaseBowOffset = getPointByAngle(mBaseAngle).y + mBowWidth

getPointByAngle就是上面初始化弓Path时用来计算弓端点坐标的方法。
mMaxBowOffset是弓的最大偏移量(最终停留在垂直的中线上):

    //弓高度val bowHeight = mBaseBowOffset//最大偏移量 = 弓高 + Drawable总高度-箭杆长度的一半mMaxBowOffset = bowHeight + (mHeight - mArrowBodyLength) / 2

emmmm,还记不记得,这个updateBowPath方法,当时是直接传的30度?
但是现在不能写死了,要根据progress来动态计算这个角度:

    /*** 根据当前拖动的进度计算出弓的弯曲角度*/private fun getAngleByProgress() =//当前角度 = 基本角度 + (可用角度 * 滑动进度)mBaseAngle + if (mProgress <= .5F) 0F else mUsableAngle * (mProgress - .5F/*对齐(从0%开始)*/) * 2F/*两倍速度追赶*/

mBaseAngle也就是一开始的那个弯曲角度,我们暂定为25度
mUsableAngle就是可以弯曲的角度,暂定为20,那这个能弯曲的最大角度就是45度了。
在刚刚分配的滑动进度中,因为前50% 是用来偏移的,所以在50%之前,弓的弯曲角度是不变的,也就是可以直接取mBaseAngle的值了。
过了50%之后,角度才开始变化,但这时候,进度已经被消费了一半,如果按照原速度来弯曲,肯定是来不及了,所以要用2倍速度弯曲。
弯曲了之后,握柄自然也就跟着弯曲了(因为是截取弓的中间一部分)。

那接下来到了,弦的话,其实只是偏移中间的点,两边的端点不用变。
那具体怎么做呢? 很简单,只需要在updateStringPoints方法中加几句代码就行:

    mStringOffset = mStringStartPoint.y + if (mProgress <= .5F) 0Felse (mProgress - .5F) * mMaxStringOffset * 2F//改变弦的中点y坐标mStringMiddlePoint.y = mStringOffset

mStringOffset就是我们记录的弦的偏移量,它的计算方法是这样的:
当拖动的进度mProgress还没超过一半的时候,就不用偏移,即偏移量=0,如果超过了一半呢,就要2倍速度偏移了(因为已经消耗了一半)。
mMaxStringOffset就是的最大偏移量了,它的值是:

    //弓高度val bowHeight = mBaseBowOffset//弦最大偏移量 = 箭杆长度 - 弓的高度mMaxStringOffset = mArrowBodyLength - bowHeight

其实也就是预留了箭嘴的高度,那么在拉满弓的时候,就可以保证箭嘴在弓的上面。

好了,最后到箭的偏移啦。
因为我们刚刚并没有定义更新箭偏移量的方法,所以现在要新写一个了:

    /*** 更新箭偏移量*/private fun updateArrowOffset() {var newOffset = 0F//如果进度超过一半,证明已经开始拉弓了if (mProgress > .5F) {//这时候可以直接使用弦的偏移量。newOffset = mStringOffset} else if (mProgress >= .25F) {//如果进度大于1/4,证明弓已经到达目的地,要开始箭的偏移了//这时候要用4倍速度去偏移,因为箭偏移的动作只分配了25%。newOffset = (mProgress - .25F/*对齐(从0开始)*/) * mStringOffset * 4F}//先重置偏移量为0(抵消)mArrowPath.offset(0F, -mArrowOffset)//应用新的偏移量mArrowPath.offset(0F, newOffset)//更新本次偏移量mArrowOffset = newOffset}

可以看到,每次更新箭偏移量的时候都要调用两次offset方法,为什么呢?
因为现在的箭我们是重用的,也就是只初始化了一次,如果不重置offset的话,那么这个偏移量每次都会重复叠加,这样肯定是不对的。

好了,现在到draw方法里,在调用drawArrow方法前,先调用updateArrowOffset更新一下箭的偏移量,看看效果:

哇!终于动起来了,哈哈哈哈哈哈,是不是很开心?

发射

在做发射动画之前,我们还要先定义几个状态,用来区分当前是要拉弓还是发射还是做其他:

    companion object{const val  STATE_NORMAL = 0 //静止状态const val  STATE_DRAGGING = 1 //正在拉弓const val  STATE_FIRING = 2 //发射动画播放中}

那么draw方法就可以改成这样:

    override fun draw(canvas: Canvas) {when (mState) {STATE_FIRING -> {//处理发射动画}else -> {......//原来画弓箭弦的代码......}}}

发射的动画,就按一开始说的那样,给每个要移动的元素定义三样东西:开始时间动画时长要移动的距离
在这里重新捋一下动画的流程和细节:

  1. 先向下偏移,直至超出可见范围。偏移过程中弓会慢慢张开,张开的动作占用总进度的30%(即弯曲角度在弓偏移到总距离的30%处会恢复到初始的角度);
  2. 箭杆在离弦(弦的中点y值>箭的偏移量)之后,开始缩短(改变箭杆长度后重画),并且箭的小尾巴(一个外发光的矩形)慢慢出现;
  3. 箭杆缩短了30%之后,开始上下反复移动(移动的幅度为一个箭羽的高度);
  4. 上下移动时,会出现一条条的竖线快速地往下掉;

好,那现在先来看看的行为代码:

    /*** 处理发射中的状态*/private fun handleFiringState(canvas: Canvas) {//弓坠落动画已播放的时长val totalFallTime = (SystemClock.uptimeMillis() - mFireTime).toFloat()//检查弓坠落动画是否播放完毕if (totalFallTime <= mFiringBowFallDuration) {//得出动画已播放的百分比var percent = totalFallTime / mFiringBowFallDuration//处理溢出if (percent > 1) {percent = 1F}//当前要弯曲的角度//在弓向下移动了总距离的30%时完全展开(弯曲角度恢复到未拉弓前的角度)var angle = getAngleByProgress() - percent * 3F * mUsableAngle//弯曲角度不能小于未拉弓前的角度if (angle < mBaseAngle) {angle = mBaseAngle}//根据新的角度更新弓的PathupdateBowPath(angle)//偏移弓,偏移量就是当前进度 * 要偏移的总距离mBowPath.offset(0F, percent * mFiringBowOffsetDistance)//画弓drawBowPath(canvas)//更新握柄PathupdateHandlePath()//画手柄drawHandlePath(canvas)//更新弦坐标点updateStringPoints(false)if (mStringMiddlePoint.y < mStringStartPoint.y) {//弦中点y值小于两边端点y值的时候,证明箭已经离弦了//弦绷紧(即三个点的y值都一样)mStringMiddlePoint.y = mStringStartPoint.y//箭杆的缩放动画是时候播放了,记录开始时间if (mFiredArrowShrinkStartTime == 0L) {mFiredArrowShrinkStartTime = SystemClock.uptimeMillis()}}//画弦drawString(canvas)//画箭(这时候箭不用更新偏移量)drawArrow(canvas)}//不断请求重绘invalidateSelf()}

mFireTimemFiringBowFallDurationmFiringBowOffsetDistance分别是刚刚说的:开始时间、动画时长、要偏移的总距离。
mFiredArrowShrinkStartTime就是等下箭的缩短动画的开始时间。
还有一个更新弦坐标点的方法updateStringPoints,可以看到这次传了个false进去,这个boolean是用来判断弦的中点y坐标是否跟随当前拉弓的进度作偏移
因为现在只是弓向下移动,箭的位置是不变的,所以弦的中点坐标也不用变。当箭离弦后,中点的y值跟两端点的y值一样(变成一条直线)。
这样说好像有点抽象,先来看个图吧:

就是这样了。
现在来看看修改后的updateStringPoints方法:

    private fun updateStringPoints() {updateStringPoints(true)}private fun updateStringPoints(updateMiddlePointY: Boolean) {......//上面的代码不变......if (updateMiddlePointY) {//y轴呢,先跟起始点的y轴一样mStringMiddlePoint.y = mStringStartPoint.ymStringOffset = mStringStartPoint.y + if (mProgress <= .5F) 0Felse (mProgress - .5F) * mMaxStringOffset * 2F//改变弦的中点y坐标mStringMiddlePoint.y = mStringOffset}}

其实只是在更新mStringMiddlePoint.y(弦的中点y坐标)值之前加了条件判断,如果参数为false就不更新。可以看到这个方法还被分成了两个,没参数的那个默认为true,也就是修改之前的效果了。

好,那接下来到箭杆的缩短和发光的箭尾了:
箭杆可以用上面偏移弓那种做法,还记不记得当时初始化箭Path的方法,需要传一个箭杆长度进去?
那么等下我们就可以先计算出当前箭的长度,再调用那个方法来重新初始化箭,以达到缩短的效果。

至于发光的箭尾,它其实就是一个加了MaskFilter的矩形,但是要注意的是:
MaskFilter不支持硬件加速,所以等下还要先把硬件加速给关掉。

来看看它初始化的代码:

    //箭尾private val mArrowTail = RectF()/*** 初始化箭尾*/private fun initArrowTail() {//箭尾尺寸暂定为箭羽宽高的两倍val tailHeight = mFinHeight * 2//位置在Drawable的水平中点上mArrowTail.set(mCenterX - mFinWidth, 0F, mCenterX + mFinWidth, tailHeight)//发光效果,模式为内外发光,半径为箭羽的宽度mTailMaskFilter = BlurMaskFilter(mFinWidth, BlurMaskFilter.Blur.NORMAL)}

因为这些都是可以重用的,所以应该像初始化箭Path那样,在箭尺寸确定后调用这个方法就行了。
看看绘制的方法:

    /*** 画箭尾*/private fun drawArrowTail(canvas: Canvas) {//实心的mPaint.style = Paint.Style.FILL//加上发光效果mPaint.maskFilter = mTailMaskFilter//画箭尾canvas.drawRect(mArrowTail, mPaint)//移除发光效果(因为等下还可能要画其他东西)mPaint.maskFilter = null}

好,接下来是处理动画的方法:

    /*** 画正在缩短的箭*/private fun drawShrinkingArrow(canvas: Canvas) {//先算出已播放的时长val runTime = (SystemClock.uptimeMillis() - mFiredArrowShrinkStartTime).toFloat()//得出当前进度var percent = runTime / mFiredArrowShrinkDurationif (percent > 1) {percent = 1F}//当前进度 * 要缩短的总长度 = 当前要缩短的长度val needSubtractLength = percent * mFiredArrowShrinkDistance//新的箭杆长度(原始长度 - 要缩短的长度)val arrowLength = mArrowBodyLength - needSubtractLength//根据新的箭杆长度重新初始化箭的PathinitArrowPath(arrowLength)//因为现在的箭是新画的,还没有偏移量,所以还要偏移一下//箭新的偏移量(缩短了多少就向下偏移多少,以保持箭头位置不变)val newArrowOffset = mArrowOffset - needSubtractLength//应用偏移到箭mArrowPath.offset(0F, newArrowOffset)//更新箭尾的位置:x坐标不变(在Drawable的中间),y坐标,在箭的底部往上偏移一半的箭羽高度mArrowTail.offsetTo(mArrowTail.left, newArrowOffset - mFinHeight / 2)mPaint.color = Color.YELLOW//在缩短过程中,慢慢出现(透明度渐变)mPaint.alpha = (255 * percent).toInt()//画箭尾drawArrowTail(canvas)//重置透明度mPaint.alpha = 255drawArrow(canvas)if (percent == 1F) {//缩短动画播放完毕,开始上下移动的动画mFiredArrowShrinkStartTime = 0mFiredArrowMoveStartTime = SystemClock.uptimeMillis()}}

逻辑呢,跟上面偏移弓的是一样的,也是先计算出百分比,再根据百分比计算出当前的距离(要缩短的长度)。
可以看到还调用了mArrowTailoffsetTo方法,这个方法是用绝对坐标来定位的,我们传进去的那两个参数分别对应lefttop
在动画结束时,还记录了下一个环节(上下移动)的开始时间。

好,来看看现在的效果是怎么样的:

哈哈哈,箭最后消失了的原因是动画已经播放完毕,不符合draw的条件。

那现在来把剩下的动画完善一下:
先是箭上下移动的方法:

    /*** 画正在上下移动的箭*/private fun drawDancingArrow(canvas: Canvas) {val runTime = (SystemClock.uptimeMillis() - mFiredArrowMoveStartTime).toFloat()var percent = runTime / mFiredArrowMoveDurationif (percent > 1) {percent = 1F}//基于当前进度计算得出绝对偏移亮val distance = percent * mFiredArrowMoveDistance//减去上一次记录的 已偏移距离,得出相对偏移量val offset = distance - mFiredArrowLastMoveDistance//应用相对偏移量到箭mArrowPath.offset(0F, offset)//应用相对偏移量到箭尾mArrowTail.offset(0F, offset)//记录上一次的绝对偏移量mFiredArrowLastMoveDistance = distance//画箭drawArrow(canvas)//画尾巴drawArrowTail(canvas)//检查本次动画是否播放完毕if (percent == 1F) {//刷新开始时间mFiredArrowMoveStartTime = SystemClock.uptimeMillis()//切换方向mFiredArrowMoveDistance = -mFiredArrowMoveDistance//重置上一次的偏移距离mFiredArrowLastMoveDistance = 0F}}

可以看到,在动画播放完成之后,并没有将动画的开始时间置0,而是刷新这个时间,让它一直重复上下移动。

好,现在来想想,不断从顶部掉下来的线条,要怎么画呢?
其实一样可以用偏移动画的方法来做,不过呢,这些线条除了开始时间、总时长、总距离这三样,还有两个端点的坐标值要记录,如果不把这些东西装起来的话,那么等下写起代码来就会很痛苦,所以我们应该用一个内部类来把它们封装起来:

    /*** 坠落的线条*/private class Line {var duration = 0L//坠落的时长var startTime = 0L//开始坠落的时间var distance = 0F//坠落的总距离var startX = 0F//线条端点x坐标var startY = 0F//线条端点y坐标var height = 0F//线条高度var endX = 0F//线条端点x坐标}

接着用List把它装起来:

    //发射中坠落的线条private var mLines = MutableList(6){ Line() }

还没开始学习Kotlin的同学看这句代码可能有点费解,其实就是在创建了MutableList实例后,再创建6个Line实例并把它放到mLines里面去。

接下来到画的,很简单,把参数填上去就行了:

    /*** 画正在坠落的线条*/private fun drawLines(canvas: Canvas) {mPaint.style = Paint.Style.STROKE//遍历Lines来把全部的线条draw出来mLines.forEach {canvas.drawLine(it.startX, it.startY, it.endX, it.startY + it.height, mPaint)}}

那么,在画完之后,肯定还要有一个更新线条坐标的方法,不然的话这些线条就不会动了:

    /*** 更新每一条线的y坐标*/private fun updateLinesY() {mLines.forEach {//该线条已坠落的时间val runtime = (SystemClock.uptimeMillis() - it.startTime).toFloat()//动画播放的百分比val percent = runtime / it.duration//根据百分比更新线条的y坐标it.startY = percent * it.distance - it.height//如果该线条已超出屏幕,则重新初始化,即回到顶部重新开始坠落if (it.startY >= mHeight) {initLines(it)}}}

可以看到,当这些线条播放完之后呢,会被重用(调用initLines方法重新初始化),来看看它是怎么初始化的:

    /*** 初始化线条数据*/private fun initLines(tmp: Line) {//记录开始时间tmp.startTime = SystemClock.uptimeMillis()//随机时长:最小不会小于给定时长的1/4,最大时长是给定时长的1.25倍tmp.duration = (mBaseLinesFallDuration / 4 + mRandom.nextInt(mBaseLinesFallDuration)).toLong()//线条起始点的y坐标值,是一个负的随机数,最大不超过Drawable的高度tmp.startY = -mHeight + mRandom.nextFloat() * mHeight//线条的结束点y坐标值刚好未为0tmp.height = -tmp.startY//在x轴上随机一个位置坠落tmp.startX = mRandom.nextFloat() * mWidth//两端点的x轴坐标是一样的,即垂直的线条tmp.endX = tmp.startX//要偏移的距离就是线条起始点离Drawable底部的距离tmp.distance = mHeight - tmp.startY}

好,各个方法都定义好了之后,现在来把它们拼装起来,我们修改一下刚刚的handleFiringState方法:

    private fun handleFiringState(canvas: Canvas) {......//原来的代码不变......if (mFiredArrowShrinkStartTime > 0) {//画不断缩短的箭drawShrinkingArrow(canvas)} else if (mFiredArrowMoveStartTime > 0) {//先画线条drawLines(canvas)//更新线条坐标updateLinesY()//画重复上下移动的箭drawDancingArrow(canvas)}invalidateSelf()}

emmmm,最后还需要一个fire方法来触发射箭的动画:

    fun fire() {//标记当前状态为 正在发射mState = STATE_FIRING//首先初始化线条数据mLines.forEach { initLines(it) }//重置缩短动画的开始时间mFiredArrowShrinkStartTime = 0//重置上下移动动画的开始时间mFiredArrowMoveStartTime = 0//重置上一次的偏移距离mFiredArrowLastMoveDistance = 0F//记录发射开始时间mFireTime = SystemClock.uptimeMillis()invalidateSelf()}

好了,来看看现在的效果:

哇!太棒了!

命中

到最后一个环节啦,命中的动画,它会先向上移动,直至箭嘴没入Drawable顶部,接着尾部开始左右摆动,摆动一定次数后停止。
向上移动这个是完全没有问题,关键是摆动要怎么个摆动法呢?
熟悉Canvas的同学会知道,它有一个skew方法,是用来倾斜画布的,那么我们等下也可以用倾斜画布的方法,来做这个左右摆动的效果。
好,现在先来做箭向上移动的动画吧:
在开始之前,我们还要定义一个新的状态:STATE_HITTING = 3
那么在draw方法里就可以加上这个状态的分支了。:

    override fun draw(canvas: Canvas) {when (mState) {STATE_HITTING ->{handleHittingState(canvas)}............}}

来看看这个handleHittingState方法:

    /*** 处理命中状态*/private fun handleHittingState(canvas: Canvas) {if (mHitStartTime > 0) {//画向上偏移的动画drawArrowHitting(canvas)//请求重绘invalidateSelf()} else {if (mSkewStartTime > 0) {//画左右摆动的动画drawArrowSkewing(canvas)//请求重绘invalidateSelf()} else {//如果摆动动画播放完成,就直接画箭//并且不再请求重绘drawArrow(canvas)}}}/*** 画正在射向目标的箭*/private fun drawArrowHitting(canvas: Canvas) {val runTime = (SystemClock.uptimeMillis() - mHitStartTime).toFloat()var percent = runTime / mHitDurationif (percent > 1) {percent = 1F//偏移动画结束,开始左右摆动的动画mHitStartTime = 0mSkewStartTime = SystemClock.uptimeMillis().toFloat()//标记当前摆动的次数是1mCurrentSkewCount = 1}val distance = percent * mHitDistance//相对偏移量val offset = distance - mFiredArrowLastMoveDistancemFiredArrowLastMoveDistance = distance//偏移箭和箭尾mArrowPath.offset(0F, offset)mArrowTail.offset(0F, offset)//画线条drawLines(canvas)//更新线条坐标updateLinesY()//画箭drawArrow(canvas)//箭尾渐渐变得透明起来,直至完全透明mPaint.alpha = (255 * (1 - percent)).toInt()drawArrowTail(canvas)mPaint.alpha = 255}

emmmm,偏移的动画逻辑都是大同小异的,我们重点来看下面的drawArrowSkewing方法:

    /*** 画正在左右摇摆的箭*/private fun drawArrowSkewing(@NonNull canvas: Canvas) {val runTime = SystemClock.uptimeMillis() - mSkewStartTimevar percent = runTime / mSkewDurationif (percent > 1) {percent = 1F}//当前要摆动的幅度var tan = mSkewTan * percent//如果是偶数,则向左摆动,否则向右if (mCurrentSkewCount % 2 == 0) {tan -= mSkewTan}//倾斜画布canvas.skew(tan, 0F)//画箭drawArrow(canvas)if (percent == 1F) {//如果摆动的次数达到指定的次数则停止动画if (mCurrentSkewCount == mMaxSkewCount) {//完满结束,重置时间mSkewStartTime = 0Freturn} else {//更新动画的开始时间mSkewStartTime = SystemClock.uptimeMillis().toFloat()//记录当前已摆动的次数mCurrentSkewCount++}//如果次数为偶数就要切换方法(一次来一次回,所以是偶数)if (mCurrentSkewCount % 2 == 0) {mSkewTan = -mSkewTan}}}

我们的摆动策略是这样的:

  • 一共要摆动的次数为8次(mMaxSkewCount = 8),左右各2次来回;
  • 每次摆动的幅度大概为2度(mSkewTan = .035F)(这个是正切值);
  • 第一次向右偏移(刚刚的drawArrowHitting方法标记了mCurrentSkewCount1),偏移结束后,mCurrentSkewCount会+1,+1后就是偶数了,继续往下执行,因为检测到是偶数还会把目的地取反,也就是要往相反方向走了。所以当它重新开始动画时,所偏移的方向是反的,这样一来一回,看上去就像是箭尾在摆动的样子。

好,最后还要定义一个hit方法,用来触发命中动画:

    fun hit() {//处在上下移动状态时才可以hitif (mState == STATE_FIRING && mFiredArrowMoveStartTime > 0) {mState = STATE_HITTING//标记命中动画开始时间mHitStartTime = SystemClock.uptimeMillis()//计算出当前箭的偏移量var currentArrowOffset = mArrowOffset + mFiredArrowLastMoveDistanceif (mFiredArrowMoveDistance > 0) {//如果距离是正数,证明已经向上偏移过一次了,因为第一次是负数,所以要减去这个距离currentArrowOffset -= mFiredArrowMoveDistance}//除去箭嘴的箭高度(要没入一个箭嘴的高度)val arrowBodyHeight = mFinHeight + mFinSlopeHeight + mArrowBodyLength//因为是向上移动,所以是负数mHitDistance = -(currentArrowOffset - arrowBodyHeight)//重置上一次的偏移量mFiredArrowLastMoveDistance = 0FinvalidateSelf()}}

来看看最终的效果:

哈哈哈,可以了,发张表情包鼓励下自己:

好了,本篇文章到此结束,有错误的地方请指出,谢谢大家!

Github地址:https://github.com/wuyr/ArrowDrawable 欢迎Star

Android自定义Drawable第十四式之百步穿杨相关推荐

  1. Android自定义Drawable第十五式之啡常OK

    前言 在上一篇的自定义Drawable中,我们学习了如何在Canvas上draw一个射箭的动画,不过那个动画是以线条为主的,多看几眼可能就会觉得没味道了,那么在本篇文章,将和同学们一起做一个看起来更耐 ...

  2. Android自定义ViewGroup第十二式之年年有鱼

    前言 先来看两张效果图: 哈哈,就是这样了. 前段时间在鸿神的群里看到有群友截了一张QQ空间的图,问它那个是怎么实现的: 在好友动态的列表中多了个Header,这个Header有一叠卡片的效果,上面的 ...

  3. Android自定义Behavior第十六式之空中楼阁

    前言 & 初步分析 上个月鸿神在群里推荐一位同学的Flutter版WanAndroid项目的时候发现了一个炫酷的效果: 嗯,就是一个下拉进入二楼的效果,但因为这个项目是用Flutter做的,无 ...

  4. Xamarin.Android开发实践(十四)

    原文:Xamarin.Android开发实践(十四) Xamarin.Android之ListView和Adapter 一.前言 如今不管任何应用都能够看到列表的存在,而本章我们将学习如何使用Xama ...

  5. Android 自定义UI 实战 02 流式布局

    Android 自定义UI 实战 02 流式布局-- 自定义ViewGroup 第二章 自定义ViewGroup 流式布局 文章目录 Android 自定义UI 实战 02 流式布局-- 自定义Vie ...

  6. Android自定义View之实现流式布局

    Android自定义View之实现流式布局 运行效果 流式布局 把子控件从左到右摆放,如果一行放不下,自动放到下一行 自定义布局流程 1. 自定义属性:声明,设置,解析获取自定义值 在attr.xml ...

  7. Android开发问题集锦十四--绚丽的烟花

    Android开发问题集锦十四--绚丽的烟花 程序之美 前言 源码下载 程序之美 前言 随着一声突如其来的响声,打破了久违的不能喘息般的的寂静.一团彩色的光芒快速上升着,留下一线灰色的烟雾.啪!一朵& ...

  8. 一个项目玩转 Android 自定义 Drawable。

    DreamDrawable 项目地址:yanbober/DreamDrawable 简介: 一个项目玩转 Android 自定义 Drawable. 更多:作者   提 Bug 标签: 一个项目玩转 ...

  9. Android 开发:(十四)NavigationBar篇-自定义顶部导航栏

    本篇记录了navigation bar顶部导航栏的自定义方法,抛砖引玉,简单实现了常用的布局,在此基础上可添加较复杂的布局. 第一步:新建NavigationBar文件,继承与FrameLayout. ...

最新文章

  1. 终于完成了“微软”化
  2. HttpSessionActivationListener接口 学习笔记
  3. spring cloud 定时任务
  4. python 打包exe出现RuntimeError: Could not find the matplotlib data files 的解决方法
  5. scrapy 动态IP、随机UA、验证码
  6. 【网络流】最大流问题(EK算法带模板,Dinic算法带模板及弧优化,ISAP算法带模板及弧优化)上下界网络流
  7. 山西专科学校计算机专业排名,河南单招计算机专业专科学校排名
  8. C++STL笔记(四):vector详解
  9. PHP中 如何将二位数组按某一个或多个字段值(升序/降序)排序?数字索引被重置,关联索引保持不变...
  10. 关于HP C7K的firmware management中的power policy理解
  11. chrome离线安装包下载方法
  12. java jdk生成安卓app证书
  13. delete不起作用 nsis_Delete键为什么不起作用了?
  14. 深度学习入门笔记(1)——什么是深度学习?
  15. 【无人机】无刷电调学习之路
  16. OpenCV这么简单为啥不学——1.5、解决putText中文乱码问题
  17. imprecise external abort
  18. opencv报错:(depth == CV_8U || depth == CV_32F)
  19. 01-初识sketch-sketch优势
  20. 拿了北京户口!却是跌落的开始....

热门文章

  1. mysql三叶草,温州日报瓯网 - 面对温州话,你被困住了吗?
  2. 20150802厦门大学华为校园提前批招聘机试体验题三:Word Maze(单词迷宫)
  3. QNX系列:五、资源管理器(1)官方文档的翻译
  4. Macbook清理other
  5. JavaScript完整版国家-省-市地区,级联效果(带效果图哦)
  6. ESP32入门基础之UDP和TCP实验
  7. Java中进入wait状态的线程被唤醒后会接着上次执行的地方往下执行还是会重新执行临界区的代码
  8. wps转换成word如何实现?不妨试试这两个小技巧
  9. narwal机器人_省时省心才见真章!Narwal云鲸J1智能扫拖机器人国内上市
  10. SRS 代码分析【mpeg-ts解析】