package com.evsuite.abrp; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.RectF; import android.util.AttributeSet; import android.view.View; /** * The Cockpit's instrument: a ring of segments open at the bottom, lit up to the value, brighter * towards the leading segment, with text inside — a small label on top, the value large, its * unit, and up to two caption lines. * * Two kinds of scale: from {@code min} at the lower left round to {@code max} at the lower right, * or centred ({@link #setCentred}): zero at the top, positive values clockwise in the accent * colour, negative ones (regeneration) anticlockwise in green. An optional marker shows a second * value on the same scale (an average). Null draws no lit segments and the text "—": an unread * value is never shown as zero. Canvas only, no gradient drawables (Android 9 crashes on those * with theme colours). */ public final class RingView extends View { private static final float START_DEG = 135f; private static final float SWEEP_DEG = 270f; private static final float TOP_DEG = 270f; private final Paint track = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint lit = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint halo = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint hairline = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint markerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint topPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint pill = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint big = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint unitPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint caption = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint scale = new Paint(Paint.ANTI_ALIAS_FLAG); private final RectF oval = new RectF(); private final RectF pillRect = new RectF(); private final int accent, regen, warn; private float min = 0f, max = 100f; private boolean centred; private int segments = 48; private Float warnBelow; private String minLabel, maxLabel, zeroLabel; private float bigScale = 0.40f; private boolean topPill; private String top, valueText = "—", unit = "", caption1, caption2; private Float target, marker; private float shown = Float.NaN; public RingView(Context context, AttributeSet attrs) { super(context, attrs); accent = AccentColor.resolve(context, R.attr.evAccent); int light = AccentColor.resolve(context, R.attr.evAccentLight); regen = context.getColor(R.color.ev_ok); warn = context.getColor(R.color.ev_error); track.setStyle(Paint.Style.STROKE); track.setColor(AccentColor.withAlpha(accent, 0x26)); lit.setStyle(Paint.Style.STROKE); halo.setStyle(Paint.Style.STROKE); hairline.setStyle(Paint.Style.STROKE); hairline.setColor(context.getColor(R.color.ev_outline)); markerPaint.setColor(context.getColor(R.color.ev_text_primary)); markerPaint.setStrokeCap(Paint.Cap.ROUND); topPaint.setColor(light); topPaint.setTextAlign(Paint.Align.CENTER); topPaint.setFakeBoldText(true); pill.setStyle(Paint.Style.STROKE); pill.setColor(AccentColor.withAlpha(accent, 0x99)); big.setColor(context.getColor(R.color.ev_text_primary)); big.setTextAlign(Paint.Align.CENTER); big.setFontFeatureSettings("tnum"); big.setTypeface(android.graphics.Typeface.create("sans-serif-light", android.graphics.Typeface.NORMAL)); unitPaint.setColor(light); unitPaint.setTextAlign(Paint.Align.CENTER); unitPaint.setLetterSpacing(0.12f); caption.setColor(context.getColor(R.color.ev_text_secondary)); caption.setTextAlign(Paint.Align.CENTER); caption.setFontFeatureSettings("tnum"); scale.setColor(context.getColor(R.color.ev_text_secondary)); scale.setTextAlign(Paint.Align.CENTER); scale.setFontFeatureSettings("tnum"); } /** A scale from min (lower left) to max (lower right), in this many segments. */ void configure(float min, float max, int segments) { this.min = min; this.max = max; this.segments = Math.max(4, segments); this.centred = false; invalidate(); } /** A scale with zero at the top: max clockwise, min (negative) anticlockwise. */ void setCentred(float min, float max, int segments) { configure(min, max, segments); this.centred = true; } /** Labels at the two ends of the scale, and at the top (centred scales). */ void setScaleLabels(String minLabel, String zeroLabel, String maxLabel) { this.minLabel = minLabel; this.zeroLabel = zeroLabel; this.maxLabel = maxLabel; invalidate(); } /** Lit segments in the warning colour while the value is below this (a low battery). */ void setWarnBelow(Float value) { warnBelow = value; invalidate(); } /** The size of the value text, as a fraction of the radius. */ void setBigScale(float fraction) { bigScale = fraction; invalidate(); } /** The label on top in an outlined pill (the gear) instead of as a plain title. */ void setTopPill(boolean pill) { topPill = pill; invalidate(); } /** * The value (null when unread) and the texts: the label on top (null for none), the value's * digits ("—" when null), its unit and two caption lines (null for none). */ void setValue(Float value, String top, String valueText, String unit, String caption1, String caption2) { target = value == null || !Float.isFinite(value) ? null : value; this.top = top; this.valueText = valueText == null ? "—" : valueText; this.unit = unit == null ? "" : unit; this.caption1 = caption1; this.caption2 = caption2; if (target != null && Float.isNaN(shown)) shown = target; invalidate(); } /** A second value on the same scale, as a tick on the ring; null for none. */ void setMarker(Float value) { marker = value == null || !Float.isFinite(value) ? null : value; invalidate(); } /** The angle of a value; on a centred scale each side has half the sweep. */ private float angleOf(float v) { v = Math.max(min, Math.min(max, v)); if (!centred) return START_DEG + SWEEP_DEG * (v - min) / (max - min); float half = SWEEP_DEG / 2f; if (v >= 0f) return TOP_DEG + (max > 0f ? half * v / max : 0f); return TOP_DEG - (min < 0f ? half * v / min : 0f); } @Override protected void onDraw(Canvas c) { super.onDraw(c); float w = getWidth(), h = getHeight(); // The ring is open at the bottom, so the circle may hang below the view by the part of // the gap that holds only the end labels. float r = Math.min(w / 2f, h / 1.86f) * 0.96f; if (r <= 0f) return; float cx = w / 2f, cy = h / 2f + r * 0.06f; float thick = r * 0.13f; float rr = r - thick / 2f; oval.set(cx - rr, cy - rr, cx + rr, cy + rr); track.setStrokeWidth(thick); lit.setStrokeWidth(thick); halo.setStrokeWidth(thick * 1.7f); // The value, eased towards its target. boolean animating = false; if (target != null) { shown += (target - shown) * 0.25f; if (Math.abs(target - shown) < (max - min) * 0.001f) shown = target; animating = shown != target; } float seg = SWEEP_DEG / segments; float gap = Math.min(seg * 0.32f, 2.2f); float zeroAngle = centred ? TOP_DEG : START_DEG; float valueAngle = target == null ? zeroAngle : angleOf(shown); float from = Math.min(zeroAngle, valueAngle), to = Math.max(zeroAngle, valueAngle); boolean negative = valueAngle < zeroAngle; int colour = negative ? regen : warnBelow != null && target != null && shown < warnBelow ? warn : accent; float span = Math.max(seg, to - from); for (int i = 0; i < segments; i++) { float a0 = START_DEG + i * seg; float mid = a0 + seg / 2f; boolean on = target != null && mid >= from && mid <= to; if (!on) { c.drawArc(oval, a0 + gap / 2f, seg - gap, false, track); continue; } // Brighter away from zero, towards the leading segment. float f = Math.max(0f, Math.min(1f, (negative ? to - mid : mid - from) / span)); int alpha = (int) (0x55 + (0xFF - 0x55) * f); lit.setColor(AccentColor.withAlpha(colour, alpha)); c.drawArc(oval, a0 + gap / 2f, seg - gap, false, lit); } if (target != null && to - from >= seg * 0.5f) { // A soft halo on the leading segment. float lead = negative ? from : to; halo.setColor(AccentColor.withAlpha(colour, 0x33)); c.drawArc(oval, lead - (negative ? 0f : seg), seg, false, halo); } // A fine inner ring, and the scale's zero at the top. hairline.setStrokeWidth(Math.max(1f, r * 0.006f)); float ir = r - thick - r * 0.05f; oval.set(cx - ir, cy - ir, cx + ir, cy + ir); c.drawArc(oval, START_DEG, SWEEP_DEG, false, hairline); if (centred) { markerPaint.setStrokeWidth(r * 0.012f); c.drawLine(cx, cy - ir, cx, cy - ir + r * 0.05f, markerPaint); } if (marker != null) { double a = Math.toRadians(angleOf(marker)); float cos = (float) Math.cos(a), sin = (float) Math.sin(a); markerPaint.setStrokeWidth(r * 0.022f); float o = r + r * 0.015f, in = r - thick - r * 0.03f; c.drawLine(cx + o * cos, cy + o * sin, cx + in * cos, cy + in * sin, markerPaint); } // End labels, tucked into the gap under the ends of the ring. scale.setTextSize(r * 0.075f); drawScaleLabel(c, cx, cy, rr, START_DEG - 9f, minLabel, negative && target != null ? regen : 0); drawScaleLabel(c, cx, cy, rr, START_DEG + SWEEP_DEG + 9f, maxLabel, 0); if (centred && zeroLabel != null) { c.drawText(zeroLabel, cx, cy - ir + r * 0.05f + scale.getTextSize() * 1.15f, scale); } // Inside: the label on top in a pill, the value, the unit, the captions. if (top != null) { topPaint.setTextSize(r * (topPill ? 0.11f : 0.075f)); topPaint.setLetterSpacing(topPill ? 0f : 0.16f); float ty = cy - r * 0.40f; if (topPill) { float tw = Math.max(topPaint.measureText(top), topPaint.getTextSize() * 0.7f); float ph = topPaint.getTextSize() * 1.45f; pillRect.set(cx - tw / 2f - ph * 0.45f, ty - ph * 0.72f, cx + tw / 2f + ph * 0.45f, ty + ph * 0.28f); pill.setStrokeWidth(Math.max(1.5f, r * 0.008f)); c.drawRoundRect(pillRect, ph / 2f, ph / 2f, pill); } c.drawText(top, cx, ty, topPaint); } big.setTextSize(r * bigScale); c.drawText(valueText, cx, cy + big.getTextSize() * 0.30f, big); unitPaint.setTextSize(r * 0.085f); c.drawText(unit, cx, cy + big.getTextSize() * 0.30f + r * 0.15f, unitPaint); caption.setTextSize(r * 0.085f); float y = cy + big.getTextSize() * 0.30f + r * 0.34f; if (caption1 != null) c.drawText(caption1, cx, y, caption); if (caption2 != null) c.drawText(caption2, cx, y + caption.getTextSize() * 1.3f, caption); if (animating) postInvalidateOnAnimation(); } private void drawScaleLabel(Canvas c, float cx, float cy, float radius, float deg, String label, int colour) { if (label == null) return; double a = Math.toRadians(deg); int old = scale.getColor(); if (colour != 0) scale.setColor(colour); c.drawText(label, cx + radius * (float) Math.cos(a), cy + radius * (float) Math.sin(a) + scale.getTextSize() * 0.36f, scale); scale.setColor(old); } }