Skip to content

Repository files navigation

WheelView

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.

Features

  • 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 AccessibilityEvent and contentDescription.
  • 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.

Installation

dependencies {
    implementation 'cn.nextop:wheel-view:1.0.0'
}

Quick Start

1. Add to your layout

<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" />

2. Provide an adapter

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–23

If 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());

3. Listen for selection changes

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

XML Attributes

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.

Public API

Data

void setAdapter(WheelViewAdapter<?> adapter);
<T> WheelViewAdapter<T> getAdapter();
int getCount();

Selection

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)

Listener

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).
  • setListener returns whichever listener was previously registered, and accepts null to detach the current one — useful for temporarily silencing callbacks:
WheelViewListener previous = wheel.setListener(null);
// ... changes that shouldn't notify the listener ...
wheel.setListener(previous);

Appearance

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));

Misc

void setCookie(Object tag);     // attach arbitrary business data to the view
<T> T getCookie();

Adapter API

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().

Built-in adapters

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

Adapter & Data Changes

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.


Cyclic Mode Notes

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

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).


Accessibility

On each user-driven selection, WheelView:

  • Updates its contentDescription to the selected item's text.
  • Sends an AccessibilityEvent.TYPE_VIEW_SELECTED event.

Note: WheelView does not yet implement ExploreByTouchHelper for per-item touch exploration. Screen reader users can hear the current value, but virtual node-level exploration of individual items is not yet supported.


Known Limitations

  • 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 — persist getIndex() externally across configuration changes if needed.
  • Perspective strength under wheel_curved="true" is tuned empirically against Camera's internal 72-DPI assumption; adjust radius/depth scaling if the 3D effect looks too strong or too subtle on a given device.

License

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.

Preview

WheelView Preview WheelView Preview

About

An Android WheelView

Topics

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages