效果
首先看一下实现的效果:
可以看出,环形菜单的实现有点类似于滚轮效果,滚轮效果比较常见,比如在设置时间的时候就经常会用到滚轮的效果。那么其实通过环形菜单的表现可以将其看作是一个圆形的滚轮,是一种滚轮实现的变式。
实现环形菜单的方式比较明确的方式就是两种,一种是自定义view,这种实现方式需要自己处理滚动过程中的绘制,不同item的点击、绑定数据管理等等,优势是可以深层次的定制化,每个步骤都是可控的。另外一种方式是将环形菜单看成是一个环形的list,也就是通过自定义layoutmanager来实现环形效果,这种方式的优势是自定义layoutmanager只需要实现子控件的onlayoutchildren即可,数据绑定也由recyclerview管理,比较方便。本文主要是通过第二种方式来实现,即自定义layoutmanager的方式。
如何实现
第一步需要继承recyclerview.layoutmanager:
class arclayoutmanager( private val context: context, ) : recyclerview.layoutmanager() { override fun generatedefaultlayoutparams(): recyclerview.layoutparams = recyclerview.layoutparams(match_parent, wrap_content) override fun onlayoutchildren(recycler: recyclerview.recycler, state: recyclerview.state) { super.onlayoutchildren(recycler, state) fill(recycler) } // layout子view private fun fill(recycler: recyclerview.recycler) { } }
继承layoutmanager之后,重写了onlayoutchildren
,并且通过fill()
函数来摆放子view,所以fill()
函数如何实现就是重点了:
首先看一下上图,首先假设圆心坐标(x, y)为坐标原点建立坐标系,然后图中蓝色线段b的为半径,红色线段a为子view中心到x轴的距离,绿色线段c为子view中心到y轴的距离,要知道子view如何摆放,就需要计算出红色和绿色的距离。那么假设以-90为起点开始摆放子view,假设一共有n个子view,那么就可以计算得到:
计算中,需要使用弧度计算,需要将角度首先转为弧度:math.toradians(angle)。弧度计算公式:弧度 = 角度 * π / 180
根据上述公式就可以得出fill()
函数为:
// mcurrangle: 当前初始摆放角度 // minitialangle:初始角度 private fun fill(recycler: recyclerview.recycler) { if (itemcount == 0) { removeandrecycleallviews(recycler) return } detachandscrapattachedviews(recycler) angledelay = math.pi * 2 / (mvisibleitemcount) if (mcurrangle == 0.0) { mcurrangle = minitialangle } var angle: double = mcurrangle val count = itemcount for (i in 0 until count) { val child = recycler.getviewforposition(i) measurechildwithmargins(child, 0, 0) addview(child) //测量的子view的宽,高 val cwidth: int = getdecoratedmeasuredwidth(child) val cheight: int = getdecoratedmeasuredheight(child) val cl = (innerx + radius * sin(angle)).toint() val ct = (innery - radius * cos(angle)).toint() //设置子view的位置 var left = cl - cwidth / 2 val top = ct - cheight / 2 var right = cl + cwidth / 2 val bottom = ct + cheight / 2 layoutdecoratedwithmargins( child, left, top, right, bottom ) angle += angledelay * orientation.value } recycler.scraplist.tolist().foreach { recycler.recycleview(it.itemview) } }
通过实现以上fill()
函数,首先就可以实现一个圆形排列的recyclerview:
此时如果尝试滑动的话,是没有效果的,所以还需要实现在滑动过程中的view摆放, 因为仅允许在竖直方向的滑动,所以:
// 允许竖直方向的滑动 override fun canscrollvertically() = true // 滑动过程的处理 override fun scrollverticallyby( dy: int, recycler: recyclerview.recycler, state: recyclerview.state ): int { // 根据滑动距离 dy 计算滑动角度 val theta = ((-dy * 180) * orientation.value / (math.pi * radius * default_ratio)) * default_scroll_damp // 根据滑动角度修正开始摆放的角度 mcurrangle = (mcurrangle + theta) % (math.pi * 2) offsetchildrenvertical(-dy) fill(recycler) return dy }
在根据滑动距离计算角度时,将竖直方向的滑动距离,近似看成是在圆上的弧长,再根据自定义的系数计算出需要滑动的角度。然后重新摆放子view。
实现了上述函数后,就可以正常滚动了。那么当我们希望滚动完成后,能够自动将距离最近的一个子view位置修正为初始位置(在本例中即为-90度的位置),应该如何实现呢?
// 当所有子view计算并摆放完毕会调用该函数 override fun onlayoutcompleted(state: recyclerview.state) { super.onlayoutcompleted(state) stabilize() } // 修正子view位置 private fun stabilize() { }
要修正子view位置,就需要在所有子view都摆放完成后,再计算子view的位置,再重新摆放,所以stabilize()
实现就是关键了, 接下来就看下stabilize()
的实现:
// 修正子view位置 private fun stabilize() { if (childcount < mvisibleitemcount / 2 || issmoothscrolling) return var mindistance = int.max_value var nearestchildindex = 0 for (i in 0 until childcount) { val child = getchildat(i) ?: continue if (orientation == fillitemorientation.left_start && getdecoratedright(child) > innerx) continue if (orientation == fillitemorientation.right_start && getdecoratedleft(child) < innerx) continue val y = (getdecoratedtop(child) + getdecoratedbottom(child)) / 2 if (abs(y - innery) < abs(mindistance)) { nearestchildindex = i mindistance = y - innery } } if (mindistance in 0..10) return getchildat(nearestchildindex)?.let { startsmoothscroll( getposition(it), true ) } } // 滚动 private fun startsmoothscroll( targetposition: int, shouldcenter: boolean ) { }
在stabilize()
函数中,做了一件事就是找到距离圆心最近距离的一个子view,然后调用startsmoothscroll()
滚动到该子view的位置。
接下来就是startsmoothscroll()
的实现了:
private val scroller by lazy { object : linearsmoothscroller(context) { override fun calculatedttofit( viewstart: int, viewend: int, boxstart: int, boxend: int, snappreference: int ): int { if (shouldcenter) { val viewy = (viewstart + viewend) / 2 var modulus = 1 val distance: int if (viewy > innery) { modulus = -1 distance = viewy - innery } else { distance = innery - viewy } val alpha = asin(distance.todouble() / radius) return (pi * radius * default_ratio * alpha / (180 * default_scroll_damp) * modulus).roundtoint() } else { return super.calculatedttofit( viewstart, viewend, boxstart, boxend, snappreference ) } } override fun calculatespeedperpixel(displaymetrics: displaymetrics) = speech_millis_inch / displaymetrics.densitydpi } } // 滚动 private fun startsmoothscroll( targetposition: int, shouldcenter: boolean ) { this.shouldcenter = shouldcenter scroller.targetposition = targetposition startsmoothscroll(scroller) }
滚动的过程是通过自定义的linearsmoothscroller来实现的,主要是两个重写函数:calculatedttofit
, calculatespeedperpixel
。其中calculatedttofit
需要说明一下的是,当竖直方向滚动的时候,它的参数分别为:(子view的top,子view的bottom,recyclerview的top,recyclerview的bottom),返回值为竖直方向上的滚动距离。当水平方向滚动的时候,它的参数分别为:(子view的left,子view的right,recyclerview的left,recyclerview的right),返回值为水平方向上的滚动距离。 而calculatespeedperpixel
函数主要是控制滑动速率的,返回值表示每滑动1像素需要耗费多长时间(ms),这里speech_millis_inch是自定义的阻尼系数。
关于calculatedttofit
计算过程如下:
计算出目标子view与x轴的夹角后,再根据之前说过的根据滑动距离 dy 计算滑动角度反推出dy的值就可以了。
通过上述一系列操作,就可以实现了大部分效果,最后再加上一个初始位置的view 放大的效果:
private fun fill(recycler: recyclerview.recycler) { ... layoutdecoratedwithmargins( child, left, top, right, bottom ) scalechild(child) ... } private fun scalechild(child: view) { val y = (child.top + child.bottom) / 2 val scale = if (abs( y - innery) > child.measuredheight / 2) { child.translationx = 0f 1f } else { child.translationx = -child.measuredwidth * 0.2f 1.2f } child.pivotx = 0f child.pivoty = child.height / 2f child.scalex = scale child.scaley = scale }
当子view位于初始位置一定范围内,将其放大1.2倍,注意子view放大的同时,x坐标也同样需要变化。
经过上述步骤,就实现了基于自定义layoutmanager方式的环形菜单。
以上就是基于android实现可滚动的环形菜单效果的详细内容,更多关于android环形菜单的资料请关注其它相关文章!