Creating a spin-wheel in Compose

Laurentyhuel
Voodoo Engineering
Published in
7 min readFeb 23, 2024
Designed by katemangostar / Freepik

Introduction

At Blitz we develop an application to play classical games (Bingo, Solitaire, Match3, Pool, …) against real players worldwide. We also have some random stuff (for instance bonus amount when deposit), so we want to display to the user a kind of wheel of fortune.
Our Android app is in full-compose.

Existing libraries

I looked for libraries to avoid reinventing the wheel 😁 but none of them suited me in terms of design or technical aspects.
I took a little inspiration from: https://github.com/commandiron/SpinWheelCompose, but reworked almost everything 😅.

Requirements

My spin-wheel must accept as parameters a list of sections. Each section has a brush background, and a Composable to draw in the section. This Composable must follow the rotation of the section. If the section is upside down, the Composable must also be upside down🙃.

My spin-wheel must be able to:
- go to a specific section without animation
- rotate indefinitely
- stop smoothly at a specific section

Let’s start with the basics

I need to create a section. In Compose it’s easy to draw an arc (sweepAngle is the size of arc in degrees) thanks to the modifier drawBehind and function drawArc:

    Box(
modifier = Modifier
.size(200.dp)
.drawBehind {
drawArc(
brush = Brush.verticalGradient(
listOf(
Color.Red,
Color.Yellow
)),
startAngle = 0f,
sweepAngle = 30f,
useCenter = true,
)
}
) {

}

Ok, now I have to move the section to the top and center it, to apply the brush correctly and be able to place the section content easily.

startAngle = -90f - (sweepAngle / 2)

It’s better but I can’t see the yellow color of my brush, because my brush is applied to the whole circle. So I have to tell my brush to stop halfway up. There’s a parameter for this:

endY = 200.dp.toPx() /2f

Hey, I have the expected result 😎. We can now move on to the content. First the data class, to setup the section :

@Stable
data class SpinWheelItem(
val colors: PersistentList<Color>,
val content: @Composable () -> Unit,
)

It contains a list of colors, to setup the background brush, and the Composable content to show.
Now the section UI:

@Composable
internal fun SpinWheelSlice(
modifier: Modifier = Modifier,
size: Dp,
brush: Brush,
degree: Float,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.size(size)
.drawBehind {
drawArc(
brush = brush,
startAngle = -90f - (degree / 2),
sweepAngle = degree,
useCenter = true,
)
}
) {
Box(modifier = Modifier.align(Alignment.TopCenter).padding(top = 20.dp)) {
content()
}
}
}

I place the content to the top-center of my component and add a top-padding.

Let’s build the whole wheel

In this step, I will build each section and apply a rotation to form the wheel. The rotation will also be applied to the section’s content, which is the expected result.
I start by calculating the number of degrees in an arc. It depends on the number of sections:

val degreesPerItems = 360f / items.size.toFloat()

Then I calculate the rotation of each item:
− The first doesn’t move
- The second rotates once the size of an arc
- The third rotates twice the size of an arc
- ….
This gives :

items.forEachIndexed { index, item ->
SpinWheelSlice(
modifier = Modifier.rotate(degrees = degreesPerItems * index)
)
}

And just like that 🤩.

Let’s spin the wheel

To spin the wheel, I play with the rotation of the parent component. This rotates all the sections of my wheel at the same time. I use the modifier graphicsLayer with the property rotationZ, to avoid recomposition but only do phase 3 of it.

🎁rotationZ accepts value greater than 360, this is very practical, as there’s no need to reset at every turn.

I store the current rotation value in an Animatable:

val rotation = Animatable(0f)

What I need to do ?

  • To go to a specific section without animation
    There is the snapTo function, I just need to calculate the degree of the destination.
fun getDegreeFromSection(items: List<SpinWheelItem>, section: Int): Float {
val pieDegree = 360f / items.size
return pieDegree * section.times(-1)
}
  • Rotate indefinitely
    There’s the animateTo function. Compose has an infiniteRepeatable animation specification for easy looping. targetValue is equals to current value plus a full loop in degrees.
rotation.animateTo(
targetValue = rotation.value + 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = (rotationPerSecond * 1000f).toInt(),
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
)
)
  • Stop smoothly at a specific section
    I also use the animateTo function but without repeatable. The targetValue is a bit more complex to calculate, but it’s still very simple. It’s the sum of : current position, plus minimum number of turns, plus destination (calculate from 0 degres), plus current modulo 360 (to finish current turn at 0 degree)
rotation.animateTo(
targetValue = rotation.value + (stopNbTurn * 360f) + destinationDegree + (360f - (rotation.value % 360f)),
animationSpec = tween(
durationMillis = stopDuration.inWholeMilliseconds.toInt(),
easing = EaseOutQuad
)
)
The animation rendering in gif is so bad :(

To reinforce the impression of randomness, I’ve added to the destination calculation a random angle between -(sectionAngle / 2)° and +(sectionAngle /2)° with 90% apply to avoid stop near border.

fun getDegreeFromSectionWithRandom(items: List<SpinWheelItem>, section: Int): Float {
val pieDegree = 360f / items.size
val exactDegree = pieDegree * section.times(-1)

val pieReduced = pieDegree * 0.9f //to avoid stop near border
val multiplier = if (Random.nextBoolean()) 1f else -1f //before or after exact degree
val randomDegrees = Random.nextDouble(0.0, pieReduced / 2.0)
return exactDegree + (randomDegrees.toFloat() * multiplier)
}
The animation rendering in gif is so bad :(

It’s perfect 😍.
All this logic is inside specific State wrap by a remember.

@Stable
data class SpinWheelState(
internal val items: PersistentList<SpinWheelItem>,
@DrawableRes internal val backgroundImage: Int,
@DrawableRes internal val centerImage: Int,
@DrawableRes internal val indicatorImage: Int,
private val initSpinWheelSection: Int?,
private val onSpinningFinished: (() -> Unit)?,
private val stopDuration: Duration,
private val stopNbTurn: Float,
private val rotationPerSecond: Float,
private val scope: CoroutineScope,
) {
internal val rotation = Animatable(0f)

init {
initSpinWheelSection?.let {
goto(it)
} ?: launchInfinite()
}

fun stoppingWheel(sectionToStop: Int) {
if (sectionToStop !in items.indices) {
Log.e("spin-wheel", "cannot stop wheel, section $sectionToStop not exists in items")
return
}


scope.launch {
val destinationDegree = getDegreeFromSectionWithRandom(items, sectionToStop)

rotation.animateTo(
targetValue = rotation.value + (stopNbTurn * 360f) + destinationDegree + (360f - (rotation.value % 360f)),
animationSpec = tween(
durationMillis = stopDuration.inWholeMilliseconds.toInt(),
easing = EaseOutQuad
)
)
}

}

fun goto(section: Int) {
scope.launch {
if (section !in items.indices) {
Log.e(
"spin-wheel",
"cannot goto specific section of wheel, section $section not exists in items"
)
return@launch
}
val positionDegree = getDegreeFromSection(items, section)
rotation.snapTo(positionDegree)
}
}

fun launchInfinite() {
scope.launch {
// Infinite repeatable rotation when is playing
rotation.animateTo(
targetValue = rotation.value + 360f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = (rotationPerSecond * 1000f).toInt(),
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
)
)
}
}
}

@Composable
fun rememberSpinWheelState(
items: PersistentList<SpinWheelItem>,
@DrawableRes backgroundImage: Int,
@DrawableRes centerImage: Int,
@DrawableRes indicatorImage: Int,
onSpinningFinished: (() -> Unit)?,
initSpinWheelSection: Int? = 0, //if null then infinite
stopDuration: Duration = 8.seconds,
stopNbTurn: Float = 3f,
rotationPerSecond: Float = 0.8f,
scope: CoroutineScope = rememberCoroutineScope(),
): SpinWheelState {
return remember {
SpinWheelState(
items = items,
backgroundImage = backgroundImage,
centerImage = centerImage,
indicatorImage = indicatorImage,
initSpinWheelSection = initSpinWheelSection,
stopDuration = stopDuration,
stopNbTurn = stopNbTurn,
rotationPerSecond = rotationPerSecond,
scope = scope,
onSpinningFinished = onSpinningFinished,
)
}
}

Let’s use it

I add the state and the component:

val spinState = rememberSpinWheelState(  
items = items,
backgroundImage = R.drawable.spin_wheel_background,
centerImage = R.drawable.spin_wheel_center,
indicatorImage = R.drawable.spin_wheel_tick,
onSpinningFinished = null,
)
Box(modifier = Modifier.size(300.dp)) {
SpinWheelComponent(spinState)
}

And then I could play with the state :

spinState.goto(value)
spinState.launchInfinite()
spinState.stoppingWheel(value)

Skin on wheel

On my project, the wheel skin (background, tick and center images) is customized and comes from the server.
To facilitate integration, the background image must respect a ratio.
The center image size equals 14% of total size.

Conclusion

Thank you for reading until here!

Source are available here 🚀 : https://github.com/laurentyhuel/SpinWheelCompose

--

--