A custom Android WheelView that implements an iOS-style, cylindrical (3D) picker wheel — similar to UIPickerView — with physics-based scrolling, magnetic snapping, cyclic (infinite loop) mode, haptic feedback, and full XML attribute support.
- True 3D perspective — item rotation and depth are computed with
android.graphics.Camera, not simulated with simple scale/alpha tricks. - Physics-based scrolling — drag, fling, and velocity tracking backed by
Scroller, with edge damping and magnetic snap-to-item alignment. - Cyclic (infinite loop) mode — scroll endlessly through a finite data set.
- Haptic feedback — a subtle tick as the wheel passes each item, using
HapticFeedbackConstantsCompat. - Accessibility — announces the selected value via
AccessibilityEventandcontentDescription. - Adapter-driven data source — decouples data changes from rendering via
WheelViewAdapter+ an observer callback. - Fully configurable via XML attributes — no need to touch code for common customization.
- Curved / flat toggle — switch between the 3D cylindrical look and a flat linear list at runtime.
dependencies {
implementation 'cn.nextop:wheel-view:1.0.0'
}<cn.nextop.mid.widget.wheelview.WheelView
android:id="@+id/wheel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:wheel_visibles="7"
app:wheel_curved="true"
app:wheel_cyclic="true"
app:wheel_divided="true"
app:wheel_haptic_feedback="true" />For a plain list of values, use the built-in ArrayAdapter — no subclassing required:
WheelView wheel = findViewById(R.id.wheel);
wheel.setAdapter(new ArrayAdapter<>(new String[] {
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"
}));For a bounded numeric range (e.g. hours, minutes), use NumericAdapter:
wheel.setAdapter(new NumericAdapter(0, 23)); // 0–23If your data doesn't fit either shape, extend AbstractAdapter<T> directly — it already
takes care of observer registration for you, so you only need to implement the four
data-access methods:
public class UserAdapter extends AbstractAdapter<User> {
private final List<User> data;
public UserAdapter(List<User> data) {
this.data = data;
}
@Override public int getCount() { return data.size(); }
@Override public User getValue(int index) { return data.get(index); }
@Override public int getIndex(User value) { return data.indexOf(value); }
}AbstractAdapter derives the displayed text from getValue(index).toString() by
default. If you need custom formatting instead (e.g. appending a unit, or showing a
display name instead of toString()), call setFormatter:
adapter.setFormatter(user -> user.getDisplayName());wheel.setListener((view, user, index) -> {
Object selected = view.getValue();
Log.d("WheelView", "Selected: " + selected + " (user=" + user + ")");
});user = true— the selection changed because of user interaction (drag, fling, or tap).user = false— the selection changed programmatically (setIndex,setAdapter, data change).
setListener accepts null to remove the current listener, and returns whichever
listener was previously registered — handy if you need to temporarily swap it out and
restore it later:
WheelViewListener previous = wheel.setListener(null); // detach
// ... do something that shouldn't trigger callbacks ...
wheel.setListener(previous); // restore| Attribute | Type | Default | Description |
|---|---|---|---|
wheel_visibles |
integer | 7 |
Number of visible rows (automatically rounded up to an odd number so there's always a centered item). |
wheel_curved |
boolean | true |
Enable the 3D cylindrical perspective. false renders a flat vertical list. |
wheel_cyclic |
boolean | true |
Enable infinite looping through the data set. |
wheel_divided |
boolean | true |
Draw the two horizontal divider lines around the selected row. |
wheel_haptic_feedback |
boolean | true |
Trigger a haptic tick as the wheel scrolls past each item. |
wheel_text_color |
color | Color.GRAY |
Text color for non-selected rows. |
wheel_highlight_color |
color | Color.WHITE |
Text color for the selected row. |
wheel_divider_color |
color | Color.LTGRAY |
Color of the divider lines. |
wheel_text_size |
dimension | 24dp |
Text size (accepts sp/dp/px). |
wheel_item_pace |
dimension | 15dp |
Vertical spacing between the divider lines and the selected row. |
wheel_stroke_width |
dimension | 2dp |
Stroke width used for the divider lines. |
void setAdapter(WheelViewAdapter<?> adapter);
<T> WheelViewAdapter<T> getAdapter();
int getCount();int getIndex(); // currently selected index
<T> T getValue(); // currently selected value, via the adapter
<T> void setValue(T value); // programmatically select by value
void setIndex(int index); // programmatically select by index (no animation)public interface WheelViewListener {
void onSelected(WheelView view, boolean user, int index);
}
WheelViewListener setListener(@Nullable WheelViewListener listener);
WheelViewListener getListener();wheel.setListener((view, user, index) -> { ... });user = true— the selection changed as a result of user interaction (drag, fling, or tap).user = false— the selection changed programmatically (setIndex,setAdapter, data change).setListenerreturns whichever listener was previously registered, and acceptsnullto detach the current one — useful for temporarily silencing callbacks:
WheelViewListener previous = wheel.setListener(null);
// ... changes that shouldn't notify the listener ...
wheel.setListener(previous);Each setter below has a matching getter.
void setVisibles(int count); int getVisibles();
void setCurved(boolean curved); boolean isCurved();
void setDivided(boolean divided); boolean isDivided();
void setCyclic(boolean cyclic); boolean isCyclic();
void setColors(WheelColors colors); WheelColors getColors();
void setItemSpace(int px); int getItemSpace();
void setTextSize(int px);WheelColors is an immutable record grouping the three colors used to render the wheel:
public record WheelColors(
@ColorInt int text, // non-selected row text color
@ColorInt int divider, // divider line color
@ColorInt int highlight // selected row text color
) {}
wheel.setColors(new WheelColors(Color.GRAY, Color.LTGRAY, Color.WHITE));void setCookie(Object tag); // attach arbitrary business data to the view
<T> T getCookie();Beyond getCount()/getValue()/getIndex()/getText() shown in Quick Start,
WheelViewAdapter<T> also exposes:
void register(Observer observer);
void unregister(Observer observer);
void setFormatter(Formatter<T> formatter);
interface Formatter<T> { String format(T value); }
interface Observer {
void onDataChanged(); // underlying data set changed
void onFormatterChanged(); // display formatting changed, same underlying data
}If you extend AbstractAdapter<T>, register/unregister are already implemented for
you — WheelView calls them automatically in setAdapter(), so you don't need to touch
them directly. setFormatter lets you customize how a value is rendered without
changing the underlying data:
adapter.setFormatter(user -> user.getDisplayName());WheelView reacts to both observer callbacks automatically: it re-measures, re-clamps
the selected index, and redraws whenever the adapter notifies onDataChanged() or
onFormatterChanged().
| Class | Use for |
|---|---|
ArrayAdapter<T> |
A plain array/list of values, e.g. new ArrayAdapter<>(new String[]{...}) |
NumericAdapter |
A bounded integer range, e.g. new NumericAdapter(0, 23) for hours |
WheelView reacts to data source changes through the WheelViewAdapter.Observer interface:
public interface Observer {
void onFormatterChanged(); // text formatting changed, same underlying data
void onDataChanged(); // underlying data set changed
}Call the appropriate notification method on your adapter after mutating its backing data; WheelView will re-measure, re-clamp the selected index, and redraw automatically.
When wheel_cyclic="true":
- The wheel scrolls without upper/lower bounds; indices wrap using modulo arithmetic.
- Internal scroll offset is periodically normalized to avoid unbounded growth over long-running sessions.
- Edge damping (
wheel_cyclic="false"only) does not apply, since there is no edge.
When wheel_cyclic="false":
- Scrolling is clamped to
[firstItem, lastItem]. - Dragging past either end applies a damping curve that increases resistance the further you pull.
Haptic feedback fires:
- Once per item boundary crossed, while dragging or during fling/snap animation.
- Once when a selection is confirmed (drag/fling settles, or a tap-to-jump completes).
Feedback respects the system's haptic feedback setting and can be disabled entirely via wheel_haptic_feedback="false" or setHapticFeedbackEnabled(false).
On each user-driven selection, WheelView:
- Updates its
contentDescriptionto the selected item's text. - Sends an
AccessibilityEvent.TYPE_VIEW_SELECTEDevent.
Note:
WheelViewdoes not yet implementExploreByTouchHelperfor per-item touch exploration. Screen reader users can hear the current value, but virtual node-level exploration of individual items is not yet supported.
- Single-line plain text items only; no built-in support for rich/multi-style items (e.g. large number + small unit label).
- No built-in
onSaveInstanceState/onRestoreInstanceState— persistgetIndex()externally across configuration changes if needed. - Perspective strength under
wheel_curved="true"is tuned empirically againstCamera's internal 72-DPI assumption; adjustradius/depth scaling if the 3D effect looks too strong or too subtle on a given device.
Copyright 2016-2026 Nextop Co.,Ltd
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

