自定义ViewGroup

229 阅读4分钟

自定义View是Android开发必备的技能,在日常开发中,自定义View大家可能都使用过,但是自定义ViewGroup却比较少使用,正好在工作中遇到这种需求,记录一次自定义ViewGroup的过程.

需求分析

image.png

  • 需求如上
  1. 中心关闭图标

  2. 按照角度值进行排列的子View

  3. ViewGroup正方形大小

  • 思路
  1. 子View的排列方式有两种,中心排列和一定比例的圆按角度值排列

  2. 此ViewGroup的大小判定:

    • 精确大小情况下,非中心的子View按照一定半径的圆按角度值排列

    • 非精确情况下,父Layout的size = padding + 一定半径(的圆) * 2 + 最大子View的矩形对讲线 / 2(按照角度排列的child)

流程

首先

新建类FloatCircleLayout继承ViewGroup

自定义属性定义和获取

  • 需要在values/styles.xml中,定义自定义属性
<declare-styleable name="FloatCircleLayout">
    <attr name="fcl_inner_radius_percent" format="float"/>
    <attr name="fcl_inner_radius" format="dimension|reference"/>
    <attr name="fcl_padding" format="dimension|reference"/>
</declare-styleable>
  • 获取自定义属性
/**
 * 子children排列圆的半径比,在宽高固定的情况下适用
 */
private var innerRadiusPercent = 0.61f

/**
 * 内边距,仅在大小不固定的情况下适用
 */
private var padding = 0

/**
 * 子children排列圆的半径比,在宽高不固定的情况下适用
 */
private var innerRadius = 0f

init {
    val typedArray=context.obtainStyledAttributes(attrs,R.styleable.FloatCircleLayout)
    innerRadiusPercent typedArray.getFloat(R.styleable.FloatCircleLayout_fcl_inner_radius_percent, innerRadiusPercent)
    innerRadius = typedArray.getDimension(R.styleable.FloatCircleLayout_fcl_inner_radius, innerRadius)
    padding = typedArray.getDimensionPixelSize(R.styleable.FloatCircleLayout_fcl_padding, padding)
    typedArray.recycle()
}

自定义LayoutParams-FloatCircleLayoutParams

  • 一般自定义LayoutParams需要继承ViewGroup.MarginLayoutParamsMarginLayoutParams扩展了ViewGroup.LayoutParams,具有设置外边距的功能,在我这个ViewGroup中不需要margin,所以只需要继承ViewGroup.LayoutParams

FloatCircleLayoutParams增加了一个自定义LayoutParams属性 isCenter: Boolean,表明子View是否在ViewGroup的中心,非中心的子View会按照角度值排列. FloatCircleLayoutParams定义在FloatCircleLayout

/**
 * 自有的LayoutParams
 */
open class FloatCircleLayoutParams: LayoutParams {
    // 是否在中心位置
    var isCenter = false

    // 次构造方法,需要获取自定义的LayoutParams属性
    constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) {
        val typedArray = c.obtainStyledAttributes(attrs, R.styleable.FloatCircleLayout_Layout)
        isCenter = typedArray.getBoolean(R.styleable.FloatCircleLayout_Layout_android_layout_centerInParent, false)
        typedArray.recycle()
    }

    // 宽高构造方法
    constructor(width: Int, height: Int) : super(width, height)

    // 实例构造方法
    constructor(source: LayoutParams) : super(source)
}
  • 自定义属性使用自定义命名和系统命名
<declare-styleable name="FloatCircleLayout_Layout">
	<!-- 此为自定义命名属性,这里仅做示例作用 -->
	<attr name="layout_test" format="int"/>
	<!-- 使用系统已有的属性 -->
	<attr name="android:layout_centerInParent"/>
</declare-styleable>

注意: 使用自定义LayoutParams,简历与自定义ViewGroup的联系

在FloatCircleLayout重写以下方法

    // 生成默认的LayoutParams
    override fun generateDefaultLayoutParams(): LayoutParams {
        return FloatCircleLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
    }

    // 对传入的LayoutParams进行转化
    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return FloatCircleLayoutParams(context, attrs)
    }

    // 对传入的LayoutParams进行转化
    override fun generateLayoutParams(p: LayoutParams): LayoutParams {
        return FloatCircleLayoutParams(p)
    }

    // 检查LayoutParams是否合法
    override fun checkLayoutParams(p: LayoutParams?): Boolean {
        return p is FloatCircleLayoutParams
    }

测量方法 onMeasure

  • 在FloatCircleLayout中重写测量方法,测量children和自身大小
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
	// 测量children的大小和位置
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
        if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
            // 固定大小,使用内圆半径比例, 比例大于等于1f会抛出异常
            Log.v("FloatCircleLayout", "精确模式")
            if (innerRadiusPercent >= 1f) throw IllegalArgumentException("innerRadiusPercent is illegal argument in FloatCircleLayout ")
            // 获取宽高的最小值
            val size = min(widthSize, heightSize)
            // 根据内圆比例获取内圆半径
            innerRadius = size / 2 * innerRadiusPercent
            // 遍历children,将children的测量大小设置给children的LayoutParams
            // children的测量大小大于size会抛出异常
            for (i in 0 until childCount) {
                val child = getChildAt(i)
                child.layoutParams.let {
                    it.width = child.measuredWidth
                    it.height = child.measuredHeight
                }
                val params = child.layoutParams as FloatCircleLayoutParams
                if (params.width > size || params.height > size) throw IllegalArgumentException("Please set the child component size reasonably in FloatCircleLayout ")
            }
            // 设置size为自身宽高
            setMeasuredDimension(size, size)
        } else {
            // 非固定大小,使用内圆半径
            Log.v("FloatCircleLayout", "非精确模式")
            // 遍历children,将children的测量大小设置给children的LayoutParams
            // 并算出children的矩形最大对角连
            var maxDiagonal = 0f
            for (i in 0 until childCount) {
                val child = getChildAt(i)
                child.layoutParams.let {
                    it.width = child.measuredWidth
                    it.height = child.measuredHeight
                }
                val params = child.layoutParams as FloatCircleLayoutParams
                if (!params.isCenter) {
                    maxDiagonal = max(maxDiagonal, sqrt(params.width.toFloat().pow(2) + params.height.toFloat().pow(2)))
                }
            }
            // 大小 = 内边距 + 非中心children矩形最大对角线 / 2 + children排列圆的半径
            val size = padding + maxDiagonal / 2 + innerRadius
            // 设置size * 2为自身宽高
            setMeasuredDimension(size.toInt() * 2, size.toInt() * 2)
        }
        centerX = measuredWidth / 2
        centerY = measuredHeight / 2
        clipPath.addCircle(centerX.toFloat(), centerY.toFloat(), centerX.toFloat(), Path.Direction.CCW)
    }

布局 onLayout()

  • 在FloatCircleLayout中重写布局方法,布局children
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        Log.v("FloatCircleLayout", "onLayout l:$l,t:$t,r:$r,b:$b")
        // 默认从-60开始布局children
        var currentAngle = -60f
	// 遍历children,调用View.layout(left, top, right, bottom), 布局子View矩形范围的左,上,右,下的参数
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            // 布局参数,包含大小,是否中心位置
            val params = child.layoutParams as FloatCircleLayoutParams
            Log.v("FloatCircleLayout", "child($i): ${params.width}x${params.height}, isCenter=${params.isCenter}")
            if (params.isCenter) {
		// 中心位置子View
                child.layout(centerX - params.width / 2, centerY - params.height / 2,centerX + params.width / 2, centerY + params.height / 2)
            } else {
                // 按照进度值排列的子View
                // 已知圆心,半径,角度获取圆上的点				  
                val x = (centerX + innerRadius * cos(currentAngle * PI / 180)).toInt()
                val y = (centerY + innerRadius * sin(currentAngle * PI / 180)).toInt()
                child.layout(x - params.width / 2,y - params.height / 2,x + params.width / 2,y + params.height / 2)
                // 每过60度布局一个字View
                currentAngle += 60
            }
        }
    }

小技巧,如果你想使用ViewGroup的设置背景功能(设置图片和设置颜色),又想对背景进行形状上变化。

可以重写draw(canvas)方法

 override fun draw(canvas: Canvas?) {
     canvas?.clipPath(clipPath)
     super.draw(canvas)
 }

canvas操作写在super.draw(canvas),操作会对后面的canva的绘制生效,比如背景

clipPath在onMeasure中进行了计算

使用

<com.xn.launcher.widget.FloatCircleLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/float_expand_bg">

    <com.xn.launcher.widget.ImageTextView
        android:id="@+id/itvBack"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:drawablePadding="@dimen/size18"
        app:itv_drawable="@drawable/ic_back"
        app:itv_drawable_size="@dimen/size96"
        app:itv_drawable_overlay="@drawable/shape_itv_overlay_circle"
        android:text="@string/back"
        android:gravity="center_horizontal"
        android:textColor="@color/text1"
        android:textSize="@dimen/text33"/>

    <com.xn.launcher.widget.ImageTextView
        android:id="@+id/itvMore"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:drawablePadding="@dimen/size18"
        app:itv_drawable="@drawable/ic_more"
        app:itv_drawable_size="@dimen/size96"
        app:itv_drawable_overlay="@drawable/shape_itv_overlay_circle"
        android:text="@string/more"
        android:gravity="center_horizontal"
        android:textColor="@color/text1"
        android:textSize="@dimen/text33"/>

    <com.xn.launcher.widget.ImageTextView
        android:id="@+id/itvTasks"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:drawablePadding="@dimen/size18"
        app:itv_drawable="@drawable/ic_tasks"
        app:itv_drawable_size="@dimen/size96"
        app:itv_drawable_overlay="@drawable/shape_itv_overlay_circle"
        android:text="@string/tasks"
        android:gravity="center_horizontal"
        android:textColor="@color/text1"
        android:textSize="@dimen/text33"/>

    <com.xn.launcher.widget.ImageTextView
        android:id="@+id/itvExpressWb"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:drawablePadding="@dimen/size18"
        app:itv_drawable="@drawable/ic_express_wb"
        app:itv_drawable_size="@dimen/size96"
        app:itv_drawable_overlay="@drawable/shape_itv_overlay_circle"
        android:text="@string/express_wb"
        android:gravity="center_horizontal"
        android:textColor="@color/text1"
        android:textSize="@dimen/text33"/>

    <com.xn.launcher.widget.ImageTextView
        android:id="@+id/itvAnnotation"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:drawablePadding="@dimen/size18"
        app:itv_drawable="@drawable/ic_annotation"
        app:itv_drawable_size="@dimen/size96"
        app:itv_drawable_overlay="@drawable/shape_itv_overlay_circle"
        android:text="@string/annotation"
        android:gravity="center_horizontal"
        android:textColor="@color/text1"
        android:textSize="@dimen/text33"/>

    <com.xn.launcher.widget.ImageTextView
        android:id="@+id/itvHome"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:drawablePadding="@dimen/size18"
        app:itv_drawable="@drawable/ic_home"
        app:itv_drawable_size="@dimen/size96"
        app:itv_drawable_overlay="@drawable/shape_itv_overlay_circle"
        android:text="@string/home"
        android:gravity="center_horizontal"
        android:textColor="@color/text1"
        android:textSize="@dimen/text33"/>

    <ImageView
        android:id="@+id/ivClose"
        android:layout_width="@dimen/size189"
        android:layout_height="@dimen/size189"
        android:src="@mipmap/float_close"
        android:layout_centerInParent="true"
        android:contentDescription="@string/todo"/>
</com.xn.launcher.widget.FloatCircleLayout>

预览效果

image.png