Skip to content

Instantly share code, notes, and snippets.

@ahmedre
Last active June 29, 2026 20:58
Show Gist options
  • Select an option

  • Save ahmedre/89c62832f491c2725fff2598eed291a2 to your computer and use it in GitHub Desktop.

Select an option

Save ahmedre/89c62832f491c2725fff2598eed291a2 to your computer and use it in GitHub Desktop.
Slider Examples for Blog Post
package net.helw.playground.seekbar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun BoxBottomBar(
currentPage: Int,
onSetCurrentPage: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val isInteracting = remember { mutableStateOf(false) }
// later, this should be the actual button width
val buttonWidthDp = 72.dp
val sliderWidthPx = remember { mutableIntStateOf(0) }
val thumbWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
val fraction = ((currentPage - 1f) / (604f - 1f)).coerceIn(0f, 1f)
val usableSliderPx = sliderWidthPx.intValue - thumbWidthPx
val buttonWidthPx = with(LocalDensity.current) { buttonWidthDp.toPx() }
val edge = (buttonWidthPx / usableSliderPx).coerceIn(0f, 0.5f)
val thumbOffsetPx = when {
fraction < edge -> buttonWidthPx
fraction > 1f - edge -> -buttonWidthPx
else -> 0f
}
Box(modifier = modifier.fillMaxWidth()) {
val interactionSource = remember { MutableInteractionSource() }
@OptIn(ExperimentalMaterial3Api::class)
Slider(
interactionSource = interactionSource,
value = currentPage.toFloat(),
onValueChange = { onSetCurrentPage(it.roundToInt()) },
valueRange = 1f.rangeTo(604f),
onValueChangeFinished = { isInteracting.value = false },
modifier = Modifier
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
isInteracting.value = true
}
}
.onSizeChanged { sliderWidthPx.intValue = it.width },
thumb = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = SliderDefaults.colors(),
enabled = true,
modifier = Modifier.offset {
if (!isInteracting.value) {
IntOffset(
thumbOffsetPx.roundToInt(),
0
)
} else {
IntOffset(0, 0)
}
}
)
},
)
AnimatedVisibility(
visible = !isInteracting.value,
modifier = Modifier.align(Alignment.CenterStart),
exit = fadeOut(),
enter = fadeIn()
) {
Button(onClick = {}) {
Text("B1")
}
}
AnimatedVisibility(
visible = !isInteracting.value,
modifier = Modifier.align(Alignment.CenterEnd),
exit = fadeOut(),
enter = fadeIn()
) {
Button(onClick = {}) {
Text("B2")
}
}
}
}
package net.helw.playground.seekbar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlin.math.roundToInt
@Composable
fun BoxBottomBar(
currentPage: Int,
onSetCurrentPage: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val isInteracting = remember { mutableStateOf(false) }
// later, this should be the actual button width
val buttonWidthDp = 72.dp
val fraction = ((currentPage - 1f) / (604f - 1f)).coerceIn(0f, 1f)
val buttonWidthPx = with(LocalDensity.current) { buttonWidthDp.toPx() }
val thumbOffsetPx = lerp(buttonWidthPx, -buttonWidthPx, fraction)
Box(modifier = modifier.fillMaxWidth()) {
val interactionSource = remember { MutableInteractionSource() }
@OptIn(ExperimentalMaterial3Api::class)
Slider(
interactionSource = interactionSource,
value = currentPage.toFloat(),
onValueChange = { onSetCurrentPage(it.roundToInt()) },
valueRange = 1f.rangeTo(604f),
onValueChangeFinished = { isInteracting.value = false },
thumb = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = SliderDefaults.colors(),
enabled = true,
modifier = Modifier.offset {
if (!isInteracting.value) {
IntOffset(
thumbOffsetPx.roundToInt(),
0
)
} else {
IntOffset(0, 0)
}
}
)
},
modifier = Modifier.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
isInteracting.value = true
}
}
)
AnimatedVisibility(
visible = !isInteracting.value,
modifier = Modifier.align(Alignment.CenterStart),
exit = fadeOut(),
enter = fadeIn()
) {
Button(onClick = {}) {
Text("B1")
}
}
AnimatedVisibility(
visible = !isInteracting.value,
modifier = Modifier.align(Alignment.CenterEnd),
exit = fadeOut(),
enter = fadeIn()
) {
Button(onClick = {}) {
Text("B2")
}
}
}
}
package net.helw.playground.seekbar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import kotlin.math.roundToInt
@Composable
fun BoxBottomBar(
currentPage: Int,
onSetCurrentPage: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val isInteracting = remember { mutableStateOf(false) }
Box(modifier = modifier.fillMaxWidth()) {
Slider(
value = currentPage.toFloat(),
onValueChange = { onSetCurrentPage(it.roundToInt()) },
valueRange = 1f.rangeTo(604f),
onValueChangeFinished = { isInteracting.value = false },
modifier = Modifier.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
isInteracting.value = true
}
}
)
AnimatedVisibility(
visible = !isInteracting.value,
modifier = Modifier.align(Alignment.CenterStart),
exit = fadeOut(),
enter = fadeIn()
) {
Button(onClick = {}) {
Text("B1")
}
}
AnimatedVisibility(
visible = !isInteracting.value,
modifier = Modifier.align(Alignment.CenterEnd),
exit = fadeOut(),
enter = fadeIn()
) {
Button(onClick = {}) {
Text("B2")
}
}
}
}
package net.helw.playground.seekbar
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlin.math.roundToInt
@Composable
fun ManualSlider(
currentPage: Int,
isInteracting: Boolean,
horizontalInset: Dp,
onSetInteracting: (Boolean) -> Unit,
onSetCurrentPage: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val thumbWidth = 40.dp
val onSetInteracting by rememberUpdatedState(onSetInteracting)
val onSetCurrentPage by rememberUpdatedState(onSetCurrentPage)
val widthPx = remember { mutableFloatStateOf(0f) }
val buttonInsetPx = with(LocalDensity.current) { horizontalInset.toPx() }
val latestCurrentPage by rememberUpdatedState(currentPage)
val thumbHalfPx = with(LocalDensity.current) { 20.dp.toPx() }
fun pageFromX(x: Float): Int {
val start = 0f + thumbHalfPx
val end = widthPx.floatValue - thumbHalfPx
val range = (end - start).takeIf { it > 0f } ?: return latestCurrentPage
val fraction = ((x - start) / range).coerceIn(0f, 1f)
return (1f + fraction * (604f - 1f))
.roundToInt()
.coerceIn(1, 604)
}
Box(
modifier
.fillMaxWidth()
.onSizeChanged { widthPx.floatValue = it.width.toFloat() }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
onSetInteracting(true)
try {
val dragStart = awaitTouchSlopOrCancellation(down.id) { change, _ ->
change.consume()
onSetCurrentPage(pageFromX(change.position.x))
}
if (dragStart != null) {
drag(dragStart.id) { change ->
change.consume()
onSetCurrentPage(pageFromX(change.position.x))
}
}
} finally {
onSetInteracting(false)
}
}
}
) {
Canvas(Modifier.fillMaxWidth().height(56.dp)) {
val thumbWidth = thumbWidth.toPx()
val thumbHeight = 20.dp.toPx()
val trackHeight = 20.dp.toPx()
val thumbHalfWidth = thumbWidth / 2f
val visualStart = (if (isInteracting) 0f else buttonInsetPx) + thumbHalfWidth
val visualEnd = (if (isInteracting) size.width else size.width - buttonInsetPx) - thumbHalfWidth
val fraction = ((currentPage - 1f) / (604f - 1f)).coerceIn(0f, 1f)
val thumbX = lerp(visualStart, visualEnd, fraction)
val centerY = size.height / 2f
drawLine(
color = Color.LightGray,
start = Offset(visualStart, centerY),
end = Offset(visualEnd, centerY),
strokeWidth = trackHeight,
cap = StrokeCap.Round,
)
drawLine(
color = Color.Blue,
start = Offset(visualStart, centerY),
end = Offset(thumbX, centerY),
strokeWidth = trackHeight,
cap = StrokeCap.Round,
)
drawRoundRect(
color = Color.Gray,
topLeft = Offset(
x = thumbX - thumbWidth / 2f,
y = centerY - thumbHeight / 2f,
),
size = Size(thumbWidth, thumbHeight),
cornerRadius = CornerRadius(thumbHeight / 2f, thumbHeight / 2f),
)
}
}
}
package net.helw.playground.seekbar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import kotlin.math.roundToInt
@Composable
fun RowBottomBar(
currentPage: Int,
onSetCurrentPage: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val isInteracting = remember { mutableStateOf(false) }
Row(modifier = modifier.fillMaxWidth()) {
AnimatedVisibility(
visible = !isInteracting.value,
exit = fadeOut(),
enter = fadeIn()
) {
Button(onClick = {}) {
Text("B1")
}
}
Slider(
value = currentPage.toFloat(),
onValueChange = { onSetCurrentPage(it.roundToInt()) },
valueRange = 1f.rangeTo(604f),
onValueChangeFinished = { isInteracting.value = false },
modifier = Modifier.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
isInteracting.value = true
}
}
.weight(1f)
)
AnimatedVisibility(
visible = !isInteracting.value,
exit = fadeOut(),
enter = fadeIn()
) {
Button(onClick = {}) {
Text("B2")
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment