跪拜 Guibai
← All articles
Android

A Production-Ready iOS-Style 3D Wheel Picker for Jetpack Compose

By Zha0Zhun ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Android lacks a native cylindrical picker that matches the fluid feel of iOS. This component closes that gap with a single-file, controlled-state implementation that slots directly into Compose forms, date pickers, and cascading selectors without pulling in a third-party library.

Summary

The component maps a flat list onto a 3D cylinder using sine projection, so options arc toward the user instead of sitting in a flat column. It pairs a VerticalPager with exponential decay fling behavior to produce the characteristic fast-then-slow inertial scroll, snapping precisely to the center item when motion stops. Cyclic scrolling runs on a virtual index range near Int.MAX_VALUE, avoiding data duplication while silently re-centering to prevent boundary overflows during long sessions.

A dual-layer drawing system with mutually exclusive clipping changes text color progressively as it enters the central mask, eliminating the ghosting that occurs when simply overlaying two text styles. The component recalculates its own height from the projected visual bounds of every visible row, so the container fits the curved surface without top or bottom gaps. A controlled-state API keeps selection logic in the caller, and a bundled DateWheelPicker handles leap years, month-length transitions, and illegal-date convergence from a single LocalDate.

Every parameter — row height, visible count, curvature, scale, alpha, friction, and fling distance — is exposed through a single style object with runtime validation. The implementation stays efficient: the pager assembles only viewport-plus-buffer pages, and the dual text draw is limited to visible items.

Takeaways
Options are arranged on a cylindrical arc via sine projection, not by scaling a flat list.
VerticalPager provides native page snapping and beyond-viewport page assembly, avoiding manual LazyColumn + SnapFlingBehavior workarounds.
Cyclic scrolling uses a virtual index range starting at Int.MAX_VALUE / 2, mapping back to real data with floorMod to avoid duplicating the source list.
The component silently re-centers the virtual index when approaching Int boundaries, keeping the selected item unchanged.
Exponential decay on the fling behavior produces a fast-to-slow inertial scroll; friction is tunable between 0.8f and 3f.
Two text layers with mutually exclusive clipping draw the unselected style outside the mask and the selected style inside it, preventing ghosting when font sizes or weights differ.
Container height is computed from the projected visual bounds of every visible row, eliminating the empty space a flat itemHeight * visibleItemCount calculation would leave.
The component is fully controlled: the caller owns selectedIndex, and onSelected fires only after scrolling and snapping complete.
DateWheelPicker wraps three WheelPickers and converges dates using YearMonth.lengthOfMonth(), handling leap years and month-boundary transitions automatically.
visibleItemCount must be an odd number ≥ 3; the effective inter-row angle is automatically reduced when the count is large to keep all rows on the front arc.
Conclusions

Using VerticalPager instead of LazyColumn is the architectural linchpin — it supplies both snap behavior and off-screen page retention, two capabilities that are otherwise painful to retrofit onto a lazy list.

The dual-layer clipping approach solves a subtle rendering problem (text ghosting from overlaid styles) that many picker implementations ignore or patch with opacity hacks.

Re-centering the virtual index during long scroll sessions is a defensive detail that most cyclic-picker implementations omit, and it prevents a real crash vector when the pager state approaches Int boundaries.

Computing container height from projected bounds rather than a flat formula means the component visually self-adjusts when curvature, scale, or visible count change — a quality-of-life detail that eliminates manual tuning.

Concepts & terms
Cylindrical projection (in UI)
Mapping flat list items onto a virtual cylinder so their positions follow a circular arc. Each item's vertical offset is computed as r * sin(angle), where r is derived from the arc-length formula s = r * θ using the item height as the arc length.
Virtual index cycling
A technique for infinite scrolling that creates a large virtual page range (e.g., Int.MAX_VALUE) and maps it back to real data indices via modulo. The pager only assembles visible pages, so no extra data is allocated.
Exponential decay fling
A scrolling deceleration curve where velocity decreases exponentially over time, producing the fast-then-slow feel characteristic of iOS pickers. Controlled by a friction multiplier in Compose's exponentialDecay spec.
Mutually exclusive clipping
Drawing two content layers where one is clipped to the region outside a mask and the other is clipped to the region inside it, with identical clip boundaries. This prevents overlapping pixels that cause ghosting when the two layers use different typography.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗