Skip to content

Instantly share code, notes, and snippets.

@vganin
Created October 5, 2025 15:18
Show Gist options
  • Save vganin/7ca62575bd5d1c6a4bd91409811f6985 to your computer and use it in GitHub Desktop.
Save vganin/7ca62575bd5d1c6a4bd91409811f6985 to your computer and use it in GitHub Desktop.
Jetpack Compose Wheel Picker
/**
* A composable function that displays a vertical scrolling picker allowing users to choose
* from a list of items.
*
* @param T The type of the items to be displayed in the picker.
* @param selectedIndex The index of the currently selected item. This controls the initial
* position of the picker and can be updated to animate a selection change.
* @param items A list of items to display in the picker.
* @param onItemSelect A callback invoked whenever an item is selected. Provides the selected
* index and item.
* @param modifier A [Modifier] for styling and configuring the appearance and behavior
* of the picker.
* @param itemContent A composable lambda that describes how to render each item in the list. Provides
* the item's index, value, whether it is selected, and its closeness to the center of the picker
* (to animate stuff like alpha, scale, etc.).
*/
@Composable
fun <T> WheelPicker(
selectedIndex: Int,
items: List<T>,
onItemSelect: (index: Int, item: T) -> Unit,
modifier: Modifier = Modifier,
itemContent: @Composable LazyItemScope.(index: Int, item: T, isSelected: Boolean, closenessToSelection: Float) -> Unit =
{ _, item, isSelected, closenessToSelection ->
DefaultWheelItemContent(item, isSelected, closenessToSelection)
},
) {
val lazyListState = rememberLazyListState(selectedIndex)
val itemIndexInCenter by itemIndexInCenter(lazyListState)
val contentPadding by contentPadding(lazyListState)
val snapBehaviour = rememberSnapFlingBehaviorWithOnFinishedListener(lazyListState) {
onItemSelect(itemIndexInCenter, items[itemIndexInCenter])
}
LaunchedEffect(selectedIndex) {
lazyListState.animateScrollToItem(selectedIndex)
}
LazyColumn(
modifier = modifier,
state = lazyListState,
contentPadding = contentPadding,
flingBehavior = snapBehaviour,
) {
itemsIndexed(items) { index, item ->
val closenessToSelection by closenessToSelection(index, lazyListState)
itemContent(
index,
item,
index == itemIndexInCenter,
closenessToSelection,
)
}
}
}
@Composable
fun <T> LazyItemScope.DefaultWheelItemContent(item: T, isSelected: Boolean, closenessToSelection: Float) {
val easedClosenessToSelection = EaseOutQuad.transform(closenessToSelection)
val scale = lerp(0.0f, 1f, easedClosenessToSelection)
val color by animateColorAsState(if (isSelected) MaterialTheme.colors.primary else LocalContentColor.current)
Text(
text = item.toString(),
modifier = Modifier
.fillParentMaxWidth()
.scale(scale)
.alpha(easedClosenessToSelection),
color = color,
textAlign = TextAlign.Center,
)
}
@Composable
private fun rememberSnapFlingBehaviorWithOnFinishedListener(
lazyListState: LazyListState,
onSnapFinished: () -> Unit,
): FlingBehavior {
val flingBehaviour = rememberSnapFlingBehavior(lazyListState)
return remember(flingBehaviour, onSnapFinished) {
object : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
return with(flingBehaviour) { performFling(initialVelocity) }
.also { onSnapFinished() }
}
}
}
}
@Composable
private fun itemIndexInCenter(lazyListState: LazyListState): State<Int> {
return remember {
derivedStateOf {
val layout = lazyListState.layoutInfo
val viewportStart = layout.viewportStartOffset
val viewportEnd = layout.viewportEndOffset
val viewportCenter = (viewportStart + viewportEnd) / 2
layout.visibleItemsInfo.minByOrNull { info ->
val itemCenter = info.offset + info.size / 2
abs(itemCenter - viewportCenter)
}?.index ?: 0
}
}
}
@Composable
private fun contentPadding(lazyListState: LazyListState): State<PaddingValues> {
val density = LocalDensity.current
return remember {
derivedStateOf {
val layout = lazyListState.layoutInfo
val verticalPaddingPx = (layout.viewportSize.height - layout.visibleItemsAverageSize()) / 2
val verticalPaddingDp = with(density) { verticalPaddingPx.toDp() }
PaddingValues(vertical = verticalPaddingDp)
}
}
}
@Composable
private fun closenessToSelection(itemIndex: Int, lazyListState: LazyListState): State<Float> {
return remember {
derivedStateOf {
val layout = lazyListState.layoutInfo
val viewportStart = layout.viewportStartOffset
val viewportEnd = layout.viewportEndOffset
val viewportCenter = (viewportStart + viewportEnd) / 2
lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == itemIndex }
?.let { info ->
val itemCenter = info.offset + info.size / 2
abs(itemCenter - viewportCenter)
}
?.let { distanceFromCenter ->
1f - distanceFromCenter.toFloat() / layout.viewportSize.height * 2
}
?: 0f
}
}
}
private fun LazyListLayoutInfo.visibleItemsAverageSize(): Int {
return if (visibleItemsInfo.isEmpty()) {
0
} else {
val visibleItems = visibleItemsInfo
val itemsSum = visibleItems.fastSumBy { it.size }
itemsSum / visibleItems.size + mainAxisItemSpacing
}
}
@Preview
@Composable
private fun WheelPickerPreview() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
WheelPicker(
selectedIndex = 100,
items = List(300) { index -> "Item $index" },
onItemSelect = { _, _ -> },
modifier = Modifier.fillMaxHeight(0.5f),
)
// Guidelines
Box(
modifier = Modifier
.height(1.dp)
.fillMaxWidth()
.background(Color.Gray.copy(alpha = 0.2f)),
)
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(Color.Gray.copy(alpha = 0.2f)),
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment