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
)
}