Skip to content

Instantly share code, notes, and snippets.

@kimji1
Last active February 24, 2025 15:37
Show Gist options
  • Save kimji1/1a93a65afb273ac7c27b18b7306773e5 to your computer and use it in GitHub Desktop.
Save kimji1/1a93a65afb273ac7c27b18b7306773e5 to your computer and use it in GitHub Desktop.
@Composable
fun HorizontalGaugeBarChart(
modifier: Modifier = Modifier,
state: HorizontalGaugeBarChartState,
) {
var unit by remember { mutableFloatStateOf(0f) }
val cornerRadius = remember { state.barHeight / 2 }
Column(modifier = modifier.wrapContentSize()) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(state.barHeight.toDp())
.clip(RoundedCornerShape(cornerRadius.toDp()))
) {
val base = state.gauge.base
unit = size.width / if (base > 0f) base else 1f
if (unit > 0f) {
drawHorizontalStackedBar(
currentIdx = 0,
lastIndex = 0,
barHeight = state.barHeight,
cornerRadius = cornerRadius,
stackedWidth = 0f,
elementWidth = size.width,
topPadding = 0f,
color = state.backgroundBarColor
)
val gaugeWidth = state.gauge.value * unit
drawHorizontalStackedBar(
currentIdx = 0,
lastIndex = 0,
barHeight = state.barHeight,
cornerRadius = cornerRadius,
stackedWidth = 0f,
elementWidth = gaugeWidth,
topPadding = 0f,
color = state.gauge.barColor
)
state.highlightRange?.let { highlightRange ->
drawHighlightRange(
highlightRange = highlightRange,
unit = unit,
barHeight = state.barHeight,
highlightColor = state.highlightColor,
highlightLineColor = state.highlightLineColor
)
}
}
}
state.highlightRange?.let { range ->
HighlightRange(range, unit)
}
}
}
@Composable
fun HighlightRange(
range: Range,
unit: Float,
) {
val textMeasurer = rememberTextMeasurer()
val textStyle = VCTheme.typo.caption1.copy(color = VCTheme.colors.graphSafety10)
val minTextSize = textMeasurer.measure(
text = range.min.toIntegerableString(),
style = textStyle
).size
val height = minTextSize.height.toFloat().toDp()
val minTextWidth = minTextSize.width
val maxTextWidth = textMeasurer.measure(
text = range.max.toIntegerableString(),
style = textStyle
).size.width
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp)
.height(height)
) {
val minOffset = (range.min ?: 0f) * unit
val maxOffset = (range.max ?: 0f) * unit
val minTextXOffset = minOffset - (minTextWidth / 2)
val maxTextXOffset = maxOffset - minTextWidth - (maxTextWidth / 2)
val minimumGapConst = 4.dp.toPx()
val overlappingWidth = maxTextXOffset - minTextXOffset
val minGap = if (overlappingWidth < minimumGapConst) {
(overlappingWidth.absoluteValue + minimumGapConst) / 2
} else 0f
if (range.min != null && unit > 0f) {
Text(
modifier = Modifier.offset(x = (minTextXOffset - minGap).toDp()),
text = range.min.toIntegerableString(),
style = textStyle,
)
}
if (range.max != null && unit > 0f) {
Text(
modifier = Modifier.offset(x = (maxTextXOffset + minGap).toDp()),
text = range.max.toIntegerableString(),
style = textStyle,
)
}
}
}
fun DrawScope.drawHighlightRange(
highlightRange: Range,
unit: Float,
barHeight: Float,
highlightColor: Color,
highlightLineColor: Color
) {
val width = ((highlightRange.max ?: 0f) - (highlightRange.min ?: 0f)) * unit
val minOffset = (highlightRange.min ?: 0f) * unit
val maxOffset = (highlightRange.max ?: 0f) * unit
val strokeWidth = 1.dp.toPx()
drawRect(
color = highlightColor,
topLeft = Offset(x = minOffset, y = 0f),
size = size.copy(width = width, height = barHeight),
)
drawLine(
color = highlightLineColor,
start = Offset(x = minOffset, y = 0f),
end = Offset(x = minOffset, y = barHeight),
strokeWidth = strokeWidth,
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(strokeWidth, strokeWidth * 3f),
phase = strokeWidth * 0.5f,
),
)
drawLine(
color = highlightLineColor,
start = Offset(x = maxOffset, y = 0f),
end = Offset(x = maxOffset, y = barHeight),
strokeWidth = strokeWidth,
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(strokeWidth, strokeWidth * 3f),
phase = strokeWidth * 0.5f,
),
)
}
data class HorizontalGaugeBarChartState(
val gauge: Gauge,
val barHeight: Float,
val backgroundBarColor: Color,
val highlightRange: Range?,
val highlightColor: Color,
val highlightLineColor: Color,
)
data class Gauge(
val value: Float,
val base: Float,
val barColor: Color,
)
data class Range(
val min: Float?,
val max: Float?,
)
@Composable
fun rememberHorizontalGaugeBarChartState(
gauge: Gauge,
highlightRange: Range?,
barHeight: Float = 8.dp.toPx(),
backgroundBarColor: Color = VCTheme.colors.line,
highlightColor: Color = VCTheme.colors.graphSafety99.copy(alpha = 0.45f),
highlightLine: Color = VCTheme.colors.graphSafety10,
) = remember(gauge) {
HorizontalGaugeBarChartState(
gauge = gauge,
barHeight = barHeight,
backgroundBarColor = backgroundBarColor,
highlightRange = highlightRange,
highlightColor = highlightColor,
highlightLineColor = highlightLine,
)
}
fun Float?.toIntegerableString(
nullStr: String = "",
requireRound: Boolean = false,
requireTwoDecimalPlaces: Boolean = false
): String = if (this == null) {
nullStr
} else if (requireRound || (this % 1).compareTo(0f) == 0) {
DecimalFormat("##,###").format(roundHalfUp(0).toInt())
} else if (requireTwoDecimalPlaces && (this % 0.1).compareTo(0f) != 0) {
DecimalFormat("##,###.##").format(this)
} else {
DecimalFormat("##,###.#").format(this)
}
fun Dp.toPx(): Float {
return this.value * getDensity()
}
fun Float.toDp(): Dp {
return (this / getDensity()).dp
}
// cf. HorizontalStackedBarChart.kt
fun DrawScope.drawHorizontalStackedBar(
currentIdx: Int,
lastIndex: Int,
barHeight: Float,
cornerRadius: Float,
stackedWidth: Float,
elementWidth: Float,
topPadding: Float,
color: Color
) {
val path = Path().apply {
val xPos = if (stackedWidth < cornerRadius) 0f else stackedWidth
val width = if (stackedWidth < cornerRadius) elementWidth + stackedWidth else elementWidth
val radius = CornerRadius(cornerRadius, cornerRadius)
addRoundRect(
RoundRect(
rect = Rect(
offset = Offset(x = xPos, y = topPadding),
size = size.copy(width = width, height = barHeight),
),
topLeft = if (currentIdx == 0) radius else CornerRadius.Zero,
topRight = if (currentIdx == lastIndex) radius else CornerRadius.Zero,
bottomLeft = if (currentIdx == 0) radius else CornerRadius.Zero,
bottomRight = if (currentIdx == lastIndex) radius else CornerRadius.Zero
)
)
}
drawPath(path, color = color)
}
@Preview(widthDp = 320, heightDp = 40)
@Composable
fun GaugeChartPreview() {
VirtualCareTheme(darkTheme = true) {
Column(
modifier = Modifier
.fillMaxSize()
.background(VCTheme.colors.background)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center
) {
HorizontalGaugeBarChart(
state = rememberHorizontalGaugeBarChartState(
gauge = Gauge(
value = 30f,
base = 100f,
barColor = VCTheme.colors.onSurface40
),
highlightRange = null,
)
)
}
}
}
@Preview(widthDp = 320, heightDp = 40)
@Composable
fun GaugeChartPreview_Clip() {
VirtualCareTheme(darkTheme = true) {
Column(
modifier = Modifier
.fillMaxSize()
.background(VCTheme.colors.background)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center
) {
HorizontalGaugeBarChart(
state = rememberHorizontalGaugeBarChartState(
gauge = Gauge(
value = 2f,
base = 100f,
barColor = VCTheme.colors.onSurface40
),
highlightRange = null,
)
)
}
}
}
@Preview(widthDp = 320)
@Composable
fun GaugeChartPreview_RangeTextGapTest1() {
VirtualCareTheme(darkTheme = true) {
Column(
modifier = Modifier
.background(VCTheme.colors.background)
.padding(all = 16.dp),
) {
HorizontalGaugeBarChart(
state = rememberHorizontalGaugeBarChartState(
gauge = Gauge(
value = 50f,
base = 75f,
barColor = VCTheme.colors.onSurface40
),
highlightRange = Range(30f, 40f),
)
)
}
}
}
@Preview(widthDp = 320)
@Composable
fun GaugeChartPreview_RangeTextGapTest2() {
VirtualCareTheme(darkTheme = true) {
Column(
modifier = Modifier
.background(VCTheme.colors.background)
.padding(all = 16.dp),
) {
HorizontalGaugeBarChart(
state = rememberHorizontalGaugeBarChartState(
gauge = Gauge(
value = 50f,
base = 75f,
barColor = VCTheme.colors.onSurface40
),
highlightRange = Range(20f, 22f),
)
)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment