🌈 Create new beautiful Compose Gradients with our new wesite ->

Scanner radar

Stijn De Raeve-Zenner
Kotlin
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
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.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextMeasurer
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin

// region Public

/**
 * A contact plotted on the radar.
 *
 * @param bearing degrees clockwise from straight up (0 = north, 90 = east).
 * @param distance 0f at the centre, 1f at the outer ring.
 */
@Immutable
data class RadarContact(
    val id: String,
    val label: String,
    val bearing: Float,
    val distance: Float,
)

/**
 * A sweeping scanner radar.
 *
 * The beam rotates continuously; every contact lights up the moment the beam crosses its bearing,
 * throws off an expanding ping ring, then decays back into the dark until the next pass.
 * Tapping a contact reports it back through [onContactClick].
 *
 */
@Composable
fun ScannerRadar(
    contacts: List<RadarContact>,
    modifier: Modifier = Modifier,
    selectedId: String? = null,
    onContactClick: ((RadarContact) -> Unit)? = null,
    accent: Color = Color(0xFF00E5A0),
    background: Color = Color(0xFF04120D),
    sweepDurationMillis: Int = 3400,
    ringCount: Int = 4,
    showLabels: Boolean = true,
    labelStyle: TextStyle = TextStyle(
        fontSize = 10.sp,
        fontWeight = FontWeight.Medium,
        letterSpacing = 0.6.sp
    ),
) {
    val textMeasurer = rememberTextMeasurer()

    val sweep: State<Float> = rememberInfiniteTransition(label = "radar").animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(tween(sweepDurationMillis, easing = LinearEasing)),
        label = "sweep",
    )

    Canvas(
        modifier = modifier
            .aspectRatio(1f)
            .pointerInput(contacts, onContactClick) {
                if (onContactClick == null) return@pointerInput
                detectTapGestures { tap ->
                    val centre = Offset(size.width / 2f, size.height / 2f)
                    val radius = minOf(size.width, size.height) / 2f * OUTER_RING_FRACTION
                    val slop = 22.dp.toPx()
                    contacts
                        .map { it to (contactOffset(centre, radius, it) - tap).getDistance() }
                        .filter { it.second <= slop }
                        .minByOrNull { it.second }
                        ?.let { onContactClick(it.first) }
                }
            },
    ) {
        val centre = center
        val radius = size.minDimension / 2f * OUTER_RING_FRACTION
        val beam = sweep.value

        drawDish(centre, radius, background, accent, ringCount)
        drawBeam(centre, radius, beam, accent)

        contacts.forEach { contact ->
            val since = ((beam - contact.bearing) % 360f + 360f) % 360f
            val glow = (1f - (since / CONTACT_FADE_DEGREES)).coerceAtLeast(0f)
            val position = contactOffset(centre, radius, contact)
            drawContact(
                position = position,
                accent = accent,
                glow = glow,
                since = since,
                selected = contact.id == selectedId,
            )
            if (showLabels) {
                drawContactLabel(textMeasurer, contact.label, position, accent, glow, labelStyle)
            }
        }

        drawHub(centre, accent)
    }
}

// endregion

// region Private

private const val OUTER_RING_FRACTION = 0.86f
private const val BEAM_TRAIL_DEGREES = 92f
private const val CONTACT_FADE_DEGREES = 300f
private const val PING_DEGREES = 70f

private fun contactOffset(centre: Offset, radius: Float, contact: RadarContact): Offset {
    val radians = ((contact.bearing - 90f) * PI / 180f).toFloat()
    val distance = contact.distance.coerceIn(0f, 1f) * radius
    return Offset(centre.x + cos(radians) * distance, centre.y + sin(radians) * distance)
}

private fun DrawScope.drawDish(
    centre: Offset,
    radius: Float,
    background: Color,
    accent: Color,
    ringCount: Int,
) {
    drawCircle(
        brush = Brush.radialGradient(
            colors = listOf(accent.copy(alpha = 0.09f), background),
            center = centre,
            radius = radius,
        ),
        radius = radius,
        center = centre,
    )

    repeat(ringCount) { index ->
        val ringRadius = radius * (index + 1) / ringCount
        drawCircle(
            color = accent.copy(alpha = if (index == ringCount - 1) 0.34f else 0.14f),
            radius = ringRadius,
            center = centre,
            style = Stroke(width = 1.dp.toPx()),
        )
    }

    // Crosshair
    drawLine(
        accent.copy(alpha = 0.12f),
        Offset(centre.x - radius, centre.y),
        Offset(centre.x + radius, centre.y)
    )
    drawLine(
        accent.copy(alpha = 0.12f),
        Offset(centre.x, centre.y - radius),
        Offset(centre.x, centre.y + radius)
    )

    // Bearing ticks every 30 degrees, longer on the cardinals
    for (degrees in 0 until 360 step 30) {
        val radians = ((degrees - 90) * PI / 180f).toFloat()
        val tick = if (degrees % 90 == 0) 9.dp.toPx() else 5.dp.toPx()
        val outer = Offset(centre.x + cos(radians) * radius, centre.y + sin(radians) * radius)
        val inner = Offset(
            centre.x + cos(radians) * (radius - tick),
            centre.y + sin(radians) * (radius - tick)
        )
        drawLine(accent.copy(alpha = 0.3f), inner, outer, strokeWidth = 1.dp.toPx())
    }
}

private fun DrawScope.drawBeam(centre: Offset, radius: Float, beam: Float, accent: Color) {
    val trail = BEAM_TRAIL_DEGREES / 360f
    val brush = Brush.sweepGradient(
        0f to Color.Transparent,
        trail * 0.55f to accent.copy(alpha = 0.08f),
        trail to accent.copy(alpha = 0.40f),
        1f to Color.Transparent,
        center = centre,
    )
    rotate(degrees = beam - 90f - BEAM_TRAIL_DEGREES, pivot = centre) {
        drawArc(
            brush = brush,
            startAngle = 0f,
            sweepAngle = BEAM_TRAIL_DEGREES,
            useCenter = true,
            topLeft = Offset(centre.x - radius, centre.y - radius),
            size = Size(radius * 2f, radius * 2f),
        )
    }

    val leadingEdge = ((beam - 90f) * PI / 180f).toFloat()
    val tip = Offset(centre.x + cos(leadingEdge) * radius, centre.y + sin(leadingEdge) * radius)
    drawLine(
        brush = Brush.linearGradient(
            listOf(accent.copy(alpha = 0.1f), accent),
            start = centre,
            end = tip
        ),
        start = centre,
        end = tip,
        strokeWidth = 1.5.dp.toPx(),
    )
}

private fun DrawScope.drawContact(
    position: Offset,
    accent: Color,
    glow: Float,
    since: Float,
    selected: Boolean,
) {
    val dotRadius = 3.5.dp.toPx()

    if (since < PING_DEGREES) {
        val progress = since / PING_DEGREES
        drawCircle(
            color = accent.copy(alpha = (1f - progress) * 0.55f),
            radius = dotRadius + progress * 18.dp.toPx(),
            center = position,
            style = Stroke(width = 1.dp.toPx()),
        )
    }

    if (glow > 0f) {
        drawCircle(
            brush = Brush.radialGradient(
                colors = listOf(accent.copy(alpha = 0.45f * glow), Color.Transparent),
                center = position,
                radius = 14.dp.toPx(),
            ),
            radius = 14.dp.toPx(),
            center = position,
        )
    }

    drawCircle(
        color = accent.copy(alpha = 0.25f + 0.75f * glow),
        radius = dotRadius,
        center = position
    )

    if (selected) {
        drawCircle(
            color = accent.copy(alpha = 0.8f),
            radius = dotRadius + 6.dp.toPx(),
            center = position,
            style = Stroke(width = 1.5.dp.toPx()),
        )
    }
}

private fun DrawScope.drawContactLabel(
    textMeasurer: TextMeasurer,
    label: String,
    position: Offset,
    accent: Color,
    glow: Float,
    style: TextStyle,
) {
    val layout = textMeasurer.measure(label, style)
    drawText(
        textLayoutResult = layout,
        color = accent.copy(alpha = 0.2f + 0.8f * glow),
        topLeft = Offset(position.x - layout.size.width / 2f, position.y + 9.dp.toPx()),
    )
}

private fun DrawScope.drawHub(centre: Offset, accent: Color) {
    drawCircle(
        brush = Brush.radialGradient(
            colors = listOf(accent.copy(alpha = 0.35f), Color.Transparent),
            center = centre,
            radius = 16.dp.toPx(),
        ),
        radius = 16.dp.toPx(),
        center = centre,
    )
    drawCircle(color = accent, radius = 2.5.dp.toPx(), center = centre)
}

// endregion

@Preview(
    name = "Scanner radar",
    showBackground = true,
    backgroundColor = 0xFF04120D,
)
@Composable
private fun ScannerRadarPreview() {
    ScannerRadar(
        contacts = listOf(
            RadarContact("north", "SENTINEL-7", bearing = 12f, distance = 0.78f),
            RadarContact("east", "ECHO-9", bearing = 82f, distance = 0.53f),
            RadarContact("south-east", "VANTA-03", bearing = 138f, distance = 0.84f),
            RadarContact("south", "NODE-42", bearing = 191f, distance = 0.35f),
            RadarContact("south-west", "GHOST-1", bearing = 238f, distance = 0.69f),
            RadarContact("west", "ORBIT-6", bearing = 294f, distance = 0.47f),
            RadarContact("north-west", "NOVA-12", bearing = 326f, distance = 0.88f),
        ),
        modifier = Modifier.size(320.dp),
        selectedId = "east",
    )
}

MIT License

MIT License

Copyright (c) 2026 Stijn De Raeve-Zenner

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.