spinning_wheel 1.0.0
spinning_wheel: ^1.0.0 copied to clipboard
Customizable animated spinning wheel for Flutter: fortune wheel, prize wheel and lucky draw with weighted odds, swipe to spin and themed frames.
🎡 Spinning Wheel for Flutter #
A customizable, animated spinning wheel widget for Flutter: build a fortune wheel, wheel of fortune, prize wheel, lucky draw, spin-to-win promotion or random picker in a few lines. Weighted odds, server-decided results, swipe to spin, haptic ticks, four ready-made frames, and it stays sharp and smooth at any size.
Contents #
- Features
- Installation
- Quick start
- Segments and probability
- Controlling the spin
- How the spin feels
- Interaction
- Frames
- Slices and labels
- Images and widgets on slices
- Accessibility
- Recipes
- API reference
- Upgrading from 0.0.x
- Example app
- Contributing
- License
✨ Features #
Spinning
- 🎯 Weighted probabilities, or equal odds by default
- 🖥️ Land on a result chosen in advance (for example by your server) with
spinTo(index) - ⏹️ Stop a spin early, and
awaitthe result of any spin - 🌀 Choose the duration, easing curve and number of turns
- 👀 Never looks like it is spinning backwards: speed is kept below the "wagon-wheel" effect
Interaction
- 👆 Tap the center to spin
- 🤚 Drag the wheel around, and fling it to spin in that direction
- 🔊 A callback for every slice that passes the pointer, for tick sounds and haptics
- 📍 Put the pointer on any side, with an optional flick animation
Look
- 🛞 Four ready-made frames: classic, royal gold, neon and wooden ship's helm. You can also paint your own or use an image.
- 🍕 Equal slices, or slices sized by their chance of winning
- 🎨 Gradient or flat slices, slice borders, winner highlight
- 🖼️ Images from assets, URLs or any
ImageProvider, with loading and error placeholders - 🧩 Any widget on a slice, and a text style per slice
- ✍️ Label rotation, wrapping, ellipsis, fading and clipping
Developer friendly
- 🔤 Typed values:
WheelSegment<String>,WheelSegment<MyPrize>, ... - 📐 Responsive: fills the space it's given and scales everything with it
- ♿ Screen reader support and right-to-left text
- 📱 Pure Flutter, works on Android, iOS, web, macOS, Windows and Linux
📦 Installation #
flutter pub add spinning_wheel
Or add it to your pubspec.yaml yourself:
dependencies:
spinning_wheel: ^1.0.0
Requires Flutter 3.27 (Dart 3.6) or newer. Then import it:
import 'package:spinning_wheel/spinning_wheel.dart';
🚀 Quick start #
A complete screen with a wheel and a button:
import 'package:flutter/material.dart';
import 'package:spinning_wheel/spinning_wheel.dart';
class PrizeWheelPage extends StatefulWidget {
const PrizeWheelPage({super.key});
@override
State<PrizeWheelPage> createState() => _PrizeWheelPageState();
}
class _PrizeWheelPageState extends State<PrizeWheelPage> {
final SpinnerController _controller = SpinnerController();
final List<WheelSegment<int>> _segments = [
WheelSegment('100', 100, color: Colors.red),
WheelSegment('200', 200, color: Colors.blue),
WheelSegment('500', 500, color: Colors.green),
WheelSegment('Try again', 0, color: Colors.grey),
];
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 320,
height: 320,
child: SpinnerWheel<int>(
controller: _controller,
segments: _segments,
onComplete: (segment, index) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('You won ${segment.label}!')),
);
},
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _controller.startSpin,
child: const Text('Spin'),
),
],
),
);
}
}
The wheel fills the space its parent gives it, so give it a size (a SizedBox, Expanded, AspectRatio, ...). With no size limit at all, it uses 300 × 300.
🎯 Segments and probability #
Each WheelSegment has a label, a value of any type, and optional color, probability, image, widget and text style:
final segments = [
WheelSegment('Jackpot', 1000, color: Colors.amber, probability: 0.05),
WheelSegment('Prize', 20, color: Colors.blue, probability: 0.30),
WheelSegment('Gift', 100, path: 'https://example.com/gift.png'),
WheelSegment('Nothing', 0, color: Colors.grey, probability: 0.20),
];
How the winner is picked:
- If no segment sets
probability, every segment is equally likely. - Segments without a
probabilityshare whatever is left of1.0equally. Above, "Gift" gets the remaining0.45. - If the explicit values already add up to
1.0or more, segments without one can't win. - Negative values count as
0. - Results use
Random.secure().
Without a color, a segment gets one picked from its label and value, so it stays the same across rebuilds.
🎮 Controlling the spin #
SpinnerController starts, steers and stops the wheel:
// Spin using the probabilities. Completes when the wheel stops.
final WheelSpinResult? result = await controller.startSpin();
print('Landed on ${result?.segment.label} (index ${result?.index})');
// Land on a specific segment, e.g. one your server decided.
await controller.spinTo(2);
// Bring a spinning wheel to a quick stop on whatever it reaches.
controller.stop();
The controller is a ChangeNotifier, so you can rebuild on isSpinning and lastResult:
ListenableBuilder(
listenable: controller,
builder: (context, _) => FilledButton(
onPressed: controller.isSpinning ? controller.stop : controller.startSpin,
child: Text(controller.isSpinning ? 'Stop' : 'Spin'),
),
);
Good to know:
- Calling
startSpin()orspinTo()while the wheel is spinning doesn't start a new spin; it returns the current spin's result. onCompleteis called when the wheel stops, before the future completes.- If the wheel is removed mid-spin, the future completes with
null. - Dispose the controller when you're done with it.
🌀 How the spin feels #
SpinnerWheel(
// ...
spinDuration: const Duration(seconds: 4),
spinCurve: Curves.easeOutCubic, // Default: Curves.decelerate
minSpins: 3, // Whole turns before stopping
maxSpins: 6,
onSpinStart: () => print('Spinning...'),
onSegmentPass: (index) => HapticFeedback.selectionClick(),
);
onSegmentPass fires for every slice that passes the pointer, in order, even when frames are dropped, so it's reliable for tick sounds and haptics.
Why the wheel sometimes makes fewer turns than minSpins. On a screen that redraws 60 times a second, a wheel turning more than half a slice per frame looks like it's turning backwards (the "wagon-wheel" effect). The wheel never turns faster than 40% of a slice per frame. If the requested turns don't fit into spinDuration at that speed, it makes fewer turns, so wheels with many slices turn fewer times. A longer spinDuration allows more turns. Curves that overshoot, like Curves.easeOutBack or Curves.elasticOut, do turn backwards at the end on purpose.
👆 Interaction #
SpinnerWheel(
// ...
tapToSpin: true, // Tap the center to spin
swipeToSpin: true, // Drag the wheel; fling it to spin
indicatorPosition: IndicatorPosition.right, // top, right, bottom or left
indicatorBounce: true, // The pointer flicks as slices pass
);
- A fling spins the wheel in the direction it was thrown, and the result still follows the probabilities.
- A slow drag just turns the wheel.
- A custom
indicatorwidget should be designed pointing down; it's rotated to point at the center from whichever side you choose. - If the wheel sits inside a scrolling list,
swipeToSpincompetes with the list for drags.
🛞 Frames #
The frame around the wheel is painted, not an image, so it stays sharp at any size and scales with the wheel. There are four ready-made frames:
frame: const WheelFrame.classic(), // Red rim, silver teeth, gold studs (default)
frame: const WheelFrame.royal(), // Polished gold set with gems and pearls
frame: const WheelFrame.neon(), // Two glowing tubes on a dark ring
frame: const WheelFrame.wooden(), // A ship's helm with turned wooden handles
Each one can be recolored and tuned:
const WheelFrame.classic(
rimColor: Color(0xFF14532D), toothCount: 12, studCount: 12);
const WheelFrame.royal(
goldColor: Color(0xFFC0C6CC), gemColor: Color(0xFF1565C0), gemCount: 8);
const WheelFrame.neon(
color: Color(0xFF39FF14), secondaryColor: Color(0xFFFFEA00));
const WheelFrame.wooden(woodColor: Color(0xFF5D3A1A), handleCount: 6);
Every frame decides how much room it needs around the slices and suggests a matching pointer color. Override them with wheelInset and indicatorColor. wheelColor: Colors.purple is a shortcut for frame: WheelFrame.classic(rimColor: Colors.purple).
Paint your own frame #
WheelFrame.custom takes painting functions. paintBack draws behind the slices and paintFront over their edge. The WheelFrameGeometry gives you the center, the outer edge and the slices' edge, so your frame lines up at any size:
void paintRing(Canvas canvas, WheelFrameGeometry g) {
canvas.drawPath(
g.ring(g.sliceRadius, g.outer), // From the slices' edge to the outside
Paint()..color = Colors.indigo,
);
}
const myFrame = WheelFrame.custom(
paintFront: paintRing,
preferredInset: 0.08, // Room for the ring, as a fraction of the size
indicatorColor: Colors.amber,
);
- Use top-level or static functions, not inline closures, so the frame isn't repainted on every rebuild.
- For full control, extend
WheelFrameand overridepaintBack,paintFrontandpreferredInset. WheelFrame.paintDropShadow,paintSliceShadow,paintPlateandpaintStudare there to reuse.
Use an image or any widget #
SpinnerWheel(
// ...
background: Image.asset('assets/my_frame.png', fit: BoxFit.contain),
wheelInset: 0.094, // Match the image's rim
);
shouldDrawBackground: false hides the frame completely.
🍕 Slices and labels #
SpinnerWheel(
// ...
sliceSizing: SliceSizing.proportional, // Slice size matches its chance
sliceStyle: SliceStyle.flat, // Or SliceStyle.gradient (default)
sliceBorderColor: Colors.white,
sliceBorderWidth: 2,
highlightWinner: true, // Outline the winner, dim the rest
highlightColor: Colors.yellow,
slicePadding: const EdgeInsets.only(top: 8, left: 4, right: 4),
labelStyle: const WheelLabelStyle(
labelStyle: TextStyle(color: Colors.white, fontSize: 14),
overflow: TextOverflow.ellipsis, // clip, ellipsis, fade or visible
maxLines: 2,
angle: 0.0, // Extra rotation, in radians
),
);
- With
SliceSizing.proportional, a segment with probability0isn't drawn. clip,ellipsisandfadekeep each label inside its slice;visiblelets it overflow.slicePadding.topmoves labels and images toward the center,bottomtoward the rim, andleft/rightnarrow the label.
🧩 Images and widgets on slices #
WheelSegment('Lion', 1, path: 'assets/lion.png'), // Asset
WheelSegment('Gift', 2, path: 'https://example.com/gift.png'), // URL
WheelSegment('Photo', 3, imageProvider: FileImage(file)), // Any ImageProvider
WheelSegment('Star', 4, child: const Icon(Icons.star)), // Any widget
WheelSegment('Big', 5, textStyle: const TextStyle(fontSize: 22)),
- Images load in parallel and appear as they arrive.
- Asset images use the device's 2x/3x versions.
- Set the size with
imageWidthandimageHeight.
Show something while they load, or if they fail:
SpinnerWheel(
// ...
imagePlaceholder: const CircularProgressIndicator(strokeWidth: 2),
imageErrorWidget: const Icon(Icons.broken_image),
onImageError: (segment, error) => debugPrint('${segment.label}: $error'),
);
On the web, network images need the server to allow cross-origin requests (CORS).
♿ Accessibility #
- Screen readers: the wheel is announced as "Spinning wheel" (change it with
semanticsLabel) along with its segments. While it spins it announces "Spinning", and then the result. - Better descriptions: use
WheelSegment.semanticLabelwhen the label text alone isn't clear. - Spinning without sight: with
tapToSpin, screen reader users can spin the wheel by activating it. - Right-to-left text: labels follow the app's text direction.
🍳 Recipes #
Prize decided by your server
final int prizeIndex = await api.claimPrize(); // Your backend picks the prize
final WheelSpinResult? result = await controller.spinTo(prizeIndex);
showPrize(result!.segment.value);
Random name picker or decision maker
final names = ['Alice', 'Bob', 'Chen', 'Dana'];
SpinnerWheel<String>(
controller: controller,
segments: [for (final n in names) WheelSegment(n, n)],
tapToSpin: true,
swipeToSpin: true,
highlightWinner: true,
onComplete: (segment, _) => print('Picked ${segment.value}'),
);
Coupon codes with your own type
class Coupon {
final String code;
const Coupon(this.code);
}
SpinnerWheel<Coupon>(
controller: controller,
segments: [
WheelSegment('10% off', const Coupon('TEN'), probability: 0.6),
WheelSegment('Free ship', const Coupon('SHIP'), probability: 0.3),
WheelSegment('50% off', const Coupon('HALF'), probability: 0.1),
],
sliceSizing: SliceSizing.proportional,
onComplete: (segment, _) => applyCoupon(segment.value.code),
);
Tick sound on every slice
SpinnerWheel(
// ...
indicatorBounce: true,
onSegmentPass: (_) {
HapticFeedback.selectionClick();
tickPlayer.play(); // e.g. from the audioplayers package
},
);
📖 API reference #
SpinnerWheel #
Required
| Property | Type | Description |
|---|---|---|
controller |
SpinnerController |
Starts, steers and stops the wheel |
segments |
List<WheelSegment<T>> |
The slices |
onComplete |
void Function(WheelSegment<T>, int) |
Called when a spin ends, with the winner and its index |
Spinning
| Property | Type | Default | Description |
|---|---|---|---|
spinDuration |
Duration |
5 seconds | How long a spin takes |
spinCurve |
Curve |
Curves.decelerate |
Easing of button and tap spins |
minSpins / maxSpins |
int |
5 / 9 |
Range of whole turns per spin |
onSpinStart |
VoidCallback? |
Called when any spin starts | |
onSegmentPass |
void Function(int)? |
Called for every slice that passes the pointer |
Interaction
| Property | Type | Default | Description |
|---|---|---|---|
tapToSpin |
bool |
false |
Tap the center to spin |
swipeToSpin |
bool |
false |
Drag the wheel and fling it to spin |
indicatorPosition |
IndicatorPosition |
top |
Side the pointer sits on |
indicatorBounce |
bool |
false |
Pointer flicks as slices pass |
Frame
| Property | Type | Default | Description |
|---|---|---|---|
frame |
WheelFrame? |
WheelFrame.classic() |
The frame around the wheel |
wheelColor |
Color? |
Rim color of the classic frame | |
wheelInset |
double? |
the frame's | Gap between the wheel's edge and the slices, as a fraction of its size |
background |
Widget? |
A widget (e.g. an image) in place of the frame | |
shouldDrawBackground |
bool |
true |
Show the frame or background |
Slices and labels
| Property | Type | Default | Description |
|---|---|---|---|
sliceSizing |
SliceSizing |
equal |
equal, or proportional to probability |
sliceStyle |
SliceStyle |
gradient |
gradient or flat fill |
sliceBorderColor |
Color? |
white | Color of slice dividers and outer ring |
sliceBorderWidth |
double |
0 |
Width of slice dividers and outer ring |
highlightWinner |
bool |
false |
Outline the winning slice after a spin |
highlightColor |
Color |
white | Outline color of the winning slice |
labelStyle |
WheelLabelStyle? |
Text style, rotation and overflow of labels | |
slicePadding |
EdgeInsets |
zero |
Padding for labels and images inside slices |
Images
| Property | Type | Default | Description |
|---|---|---|---|
imageWidth / imageHeight |
double? |
11% of the wheel | Size of slice images and widgets |
imagePlaceholder |
Widget? |
Shown while an image loads | |
imageErrorWidget |
Widget? |
Shown if an image fails to load | |
onImageError |
void Function(WheelSegment<T>, Object)? |
Called when an image fails to load |
Pointer, center and accessibility
| Property | Type | Default | Description |
|---|---|---|---|
indicator |
Widget? |
Custom pointer, designed pointing down | |
indicatorColor |
Color? |
the frame's, or red | Color of the default pointer |
centerChild |
Widget? |
Custom widget in the center hub | |
semanticsLabel |
String? |
Spinning wheel |
Screen reader label |
SpinnerController #
| Member | Description |
|---|---|
Future<WheelSpinResult?> startSpin() |
Spins using the probabilities |
Future<WheelSpinResult?> spinTo(int index) |
Spins and lands on index |
void stop() |
Quickly stops a spinning wheel |
bool isSpinning |
Whether the wheel is spinning |
WheelSpinResult? lastResult |
The result of the last finished spin |
bool isAttached |
Whether a wheel is using this controller |
WheelSegment<T> #
| Property | Type | Description |
|---|---|---|
label |
String |
Text on the slice (required) |
value |
T |
Your value for this slice (required) |
color |
Color? |
Slice color; picked from the label if not set |
probability |
double? |
Chance of winning (see probability) |
path |
String? |
Asset path or http(s) URL of the slice image |
imageProvider |
ImageProvider? |
Any image provider; takes precedence over path |
child |
Widget? |
A widget shown in place of the image |
textStyle |
TextStyle? |
Merged on top of the wheel's label style |
semanticLabel |
String? |
Screen reader description; defaults to label |
WheelLabelStyle #
| Property | Type | Default | Description |
|---|---|---|---|
labelStyle |
TextStyle? |
bold, sized to the wheel | Text style of the labels |
angle |
double |
0.0 |
Extra rotation of the text, in radians |
overflow |
TextOverflow |
clip |
clip, ellipsis, fade or visible |
maxLines |
int? |
1 |
Maximum lines per label |
WheelSpinResult<T> #
| Property | Type | Description |
|---|---|---|
segment |
WheelSegment<T> |
The segment the wheel landed on |
index |
int |
Its index in segments |
Frames #
| Frame | Options |
|---|---|
WheelFrame.classic() |
rimColor, rimHighlightColor, trimColor, studColor, toothCount, studCount, plateColor, shadow |
WheelFrame.royal() |
goldColor, gemColor, gemCount, plateColor, shadow |
WheelFrame.neon() |
color, secondaryColor, plateColor |
WheelFrame.wooden() |
woodColor, brassColor, handleCount, shadow |
WheelFrame.custom() |
paintFront, paintBack, preferredInset, indicatorColor |
🔄 Upgrading from 0.0.x #
Version 1.0.0 has a few breaking changes. See the changelog for everything.
Segment values are typed. If you wrote the list type without one, value becomes dynamic:
// Before: value was always an int
List<WheelSegment> segments = [...];
score += win.value; // Error now: can't assign num to int
// After: say what the values are
List<WheelSegment<int>> segments = [...];
Widget tests. find.byType(SpinnerWheel) only matches SpinnerWheel<dynamic>. Use find.byType(SpinnerWheel<int>) or find.byWidgetPredicate((w) => w is SpinnerWheel).
Other changes
startSpin()now completes when the wheel stops, not right away.- The frame is painted instead of an image, and
wheelColorsets its rim color. - The default
spinCurveisCurves.decelerate.
📱 Example app #
The example app has five demos:
- Prize game: a complete game.
- Playground: a live control for every option.
- Frames: a gallery of all the frames.
- Server-decided result: spins to a result picked in advance.
- Custom segments: typed values, widgets and images on slices.
cd example
flutter run
🤝 Contributing #
Bug reports, ideas and pull requests are welcome on GitHub. If this package helps you, please give it a ⭐ on GitHub and a 👍 on pub.flutter-io.cn.
📄 License #
MIT. See LICENSE.
Keywords: flutter spinning wheel, fortune wheel, wheel of fortune, spin the wheel, prize wheel, lucky wheel, lucky draw, spin to win, roulette wheel, random picker, random name picker, decision wheel, raffle, giveaway, gamification, rewards, coupon wheel.
#flutter #dart #flutterpackage #flutterwidget #spinningwheel #spinwheel #fortunewheel #wheeloffortune #spinthewheel #luckywheel #luckydraw #prizewheel #spintowin #roulette #randompicker #decisionwheel #giveaway #raffle #gamification #flutterui #flutteranimation #mobilegame