This commit is contained in:
coco
2026-07-03 15:56:07 +08:00
commit caef23209c
5767 changed files with 1004268 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+26
View File
@@ -0,0 +1,26 @@
plugins {
id("com.android.library")
kotlin("android")
`maven-publish`
}
apply(from = rootProject.file("gradle/configure-android.gradle"))
apply(from = rootProject.file("gradle/configure-compose.gradle"))
apply(from = rootProject.file("gradle/jitpack-publish.gradle"))
dependencies {
api(project(":lib:common"))
implementation(Kotlin.stdLib)
implementation(Compose.animation)
implementation(Compose.core)
implementation(Compose.layout)
implementation(Compose.foundation)
implementation(Compose.material)
implementation(Compose.runtime)
debugImplementation(Compose.tooling)
}
android {
namespace = "com.github.tehras.charts.bar"
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,124 @@
package com.github.tehras.charts.bar
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import com.github.tehras.charts.bar.BarChartUtils.axisAreas
import com.github.tehras.charts.bar.BarChartUtils.barDrawableArea
import com.github.tehras.charts.bar.BarChartUtils.forEachWithArea
import com.github.tehras.charts.bar.renderer.bar.BarDrawer
import com.github.tehras.charts.bar.renderer.bar.SimpleBarDrawer
import com.github.tehras.charts.bar.renderer.label.LabelDrawer
import com.github.tehras.charts.bar.renderer.label.SimpleValueDrawer
import com.github.tehras.charts.bar.renderer.xaxis.SimpleXAxisDrawer
import com.github.tehras.charts.bar.renderer.xaxis.XAxisDrawer
import com.github.tehras.charts.bar.renderer.yaxis.SimpleYAxisDrawer
import com.github.tehras.charts.bar.renderer.yaxis.YAxisDrawer
import com.github.tehras.charts.piechart.animation.simpleChartAnimation
@Composable
fun BarChart(
barChartData: BarChartData,
modifier: Modifier = Modifier,
animation: AnimationSpec<Float> = simpleChartAnimation(),
barDrawer: BarDrawer = SimpleBarDrawer(),
xAxisDrawer: XAxisDrawer = SimpleXAxisDrawer(),
yAxisDrawer: YAxisDrawer = SimpleYAxisDrawer(),
labelDrawer: LabelDrawer = SimpleValueDrawer()
) {
val transitionAnimation = remember(barChartData.bars) { Animatable(initialValue = 0f) }
LaunchedEffect(barChartData.bars) {
transitionAnimation.animateTo(1f, animationSpec = animation)
}
val progress = transitionAnimation.value
Canvas(modifier = modifier
.fillMaxSize()
.drawBehind {
drawIntoCanvas { canvas ->
val (xAxisArea, yAxisArea) = axisAreas(
drawScope = this,
totalSize = size,
xAxisDrawer = xAxisDrawer,
labelDrawer = labelDrawer
)
val barDrawableArea = barDrawableArea(xAxisArea)
// Draw yAxis line.
yAxisDrawer.drawAxisLine(
drawScope = this,
canvas = canvas,
drawableArea = yAxisArea
)
// Draw xAxis line.
xAxisDrawer.drawAxisLine(
drawScope = this,
canvas = canvas,
drawableArea = xAxisArea
)
// Draw each bar.
barChartData.forEachWithArea(
this,
barDrawableArea,
progress,
labelDrawer
) { barArea, bar ->
barDrawer.drawBar(
drawScope = this,
canvas = canvas,
barArea = barArea,
bar = bar
)
}
}
}
) {
/**
* Typically we could draw everything here, but because of the lack of canvas.drawText
* APIs we have to use Android's `nativeCanvas` which seems to be drawn behind
* Compose's canvas.
*/
drawIntoCanvas { canvas ->
val (xAxisArea, yAxisArea) = axisAreas(
drawScope = this,
totalSize = size,
xAxisDrawer = xAxisDrawer,
labelDrawer = labelDrawer
)
val barDrawableArea = barDrawableArea(xAxisArea)
barChartData.forEachWithArea(
this,
barDrawableArea,
progress,
labelDrawer
) { barArea, bar ->
labelDrawer.drawLabel(
drawScope = this,
canvas = canvas,
label = bar.label,
barArea = barArea,
xAxisArea = xAxisArea
)
}
yAxisDrawer.drawAxisLabels(
drawScope = this,
canvas = canvas,
minValue = barChartData.minYValue,
maxValue = barChartData.maxYValue,
drawableArea = yAxisArea
)
}
}
}
@@ -0,0 +1,39 @@
package com.github.tehras.charts.bar
import androidx.compose.ui.graphics.Color
data class BarChartData(
val bars: List<Bar>,
val padBy: Float = 10f,
val startAtZero: Boolean = true
) {
init {
require(padBy in 0f..100f)
}
private val yMinMax: Pair<Float, Float>
get() {
val min = bars.minByOrNull { it.value }?.value ?: 0f
val max = bars.maxByOrNull { it.value }?.value ?: 0f
return min to max
}
internal val maxYValue: Float =
yMinMax.second + ((yMinMax.second - yMinMax.first) * padBy / 100f)
internal val minYValue: Float
get() {
return if (startAtZero) {
0f
} else {
yMinMax.first - ((yMinMax.second - yMinMax.first) * padBy / 100f)
}
}
val maxBarValue = bars.maxByOrNull { it.value }?.value ?: 0f
data class Bar(
val value: Float,
val color: Color,
val label: String
)
}
@@ -0,0 +1,71 @@
package com.github.tehras.charts.bar
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.dp
import com.github.tehras.charts.bar.renderer.label.LabelDrawer
import com.github.tehras.charts.bar.renderer.xaxis.XAxisDrawer
internal object BarChartUtils {
fun axisAreas(
drawScope: DrawScope,
totalSize: Size,
xAxisDrawer: XAxisDrawer,
labelDrawer: LabelDrawer
): Pair<Rect, Rect> = with(drawScope) {
// yAxis
val yAxisTop = labelDrawer.requiredAboveBarHeight(drawScope)
// Either 50dp or 10% of the chart width.
val yAxisRight = minOf(50.dp.toPx(), size.width * 10f / 100f)
// xAxis
val xAxisRight = totalSize.width
// Measure the size of the text and line.
val xAxisTop = totalSize.height - xAxisDrawer.requiredHeight(drawScope)
return Pair(
Rect(yAxisRight, xAxisTop, xAxisRight, totalSize.height),
Rect(0f, yAxisTop, yAxisRight, xAxisTop)
)
}
fun barDrawableArea(xAxisArea: Rect): Rect {
return Rect(
left = xAxisArea.left,
top = 0f,
right = xAxisArea.right,
bottom = xAxisArea.top
)
}
fun BarChartData.forEachWithArea(
drawScope: DrawScope,
barDrawableArea: Rect,
progress: Float,
labelDrawer: LabelDrawer,
block: (barArea: Rect, bar: BarChartData.Bar) -> Unit
) {
val totalBars = bars.size
val widthOfBarArea = barDrawableArea.width / totalBars
val offsetOfBar = widthOfBarArea * 0.2f
bars.forEachIndexed { index, bar ->
val left = barDrawableArea.left + (index * widthOfBarArea)
val height = barDrawableArea.height
val barHeight = (height - labelDrawer.requiredAboveBarHeight(drawScope)) * progress
val barArea = Rect(
left = left + offsetOfBar,
top = barDrawableArea.bottom - (bar.value / maxBarValue) * barHeight,
right = left + widthOfBarArea - offsetOfBar,
bottom = barDrawableArea.bottom
)
block(barArea, bar)
}
}
}
@@ -0,0 +1,15 @@
package com.github.tehras.charts.bar.renderer.bar
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
import com.github.tehras.charts.bar.BarChartData
interface BarDrawer {
fun drawBar(
drawScope: DrawScope,
canvas: Canvas,
barArea: Rect,
bar: BarChartData.Bar
)
}
@@ -0,0 +1,24 @@
package com.github.tehras.charts.bar.renderer.bar
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.DrawScope
import com.github.tehras.charts.bar.BarChartData
class SimpleBarDrawer : BarDrawer {
private val barPaint = Paint().apply {
this.isAntiAlias = true
}
override fun drawBar(
drawScope: DrawScope,
canvas: Canvas,
barArea: Rect,
bar: BarChartData.Bar
) {
canvas.drawRect(barArea, barPaint.apply {
color = bar.color
})
}
}
@@ -0,0 +1,18 @@
package com.github.tehras.charts.bar.renderer.label
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
interface LabelDrawer {
fun requiredXAxisHeight(drawScope: DrawScope): Float = 0f
fun requiredAboveBarHeight(drawScope: DrawScope): Float = 0f
fun drawLabel(
drawScope: DrawScope,
canvas: Canvas,
label: String,
barArea: Rect,
xAxisArea: Rect
)
}
@@ -0,0 +1,71 @@
package com.github.tehras.charts.bar.renderer.label
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.github.tehras.charts.bar.renderer.label.SimpleValueDrawer.DrawLocation.Inside
import com.github.tehras.charts.bar.renderer.label.SimpleValueDrawer.DrawLocation.Outside
import com.github.tehras.charts.bar.renderer.label.SimpleValueDrawer.DrawLocation.XAxis
import com.github.tehras.charts.piechart.utils.toLegacyInt
class SimpleValueDrawer(
private val drawLocation: DrawLocation = Inside,
private val labelTextSize: TextUnit = 12.sp,
private val labelTextColor: Color = Color.Black
) : LabelDrawer {
private val _labelTextArea: Float? = null
private val paint = android.graphics.Paint().apply {
this.textAlign = android.graphics.Paint.Align.CENTER
this.color = labelTextColor.toLegacyInt()
}
override fun requiredAboveBarHeight(drawScope: DrawScope): Float = when (drawLocation) {
Outside -> (3f / 2f) * labelTextHeight(drawScope)
Inside,
XAxis -> 0f
}
override fun requiredXAxisHeight(drawScope: DrawScope): Float = when (drawLocation) {
XAxis -> labelTextHeight(drawScope)
Inside,
Outside -> 0f
}
override fun drawLabel(
drawScope: DrawScope,
canvas: Canvas,
label: String,
barArea: Rect,
xAxisArea: Rect
) = with(drawScope) {
val xCenter = barArea.left + (barArea.width / 2)
val yCenter = when (drawLocation) {
Inside -> (barArea.top + barArea.bottom) / 2
Outside -> (barArea.top) - labelTextSize.toPx() / 2
XAxis -> barArea.bottom + labelTextHeight(drawScope)
}
canvas.nativeCanvas.drawText(label, xCenter, yCenter, paint(drawScope))
}
private fun labelTextHeight(drawScope: DrawScope) = with(drawScope) {
_labelTextArea ?: ((3f / 2f) * labelTextSize.toPx())
}
private fun paint(drawScope: DrawScope) = with(drawScope) {
paint.apply {
this.textSize = labelTextSize.toPx()
}
}
enum class DrawLocation {
Inside,
Outside,
XAxis
}
}
@@ -0,0 +1,46 @@
package com.github.tehras.charts.bar.renderer.xaxis
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
class SimpleXAxisDrawer(
private val axisLineThickness: Dp = 1.dp,
private val axisLineColor: Color = Color.Black
) : XAxisDrawer {
private val axisLinePaint = Paint().apply {
isAntiAlias = true
color = axisLineColor
style = PaintingStyle.Stroke
}
override fun requiredHeight(drawScope: DrawScope): Float = with(drawScope) {
(3f / 2f) * axisLineThickness.toPx()
}
override fun drawAxisLine(drawScope: DrawScope, canvas: Canvas, drawableArea: Rect) =
with(drawScope) {
val lineThickness = axisLineThickness.toPx()
val y = drawableArea.top + (lineThickness / 2f)
canvas.drawLine(
p1 = Offset(
x = drawableArea.left,
y = y
),
p2 = Offset(
x = drawableArea.right,
y = y
),
paint = axisLinePaint.apply {
strokeWidth = lineThickness
}
)
}
}
@@ -0,0 +1,15 @@
package com.github.tehras.charts.bar.renderer.xaxis
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
interface XAxisDrawer {
fun requiredHeight(drawScope: DrawScope): Float
fun drawAxisLine(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect
)
}
@@ -0,0 +1,92 @@
package com.github.tehras.charts.bar.renderer.yaxis
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.tehras.charts.piechart.utils.toLegacyInt
import kotlin.math.max
import kotlin.math.roundToInt
typealias LabelFormatter = (value: Float) -> String
class SimpleYAxisDrawer(
private val labelTextSize: TextUnit = 12.sp,
private val labelTextColor: Color = Color.Black,
private val labelRatio: Int = 3,
private val labelValueFormatter: LabelFormatter = { value -> "%.1f".format(value) },
private val axisLineThickness: Dp = 1.dp,
private val axisLineColor: Color = Color.Black
) : YAxisDrawer {
private val axisLinePaint = Paint().apply {
isAntiAlias = true
color = axisLineColor
style = PaintingStyle.Stroke
}
private val textPaint = android.graphics.Paint().apply {
isAntiAlias = true
color = labelTextColor.toLegacyInt()
}
private val textBounds = android.graphics.Rect()
override fun drawAxisLine(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect
) = with(drawScope) {
val lineThickness = axisLineThickness.toPx()
val x = drawableArea.right - (lineThickness / 2f)
canvas.drawLine(
p1 = Offset(
x = x,
y = drawableArea.top
),
p2 = Offset(
x = x,
y = drawableArea.bottom
),
paint = axisLinePaint.apply {
strokeWidth = lineThickness
}
)
}
override fun drawAxisLabels(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect,
minValue: Float,
maxValue: Float
) = with(drawScope) {
val labelPaint = textPaint.apply {
textSize = labelTextSize.toPx()
textAlign = android.graphics.Paint.Align.RIGHT
}
val minLabelHeight = (labelTextSize.toPx() * labelRatio.toFloat())
val totalHeight = drawableArea.height
val labelCount = max((drawableArea.height / minLabelHeight).roundToInt(), 2)
for (i in 0..labelCount) {
val value = minValue + (i * ((maxValue - minValue) / labelCount))
val label = labelValueFormatter(value)
val x = drawableArea.right - axisLineThickness.toPx() - (labelTextSize.toPx() / 2f)
labelPaint.getTextBounds(label, 0, label.length, textBounds)
val y =
drawableArea.bottom - (i * (totalHeight / labelCount)) + (textBounds.height() / 2f)
canvas.nativeCanvas.drawText(label, x, y, labelPaint)
}
}
}
@@ -0,0 +1,21 @@
package com.github.tehras.charts.bar.renderer.yaxis
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
interface YAxisDrawer {
fun drawAxisLine(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect
)
fun drawAxisLabels(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect,
minValue: Float,
maxValue: Float
)
}
+1
View File
@@ -0,0 +1 @@
/build
+23
View File
@@ -0,0 +1,23 @@
plugins {
id("com.android.library")
kotlin("android")
`maven-publish`
}
apply(from = rootProject.file("gradle/configure-android.gradle"))
apply(from = rootProject.file("gradle/configure-compose.gradle"))
apply(from = rootProject.file("gradle/jitpack-publish.gradle"))
dependencies {
implementation(Kotlin.stdLib)
implementation(Compose.animation)
implementation(Compose.core)
implementation(Compose.layout)
implementation(Compose.foundation)
implementation(Compose.runtime)
debugImplementation(Compose.tooling)
}
android {
namespace = "com.github.tehras.charts.common"
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,22 @@
package com.github.tehras.charts.piechart
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class AxisLine(
val thickness: Dp = 1.5.dp,
val color: Color = Color.Gray
) {
private val paint = Paint().apply {
this.color = this@AxisLine.color
this.style = PaintingStyle.Stroke
}
fun paint(density: Density) = paint.apply {
this.strokeWidth = thickness.value * density.density
}
}
@@ -0,0 +1,5 @@
package com.github.tehras.charts.piechart.animation
import androidx.compose.animation.core.TweenSpec
fun simpleChartAnimation() = TweenSpec<Float>(durationMillis = 500)
@@ -0,0 +1,12 @@
package com.github.tehras.charts.piechart.utils
import androidx.compose.ui.graphics.Color
fun Color.toLegacyInt(): Int {
return android.graphics.Color.argb(
(alpha * 255.0f + 0.5f).toInt(),
(red * 255.0f + 0.5f).toInt(),
(green * 255.0f + 0.5f).toInt(),
(blue * 255.0f + 0.5f).toInt()
)
}
+1
View File
@@ -0,0 +1 @@
/build
+25
View File
@@ -0,0 +1,25 @@
plugins {
id("com.android.library")
kotlin("android")
`maven-publish`
}
apply(from = rootProject.file("gradle/configure-android.gradle"))
apply(from = rootProject.file("gradle/configure-compose.gradle"))
apply(from = rootProject.file("gradle/jitpack-publish.gradle"))
dependencies {
api(project(":lib:common"))
implementation(Kotlin.stdLib)
implementation(Compose.animation)
implementation(Compose.core)
implementation(Compose.layout)
implementation(Compose.foundation)
implementation(Compose.runtime)
debugImplementation(Compose.tooling)
}
android {
namespace = "com.github.tehras.charts.line"
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
@@ -0,0 +1,188 @@
package com.github.tehras.charts.line
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import com.github.tehras.charts.line.LineChartUtils.calculateDrawableArea
import com.github.tehras.charts.line.LineChartUtils.calculateFillPath
import com.github.tehras.charts.line.LineChartUtils.calculateLinePath
import com.github.tehras.charts.line.LineChartUtils.calculatePointLocation
import com.github.tehras.charts.line.LineChartUtils.calculateXAxisDrawableArea
import com.github.tehras.charts.line.LineChartUtils.calculateXAxisLabelsDrawableArea
import com.github.tehras.charts.line.LineChartUtils.calculateYAxisDrawableArea
import com.github.tehras.charts.line.LineChartUtils.withProgress
import com.github.tehras.charts.line.renderer.line.LineDrawer
import com.github.tehras.charts.line.renderer.line.LineShader
import com.github.tehras.charts.line.renderer.line.NoLineShader
import com.github.tehras.charts.line.renderer.line.SolidLineDrawer
import com.github.tehras.charts.line.renderer.point.FilledCircularPointDrawer
import com.github.tehras.charts.line.renderer.point.PointDrawer
import com.github.tehras.charts.line.renderer.xaxis.SimpleXAxisDrawer
import com.github.tehras.charts.line.renderer.xaxis.XAxisDrawer
import com.github.tehras.charts.line.renderer.yaxis.SimpleYAxisDrawer
import com.github.tehras.charts.line.renderer.yaxis.YAxisDrawer
import com.github.tehras.charts.piechart.animation.simpleChartAnimation
@Composable
fun LineChart(
modifier: Modifier = Modifier,
linesChartData: List<LineChartData>,
labels: List<String> = linesChartData.maxByOrNull { it.points.size }?.points?.map { it.label }
?: emptyList(),
animation: AnimationSpec<Float> = simpleChartAnimation(),
pointDrawer: PointDrawer = FilledCircularPointDrawer(),
lineShader: LineShader = NoLineShader,
xAxisDrawer: XAxisDrawer = SimpleXAxisDrawer(),
yAxisDrawer: YAxisDrawer = SimpleYAxisDrawer(),
horizontalOffset: Float = 5f
) {
check(horizontalOffset in 0f..25f) {
"Horizontal offset is the % offset from sides, " +
"and should be between 0%-25%"
}
val transitionAnimation = remember(linesChartData.forEach { it.points }) {
mutableStateListOf(
*linesChartData.map { Animatable(0f) }.toTypedArray()
)
}
LaunchedEffect(linesChartData.forEach { it.points }) {
repeat(linesChartData.size) { index ->
transitionAnimation[index].snapTo(0f)
transitionAnimation[index].animateTo(
targetValue = 1f,
animationSpec = animation
)
}
}
Canvas(modifier = modifier.fillMaxSize()) {
drawIntoCanvas { canvas ->
val yAxisDrawableArea = calculateYAxisDrawableArea(
xAxisLabelSize = xAxisDrawer.requiredHeight(this),
size = size
)
val xAxisDrawableArea = calculateXAxisDrawableArea(
yAxisWidth = yAxisDrawableArea.width,
labelHeight = xAxisDrawer.requiredHeight(this),
size = size
)
val xAxisLabelsDrawableArea = calculateXAxisLabelsDrawableArea(
xAxisDrawableArea = xAxisDrawableArea,
offset = horizontalOffset
)
val chartDrawableArea = calculateDrawableArea(
xAxisDrawableArea = xAxisDrawableArea,
yAxisDrawableArea = yAxisDrawableArea,
size = size,
offset = horizontalOffset
)
// Draw the X Axis line.
xAxisDrawer.drawAxisLine(
drawScope = this,
drawableArea = xAxisDrawableArea,
canvas = canvas
)
xAxisDrawer.drawAxisLabels(
drawScope = this,
canvas = canvas,
drawableArea = xAxisLabelsDrawableArea,
labels = labels
)
// Draw the Y Axis line.
yAxisDrawer.drawAxisLine(
drawScope = this,
canvas = canvas,
drawableArea = yAxisDrawableArea
)
yAxisDrawer.drawAxisLabels(
drawScope = this,
canvas = canvas,
drawableArea = yAxisDrawableArea,
minValue = linesChartData.minOf { it.minYValue },
maxValue = linesChartData.maxOf { it.maxYValue }
)
linesChartData.forEachIndexed { index, lineChartData ->
drawLine(
canvas = canvas,
lineChartData = lineChartData,
transitionAnimation = transitionAnimation[index],
pointDrawer = pointDrawer,
lineDrawer = lineChartData.lineDrawer,
lineShader = lineShader,
chartDrawableArea = chartDrawableArea
)
}
}
}
}
private fun DrawScope.drawLine(
pointDrawer: PointDrawer = FilledCircularPointDrawer(),
lineDrawer: LineDrawer = SolidLineDrawer(),
lineShader: LineShader = NoLineShader,
canvas: Canvas,
transitionAnimation: Animatable<Float, AnimationVector1D>,
lineChartData: LineChartData,
chartDrawableArea: Rect
) {
// Draw the chart line.
lineDrawer.drawLine(
drawScope = this,
canvas = canvas,
linePath = calculateLinePath(
drawableArea = chartDrawableArea,
lineChartData = lineChartData,
transitionProgress = transitionAnimation.value
)
)
lineShader.fillLine(
drawScope = this,
canvas = canvas,
fillPath = calculateFillPath(
drawableArea = chartDrawableArea,
lineChartData = lineChartData,
transitionProgress = transitionAnimation.value
)
)
lineChartData.points.forEachIndexed { index, point ->
withProgress(
index = index,
lineChartData = lineChartData,
transitionProgress = transitionAnimation.value
) {
pointDrawer.drawPoint(
drawScope = this,
canvas = canvas,
center = calculatePointLocation(
drawableArea = chartDrawableArea,
lineChartData = lineChartData,
point = point,
index = index
)
)
}
}
}
@@ -0,0 +1,37 @@
package com.github.tehras.charts.line
import com.github.tehras.charts.line.renderer.line.LineDrawer
data class LineChartData(
val points: List<Point>,
/** This is percentage we pad yValue by.**/
val padBy: Float = 20f,
val startAtZero: Boolean = false,
val lineDrawer: LineDrawer,
) {
init {
require(padBy in 0f..100f)
}
private val yMinMax: Pair<Float, Float>
get() {
val min = points.minByOrNull { it.value }?.value ?: 0f
val max = points.maxByOrNull { it.value }?.value ?: 0f
return min to max
}
internal val maxYValue: Float =
yMinMax.second + ((yMinMax.second - yMinMax.first) * padBy / 100f)
internal val minYValue: Float
get() {
return if (startAtZero) {
0f
} else {
yMinMax.first - ((yMinMax.second - yMinMax.first) * padBy / 100f)
}
}
internal val yRange = maxYValue - minYValue
data class Point(val value: Float, val label: String)
}
@@ -0,0 +1,205 @@
package com.github.tehras.charts.line
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
object LineChartUtils {
fun calculateDrawableArea(
xAxisDrawableArea: Rect,
yAxisDrawableArea: Rect,
size: Size,
offset: Float
): Rect {
val horizontalOffset = xAxisDrawableArea.width * offset / 100f
return Rect(
left = yAxisDrawableArea.right + horizontalOffset,
top = 0f,
bottom = xAxisDrawableArea.top,
right = size.width - horizontalOffset
)
}
fun calculateXAxisDrawableArea(
yAxisWidth: Float,
labelHeight: Float,
size: Size
): Rect {
val top = size.height - labelHeight
return Rect(
left = yAxisWidth,
top = top,
bottom = size.height,
right = size.width
)
}
fun calculateXAxisLabelsDrawableArea(
xAxisDrawableArea: Rect,
offset: Float
): Rect {
val horizontalOffset = xAxisDrawableArea.width * offset / 100f
return Rect(
left = xAxisDrawableArea.left + horizontalOffset,
top = xAxisDrawableArea.top,
bottom = xAxisDrawableArea.bottom,
right = xAxisDrawableArea.right - horizontalOffset
)
}
fun Density.calculateYAxisDrawableArea(
xAxisLabelSize: Float,
size: Size
): Rect {
// Either 50dp or 10% of the chart width.
val right = minOf(50.dp.toPx(), size.width * 10f / 100f)
return Rect(
left = 0f,
top = 0f,
bottom = size.height - xAxisLabelSize,
right = right
)
}
fun calculatePointLocation(
drawableArea: Rect,
lineChartData: LineChartData,
point: LineChartData.Point,
index: Int
): Offset {
val x = (index.toFloat() / (lineChartData.points.size - 1))
val y = ((point.value - lineChartData.minYValue) / lineChartData.yRange)
return Offset(
x = (x * drawableArea.width) + drawableArea.left,
y = drawableArea.height - (y * drawableArea.height)
)
}
fun withProgress(
index: Int,
lineChartData: LineChartData,
transitionProgress: Float,
showWithProgress: (progress: Float) -> Unit
) {
val size = lineChartData.points.size
val toIndex = (size * transitionProgress).toInt() + 1
if (index == toIndex) {
// Get the left over.
val sizeF = lineChartData.points.size.toFloat()
val perIndex = (1f / sizeF)
val down = (index - 1) * perIndex
showWithProgress((transitionProgress - down) / perIndex)
} else if (index < toIndex) {
showWithProgress(1f)
}
}
fun calculateLinePath(
drawableArea: Rect,
lineChartData: LineChartData,
transitionProgress: Float
): Path = Path().apply {
var prevPointLocation: Offset? = null
lineChartData.points.forEachIndexed { index, point ->
withProgress(
index = index,
transitionProgress = transitionProgress,
lineChartData = lineChartData
) { progress ->
val pointLocation = calculatePointLocation(
drawableArea = drawableArea,
lineChartData = lineChartData,
point = point,
index = index
)
if (pointLocation.isSpecified) {
if (index == 0) {
moveTo(pointLocation.x, pointLocation.y)
} else {
if (progress <= 1f) {
// We have to change the `dy` based on the progress
val prevX = prevPointLocation!!.x
val prevY = prevPointLocation!!.y
val x = (pointLocation.x - prevX) * progress + prevX
val y = (pointLocation.y - prevY) * progress + prevY
lineTo(x, y)
} else {
lineTo(pointLocation.x, pointLocation.y)
}
}
prevPointLocation = pointLocation
}
}
}
}
fun calculateFillPath(drawableArea: Rect,
lineChartData: LineChartData,
transitionProgress: Float
): Path = Path().apply {
// we start from the bottom left
moveTo(drawableArea.left, drawableArea.bottom)
var prevPointX : Float? = null
var prevPointLocation: Offset? = null
lineChartData.points.forEachIndexed { index, point ->
withProgress(
index = index,
transitionProgress = transitionProgress,
lineChartData = lineChartData
) { progress ->
val pointLocation = calculatePointLocation(
drawableArea = drawableArea,
lineChartData = lineChartData,
point = point,
index = index
)
if (pointLocation.isSpecified) {
if (index == 0) {
lineTo(drawableArea.left, pointLocation.y)
lineTo(pointLocation.x, pointLocation.y)
} else {
if (progress <= 1f) {
// We have to change the `dy` based on the progress
val prevX = prevPointLocation!!.x
val prevY = prevPointLocation!!.y
val x = (pointLocation.x - prevX) * progress + prevX
val y = (pointLocation.y - prevY) * progress + prevY
lineTo(x, y)
prevPointX = x
} else {
lineTo(pointLocation.x, pointLocation.y)
prevPointX = pointLocation.x
}
}
prevPointLocation = pointLocation
}
}
}
// We need to connect the line to the end of the drawable area
prevPointX?.let { x->
lineTo(x, drawableArea.bottom)
lineTo(drawableArea.right, drawableArea.bottom)
} ?: lineTo(drawableArea.left, drawableArea.bottom)
}
}
@@ -0,0 +1,17 @@
package com.github.tehras.charts.line.renderer.line
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
class GradientLineShader(colors: List<Color> = listOf(Color.Blue, Color.Transparent)) : LineShader {
private val brush = Brush.verticalGradient(colors)
override fun fillLine(drawScope: DrawScope, canvas: Canvas, fillPath: Path) {
drawScope.drawPath(
path = fillPath,
brush = brush
)
}
}
@@ -0,0 +1,13 @@
package com.github.tehras.charts.line.renderer.line
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
interface LineDrawer {
fun drawLine(
drawScope: DrawScope,
canvas: Canvas,
linePath: Path
)
}
@@ -0,0 +1,13 @@
package com.github.tehras.charts.line.renderer.line
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
interface LineShader {
fun fillLine(
drawScope: DrawScope,
canvas: Canvas,
fillPath: Path
)
}
@@ -0,0 +1,11 @@
package com.github.tehras.charts.line.renderer.line
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
object NoLineShader : LineShader {
override fun fillLine(drawScope: DrawScope, canvas: Canvas, fillPath: Path) {
// Do nothing
}
}
@@ -0,0 +1,34 @@
package com.github.tehras.charts.line.renderer.line
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class SolidLineDrawer(
val thickness: Dp = 3.dp,
val color: Color = Color.Cyan
) : LineDrawer {
private val paint = Paint().apply {
this.color = this@SolidLineDrawer.color
this.style = PaintingStyle.Stroke
this.isAntiAlias = true
}
override fun drawLine(
drawScope: DrawScope,
canvas: Canvas,
linePath: Path
) {
val lineThickness = with(drawScope) {
thickness.toPx()
}
canvas.drawPath(
path = linePath,
paint = paint.apply {
strokeWidth = lineThickness
}
)
}
}
@@ -0,0 +1,15 @@
package com.github.tehras.charts.line.renderer.line
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
class SolidLineShader(val color: Color = Color.Blue) : LineShader {
override fun fillLine(drawScope: DrawScope, canvas: Canvas, fillPath: Path) {
drawScope.drawPath(
path = fillPath,
color = color
)
}
}
@@ -0,0 +1,33 @@
package com.github.tehras.charts.line.renderer.point
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class FilledCircularPointDrawer(
val diameter: Dp = 8.dp,
val color: Color = Color.Blue
) : PointDrawer {
private val paint = Paint().apply {
color = this@FilledCircularPointDrawer.color
style = PaintingStyle.Fill
isAntiAlias = true
}
override fun drawPoint(
drawScope: DrawScope,
canvas: Canvas,
center: Offset
) {
with(drawScope as Density) {
canvas.drawCircle(center, diameter.toPx() / 2f, paint)
}
}
}
@@ -0,0 +1,40 @@
package com.github.tehras.charts.line.renderer.point
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class HollowCircularPointDrawer(
val diameter: Dp = 8.dp,
val lineThickness: Dp = 2.dp,
val color: Color = Color.Blue
) : PointDrawer {
private val paint = Paint().apply {
color = this@HollowCircularPointDrawer.color
style = PaintingStyle.Stroke
isAntiAlias = true
}
override fun drawPoint(
drawScope: DrawScope,
canvas: Canvas,
center: Offset
) {
with(drawScope as Density) {
canvas.drawCircle(
center = center,
radius = diameter.toPx() / 2f,
paint = paint.apply {
strokeWidth = lineThickness.toPx()
}
)
}
}
}
@@ -0,0 +1,15 @@
package com.github.tehras.charts.line.renderer.point
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
object NoPointDrawer : PointDrawer {
override fun drawPoint(
drawScope: DrawScope,
canvas: Canvas,
center: Offset
) {
// Leave empty on purpose, we do not want to draw anything.
}
}
@@ -0,0 +1,13 @@
package com.github.tehras.charts.line.renderer.point
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
interface PointDrawer {
fun drawPoint(
drawScope: DrawScope,
canvas: Canvas,
center: Offset
)
}
@@ -0,0 +1,90 @@
package com.github.tehras.charts.line.renderer.xaxis
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.tehras.charts.piechart.utils.toLegacyInt
class SimpleXAxisDrawer(
private val labelTextSize: TextUnit = 12.sp,
private val labelTextColor: Color = Color.Black,
/** 1 means we draw everything. 2 means we draw every other, and so on. */
private val labelRatio: Int = 1,
private val axisLineThickness: Dp = 1.dp,
private val axisLineColor: Color = Color.Black
) : XAxisDrawer {
private val axisLinePaint = Paint().apply {
isAntiAlias = true
color = axisLineColor
style = PaintingStyle.Stroke
}
private val textPaint = android.graphics.Paint().apply {
isAntiAlias = true
color = labelTextColor.toLegacyInt()
}
override fun requiredHeight(drawScope: DrawScope): Float {
return with(drawScope) {
(3f / 2f) * (labelTextSize.toPx() + axisLineThickness.toPx())
}
}
override fun drawAxisLine(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect
) {
with(drawScope) {
val lineThickness = axisLineThickness.toPx()
val y = drawableArea.top + (lineThickness / 2f)
canvas.drawLine(
p1 = Offset(
x = drawableArea.left,
y = y
),
p2 = Offset(
x = drawableArea.right,
y = y
),
paint = axisLinePaint.apply {
strokeWidth = lineThickness
}
)
}
}
override fun drawAxisLabels(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect,
labels: List<String>
) {
with(drawScope) {
val labelPaint = textPaint.apply {
textSize = labelTextSize.toPx()
textAlign = android.graphics.Paint.Align.CENTER
}
val labelIncrements = drawableArea.width / (labels.size - 1)
labels.forEachIndexed { index, label ->
if (index.rem(labelRatio) == 0) {
val x = drawableArea.left + (labelIncrements * (index))
val y = drawableArea.bottom
canvas.nativeCanvas.drawText(label, x, y, labelPaint)
}
}
}
}
}
@@ -0,0 +1,22 @@
package com.github.tehras.charts.line.renderer.xaxis
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
interface XAxisDrawer {
fun requiredHeight(drawScope: DrawScope): Float
fun drawAxisLine(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect
)
fun drawAxisLabels(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect,
labels: List<String>
)
}
@@ -0,0 +1,93 @@
package com.github.tehras.charts.line.renderer.yaxis
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.github.tehras.charts.piechart.utils.toLegacyInt
import kotlin.math.max
import kotlin.math.roundToInt
typealias LabelFormatter = (value: Float) -> String
class SimpleYAxisDrawer(
private val labelTextSize: TextUnit = 12.sp,
private val labelTextColor: Color = Color.Black,
private val labelRatio: Int = 3,
private val labelValueFormatter: LabelFormatter = { value -> "%.1f".format(value) },
private val axisLineThickness: Dp = 1.dp,
private val axisLineColor: Color = Color.Black
) : YAxisDrawer {
private val axisLinePaint = Paint().apply {
isAntiAlias = true
color = axisLineColor
style = PaintingStyle.Stroke
}
private val textPaint = android.graphics.Paint().apply {
isAntiAlias = true
color = labelTextColor.toLegacyInt()
}
private val textBounds = android.graphics.Rect()
override fun drawAxisLine(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect
) = with(drawScope) {
val lineThickness = axisLineThickness.toPx()
val x = drawableArea.right - (lineThickness / 2f)
canvas.drawLine(
p1 = Offset(
x = x,
y = drawableArea.top
),
p2 = Offset(
x = x,
y = drawableArea.bottom
),
paint = axisLinePaint.apply {
strokeWidth = lineThickness
}
)
}
override fun drawAxisLabels(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect,
minValue: Float,
maxValue: Float
) = with(drawScope) {
val labelPaint = textPaint.apply {
textSize = labelTextSize.toPx()
textAlign = android.graphics.Paint.Align.RIGHT
}
val minLabelHeight = (labelTextSize.toPx() * labelRatio.toFloat())
val totalHeight = drawableArea.height
val labelCount = max((drawableArea.height / minLabelHeight).roundToInt(), 2)
for (i in 0..labelCount) {
val value = minValue + (i * ((maxValue - minValue) / labelCount))
val label = labelValueFormatter(value)
val x =
drawableArea.right - axisLineThickness.toPx() - (labelTextSize.toPx() / 2f)
labelPaint.getTextBounds(label, 0, label.length, textBounds)
val y =
drawableArea.bottom - (i * (totalHeight / labelCount)) + (textBounds.height() / 2f)
canvas.nativeCanvas.drawText(label, x, y, labelPaint)
}
}
}
@@ -0,0 +1,21 @@
package com.github.tehras.charts.line.renderer.yaxis
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
interface YAxisDrawer {
fun drawAxisLine(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect
)
fun drawAxisLabels(
drawScope: DrawScope,
canvas: Canvas,
drawableArea: Rect,
minValue: Float,
maxValue: Float
)
}
+1
View File
@@ -0,0 +1 @@
/build
+27
View File
@@ -0,0 +1,27 @@
plugins {
id("com.android.library")
kotlin("android")
`maven-publish`
}
apply(from = rootProject.file("gradle/configure-android.gradle"))
apply(from = rootProject.file("gradle/configure-compose.gradle"))
apply(from = rootProject.file("gradle/jitpack-publish.gradle"))
dependencies {
api(project(":lib:common"))
implementation(Kotlin.stdLib)
implementation(Compose.animation)
implementation(Compose.core)
implementation(Compose.layout)
implementation(Compose.foundation)
implementation(Compose.runtime)
// Previews weren't working when using debugImplementation
implementation(Compose.tooling)
}
android {
namespace = "com.github.tehras.charts.piechart"
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,86 @@
package com.github.tehras.charts.piechart
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.tooling.preview.Preview
import com.github.tehras.charts.piechart.PieChartUtils.calculateAngle
import com.github.tehras.charts.piechart.animation.simpleChartAnimation
import com.github.tehras.charts.piechart.renderer.SimpleSliceDrawer
import com.github.tehras.charts.piechart.renderer.SliceDrawer
@Composable
fun PieChart(
pieChartData: PieChartData,
modifier: Modifier = Modifier,
animation: AnimationSpec<Float> = simpleChartAnimation(),
sliceDrawer: SliceDrawer = SimpleSliceDrawer()
) {
val transitionProgress = remember(pieChartData.slices) { Animatable(initialValue = 0f) }
// When slices value changes we want to re-animated the chart.
LaunchedEffect(pieChartData.slices) {
transitionProgress.animateTo(1f, animationSpec = animation)
}
DrawChart(
pieChartData = pieChartData,
modifier = modifier.fillMaxSize(),
progress = transitionProgress.value,
sliceDrawer = sliceDrawer
)
}
@Composable
private fun DrawChart(
pieChartData: PieChartData,
modifier: Modifier,
progress: Float,
sliceDrawer: SliceDrawer
) {
val slices = pieChartData.slices
Canvas(modifier = modifier) {
drawIntoCanvas {
var startArc = 0f
slices.forEach { slice ->
val arc = calculateAngle(
sliceLength = slice.value,
totalLength = pieChartData.totalSize,
progress = progress
)
sliceDrawer.drawSlice(
drawScope = this,
canvas = drawContext.canvas,
area = size,
startAngle = startArc,
sweepAngle = arc,
slice = slice
)
startArc += arc
}
}
}
}
@Preview
@Composable
fun PieChartPreview() = PieChart(
pieChartData = PieChartData(
slices = listOf(
PieChartData.Slice(25f, Color.Red),
PieChartData.Slice(42f, Color.Blue),
PieChartData.Slice(23f, Color.Green)
)
)
)
@@ -0,0 +1,19 @@
package com.github.tehras.charts.piechart
import androidx.compose.ui.graphics.Color
data class PieChartData(
val slices: List<Slice>
) {
internal val totalSize: Float
get() {
var total = 0f
slices.forEach { total += it.value }
return total
}
data class Slice(
val value: Float,
val color: Color
)
}
@@ -0,0 +1,11 @@
package com.github.tehras.charts.piechart
internal object PieChartUtils {
fun calculateAngle(
sliceLength: Float,
totalLength: Float,
progress: Float
): Float {
return 360.0f * (sliceLength * progress) / totalLength
}
}
@@ -0,0 +1,64 @@
package com.github.tehras.charts.piechart.renderer
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.drawscope.DrawScope
import com.github.tehras.charts.piechart.PieChartData.Slice
class SimpleSliceDrawer(private val sliceThickness: Float = 25f) : SliceDrawer {
init {
require(sliceThickness in 10f..100f) {
"Thickness of $sliceThickness must be between 10-100"
}
}
private val sectionPaint = Paint().apply {
isAntiAlias = true
style = PaintingStyle.Stroke
}
override fun drawSlice(
drawScope: DrawScope,
canvas: Canvas,
area: Size,
startAngle: Float,
sweepAngle: Float,
slice: Slice
) {
val sliceThickness = calculateSectorThickness(area = area)
val drawableArea = calculateDrawableArea(area = area)
canvas.drawArc(
rect = drawableArea,
paint = sectionPaint.apply {
color = slice.color
strokeWidth = sliceThickness
},
startAngle = startAngle,
sweepAngle = sweepAngle,
useCenter = false
)
}
private fun calculateSectorThickness(area: Size): Float {
val minSize = minOf(area.width, area.height)
return minSize * (sliceThickness / 200f)
}
private fun calculateDrawableArea(area: Size): Rect {
val sliceThicknessOffset =
calculateSectorThickness(area = area) / 2f
val offsetHorizontally = (area.width - area.height) / 2f
return Rect(
left = sliceThicknessOffset + offsetHorizontally,
top = sliceThicknessOffset,
right = area.width - sliceThicknessOffset - offsetHorizontally,
bottom = area.height - sliceThicknessOffset
)
}
}
@@ -0,0 +1,17 @@
package com.github.tehras.charts.piechart.renderer
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.DrawScope
import com.github.tehras.charts.piechart.PieChartData.Slice
interface SliceDrawer {
fun drawSlice(
drawScope: DrawScope,
canvas: Canvas,
area: Size,
startAngle: Float,
sweepAngle: Float,
slice: Slice
)
}