前言
在上篇文章(Android实现圆弧滑动效果之ArcSlidingHelper篇)中,我们把圆弧滑动手势处理好了,那么这篇文章我们就来自定义一个ViewGroup,名字叫就风扇布局吧,接地气。
在开始之前,我们先来看2张效果图 (表情包来自百度贴吧):
哈哈,其实还有以下特性的,就先不发那么多图了:
简单分析
圆弧手势滑动我们现在可以跳过了(因为在上一篇文章中做好了),先从最基本的开始,想一下该怎么layout? 其实也很简单:从上面几张效果图中我们可以看出来,那一串串小表情是围着大表情旋转的,即小表情的旋转点(mPivotX,mPivotY) = 大表情的中心点(宽高 ÷ 2),至于旋转,肯定是用setRotation方法啦,不过在setRotation之前,还要先set一下PivotX和PivotY。
创建FanLayout
首先是onLayout方法:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
onMeasure我们先不考虑那么细,measureChildren后直接setMeasuredDimension:
- 1
- 2
- 3
- 4
- 5
好了,就这么简单,我们来看看效果怎么样:
我们的布局 (item就是一排ImageView):
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
效果:
支持圆弧手势
哈哈,现在最基本的效果是出来了,但是还未支持手势,这时候我们上篇做的ArcSlidingHelper要登场了,我们把ArcSlidingHelper添加进来:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
我们把ArcSlidingHelper放到onSizeChanged里面初始化,为什么呢,因为这个方法回调时,getWidth和getHeight已经能获取到正确的值了
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
onSliding方法就是ArcSlidingHelper的OnSlidingListener接口,当ArcSlidingHelper计算出角度之后,就会回调onSliding方法,我们在这里面直接更新了子view的角度,并且在onDetachedFromWindow释放了ArcSlidingHelper,哈哈,节约内存,从每一个细节做起。
好了,添加支持圆弧手势滑动就这么简单,我们来看看效果如何:
可以看到已经成功处理圆弧滑动手势了,但是还有一个情况就是,当子view设置了自己的OnClickListener,这个时候如果我们手指刚好是按在这个子view上,当手指移动时会发现,旋转不了,因为这个事件正在被这个子view消费。所以还要在onInterceptTouchEvent方法里处理一下:如果手指滑动的距离超过了指定的最小距离,则拦截这个事件,交给我们的ArcSlidingHelper来处理。
我们来看看代码怎么写:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
当然了,onTouchEvent方法也要加上手指松开后,标记isBeingDragged为false:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
mTouchSlop的初始化放在构造方法中:
- 1
哈哈,那个拦截的方法是参考自ScrollView的 (我们平时从SDK源码中也能学到不少东西)
好的,看看效果怎么样:
额。。有没有发现,每次拦截之后,开始滑动时都是跳一下,是什么原因呢?
就是因为我们的ArcSlidingHelper内部也是用startX和startY来记录上一次手指坐标的,在FanLayout拦截事件之前,有一段距离已经被消费了,所以ArcSlidingHelper里面的startX和startY并不是最新的距离 (计算出来的滑动距离会偏长),就会出现上面这种:跳了一下 的情况。
那么,我们应该怎么解决呢,加上触发滑动的最小距离吗?哈哈,当然不是了,这个方法太麻烦,还要根据手指的滑动趋势来决定是加还是减。
其实ArcSlidingHelper早就已经准备了一个方法来应对这种情况:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
我们在onInterceptTouchEvent方法中更新一下坐标就可以了:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
updateMovement方法里面直接更新了x和y的值,这样的话,就不会出现跳一下的情况了。
添加轴承(中间的大表情)
我们的轴承有两种类型:Color和View,Color类型就是指定一种颜色,不能接受点击事件,是直接用Paint画出来的。View类型可以自己定义轴承的内容,来看看下面两张效果图:
哈哈,可以看到,我们除了添加两种不同的轴承类型之外,还加上了动态切换轴承的位置(在顶部或底部)和圆形半径还有Item偏移量,View类型下还可以设置是否跟随子View旋转。
一下子多了这么多属性,但是不要怕,我们来逐个击破。
现在是时候在attr中自定义这些属性了:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
好了,定义完布局属性之后,再回到FanLayout中,也要定义对应的属性:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
最后,在构造方法中获取这些属性:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
在获取到这些属性之后,我们需要改一下onMeasure方法了,刚刚贪方便,测量了子view后直接setMeasuredDimension了,这样做一般是不可取的,因为还要考虑宽高为wrap_content的情况,好,我们来看看修改之后的onMeasure方法:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
改完onMeasure方法后,onLayout方法也要改了,因为我们加入了轴承,如果是View类型,那就应该不能把它当作Item来layout,还加入了Item偏移量和轴承半径这两个属性,所以Item的位置也要调整一下:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
可以看到,在layoutItem方法中,根据当前轴承的半径和Item偏移量来计算出正确的Item位置。
好吧,现在已经迫不及待的想看看效果了,等等,还是先在布局里面设置下刚刚加进去的一些属性吧:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
我们把bearing_type设置为View类型,并且指定了轴承的布局:
- 1
- 2
- 3
- 4
轴承的布局就只是一个ImageView,还有,我们还把item_offset设置为-20dp,好了,现在来看看效果吧:
emmm,还差一个轴承在顶部的效果没实现呢,还有一个轴承不跟随旋转的,这个非常简单,我们在旋转的回调方法里面加一个条件就可以了,因为现在是遍历了全部的子View来设置旋转角度的:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
好了,现在我们来想想轴承在顶部的效果应该要怎么做呢,可能有同学就会想了:真是的,把它放到最后添加不就行了,还用想吗?
额,其实还有2点要考虑的:因为我们现在做的是支持动态增删Item和动态的设置轴承在顶部还是在底部,这样一来,如果轴承添加进去之后,又继续添加了Item,这时候新添加的Item就会盖住轴承的了,所以我们决定重写addView方法:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
那现在来测试下刚刚加进去的那两个效果如何:
哈哈,可以看到,经过我们重写addView方法之后,如果是在顶部的话,就算新添加进去的Item,也不会遮住轴承View的,这是我们想看到的效果。
呼~~ 现在我们来看看Color类型的应该怎么做:其实很简单,这个圆形直接在onDraw里面去drawCircle就行了,不过这个onDraw方法是draw在子view的下面的,那么我们如果要它在上面的话怎么办呢,嘻嘻,其实View还有一个onDrawForeground方法,如果要画在子View上面的话,可以在这个方法里面draw,看下代码怎么写:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
emmm,就是这么简单,在动态改变了轴承的位置之后(invalidate()),它也会根据isBearingOnBottom来决定圆形draw在顶部或底部。
对齐方式
我们的对齐方式有:左(默认)、右、上、下、左上、右上、左下、右下 8种,可能有同学看到一共有8种这么多就怕了,其实不用怕,这个很简单的,代码很少。
在开始之前,我们来回忆一下,刚刚的onLayout方法中,Item是怎么layout的:
- 1
- 2
- 3
- 4
可以看到,Item的位置都是取决于mPivotX和mPivotY的,这样的话,我们只需改变一下mPivotX和mPivotY的值,然后requestLayout就行了。那么,怎么根据不同的对齐方式计算出正确的mPivotX和mPivotY呢?
在开始之前,我们先来看看这张图:
这样思路就清晰很多了,我们根本就不用怎么去计算,都是直接取:0,宽度、高度、一半宽度、一半高度就行了,好了,首先我们要声明一下有哪些对齐方式:
attr中:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
FanLayout中:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
构造方法中也要加上一句:
- 1
- 2
再看看计算方法怎么写:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
好了,那么我们应该在哪里调用这个方法最好呢?哈哈,当然是onMeasure了:
- 1
- 2
- 3
- 4
- 5
现在可以把onLayout方法里面的
- 1
- 2
还有onSizeChanged里面的:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
这4句删掉了。
再提供一个setGravity方法:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
@Gravity就是使用了@IntDef的自定义注解:
- 1
- 2
- 3
- 4
好,来看看效果:
哈哈,可以了。
咦?等等!
当设置为右对齐的时候,item居然反了。。额其实不是反了,只是它正的一面我们看不到而已,那么我们要怎么样使它变正呢?很简单,layoutItems方法中,加个条件判断是不是右边的对其方式,如果是,layout 子View时从左边开始就行:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
哈哈,这样就可以了。
Item保持垂直
哈哈,有没有发现开启这个效果之后,小表情们就充满活力了?其实实现这个效果非常简单:
首先attr中定义个属性:
- 1
- 2
FanLayout中:
- 1
- 1
- 2
- 3
- 4
- 5
- 6
接下来就是在旋转回调里面做手脚了,但是有一点需要注意的就是,这个所谓的Item保持垂直,并不是FanLayout的直接子View,而是FanLayout的子View的子View,为什么呢?因为现在FanLayout的Item布局都是一个LinearLayout里面水平放ImageView的,所以想要达到上图中的效果,必须拿FanLayout的子View的子View来旋转,而旋转角度,是跟子View的旋转角度相反,而非直接设置为0,我们来看代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
哈哈,这样就可以了,就是这么简单。
轴承偏移
可以看到,当对齐方式不同的时候,设置偏移量,这个偏移的方向也会不同(它总是会朝着FanLayout的中心偏移)
其实这个也是非常简单的,因为我们刚刚已经做好了对齐方式,那么,现在只要在对齐方式的基础上,按规则加上或减去这个轴承的偏移量就行了。
我们添加mBearingOffset属性之后,把updateCircleCenterPoint方法改成这样:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
哈哈,这样当偏移量设置得越大的时候,就越向中心靠拢了(当然了,太大也会超出范围的)。
自动选中
好了,到了自动选中就稍微有点复杂了,但是我们也不要怕他,先看看下面这张图:
可以看到,当自动选中一打开,就自动选择了距离中线最近的那一个item,当惯性滚动结束后,也会自动选择距离最近的item。那现在我们已经有初步的思路了:找到离目标角度最近的item,然后平滑旋转它,直到item的角度 = 目标角度为止
但是怎么找到这个距离最近的item呢?因为目标角度是跟随着对齐方式的变化而变化的,所以肯定不能把代码写死了。
emmm,其实我们可以先根据当前的对齐方式来获取到目标角度:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
拿到目标角度之后,下一步就是根据这个目标角度,来找出离它最近的那个item了,大概的思路就是:遍历子View,逐个判断,取距离目标角度更近的那一个item的索引。然后我们就可以根据这个索引找到对应的子View来计算出所需要的旋转角度了,最后判断是需要顺时针还是逆时针旋转,再播放旋转的动画就完成了。
我们来看看查找离目标角度最近的Item代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
好,我们现在定义一个调整位置的方法:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
fixRotation方法就是来用调整角度,使其始终处于0和360之间的 (这个在上一篇也有介绍到):
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
最后调用了startValueAnimator方法,来看看:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
很简单,就播放了一个ValueAnimator,mFixingAnimationDuration这个就是自定义的动画时长,贴心的我们还把它做成可以动态设置选中动画时长。
动画更新的时候,通过调用onSliding方法来旋转Item们,我们还可以提供一个OnItemSelectedListener,在动画播放结束后,利用它来通知外部有新的Item选中。
emmm,现在是万事俱备,只欠东风了,我们需要在手指停止滑动或惯性滚动结束后,来调用playFixingAnimation方法,这个接口在ArcSlidingHelper里也有提供了,哈哈,我们现在只需这样:
- 1
- 2
- 3
- 4
- 5
- 6
转为lambda后只有一行代码:
- 1
添加是否自动选中的自定义属性的话,可以在调用playFixingAnimation方法之前判断一下是否已开启自动选中效果就行了。
好啦,快来看看效果吧:
哈哈,可以了,是不是很开心 (*^__^*)
布局模式
对了,那位同学提出了个问题就是:当Item只有四五个的时候,可不可以把他们都显示出来呢,因为现在是把360平均分了。
这个当然是可以的,我们干脆就分成两种布局模式吧:平均分布模式和指定角度模式。指定角度模式,那就肯定要指定一个角度了,所以如果是设置了这个模式的话,我们还要添加一个mItemAngleOffset属性来记录每个Item之间的偏移角度。
先来定义一下属性:
- 1
- 2
- 3
- 4
- 5
- 6
我们添加了2个新属性:item_layout_mode(布局方式)和item_angle_offset(Item偏移角度),布局方式有两种:average(平均)和fixed(指定角度),默认为前者。当设置为fixed的时候,还要再指定一个偏移的角度,因为FanLayout不知道每一个Item的偏移角度是多少。
在FanLayout中,也要添加对应的属性:
- 1
- 2
- 3
- 4
- 5
然后再在构造方法中获取到属性:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
接下来就很简单了,只需要在layoutItems方法中改一行代码:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
哈哈,判断一下是不是fixed模式,如果是fixed模式,直接使用指定的偏移角度就行了。
我们再来定义两个set方法来动态设置布局方式和Item偏移量:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
setItemLayoutMode方法中的@LayoutMode也是使用了@IntDef的自定义注解。
好,我们来看看效果:
哈哈,可以了。
但是可能有同学会觉得:为什么添加新Item和偏移时只能是顺时针呢?而且现在的Item总是在轴承的右下方,如果我要Item显示在正右方怎么办?现在是这样:
这样看上去就不是很舒服了,因为上方有一部分是没有Item的。
好吧,既然这样,那就再加上一个可以动态设置Item的添加方向的效果吧。
Item添加方向
除了顺时针和逆时针,我们还准备再添加一个交叉添加模式,哈哈,就是一个顺时针一个逆时针了,这样做的话,就可以实现刚刚说的:让Item整体保持在正右方。
先添加属性,首先是attr:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
FanLayout:
- 1
- 2
- 3
- 4
- 5
我们在构造方法中拿到item_add_direction这个属性之后,就要想想接下来应该怎么做了:
- 其实如果是顺时针的话,我们什么都不用做,保持原来的就行;
- 如果是逆时针呢?那就刚好跟顺时针相反,顺时针是50度的话,那么逆时针就是-50度了,所以我们等下直接用360减去顺时针中的角度就行了;
- 交叉模式的话,我们是打算奇数顺时针添加,偶数逆时针添加,这样就能实现交叉的效果了;
好,来看看代码怎么写(也是只需要在layoutItems方法里面修改一下就行了):
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
好了,在添加seter方法之后,看看效果如何:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
哈哈哈,三种模式我们都实现了,是不是很开心。
添加指定选中
可能还有同学不满足,ListView有setSelection,RecyclerView有scrollToPosition,为什么FanLayout就不能有一个选中指定Item的方法呢?
好吧,那我们也加一个这个的效果吧:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
- 67
- 68
- 69
- 70
- 71
- 72
- 73
- 74
- 75
- 76
- 77
- 78
- 79
- 80
- 81
- 82
- 83
- 84
- 85
- 86
- 87
- 88
- 89
- 90
- 91
- 92
- 93
- 94
- 95
- 96
- 97
- 98
- 99
我们一气之下 一口气加了三个方法,经过之前的一些分析,相信上面那些代码大家都可以轻松看懂了。
来看看效果如何:
哈哈,可以看到,开启自动选中之后,通过点击上面那一排数字,也能正确地旋转到对应的Item了。