A beautiful 3D perspective wheel selector for iOS, ported from the Android library by Shchurov. This library provides a unique horizontal wheel picker with perspective rendering and active range highlighting.
Custom view for user input that models horizontal wheel controller.
- 3D Perspective Rendering: Marks appear with realistic 3D perspective using sinusoidal distribution
- Active Range Coloring: Highlighted marks within the active rotation range
- Smooth Animations: Fluid scroll and snap animations with deceleration
- Customizable Appearance: Colors, mark count, and visual properties
- Touch Gestures: Intuitive pan gestures with fling support
- Haptic & Sound Feedback: Optional UIPickerView-style feedback (iOS 10+)
- End Lock & Constraints: Limit rotation range and enforce positive-only values
- Snap to Marks: Optional snapping behavior for precise selection
- Clone or download this repository
- Add
HorizontalWheelView.Library.iOS.csprojto your solution - Add a project reference from your app to the library
<ItemGroup>
<ProjectReference Include="..\HorizontalWheelView.Library.iOS\HorizontalWheelView.Library.iOS.csproj" />
</ItemGroup>dotnet add package Polyariz.iOS.Shchurov.HorizontalWheelView- iOS 15.0 or later
- .NET 10.0-ios
- Xcode 15+ (for building)
using Polyariz.iOS.Shchurov.HorizontalWheelView;
// Create the wheel view
var wheelView = new HorizontalWheelView();
// Configure appearance
wheelView.SetMarksCount(40);
wheelView.SetNormalColor(UIColor.White);
wheelView.SetActiveColor(UIColor.SystemBlue);
wheelView.SetShowActiveRange(true);
// Add to your view hierarchy
View.AddSubview(wheelView);
// Set up Auto Layout constraints
wheelView.TranslatesAutoresizingMaskIntoConstraints = false;
NSLayoutConstraint.ActivateConstraints(new[]
{
wheelView.CenterXAnchor.ConstraintEqualTo(View.CenterXAnchor),
wheelView.CenterYAnchor.ConstraintEqualTo(View.CenterYAnchor),
wheelView.WidthAnchor.ConstraintEqualTo(200),
wheelView.HeightAnchor.ConstraintEqualTo(64)
});// Create a listener
public class MyWheelListener : HorizontalWheelViewListener
{
public override void OnRotationChanged(double radians)
{
double degrees = radians * 180 / Math.PI;
Console.WriteLine($"Angle changed to: {degrees}°");
}
public override void OnScrollStateChanged(int state)
{
switch (state)
{
case HorizontalWheelView.SCROLL_STATE_IDLE:
Console.WriteLine("Wheel is idle");
break;
case HorizontalWheelView.SCROLL_STATE_DRAGGING:
Console.WriteLine("User is dragging");
break;
case HorizontalWheelView.SCROLL_STATE_SETTLING:
Console.WriteLine("Wheel is settling");
break;
}
}
}
// Set the listener
wheelView.SetListener(new MyWheelListener());// Set angle in radians
wheelView.SetRadiansAngle(Math.PI); // 180°
// Set angle in degrees
wheelView.SetDegreesAngle(45.0); // 45°
// Set angle as complete turn fraction
wheelView.SetCompleteTurnFraction(0.25); // 90° (quarter turn)// Get angle in radians
double radians = wheelView.GetRadiansAngle();
// Get angle in degrees
double degrees = wheelView.GetDegreesAngle();
// Get angle as complete turn fraction
double fraction = wheelView.GetCompleteTurnFraction();// Set colors using UIColor
wheelView.SetNormalColor(UIColor.White);
wheelView.SetActiveColor(UIColor.SystemBlue);
// Set colors using ARGB integer format
wheelView.SetNormalColor(0xFFFFFFFF); // White
wheelView.SetActiveColor(0xFF54ACF0); // Blue// Set the number of marks around the wheel
wheelView.SetMarksCount(40); // Default: 40
// Show or hide active range highlighting
wheelView.SetShowActiveRange(true); // Default: true// Add padding around the wheel (similar to Android padding)
wheelView.ContentEdgeInsets = new UIEdgeInsets(
top: 0,
left: 0,
bottom: 32, // Add bottom padding
right: 0
);// Enable snapping to nearest mark when user releases
wheelView.SetSnapToMarks(true); // Default: false// Restrict to positive angles only (0 to 2π)
wheelView.SetOnlyPositiveValues(true); // Default: false
// Lock rotation at range boundaries (prevent wrapping)
wheelView.SetEndLock(true); // Default: falseWhen both options are enabled:
- Rotation is limited to 0° to 360° (0 to 2π radians)
- Rotation stops at boundaries instead of wrapping around
// Enable/disable haptic feedback when crossing marks
wheelView.SetHapticFeedbackEnabled(true); // Default: trueUses UIImpactFeedbackGenerator with Light style, similar to UIPickerView.
// Enable/disable sound feedback when crossing marks
wheelView.SetSoundFeedbackEnabled(true); // Default: truePlays system sound ID 1104 (picker click sound) when crossing mark boundaries.
public class HorizontalWheelViewListener
{
// Called when rotation angle changes
public virtual void OnRotationChanged(double radians) { }
// Called when scroll state changes
public virtual void OnScrollStateChanged(int state) { }
}Scroll states:
HorizontalWheelView.SCROLL_STATE_IDLE(0) - Not scrollingHorizontalWheelView.SCROLL_STATE_DRAGGING(1) - User is draggingHorizontalWheelView.SCROLL_STATE_SETTLING(2) - Animating to final position
public class RocketViewController : UIViewController
{
private HorizontalWheelView wheelView;
private UIImageView rocketImageView;
public override void ViewDidLoad()
{
base.ViewDidLoad();
// Create wheel view
wheelView = new HorizontalWheelView();
wheelView.SetMarksCount(40);
wheelView.SetNormalColor(UIColor.White);
wheelView.SetActiveColor(UIColor.FromRGB(1.0f, 0.84f, 0.0f));
// Create rocket image
rocketImageView = new UIImageView();
var config = UIImageSymbolConfiguration.Create(UIImageSymbolScale.Large);
rocketImageView.Image = UIImage.GetSystemImage("airplane", config);
rocketImageView.TintColor = UIColor.White;
// Listen to rotation changes
wheelView.SetListener(new RocketListener(this));
// Add to view and setup constraints...
}
private class RocketListener : HorizontalWheelViewListener
{
private readonly RocketViewController controller;
public RocketListener(RocketViewController controller)
{
this.controller = controller;
}
public override void OnRotationChanged(double radians)
{
// Rotate the rocket image
controller.rocketImageView.Transform =
CGAffineTransform.MakeRotation((nfloat)radians);
}
}
}public class TemperatureSelector
{
private HorizontalWheelView wheelView;
private UILabel temperatureLabel;
public TemperatureSelector()
{
wheelView = new HorizontalWheelView();
wheelView.SetMarksCount(100); // 0-100 degrees
wheelView.SetOnlyPositiveValues(true); // Only positive temps
wheelView.SetEndLock(true); // Lock at 0 and 100
wheelView.SetSnapToMarks(true); // Snap to integers
wheelView.SetActiveColor(UIColor.SystemRed);
wheelView.SetListener(new TemperatureListener(this));
}
private class TemperatureListener : HorizontalWheelViewListener
{
private readonly TemperatureSelector selector;
public TemperatureListener(TemperatureSelector selector)
{
this.selector = selector;
}
public override void OnRotationChanged(double radians)
{
// Convert to temperature (0-100°C)
double fraction = radians / (2 * Math.PI);
int temperature = (int)(fraction * 100);
selector.temperatureLabel.Text = $"{temperature}°C";
}
}
}public class VolumeControl
{
private HorizontalWheelView wheelView;
public VolumeControl()
{
wheelView = new HorizontalWheelView();
wheelView.SetMarksCount(20); // 20 volume levels
wheelView.SetOnlyPositiveValues(true);
wheelView.SetEndLock(true);
wheelView.SetSnapToMarks(true);
// Enable feedback for better UX
wheelView.SetHapticFeedbackEnabled(true);
wheelView.SetSoundFeedbackEnabled(true);
// Customize appearance
wheelView.SetNormalColor(0xFFCCCCCC);
wheelView.SetActiveColor(0xFF00FF00); // Green for volume
wheelView.SetListener(new VolumeListener(this));
}
private class VolumeListener : HorizontalWheelViewListener
{
private readonly VolumeControl control;
public VolumeListener(VolumeControl control)
{
this.control = control;
}
public override void OnRotationChanged(double radians)
{
// Calculate volume level (0-20)
double fraction = radians / (2 * Math.PI);
int volume = (int)(fraction * 20);
// Update system volume or your custom logic
Console.WriteLine($"Volume: {volume}/20");
}
public override void OnScrollStateChanged(int state)
{
if (state == HorizontalWheelView.SCROLL_STATE_IDLE)
{
// Volume adjustment completed
Console.WriteLine("Volume adjustment complete");
}
}
}
}The library consists of four main components:
- HorizontalWheelView: Main view class that manages state and coordinates other components
- Drawer: Handles all rendering with 3D perspective effects and active range coloring
- TouchHandler: Manages pan gestures, animations, and scroll states
- Utils: Helper utilities for coordinate transformations
The wheel uses sinusoidal distribution to create realistic 3D perspective:
// Calculate gaps between marks based on angle
gap[i] = sin(angle / 2) * scaleFactor
// Apply shade based on distance from center
shade = 1 - SHADE_RANGE * (1 - sin(angle))
// Apply scale based on distance from center
scale = 1 - SCALE_RANGE * (1 - sin(angle))Marks change color based on their position relative to the rotation angle:
- Marks within
|markAngle| <= |rotationAngle|use active color - Other marks use normal color
- Zero mark (12 o'clock position) is always highlighted
Settling animations use CADisplayLink for smooth 60fps rendering with deceleration interpolation:
// Decelerate interpolator with factor 2.5
interpolatedT = 1.0 - Math.Pow(1.0 - t, 5.0)This iOS port maintains 98.5% compliance with the original Android library:
✅ Fully Implemented:
- All 14 public API methods
- 3D perspective rendering algorithm
- Active range coloring logic
- Touch handling and animations
- Snap to marks behavior
- End lock and value constraints
- Color customization (UIColor and ARGB int)
✅ iOS Enhancements:
- Haptic feedback (UIImpactFeedbackGenerator)
- Sound feedback (System Sound 1104)
- Native iOS gesture recognizers
- Auto Layout support
❌ Not Implemented:
- SavedState (Android lifecycle-specific)
- Rendering: 60 FPS using Core Graphics
- Touch latency: < 16ms using UIPanGestureRecognizer
- Animation: Smooth deceleration using CADisplayLink
- Memory: Lightweight (~100KB for library)
A complete sample application is included in HorizontalWheelView.Sample.iOS demonstrating:
- Basic wheel view with rocket rotation
- All API features
- Haptic and sound feedback
- Custom colors and mark counts
To run the sample:
cd HorizontalWheelView.Sample.iOS
dotnet build
# Open in Xcode or run on simulatorIssue: Library doesn't build
Solution: Ensure you have .NET 10.0-ios SDK installed
dotnet --list-sdks # Check installed SDKs
Issue: Missing namespace errors
Solution: Add using directive:
using Polyariz.iOS.Shchurov.HorizontalWheelView;
Issue: Wheel doesn't appear
Solution: Check that constraints are properly set and view has non-zero size
Issue: Touch gestures not working
Solution: Ensure UserInteractionEnabled is true (it's enabled by default)
Issue: No haptic feedback
Solution: Haptic feedback requires iOS 10+ and a physical device
Issue: No sound feedback
Solution: Check device volume and silent mode settings
If you're migrating from the Android version, here are the key differences:
| Android | iOS | Notes |
|---|---|---|
Canvas / Paint |
CGContext |
Core rendering |
ValueAnimator |
CADisplayLink |
Animation system |
GestureDetector |
UIPanGestureRecognizer |
Touch handling |
onSaveInstanceState |
Not needed | iOS manages state differently |
dp units |
Points | iOS uses points (logical pixels) |
Color.argb() |
UIColor.FromRGBA() |
Color creation |
- Original Android Library: HorizontalWheelView by Shchurov
- iOS Port: Polyariz
- Inspired by: UIPickerView
MIT License - See LICENSE file for details
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/AmazingFeature) - Commit your changes (
git commit -m 'Add some AmazingFeature') - Push to the branch (
git push origin feature/AmazingFeature) - Open a Pull Request
- Initial iOS port from Android
- Full API compliance (98.5%)
- Added haptic feedback support
- Added sound feedback support
- iOS 15.0+ support
- .NET 10.0-ios support
For issues, questions, or suggestions:
- Open an issue on GitHub
- Check existing issues and discussions
- Review the sample project for examples
Made with ❤️ for iOS developers
