How to Create a Draggable Rating Bar in Jetpack Compose

Kappdev
6 min readJul 1, 2024

--

Welcome 👋

In this article, we’ll create a highly customizable and draggable Rating Bar in Jetpack Compose. This Rating Bar offers complete control over the progress (accepting Float values), the number of stars, their size, spacing, and even the content of each star, which is a composable.

Stay tuned, and let’s dive in! 🚀

Created by Kappdev

Function Signature Definition

First things first, let’s define the RatingBar composable function along with its parameters for customization and interaction.

@Composable
fun RatingBar(
rating: Float,
onRatingChanged: (newRating: Float) -> Unit,
modifier: Modifier = Modifier,
@FloatRange(0.0, 1.0)
ratingStep: Float = 0.5f,
starsCount: Int = 5,
starSize: Dp = 32.dp,
starSpacing: Dp = 0.dp,
unratedContent: @Composable BoxScope.(starIndex: Int) -> Unit = {
RatingBarDefaults.UnratedContent()
},
ratedContent: @Composable BoxScope.(starIndex: Int) -> Unit = {
RatingBarDefaults.RatedContent()
},
enableDragging: Boolean = true,
enableTapping: Boolean = true
) {
// Implementation...
}

Parameters:

rating 👉 Current rating value.

onRatingChanged 👉 Callback invoked when rating changes.

modifier 👉 Modifier for styling and layout customization.

ratingStep 👉 Increment step for rating changes.

starsCount 👉 Number of stars in the rating bar.

starSize 👉 Size of each star.

starSpacing 👉 Spacing between stars.

unratedContent 👉 Custom content for unrated stars.

ratedContent 👉 Custom content for rated stars.

enableDragging 👉 Enables dragging to change the rating.

enableTapping 👉 Enables tapping to change the rating.

Implementing RatingBar Layout

To display the fractional star, we clip the content composable to an appropriate fraction value. For example, for a rating of 0.5f, we clip the content in half. So, we need the following utility shape for this purpose:

ClippingRectShape Utility

The ClippingRectShape class implements the Shape interface and clips the width based on the fillWidthFraction parameter.

private class ClippingRectShape(private val fillWidthFraction: Float) : Shape {

override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val clippingRect = Rect(Offset.Zero, Size(size.width * fillWidthFraction, size.height))
return Outline.Rectangle(clippingRect)
}
}

Implementation

The layout implementation is straightforward: we place the stars into a Row and, for each star, render unratedContent and then overlap it with ratedContent clipped to an appropriate fraction.

@Composable
fun RatingBar(
// Parameters...
) {
Row(
horizontalArrangement = Arrangement.spacedBy(starSpacing), // Space between stars
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
// Loop to create the stars based on starsCount
for (index in 1..starsCount) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(starSize) // Set the size of the star
) {
// Unrated star content
unratedContent(index)

// Calculate how much of the star should be filled based on the rating
val fillWidthFraction = when {
(rating >= index) -> 1f
(rating > index - 1) && (rating <= index) -> rating - (index - 1)
else -> 0f
}

// Box to overlay the rated star content based on the fill fraction
Box(
modifier = Modifier
.matchParentSize() // Match the size of the parent box
.clip(ClippingRectShape(fillWidthFraction)), // Clip the content based on fill fraction
contentAlignment = Alignment.Center,
content = {
ratedContent(index) // Rated star content
}
)
}
}
}
}

Dragging & Tapping

In this section, we’ll implement the dragging and tapping functionality, allowing users to change the rating.

We will also need the roundToStep utility function, listed below:

roundToStep Utility

This function rounds the value based on a specified step.

private fun roundToStep(value: Float, step: Float): Float {
return round(value / step) * step
}

For both interactions, we’ll utilize the pointerInput modifier, which allows us to detect various user interactions.

Dragging

To identify the rating star based on the drag coordinates, we need to keep a map of star indices and their bounds in the parent layout.

Define a variable:

val bounds = remember { mutableMapOf<Int, Rect>() }

Update values when the child stars are positioned:

Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(starSize)
.onGloballyPositioned { layoutCoordinates ->
bounds[index] = layoutCoordinates.boundsInParent()
}
) {
// Star content
}

Now, we can implement the drag logic utilizing the detectHorizontalDragGestures in the RatingBar layout:

Row(
horizontalArrangement = Arrangement.spacedBy(starSpacing),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.then(
if (enableDragging) {
Modifier.pointerInput(Unit) {
detectHorizontalDragGestures { change, _ ->

// Find the star index and corresponding rectangle that contains the drag position
val (index, rect) = bounds.entries.find { (_, rect) ->
rect.contains(Offset(change.position.x, 0f))
} ?: return@detectHorizontalDragGestures

// Calculate the base rating based on the star index
val baseRating = (index - 1)

// Normalize the horizontal drag position within the bounds of the star's rectangle
val normalizedX = (change.position.x - rect.left)

// Calculate the fractional rating within the star's width, constrained between 0 and 1
val fractionalRating = (normalizedX / rect.width).coerceIn(0f, 1f)

// Determine the final rounded rating based on the rating step
val roundedRating = when (ratingStep) {
1f -> round(fractionalRating)
0f -> fractionalRating
else -> roundToStep(fractionalRating, ratingStep)
}

// Update the rating by adding the base rating and the rounded fractional rating
onRatingChanged(baseRating + roundedRating)
}
}
} else {
Modifier
}
)
) {
// RatingBar content
}

Tapping

To implement the tapping interaction, we leverage the detectTapGestures modifier applied to the star's Box:

Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(starSize)
.onGloballyPositioned { layoutCoordinates ->
bounds[index] = layoutCoordinates.boundsInParent()
}
.then(
if (enableTapping) {
Modifier.pointerInput(Unit) {
detectTapGestures {
onRatingChanged(index.toFloat())
}
}
} else {
Modifier
}
)
) {
// Star content implementation
}

RatingBar Defaults

To simplify the usage, let’s create common components for rated and unrated content using the star material icon.

object RatingBarDefaults {

@Composable
fun UnratedContent(color: Color = Color.LightGray) {
Icon(
tint = color,
imageVector = Icons.Rounded.Star,
modifier = Modifier.fillMaxSize(),
contentDescription = "Unrated Star"
)
}

@Composable
fun RatedContent(color: Color = Color(0xFFFFC107)) {
Icon(
tint = color,
imageVector = Icons.Rounded.Star,
modifier = Modifier.fillMaxSize(),
contentDescription = "Rated Star"
)
}

}

Congratulations 🥳! We’ve successfully built it 👏. For the complete code implementation, you can access it on GitHub Gist 🧑‍💻. Now, let’s explore how we can put it to use.

Practical Usage 💁‍♂️

Here are some common use cases for the RatingBar, along with demos.

In the examples below, we use a state to store the rating:

var rating by remember { mutableFloatStateOf(0f) }

Simple

RatingBar(
rating = rating,
starSize = 50.dp,
onRatingChanged = { newRating ->
rating = newRating
}
)

10-score

RatingBar(
rating = rating,
starsCount = 10,
onRatingChanged = { newRating ->
rating = newRating
}
)

Integer-valued

RatingBar(
rating = rating,
ratingStep = 1f,
starSize = 50.dp,
onRatingChanged = { newRating ->
rating = newRating
}
)

Custom step

RatingBar(
rating = rating,
ratingStep = 0.05f,
starSize = 50.dp,
onRatingChanged = { newRating ->
rating = newRating
}
)

Custom content

RatingBar(
rating = rating,
starSize = 50.dp,
onRatingChanged = { newRating ->
rating = newRating
},
unratedContent = {
Icon(
tint = Color.Red.copy(0.32f),
imageVector = Icons.Rounded.FavoriteBorder,
modifier = Modifier.matchParentSize(),
contentDescription = "Unrated heart"
)
},
ratedContent = {
Icon(
tint = Color.Red,
imageVector = Icons.Rounded.Favorite,
modifier = Modifier.matchParentSize(),
contentDescription = "Rated heart"
)
}
)

Thank you for reading this article! ❤️ If you found it enjoyable and valuable, show your appreciation by clapping 👏 and following Kappdev for more exciting articles 😊

--

--

Kappdev

💡 Curious Explorer 🧭 Kotlin and Compose enthusiast 👨‍💻 Passionate about self-development and growth ❤️‍🔥 Push your boundaries 🚀