Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active March 27, 2024 15:49
Show Gist options
  • Save pskink/50864143fa9e7c205879048e93365ea8 to your computer and use it in GitHub Desktop.
Save pskink/50864143fa9e7c205879048e93365ea8 to your computer and use it in GitHub Desktop.
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