A Production-Ready iOS-Style 3D Wheel Picker for Jetpack Compose
Implementing an iOS-style 3D WheelPicker with Jetpack Compose
This article introduces a Jetpack Compose 3D wheel picker component ready for production use. The component supports cylindrical perspective, inertial deceleration, snap-to-center, cyclic scrolling, text color change within a mask, custom options, and an out-of-the-box date picker.
1. Implementation Effects
The component ultimately exhibits the following behaviors:
- Options are arranged along a cylindrical arc, rather than simply scaling a flat list.
- The center option faces the user directly, while options above and below gradually rotate, shrink, and fade out.
- After a quick swipe, it moves at high speed initially, then decelerates along an exponential curve, finally snapping to the center.
- Supports near-infinite cyclic scrolling without duplicating large amounts of business data.
- Text changes to the selected color proportionally to how much it enters the central mask.
- Mutually exclusive clipping is used inside and outside the selection area, preventing double-layered text or ghosting.
- The visible row count supports odd numbers like 3, 5, 7, 9, and automatically adjusts the inter-row angle on the cylinder.
- Provides a combined year-month-day control that automatically handles leap years, months with different lengths, and illegal date convergence.
2. Why Use VerticalPager
A regular LazyColumn can easily implement vertical scrolling, but this component has two special requirements:
- After each scroll, it must strictly snap to a specific center item.
- After the container height is tightened according to the cylindrical projection, the planar positions of some items are already outside the viewport, but they still need to be displayed after 3D displacement.
VerticalPager natively provides page snapping and supports beyondViewportPageCount, allowing pages outside the viewport to continue being assembled. Therefore, it is more suitable for the current scenario than manually combining LazyColumn, SnapFlingBehavior, and additional measurement logic.
The core structure is as follows:
VerticalPager(
state = pagerState,
pageSize = PageSize.Fixed(style.itemHeight),
contentPadding = PaddingValues(vertical = verticalPadding),
beyondViewportPageCount = style.visibleItemCount / 2,
flingBehavior = flingBehavior,
) { virtualIndex ->
// Maps the virtual index to business data and performs cylindrical projection.
}
3. Controlled State Design
WheelPicker is a controlled component. The caller holds selectedIndex, and the component is only responsible for displaying and producing new selections:
var selectedIndex by rememberSaveable { mutableIntStateOf(0) }
WheelPicker(
items = listOf("Beijing", "Shanghai", "Shenzhen"),
selectedIndex = selectedIndex,
onSelected = { index, item ->
selectedIndex = index
},
)
The controlled design has three benefits:
- State can be managed uniformly by a ViewModel, form, or business state.
- When the index is modified externally, the wheel automatically scrolls to the new option.
- Page reconstruction, configuration changes, and state restoration do not depend on hidden internal state of the component.
It does not callback for every item passed during scrolling. The component waits for isScrollInProgress to become false, confirming that inertia and snapping are all complete, before submitting the final selected value:
snapshotFlow { pagerState.isScrollInProgress }
.filter { scrolling -> !scrolling }
.collect {
val dataIndex = pagerState.currentPage.floorMod(items.size)
if (dataIndex != latestSelectedIndex) {
latestOnSelected(dataIndex, latestItems[dataIndex])
}
}
4. Cyclic Scrolling
In cyclic mode, instead of copying the original list into a huge collection, a large range of virtual page indices is created:
private const val LoopItemCount = Int.MAX_VALUE
private const val LoopCenter = LoopItemCount / 2
val dataIndex = virtualIndex.floorMod(itemCount)
The initial page is located near the midpoint of the virtual list, maintaining the following mapping relationship:
virtualIndex % itemCount == dataIndex
This provides sufficiently large scrolling space both upwards and downwards. When the component approaches the virtual boundary, it silently returns to the middle while keeping the current business option unchanged, avoiding hitting the Int boundary after long-term operation.
When selectedIndex is changed externally, the component finds the nearest virtual page with the same value to the current page, scrolling only the shortest distance.
5. 3D Cylindrical Projection
Simply setting rotationX results in a column of tilted text, but the item centers are still arranged equidistantly like a flat list, not looking like a true circular wheel. Here, both the rotation angle on the cylinder and the vertical projection are calculated simultaneously.
Assume:
- The single row height is the arc length
s. - The angle between two adjacent rows is
theta. - The cylinder radius is
r.
According to the arc formula:
s = r * theta
r = s / theta
The projection of the item center on the screen's vertical axis is:
y = r * sin(angle)
Core code:
val angleRadians = rotation * PI.toFloat() / 180f
val radiansPerItem = effectiveRotationPerItem * PI.toFloat() / 180f
val itemHeightPx = style.itemHeight.toPx()
val cylinderRadius = itemHeightPx / radiansPerItem
val projectedY = cylinderRadius * sin(angleRadians)
val flatY = distance * itemHeightPx
translationY = projectedY - flatY
rotationX = -rotation
scaleX = scale
scaleY = scale
alpha = 1f - fraction.absoluteValue * (1f - style.minAlpha)
cameraDistance = 12f * density
translationY first cancels out the flat list position, then moves the item to the cylindrical projection position. Rotation, scaling, transparency, and perspective together form a wheel surface effect close to the iOS Picker.
Adaptive Visible Row Count
If a fixed 32 degrees per row is used, when the visible row count is set to 9, multiple outer rows might be simultaneously clamped to the maximum rotation angle, causing overlap. The actual inter-row angle needs to converge based on the visible row count:
private fun WheelPickerStyle.effectiveRotationPerItem(): Float {
val stepsToEdge = visibleItemCount / 2f
return minOf(rotationPerItem, maxRotation / stepsToEdge)
}
Therefore, when visibleItemCount = 9, all 9 rows can have independent arc positions.
6. True Wheel Surface Height
Flat lists commonly use the following height:
itemHeight * visibleItemCount
But after cylindrical projection, items converge towards the center. Continuing to use the flat height leaves noticeable blank space at the top and bottom.
This component calculates the final visual boundary row by row:
val projectedCenter = radius * sin(angleRadians)
val projectedHalfItem =
itemHeight.value / 2f * cos(angleRadians) * scale
maxExtent = max(maxExtent, projectedCenter + projectedHalfItem)
Finally, maxExtent * 2 is used as the component height. After modifying itemHeight, visibleItemCount, curvature, or scaling, the height is automatically recalculated.
7. Inertial Deceleration and Center Snapping
By default, a Pager allows only a few pages to be crossed in one fling, making inertia less noticeable as a wheel. The component expands the number of crossable items and uses exponential decay to simulate motion from fast to slow:
val flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
pagerSnapDistance = PagerSnapDistance.atMost(style.maxFlingItems),
decayAnimationSpec = exponentialDecay(
frictionMultiplier = style.flingFriction,
),
snapAnimationSpec = spring(
stiffness = Spring.StiffnessMediumLow,
dampingRatio = Spring.DampingRatioNoBouncy,
),
snapPositionalThreshold = 0.35f,
)
Scrolling is divided into two phases:
- After the finger is released, the current velocity is retained and continuously decelerates along an exponential curve.
- When approaching the final item, a non-bouncy spring snaps it to the center.
Inertia can be adjusted via styles:
WheelPickerStyle(
maxFlingItems = 30,
flingFriction = 1.35f,
)
- The smaller the
flingFriction, the farther it scrolls and the slower it decelerates. - The larger the
flingFriction, the faster it stops. - Adjustment within the range of
0.8f..3fis recommended.
8. Continuous Text Color Change within the Mask
The goal is not to switch the color of the entire row after the item snaps to the center, but to change the text color proportionally to how much it enters the central area.
Two versions of each text item are drawn:
- The normal layer uses
unselectedTextStyle. - The selected layer uses
selectedTextStyle.
Both layers must use mutually exclusive clipping. If the normal layer is drawn completely first, and then the selected layer is overlaid on top, the underlying text will be visible when font sizes or weights differ, creating a ghosting effect. The current implementation is:
Normal layer: Only draws above and below the mask
Selected layer: Only draws inside the mask
After an item undergoes rotation and scaling, the central mask boundaries need to be inversely calculated to the item's local coordinates:
val verticalProjection =
(cos(angleRadians).absoluteValue * scale).coerceAtLeast(0.001f)
val clipTop = localCenter +
(-maskHalfHeight - projectedCenter) / verticalProjection
val clipBottom = localCenter +
(maskHalfHeight - projectedCenter) / verticalProjection
The normal layer and the selected layer share the same clipTop and clipBottom, ensuring no overlap or gaps.
The text shortcut API enables this effect by default. Custom content can provide selectedItemContent:
WheelPicker(
items = users,
selectedIndex = selectedIndex,
onSelected = { index, _ -> selectedIndex = index },
selectedItemContent = { user ->
Text(user.name, color = Color.Black)
},
) { user, _ ->
Text(user.name, color = Color.Gray)
}
9. Basic Usage
1. Text List
val years = remember { (2020..2035).toList() }
var selectedIndex by rememberSaveable { mutableIntStateOf(6) }
WheelPicker(
items = years,
selectedIndex = selectedIndex,
onSelected = { index, year ->
selectedIndex = index
},
loop = true,
label = { "${it}Year" },
)
2. Custom Styles
val pickerStyle = WheelPickerStyle(
itemHeight = 40.dp,
visibleItemCount = 9,
selectedBackgroundColor = MaterialTheme.colorScheme.secondaryContainer,
selectedTextStyle = TextStyle(
color = Color.Black,
fontSize = 22.sp,
textAlign = TextAlign.Center,
),
unselectedTextStyle = TextStyle(
color = Color.LightGray,
fontSize = 19.sp,
textAlign = TextAlign.Center,
),
rotationPerItem = 32f,
maxRotation = 84f,
minScale = 0.68f,
minAlpha = 0.08f,
maxFlingItems = 30,
flingFriction = 1.35f,
)
visibleItemCount must be an odd number greater than or equal to 3 to ensure a unique center row exists.
3. Disabled and Non-Cyclic Mode
WheelPicker(
items = items,
selectedIndex = selectedIndex,
onSelected = { index, _ -> selectedIndex = index },
enabled = formEnabled,
loop = false,
)
In non-cyclic mode, center padding is automatically provided for the first and last items, so both can be moved to the central selection area.
10. Year-Month-Day Control
Business logic usually doesn't want to maintain three separate indices for year, month, and day, so the project additionally encapsulates DateWheelPicker. The caller only needs to maintain a single LocalDate:
var date by remember { mutableStateOf(LocalDate.now()) }
DateWheelPicker(
value = date,
onValueChange = { date = it },
)
Customizing the year range and style:
DateWheelPicker(
value = date,
onValueChange = { date = it },
yearRange = 2000..2050,
loop = true,
style = WheelPickerStyle(
itemHeight = 40.dp,
visibleItemCount = 7,
),
)
Date linkage uses YearMonth.lengthOfMonth() to calculate legal days:
val validDay = day.coerceAtMost(
YearMonth.of(year, month).lengthOfMonth(),
)
val newValue = LocalDate.of(year, month, validDay)
Therefore, the component can correctly handle:
- February in common and leap years.
- Months with 30 and 31 days.
- Automatically selecting the last day of February when switching from January 31st.
- Changes in the legality of February 29th due to year changes.
11. Year-Month-Day Hour-Minute-Second Combination
Use DateWheelPicker for the date part and three basic WheelPickers for the time part:
val hours = remember { (0..23).toList() }
val values = remember { (0..59).toList() }
Row(Modifier.fillMaxWidth()) {
WheelPicker(
items = hours,
selectedIndex = hourIndex,
onSelected = { index, _ -> hourIndex = index },
modifier = Modifier.weight(1f),
label = { "%02dHour".format(it) },
)
WheelPicker(
items = values,
selectedIndex = minuteIndex,
onSelected = { index, _ -> minuteIndex = index },
modifier = Modifier.weight(1f),
label = { "%02dMinute".format(it) },
)
WheelPicker(
items = values,
selectedIndex = secondIndex,
onSelected = { index, _ -> secondIndex = index },
modifier = Modifier.weight(1f),
label = { "%02dSecond".format(it) },
)
}
The test page includes a complete combination example that can be run directly.
12. Production Environment Considerations
Data Stability
Avoid modifying items in place during scrolling. It is recommended to pass an immutable list and ensure selectedIndex is always within items.indices.
State Restoration
Page state should be saved using rememberSaveable or a ViewModel. LocalDate can be converted to epochDay:
var epochDay by rememberSaveable {
mutableLongStateOf(LocalDate.now().toEpochDay())
}
val date = LocalDate.ofEpochDay(epochDay)
Performance
Although cyclic mode uses Int.MAX_VALUE as the virtual page count, the Pager only assembles the viewport and a small number of extra pages, not creating billions of nodes. Mask color change causes text content to be drawn twice, but only a small number of items within the visible range participate in drawing.
For complex custom options, if mask color change is not needed, simply not passing selectedItemContent keeps single-layer drawing.
Accessibility
Each option uses Role.RadioButton, and the center item sets the selected semantic. When customizing content for business, meaningful text or contentDescription should still be provided.
Parameter Suggestions
A set of configurations suitable for a regular date picker:
WheelPickerStyle(
itemHeight = 40.dp,
visibleItemCount = 7,
rotationPerItem = 32f,
maxRotation = 84f,
minScale = 0.68f,
minAlpha = 0.08f,
maxFlingItems = 30,
flingFriction = 1.35f,
)
If a more compact look is desired, visibleItemCount can be adjusted to 5; if more context needs to be displayed, it can be set to 9. The component will automatically adjust the actual inter-row angle and recalculate the wheel surface height.
13. Complete Code
WheelPicker.kt
// Cyclic mode does not copy real data but maps finite data to a sufficiently large virtual list.
// Starting from the midpoint of the Int range provides near-infinite space for scrolling both up and down.
private const val LoopItemCount = Int.MAX_VALUE
private const val LoopCenter = LoopItemCount / 2
/**
* Visual configuration for [WheelPicker].
* @param itemHeight The fixed height of each option. Fixed row height is a prerequisite for accurate snapping and cylindrical projection.
* @param visibleItemCount The number of visible rows, must be an odd number greater than or equal to 3 to ensure a unique center row always exists.
* @param selectedBackgroundColor The background color of the center selection area.
* @param selectedTextStyle The text style for the center option, only effective for the text shortcut overload.
* @param unselectedTextStyle The text style for non-center options, only effective for the text shortcut overload.
* @param selectionShape The shape of the center selection area.
* @param rotationPerItem The maximum expected angle between two adjacent rows. Larger values make the wheel curvature more pronounced; when
* [visibleItemCount] is large, the component automatically reduces the actual angle to ensure all rows are within the front arc defined by [maxRotation],
* rather than overlapping at the edges.
* @param maxRotation The maximum rotation angle allowed for edge options, preventing options from flipping to the back of the cylinder.
* @param minScale The minimum scale ratio for the outermost options.
* @param minAlpha The minimum transparency for the outermost options.
* @param maxFlingItems The maximum number of options allowed to be crossed in one quick swipe. Larger values result in longer inertial scrolling distance.
* @param flingFriction The inertial friction coefficient. Smaller values make it slide farther and decelerate slower; larger values make it stop faster.
* The recommended range is 0.8 to 3, with the default 1.35 providing a noticeable fast-to-slow curve.
*/
@Stable
data class WheelPickerStyle(
val itemHeight: Dp = 40.dp,
val visibleItemCount: Int = 7,
val selectedBackgroundColor: Color = Color(0xFFF2F2F4),
val selectedTextStyle: TextStyle = TextStyle(
color = Color(0xFF222222),
fontSize = 22.sp,
textAlign = TextAlign.Center,
),
val unselectedTextStyle: TextStyle = TextStyle(
color = Color(0xFF8E8E93),
fontSize = 19.sp,
textAlign = TextAlign.Center,
),
val selectionShape: RoundedCornerShape = RoundedCornerShape(1.dp),
val rotationPerItem: Float = 32f, // Larger is rounder
val maxRotation: Float = 84f,
val minScale: Float = 0.68f,
val minAlpha: Float = 0.08f,
val maxFlingItems: Int = 95,
val flingFriction: Float = 1.35f,
) {
init {
require(visibleItemCount >= 3 && visibleItemCount % 2 == 1) {
"visibleItemCount must be an odd number greater than or equal to 3"
}
require(itemHeight > 0.dp) { "itemHeight must be greater than 0.dp" }
require(rotationPerItem in 1f..45f) { "rotationPerItem must be between 1 and 45" }
require(maxRotation in 0f..90f) { "maxRotation must be between 0 and 90" }
require(minScale in 0f..1f) { "minScale must be between 0 and 1" }
require(minAlpha in 0f..1f) { "minAlpha must be between 0 and 1" }
require(maxFlingItems >= 1) { "maxFlingItems must be greater than or equal to 1" }
require(flingFriction > 0f) { "flingFriction must be greater than 0" }
}
}
/**
* An iOS-style 3D cylindrical wheel picker.
*
* This is a controlled component: [selectedIndex] is the sole source of truth for the selection state. After the user stops scrolling, the component
* notifies the new option via [onSelected], and the caller should update [selectedIndex] in the callback. When the caller actively modifies
* [selectedIndex], the wheel also automatically scrolls to the corresponding position.
* Cyclic mode is implemented using virtual indices, without creating or copying massive amounts of business data. When approaching the edge of the virtual list, the component
* automatically returns to the middle while keeping the selected item unchanged, making it suitable for long continuous scrolling.
* @param items The selectable data. An empty list renders nothing.
* @param selectedIndex The real index of the currently selected item in [items], must be valid for non-empty data.
* @param onSelected Callback after the user scrolls or taps and snapping is complete, returning the real index and corresponding data in order.
* @param modifier [Modifier] acting on the entire wheel container.
* @param loop Whether to scroll cyclically. Can be set to `false` for strictly ranged data.
* @param enabled Whether to allow gesture scrolling and tapping. When set to `false`, the current option is still displayed.
* @param style Wheel size and 3D visual configuration.
* @param itemContent Custom option content, providing both the data and whether it is currently at the center.
* @param selectedItemContent Optional content inside the mask. When provided, each item first draws the normal content, then
* clips this content to the central selection area; suitable for implementing a continuous effect where text changes color proportionally to how much it enters the mask.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T> WheelPicker(
items: List<T>,
selectedIndex: Int,
onSelected: (index: Int, item: T) -> Unit,
modifier: Modifier = Modifier,
loop: Boolean = true,
enabled: Boolean = true,
style: WheelPickerStyle = WheelPickerStyle(),
selectedItemContent: (@Composable BoxScope.(item: T) -> Unit)? = null,
itemContent: @Composable BoxScope.(item: T, selected: Boolean) -> Unit,
) {
if (items.isEmpty()) return
require(selectedIndex in items.indices) { "selectedIndex must be within items.indices" }
val itemCount = items.size
// Cyclic mode starts near the midpoint of the virtual list; non-cyclic mode directly uses the real index.
val initialIndex = remember(itemCount, loop) {
if (loop && itemCount > 1) alignedLoopIndex(selectedIndex, itemCount) else selectedIndex
}
val virtualItemCount = if (loop && itemCount > 1) LoopItemCount else itemCount
val pagerState = rememberPagerState(initialPage = initialIndex) { virtualItemCount }
val coroutineScope = rememberCoroutineScope()
// Long-lived LaunchedEffect must read the latest parameters to avoid calling old closures or old data after recomposition.
val latestOnSelected by rememberUpdatedState(onSelected)
val latestItems by rememberUpdatedState(items)
val latestSelectedIndex by rememberUpdatedState(selectedIndex)
val centerVirtualIndex = pagerState.currentPage
val centerDataIndex = centerVirtualIndex.floorMod(itemCount)
// When there are many visible rows, a fixed angle would clamp multiple outer rows to the same position by maxRotation.
// The actual angle is converged based on the visible row count to ensure each row has an independent position on the cylinder.
val effectiveRotationPerItem = style.effectiveRotationPerItem()
// Uses the real visual boundary after cylindrical projection, not the flat list height of itemHeight * visibleItemCount.
// The projected height is usually significantly smaller than the flat list height of itemHeight * visibleItemCount.
val pickerHeight = style.projectedWheelHeight()
// The first and last items need to be able to move to the exact center of the new container, so padding must be recalculated following the projected height.
val verticalPadding = (pickerHeight - style.itemHeight) / 2
// By default, Pager only allows crossing one page at most, feeling more like a normal selector than an inertial wheel. Here, exponential decay is used
// to retain the velocity at the moment of finger release, then continuously reduce the speed over time, finally snapping to the center with a low-stiffness spring.
val flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
pagerSnapDistance = PagerSnapDistance.atMost(style.maxFlingItems),
decayAnimationSpec = exponentialDecay(frictionMultiplier = style.flingFriction),
snapAnimationSpec = spring(
stiffness = Spring.StiffnessMediumLow,
dampingRatio = Spring.DampingRatioNoBouncy,
),
snapPositionalThreshold = 0.35f,
)
// Synchronize external controlled state. Only animate when the external index differs from the current center item, avoiding callback loops.
LaunchedEffect(selectedIndex, itemCount, loop) {
if (pagerState.isScrollInProgress || centerDataIndex == selectedIndex) return@LaunchedEffect
val target = if (loop && itemCount > 1) {
nearestVirtualIndex(pagerState.currentPage, selectedIndex, itemCount)
} else {
selectedIndex
}
pagerState.animateScrollToPage(target)
}
// VerticalPager completes page snapping first; the selection result is submitted after isScrollInProgress becomes false,
// so the business layer does not receive numerous intermediate callbacks for every item passed during inertial scrolling.
LaunchedEffect(pagerState, itemCount, loop) {
snapshotFlow { pagerState.isScrollInProgress }
.filter { scrolling -> !scrolling }
.collect {
val virtualIndex = pagerState.currentPage.coerceIn(0, virtualItemCount - 1)
val dataIndex = virtualIndex.floorMod(itemCount)
if (dataIndex != latestSelectedIndex) {
latestOnSelected(dataIndex, latestItems[dataIndex])
}
// Reaching the Int boundary is almost impossible in normal use, but the re-centering logic is kept here for long-term operational stability.
// Long is used to calculate the buffer to avoid integer overflow when executing itemCount * 100 on very large datasets.
val edgeBuffer = (itemCount.toLong() * 100L).coerceAtMost(LoopCenter.toLong())
if (loop && itemCount > 1 &&
(virtualIndex.toLong() < edgeBuffer || virtualIndex.toLong() > LoopItemCount - edgeBuffer)
) {
pagerState.scrollToPage(alignedLoopIndex(dataIndex, itemCount))
}
}
}
Box(
modifier = modifier
// Clears minimum height constraints brought in by Modifiers like fillMaxHeight/fillMaxSize, ensuring the component
// always wraps the true wheel surface height; does not affect horizontal constraints like fillMaxWidth.
.wrapContentHeight()
.height(pickerHeight)
.clipToBounds(),
contentAlignment = Alignment.Center,
) {
// The selection background is fixed at the center of the container, with list content scrolling over it.
Box(
Modifier
.fillMaxWidth()
.height(style.itemHeight)
.background(style.selectedBackgroundColor, style.selectionShape),
)
VerticalPager(
modifier = Modifier.fillMaxSize(),
state = pagerState,
userScrollEnabled = enabled,
contentPadding = PaddingValues(vertical = verticalPadding),
pageSize = PageSize.Fixed(style.itemHeight),
flingBehavior = flingBehavior,
// After the container is shortened, the planar positions of the outermost items are outside the viewport. Pager continues to assemble and draw
// the specified number of pages, allowing these items to appear on the complete arc after translationY.
beyondViewportPageCount = style.visibleItemCount / 2,
key = if (loop && itemCount > 1) null else { index: Int -> index },
) { virtualIndex ->
val dataIndex = virtualIndex.floorMod(itemCount)
val distance = virtualIndex - pagerState.currentPage -
pagerState.currentPageOffsetFraction
val selected = virtualIndex == centerVirtualIndex
// distance is in units of "rows": 0 at the center, negative above, positive below.
// Converts the row distance to a cylinder angle, then clamps it within the front visible range.
val rotation = (distance * effectiveRotationPerItem)
.coerceIn(-style.maxRotation, style.maxRotation)
val fraction = rotation / style.maxRotation.coerceAtLeast(1f)
val scale = 1f - fraction.absoluteValue * (1f - style.minScale)
Box(
modifier = Modifier
.fillMaxWidth()
.height(style.itemHeight)
.graphicsLayer {
// Projects the originally equidistant flat list onto the cylinder surface:
// 1. Each row height is treated as arc length s; radius r is obtained from s = r * theta.
// 2. The vertical projection on the cylinder surface is r * sin(theta).
// 3. projectedY - flatY cancels the flat position and moves the item to the arc position.
val angleRadians = rotation * PI.toFloat() / 180f
val radiansPerItem = effectiveRotationPerItem * PI.toFloat() / 180f
val itemHeightPx = style.itemHeight.toPx()
val cylinderRadius = itemHeightPx / radiansPerItem
val projectedY = cylinderRadius * sin(angleRadians)
val flatY = distance * itemHeightPx
translationY = projectedY - flatY
rotationX = -rotation
scaleX = scale
scaleY = scaleX
alpha = 1f - fraction.absoluteValue * (1f - style.minAlpha)
// Compose's cameraDistance uses pixels; multiplying by density keeps perspective intensity consistent across different screen densities.
// Too small a value produces exaggerated perspective or even clipping.
cameraDistance = 12f * density
}
.semantics { this.selected = selected }
.clickable(
enabled = enabled,
role = Role.RadioButton,
) {
if (virtualIndex != centerVirtualIndex) {
coroutineScope.launch {
pagerState.animateScrollToPage(virtualIndex)
}
}
},
contentAlignment = Alignment.Center,
) {
// The two layers must use mutually exclusive clipping: the normal layer only draws outside the mask, the selected layer only draws inside the mask.
// If the normal layer is drawn completely and then the selected layer is overlaid, the underlying layer will show through when font sizes or weights differ,
// visually resulting in ghosting or "double-layered text".
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (selectedItemContent != null) {
Modifier.drawWithContent {
val (clipTop, clipBottom) = selectionClipBounds(
rotation = rotation,
effectiveRotationPerItem = effectiveRotationPerItem,
scale = scale,
itemHeightPx = size.height,
)
// Draw above and below the mask separately, leaving the central intersection area entirely for the selected layer.
clipRect(bottom = clipTop) {
[email protected]()
}
clipRect(top = clipBottom) {
[email protected]()
}
}
} else {
Modifier
},
),
contentAlignment = Alignment.Center,
) {
// When selectedItemContent is not provided, maintain the original API behavior, with the center item receiving selected=true.
itemContent(items[dataIndex], selected && selectedItemContent == null)
}
selectedItemContent?.let { selectedContent ->
Box(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
val (clipTop, clipBottom) = selectionClipBounds(
rotation = rotation,
effectiveRotationPerItem = effectiveRotationPerItem,
scale = scale,
itemHeightPx = size.height,
)
clipRect(
top = clipTop,
bottom = clipBottom,
) {
[email protected]()
}
},
contentAlignment = Alignment.Center,
) {
selectedContent(items[dataIndex])
}
}
}
}
}
}
/**
* Shortcut overload for plain text scenarios.
*
* @param label Converts business data to display text, e.g., `{ "${it}Year" }`.
* Other parameters have the same meaning as the custom content version of [WheelPicker].
*/
@Composable
fun <T> WheelPicker(
items: List<T>,
selectedIndex: Int,
onSelected: (index: Int, item: T) -> Unit,
modifier: Modifier = Modifier,
loop: Boolean = true,
enabled: Boolean = true,
style: WheelPickerStyle = WheelPickerStyle(),
label: (T) -> String = { it.toString() },
) {
WheelPicker(
items = items,
selectedIndex = selectedIndex,
onSelected = onSelected,
modifier = modifier,
loop = loop,
enabled = enabled,
style = style,
selectedItemContent = { item ->
Text(
text = label(item),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
style = style.selectedTextStyle,
maxLines = 1,
)
},
) { item, selected ->
Text(
text = label(item),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
style = if (selected) style.selectedTextStyle else style.unselectedTextStyle,
maxLines = 1,
)
}
}
private fun alignedLoopIndex(dataIndex: Int, itemCount: Int): Int {
// First find the midpoint that doesn't break the data modulo relationship, then add the real index.
// This way, virtualIndex % itemCount always equals dataIndex.
val base = LoopCenter - LoopCenter.floorMod(itemCount)
return base + dataIndex
}
/**
* Calculates the true vertical bounding height of all visible items after cylindrical projection.
*
* The final boundary of each item consists of two parts: the projected position of the item center on the cylinder, and the remaining half-height of the item after
* rotation around the X-axis and scaling. Calculating the maximum boundary row by row is more suitable for different visible row counts like 3/5/7
* than directly using the cylinder diameter, and it doesn't leave blank space at the top and bottom of the wheel produced by the flat layout.
*/
private fun WheelPickerStyle.projectedWheelHeight(): Dp {
val effectiveRotationPerItem = effectiveRotationPerItem()
val radiansPerItem = effectiveRotationPerItem * PI.toFloat() / 180f
val radius = itemHeight.value / radiansPerItem
var maxExtent = itemHeight.value / 2f
for (distance in 1..visibleItemCount / 2) {
val rotation = (distance * effectiveRotationPerItem).coerceAtMost(maxRotation)
val angleRadians = rotation * PI.toFloat() / 180f
val fraction = rotation / maxRotation.coerceAtLeast(1f)
val scale = 1f - fraction * (1f - minScale)
val projectedCenter = radius * sin(angleRadians)
val projectedHalfItem = itemHeight.value / 2f * cos(angleRadians) * scale
maxExtent = max(maxExtent, projectedCenter + projectedHalfItem)
}
return (maxExtent * 2f).dp
}
/**
* Inversely calculates the vertical boundaries of the wheel's central mask into the local coordinates of the current item.
*
* The return value is already clamped to `[0, itemHeightPx]`, and can be used simultaneously for the exclusion clipping of the normal layer and the inclusion
* clipping of the selected layer, ensuring their boundaries are completely consistent with no overlapping drawing areas.
*/
private fun selectionClipBounds(
rotation: Float,
effectiveRotationPerItem: Float,
scale: Float,
itemHeightPx: Float,
): Pair<Float, Float> {
val angleRadians = rotation * PI.toFloat() / 180f
val radiansPerItem = effectiveRotationPerItem * PI.toFloat() / 180f
val cylinderRadius = itemHeightPx / radiansPerItem
val projectedCenter = cylinderRadius * sin(angleRadians)
// The central mask is [-itemHeight/2, itemHeight/2] in wheel coordinates. After rotationX and
// scaleY, the ratio of local vertical coordinates to screen vertical coordinates is approximately cos(angle) * scale.
val verticalProjection =
(cos(angleRadians).absoluteValue * scale).coerceAtLeast(0.001f)
val maskHalfHeight = itemHeightPx / 2f
val localCenter = itemHeightPx / 2f
val clipTop = localCenter + (-maskHalfHeight - projectedCenter) / verticalProjection
val clipBottom = localCenter + (maskHalfHeight - projectedCenter) / verticalProjection
return clipTop.coerceIn(0f, itemHeightPx) to clipBottom.coerceIn(0f, itemHeightPx)
}
/**
* Returns the actual inter-row angle used for the current visible row count.
*
* The outermost row is `visibleItemCount / 2` steps away from the center, so each step can occupy at most
* `maxRotation / steps`. Taking the smaller of this and the user's desired angle satisfies both curvature configuration and complete display.
*/
private fun WheelPickerStyle.effectiveRotationPerItem(): Float {
val stepsToEdge = visibleItemCount / 2f
return minOf(rotationPerItem, maxRotation / stepsToEdge)
}
private fun nearestVirtualIndex(center: Int, dataIndex: Int, itemCount: Int): Int {
// The same real option appears periodically in the virtual list. Here, the occurrence closest to the current item is selected,
// so that when selectedIndex is modified externally, it scrolls only the shortest distance, rather than crossing a large number of virtual items.
val currentDataIndex = center.floorMod(itemCount)
var delta = dataIndex - currentDataIndex
if (delta > itemCount / 2) delta -= itemCount
if (delta < -itemCount / 2) delta += itemCount
return (center + delta).coerceIn(0, LoopItemCount - 1)
}
DateWheelPicker.kt
/**
* var date by remember {
* mutableStateOf(LocalDate.now())
* }
* DateWheelPicker(
* value = date,
* onValueChange = { date = it },
* )
* Default year-month-day wheel picker.
*
* The caller only needs to maintain a single [LocalDate], without separately maintaining year, month, and day indices. The component automatically generates legal days based on the current year and month,
* and handles leap years and transitions between long and short months. For example, if the current date is January 31st, switching to
* February automatically yields the last day of February.
*
* This is a controlled component: [value] is the sole source of truth. After the user completes selecting any column, the component returns a complete and legal new date via
* [onValueChange], and the caller should use this date to update [value].
*
* @param value The currently selected date, whose year must be within [yearRange].
* @param onValueChange Date change callback, returning only a legal [LocalDate].
* @param modifier Modifier for the entire year-month-day three-column container.
* @param yearRange The selectable year range.
* @param loop Whether to allow cyclic scrolling for each column.
* @param enabled Whether to allow gesture and tap operations.
* @param style The wheel visual style shared by the three columns.
* @param yearWeight Width weight for the year column. Year text is usually longer, so it defaults to slightly wider than the month and day columns.
* @param yearLabel Year display format.
* @param monthLabel Month display format.
* @param dayLabel Day display format.
*/
@Composable
fun DateWheelPicker(
value: LocalDate,
onValueChange: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
yearRange: IntRange = 1900..2100,
loop: Boolean = true,
enabled: Boolean = true,
style: WheelPickerStyle = WheelPickerStyle(),
yearWeight: Float = 1.25f,
yearLabel: (Int) -> String = { "${it}Year" },
monthLabel: (Int) -> String = { "${it}Month" },
dayLabel: (Int) -> String = { "${it}Day" },
) {
require(!yearRange.isEmpty()) { "yearRange must not be empty" }
require(value.year in yearRange) { "value.year must be within yearRange" }
require(yearWeight > 0f) { "yearWeight must be greater than 0" }
val years = remember(yearRange.first, yearRange.last) { yearRange.toList() }
val months = remember { (1..12).toList() }
val days = remember(value.year, value.monthValue) {
(1..YearMonth.of(value.year, value.monthValue).lengthOfMonth()).toList()
}
/** Creates a new date, while converging the original date to the last day allowed by the target month. */
fun updateDate(
year: Int = value.year,
month: Int = value.monthValue,
day: Int = value.dayOfMonth,
) {
val validDay = day.coerceAtMost(YearMonth.of(year, month).lengthOfMonth())
val newValue = LocalDate.of(year, month, validDay)
if (newValue != value) onValueChange(newValue)
}
Row(modifier = modifier.fillMaxWidth()) {
WheelPicker(
items = years,
selectedIndex = value.year - yearRange.first,
onSelected = { _, year -> updateDate(year = year) },
modifier = Modifier.weight(yearWeight),
loop = loop,
enabled = enabled,
style = style,
label = yearLabel,
)
WheelPicker(
items = months,
selectedIndex = value.monthValue - 1,
onSelected = { _, month -> updateDate(month = month) },
modifier = Modifier.weight(1f),
loop = loop,
enabled = enabled,
style = style,
label = monthLabel,
)
WheelPicker(
items = days,
selectedIndex = value.dayOfMonth - 1,
onSelected = { _, day -> updateDate(day = day) },
modifier = Modifier.weight(1f),
loop = loop,
enabled = enabled,
style = style,
label = dayLabel,
)
}
}
14. Summary
The core of this WheelPicker is not simply adding rotationX to list items, but treating scrolling, cylindrical projection, true height, inertial snapping, cyclic indexing, and mask clipping as a set of systems that cooperate with each other:
VerticalPagerprovides stable page snapping and assembly of pages outside the viewport.- Virtual indices implement low-cost cyclic scrolling.
- Sine projection makes item centers truly distributed on a cylindrical arc.
- The projected bounding box eliminates blank space at the top and bottom.
- Exponential decay and spring complete a natural fast-to-slow scrolling.
- Mutually exclusive clipping achieves continuous color change within the mask without text ghosting.
- The controlled API and
DateWheelPickerallow the component to directly enter actual business forms.
On this basis, combined selectors for time, date-time, and region cascades can be further encapsulated without modifying the underlying wheel logic.