Create A Custom TV Remote UI for Android in Compose

I will show you how I created a simple smart TV remote control UI for Android in Compose.

The base element used is a Canvas. I included a callback that can be used to collect what button was pressed. I also added a different color on button pressed that can be changed to match your theme.

I cooked this up quickly for a quick app design I was working on and just needed good functionality. The design could be a bit prettier along with color choices made were just simple. Feel free to adjust how you want.

This is the MainActivity. It attaches the RemoteUI to the activity along with specified values.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            SampleRemoteTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Box(modifier = Modifier.fillMaxSize()) {
                        RemoteUI(
                            modifier = Modifier.padding(innerPadding).align(Alignment.Center),
                            desiredSize = 300.dp,
                            onSelectedColor = Color.Green,
                            buttonSelectedCallback = { selectedButton ->
                                Log.i("Test", "Button : $selectedButton")
                            }
                        )
                    }
                }
            }
        }
    }
}

Now create a new class named RemoteUI.kt ( or whatever you want ) and add the following

@Composable
fun RemoteUI(modifier: Modifier = Modifier, desiredSize: Dp, onSelectedColor : Color, buttonSelectedCallback: (Int) -> Unit) {

    var dragOffset by remember {
        mutableStateOf(Offset.Zero)
    }

    var newAngle by remember { mutableStateOf(Float.MAX_VALUE) }
    var okBtnColor by remember { mutableStateOf(false) }

    val animateUpColor by animateColorAsState(
        targetValue = if(newAngle in 225f .. 315f) onSelectedColor else Color.Black,
        animationSpec = tween(50),
        finishedListener = { newAngle = Float.MAX_VALUE }
    )
    val animateRightColor by animateColorAsState(
        targetValue = if(newAngle in 315f..360f || newAngle in 0f..45f) onSelectedColor else Color.Black,
        animationSpec = tween(50),
        finishedListener = { newAngle = Float.MAX_VALUE }
    )
    val animateDownColor by animateColorAsState(
        targetValue = if(newAngle in 45f..135f) onSelectedColor else Color.Black,
        animationSpec = tween(50),
        finishedListener = { newAngle = Float.MAX_VALUE }
    )
    val animateLeftColor by animateColorAsState(
        targetValue = if(newAngle in 135f..225f) onSelectedColor else Color.Black,
        animationSpec = tween(50),
        finishedListener = { newAngle = Float.MAX_VALUE }
    )
    val animateOkColor by animateColorAsState(
        targetValue = if(okBtnColor) onSelectedColor else Color.Black,
        animationSpec = tween(50),
        finishedListener = { okBtnColor = false }
    )

    Canvas(modifier = modifier
        .size(desiredSize)
        .aspectRatio(1f)
        .pointerInput(Unit) {
            detectTapGestures(
                onPress = {
                    dragOffset = it
                    val okBtnRadius = size.center.x / 2f

                    if(dragOffset.x in size.center.x-okBtnRadius .. size.center.x + okBtnRadius
                        && dragOffset.y in size.center.y-okBtnRadius .. size.center.y + okBtnRadius){
                        okBtnColor = true
                        buttonSelectedCallback(RemoteUIConstants.OK)
                        return@detectTapGestures
                    }

                    newAngle = calculateNewAngle(size.center.toOffset(), dragOffset)

                    when(newAngle){
                        in 225f..315f -> {
                            buttonSelectedCallback(RemoteUIConstants.UP)
                        }
                        in 315f..360f -> {
                            buttonSelectedCallback(RemoteUIConstants.RIGHT)
                        }
                        in 0f..45f -> {
                            buttonSelectedCallback(RemoteUIConstants.RIGHT)
                        }
                        in 45f..135f -> {
                            buttonSelectedCallback(RemoteUIConstants.DOWN)
                        }
                        in 135f..225f -> {
                            buttonSelectedCallback(RemoteUIConstants.LEFT)
                        }
                    }
                    Log.i("Sample", newAngle.toString())
                }
            )
        }
        .graphicsLayer {
            compositingStrategy = CompositingStrategy.Offscreen
        }
        .drawWithContent {

            val arrowSize = center.x / 10f

            drawCircle(
                brush = Brush.radialGradient(
                    0.0f to Color.Transparent,
                    0.75f to Color.White,
                    1.0f to Color.Black,
                    center = center
                ),
                center = center
            )

            drawArc(
                color = animateUpColor,
                startAngle = 230f,
                sweepAngle = 80f,
                useCenter = true,
                size = Size(size.width, size.width),
                topLeft = Offset(0f, 0f)
            )
            drawPath(
                path = Path().apply {
                    reset()
                    moveTo(center.x - arrowSize, arrowSize * 2)
                    lineTo(center.x, arrowSize)
                    lineTo(center.x + arrowSize, arrowSize * 2)
                    close()
                },
                color = Color.White
            )

            rotate(90f, center) {
                drawArc(
                    color = animateRightColor,
                    startAngle = 230f,
                    sweepAngle = 80f,
                    useCenter = true,
                    size = Size(size.width, size.width),
                    topLeft = Offset(0f, 0f)
                )
                drawPath(
                    path = Path().apply {
                        reset()
                        moveTo(center.x - arrowSize, arrowSize * 2)
                        lineTo(center.x, arrowSize)
                        lineTo(center.x + arrowSize, arrowSize * 2)
                        close()
                    },
                    color = Color.White
                )
            }

            rotate(180f, center) {
                drawArc(
                    color = animateDownColor,
                    startAngle = 230f,
                    sweepAngle = 80f,
                    useCenter = true,
                    size = Size(size.width, size.width),
                    topLeft = Offset(0f, 0f)
                )
                drawPath(
                    path = Path().apply {
                        reset()
                        moveTo(center.x - arrowSize, arrowSize * 2)
                        lineTo(center.x, arrowSize)
                        lineTo(center.x + arrowSize, arrowSize * 2)
                        close()
                    },
                    color = Color.White
                )
            }

            rotate(270f, center) {
                drawArc(
                    color = animateLeftColor,
                    startAngle = 230f,
                    sweepAngle = 80f,
                    useCenter = true,
                    size = Size(size.width, size.width),
                    topLeft = Offset(0f, 0f)
                )
                drawPath(
                    path = Path().apply {
                        reset()
                        moveTo(center.x - arrowSize, arrowSize * 2)
                        lineTo(center.x, arrowSize)
                        lineTo(center.x + arrowSize, arrowSize * 2)
                        close()
                    },
                    color = Color.White
                )
            }

            drawCircle(Color.Blue, radius = center.x / 1.5f, blendMode = BlendMode.Clear)

            drawCircle(Color.Gray, radius = center.x / 1.5f)
            drawCircle(
                brush = Brush.radialGradient(
                    0.0f to Color.Gray,
                    0.5f to animateOkColor,
                    1.0f to Color.Gray,
                    center = center
                ),
                radius = center.x / 2f)

            drawCircle(Color.White, radius = center.x / 10f)
        }) {

    }
}

fun calculateNewAngle(mCenter: Offset, dragOffset: Offset): Float {
    val rad = atan2((mCenter.y - dragOffset.y).toDouble(), (mCenter.x - dragOffset.x).toDouble())
    val angle = Math.toDegrees(rad)
    return (angle + 180f).roundToInt().toFloat()
}
NOTE – I updated code slightly to make the UI look a bit better

Finally, added some button constants into another file. Create a new file name RemoteUIConstants.kt

data object RemoteUIConstants {

    val UP: Int = 0
    val LEFT : Int = 1
    val RIGHT : Int = 2
    val DOWN : Int = 3
    val OK : Int = 4
}

When the user selects a button, it will change its color briefly and send a callback to buttonSelectedCallback() specifying which button was selected using the RemoteUIConstants.

The Breakdown

The function creates a Canvas with the specified size. Then it draws one of the outer buttons using the drawArc and drawPath methods inside onDraw

Below is the snippet for the top button creation. It basically draws a pie like a pie chart with the drawArc method using the center. Then it draws the little triangle arrow on the top of the button with the drawPath method.

 drawArc(
                color = if (oldAngle in 225f..315f) onSelectedColor else Color.Black,
                startAngle = 230f,
                sweepAngle = 80f,
                useCenter = true,
                size = Size(size.width, size.width),
                topLeft = Offset(0f, 0f)
            )
            drawPath(
                path = Path().apply {
                    reset()
                    moveTo(center.x - arrowSize, arrowSize * 2)
                    lineTo(center.x, arrowSize)
                    lineTo(center.x + arrowSize, arrowSize * 2)
                    close()
                },
                color = Color.White
            )

This is what it produces

Then I copy/paste the other 4 triangles. More easily could have done a for/loop but there was only 4.

Then I created a few center circles to clear the center and just keep the outside portions.

Finally touch was to add the arrows inside the rotate { } method under the drawArc() for each button.

Trail and Error

First I tried to do this using custom shape objects and drawing a Path with curved lines also with bezier curves. My values kept getting it slightly off and it wasn’t dimensional accurate with proper curves/symmetrical. When all 4 buttons were drawn, they would come too close or overlap.

This method turned out to be the best quickest option. Adjust to your liking or used the idea as a better foundation to improve on your creation.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *