Skip to content

Instantly share code, notes, and snippets.

@jogboms
Created February 25, 2022 18:02
Show Gist options
  • Save jogboms/ddf1e4eefcd2f10fd4292f8be6e1fedc to your computer and use it in GitHub Desktop.
Save jogboms/ddf1e4eefcd2f10fd4292f8be6e1fedc to your computer and use it in GitHub Desktop.
Circular mood picker
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