Last active
March 27, 2024 15:49
-
-
Save pskink/50864143fa9e7c205879048e93365ea8 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/physics.dart'; | |
main() { | |
runApp(MaterialApp(home: Scaffold(body: ColorWheel()))); | |
} | |
typedef RangeRecord = ({int index, double begin, double end, double turn}); | |
class ColorWheel extends StatefulWidget { | |
@override | |
State<ColorWheel> createState() => _ColorWheelState(); | |
} | |
final spring = SpringDescription.withDampingRatio( | |
mass: 0.4, | |
stiffness: 500, | |
ratio: 0.95, | |
); | |
class _ColorWheelState extends State<ColorWheel> with TickerProviderStateMixin { | |
late final rotation = AnimationController.unbounded(vsync: this) | |
..addListener(_listener); | |
double currentAngle = 0; | |
double oldAngle = 0; | |
final data = [ | |
(0, 3.5, Colors.orange, 'orange', 'Reprehenderit eu laboris aliquip aliqua officia.'), | |
(1, 2.5, Colors.pink, 'pink', 'In culpa pariatur.'), | |
(2, 2.5, Colors.teal, 'teal', 'In culpa pariatur.'), | |
(3, 3, Colors.deepPurple, 'deep purple', 'Aliqua quis et id dolore labore.'), | |
]; | |
final current = ValueNotifier(0); | |
late final ranges = _initRanges(); | |
late double delta; | |
List<RangeRecord> _initRanges() { | |
final totalFlex = data.fold(0.0, (acc, d) => acc += d.$2); | |
double sum = 0; | |
delta = 0.5 * data.first.$2 / totalFlex; | |
return List.generate(data.length, (index) { | |
final v0 = sum / totalFlex; | |
sum += data[index].$2; | |
final v1 = sum / totalFlex; | |
return (index: index, begin: 1 - v1 + delta, end: 1 - v0 + delta, turn: (v0 + v1) / 2 - delta); | |
}); | |
} | |
(double, RangeRecord) get normalizedRotationWithRange { | |
double nr = rotation.value % 1; | |
if (nr < delta) nr++; | |
assert(nr >= delta && nr <= 1 + delta); | |
return (nr, ranges.firstWhere((r) => r.begin <= nr && nr <= r.end)); | |
} | |
@override | |
Widget build(BuildContext context) { | |
ranges.forEach(print); | |
return AspectRatio( | |
aspectRatio: 1, | |
child: LayoutBuilder( | |
builder: (context, constraints) { | |
return ListenableBuilder( | |
listenable: current, | |
builder: (context, _) { | |
print('current: ${current.value} (${data[current.value].$4})'); | |
return GestureDetector( | |
onPanDown: (d) => _updateAngle(constraints.biggest, d.localPosition, true), | |
onPanUpdate: (d) { | |
_updateAngle(constraints.biggest, d.localPosition, false); | |
}, | |
onPanEnd: (d) async { | |
final (v, r) = normalizedRotationWithRange; | |
print('starting animation, target: ${data[r.index].$4}'); | |
final simulation = SpringSimulation(spring, v, (r.begin + r.end) / 2, 0); | |
await rotation.animateWith(simulation); | |
// print('animation finished'); | |
}, | |
child: RotationTransition( | |
turns: rotation, | |
child: Stack( | |
children: [ | |
for (final (i, _, color, colorText, label) in data) | |
Transform.rotate( | |
angle: 2 * pi * ranges[i].turn, | |
child: AnimatedContainer( | |
duration: Durations.extralong4, | |
decoration: ShapeDecoration( | |
shape: PieShape(sweepAngle: 2 * pi * (ranges[i].end - ranges[i].begin)), | |
color: current.value == i? color : Colors.grey, | |
), | |
child: Align( | |
alignment: const Alignment(0, -0.80), | |
child: ConstrainedBox( | |
constraints: BoxConstraints(maxWidth: constraints.maxWidth * 0.4), | |
child: AnimatedContainer( | |
duration: Durations.extralong4, | |
decoration: BoxDecoration( | |
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(8)), | |
color: current.value == i? Color.alphaBlend(Colors.white12, color) : Colors.grey, | |
boxShadow: current.value == i? kElevationToShadow[2] : null, | |
), | |
child: Padding( | |
padding: const EdgeInsets.all(4), | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
Row( | |
children: [ | |
_buildMoveArrow(Icons.arrow_left, i - 1, current.value == i), | |
Expanded( | |
child: Center( | |
child: Text(colorText, style: const TextStyle(fontWeight: FontWeight.bold)), | |
), | |
), | |
_buildMoveArrow(Icons.arrow_right, i + 1, current.value == i), | |
], | |
), | |
ClipRect( | |
child: AnimatedAlign( | |
alignment: Alignment.bottomCenter, | |
duration: Durations.long4, | |
heightFactor: current.value == i? 1 : 0, | |
child: Text(label), | |
), | |
), | |
], | |
), | |
), | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
), | |
); | |
} | |
); | |
}, | |
), | |
); | |
} | |
_updateAngle(Size size, Offset position, bool down) { | |
final center = size.center(Offset.zero); | |
currentAngle = (position - center).direction; | |
final delta = down? 0 : currentAngle - oldAngle; | |
oldAngle = currentAngle; | |
rotation.value += delta / (2 * pi); | |
} | |
void _listener() { | |
final (_, r) = normalizedRotationWithRange; | |
current.value = r.index; | |
} | |
Widget _buildMoveArrow(IconData icon, int i, bool top) { | |
return SizedOverflowBox( | |
size: const Size.square(18), | |
child: AnimatedScale( | |
duration: Durations.long1, | |
scale: top? 1 : 0, | |
child: IconButton( | |
onPressed: () => _move(i), | |
icon: Icon(icon), | |
), | |
), | |
); | |
} | |
_move(int i) async { | |
final r = ranges[i % ranges.length]; | |
double to = (r.begin + r.end) / 2; | |
final diff = to - rotation.value; | |
if (diff.abs() > 0.5) { | |
final delta = (to - rotation.value).round(); | |
// print('before, to: $to, rotation.value: ${rotation.value}, delta: $delta'); | |
to -= delta; | |
// print('after, to: $to'); | |
} | |
final simulation = SpringSimulation(spring, rotation.value, to, 0); | |
await rotation.animateWith(simulation); | |
} | |
} | |
class PieShape extends ShapeBorder { | |
PieShape({ | |
required this.sweepAngle, | |
}); | |
final double sweepAngle; | |
@override | |
EdgeInsetsGeometry get dimensions => EdgeInsets.zero; | |
@override | |
Path getInnerPath(Rect rect, {TextDirection? textDirection}) => getOuterPath(rect); | |
@override | |
Path getOuterPath(Rect rect, {TextDirection? textDirection}) { | |
return Path() | |
..moveTo(rect.center.dx, rect.center.dy) | |
..arcTo(rect, -(sweepAngle + pi) / 2, sweepAngle, false); | |
} | |
@override | |
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { | |
} | |
@override | |
ShapeBorder scale(double t) => this; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment