第一次写这种东西emmm写错的地方求轻diss
GPA2 自定义View
GPALineChartView
整体布局:
看上去很ok!
注释里可大致知道paddingLeft,paddingRight,maxScroreExtended,minScoreExtended等参数的比例,作为接下来的参考。
各种参数:
companion object {
// fixed constant
const val LINE_STROKE = 16F
const val POINT_RADIUS = 24F
const val SELECTED_POINT_RADIUS = 28F
const val SELECTED_POINT_STROKE_WIDTH = 20F
const val SELECTED_POINT_STROKE_COLOR = Color.WHITE
const val POPUP_BOX_COLOR = Color.WHITE
const val POPUP_BOX_TRI_WIDTH = 80F
const val POPUP_BOX_TRI_HEIGHT = 40F
const val POPUP_BOX_RECT_WIDTH = 320F
const val POPUP_BOX_RECT_HEIGHT = 320F
const val POPUP_BOX_RECT_ROUND_RADIUS = 16F
const val POPUP_BOX_MARGIN = 40F
const val POPUP_BOX_PADDING = 40F
const val DETAILS_TEXT_SIZE = 36F
const val SHADOW_RADIUS = 16F
const val SHADOW_COLOR = 0x66666666
// default constant for attrs
const val DEFAULT_LINE_COLOR = 0xFFEC826A.toInt()
const val DEFAULT_FILL_COLOR = 0xFFF3AB9B.toInt()
const val DEFAULT_POINT_COLOR = 0xFFEC826A.toInt()
}
好处(个人理解)
1.将各种参数存为常量,在后面统一使用常量,如果要更改,只需改动一处,便于维护。
Paint准备
设置各种各样的画笔,在需要的时候调用它们
private val linePaint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = LINE_STROKE
setShadowLayer(SHADOW_RADIUS, 0F, 0F, SHADOW_COLOR)
isAntiAlias = true
}
以linePaint为例,设置画笔的参数分别为:
style = Paint.Style.STROKE //style设置为勾边模式
strokeWidth = LINE_STROKE //线条的宽度设置为LINE_STROKE
setShadowLayer(SHADOW_RADIUS, 0F, 0F, SHADOW_COLOR) //设置阴影层
isAntiAlias = true //开启抗锯齿模式
顺便也学习到了kotlin的语法糖——apply函数
就比如上面的代码,用Paint().apply直接生成了一个参数为(此处省去n字)的Paint对象,比Java简洁。而且因为它再一个代码块里,所以更有紧凑感(词穷...)。阅读性很棒。
3个Color&&context.obtainStyledAttributes
var lineColor
get() = linePaint.color
set(value) {
linePaint.color = value
}
var fillColor
get() = fillPaint.color
set(value) {
fillPaint.color = value
}
var pointColor
get() = pointPaint.color
set(value) {
pointPaint.color = value
selectedPointPaint.color = value
}
context.obtainStyledAttributes(attrs, R.styleable.GpaLineChartView, defStyle, 0).apply {
lineColor = getColor(R.styleable.GpaLineChartView_lineColor, DEFAULT_LINE_COLOR)
fillColor = getColor(R.styleable.GpaLineChartView_fillColor, DEFAULT_FILL_COLOR)
pointColor = getColor(R.styleable.GpaLineChartView_pointColor, DEFAULT_POINT_COLOR)
recycle()
}
这两端代码看的我一脸懵逼,看完网上的一些教程也有点摸不着头脑
模糊的理解是为这个自定义View设置属性值,而lineColor、fillColor、pointColor对应着R.styleable.GpaLineChartView中的三个Color。。。
但是我觉得自己这个解释对于apply好像并不行得通。。。于是对于这里我有一个问题:
context.obtainStyledAttributes是返回了一个TypedArray,但是TypedArray里面并没有lineColor、fillColor、pointColor这三个参数,也没有set方法,那代码块里面的东西有什么作用呢???求解答
接收的数据类和一些参数
data class DataWithDetail(val data: Double, val detail: String)
数据类,包括一个Double和一个String。
//语法糖:data class:自动生成setter和getter方法的类,可用于接受网络请求GET到的数据,舒服。
var dataWithDetail: List = emptyList()
set(value) {
field = value
selectedIndex = selectedIndex // ha?
invalidate()
}
var selectedIndex = 0
set(value) {
field = value.coerceIn(if (dataWithDetail.isNotEmpty()) dataWithDetail.indices else 0..0)
invalidate()
}
两个参数,一个是返回数据的一个list,一个是选中的参数。
还有一个invalidate()函数,当检测到数据变化的时候,可以调用此函数来重绘自定义View,个人感觉有点像notifiedDataChanged()
//又是语法糖hhh,这回还不止一个
1)在参数下直接写set(value)={}或get()={}来为类中的属性值写setter和getter方法,
在set(value)中,可直接用field来代表属性值。
2)coerceIn:
@return this value if it's in the [range], or range.start
if this value is less than range.start
, or range.endInclusive
if this value is greater than range.endInclusive
.
这是源码的注解,嗯大概的意思是对于一个给定的范围,在范围内的会保持原来的值,比最大的大就返回最大值,比最小的小就返回最小值,可以有效的防止越界。
Path准备和计算
path的准备工作
private val linePath = Path()
private val fillPath = Path()
private val pointPath = Path()
private val selectedPointPath = Path()
private val popupBoxPath = Path()
嗯,总共五个Path,分别是曲线的Path、填充颜色的Path、GPA分数点的Path、被选中点的Path和点击分数以后弹出的Box的Path,在后面的computePath函数中进行计算。
val contentWidth = width - paddingLeft - paddingRight
val contentHeight = height - paddingTop - paddingBottom
val widthStep = contentWidth.toFloat() / (dataWithDetail.size + 1)
val minData = dataWithDetail.minBy(DataWithDetail::data)?.data ?: 0.0
val maxData = dataWithDetail.maxBy(DataWithDetail::data)?.data ?: 1.0
val dataSpan = if (maxData != minData) maxData - minData else 1.0
val minDataExtended = minData - dataSpan / 4F
val maxDataExtended = maxData + dataSpan / 4F
val dataSpanExtended = maxDataExtended - minDataExtended
(0 until dataWithDetail.size).mapTo(pointsX.apply { clear() }) {
paddingLeft + widthStep * (it + 1)
}
dataWithDetail.mapTo(pointsY.apply { clear() }) {
paddingTop + ((1 - ((it.data - minDataExtended) / dataSpanExtended)) * contentHeight).toFloat()
}
嗯这是一些参数,但是mapTo是干什么的有点没看懂...留着待解答
path的计算
linePath:
linePath.apply {
reset()
var py = (paddingTop + contentHeight).toFloat()
moveTo(0F, py)
(0 until dataWithDetail.size).forEach {
val cx = pointsX[it] - widthStep / 2F
cubicTo(cx, py, cx, pointsY[it], pointsX[it], pointsY[it])
py = pointsY[it] }
val cx = width - widthStep / 2F
cubicTo(cx, py, cx, paddingTop.toFloat(), width.toFloat(), paddingTop.toFloat())
}
使用moveTo函数将起点移到(0f,py)这个点(左边边界上的一个点),对于dataWithDetail,没有数据就调用一次cubicTo函数画出一条曲线;如果只有一个数据,以(0f,py)为起点,使用cubicTo函数传入计算后的控制点和终点绘画贝塞尔曲线;如果多个数据,第一个数据如上,其余每个数据以前一个数据绘制后的终点作为起点,再调用cubicTo函数进行绘制。所有数据绘制完毕后,再计算两个控制点和终点,绘制一个贝塞尔曲线收尾。
Kotlin语法糖:
(0 until dataWithDetail.size).foreach{
// do something with "it"
}
//对范围(左开右闭)内的(Int类型数字)进行迭代。
fillPath
fillPath.apply {
reset()
addPath(linePath)
lineTo(width.toFloat(), height.toFloat())
lineTo(0F, height.toFloat())
close()
}
使用addPath把计算好的linePath加进来,
用两个lineTo和close把linePath完善为一个以linePath为曲边的曲边梯形。
pointPath&&selectedPiontPath
pointPath:
pointPath.apply {
reset()
if (dataWithDetail.isEmpty())
return@apply
(0 until dataWithDetail.size)//
.filter { it != selectedIndex }
.forEach {
addCircle(
pointsX[it] - LINE_STROKE / 4F,
pointsY[it] - LINE_STROKE / 4F,
POINT_RADIUS,
Path.Direction.CCW
)
}
}
如果dataWithDetail为空,证明没有成绩点,无需画点
对于所有的数据,用.filter进行筛选,选出没被选中的点,对这些点进行一个实现圆心效果的操作:
.forEach {
addCircle(//加一个比圆点小一号的圆
pointsX[it] - LINE_STROKE / 4F,
pointsY[it] - LINE_STROKE / 4F,
POINT_RADIUS,
Path.Direction.CCW
)
}
selectedPointPath:
selectedPointPath.apply {
reset()
if (dataWithDetail.isEmpty())
return@apply // no need to draw
addCircle(
pointsX[selectedIndex] - LINE_STROKE / 4F,
pointsY[selectedIndex] - LINE_STROKE / 4F,
SELECTED_POINT_RADIUS,
Path.Direction.CCW
)
}
和ponitPath实现效果的操作基本一样,Circle的半径较大。
popupBoxPath
popupBoxPath.apply {
reset()
if (dataWithDetail.isEmpty())
return@apply // no need to draw
val triCenter = pointsX[selectedIndex]
val triTop = pointsY[selectedIndex] + SELECTED_POINT_RADIUS + POPUP_BOX_MARGIN
moveTo(triCenter, triTop)
lineTo(triCenter - POPUP_BOX_TRI_WIDTH / 2F, triTop + POPUP_BOX_TRI_HEIGHT)
lineTo(triCenter + POPUP_BOX_TRI_WIDTH / 2F, triTop + POPUP_BOX_TRI_HEIGHT)
close()
val rectCenter =
when {
triCenter - POPUP_BOX_RECT_WIDTH / 2F < POPUP_BOX_MARGIN -> POPUP_BOX_MARGIN + POPUP_BOX_RECT_WIDTH / 2F
triCenter + POPUP_BOX_RECT_WIDTH / 2F > width - POPUP_BOX_MARGIN -> width - POPUP_BOX_MARGIN - POPUP_BOX_RECT_WIDTH / 2F
else -> triCenter
}
val rectTop = triTop + POPUP_BOX_TRI_HEIGHT
detailTextLayout = StaticLayout(
dataWithDetail[selectedIndex].detail,
detailsTextPaint,
(POPUP_BOX_RECT_WIDTH - POPUP_BOX_PADDING * 2).toInt(),
Layout.Alignment.ALIGN_NORMAL,
1.75F,
0F,
true
).also {
detailTextLeft = rectCenter - it.width / 2F
detailTextTop = rectTop + POPUP_BOX_PADDING
}
val rectHeight = detailTextLayout?.height?.toFloat() ?: POPUP_BOX_RECT_HEIGHT
addRoundRect(
RectF(rectCenter - POPUP_BOX_RECT_WIDTH / 2F,
rectTop,
rectCenter + POPUP_BOX_RECT_WIDTH / 2F,
rectTop + rectHeight + POPUP_BOX_PADDING * 2F),
POPUP_BOX_RECT_ROUND_RADIUS,
POPUP_BOX_RECT_ROUND_RADIUS,
Path.Direction.CCW
)
}
}
好长的一段代码啊!让我们把它来分成三部分:
1.数据判空:
if (dataWithDetail.isEmpty())
return@apply // no need to draw
如果没有数据就不需要进行绘制了
2.对话框的实现(自定义view部分):
1)对话框的小三角(等腰):
val triCenter = pointsX[selectedIndex]
val triTop = pointsY[selectedIndex] + SELECTED_POINT_RADIUS + POPUP_BOX_MARGIN
moveTo(triCenter, triTop)
lineTo(triCenter - POPUP_BOX_TRI_WIDTH / 2F, triTop + POPUP_BOX_TRI_HEIGHT)
lineTo(triCenter + POPUP_BOX_TRI_WIDTH / 2F, triTop + POPUP_BOX_TRI_HEIGHT)
close()
计算出了这个三角的顶点、高度和底边宽度,用lineto画出两条线再调用close()封闭成一个三角形。
2)圆角矩形的绘制:
addRoundRect(
RectF(rectCenter - POPUP_BOX_RECT_WIDTH / 2F,
rectTop,
rectCenter + POPUP_BOX_RECT_WIDTH / 2F,
rectTop + rectHeight + POPUP_BOX_PADDING * 2F),
POPUP_BOX_RECT_ROUND_RADIUS,
POPUP_BOX_RECT_ROUND_RADIUS,
Path.Direction.CCW
)
emmmm,就是一个addRoundRect函数,里面传进去了计算好的参数。
3.对话框的实现(StaticLayout部分)
这个说实话有点出乎我的预料。我原以为会用Paint.drawText之类的方法但是并没有,这是我没有接触过的船新版本,挤需三分钟(扯淡)我就爱上了节款Layout
detailTextLayout = StaticLayout(
dataWithDetail[selectedIndex].detail,
detailsTextPaint,
(POPUP_BOX_RECT_WIDTH - POPUP_BOX_PADDING * 2).toInt(),
Layout.Alignment.ALIGN_NORMAL,
1.75F,
0F,
true
).also {
detailTextLeft = rectCenter - it.width / 2F
detailTextTop = rectTop + POPUP_BOX_PADDING
}
具体的原因呢emmmm,查了一下是因为drawText不能自动换行,所以要用StaticLayout来实现(涨姿势)
//语法糖:when
val rectCenter =
when {
triCenter - POPUP_BOX_RECT_WIDTH / 2F < POPUP_BOX_MARGIN -> POPUP_BOX_MARGIN + POPUP_BOX_RECT_WIDTH / 2F
triCenter + POPUP_BOX_RECT_WIDTH / 2F > width - POPUP_BOX_MARGIN -> width - POPUP_BOX_MARGIN - POPUP_BOX_RECT_WIDTH / 2F
else -> triCenter
}
这个语法糖真的是让我爱不释手,配合lambda表达式,去除了冗杂的else if语句,代码简洁而不失可读性,简直爽的不行。
绘制
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
computePath()
canvas.apply {
// disable hardware acceleration for a perfect display of shadows
if (isHardwareAccelerated) setLayerType(View.LAYER_TYPE_SOFTWARE, null)
drawPath(linePath, linePaint)
drawPath(fillPath, fillPaint)
drawPath(pointPath, pointPaint)
drawPath(selectedPointPath, selectedPointStrokePaint)
drawPath(selectedPointPath, selectedPointPaint)
drawPath(popupBoxPath, popupBoxPaint)
save()
translate(detailTextLeft, detailTextTop)
detailTextLayout?.draw(canvas)
restore()
}
}
重写OnDraw方法,调用cumputePath()函数,然后用canvas.apply进行如下操作——
if (isHardwareAccelerated) setLayerType(View.LAYER_TYPE_SOFTWARE, null)//关闭硬件加速
嗯翻译一遍注释hhh:为了完美的阴影效果,我们要添加这行代码来关闭硬件加速
drawPath(linePath, linePaint)
drawPath(fillPath, fillPaint)
drawPath(pointPath, pointPaint)
drawPath(selectedPointPath, selectedPointStrokePaint)
drawPath(selectedPointPath, selectedPointPaint)
drawPath(popupBoxPath, popupBoxPaint)
咳咳,最重要的时刻了!!!养兵千日用兵一时,用我们备好的point和path一一对应然后进行绘制!
save()
translate(detailTextLeft, detailTextTop)
detailTextLayout?.draw(canvas)
restore()
完善对话框。
结束语
自定义View可以使我们突破Android自带控件的限制,更精确的还原设计人员所提需求。可以说自定义VIew绘制最重要的是以下三点:
1.对于不同的需求设置不同参数的Paint(画笔)
可以用Paint().aplly{
//参数设置
}生成
2.对于一些不规则的形状,要事先计算Path.
可以使用Path().apply{
//计算参数
//设置Path
}
3.重写onDraw方法,用drawXXX()方法来绘制自定义View
下期是GPA2模块中对网络请求的封装处理
喜欢就点个赞哦~23333