I will show you how I created a simple smart TV remote UI for Android in Compose.
The base element used is a Canvas composable. This will allow us to able to customize the element as needed.
Android TV Remote UI
Features
- 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. Feel free to adjust how you want.
Code
MainActivity
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")
}
)
}
}
}
}
}
}RemoteUI
Now create a new class named RemoteUI.kt ( or whatever you want ) and add the following
NOTE - I updated code slightly to make the UI look a bit better
Breakdown
RemoteUI Main Function
Create main function
@Composable
fun RemoteUI(modifier: Modifier = Modifier, desiredSize: Dp, onSelectedColor : Color, buttonSelectedCallback: (Int) -> Unit) {
//add code here
...
}Variables
Create variables.
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 }
)
Draw Onto Canvas
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)
}) {
}
calculateNewAngle Function
This function is used to help calculate where the user tag occurred.
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()
}RemoteUIConstants
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.
Final 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 use as is.
Use this sample to build a better foundation for improving your creation.




