在软件开发中,很多交互设计是采用圆形方案,这样具有良好的对称性显示。
本文主要介绍由多个ImageView组成的圆形菜单的实现。
1,首先定义这个ImageView
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_launcher"
/>
</FrameLayout>
2, 定义展示圆形菜单的类
// 定义了 CircleMenuView 类,它继承自 ViewGroup。
// @JvmOverloads 注解让 Kotlin 编译器为构造函数生成多个重载版本,以适配 Java 代码。
// 构造函数接收 Context、AttributeSet 和 defStyleAttr 三个参数。
class CircleMenuView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
companion object {
private const val TAG = "CircleMenuView"
// 该容器内 child item 的默认尺寸
private const val DEFAULT_CHILD_DIMENSION = 1 / 4f
private const val CIRCLE_BACKGROUND_SIZE = 1 / 1.3f
// 该容器的内边距,无视 padding 属性 , 如需边距请用该变量
private const val PADDING_LAYOUT = 1 / 12f
}
// 圆形直径
private var mRadius = 0
private var mSelfWidth = 0
private val context = getContext()
private var mPadding = 0f
// 布局时的开始角度
private var mStartAngle = 0.0
// 菜单项的图标
private var mItemImages: IntArray? = null
// 菜单的个数
private var mMenuCount = 0
// 菜单布局资源 id
@LayoutRes
private val mMenuItemLayoutId = R.layout.circle_menu_item
private var menuViewCallback: CircleMenuViewCallback? = null
// 设置 CircleMenuViewCallback 回调接口,以便在菜单项被点击时通知外部
fun setMenuViewCallback(menuViewCallback: CircleMenuViewCallback) {
this.menuViewCallback = menuViewCallback
}
fun setMenuItemIcons(images: IntArray) {
Log.v(TAG, "setMenuItemIcons...")
if (images.isEmpty()) {
throw IllegalArgumentException("Set one item at least.")
}
mItemImages = images
mMenuCount = images.size
buildMenuItems()
}
// 该方法用于构建菜单项。通过循环遍历菜单数量,调用 inflateMenuView 方法创建菜单项视图,调用 initMenuItem 方法初始化菜单项,最后将菜单项视图添加到 CircleMenuView 中。
private fun buildMenuItems() {
Log.v(TAG, "buildMenuItems...mMenuCount=$mMenuCount")
for (i in 0 until mMenuCount) {
val itemView = inflateMenuView(i)
initMenuItem(itemView, i)
addView(itemView)
}
}
// 此方法用于根据布局资源 ID 填充菜单项视图。设置菜单项的 ID 为索引值,并为其设置点击监听器。
private fun inflateMenuView(index: Int): View {
val inflater = LayoutInflater.from(context)
val itemView = inflater.inflate(mMenuItemLayoutId, this, false)
itemView.id = index
itemView.setOnClickListener(clickListener)
return itemView
}
// 定义了一个点击监听器,当菜单项被点击时,调用 menuViewCallback 的 menuItemClicked 方法。
private val clickListener = View.OnClickListener { v ->
Log.v(TAG, "clickListener...id=${v.id}")
menuViewCallback?.menuItemClicked(v)
}
// 该方法用于初始化菜单项,从菜单项视图中获取 ImageView,并为 ImageView 设置对应的图标资源。
private fun initMenuItem(itemView: View, index: Int) {
if (itemView == null) {
return
}
Log.v(TAG, "initMenuItem...,index=$index,itemView=$itemView")
val iv = itemView.findViewById<ImageView>(R.id.imageview)
iv.setImageResource(mItemImages!![index])
}
// 重写 onMeasure 方法,用于测量 CircleMenuView 自身和子视图的尺寸。
// 调用 measureSelf 方法测量自身尺寸,调用 measureChildViews 方法测量子视图尺寸。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
Log.v(TAG, "onMeasure...")
measureSelf(widthMeasureSpec, heightMeasureSpec)
measureChildViews(widthMeasureSpec, heightMeasureSpec)
}
// measureSelf 方法的主要功能是根据父视图传递的测量规格和背景图的设置情况,确定 CircleMenuView 自身的宽度和高度,并将测量结果进行设置。
// 该方法用于测量 CircleMenuView 自身的尺寸。根据测量模式的不同,计算最终的宽度和高度,并调用 setMeasuredDimension 方法设置测量结果。
// 两个参数代表了父视图传递给当前视图的宽度和高度测量规格。
private fun measureSelf(widthMeasureSpec: Int, heightMeasureSpec: Int) {
Log.v(TAG, "measureSelf...widthMeasureSpec=$widthMeasureSpec,widthMeasureSpec=$heightMeasureSpec")
// 声明两个变量 resWidth 和 resHeight,并初始化为 0。这两个变量用于存储最终测量得到的宽度和高度。
var resWidth = 0
var resHeight = 0
// MeasureSpec 是 Android 中用于封装父视图传递给子视图的布局要求的类。
// MeasureSpec.getSize 方法用于从测量规格中提取出建议的尺寸大小。
val width = MeasureSpec.getSize(widthMeasureSpec)
// MeasureSpec.getMode 方法用于从测量规格中提取出测量模式。
// 测量模式有三种:MeasureSpec.EXACTLY(精确值)、MeasureSpec.AT_MOST(最大值)和 MeasureSpec.UNSPECIFIED(未指定)。
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
Log.v(TAG, "measureSelf...width=$width,height=$height")
Log.v(TAG, "measureSelf...,widthMode=$widthMode,heightMode=$heightMode")
// 将 mSelfWidth 赋值为宽度和高度中的最大值,mSelfWidth 可能在后续计算中会被使用。
mSelfWidth = Math.max(width, height)
// 如果宽或者高的测量模式非精确值
if (widthMode != MeasureSpec.EXACTLY || heightMode != MeasureSpec.EXACTLY) {
// 如果满足条件,说明父视图没有明确指定当前视图的尺寸,需要根据其他规则来确定尺寸。
// 设置为背景图的宽高
// suggestedMinimumWidth 和 suggestedMinimumHeight 是 View 类的属性,
// 它们表示视图在没有设置背景图时的最小宽度和高度,若设置了背景图,则为背景图的宽度和高度。
resWidth = suggestedMinimumWidth
Log.v(TAG, "measureSelf...,resWidth=$resWidth")
resHeight = suggestedMinimumHeight
Log.v(TAG, "measureSelf...,resHeight=$resHeight")
// 如果未设置背景图,则设置为屏幕宽高的默认值
// 这两行代码使用三元运算符进行判断,如果 resWidth 或 resHeight 为 0,说明没有设置背景图,调用 getDefaultWidth 方法获取默认宽度,并将其赋值给 resWidth 或 resHeight。
resWidth = if (resWidth == 0) getDefaultWidth() else resWidth
resHeight = if (resHeight == 0) getDefaultWidth() else resHeight
} else {
// 如果宽度和高度的测量模式都是精确值,说明父视图已经明确指定了当前视图的尺寸,将 resWidth 和 resHeight 赋值为宽度和高度中的最小值。
resWidth = Math.min(width, height)
resHeight = resWidth
Log.v(TAG, "measureSelf...,else,resWidth=$resWidth")
}
// 调用 setMeasuredDimension 方法,将最终测量得到的宽度和高度设置给当前视图,完成自身尺寸的测量。
setMeasuredDimension(resWidth, resHeight)
}
// 获取默认宽度
private fun getDefaultWidth(): Int {
val bgSize = CIRCLE_BACKGROUND_SIZE
return (mSelfWidth * bgSize).toInt()
}
// 该方法用于测量子视图的尺寸。根据 CircleMenuView 的测量宽度计算半径和子视图的尺寸,然后遍历所有子视图,为每个可见的子视图设置测量规格并进行测量。最后计算内边距。
// 两个参数:widthMeasureSpec 和 heightMeasureSpec,这两个参数是父视图传递给当前视图的宽度和高度测量规格。
private fun measureChildViews(widthMeasureSpec: Int, heightMeasureSpec: Int) {
Log.v(TAG, "measureChildViews...")
// MeasureSpec.getSize 方法从 widthMeasureSpec 和 heightMeasureSpec 里提取出父视图建议的宽度和高度大小。
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
Log.d(TAG, "measureChildViews...width=$width,height=$height")
// measuredWidth 是当前视图自身测量后的宽度。这里把它赋值给 measuredWidth1 变量
val measuredWidth1 = measuredWidth
Log.d(TAG, "measureChildViews...measuredWidth1=$measuredWidth1")
// 把当前视图的测量宽度赋值给 mRadius 变量,此变量代表圆形菜单的半径。
mRadius = measuredWidth1
Log.d(TAG, "measureChildViews...mRadius=$mRadius")
// childCount 是 ViewGroup 类的属性,它表示当前视图组里子视图的数量。
val count = childCount
Log.d(TAG, "measureChildViews...count=$count")
// DEFAULT_CHILD_DIMENSION 是一个预定义的常量,代表子视图尺寸与半径的比例。
// 计算出子视图的尺寸,将结果转换为整数类型并赋值给 childSize 变量。
val childSize = (mRadius * DEFAULT_CHILD_DIMENSION).toInt()
Log.d(TAG, "measureChildViews...childSize=$childSize")
// menu item 测量模式,设定子视图的测量模式为 MeasureSpec.EXACTLY,
// 这意味着子视图的尺寸必须严格按照给定的值来确定。
val childMode = MeasureSpec.EXACTLY
// 迭代测量,遍历当前视图组里的所有子视图。
for (i in 0 until count) {
val child = getChildAt(i)
if (child.visibility == View.GONE) {
Log.v(TAG, "measureChildViews...GONE,continue...")
continue
}
// 计算 menu item 的尺寸 , 以及设置好的模式,去对 item 进行测量
// MeasureSpec.makeMeasureSpec 方法依据子视图的尺寸和测量模式创建测量规格。
val makeMeasureSpec = MeasureSpec.makeMeasureSpec(childSize, childMode)
// 调用子视图的 measure 方法,传入宽度和高度的测量规格,对该子视图进行测量。
child.measure(makeMeasureSpec, makeMeasureSpec)
}
// PADDING_LAYOUT 是一个预定义的常量,代表内边距与半径的比例。
// 计算出内边距的值并赋值给 mPadding 变量。
mPadding = PADDING_LAYOUT * mRadius
}
// 重写 onLayout 方法,用于布局子视图。根据子视图的数量计算每个子视图的布局角度,然后遍历所有子视图,根据角度计算子视图的位置并调用 layout 方法进行布局。
// 参数 changed 表示布局是否发生了变化;l、t、r、b 分别代表父视图相对于其父容器的左、上、右、下边界的位置。
// onLayout 方法的核心逻辑是通过循环遍历所有子视图,根据圆形布局的原理,使用三角函数计算每个子视图的位置,并调用子视图的 layout 方法将其放置在正确的位置上。这样就实现了菜单项以圆形布局的方式排列在父视图内的效果。
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
Log.v(TAG, "onLayout...")
val childCount = childCount
var left: Int
var top: Int
// menu item 的尺寸
// 根据之前计算得到的圆形菜单半径 mRadius 和预定义的常量 DEFAULT_CHILD_DIMENSION 计算每个菜单项的宽度,并将结果转换为整数类型。
val itemWidth = (mRadius * DEFAULT_CHILD_DIMENSION).toInt()
// 根据 menuItem 的个数 计算 item 的布局占用的角度
// 计算每个菜单项在圆形布局中所占用的角度。通过将 360 度平均分配给所有菜单项,得到每个菜单项之间的角度间隔。
val angleDelay = 360f / childCount
// 遍历所有菜单 设置它们的位置
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility == View.GONE) {
continue
}
// 菜单的起始角度
// 确保 mStartAngle 的值在 0 到 360 度之间。通过取模运算,将 mStartAngle 限制在这个范围内,避免角度值超出正常范围。
mStartAngle %= 360
// 计算中心点到 menuItem 中心的距离
// 计算从圆形菜单的中心点到每个菜单项中心的距离。该距离考虑了圆形菜单的半径、菜单项的宽度以及内边距 mPadding。
val distanceFromCenter = mRadius / 2f - itemWidth / 2f - mPadding
// 根据三角函数的原理,使用 Math.cos 和 Math.sin 方法计算菜单项左上角的 left 和 top 坐标。
// Math.toRadians 方法将角度值转换为弧度值,然后通过 Math.cos 和 Math.sin 计算出在圆形上的坐标偏移量,再结合中心点坐标和菜单项宽度,得到最终的 left 和 top 坐标。
left = (mRadius / 2 + Math.round(distanceFromCenter * Math.cos(Math.toRadians(mStartAngle))) - itemWidth / 2).toInt()
top = (mRadius / 2 + Math.round(distanceFromCenter * Math.sin(Math.toRadians(mStartAngle))) - itemWidth / 2).toInt()
// 调用子视图的 layout 方法,将计算得到的 left、top 坐标以及对应的 right(left + itemWidth)、bottom(top + itemWidth)坐标传递给子视图,从而确定子视图在父视图中的具体位置。
child.layout(left, top, left + itemWidth, top + itemWidth)
// 每次处理完一个子视图后,将起始角度 mStartAngle 增加一个角度间隔 angleDelay,以便下一个子视图能够按照圆形布局依次排列。
mStartAngle += angleDelay
}
}
// 定义了一个接口 CircleMenuViewCallback,包含一个 menuItemClicked 方法,用于在菜单项被点击时通知外部。
interface CircleMenuViewCallback {
fun menuItemClicked(view: View)
}
}
3,在布局文件中调用定义的类
<com.eric.CircleMenuView
android:id="@+id/circle_menu_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true"
/>
4,之后在类文件中,先后调用setMenuItemIcons函数传入组成圆形菜单的图标资源,并可以调用setMenuViewCallback函数对点击事件进行处理。