-
-
Save moh-affan/1f09c9d10c1f1d65f3f90e49beccccb1 to your computer and use it in GitHub Desktop.
Light Gradient Knob
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 'dart:ui'; | |
| 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: const Color(0xFFEAEBEA), | |
| body: Center( | |
| child: SizedBox.fromSize( | |
| size: Size.square(width), | |
| child: LightGradientKnob( | |
| range: const Range(Colors.blueAccent, Colors.redAccent), | |
| onChanged: (Color color) { | |
| // print(color); | |
| }, | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class LightGradientKnob extends LeafRenderObjectWidget { | |
| const LightGradientKnob({Key? key, this.color, this.knobColor, required this.range, this.onChanged}) | |
| : super(key: key); | |
| final Color? color; | |
| final Color? knobColor; | |
| final Range<Color> range; | |
| final ValueChanged<Color>? onChanged; | |
| @override | |
| RenderLightGradientKnob createRenderObject(BuildContext context) => | |
| RenderLightGradientKnob(color: color, knobColor: knobColor, range: range).._onChanged = onChanged; | |
| @override | |
| void updateRenderObject(BuildContext context, covariant RenderLightGradientKnob renderObject) => renderObject | |
| ..range = range | |
| ..knobColor = knobColor | |
| .._onChanged = onChanged; | |
| } | |
| class RenderLightGradientKnob extends RenderBox { | |
| RenderLightGradientKnob({ | |
| required Color? color, | |
| required Color? knobColor, | |
| required Range<Color> range, | |
| }) : _selectedAngle = _resolveAngleFromColor(range, color), | |
| _knobColor = knobColor ?? _defaultKnobColor, | |
| _range = range { | |
| drag = PanGestureRecognizer() | |
| ..onStart = _onDragStart | |
| ..onUpdate = _onDragUpdate | |
| ..onCancel = _onDragCancel | |
| ..onEnd = _onDragEnd; | |
| } | |
| static const _aspectRatio = 1.125; | |
| static final _lowerLimitAngle = 225.radians; | |
| static final _upperLimitAngle = 315.radians; | |
| static const _defaultKnobColor = Color(0xFFFFFFFF); | |
| static final double Function(double input) _angleToColor = | |
| interpolate(inputMin: _lowerLimitAngle, inputMax: _upperLimitAngle); | |
| static double _resolveAngleFromColor(Range<Color> range, Color? color) { | |
| if (color == null) { | |
| return _lowerLimitAngle; | |
| } | |
| final colorToAngle = interpolate( | |
| inputMin: range.start.value.toDouble(), | |
| inputMax: range.end.value.toDouble(), | |
| outputMin: _lowerLimitAngle, | |
| outputMax: _upperLimitAngle); | |
| return colorToAngle(color.value.toDouble()); | |
| } | |
| late final List<Color> colorsSpectrum; | |
| late final PanGestureRecognizer drag; | |
| Color get knobColor => _knobColor; | |
| Color _knobColor; | |
| set knobColor(Color? knobColor) { | |
| if (_knobColor == knobColor) { | |
| return; | |
| } | |
| _knobColor = knobColor ?? _defaultKnobColor; | |
| markNeedsPaint(); | |
| } | |
| Range<Color> get range => _range; | |
| Range<Color> _range; | |
| set range(Range<Color> range) { | |
| if (_range == range) { | |
| return; | |
| } | |
| _range = range; | |
| markNeedsPaint(); | |
| } | |
| double get selectedAngle => _selectedAngle; | |
| double _selectedAngle; | |
| set selectedAngle(double selectedAngle) { | |
| final angle = selectedAngle.clamp(_lowerLimitAngle, _upperLimitAngle); | |
| if (_selectedAngle == angle) { | |
| return; | |
| } | |
| _selectedAngle = angle; | |
| markNeedsPaint(); | |
| } | |
| Color get _color => Color.lerp(range.start, range.end, _angleToColor(selectedAngle))!; | |
| ValueChanged<Color>? _onChanged; | |
| Offset _currentDragOffset = Offset.zero; | |
| Size get _knobSize => Size.fromRadius(size.radius / _aspectRatio); | |
| late Rect _knobBounds; | |
| 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 _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(); | |
| @override | |
| void performLayout() { | |
| size = _computeSize(constraints); | |
| } | |
| @override | |
| Size computeDryLayout(BoxConstraints constraints) => _computeSize(constraints); | |
| Size _computeSize(BoxConstraints constraints) => | |
| Size(constraints.constrainWidth() / _aspectRatio, constraints.constrainWidth()); | |
| @override | |
| bool hitTestSelf(Offset position) => _knobBounds.contains(position); | |
| @override | |
| void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) { | |
| assert(debugHandleEvent(event, entry)); | |
| if (event is PointerDownEvent) { | |
| drag.addPointer(event); | |
| } | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| _knobBounds = offset.translate((size.width / 2) - _knobSize.radius, size.height - _knobSize.height) & _knobSize; | |
| final canvas = context.canvas; | |
| _drawKnob(canvas); | |
| _drawTrack(canvas); | |
| } | |
| void _drawIndicator( | |
| Canvas canvas, { | |
| required double trackWidth, | |
| required double trackRadius, | |
| }) { | |
| final halfLength = trackWidth * 1.5 / 2; | |
| final unitVector = Offset.fromDirection(selectedAngle); | |
| final strokeWidth = halfLength / 3; | |
| final blurRadius = strokeWidth / 3; | |
| final p1 = _knobBounds.center + (unitVector * (trackRadius + halfLength)); | |
| final p2 = _knobBounds.center + (unitVector * (trackRadius - halfLength)); | |
| canvas | |
| ..drawLine( | |
| p1, | |
| p2, | |
| Paint() | |
| ..imageFilter = ImageFilter.blur(sigmaX: blurRadius, sigmaY: blurRadius) | |
| ..strokeWidth = strokeWidth | |
| ..color = Colors.black26, | |
| ) | |
| ..drawLine( | |
| p1, | |
| p2, | |
| Paint() | |
| ..color = knobColor | |
| ..strokeWidth = strokeWidth | |
| ..strokeCap = StrokeCap.round, | |
| ); | |
| } | |
| void _drawTrack(Canvas canvas) { | |
| final radius = _knobBounds.size.radius; | |
| final center = _knobBounds.center; | |
| final trackWidth = radius / 5; | |
| final trackRadius = radius * 1.4; | |
| final outerRadius = trackRadius + (trackWidth / 2); | |
| final innerRadius = trackRadius - (trackWidth / 2); | |
| final startOuterOffset = center + Offset.fromDirection(_lowerLimitAngle, outerRadius); | |
| final endOuterOffset = center + Offset.fromDirection(_upperLimitAngle, outerRadius); | |
| final startInnerOffset = center + Offset.fromDirection(_lowerLimitAngle, innerRadius); | |
| final endInnerOffset = center + Offset.fromDirection(_upperLimitAngle, innerRadius); | |
| final path = Path() | |
| ..moveTo(startOuterOffset.dx, startOuterOffset.dy) | |
| ..arcToPoint(endOuterOffset, radius: Radius.circular(outerRadius)) | |
| ..arcToPoint(endInnerOffset, radius: Radius.circular(trackWidth / 2)) | |
| ..arcToPoint(startInnerOffset, radius: Radius.circular(innerRadius), clockwise: false) | |
| ..arcToPoint(startOuterOffset, radius: Radius.circular(trackWidth / 2)) | |
| ..close(); | |
| final blurRadius = radius / 6; | |
| canvas | |
| ..drawPath( | |
| path, | |
| Paint() | |
| ..imageFilter = ImageFilter.blur(sigmaX: blurRadius, sigmaY: blurRadius) | |
| ..shader = LinearGradient(colors: [range.start, range.end]).createShader(_knobBounds), | |
| ) | |
| ..drawPath( | |
| path, | |
| Paint()..shader = LinearGradient(colors: [range.start, range.end]).createShader(_knobBounds), | |
| ); | |
| _drawIndicator(canvas, trackWidth: trackWidth, trackRadius: trackRadius); | |
| } | |
| void _drawKnob(Canvas canvas) { | |
| final radius = _knobBounds.size.radius; | |
| final center = _knobBounds.center; | |
| final triangle = 4.radians; | |
| final magnitude = radius / 12; | |
| final top = center + Offset.fromDirection(selectedAngle, radius + magnitude); | |
| final left = center + Offset.fromDirection(selectedAngle - triangle, radius); | |
| final right = center + Offset.fromDirection(selectedAngle + triangle, radius); | |
| final arcRadius = Radius.circular(radius); | |
| final path = Path() | |
| ..moveTo(left.dx, left.dy) | |
| ..lineTo(top.dx, top.dy) | |
| ..lineTo(right.dx, right.dy) | |
| ..arcToPoint(center + Offset.fromDirection(selectedAngle + 180.radians, radius), radius: arcRadius) | |
| ..arcToPoint(left, radius: arcRadius) | |
| ..close(); | |
| final blurRadius = radius / 2; | |
| final innerRadius = radius / 3.5; | |
| canvas | |
| ..drawPath( | |
| path, | |
| Paint() | |
| ..imageFilter = ImageFilter.blur(sigmaX: blurRadius, sigmaY: blurRadius) | |
| ..color = Colors.black26, | |
| ) | |
| ..drawPath( | |
| path, | |
| Paint() | |
| ..shader = LinearGradient( | |
| colors: [HSVColor.fromColor(knobColor).withValue(.881).toColor(), knobColor], | |
| transform: GradientRotation(selectedAngle), | |
| ).createShader(_knobBounds), | |
| ) | |
| ..drawCircle( | |
| center, | |
| innerRadius, | |
| Paint() | |
| ..color = Colors.white | |
| ..shader = LinearGradient( | |
| colors: [knobColor, HSVColor.fromColor(knobColor).withValue(.856).toColor()], | |
| transform: GradientRotation(selectedAngle), | |
| ).createShader(_knobBounds), | |
| ) | |
| ..drawCircle( | |
| center, | |
| innerRadius, | |
| Paint() | |
| ..color = Colors.white60 | |
| ..style = PaintingStyle.stroke | |
| ..blendMode = BlendMode.luminosity | |
| ..strokeWidth = 2, | |
| ); | |
| } | |
| } | |
| extension SizeX on Size { | |
| double get radius => shortestSide / 2; | |
| } | |
| const fullAngle = math.pi * 2.0; | |
| extension NumX<T extends num> on T { | |
| 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; | |
| class Range<T> { | |
| const Range(this.start, this.end); | |
| final T start; | |
| final T end; | |
| } | |
| // https://stackoverflow.com/a/55088673/8236404 | |
| double Function(double input) interpolate({ | |
| double inputMin = 0, | |
| double inputMax = 1, | |
| double outputMin = 0, | |
| double outputMax = 1, | |
| }) { | |
| assert(inputMin != inputMax || outputMin != outputMax); | |
| final diff = (outputMax - outputMin) / (inputMax - inputMin); | |
| return (input) => ((input - inputMin) * diff) + outputMin; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment