圆形菜单设计与实现

0 阅读7分钟

在软件开发中,很多交互设计是采用圆形方案,这样具有良好的对称性显示。
本文主要介绍由多个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函数对点击事件进行处理。