Android-Kotlin实现可滚动的环形菜单

Posted bug樱樱

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android-Kotlin实现可滚动的环形菜单相关的知识,希望对你有一定的参考价值。

效果

首先看一下实现的效果:

可以看出,环形菜单的实现有点类似于滚轮效果,滚轮效果比较常见,比如在设置时间的时候就经常会用到滚轮的效果。那么其实通过环形菜单的表现可以将其看作是一个圆形的滚轮,是一种滚轮实现的变式。

实现环形菜单的方式比较明确的方式就是两种,一种是自定义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方式的环形菜单。

作者:fb0122
链接:https://juejin.cn/post/7080091488519979015

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。

相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

一、架构师筑基必备技能

1、深入理解Java泛型
2、注解深入浅出
3、并发编程
4、数据传输与序列化
5、Java虚拟机原理
6、高效IO
……

二、Android百大框架源码解析

1.Retrofit 2.0源码解析
2.Okhttp3源码解析
3.ButterKnife源码解析
4.MPAndroidChart 源码解析
5.Glide源码解析
6.Leakcanary 源码解析
7.Universal-lmage-Loader源码解析
8.EventBus 3.0源码解析
9.zxing源码分析
10.Picasso源码解析
11.LottieAndroid使用详解及源码解析
12.Fresco 源码分析——图片加载流程

三、Android性能优化实战解析

  • 腾讯Bugly:对字符串匹配算法的一点理解
  • 爱奇艺:安卓APP崩溃捕获方案——xCrash
  • 字节跳动:深入理解Gradle框架之一:Plugin, Extension, buildSrc
  • 百度APP技术:Android H5首屏优化实践
  • 支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」
  • 携程:从智行 Android 项目看组件化架构实践
  • 网易新闻构建优化:如何让你的构建速度“势如闪电”?

四、高级kotlin强化实战

1、Kotlin入门教程
2、Kotlin 实战避坑指南
3、项目实战《Kotlin Jetpack 实战》

  • 从一个膜拜大神的 Demo 开始

  • Kotlin 写 Gradle 脚本是一种什么体验?

  • Kotlin 编程的三重境界

  • Kotlin 高阶函数

  • Kotlin 泛型

  • Kotlin 扩展

  • Kotlin 委托

  • 协程“不为人知”的调试技巧

  • 图解协程:suspend

五、Android高级UI开源框架进阶解密

1.SmartRefreshLayout的使用
2.Android之PullToRefresh控件源码解析
3.Android-PullToRefresh下拉刷新库基本用法
4.LoadSir-高效易用的加载反馈页管理框架
5.Android通用LoadingView加载框架详解
6.MPAndroidChart实现LineChart(折线图)
7.hellocharts-android使用指南
8.SmartTable使用指南
9.开源项目android-uitableview介绍
10.ExcelPanel 使用指南
11.Android开源项目SlidingMenu深切解析
12.MaterialDrawer使用指南

六、NDK模块开发

1、NDK 模块开发
2、JNI 模块
3、Native 开发工具
4、Linux 编程
5、底层图片处理
6、音视频开发
7、机器学习

七、Flutter技术进阶

1、Flutter跨平台开发概述
2、Windows中Flutter开发环境搭建
3、编写你的第一个Flutter APP
4、Flutter开发环境搭建和调试
5、Dart语法篇之基础语法(一)
6、Dart语法篇之集合的使用与源码解析(二)
7、Dart语法篇之集合操作符函数与源码分析(三)

八、微信小程序开发

1、小程序概述及入门
2、小程序UI开发
3、API操作
4、购物商场项目实战……

全套视频资料:

一、面试合集

二、源码解析合集


三、开源框架合集


欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取↓↓↓

以上是关于Android-Kotlin实现可滚动的环形菜单的主要内容,如果未能解决你的问题,请参考以下文章

ionic使用ion-scroll实现竖向侧边可滚动菜单栏

Duilib的圆环形 滚动条 实现(网易云信版本)

Bootstrap 中带有子菜单的可滚动下拉菜单

js+css 圆形/环形 排列

2-环形队列-Scala实现

2-环形队列-Scala实现