Skip to content

Instantly share code, notes, and snippets.

@tarek360
Created December 13, 2025 22:55
Show Gist options
  • Select an option

  • Save tarek360/ac59978769ebcf990d6a90fb367ecae7 to your computer and use it in GitHub Desktop.

Select an option

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
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