Created
February 25, 2022 18:02
-
-
Save jogboms/ddf1e4eefcd2f10fd4292f8be6e1fedc to your computer and use it in GitHub Desktop.
Circular mood picker
This file contains 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/physics.dart'; | |
import 'package:flutter/rendering.dart'; | |
import 'package:flutter/services.dart'; | |
const primaryColor = Color(0xFF080B21); | |
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 SingleTickerProviderStateMixin { | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
backgroundColor: Color.lerp(primaryColor, Colors.black12, .15), | |
body: Center( | |
child: Container( | |
decoration: BoxDecoration( | |
color: primaryColor, | |
border: Border.all(color: Color.lerp(primaryColor, Colors.white, .15)!, width: 2), | |
borderRadius: BorderRadius.circular(10), | |
), | |
padding: const EdgeInsets.symmetric(vertical: 128), | |
child: SizedBox.fromSize( | |
size: const Size(500, 400), | |
child: MoodSpinner( | |
vsync: this, | |
moods: const <Mood>[ | |
Mood('Anger', '😡', [ | |
Pair('Frustrated', 'Frustrated - Lorem ipsum dolor set it gap'), | |
Pair('Resentful', 'Resentful - Lorem ipsum dolor set it gap'), | |
Pair('Envious', 'Envious - Lorem ipsum dolor set it gap'), | |
Pair('Vengeful', 'Vengeful - Lorem ipsum dolor set it gap'), | |
Pair('Enraged', 'Enraged - Lorem ipsum dolor set it gap'), | |
]), | |
Mood('Disgust', '🤮', [ | |
Pair('Bored', 'Bored - Lorem ipsum dolor set it gap'), | |
Pair('Dissatisfied', 'Dissatisfied - Lorem ipsum dolor set it gap'), | |
Pair('Distrustful', 'Distrustful - Lorem ipsum dolor set it gap'), | |
Pair('Embarrassed', 'Embarrassed - Lorem ipsum dolor set it gap'), | |
Pair('Regretful', 'Regretful - Lorem ipsum dolor set it gap'), | |
Pair('Ashamed', 'Ashamed - Lorem ipsum dolor set it gap'), | |
Pair('Contemptuous', 'Contemptuous - Lorem ipsum dolor set it gap'), | |
]), | |
Mood('Sadness', '😞', [ | |
Pair('Pensive', 'Pensive - Lorem ipsum dolor set it gap'), | |
Pair('Disappointed', 'Disappointed - Lorem ipsum dolor set it gap'), | |
Pair('Helpless', 'Helpless - Lorem ipsum dolor set it gap'), | |
Pair('Rejected', 'Rejected - Lorem ipsum dolor set it gap'), | |
Pair('Lonely', 'Lonely - Lorem ipsum dolor set it gap'), | |
Pair('Depressed', 'Depressed - Lorem ipsum dolor set it gap'), | |
Pair('In Grief', 'In Grief - Lorem ipsum dolor set it gap'), | |
]), | |
Mood('Surprise', '😮', [ | |
Pair('Distracted', 'Distracted - Lorem ipsum dolor set it gap'), | |
Pair('Surprised', 'Surprised - Lorem ipsum dolor set it gap'), | |
Pair('Touched', 'Touched - Lorem ipsum dolor set it gap'), | |
Pair('Amazed', 'Amazed - Lorem ipsum dolor set it gap'), | |
]), | |
Mood('Fear', '😧', [ | |
Pair('Anxious', 'Anxious - Lorem ipsum dolor set it gap'), | |
Pair('Unclear', 'Unclear - Lorem ipsum dolor set it gap'), | |
Pair('Insecure', 'Insecure - Lorem ipsum dolor set it gap'), | |
Pair('Indecisive', 'Indecisive - Lorem ipsum dolor set it gap'), | |
Pair('Jealous', 'Jealous - Lorem ipsum dolor set it gap'), | |
Pair('Overwhelmed', 'Overwhelmed - Lorem ipsum dolor set it gap'), | |
Pair('Panicked', 'Panicked - Lorem ipsum dolor set it gap'), | |
]), | |
Mood('Trust', '🙂', [ | |
Pair('Accepting', 'Accepting - Lorem ipsum dolor set it gap'), | |
Pair('Secure', 'Secure - Lorem ipsum dolor set it gap'), | |
Pair('Confident', 'Confident - Lorem ipsum dolor set it gap'), | |
Pair('Forgiving', 'Forgiving - Lorem ipsum dolor set it gap'), | |
Pair('Supported', 'Supported - Lorem ipsum dolor set it gap'), | |
Pair('Admiring', 'Admiring - Lorem ipsum dolor set it gap'), | |
]), | |
Mood('Joy', '😃', [ | |
Pair('Serene', 'Serene - Lorem ipsum dolor set it gap'), | |
Pair('Grateful', 'Grateful - Lorem ipsum dolor set it gap'), | |
Pair('Relieved', 'Relieved - Lorem ipsum dolor set it gap'), | |
Pair('Content', 'Content - Lorem ipsum dolor set it gap'), | |
Pair('Fulfilled', 'Fulfilled - Lorem ipsum dolor set it gap'), | |
Pair('In Love', 'In Love - Lorem ipsum dolor set it gap'), | |
]), | |
Mood('Interest', '😕', [ | |
Pair('Interested', 'Interested - Lorem ipsum dolor set it gap'), | |
Pair('Hopeful', 'Hopeful - Lorem ipsum dolor set it gap'), | |
Pair('Anticipating', 'Anticipating - Lorem ipsum dolor set it gap'), | |
Pair('Compassionate', 'Compassionate - Lorem ipsum dolor set it gap'), | |
Pair('Excited', 'Excited - Lorem ipsum dolor set it gap'), | |
Pair('Vigilant', 'Vigilant - Lorem ipsum dolor set it gap'), | |
]), | |
], | |
), | |
), | |
), | |
), | |
); | |
} | |
} | |
class MoodSpinner extends LeafRenderObjectWidget { | |
const MoodSpinner({Key? key, required this.moods, required this.vsync}) : super(key: key); | |
final List<Mood> moods; | |
final TickerProvider vsync; | |
@override | |
RenderObject createRenderObject(BuildContext context) => RenderMoodSpinner(moods, vsync: vsync); | |
@override | |
void updateRenderObject(BuildContext context, covariant RenderMoodSpinner renderObject) => renderObject | |
..moods = moods | |
..vsync = vsync; | |
} | |
class RenderMoodSpinner extends RenderBox with RenderBoxDebugBounds { | |
RenderMoodSpinner(List<Mood> moods, {required TickerProvider vsync}) | |
: _moods = moods, | |
_vsync = vsync, | |
colorsSpectrum = [for (var i = 0; i < 360; i++) _resolveColorFromHue(fullAngle - i.toDouble())], | |
colorsByMood = | |
moods.fold<Triple<int, int, Map<int, List<Color>>>>(const Triple(0, 0, {}), (previousValue, Mood element) { | |
final index = previousValue.a; | |
final total = previousValue.b; | |
return Triple(index + 1, total + element.items.length, { | |
...previousValue.c, | |
index: [ | |
for (int i = 0; i < element.items.length; i++) | |
_resolveColorFromHue(fullAngle - (((total + i + 1) / moods.totalSubs) * fullAngle)) | |
] | |
}); | |
}).c { | |
drag = PanGestureRecognizer() | |
..onStart = _onDragStart | |
..onUpdate = _onDragUpdate | |
..onCancel = _onDragCancel | |
..onEnd = _onDragEnd; | |
} | |
static const contentBackgroundColor = primaryColor; | |
static const activeTextColor = Colors.white; | |
static final labelTextStyle = TextStyle(fontSize: 11, color: Colors.grey.shade500, fontWeight: FontWeight.normal); | |
static const minFlingVelocity = 10; | |
late final DragGestureRecognizer drag; | |
late final List<Color> colorsSpectrum; | |
late final Map<int, List<Color>> colorsByMood; | |
late final AnimationController controller; | |
late Animation<Offset> animation; | |
late Rect trackBounds; | |
List<Mood> get moods => _moods; | |
List<Mood> _moods; | |
set moods(List<Mood> moods) { | |
if (_moods == moods) { | |
return; | |
} | |
_moods = moods; | |
markNeedsPaint(); | |
} | |
double get selectedAngle => _selectedAngle; | |
double _selectedAngle = 0; | |
set selectedAngle(double angle) { | |
if (_selectedAngle == angle) { | |
return; | |
} | |
_selectedAngle = angle; | |
markNeedsPaint(); | |
} | |
TickerProvider get vsync => _vsync; | |
TickerProvider _vsync; | |
set vsync(TickerProvider vsync) { | |
if (vsync == _vsync) { | |
return; | |
} | |
_vsync = vsync; | |
controller.resync(_vsync); | |
} | |
double get trackDivisions => fullAngle / moods.totalSubs; | |
int get selectedTrackItem => (fullAngle - selectedAngle.degrees) ~/ trackDivisions; | |
int get selectedMoodIndex { | |
int sum = 0; | |
for (int i = 0; i < moods.length; i++) { | |
sum += moods[i].items.length; | |
if (sum > selectedTrackItem) { | |
return i; | |
} | |
} | |
return moods.length - 1; | |
} | |
int get totalItemsUntilSelectedMoodIndex => | |
moods.sublist(0, selectedMoodIndex).fold<int>(0, (total, element) => total + element.items.length); | |
List<Pair<String, String>> get selectedMoodItems => moods[selectedMoodIndex].items; | |
int get selectedMoodItemIndex => | |
math.min(selectedMoodItems.length - 1, selectedTrackItem - totalItemsUntilSelectedMoodIndex); | |
Offset _dragOffset = Offset.zero; | |
void _onUpdateSelectedAngle(Offset to) { | |
final previousSelectedTrackItem = selectedTrackItem; | |
final diffInAngle = toAngle(to, trackBounds.center) - toAngle(_dragOffset, trackBounds.center); | |
selectedAngle = (selectedAngle + diffInAngle).normalizeAngle; | |
if (previousSelectedTrackItem != selectedTrackItem) { | |
WidgetsBinding.instance!.addPostFrameCallback((_) { | |
HapticFeedback.selectionClick(); | |
}); | |
} | |
} | |
void _onDragStart(DragStartDetails details) => _dragOffset = details.localPosition; | |
void _onDragUpdate(DragUpdateDetails details) { | |
_onUpdateSelectedAngle(details.localPosition); | |
_dragOffset = details.localPosition; | |
} | |
void _onDragCancel() => _dragOffset = Offset.zero; | |
void _onDragEnd(DragEndDetails details) => _onFling(details.velocity.pixelsPerSecond); | |
void _onFling(Offset pixelsPerSecond) { | |
animation = controller.drive(Tween(begin: Offset.zero, end: _dragOffset)); | |
final unitsPerSecond = Offset(pixelsPerSecond.dx / trackBounds.width, pixelsPerSecond.dy / trackBounds.height); | |
final primaryVelocity = unitsPerSecond.distance; | |
if (primaryVelocity > minFlingVelocity) { | |
controller | |
.animateWith(SpringSimulation( | |
SpringDescription(mass: trackBounds.radius * .1, stiffness: .5, damping: 1.0), 0, 1, -primaryVelocity)) | |
.whenCompleteOrCancel(() => _onDragCancel()); | |
} | |
} | |
@override | |
void attach(covariant PipelineOwner owner) { | |
super.attach(owner); | |
controller = AnimationController.unbounded(vsync: vsync, duration: const Duration(milliseconds: 250)) | |
..addListener(() => _onUpdateSelectedAngle(animation.value)); | |
} | |
@override | |
void detach() { | |
controller.dispose(); | |
super.detach(); | |
} | |
@override | |
bool get sizedByParent => true; | |
@override | |
bool get isRepaintBoundary => true; | |
@override | |
bool hitTestSelf(Offset position) => trackBounds.contains(position); | |
@override | |
void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) { | |
if (event is PointerDownEvent) { | |
drag.addPointer(event); | |
} | |
} | |
@override | |
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest; | |
@override | |
void paint(PaintingContext context, Offset offset) { | |
final canvas = context.canvas; | |
final bounds = offset & size; | |
debugBounds.add(bounds); | |
canvas.clipRect(bounds); | |
final trackRadius = size.height * .5; | |
final trackThickness = size.width * .04; | |
const trackTranslateFactor = .3; | |
final trackXTranslate = trackRadius * trackTranslateFactor; | |
final trackCenter = size.centerLeft(offset).translate(trackXTranslate, 0); | |
const trackArcRadius = Radius.circular(1); | |
trackBounds = Rect.fromCircle(center: trackCenter, radius: trackRadius); | |
final trackPath = Path() | |
..moveTo(trackBounds.topCenter.dx, trackBounds.topCenter.dy + trackThickness) | |
..arcToPoint(trackBounds.bottomCenter.translate(0, -trackThickness), radius: trackArcRadius) | |
..arcToPoint(trackBounds.topCenter.translate(0, trackThickness), radius: trackArcRadius) | |
..moveTo(trackBounds.topCenter.dx, trackBounds.topCenter.dy) | |
..arcToPoint(trackBounds.bottomCenter, radius: trackArcRadius, clockwise: false) | |
..arcToPoint(trackBounds.topCenter, radius: trackArcRadius, clockwise: false); | |
canvas.drawPath( | |
trackPath, | |
Paint() | |
..shader = SweepGradient( | |
endAngle: fullAngleInRadians, | |
transform: GradientRotation(selectedAngle), | |
colors: colorsSpectrum, | |
).createShader(trackBounds), | |
); | |
for (int i = 0; i < (fullAngle / moods.length); i++) { | |
final angle = (i * moods.length).radians + selectedAngle; | |
final p2 = trackCenter.translateAlong(angle, trackRadius); | |
canvas.drawLine( | |
p2.translateAlong(angle, -trackThickness), | |
p2, | |
Paint() | |
..color = Colors.black87 | |
..blendMode = BlendMode.overlay, | |
); | |
} | |
final trackVisibleBounds = Rect.fromLTWH(offset.dx, offset.dy, trackRadius + trackXTranslate, trackRadius * 2); | |
debugBounds.add(trackVisibleBounds); | |
final moodTrackRadius = trackRadius - trackThickness; | |
final moodTrackWidth = moodTrackRadius * .5; | |
final moodTrackBounds = Rect.fromCircle(center: trackCenter, radius: moodTrackRadius); | |
canvas.drawCircle( | |
trackCenter, | |
moodTrackRadius, | |
Paint() | |
..shader = const RadialGradient( | |
colors: [Color(0x10FFFFFF), Color(0x2F000000)], | |
stops: [.5, 1], | |
).createShader(moodTrackBounds), | |
); | |
double moodItemPrevAngle = 0; | |
for (int i = 0; i < moods.length; i++) { | |
final item = moods[i]; | |
final angle = (item.items.length * trackDivisions).radians; | |
final effectivePrevAngle = moodItemPrevAngle + selectedAngle; | |
canvas.drawLine( | |
trackCenter, | |
trackCenter.translateAlong(effectivePrevAngle + angle, trackRadius - trackThickness), | |
Paint() | |
..color = contentBackgroundColor | |
..strokeWidth = trackThickness * .1, | |
); | |
final isSelected = selectedMoodIndex == i; | |
final titleCenter = | |
trackCenter.translateAlong(effectivePrevAngle + angle / 2, moodTrackRadius - moodTrackWidth / 2); | |
final titleBounds = canvas.drawText( | |
item.title, | |
center: titleCenter, | |
style: labelTextStyle.copyWith( | |
fontSize: labelTextStyle.fontSize! * 1.05, | |
color: isSelected ? activeTextColor : Colors.grey.shade600, | |
fontWeight: isSelected ? FontWeight.bold : null, | |
), | |
); | |
final iconTextLayoutResult = | |
canvas.layoutText(item.icon, style: TextStyle(fontSize: labelTextStyle.fontSize! * 1.65, height: .75)); | |
final iconBounds = iconTextLayoutResult | |
.paint(titleCenter.translate(0, -(titleBounds.radius + iconTextLayoutResult.size.radius))); | |
debugBounds.addAll({titleBounds, iconBounds}); | |
moodItemPrevAngle += angle; | |
} | |
debugBounds.add(moodTrackBounds); | |
final knobRadius = moodTrackRadius - moodTrackWidth; | |
final knobPadding = knobRadius * .5; | |
const knobTranslateFactor = .25; | |
final knobBounds = Rect.fromCircle(center: trackCenter, radius: knobRadius); | |
final knobArrowWidth = trackThickness * 1.275; | |
canvas | |
..drawCircle(trackCenter, knobRadius, Paint()..color = contentBackgroundColor) | |
..drawCircle( | |
trackCenter, | |
knobRadius * 1.0275, | |
Paint() | |
..color = contentBackgroundColor | |
..style = PaintingStyle.stroke | |
..strokeWidth = knobRadius * .015) | |
..drawPath( | |
Path() | |
..moveTo(knobBounds.centerRight.dx, knobBounds.centerRight.dy - knobArrowWidth / 2) | |
..lineTo(knobBounds.centerRight.dx + knobArrowWidth / 2, knobBounds.centerRight.dy) | |
..lineTo(knobBounds.centerRight.dx, knobBounds.centerRight.dy + knobArrowWidth / 2), | |
Paint()..color = contentBackgroundColor, | |
); | |
final descriptionTextBounds = canvas.drawText( | |
moods[selectedMoodIndex].items[selectedMoodItemIndex].b, | |
center: trackCenter.translate(knobPadding * knobTranslateFactor, 0), | |
maxWidth: (knobRadius * 2) - (knobPadding * (1 + knobTranslateFactor)), | |
style: labelTextStyle.copyWith(fontSize: labelTextStyle.fontSize! * 1.015), | |
); | |
debugBounds.addAll({descriptionTextBounds, knobBounds}); | |
final itemsTextLayoutResults = <TextLayoutResult>[]; | |
for (int i = 0; i < selectedMoodItems.length; i++) { | |
final isSelected = selectedMoodItemIndex == i; | |
itemsTextLayoutResults.add(canvas.layoutText( | |
selectedMoodItems[i].a, | |
style: labelTextStyle.copyWith( | |
fontSize: labelTextStyle.fontSize! * 1.25, | |
color: isSelected ? activeTextColor : null, | |
fontWeight: isSelected ? FontWeight.bold : null, | |
), | |
)); | |
} | |
final remainingBounds = trackBounds.topRight & Size(size.width - trackVisibleBounds.width, size.height); | |
final remainingBoundsLeftMargin = remainingBounds.width * .125; | |
final textIndicatorRadius = remainingBoundsLeftMargin * .125; | |
final textIndicatorPadding = textIndicatorRadius * 2; | |
final selectedColorsForMood = colorsByMood[selectedMoodIndex]!; | |
final itemsSeparatorHeight = remainingBoundsLeftMargin * .35; | |
final totalIndicatorHeight = | |
itemsTextLayoutResults.fold<double>(0.0, (acc, element) => acc + element.size.height + itemsSeparatorHeight) - | |
itemsSeparatorHeight; | |
Offset itemsStartOffset = remainingBounds.centerLeft.translate( | |
remainingBoundsLeftMargin + ((textIndicatorPadding + textIndicatorRadius) * 2), -totalIndicatorHeight / 2); | |
final itemsBounds = itemsStartOffset & | |
Size(itemsTextLayoutResults.fold(0.0, (maxWidth, element) => math.max(maxWidth, element.size.width)), | |
totalIndicatorHeight); | |
for (int i = 0; i < itemsTextLayoutResults.length; i++) { | |
final result = itemsTextLayoutResults[i]; | |
final textBounds = result.paint(result.size.center(itemsStartOffset)); | |
canvas.drawCircle( | |
textBounds.centerLeft.translate(-(textIndicatorRadius + textIndicatorPadding), 0), | |
textIndicatorRadius, | |
Paint()..color = selectedColorsForMood[i], | |
); | |
itemsStartOffset += Offset(0, result.size.height + itemsSeparatorHeight); | |
debugBounds.add(textBounds); | |
} | |
debugBounds.addAll({remainingBounds, itemsBounds}); | |
} | |
} | |
Color _resolveColorFromHue(double value) => HSLColor.fromAHSL(1, value, .9, .6).toColor(); | |
const fullAngle = 360.0; | |
const fullAngleInRadians = math.pi * 2.0; | |
double toAngle(Offset position, Offset center) => (position - center).direction; | |
extension on List<Mood> { | |
int get totalSubs => fold(0, (count, element) => count + element.items.length); | |
} | |
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(fullAngleInRadians as T).toDouble(); | |
} | |
extension on Size { | |
double get radius => shortestSide / 2; | |
} | |
extension on Rect { | |
double get radius => shortestSide / 2; | |
} | |
extension on Offset { | |
Offset translateAlong(double angleInRadians, double magnitude) => | |
this + Offset.fromDirection(angleInRadians, magnitude); | |
} | |
extension on Canvas { | |
static const TextStyle _defaultTextStyle = | |
TextStyle(fontSize: 14, color: Color(0xFF333333), fontWeight: FontWeight.normal); | |
TextLayoutResult layoutText(String text, {TextStyle style = _defaultTextStyle, double maxWidth = double.infinity}) => | |
TextLayoutResult( | |
canvas: this, | |
painter: TextPainter(textAlign: TextAlign.center, textDirection: TextDirection.ltr) | |
..text = TextSpan(text: text, style: style) | |
..layout(maxWidth: maxWidth)); | |
Rect drawText(String text, | |
{required Offset center, TextStyle style = _defaultTextStyle, double maxWidth = double.infinity}) => | |
layoutText(text, style: style, maxWidth: maxWidth).paint(center); | |
} | |
class Mood { | |
const Mood(this.title, this.icon, this.items); | |
final String title; | |
final String icon; | |
final List<Pair<String, String>> items; | |
} | |
class Pair<A, B> { | |
const Pair(this.a, this.b); | |
final A a; | |
final B b; | |
} | |
class Triple<A, B, C> { | |
const Triple(this.a, this.b, this.c); | |
final A a; | |
final B b; | |
final C c; | |
} | |
class TextLayoutResult { | |
const TextLayoutResult({required Canvas canvas, required TextPainter painter}) | |
: _canvas = canvas, | |
_painter = painter; | |
final Canvas _canvas; | |
final TextPainter _painter; | |
Size get size => _painter.size; | |
Rect paint(Offset center) { | |
final Rect bounds = Rect.fromCenter(center: center, width: size.width, height: size.height); | |
_painter.paint(_canvas, bounds.topLeft); | |
return bounds; | |
} | |
} | |
mixin RenderBoxDebugBounds on RenderBox { | |
Set<Rect> debugBounds = {}; | |
@override | |
void debugPaint(PaintingContext context, Offset offset) { | |
assert(() { | |
super.debugPaint(context, offset); | |
if (debugPaintSizeEnabled) { | |
for (final bounds in debugBounds) { | |
context.canvas.drawRect( | |
bounds, | |
Paint() | |
..style = PaintingStyle.stroke | |
..color = const Color(0xFF00FFFF)); | |
} | |
} | |
return true; | |
}()); | |
debugBounds.clear(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment