Created
June 4, 2026 13:17
-
-
Save Kyriakos-Georgiopoulos/e70686f442cf23184f43181e5de02f85 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Copyright 2026 Kyriakos Georgiopoulos | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.AnimationSpec | |
| import androidx.compose.animation.core.Spring | |
| import androidx.compose.animation.core.spring | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.PaddingValues | |
| import androidx.compose.foundation.layout.Row | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.defaultMinSize | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material3.Button | |
| import androidx.compose.material3.ButtonDefaults | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.Immutable | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.Stable | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableFloatStateOf | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Size | |
| import androidx.compose.ui.graphics.Brush | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.Path | |
| import androidx.compose.ui.graphics.StrokeCap | |
| import androidx.compose.ui.graphics.drawscope.Stroke | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.platform.LocalLayoutDirection | |
| import androidx.compose.ui.semantics.ProgressBarRangeInfo | |
| import androidx.compose.ui.semantics.contentDescription | |
| import androidx.compose.ui.semantics.progressBarRangeInfo | |
| import androidx.compose.ui.semantics.semantics | |
| import androidx.compose.ui.text.TextLayoutResult | |
| import androidx.compose.ui.text.TextStyle | |
| import androidx.compose.ui.text.drawText | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.text.rememberTextMeasurer | |
| import androidx.compose.ui.text.style.TextOverflow | |
| import androidx.compose.ui.unit.Dp | |
| import androidx.compose.ui.unit.LayoutDirection | |
| import androidx.compose.ui.unit.TextUnit | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import kotlinx.coroutines.delay | |
| import kotlin.math.cos | |
| import kotlin.math.max | |
| import kotlin.math.min | |
| import kotlin.math.sin | |
| import kotlin.random.Random | |
| // ========================================== | |
| // 1. DATA MODELS & CONFIG | |
| // ========================================== | |
| /** | |
| * A colored segment on the gauge arc representing a value range. | |
| * | |
| * @param id Stable identifier. | |
| * @param label Human-readable zone name (e.g. "Safe", "Warning", "Critical"). | |
| * Used by the default accessibility description builder. | |
| * @param range The value range this zone covers. Must be within min..max. | |
| * @param color Solid fill color for the arc segment. Ignored when [gradientColors] | |
| * has 2+ entries. | |
| * @param gradientColors When set with 2+ colors, renders this zone as a sweep gradient | |
| * along the arc instead of a solid color. The gradient interpolates across the | |
| * zone's angular range. When empty, falls back to [color]. | |
| */ | |
| @Immutable | |
| data class GaugeZone( | |
| val id: String, | |
| val label: String = "", | |
| val range: ClosedFloatingPointRange<Float>, | |
| val color: Color, | |
| val gradientColors: List<Color> = emptyList() | |
| ) | |
| /** | |
| * Configuration for tick marks and their labels. | |
| * | |
| * @param majorTickCount Number of major tick intervals (not marks). 10 produces | |
| * 11 marks at 0%, 10%, 20%, ... 100%. | |
| * @param minorTicksPerMajor Number of minor ticks between each pair of major ticks. | |
| * @param majorTickLength Length of major tick lines, extending inward from the arc. | |
| * @param minorTickLength Length of minor tick lines. | |
| * @param majorTickWidth Stroke width for major ticks. | |
| * @param minorTickWidth Stroke width for minor ticks. | |
| * @param majorTickColor Color for major ticks. | |
| * @param minorTickColor Color for minor ticks. | |
| * @param showLabels Whether to draw value labels at each major tick. | |
| * @param labelColor Color for tick labels. | |
| * @param labelFontSize Font size for tick labels. | |
| * @param labelPadding Distance between the arc outer edge and label anchor. | |
| * @param labelFormatter Transforms a Float value into the label string. | |
| */ | |
| @Immutable | |
| data class GaugeTickConfig( | |
| val majorTickCount: Int = 10, | |
| val minorTicksPerMajor: Int = 4, | |
| val majorTickLength: Dp = 10.dp, | |
| val minorTickLength: Dp = 5.dp, | |
| val majorTickWidth: Dp = 2.dp, | |
| val minorTickWidth: Dp = 1.dp, | |
| val majorTickColor: Color = Color(0xFF374151), | |
| val minorTickColor: Color = Color(0xFF9CA3AF), | |
| val showLabels: Boolean = true, | |
| val labelColor: Color = Color(0xFF6B7280), | |
| val labelFontSize: TextUnit = 11.sp, | |
| val labelPadding: Dp = 8.dp, | |
| val labelFormatter: (Float) -> String = { it.toInt().toString() } | |
| ) | |
| /** Visual style for the gauge needle. */ | |
| enum class GaugeNeedleStyle { | |
| /** Tapered triangle: wide at the center hub, sharp at the tip. */ | |
| Tapered, | |
| /** Thin rounded line from center hub to tip. */ | |
| Line | |
| } | |
| /** | |
| * Configuration for the gauge needle. | |
| * | |
| * @param style Visual shape of the needle. | |
| * @param color Fill (or stroke) color for the needle body. | |
| * @param lengthFraction Needle length as a fraction of the gauge radius. 0.85 = 85%. | |
| * @param tailFraction Small counter-weight tail behind center, as a fraction of radius. | |
| * @param baseRadius Radius of the center hub circle that covers the needle base. | |
| * @param baseColor Color of the center hub circle. | |
| * @param width Needle width at the base (for [GaugeNeedleStyle.Tapered]) or | |
| * stroke width (for [GaugeNeedleStyle.Line]). | |
| */ | |
| @Immutable | |
| data class GaugeNeedleConfig( | |
| val style: GaugeNeedleStyle = GaugeNeedleStyle.Tapered, | |
| val color: Color = Color(0xFFDC2626), | |
| val lengthFraction: Float = 0.80f, | |
| val tailFraction: Float = 0.12f, | |
| val baseRadius: Dp = 10.dp, | |
| val baseColor: Color = Color(0xFF374151), | |
| val width: Dp = 4.dp | |
| ) | |
| /** | |
| * Visual tuning for the gauge chart. | |
| * | |
| * Common arc presets: | |
| * - **3/4 gauge** (default): startAngle=135, sweepAngle=270 (opening at bottom) | |
| * - **Semi-circle**: startAngle=180, sweepAngle=180 | |
| * - **Full circle**: startAngle=-90, sweepAngle=360 | |
| * | |
| * @param startAngle Angle in degrees where the arc begins. 135 = 7:30 position. | |
| * @param sweepAngle Total angular sweep of the arc. 270 = three-quarter circle. | |
| * @param arcWidth Thickness of the gauge arc band. | |
| * @param trackColor Background track color (visible behind zones and value arc). | |
| * @param arcGradientColors When set with 2+ colors, draws a single sweep gradient | |
| * across the entire arc instead of individual zones. The gradient interpolates | |
| * from the first color at the arc start to the last color at the arc end. | |
| * Takes priority over zones when non-empty. | |
| * @param showValueArc Draw a colored arc from min to the current animated value. | |
| * @param valueArcColor Color for the value arc. | |
| * @param valueArcWidth Width of the value arc. Defaults to [arcWidth]. | |
| * @param fillFraction 0..1 ratio of available space used as the gauge radius. | |
| * @param outerRadius Explicit outer radius override. | |
| * @param minSize Minimum intrinsic chart size. | |
| * @param centerContentOffset Vertical offset for the center content slot. | |
| * Positive values push the content downward, away from the needle hub. | |
| * For a 270 sweep, values around 40-56 dp work well. | |
| */ | |
| @Immutable | |
| data class GaugeChartStyle( | |
| val startAngle: Float = 135f, | |
| val sweepAngle: Float = 270f, | |
| val arcWidth: Dp = 20.dp, | |
| val trackColor: Color = Color(0xFFE5E7EB), | |
| val arcGradientColors: List<Color> = emptyList(), | |
| val showValueArc: Boolean = false, | |
| val valueArcColor: Color = Color(0xFF4F46E5), | |
| val valueArcWidth: Dp = Dp.Unspecified, | |
| val fillFraction: Float = 0.85f, | |
| val outerRadius: Dp = Dp.Unspecified, | |
| val minSize: Dp = 200.dp, | |
| val centerContentOffset: Dp = 48.dp | |
| ) | |
| /** | |
| * Animation configuration for the gauge needle. | |
| * | |
| * The default uses an underdamped spring (dampingRatio < 1.0) that causes the | |
| * needle to overshoot its target and oscillate before settling. | |
| * | |
| * @param needleSpec Spring or tween spec driving the needle. | |
| * @param initialDelayMs Delay before the needle begins its first animation. | |
| */ | |
| @Immutable | |
| data class GaugeAnimationConfig( | |
| val needleSpec: AnimationSpec<Float> = spring( | |
| dampingRatio = 0.45f, | |
| stiffness = Spring.StiffnessLow | |
| ), | |
| val initialDelayMs: Long = 200L | |
| ) | |
| /** | |
| * Accessibility text builders for the gauge. | |
| */ | |
| @Stable | |
| data class GaugeA11yConfig( | |
| val descriptionBuilder: ( | |
| value: Float, minValue: Float, maxValue: Float, zones: List<GaugeZone> | |
| ) -> String = { value, minValue, maxValue, zones -> | |
| val pct = (((value - minValue) / (maxValue - minValue)) * 100).toInt() | |
| val activeZone = zones.find { value in it.range } | |
| buildString { | |
| append("Gauge at $pct percent. Value: ${value.toInt()} of ${maxValue.toInt()}.") | |
| activeZone?.let { append(" Zone: ${it.label}.") } | |
| } | |
| } | |
| ) | |
| // ========================================== | |
| // 2. INTERNAL HELPERS | |
| // ========================================== | |
| /** Pre-computed trig values for all tick mark positions. */ | |
| @Stable | |
| private class TickTrigData( | |
| val majorCos: FloatArray, | |
| val majorSin: FloatArray, | |
| val minorCos: FloatArray, | |
| val minorSin: FloatArray | |
| ) | |
| /** Resolves the gauge outer radius in pixels. */ | |
| private fun resolveGaugeRadius( | |
| style: GaugeChartStyle, | |
| canvasWidth: Float, | |
| canvasHeight: Float, | |
| tickLabelSpace: Float, | |
| density: androidx.compose.ui.unit.Density | |
| ): Float { | |
| return if (style.outerRadius != Dp.Unspecified) { | |
| with(density) { style.outerRadius.toPx() } | |
| } else { | |
| val available = (min(canvasWidth, canvasHeight) / 2f) - tickLabelSpace | |
| available * style.fillFraction.coerceIn(0.1f, 1f) | |
| } | |
| } | |
| /** Maps a value to a needle angle in degrees, accounting for RTL. */ | |
| private fun valueToAngle( | |
| value: Float, | |
| minValue: Float, | |
| maxValue: Float, | |
| startAngle: Float, | |
| sweepAngle: Float, | |
| isRtl: Boolean | |
| ): Float { | |
| val fraction = ((value - minValue) / (maxValue - minValue)).coerceIn(0f, 1f) | |
| val directed = if (isRtl) 1f - fraction else fraction | |
| return startAngle + sweepAngle * directed | |
| } | |
| /** | |
| * Computes [Brush.sweepGradient] color stops that map [colors] precisely to | |
| * the angular range [startAngle]..[startAngle + sweepAngle]. Handles wrap-around | |
| * past 360 correctly. Returns null if fewer than 2 colors. | |
| */ | |
| private fun computeArcGradientStops( | |
| colors: List<Color>, | |
| startAngle: Float, | |
| sweepAngle: Float | |
| ): Array<Pair<Float, Color>>? { | |
| if (colors.size < 2) return null | |
| val startFrac = ((startAngle % 360f + 360f) % 360f) / 360f | |
| val sweepFrac = sweepAngle / 360f | |
| return colors.mapIndexed { i, color -> | |
| val t = i.toFloat() / (colors.size - 1) | |
| var pos = startFrac + t * sweepFrac | |
| if (pos > 1f) pos -= 1f | |
| pos to color | |
| }.sortedBy { it.first }.toTypedArray() | |
| } | |
| // ========================================== | |
| // 3. THE CHART COMPONENT | |
| // ========================================== | |
| /** | |
| * A composable gauge/speedometer chart with spring-physics needle animation, | |
| * configurable color zones with gradient support, tick marks, RTL mirroring, | |
| * accessibility, and a center content slot. | |
| * | |
| * Usage: | |
| * ``` | |
| * var speed by remember { mutableFloatStateOf(42f) } | |
| * | |
| * GaugeChart( | |
| * value = speed, | |
| * minValue = 0f, | |
| * maxValue = 100f, | |
| * modifier = Modifier.size(300.dp), | |
| * zones = listOf( | |
| * GaugeZone("safe", "Safe", 0f..40f, Color.Green), | |
| * GaugeZone("warn", "Warning", 40f..70f, Color.Yellow), | |
| * GaugeZone("danger", "Danger", 70f..100f, Color.Red) | |
| * ), | |
| * centerContent = { | |
| * Text("${speed.toInt()}", fontSize = 32.sp, fontWeight = FontWeight.Bold) | |
| * } | |
| * ) | |
| * ``` | |
| * | |
| * Gradient arc (instead of zones): | |
| * ``` | |
| * GaugeChart( | |
| * value = 65f, | |
| * style = GaugeChartStyle( | |
| * arcGradientColors = listOf(Color.Green, Color.Yellow, Color.Red) | |
| * ) | |
| * ) | |
| * ``` | |
| * | |
| * @param value The current gauge reading. Animated with spring physics. | |
| * @param minValue Lower bound of the gauge scale. | |
| * @param maxValue Upper bound of the gauge scale. | |
| * @param zones Optional colored arc segments. Overridden by | |
| * [GaugeChartStyle.arcGradientColors] when set. | |
| * @param style Visual configuration for the arc geometry, colors, and gradient. | |
| * @param tickConfig Tick mark and label configuration. | |
| * @param needleConfig Needle shape, color, and sizing. | |
| * @param animationConfig Spring spec for the needle animation. | |
| * @param a11yConfig Accessibility label builders. | |
| * @param centerContent Optional composable rendered below the gauge hub. | |
| * Offset controlled by [GaugeChartStyle.centerContentOffset]. | |
| */ | |
| @Composable | |
| fun GaugeChart( | |
| value: Float, | |
| modifier: Modifier = Modifier, | |
| minValue: Float = 0f, | |
| maxValue: Float = 100f, | |
| zones: List<GaugeZone> = emptyList(), | |
| style: GaugeChartStyle = GaugeChartStyle(), | |
| tickConfig: GaugeTickConfig = GaugeTickConfig(), | |
| needleConfig: GaugeNeedleConfig = GaugeNeedleConfig(), | |
| animationConfig: GaugeAnimationConfig = GaugeAnimationConfig(), | |
| a11yConfig: GaugeA11yConfig = GaugeA11yConfig(), | |
| centerContent: @Composable (() -> Unit)? = null | |
| ) { | |
| val textMeasurer = rememberTextMeasurer() | |
| val density = LocalDensity.current | |
| val layoutDirection = LocalLayoutDirection.current | |
| val isRtl = layoutDirection == LayoutDirection.Rtl | |
| // ── Needle animation ── | |
| val targetAngle = | |
| remember(value, minValue, maxValue, style.startAngle, style.sweepAngle, isRtl) { | |
| valueToAngle(value, minValue, maxValue, style.startAngle, style.sweepAngle, isRtl) | |
| } | |
| val needleAnimatable = remember { Animatable(style.startAngle) } | |
| LaunchedEffect(targetAngle) { | |
| needleAnimatable.animateTo(targetAngle, animationConfig.needleSpec) | |
| } | |
| // ── Pre-computed tick trig (zero trig in draw) ── | |
| val tickTrig = remember( | |
| tickConfig.majorTickCount, tickConfig.minorTicksPerMajor, | |
| style.startAngle, style.sweepAngle | |
| ) { | |
| val majors = tickConfig.majorTickCount | |
| val minorsPerMajor = tickConfig.minorTicksPerMajor | |
| val majorAnglesRad = DoubleArray(majors + 1) { i -> | |
| Math.toRadians((style.startAngle + style.sweepAngle * (i.toFloat() / majors)).toDouble()) | |
| } | |
| val minorAnglesList = mutableListOf<Double>() | |
| val majorStep = style.sweepAngle / majors | |
| val minorStep = majorStep / (minorsPerMajor + 1) | |
| for (m in 0 until majors) { | |
| for (n in 1..minorsPerMajor) { | |
| minorAnglesList.add( | |
| Math.toRadians((style.startAngle + m * majorStep + n * minorStep).toDouble()) | |
| ) | |
| } | |
| } | |
| TickTrigData( | |
| majorCos = FloatArray(majors + 1) { cos(majorAnglesRad[it]).toFloat() }, | |
| majorSin = FloatArray(majors + 1) { sin(majorAnglesRad[it]).toFloat() }, | |
| minorCos = FloatArray(minorAnglesList.size) { cos(minorAnglesList[it]).toFloat() }, | |
| minorSin = FloatArray(minorAnglesList.size) { sin(minorAnglesList[it]).toFloat() } | |
| ) | |
| } | |
| // ── Pre-measured tick labels ── | |
| val tickLabelStyle = remember(tickConfig.labelColor, tickConfig.labelFontSize) { | |
| TextStyle( | |
| color = tickConfig.labelColor, | |
| fontSize = tickConfig.labelFontSize, | |
| fontWeight = FontWeight.Medium | |
| ) | |
| } | |
| val tickLabelLayouts: List<TextLayoutResult> = remember( | |
| minValue, maxValue, tickConfig.majorTickCount, | |
| tickConfig.showLabels, tickConfig.labelFormatter, tickLabelStyle | |
| ) { | |
| if (!tickConfig.showLabels) emptyList() | |
| else { | |
| val step = (maxValue - minValue) / tickConfig.majorTickCount | |
| (0..tickConfig.majorTickCount).map { i -> | |
| textMeasurer.measure( | |
| text = tickConfig.labelFormatter(minValue + i * step), | |
| style = tickLabelStyle, | |
| maxLines = 1 | |
| ) | |
| } | |
| } | |
| } | |
| val maxTickLabelDim = remember(tickLabelLayouts) { | |
| if (tickLabelLayouts.isEmpty()) 0f | |
| else tickLabelLayouts.maxOf { max(it.size.width, it.size.height) }.toFloat() | |
| } | |
| // ── Pre-computed gradient stops (zero allocation in draw for the stops) ── | |
| val globalGradientStops = | |
| remember(style.arcGradientColors, style.startAngle, style.sweepAngle) { | |
| computeArcGradientStops(style.arcGradientColors, style.startAngle, style.sweepAngle) | |
| } | |
| val zoneGradientStops: Map<String, Array<Pair<Float, Color>>> = remember( | |
| zones, style.startAngle, style.sweepAngle, minValue, maxValue, isRtl | |
| ) { | |
| val range = maxValue - minValue | |
| if (range <= 0f) return@remember emptyMap() | |
| zones.filter { it.gradientColors.size >= 2 }.associate { zone -> | |
| val sf = ((zone.range.start - minValue) / range).coerceIn(0f, 1f) | |
| val ef = ((zone.range.endInclusive - minValue) / range).coerceIn(0f, 1f) | |
| val (startF, endF) = if (isRtl) (1f - ef) to (1f - sf) else sf to ef | |
| val zoneStartAngle = style.startAngle + style.sweepAngle * startF | |
| val zoneSweep = style.sweepAngle * (endF - startF) | |
| zone.id to (computeArcGradientStops(zone.gradientColors, zoneStartAngle, zoneSweep) | |
| ?: emptyArray()) | |
| }.filterValues { it.isNotEmpty() } | |
| } | |
| // ── Accessibility ── | |
| val chartDescription = remember(value, minValue, maxValue, zones, a11yConfig) { | |
| a11yConfig.descriptionBuilder(value, minValue, maxValue, zones) | |
| } | |
| val clampedValue = value.coerceIn(minValue, maxValue) | |
| // ── Reusable Path ── | |
| val needlePath = remember { Path() } | |
| Box( | |
| modifier = modifier.defaultMinSize(minWidth = style.minSize, minHeight = style.minSize), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .semantics(mergeDescendants = true) { | |
| contentDescription = chartDescription | |
| progressBarRangeInfo = ProgressBarRangeInfo( | |
| current = clampedValue, | |
| range = minValue..maxValue | |
| ) | |
| } | |
| ) { | |
| val cx = size.width / 2f | |
| val cy = size.height / 2f | |
| val center = Offset(cx, cy) | |
| val tickLabelSpace = if (tickConfig.showLabels) { | |
| tickConfig.labelPadding.toPx() + maxTickLabelDim / 2f | |
| } else 0f | |
| val gaugeRadius = | |
| resolveGaugeRadius(style, size.width, size.height, tickLabelSpace, density) | |
| val arcWidthPx = style.arcWidth.toPx() | |
| val arcCenterRadius = gaugeRadius - arcWidthPx / 2f | |
| val arcRect = Size(arcCenterRadius * 2, arcCenterRadius * 2) | |
| val arcTopLeft = Offset(cx - arcCenterRadius, cy - arcCenterRadius) | |
| // ── 1. Background track ── | |
| drawArc( | |
| color = style.trackColor, | |
| startAngle = style.startAngle, | |
| sweepAngle = style.sweepAngle, | |
| useCenter = false, | |
| topLeft = arcTopLeft, | |
| size = arcRect, | |
| style = Stroke(width = arcWidthPx, cap = StrokeCap.Round) | |
| ) | |
| // ── 2. Arc fill: global gradient > zones > nothing ── | |
| val range = maxValue - minValue | |
| if (globalGradientStops != null) { | |
| // Global sweep gradient across the full arc | |
| val brush = Brush.sweepGradient( | |
| *globalGradientStops, | |
| center = center | |
| ) | |
| drawArc( | |
| brush = brush, | |
| startAngle = style.startAngle, | |
| sweepAngle = style.sweepAngle, | |
| useCenter = false, | |
| topLeft = arcTopLeft, | |
| size = arcRect, | |
| style = Stroke(width = arcWidthPx, cap = StrokeCap.Round) | |
| ) | |
| } else if (range > 0f) { | |
| // Per-zone arcs | |
| zones.forEach { zone -> | |
| val zoneStartFrac = ((zone.range.start - minValue) / range).coerceIn(0f, 1f) | |
| val zoneEndFrac = | |
| ((zone.range.endInclusive - minValue) / range).coerceIn(0f, 1f) | |
| val (sf, ef) = if (isRtl) (1f - zoneEndFrac) to (1f - zoneStartFrac) else zoneStartFrac to zoneEndFrac | |
| val zoneStart = style.startAngle + style.sweepAngle * sf | |
| val zoneSweep = style.sweepAngle * (ef - sf) | |
| if (zoneSweep > 0f) { | |
| val gradStops = zoneGradientStops[zone.id] | |
| if (gradStops != null) { | |
| drawArc( | |
| brush = Brush.sweepGradient(*gradStops, center = center), | |
| startAngle = zoneStart, | |
| sweepAngle = zoneSweep, | |
| useCenter = false, | |
| topLeft = arcTopLeft, | |
| size = arcRect, | |
| style = Stroke(width = arcWidthPx, cap = StrokeCap.Butt) | |
| ) | |
| } else { | |
| drawArc( | |
| color = zone.color, | |
| startAngle = zoneStart, | |
| sweepAngle = zoneSweep, | |
| useCenter = false, | |
| topLeft = arcTopLeft, | |
| size = arcRect, | |
| style = Stroke(width = arcWidthPx, cap = StrokeCap.Butt) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| // ── 3. Value arc (optional) ── | |
| if (style.showValueArc && range > 0f) { | |
| val animAngle = needleAnimatable.value | |
| val valueSweep = (animAngle - style.startAngle).coerceIn(0f, style.sweepAngle) | |
| val valueArcW = | |
| if (style.valueArcWidth != Dp.Unspecified) style.valueArcWidth.toPx() else arcWidthPx | |
| val valueArcCR = gaugeRadius - valueArcW / 2f | |
| drawArc( | |
| color = style.valueArcColor, | |
| startAngle = style.startAngle, | |
| sweepAngle = valueSweep, | |
| useCenter = false, | |
| topLeft = Offset(cx - valueArcCR, cy - valueArcCR), | |
| size = Size(valueArcCR * 2, valueArcCR * 2), | |
| style = Stroke(width = valueArcW, cap = StrokeCap.Round) | |
| ) | |
| } | |
| // ── 4. Tick marks ── | |
| val majorTickLenPx = tickConfig.majorTickLength.toPx() | |
| val minorTickLenPx = tickConfig.minorTickLength.toPx() | |
| val majorTickWidthPx = tickConfig.majorTickWidth.toPx() | |
| val minorTickWidthPx = tickConfig.minorTickWidth.toPx() | |
| val minorInnerR = gaugeRadius - minorTickLenPx | |
| val majorInnerR = gaugeRadius - majorTickLenPx | |
| for (i in tickTrig.minorCos.indices) { | |
| drawLine( | |
| color = tickConfig.minorTickColor, | |
| start = Offset( | |
| cx + gaugeRadius * tickTrig.minorCos[i], | |
| cy + gaugeRadius * tickTrig.minorSin[i] | |
| ), | |
| end = Offset( | |
| cx + minorInnerR * tickTrig.minorCos[i], | |
| cy + minorInnerR * tickTrig.minorSin[i] | |
| ), | |
| strokeWidth = minorTickWidthPx | |
| ) | |
| } | |
| for (i in tickTrig.majorCos.indices) { | |
| drawLine( | |
| color = tickConfig.majorTickColor, | |
| start = Offset( | |
| cx + gaugeRadius * tickTrig.majorCos[i], | |
| cy + gaugeRadius * tickTrig.majorSin[i] | |
| ), | |
| end = Offset( | |
| cx + majorInnerR * tickTrig.majorCos[i], | |
| cy + majorInnerR * tickTrig.majorSin[i] | |
| ), | |
| strokeWidth = majorTickWidthPx | |
| ) | |
| } | |
| // ── 5. Tick labels ── | |
| if (tickConfig.showLabels && tickLabelLayouts.isNotEmpty()) { | |
| val labelPadPx = tickConfig.labelPadding.toPx() | |
| val labelR = gaugeRadius + labelPadPx | |
| val count = min(tickTrig.majorCos.size, tickLabelLayouts.size) | |
| for (i in 0 until count) { | |
| val idx = if (isRtl) (count - 1 - i) else i | |
| val layout = tickLabelLayouts[idx] | |
| val cosV = tickTrig.majorCos[i] | |
| val sinV = tickTrig.majorSin[i] | |
| val lx = cx + labelR * cosV | |
| val ly = cy + labelR * sinV | |
| drawText( | |
| textLayoutResult = layout, | |
| topLeft = Offset( | |
| x = lx - layout.size.width / 2f + cosV * layout.size.width / 2f, | |
| y = ly - layout.size.height / 2f + sinV * layout.size.height / 2f | |
| ) | |
| ) | |
| } | |
| } | |
| // ── 6. Needle ── | |
| val animatedAngle = needleAnimatable.value | |
| val needleAngleRad = Math.toRadians(animatedAngle.toDouble()) | |
| val cosN = cos(needleAngleRad).toFloat() | |
| val sinN = sin(needleAngleRad).toFloat() | |
| val needleLength = gaugeRadius * needleConfig.lengthFraction | |
| val tailLength = gaugeRadius * needleConfig.tailFraction | |
| val needleWidthPx = needleConfig.width.toPx() | |
| val tipX = cx + needleLength * cosN | |
| val tipY = cy + needleLength * sinN | |
| when (needleConfig.style) { | |
| GaugeNeedleStyle.Tapered -> { | |
| val perpX = -sinN * needleWidthPx / 2f | |
| val perpY = cosN * needleWidthPx / 2f | |
| val tailX = cx - tailLength * cosN | |
| val tailY = cy - tailLength * sinN | |
| needlePath.apply { | |
| rewind() | |
| moveTo(tipX, tipY) | |
| lineTo(cx + perpX, cy + perpY) | |
| lineTo(tailX, tailY) | |
| lineTo(cx - perpX, cy - perpY) | |
| close() | |
| } | |
| drawPath(path = needlePath, color = Color.Black.copy(alpha = 0.08f)) | |
| drawPath(path = needlePath, color = needleConfig.color) | |
| } | |
| GaugeNeedleStyle.Line -> { | |
| drawLine( | |
| color = needleConfig.color, | |
| start = Offset(cx - tailLength * cosN, cy - tailLength * sinN), | |
| end = Offset(tipX, tipY), | |
| strokeWidth = needleWidthPx, | |
| cap = StrokeCap.Round | |
| ) | |
| } | |
| } | |
| // ── 7. Center hub ── | |
| val baseRadiusPx = needleConfig.baseRadius.toPx() | |
| drawCircle(color = needleConfig.baseColor, radius = baseRadiusPx, center = center) | |
| drawCircle(color = needleConfig.color, radius = baseRadiusPx * 0.4f, center = center) | |
| } | |
| // ── Center content, offset below the hub ── | |
| if (centerContent != null) { | |
| Box( | |
| modifier = Modifier.padding(top = style.centerContentOffset), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| centerContent() | |
| } | |
| } | |
| } | |
| } | |
| // ========================================== | |
| // 4. DEMO IMPLEMENTATION | |
| // ========================================== | |
| private data class GaugePreset(val label: String, val value: Float, val color: Long) | |
| private val GaugePresets = listOf( | |
| GaugePreset("Low", 18f, 0xFF22C55E), | |
| GaugePreset("Normal", 48f, 0xFF3B82F6), | |
| GaugePreset("High", 73f, 0xFFFBBF24), | |
| GaugePreset("Critical", 92f, 0xFFEF4444) | |
| ) | |
| @Composable | |
| fun GaugeChartDemoScreen() { | |
| val zones = remember { | |
| listOf( | |
| GaugeZone("low", "Low", 0f..30f, Color(0xFF22C55E)), | |
| GaugeZone("normal", "Normal", 30f..60f, Color(0xFF3B82F6)), | |
| GaugeZone("high", "High", 60f..80f, Color(0xFFFBBF24)), | |
| GaugeZone("critical", "Critical", 80f..100f, Color(0xFFEF4444)) | |
| ) | |
| } | |
| var currentValue by remember { mutableFloatStateOf(0f) } | |
| LaunchedEffect(Unit) { | |
| delay(400) | |
| currentValue = 42f | |
| } | |
| val activeZone = remember(currentValue, zones) { | |
| zones.find { currentValue in it.range } | |
| } | |
| var useGradientArc by remember { mutableStateOf(false) } | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color(0xFFF8FAFC)) | |
| .padding(24.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| verticalArrangement = Arrangement.Center | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(480.dp) | |
| .background(Color.White, shape = RoundedCornerShape(24.dp)) | |
| .padding(24.dp) | |
| ) { | |
| Column( | |
| modifier = Modifier.fillMaxSize(), | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| Text( | |
| "Performance", | |
| fontSize = 20.sp, | |
| fontWeight = FontWeight.ExtraBold, | |
| color = Color(0xFF111827), | |
| modifier = Modifier.padding(bottom = 4.dp) | |
| ) | |
| Text( | |
| text = activeZone?.let { "Status: ${it.label}" } ?: "Initializing...", | |
| fontSize = 13.sp, | |
| color = Color(0xFF6B7280), | |
| modifier = Modifier.padding(bottom = 12.dp) | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .weight(1f), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| GaugeChart( | |
| value = currentValue, | |
| minValue = 0f, | |
| maxValue = 100f, | |
| modifier = Modifier.fillMaxSize(), | |
| zones = if (useGradientArc) emptyList() else zones, | |
| style = GaugeChartStyle( | |
| startAngle = 135f, | |
| sweepAngle = 270f, | |
| arcWidth = 24.dp, | |
| trackColor = Color(0xFFF1F5F9), | |
| arcGradientColors = if (useGradientArc) listOf( | |
| Color(0xFF22C55E), | |
| Color(0xFF3B82F6), | |
| Color(0xFFFBBF24), | |
| Color(0xFFEF4444) | |
| ) else emptyList(), | |
| fillFraction = 0.82f, | |
| centerContentOffset = 52.dp | |
| ), | |
| tickConfig = GaugeTickConfig( | |
| majorTickCount = 10, | |
| minorTicksPerMajor = 4, | |
| majorTickLength = 10.dp, | |
| minorTickLength = 5.dp, | |
| majorTickColor = Color(0xFF9CA3AF), | |
| minorTickColor = Color(0xFFD1D5DB), | |
| labelColor = Color(0xFF6B7280), | |
| labelFontSize = 10.sp | |
| ), | |
| needleConfig = GaugeNeedleConfig( | |
| style = GaugeNeedleStyle.Tapered, | |
| color = activeZone?.color ?: Color(0xFFDC2626), | |
| baseColor = Color(0xFF374151), | |
| baseRadius = 10.dp, | |
| width = 5.dp, | |
| lengthFraction = 0.78f, | |
| tailFraction = 0.14f | |
| ), | |
| animationConfig = GaugeAnimationConfig( | |
| needleSpec = spring( | |
| dampingRatio = 0.42f, | |
| stiffness = Spring.StiffnessLow | |
| ) | |
| ), | |
| centerContent = { | |
| Column( | |
| modifier = Modifier.padding(top = 20.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| Text( | |
| text = "${currentValue.toInt()}", | |
| fontSize = 40.sp, | |
| fontWeight = FontWeight.Black, | |
| color = activeZone?.color ?: Color(0xFF111827) | |
| ) | |
| Text( | |
| text = "percent", | |
| fontSize = 12.sp, | |
| color = Color(0xFF9CA3AF), | |
| fontWeight = FontWeight.Medium | |
| ) | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(20.dp)) | |
| // Preset buttons | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.spacedBy(8.dp) | |
| ) { | |
| GaugePresets.forEach { preset -> | |
| Button( | |
| onClick = { currentValue = preset.value }, | |
| colors = ButtonDefaults.buttonColors(containerColor = Color(preset.color)), | |
| shape = RoundedCornerShape(12.dp), | |
| contentPadding = PaddingValues(horizontal = 4.dp), | |
| modifier = Modifier | |
| .weight(1f) | |
| .height(50.dp) | |
| ) { | |
| Text( | |
| text = preset.label, | |
| fontSize = 13.sp, | |
| fontWeight = FontWeight.Bold, | |
| maxLines = 1, | |
| overflow = TextOverflow.Ellipsis, | |
| color = if (preset.color == 0xFFFBBF24) Color(0xFF78350F) else Color.White | |
| ) | |
| } | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(8.dp)) | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.spacedBy(8.dp) | |
| ) { | |
| Button( | |
| onClick = { useGradientArc = !useGradientArc }, | |
| colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)), | |
| shape = RoundedCornerShape(12.dp), | |
| modifier = Modifier | |
| .weight(1f) | |
| .height(50.dp) | |
| ) { | |
| Text( | |
| text = if (useGradientArc) "Zone Mode" else "Gradient Mode", | |
| fontSize = 13.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = Color.White | |
| ) | |
| } | |
| Button( | |
| onClick = { currentValue = Random.nextInt(0, 101).toFloat() }, | |
| colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF111827)), | |
| shape = RoundedCornerShape(12.dp), | |
| modifier = Modifier | |
| .weight(1f) | |
| .height(50.dp) | |
| ) { | |
| Text( | |
| text = "Random", | |
| fontSize = 13.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = Color.White | |
| ) | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment