333 lines
9.0 KiB
Kotlin
333 lines
9.0 KiB
Kotlin
/* Copyright 2023 Tusky Contributors
|
|
*
|
|
* This file is a part of Tusky.
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
|
* Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
|
* see <http://www.gnu.org/licenses>. */
|
|
|
|
package com.keylesspalace.tusky.view
|
|
|
|
import android.content.Context
|
|
import android.graphics.Canvas
|
|
import android.graphics.Paint
|
|
import android.graphics.Path
|
|
import android.graphics.PathMeasure
|
|
import android.graphics.Rect
|
|
import android.util.AttributeSet
|
|
import android.view.View
|
|
import androidx.annotation.ColorInt
|
|
import androidx.annotation.Dimension
|
|
import androidx.core.content.res.use
|
|
import com.keylesspalace.tusky.R
|
|
import kotlin.math.max
|
|
|
|
class GraphView @JvmOverloads constructor(
|
|
context: Context,
|
|
attrs: AttributeSet? = null,
|
|
defStyleAttr: Int = 0
|
|
) : View(context, attrs, defStyleAttr) {
|
|
@get:ColorInt
|
|
@ColorInt
|
|
var primaryLineColor = 0
|
|
|
|
@get:ColorInt
|
|
@ColorInt
|
|
var secondaryLineColor = 0
|
|
|
|
@get:Dimension
|
|
var lineWidth = 0f
|
|
|
|
@get:ColorInt
|
|
@ColorInt
|
|
var graphColor = 0
|
|
|
|
@get:ColorInt
|
|
@ColorInt
|
|
var metaColor = 0
|
|
|
|
private var proportionalTrending = false
|
|
|
|
private lateinit var primaryLinePaint: Paint
|
|
private lateinit var secondaryLinePaint: Paint
|
|
private lateinit var primaryCirclePaint: Paint
|
|
private lateinit var secondaryCirclePaint: Paint
|
|
private lateinit var graphPaint: Paint
|
|
private lateinit var metaPaint: Paint
|
|
|
|
private lateinit var sizeRect: Rect
|
|
private var primaryLinePath: Path = Path()
|
|
private var secondaryLinePath: Path = Path()
|
|
|
|
var maxTrendingValue: Long = 300
|
|
var primaryLineData: List<Long> = if (isInEditMode) {
|
|
listOf(
|
|
30,
|
|
60,
|
|
70,
|
|
80,
|
|
130,
|
|
190,
|
|
80
|
|
)
|
|
} else {
|
|
listOf(
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
1
|
|
)
|
|
}
|
|
set(value) {
|
|
field = value.map { max(1, it) }
|
|
primaryLinePath.reset()
|
|
invalidate()
|
|
}
|
|
|
|
var secondaryLineData: List<Long> = if (isInEditMode) {
|
|
listOf(
|
|
10,
|
|
20,
|
|
40,
|
|
60,
|
|
100,
|
|
132,
|
|
20
|
|
)
|
|
} else {
|
|
listOf(
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
1
|
|
)
|
|
}
|
|
set(value) {
|
|
field = value.map { max(1, it) }
|
|
secondaryLinePath.reset()
|
|
invalidate()
|
|
}
|
|
|
|
init {
|
|
initFromXML(attrs)
|
|
}
|
|
|
|
private fun initFromXML(attr: AttributeSet?) {
|
|
context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a ->
|
|
primaryLineColor = context.getColor(
|
|
a.getResourceId(
|
|
R.styleable.GraphView_primaryLineColor,
|
|
R.color.tusky_blue
|
|
)
|
|
)
|
|
|
|
secondaryLineColor = context.getColor(
|
|
a.getResourceId(
|
|
R.styleable.GraphView_secondaryLineColor,
|
|
R.color.tusky_red
|
|
)
|
|
)
|
|
|
|
lineWidth = a.getDimensionPixelSize(
|
|
R.styleable.GraphView_lineWidth,
|
|
R.dimen.graph_line_thickness
|
|
).toFloat()
|
|
|
|
graphColor = context.getColor(
|
|
a.getResourceId(
|
|
R.styleable.GraphView_graphColor,
|
|
R.color.colorBackground
|
|
)
|
|
)
|
|
|
|
metaColor = context.getColor(
|
|
a.getResourceId(
|
|
R.styleable.GraphView_metaColor,
|
|
R.color.dividerColor
|
|
)
|
|
)
|
|
|
|
proportionalTrending = a.getBoolean(
|
|
R.styleable.GraphView_proportionalTrending,
|
|
proportionalTrending
|
|
)
|
|
}
|
|
|
|
primaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = primaryLineColor
|
|
strokeWidth = lineWidth
|
|
style = Paint.Style.STROKE
|
|
}
|
|
|
|
primaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = primaryLineColor
|
|
style = Paint.Style.FILL
|
|
}
|
|
|
|
secondaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = secondaryLineColor
|
|
strokeWidth = lineWidth
|
|
style = Paint.Style.STROKE
|
|
}
|
|
|
|
secondaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = secondaryLineColor
|
|
style = Paint.Style.FILL
|
|
}
|
|
|
|
graphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = graphColor
|
|
}
|
|
|
|
metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = metaColor
|
|
strokeWidth = 0f
|
|
style = Paint.Style.STROKE
|
|
}
|
|
}
|
|
|
|
private fun initializeVertices() {
|
|
sizeRect = Rect(0, 0, width, height)
|
|
|
|
initLine(primaryLineData, primaryLinePath)
|
|
initLine(secondaryLineData, secondaryLinePath)
|
|
}
|
|
|
|
private fun initLine(lineData: List<Long>, path: Path) {
|
|
val max = if (proportionalTrending) {
|
|
maxTrendingValue
|
|
} else {
|
|
max(primaryLineData.max(), 1)
|
|
}
|
|
val mainRatio = height.toFloat() / max.toFloat()
|
|
|
|
val ratioedData = lineData.map { it.toFloat() * mainRatio }
|
|
|
|
val pointDistance = dataSpacing(ratioedData)
|
|
|
|
/** X coord of the start of this path segment */
|
|
var startX = 0F
|
|
|
|
/** Y coord of the start of this path segment */
|
|
var startY = 0F
|
|
|
|
/** X coord of the end of this path segment */
|
|
var endX: Float
|
|
|
|
/** Y coord of the end of this path segment */
|
|
var endY: Float
|
|
|
|
/** X coord of bezier control point #1 */
|
|
var controlX1: Float
|
|
|
|
/** X coord of bezier control point #2 */
|
|
var controlX2: Float
|
|
|
|
// Draw cubic bezier curves between each pair of points.
|
|
ratioedData.forEachIndexed { index, magnitude ->
|
|
val x = pointDistance * index.toFloat()
|
|
val y = height.toFloat() - magnitude
|
|
|
|
if (index == 0) {
|
|
path.reset()
|
|
path.moveTo(x, y)
|
|
startX = x
|
|
startY = y
|
|
} else {
|
|
endX = x
|
|
endY = y
|
|
|
|
// X-coord for a control point is placed one third of the distance between the
|
|
// two points.
|
|
val offsetX = (endX - startX) / 3
|
|
controlX1 = startX + offsetX
|
|
controlX2 = endX - offsetX
|
|
path.cubicTo(controlX1, startY, controlX2, endY, x, y)
|
|
|
|
startX = x
|
|
startY = y
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun dataSpacing(data: List<Any>) = width.toFloat() / max(data.size - 1, 1).toFloat()
|
|
|
|
override fun onDraw(canvas: Canvas?) {
|
|
super.onDraw(canvas)
|
|
|
|
if (primaryLinePath.isEmpty && width > 0) {
|
|
initializeVertices()
|
|
}
|
|
|
|
canvas?.apply {
|
|
drawRect(sizeRect, graphPaint)
|
|
|
|
val pointDistance = dataSpacing(primaryLineData)
|
|
|
|
// Vertical tick marks
|
|
for (i in 0 until primaryLineData.size + 1) {
|
|
drawLine(
|
|
i * pointDistance,
|
|
height.toFloat(),
|
|
i * pointDistance,
|
|
height - (height.toFloat() / 20),
|
|
metaPaint
|
|
)
|
|
}
|
|
|
|
// X-axis
|
|
drawLine(0f, height.toFloat(), width.toFloat(), height.toFloat(), metaPaint)
|
|
|
|
// Data lines
|
|
drawLine(
|
|
canvas = canvas,
|
|
linePath = secondaryLinePath,
|
|
linePaint = secondaryLinePaint,
|
|
circlePaint = secondaryCirclePaint,
|
|
lineThickness = lineWidth
|
|
)
|
|
drawLine(
|
|
canvas = canvas,
|
|
linePath = primaryLinePath,
|
|
linePaint = primaryLinePaint,
|
|
circlePaint = primaryCirclePaint,
|
|
lineThickness = lineWidth
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun drawLine(
|
|
canvas: Canvas,
|
|
linePath: Path,
|
|
linePaint: Paint,
|
|
circlePaint: Paint,
|
|
lineThickness: Float
|
|
) {
|
|
canvas.apply {
|
|
drawPath(
|
|
linePath,
|
|
linePaint
|
|
)
|
|
|
|
val pm = PathMeasure(linePath, false)
|
|
val coord = floatArrayOf(0f, 0f)
|
|
pm.getPosTan(pm.length * 1f, coord, null)
|
|
|
|
drawCircle(coord[0], coord[1], lineThickness * 2f, circlePaint)
|
|
}
|
|
}
|
|
}
|