WeiPeiYang阅读笔记(1)GPA2模块自定义View

第一次写这种东西emmm写错的地方求轻diss

GPA2 自定义View

GPALineChartView

整体布局:
WeiPeiYang阅读笔记(1)GPA2模块自定义View_第1张图片

看上去很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.将各种参数存为常量,在后面统一使用常量,如果要更改,只需改动一处,便于维护。

2.实现某些比例关系(如下)
WeiPeiYang阅读笔记(1)GPA2模块自定义View_第2张图片

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

你可能感兴趣的:(WeiPeiYang阅读笔记(1)GPA2模块自定义View)