Skip to content

Commit aaf52ad

Browse files
authored
Merge pull request #143 from kevlatus/indefinite-spin-13212144778224149486
Support indefinite spinning and smooth stop at target
2 parents c8f0b8c + 2be756d commit aaf52ad

7 files changed

Lines changed: 199 additions & 18 deletions

File tree

example/lib/pages/bar.dart

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'package:flutter/material.dart';
23
import 'package:flutter_fortune_wheel/flutter_fortune_wheel.dart';
34
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -18,16 +19,35 @@ class FortuneBarPage extends HookWidget {
1819
final selected = useStreamController<int>();
1920
final selectedIndex = useStream(selected.stream, initialData: 0).data ?? 0;
2021
final isAnimating = useState(false);
22+
final isIndefinite = useState(false);
2123

2224
void handleRoll() {
23-
selected.add(
24-
roll(Constants.fortuneValues.length),
25-
);
25+
if (isIndefinite.value) {
26+
selected.add(Fortune.indefinite);
27+
Future.delayed(const Duration(seconds: 2), () {
28+
selected.add(roll(Constants.fortuneValues.length));
29+
});
30+
} else {
31+
selected.add(
32+
roll(Constants.fortuneValues.length),
33+
);
34+
}
2635
}
2736

2837
return AppLayout(
2938
child: Column(
3039
children: [
40+
SizedBox(height: 8),
41+
Row(
42+
mainAxisAlignment: MainAxisAlignment.center,
43+
children: [
44+
Text('Indefinite wait'),
45+
Switch(
46+
value: isIndefinite.value,
47+
onChanged: (v) => isIndefinite.value = v,
48+
),
49+
],
50+
),
3151
SizedBox(height: 8),
3252
RollButtonWithPreview(
3353
selected: selectedIndex,

example/lib/pages/wheel.dart

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'package:flutter/material.dart';
23
import 'package:flutter_fortune_wheel/flutter_fortune_wheel.dart';
34
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -19,16 +20,25 @@ class FortuneWheelPage extends HookWidget {
1920
final selected = useStreamController<int>();
2021
final selectedIndex = useStream(selected.stream, initialData: 0).data ?? 0;
2122
final isAnimating = useState(false);
23+
final isIndefinite = useState(false);
2224

2325
final alignmentSelector = AlignmentSelector(
2426
selected: alignment.value,
2527
onChanged: (v) => alignment.value = v!,
2628
);
2729

2830
void handleRoll() {
29-
selected.add(
30-
roll(Constants.fortuneValues.length),
31-
);
31+
if (isIndefinite.value) {
32+
// Indefinite mode: start spinning, then stop after delay
33+
selected.add(Fortune.indefinite);
34+
Future.delayed(const Duration(seconds: 2), () {
35+
selected.add(roll(Constants.fortuneValues.length));
36+
});
37+
} else {
38+
selected.add(
39+
roll(Constants.fortuneValues.length),
40+
);
41+
}
3242
}
3343

3444
return AppLayout(
@@ -38,6 +48,17 @@ class FortuneWheelPage extends HookWidget {
3848
children: [
3949
alignmentSelector,
4050
SizedBox(height: 8),
51+
Row(
52+
mainAxisAlignment: MainAxisAlignment.center,
53+
children: [
54+
Text('Indefinite wait'),
55+
Switch(
56+
value: isIndefinite.value,
57+
onChanged: (v) => isIndefinite.value = v,
58+
),
59+
],
60+
),
61+
SizedBox(height: 8),
4162
RollButtonWithPreview(
4263
selected: selectedIndex,
4364
items: Constants.fortuneValues,

lib/src/bar/fortune_bar.dart

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ class FortuneBar extends StatefulWidget implements FortuneWidget {
105105
class _FortuneBarState extends State<FortuneBar>
106106
with SingleTickerProviderStateMixin {
107107
late FortuneAnimationManager _animationManager;
108+
double _scrollWeightOffset = 0;
109+
int _previousIndex = 0;
108110

109111
@override
110112
void initState() {
@@ -118,15 +120,58 @@ class _FortuneBarState extends State<FortuneBar>
118120
onAnimationEnd: () => widget.onAnimationEnd?.call(),
119121
);
120122

123+
_animationManager.selectedIndex.addListener(_handleSelectionChange);
124+
121125
if (widget.animateFirst) {
122126
WidgetsBinding.instance!.addPostFrameCallback((_) {
123127
_animationManager.animate();
124128
});
125129
}
126130
}
127131

132+
void _handleSelectionChange() {
133+
final oldIndex = _previousIndex;
134+
final newIndex = _animationManager.selectedIndex.value;
135+
136+
final currentRotation = _animationManager.animation.value;
137+
final totalWeight = _getTotalWeight();
138+
139+
final oldTarget = _getItemCenterWeight(oldIndex);
140+
final oldTotalWeight = widget.rotationCount * totalWeight + oldTarget;
141+
142+
// Use previous interpolation logic to reconstruct where we are.
143+
// oldScrollWeight = _scrollWeightOffset * (1 - t) + t * oldTotalWeight
144+
145+
final oldScrollWeight = _scrollWeightOffset * (1 - currentRotation) + currentRotation * oldTotalWeight;
146+
147+
final newTarget = _getItemCenterWeight(newIndex);
148+
// newTotalWeight = widget.rotationCount * totalWeight + newTarget;
149+
// newScrollWeight(0) = _newOffset * (1-0) + 0 * ... = _newOffset.
150+
151+
// We want oldScrollWeight = newScrollWeight(0) = _newOffset.
152+
153+
_scrollWeightOffset = oldScrollWeight;
154+
_previousIndex = newIndex;
155+
}
156+
157+
double _getTotalWeight() {
158+
return widget.items.fold<double>(0, (p, e) => p + e.weight);
159+
}
160+
161+
double _getItemCenterWeight(int index) {
162+
if (index < 0 || index >= widget.items.length) return 0;
163+
164+
double targetCenterWeight = 0;
165+
for (int i = 0; i < index; i++) {
166+
targetCenterWeight += widget.items[i].weight;
167+
}
168+
targetCenterWeight += widget.items[index].weight / 2;
169+
return targetCenterWeight;
170+
}
171+
128172
@override
129173
void dispose() {
174+
_animationManager.selectedIndex.removeListener(_handleSelectionChange);
130175
_animationManager.dispose();
131176
super.dispose();
132177
}
@@ -164,8 +209,7 @@ class _FortuneBarState extends State<FortuneBar>
164209
);
165210

166211
// Calculate weights and dimensions
167-
final totalWeight =
168-
widget.items.fold<double>(0, (p, e) => p + e.weight);
212+
final totalWeight = _getTotalWeight();
169213
final avgWeight = totalWeight / widget.items.length;
170214
final visibleWeight = widget.visibleItemCount * avgWeight;
171215
final unitWidth = size.width / visibleWeight;
@@ -179,11 +223,7 @@ class _FortuneBarState extends State<FortuneBar>
179223
builder: (context, _) {
180224
// Calculate Target
181225
final selectedIndex = _animationManager.selectedIndex.value;
182-
double targetCenterWeight = 0;
183-
for (int i = 0; i < selectedIndex; i++) {
184-
targetCenterWeight += widget.items[i].weight;
185-
}
186-
targetCenterWeight += widget.items[selectedIndex].weight / 2;
226+
final targetCenterWeight = _getItemCenterWeight(selectedIndex);
187227

188228
final targetTotalScrollWeight =
189229
widget.rotationCount * totalWeight + targetCenterWeight;
@@ -198,8 +238,11 @@ class _FortuneBarState extends State<FortuneBar>
198238
final isAnimatingPanFactor = isAnimating ? 0 : 1;
199239

200240
// Current Scroll Weight
201-
final currentScrollWeight = _animationManager.animation.value *
202-
targetTotalScrollWeight +
241+
// Logic: _scrollWeightOffset * (1 - t) + t * targetTotalScrollWeight
242+
243+
final animationValue = _animationManager.animation.value;
244+
final currentScrollWeight = _scrollWeightOffset * (1 - animationValue) +
245+
animationValue * targetTotalScrollWeight +
203246
panWeight * isAnimatingPanFactor;
204247

205248
final scrollOffset = currentScrollWeight * unitWidth;

lib/src/core/animations.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ class FortuneAnimationManager {
6060

6161
await Future.microtask(() => onAnimationStart?.call());
6262
try {
63-
await controller.forward(from: 0);
63+
if (selectedIndex.value == Fortune.indefinite) {
64+
await controller.repeat();
65+
} else {
66+
await controller.forward(from: 0);
67+
}
6468
} catch (e) {
6569
// Controller might be disposed
6670
return;

lib/src/core/random.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ part of 'core.dart';
44

55
/// Static methods for common tasks when working with [FortuneWidget]s.
66
abstract class Fortune {
7+
/// The value to use to indicate that the fortune wheel should spin indefinitely.
8+
static const int indefinite = -1;
9+
710
/// Generates a random integer uniformly distributed in the range
811
/// from [min], inclusive, to [max], exclusive.
912
///

lib/src/wheel/fortune_wheel.dart

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ class _FortuneWheelState extends State<FortuneWheel>
205205
late AnimationController _arrowController;
206206
late Animation<double> _arrowAnimation;
207207
double _lastVibratedAngle = 0;
208+
double _rotationOffset = 0;
209+
int _previousIndex = 0;
208210

209211
@override
210212
void initState() {
@@ -232,13 +234,52 @@ class _FortuneWheelState extends State<FortuneWheel>
232234
onAnimationEnd: () => widget.onAnimationEnd?.call(),
233235
);
234236

237+
_animationManager.selectedIndex.addListener(_handleSelectionChange);
238+
235239
if (widget.animateFirst) {
236240
WidgetsBinding.instance!.addPostFrameCallback((_) {
237241
_animationManager.animate();
238242
});
239243
}
240244
}
241245

246+
void _handleSelectionChange() {
247+
final oldIndex = _previousIndex;
248+
final newIndex = _animationManager.selectedIndex.value;
249+
250+
final currentRotation = _animationManager.animation.value;
251+
final oldSelectedAngle = _getAngleForIndex(oldIndex);
252+
253+
// We want to calculate the current actual angle of rotation.
254+
// However, build() uses _rotationOffset * (1 - animation.value).
255+
// So the previous state calculation is slightly complex.
256+
// But conceptually, _rotationOffset decays to 0.
257+
// So if the previous animation finished (value=1), the offset is 0.
258+
// If it was interrupted (e.g. infinite spin), value might be anything.
259+
// But in infinite spin, we likely want to use the standard rotation formula.
260+
// Let's assume standard rotation logic applies for extracting "current pos".
261+
262+
// Wait, if we use the new formula:
263+
// rotationAngle = _rotationOffset * (1 - value) + widget._getAngle(value);
264+
265+
final oldRotationAngle = _rotationOffset * (1 - currentRotation) + widget._getAngle(currentRotation);
266+
267+
final newSelectedAngle = _getAngleForIndex(newIndex);
268+
269+
// We want the new animation to start at the same total angle.
270+
// newTotalAngle(0) = newSelectedAngle + newRotationAngle(0).
271+
// newRotationAngle(0) = _newRotationOffset * (1 - 0) + widget._getAngle(0)
272+
// = _newRotationOffset.
273+
274+
// So: oldSelectedAngle + oldRotationAngle = newSelectedAngle + _newRotationOffset.
275+
// _newRotationOffset = oldSelectedAngle + oldRotationAngle - newSelectedAngle.
276+
277+
final oldTotal = oldSelectedAngle + oldRotationAngle;
278+
_rotationOffset = oldTotal - newSelectedAngle;
279+
280+
_previousIndex = newIndex;
281+
}
282+
242283
void _arrowStatusListener(AnimationStatus status) {
243284
if (status == AnimationStatus.completed) {
244285
_arrowController.reverse();
@@ -254,6 +295,7 @@ class _FortuneWheelState extends State<FortuneWheel>
254295

255296
@override
256297
void dispose() {
298+
_animationManager.selectedIndex.removeListener(_handleSelectionChange);
257299
_arrowController.removeStatusListener(_arrowStatusListener);
258300
_arrowController.dispose();
259301
_animationManager.dispose();
@@ -275,6 +317,10 @@ class _FortuneWheelState extends State<FortuneWheel>
275317
}
276318

277319
double _getAngleForIndex(int index) {
320+
if (index < 0 || index >= widget.items.length) {
321+
return 0;
322+
}
323+
278324
final items = widget.items;
279325
final totalWeight = items.fold<double>(0, (prev, element) => prev + element.weight);
280326

@@ -326,8 +372,13 @@ class _FortuneWheelState extends State<FortuneWheel>
326372

327373
final panAngle =
328374
panState.distance * panFactor * isAnimatingPanFactor;
329-
final rotationAngle =
330-
widget._getAngle(_animationManager.animation.value);
375+
376+
// Use the interpolation logic:
377+
// rotationAngle = _rotationOffset * (1 - t) + standardRotation(t).
378+
final animationValue = _animationManager.animation.value;
379+
final rotationAngle = _rotationOffset * (1 - animationValue) +
380+
widget._getAngle(animationValue);
381+
331382
final alignmentOffset =
332383
_calculateAlignmentOffset(widget.alignment);
333384
final totalAngle = selectedAngle + panAngle + rotationAngle;

test/indefinite_test.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
2+
import 'dart:async';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_fortune_wheel/flutter_fortune_wheel.dart';
5+
import 'package:flutter_test/flutter_test.dart';
6+
7+
import 'test_helpers.dart';
8+
9+
void main() {
10+
group('FortuneWheel Indefinite Spin', () {
11+
testWidgets('supports indefinite spin then stop at target', (WidgetTester tester) async {
12+
final selected = StreamController<int>();
13+
14+
await pumpFortuneWidget(tester,
15+
FortuneWheel(
16+
items: [
17+
FortuneItem(child: Text('0')),
18+
FortuneItem(child: Text('1')),
19+
FortuneItem(child: Text('2')),
20+
],
21+
selected: selected.stream,
22+
),
23+
);
24+
25+
// Trigger indefinite spin
26+
selected.add(Fortune.indefinite);
27+
await tester.pump();
28+
await tester.pump(const Duration(seconds: 1));
29+
30+
// Trigger stop
31+
selected.add(1);
32+
await tester.pump();
33+
await tester.pump(const Duration(seconds: 5)); // Allow time to settle
34+
35+
// Verify no crashes.
36+
// In a real integration test we would verify the visual state, but here we ensure the logic runs.
37+
});
38+
});
39+
}

0 commit comments

Comments
 (0)