Created
December 13, 2025 22:55
-
-
Save tarek360/ac59978769ebcf990d6a90fb367ecae7 to your computer and use it in GitHub Desktop.
Flutter CustomPainter that morphs between feedback states (HAPPY → NEUTRAL → SAD) using a single shape, not widget swapping
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 'package:animated_text_kit/animated_text_kit.dart'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter_animate/flutter_animate.dart'; | |
| /// Wraps the morphing face with a segmented control | |
| class MorphFaceWithSelector extends StatefulWidget { | |
| final double faceSize; | |
| const MorphFaceWithSelector({super.key, this.faceSize = 180}); | |
| @override | |
| State<MorphFaceWithSelector> createState() => _MorphFaceWithSelectorState(); | |
| } | |
| class _MorphFaceWithSelectorState extends State<MorphFaceWithSelector> { | |
| FeedbackState current = FeedbackState.happy; | |
| @override | |
| Widget build(BuildContext context) { | |
| return AnimatedContainer( | |
| duration: const Duration(milliseconds: 450), | |
| color: getBackgroundColor(current), | |
| alignment: Alignment.center, | |
| child: Padding( | |
| padding: const EdgeInsets.all(32.0), | |
| child: Column( | |
| crossAxisAlignment: CrossAxisAlignment.center, | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| DefaultTextStyle( | |
| style: const TextStyle(color: Colors.black87, fontSize: 28, fontWeight: FontWeight.bold), | |
| child: AnimatedTextKit( | |
| isRepeatingAnimation: false, | |
| animatedTexts: [ | |
| TypewriterAnimatedText( | |
| 'What\'s your mood today?', | |
| speed: const Duration(milliseconds: 60), | |
| cursor: '|', | |
| ), | |
| ], | |
| ), | |
| ), | |
| const SizedBox(height: 56), | |
| Column( | |
| children: [ | |
| MorphFaceIndicator(state: current, size: widget.faceSize, color: Colors.black54), | |
| SegmentedButton<FeedbackState>( | |
| showSelectedIcon: false, | |
| style: SegmentedButton.styleFrom( | |
| side: BorderSide.none, | |
| shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(111)), | |
| backgroundColor: Colors.black12, | |
| selectedBackgroundColor: Colors.black38, | |
| textStyle: const TextStyle(fontWeight: FontWeight.bold), | |
| ), | |
| segments: const <ButtonSegment<FeedbackState>>[ | |
| ButtonSegment<FeedbackState>( | |
| value: FeedbackState.happy, | |
| label: Text('HAPPY', style: TextStyle(color: Colors.black87, fontSize: 16)), | |
| ), | |
| ButtonSegment<FeedbackState>( | |
| value: FeedbackState.neutral, | |
| label: Text('NEUTRAL', style: TextStyle(color: Colors.black87, fontSize: 16)), | |
| ), | |
| ButtonSegment<FeedbackState>( | |
| value: FeedbackState.sad, | |
| label: Text('SAD', style: TextStyle(color: Colors.black87, fontSize: 16)), | |
| ), | |
| ], | |
| selected: <FeedbackState>{current}, | |
| onSelectionChanged: (Set<FeedbackState> newSelection) { | |
| setState(() { | |
| current = newSelection.first; | |
| }); | |
| }, | |
| ), | |
| ], | |
| ).animate(delay: const Duration(milliseconds: 2000)).fade(duration: 800.ms), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| Color getBackgroundColor(FeedbackState state) { | |
| switch (state) { | |
| case FeedbackState.happy: | |
| return Colors.green.shade400; | |
| case FeedbackState.neutral: | |
| return Colors.amber.shade300; | |
| case FeedbackState.sad: | |
| return Colors.deepOrangeAccent.shade200; | |
| } | |
| } | |
| } | |
| /// ---------------------------------------------- | |
| /// 1. Feedback States | |
| /// ---------------------------------------------- | |
| enum FeedbackState { happy, neutral, sad } | |
| /// ---------------------------------------------- | |
| /// 2. Shape Model (Parametric) | |
| /// ---------------------------------------------- | |
| class FaceShapeModel { | |
| final double eyeSize; // for circular eyes | |
| final double eyeWidth; // for rectangular eyes | |
| final double eyeHeight; | |
| final double mouthCurvature; // + = smile, - = frown | |
| const FaceShapeModel({ | |
| required this.eyeSize, | |
| required this.eyeWidth, | |
| required this.eyeHeight, | |
| required this.mouthCurvature, | |
| }); | |
| } | |
| /// ---------------------------------------------- | |
| /// 3. State → Shape Definition | |
| /// ---------------------------------------------- | |
| FaceShapeModel shapeForState(FeedbackState state) { | |
| switch (state) { | |
| case FeedbackState.happy: | |
| return const FaceShapeModel(eyeSize: 80, eyeWidth: 80, eyeHeight: 80, mouthCurvature: 15); | |
| case FeedbackState.neutral: | |
| return const FaceShapeModel(eyeSize: 0, eyeWidth: 60, eyeHeight: 40, mouthCurvature: 0); | |
| case FeedbackState.sad: | |
| return const FaceShapeModel(eyeSize: 32, eyeWidth: 32, eyeHeight: 32, mouthCurvature: -15); | |
| } | |
| } | |
| /// ---------------------------------------------- | |
| /// 4. Interpolator | |
| /// ---------------------------------------------- | |
| FaceShapeModel lerpShape(FaceShapeModel a, FaceShapeModel b, double t) { | |
| double lerp(double x, double y, double t) => x + (y - x) * t; | |
| return FaceShapeModel( | |
| eyeSize: lerp(a.eyeSize, b.eyeSize, t), | |
| eyeWidth: lerp(a.eyeWidth, b.eyeWidth, t), | |
| eyeHeight: lerp(a.eyeHeight, b.eyeHeight, t), | |
| mouthCurvature: lerp(a.mouthCurvature, b.mouthCurvature, t), | |
| ); | |
| } | |
| /// ---------------------------------------------- | |
| /// 5. CustomPainter for the Face | |
| /// ---------------------------------------------- | |
| class MoodPainter extends CustomPainter { | |
| final FaceShapeModel shape; | |
| final Color color; | |
| MoodPainter(this.shape, this.color); | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| final paint = Paint() | |
| ..color = color | |
| ..style = PaintingStyle.fill; | |
| // ---- EYES ---- | |
| final leftEyeCenter = Offset(size.width * 0.25, size.height * 0.35); | |
| final rightEyeCenter = Offset(size.width * 0.75, size.height * 0.35); | |
| final eyesAreCircles = (shape.eyeWidth - shape.eyeHeight).abs() < 1; | |
| final borderRadius = Radius.circular(eyesAreCircles ? shape.eyeSize / 2 : shape.eyeHeight / 2); | |
| final leftRect = Rect.fromCenter(center: leftEyeCenter, width: shape.eyeWidth, height: shape.eyeHeight); | |
| canvas.drawRRect(RRect.fromRectAndRadius(leftRect, borderRadius), paint); | |
| final rightRect = Rect.fromCenter(center: rightEyeCenter, width: shape.eyeWidth, height: shape.eyeHeight); | |
| canvas.drawRRect(RRect.fromRectAndRadius(rightRect, borderRadius), paint); | |
| // ---- MOUTH ---- | |
| final Path mouth = Path(); | |
| mouth.moveTo(size.width * 0.42, size.height * 0.55); | |
| mouth.quadraticBezierTo( | |
| size.width * 0.50, | |
| size.height * 0.55 + shape.mouthCurvature, | |
| size.width * 0.58, | |
| size.height * 0.55, | |
| ); | |
| final mouthPaint = Paint() | |
| ..color = color | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 12 | |
| ..strokeCap = StrokeCap.round; | |
| canvas.drawPath(mouth, mouthPaint); | |
| } | |
| @override | |
| bool shouldRepaint(covariant MoodPainter oldDelegate) { | |
| return oldDelegate.shape != shape; | |
| } | |
| } | |
| /// ---------------------------------------------- | |
| /// 6. Animated Widget | |
| /// ---------------------------------------------- | |
| class MorphFaceIndicator extends StatefulWidget { | |
| final FeedbackState state; | |
| final Color color; | |
| final double size; | |
| const MorphFaceIndicator({super.key, required this.state, this.color = Colors.black, this.size = 150}); | |
| @override | |
| State<MorphFaceIndicator> createState() => _MorphFaceIndicatorState(); | |
| } | |
| class _MorphFaceIndicatorState extends State<MorphFaceIndicator> with SingleTickerProviderStateMixin { | |
| late AnimationController controller; | |
| late Animation<double> t; | |
| late FaceShapeModel oldShape; | |
| late FaceShapeModel newShape; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 450)); | |
| t = CurvedAnimation(parent: controller, curve: Curves.easeOutBack); | |
| newShape = shapeForState(widget.state); | |
| oldShape = newShape; | |
| } | |
| @override | |
| void didUpdateWidget(covariant MorphFaceIndicator oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| oldShape = newShape; | |
| newShape = shapeForState(widget.state); | |
| controller.forward(from: 0); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return SizedBox( | |
| width: widget.size, | |
| height: widget.size, | |
| child: AnimatedBuilder( | |
| animation: t, | |
| builder: (context, _) { | |
| final current = lerpShape(oldShape, newShape, t.value); | |
| return CustomPaint(painter: MoodPainter(current, widget.color)); | |
| }, | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment