Last active
November 15, 2021 15:23
-
-
Save jogboms/c00628ba4be59d01a565db86403f8f95 to your computer and use it in GitHub Desktop.
Fanciful color wheel
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' as math; | |
| import 'package:flutter/gestures.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| import 'package:flutter/services.dart'; | |
| void main() => runApp( | |
| MaterialApp( | |
| theme: ThemeData.dark(), | |
| debugShowCheckedModeBanner: false, | |
| home: const Playground(), | |
| ), | |
| ); | |
| class Playground extends StatefulWidget { | |
| const Playground({Key? key}) : super(key: key); | |
| @override | |
| _PlaygroundState createState() => _PlaygroundState(); | |
| } | |
| class _PlaygroundState extends State<Playground> with TickerProviderStateMixin { | |
| @override | |
| Widget build(BuildContext context) { | |
| final width = math.min(MediaQuery.of(context).size.width, 600.0); | |
| return Scaffold( | |
| backgroundColor: RenderColorWheel.backgroundColor, | |
| body: Center( | |
| child: Padding( | |
| padding: const EdgeInsets.all(22), | |
| child: SizedBox.square( | |
| dimension: width, | |
| child: ColorWheel( | |
| color: Colors.blue, | |
| startTime: const TimeOfDay(hour: 21, minute: 30), | |
| endTime: const TimeOfDay(hour: 1, minute: 0), | |
| child: Icon(Icons.flutter_dash, size: width / 3, color: Colors.black54), | |
| onChanged: (Color color) { | |
| // print(color); | |
| }, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class ColorWheel extends SingleChildRenderObjectWidget { | |
| const ColorWheel({ | |
| Key? key, | |
| this.color, | |
| required this.startTime, | |
| required this.endTime, | |
| this.onChanged, | |
| Widget? child, | |
| }) : super(key: key, child: child); | |
| final Color? color; | |
| final TimeOfDay startTime; | |
| final TimeOfDay endTime; | |
| final ValueChanged<Color>? onChanged; | |
| @override | |
| RenderColorWheel createRenderObject(BuildContext context) => | |
| RenderColorWheel(color: color, startTime: startTime, endTime: endTime).._onChanged = onChanged; | |
| @override | |
| void updateRenderObject(BuildContext context, covariant RenderColorWheel renderObject) => renderObject | |
| ..color = color | |
| ..startTime = startTime | |
| ..endTime = endTime | |
| .._onChanged = onChanged; | |
| } | |
| class RenderColorWheel extends RenderAligningShiftedBox { | |
| RenderColorWheel({ | |
| required Color? color, | |
| required TimeOfDay startTime, | |
| required TimeOfDay endTime, | |
| RenderBox? child, | |
| }) : _selectedAngle = color != null ? _resolveHueFromColor(color).radians : 0.0, | |
| _startTime = startTime, | |
| _endTime = endTime, | |
| colorsSpectrum = [for (var i = 0; i < maxColorHue; i++) _resolveColorFromHue(i.toDouble())], | |
| super(alignment: Alignment.center, textDirection: TextDirection.ltr, child: child) { | |
| tap = TapGestureRecognizer()..onTapDown = _onTapDown; | |
| drag = PanGestureRecognizer() | |
| ..onStart = _onDragStart | |
| ..onUpdate = _onDragUpdate | |
| ..onCancel = _onDragCancel | |
| ..onEnd = _onDragEnd; | |
| } | |
| static const backgroundColor = Color(0xFF222222); | |
| static const maxColorHue = 360; | |
| late final List<Color> colorsSpectrum; | |
| late final TapGestureRecognizer tap; | |
| late final DragGestureRecognizer drag; | |
| double get selectedAngle => _selectedAngle; | |
| double _selectedAngle; | |
| set selectedAngle(double selectedAngle) { | |
| if (_selectedAngle == selectedAngle) { | |
| return; | |
| } | |
| _selectedAngle = selectedAngle; | |
| markNeedsPaint(); | |
| } | |
| Color get _color => _resolveColorFromHue(selectedAngle.degrees); | |
| set color(Color? color) => selectedAngle = color != null ? _resolveHueFromColor(color).radians : 0.0; | |
| TimeOfDay get startTime => _startTime; | |
| TimeOfDay _startTime; | |
| set startTime(TimeOfDay startTime) { | |
| if (_startTime == startTime) { | |
| return; | |
| } | |
| _startTime = startTime; | |
| markNeedsPaint(); | |
| } | |
| TimeOfDay get endTime => _endTime; | |
| TimeOfDay _endTime; | |
| set endTime(TimeOfDay endTime) { | |
| if (_endTime == endTime) { | |
| return; | |
| } | |
| _endTime = endTime; | |
| markNeedsPaint(); | |
| } | |
| ValueChanged<Color>? _onChanged; | |
| late Path _trackPath; | |
| Offset _currentDragOffset = Offset.zero; | |
| void _onDragStart(DragStartDetails details) => _currentDragOffset = globalToLocal(details.globalPosition); | |
| void _onDragUpdate(DragUpdateDetails details) { | |
| final previousOffset = _currentDragOffset; | |
| _currentDragOffset += details.delta; | |
| final center = size.center(Offset.zero); | |
| _onChangeAngle(selectedAngle + toAngle(_currentDragOffset, center) - toAngle(previousOffset, center)); | |
| } | |
| void _onDragCancel() => _currentDragOffset = Offset.zero; | |
| void _onDragEnd(DragEndDetails details) => _onDragCancel(); | |
| void _onTapDown(TapDownDetails details) => _onChangeAngle(toAngle(details.localPosition, size.center(Offset.zero))); | |
| void _onChangeAngle(double angle) { | |
| selectedAngle = angle.normalizeAngle; | |
| WidgetsBinding.instance!.addPostFrameCallback((_) { | |
| HapticFeedback.selectionClick(); | |
| _onChanged?.call(_color); | |
| }); | |
| } | |
| @override | |
| bool get isRepaintBoundary => true; | |
| @override | |
| BoxConstraints get constraints => super.constraints.loosen(); | |
| Size computeSize(BoxConstraints constraints) => | |
| Size.square(math.min(constraints.constrainWidth(), constraints.constrainHeight())); | |
| @override | |
| void performLayout() { | |
| size = computeSize(constraints); | |
| if (child != null) { | |
| final maxSize = Size.fromRadius(size.radius * .4125); | |
| child?.layout(BoxConstraints.tight(maxSize), parentUsesSize: true); | |
| alignChild(); | |
| } | |
| } | |
| @override | |
| Size computeDryLayout(BoxConstraints constraints) => computeSize(constraints); | |
| @override | |
| bool hitTestSelf(Offset position) => _trackPath.contains(position); | |
| @override | |
| void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) { | |
| assert(debugHandleEvent(event, entry)); | |
| if (event is PointerDownEvent && _trackPath.contains(event.localPosition)) { | |
| tap.addPointer(event); | |
| drag.addPointer(event); | |
| } | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| final canvas = context.canvas; | |
| final bounds = offset & size; | |
| // Outer Ticks | |
| const outerTickWidth = 2.5; | |
| final outerTickLength = size.radius * .05; | |
| final outerTickPaint = Paint()..color = Colors.grey.shade800; | |
| for (var i = 0; i < 60; i++) { | |
| final unitVector = Offset.fromDirection(i.radians * 6); | |
| final center = bounds.center + (unitVector * bounds.radius); | |
| if (i % 5 == 0) { | |
| final offset = unitVector * outerTickLength / 2; | |
| canvas.drawLine(center - offset, center + offset, outerTickPaint..strokeWidth = outerTickWidth * 2.5); | |
| } else { | |
| final offset = unitVector * outerTickLength * .65 / 2; | |
| canvas.drawLine(center - offset, center + offset, outerTickPaint..strokeWidth = outerTickWidth); | |
| } | |
| } | |
| // Track | |
| final trackThickness = outerTickLength * 3; | |
| const trackRadius = Radius.circular(1); | |
| final trackBounds = bounds.deflate(outerTickLength * 1.5); | |
| _trackPath = Path() | |
| ..moveTo(trackBounds.topCenter.dx, trackBounds.topCenter.dy + trackThickness) | |
| ..arcToPoint(trackBounds.bottomCenter.translate(0, -trackThickness), radius: trackRadius) | |
| ..arcToPoint(trackBounds.topCenter.translate(0, trackThickness), radius: trackRadius) | |
| ..moveTo(trackBounds.topCenter.dx, trackBounds.topCenter.dy) | |
| ..arcToPoint(trackBounds.bottomCenter, radius: trackRadius, clockwise: false) | |
| ..arcToPoint(trackBounds.topCenter, radius: trackRadius, clockwise: false); | |
| canvas.drawPath( | |
| _trackPath, | |
| Paint()..shader = SweepGradient(colors: colorsSpectrum).createShader(_trackPath.getBounds()), | |
| ); | |
| // Inner Ticks | |
| final innerTickPadding = outerTickLength * .8; | |
| final innerTickLength = outerTickLength * .85; | |
| final innerTickColor = Color.lerp(outerTickPaint.color, Colors.white, .45)!; | |
| final innerTickRadius = trackBounds.radius - trackThickness - innerTickPadding; | |
| for (var i = 0; i < 24; i++) { | |
| final unitVector = Offset.fromDirection(i.radians * 15); | |
| final center = trackBounds.center + (unitVector * (innerTickRadius - (innerTickLength / 2))); | |
| final offset = unitVector * innerTickLength / 2; | |
| canvas.drawLine( | |
| center - offset, | |
| center + offset, | |
| Paint() | |
| ..color = innerTickColor | |
| ..strokeWidth = outerTickWidth, | |
| ); | |
| } | |
| // Inner Ticks Track | |
| canvas.drawCircle( | |
| trackBounds.center, | |
| innerTickRadius - outerTickWidth, | |
| Paint() | |
| ..color = outerTickPaint.color | |
| ..strokeWidth = outerTickWidth * 2 | |
| ..style = PaintingStyle.stroke, | |
| ); | |
| // Inner Progress | |
| canvas.drawArc( | |
| Rect.fromCircle(center: trackBounds.center, radius: innerTickRadius - (outerTickWidth * 2)), | |
| (((startTime.fraction - 12).abs() * 30) - 90).radians, | |
| ((24 - startTime.fraction + endTime.fraction) * 30).radians, | |
| false, | |
| Paint() | |
| ..color = innerTickColor | |
| ..strokeWidth = outerTickWidth * 4 | |
| ..style = PaintingStyle.stroke, | |
| ); | |
| // Knob | |
| final knobCenter = | |
| trackBounds.center + Offset.fromDirection(selectedAngle, (trackBounds.width - trackThickness) / 2); | |
| canvas | |
| ..drawCircle(knobCenter, trackThickness + (outerTickWidth * 2.5), Paint()..color = backgroundColor) | |
| ..drawCircle(knobCenter, trackThickness, Paint()..color = _color); | |
| // Indicator | |
| if (child != null) { | |
| final childOffset = (child!.parentData! as BoxParentData).offset; | |
| final childBounds = childOffset & child!.size; | |
| canvas | |
| ..save() | |
| ..clipPath(Path() | |
| ..moveTo(childOffset.dx, childOffset.dy) | |
| ..addOval(childBounds)) | |
| ..drawRect(childBounds, Paint()..color = _color); | |
| super.paint(context, offset); | |
| canvas.restore(); | |
| } | |
| } | |
| } | |
| Color _resolveColorFromHue(double value) => HSLColor.fromAHSL(1, value, .85, .6).toColor(); | |
| double _resolveHueFromColor(Color color) => HSLColor.fromColor(color).hue; | |
| extension on TimeOfDay { | |
| double get fraction => hour + (minute / TimeOfDay.minutesPerHour); | |
| } | |
| const fullAngle = math.pi * 2.0; | |
| extension NumX<T extends num> on T { | |
| double get degrees => (this * 180.0) / math.pi; | |
| double get radians => (this * math.pi) / 180.0; | |
| T normalize(T max) => (this % max + max) % max as T; | |
| double get normalizeAngle => normalize(fullAngle as T).toDouble(); | |
| } | |
| double toAngle(Offset position, Offset center) => (position - center).direction; | |
| extension on Size { | |
| double get radius => shortestSide / 2; | |
| } | |
| extension on Rect { | |
| double get radius => shortestSide / 2; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment