Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active May 25, 2022 09:45
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pskink/50554a116698f03a862a356c38b75eb3 to your computer and use it in GitHub Desktop.
Save pskink/50554a116698f03a862a356c38b75eb3 to your computer and use it in GitHub Desktop.
class RotaryDial extends StatefulWidget {
@override
_RotaryDialState createState() => _RotaryDialState();
}
class _RotaryDialState extends State<RotaryDial> with SingleTickerProviderStateMixin {
final numbers = List.generate(10, (i) => i != 0? 10 - i : i);
final turns = ProxyAnimation();
final stackKey = GlobalKey();
late AnimationController controller;
var elevation = 2.0;
var digit = -1;
var scaleFactor = 1.0;
@override
void initState() {
// timeDilation = 10;
super.initState();
controller = AnimationController(
vsync: this,
);
}
@override
Widget build(BuildContext context) {
return Stack(
key: stackKey,
children: [
Container(
decoration: ShapeDecoration(
shape: const CircleBorder(),
color: Colors.grey.shade300,
),
),
Builder(
builder: (context) {
final rb = stackKey.currentContext!.findRenderObject() as RenderBox;
return CustomMultiChildLayout(
delegate: RotaryDialDelegate(numbers.length),
children: [
for (var i = 0; i < numbers.length; i++)
RotaryDialButton(
id: i,
onPanDown: (d) => _onPanDown(d.globalPosition, i),
onPanUpdate: (d) => _onPanUpdate(d.globalPosition, rb),
onPanEnd: _onPanEnd,
number: numbers[i].toString(),
),
],
);
}
),
RotationTransition(
turns: turns,
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
decoration: ShapeDecoration(
shape: RotaryDialShape(numbers.length, digit, scaleFactor),
shadows: [
BoxShadow(
blurRadius: 4.0,
spreadRadius: elevation,
offset: Offset(3 * elevation, elevation)),
],
),
),
),
],
);
}
Offset oldPosition = Offset.zero;
double maxAngle = 0.0;
double angle = 0.0;
_onPanDown(Offset position, int i) {
oldPosition = position;
final t = (11 - i) / 12;
angle = 0;
maxAngle = 2 * pi * t;
turns.parent = Tween(begin: 0.0, end: t).animate(controller);
controller.value = 0;
setState(() {
elevation = 0.0;
digit = i;
scaleFactor = 0.25;
});
}
_onPanUpdate(Offset currentPosition, RenderBox rb) {
final center = rb.size.center(rb.localToGlobal(Offset.zero));
final oldAngle = (oldPosition - center).direction;
final currentAngle = (currentPosition - center).direction;
final deltaAngle = (currentAngle - oldAngle + pi) % (2 * pi) - pi;
// assert(deltaAngle >= -pi && deltaAngle <= pi, 'deltaAngle $deltaAngle not in range [-pi, pi]');
angle += deltaAngle;
controller.value = (angle / maxAngle).clamp(0, 1);
oldPosition = currentPosition;
print(controller);
}
_onPanEnd() {
angle = angle.clamp(0, maxAngle);
final milliseconds = angle * 300;
controller.animateTo(0, duration: Duration(milliseconds: milliseconds.toInt()));
setState(() {
elevation = 2.0;
scaleFactor = 1;
});
}
}
class RotaryDialButton extends StatelessWidget {
final int id;
final GestureDragDownCallback onPanDown;
final GestureDragUpdateCallback onPanUpdate;
final VoidCallback onPanEnd;
final String number;
RotaryDialButton({
required this.id,
required this.onPanDown,
required this.onPanUpdate,
required this.onPanEnd,
required this.number,
});
@override
Widget build(BuildContext context) {
return LayoutId(
id: id,
child: Material(
elevation: 4,
shape: const CircleBorder(),
child: GestureDetector(
onPanDown: onPanDown,
onPanUpdate: onPanUpdate,
onPanEnd: (d) => onPanEnd(),
onPanCancel: onPanEnd,
child: InkWell(
onTap: () {},
child: FittedBox(
child: Text(number),
),
),
),
),
);
}
}
class RotaryDialDelegate extends MultiChildLayoutDelegate {
final int numChildren;
RotaryDialDelegate(this.numChildren);
@override
void performLayout(Size size) {
var id = 0;
getBounds(Offset.zero & size, numChildren).forEach((rect) {
layoutChild(id, BoxConstraints.tight(rect.size));
positionChild(id, rect.topLeft);
id++;
});
}
@override
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => true;
}
class RotaryDialShape extends ShapeBorder {
final int numChildren;
final int activeDigit;
final double scaleFactor;
final gradient = RadialGradient(
colors: [Colors.teal.shade200, Colors.teal.shade800],
center: const Alignment(0.2, 0.2),
focal: const Alignment(0.5, 0.5),
);
RotaryDialShape(this.numChildren, this.activeDigit, this.scaleFactor);
@override
ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
if (a is RotaryDialShape) {
return RotaryDialShape(numChildren, activeDigit, ui.lerpDouble(a.scaleFactor, scaleFactor, t)!);
}
return super.lerpFrom(a, t);
}
@override
Path getOuterPath(Rect rect, {ui.TextDirection? textDirection}) {
final oval = Rect.fromCircle(center: rect.center, radius: rect.shortestSide / 2);
var path = Path()
..addOval(oval)
..fillType = PathFillType.evenOdd;
getBounds(rect, numChildren)
.mapIndexed(_scale)
.forEach(path.addOval);
return path;
}
Rect _scale(index, oval) {
return index == activeDigit? oval : Rect.fromCenter(
center: oval.center,
width: scaleFactor * oval.width,
height: scaleFactor * oval.height,
);
}
@override void paint(Canvas canvas, Rect rect, {ui.TextDirection? textDirection}) {
final path = getOuterPath(rect);
final paint = Paint()
..shader = gradient.createShader(rect);
canvas.drawPath(path, paint);
}
@override EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override Path getInnerPath(Rect rect, {ui.TextDirection? textDirection}) => getOuterPath(rect);
@override ShapeBorder scale(double t) => this;
}
Iterable<Rect> getBounds(Rect rect, int length) sync* {
final s = Size.square(rect.shortestSide / 6.5);
final radius = (rect.shortestSide - s.shortestSide) * 0.45;
for (var i = 0; i < length; i++) {
final angle = i * pi / 6 + pi / 4;
final center = rect.center + Offset(cos(angle), sin(angle)) * radius;
yield Rect.fromCenter(center: center, width: s.width, height: s.height);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment