Last active
March 2, 2023 08:39
-
-
Save pingbird/7dfbc4d743b108755b6521d0b8f24fd9 to your computer and use it in GitHub Desktop.
This file contains hidden or 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'; | |
| void main() { | |
| runApp(const MyApp()); | |
| } | |
| class MyApp extends StatelessWidget { | |
| const MyApp({Key? key}) : super(key: key); | |
| @override | |
| Widget build(BuildContext context) { | |
| return const MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| home: MyHomePage(), | |
| ); | |
| } | |
| } | |
| class MyHomePage extends StatefulWidget { | |
| const MyHomePage({Key? key}) : super(key: key); | |
| @override | |
| State<MyHomePage> createState() => _MyHomePageState(); | |
| } | |
| class _MyHomePageState extends State<MyHomePage> { | |
| var value = 5; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| body: Center( | |
| child: Container( | |
| decoration: BoxDecoration( | |
| border: Border.all(color: Colors.black), | |
| ), | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| SemiCircleSlider( | |
| initialValue: value, | |
| divisions: 11, | |
| onChanged: (i) { | |
| setState(() { | |
| value = i; | |
| }); | |
| }, | |
| image: const NetworkImage( | |
| 'https://docs.flutter.dev/assets/images/dash/Dash.png', | |
| ), | |
| ), | |
| Text('Value: $value'), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class SemiCircleSlider extends StatefulWidget { | |
| const SemiCircleSlider({ | |
| Key? key, | |
| required this.initialValue, | |
| required this.divisions, | |
| required this.onChanged, | |
| required this.image, | |
| }) : super(key: key); | |
| final int initialValue; | |
| final int divisions; | |
| final ValueChanged<int> onChanged; | |
| final ImageProvider image; | |
| @override | |
| State<SemiCircleSlider> createState() => _SemiCircleSliderState(); | |
| } | |
| class _SemiCircleSliderState extends State<SemiCircleSlider> { | |
| late var value = widget.initialValue; | |
| @override | |
| Widget build(BuildContext context) { | |
| return SizedBox( | |
| width: 350, | |
| child: LayoutBuilder( | |
| builder: (context, constraints) { | |
| // Apply some padding to the outside so the nub doesn't go past the | |
| // edge of the painter. | |
| const inset = 32.0; | |
| final arcWidth = constraints.maxWidth - inset * 2; | |
| final height = (arcWidth / 2) + inset * 2; | |
| final arcHeight = (height - inset * 2) * 2; | |
| final arcRect = Rect.fromLTRB( | |
| inset, | |
| height - (inset + arcHeight), | |
| arcWidth + inset, | |
| height - inset, | |
| ); | |
| Widget child = TweenAnimationBuilder<double>( | |
| tween: Tween(begin: value.toDouble(), end: value.toDouble()), | |
| duration: const Duration(milliseconds: 50), | |
| curve: Curves.ease, | |
| builder: (context, value, child) { | |
| return CustomPaint( | |
| painter: SemiCircleSliderPainter( | |
| divisions: widget.divisions, | |
| arcRect: arcRect, | |
| // Map the value to the angle at which to display the nub | |
| nubAngle: (1 - (value / (widget.divisions - 1))) * pi, | |
| ), | |
| child: SizedBox( | |
| height: height, | |
| ), | |
| ); | |
| }, | |
| ); | |
| child = GestureDetector( | |
| // Use TweenAnimationBuilder to smoothly animate between divisions | |
| child: child, | |
| onPanUpdate: (e) { | |
| // Calculate the angle of the tap relative to the center of the | |
| // arc, then map that angle to a value | |
| final position = e.localPosition - arcRect.center; | |
| final angle = atan2(position.dy, position.dx); | |
| final newValue = | |
| ((1 - (angle / pi)) * (widget.divisions - 1)).round(); | |
| if (value != newValue && | |
| newValue >= 0 && | |
| newValue < widget.divisions) { | |
| widget.onChanged(newValue); | |
| setState(() { | |
| value = newValue; | |
| }); | |
| } | |
| }, | |
| ); | |
| // Subtract by one to prevent the background from bleeding through | |
| // and creating a seam | |
| const imageInset = inset + SemiCircleSliderPainter.lineWidth - 1; | |
| const imageTopInset = inset - SemiCircleSliderPainter.lineWidth / 2; | |
| child = Stack( | |
| fit: StackFit.passthrough, | |
| children: [ | |
| // Position the image so that it fits neatly inside the semicircle | |
| Positioned( | |
| left: imageInset, | |
| top: imageTopInset, | |
| right: imageInset, | |
| bottom: imageInset, | |
| child: ClipRRect( | |
| // A clever trick to round it into a semi-circle: round the | |
| // bottom left and bottom right a large amount | |
| borderRadius: const BorderRadius.vertical( | |
| bottom: Radius.circular(1000.0), | |
| ), | |
| child: Image( | |
| image: widget.image, | |
| fit: BoxFit.cover, | |
| ), | |
| ), | |
| ), | |
| child, | |
| ], | |
| ); | |
| return child; | |
| }, | |
| ), | |
| ); | |
| } | |
| } | |
| class SemiCircleSliderPainter extends CustomPainter { | |
| SemiCircleSliderPainter({ | |
| required this.divisions, | |
| required this.arcRect, | |
| required this.nubAngle, | |
| }); | |
| final int divisions; | |
| final Rect arcRect; | |
| final double nubAngle; | |
| static const nubRadius = 16.0; | |
| static const lineWidth = 16.0; | |
| static const stepThickness = 3.0; | |
| static const stepLength = 2.0; | |
| late final lineArcRect = arcRect.deflate(lineWidth / 2); | |
| late final xradius = lineArcRect.width / 2; | |
| late final yradius = lineArcRect.height / 2; | |
| late final center = arcRect.center; | |
| late final nubPath = Path() | |
| ..addPath( | |
| Path() | |
| ..moveTo(0, 0) | |
| ..arcTo( | |
| const Offset(nubRadius / 2, -nubRadius) & | |
| const Size.fromRadius(nubRadius), | |
| 5 * pi / 4, | |
| 3 * pi / 2, | |
| false, | |
| ), | |
| Offset( | |
| center.dx + cos(nubAngle) * xradius, | |
| center.dy + sin(nubAngle) * yradius, | |
| ), | |
| matrix4: Matrix4.rotationZ(nubAngle).storage, | |
| ); | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| // Paint large arc | |
| canvas.drawPath( | |
| Path() | |
| // Extend a line on the left and right so the markers aren't sitting | |
| // right on the border | |
| ..moveTo(lineArcRect.right, lineArcRect.center.dy - lineWidth / 2) | |
| ..arcTo( | |
| lineArcRect, | |
| 0, | |
| pi, | |
| false, | |
| ) | |
| ..lineTo(lineArcRect.left, lineArcRect.center.dy - lineWidth / 2), | |
| Paint() | |
| ..style = PaintingStyle.stroke | |
| ..color = Colors.black | |
| ..strokeWidth = lineWidth, | |
| ); | |
| // Paint division markers | |
| for (var i = 0; i < divisions; i++) { | |
| final angle = pi * i / (divisions - 1); | |
| final xnorm = cos(angle); | |
| final ynorm = sin(angle); | |
| canvas.drawLine( | |
| center + | |
| Offset( | |
| xnorm * (xradius - stepLength), | |
| ynorm * (yradius - stepLength), | |
| ), | |
| center + | |
| Offset( | |
| xnorm * (xradius + stepLength), | |
| ynorm * (yradius + stepLength), | |
| ), | |
| Paint() | |
| ..style = PaintingStyle.stroke | |
| ..color = Colors.white | |
| ..strokeWidth = stepThickness | |
| ..strokeCap = StrokeCap.round, | |
| ); | |
| } | |
| // Paint nub | |
| canvas.drawPath( | |
| nubPath, | |
| Paint()..color = Colors.pink.shade200, | |
| ); | |
| } | |
| @override | |
| bool? hitTest(Offset position) { | |
| // Only respond to hit tests when tapping the nub | |
| return nubPath.contains(position); | |
| } | |
| @override | |
| bool shouldRepaint(SemiCircleSliderPainter oldDelegate) => | |
| divisions != oldDelegate.divisions || | |
| arcRect != oldDelegate.arcRect || | |
| nubAngle != oldDelegate.nubAngle; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment