自定义View是Android开发必备的技能,在日常开发中,自定义View大家可能都使用过,但是自定义ViewGroup却比较少使用,正好在工作中遇到这种需求,记录一次自定义ViewGroup的过程.
需求分析
- 需求如上
-
中心关闭图标
-
按照角度值进行排列的子View
-
ViewGroup正方形大小
- 思路
-
子View的排列方式有两种,中心排列和一定比例的圆按角度值排列
-
此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.MarginLayoutParams,MarginLayoutParams扩展了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>