Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active July 29, 2024 18:20
Show Gist options
  • Save pskink/7955c8f7a27a25e70d530648964cf2e5 to your computer and use it in GitHub Desktop.
Save pskink/7955c8f7a27a25e70d530648964cf2e5 to your computer and use it in GitHub Desktop.
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
typedef PathBuilder = ui.Path Function(ui.Rect bounds, double phase);
typedef OnPaintFrame = void Function(Canvas canvas, ui.Rect bounds, double phase);
/// Simple [OutlinedBorder] implementation.
/// You can use [PathBuilderBorder] directly in the build tree:
/// ```dart
/// child: Card(
/// shape: PathBuilderBorder(
/// pathBuilder: (r, phase) => roundPolygon(
/// points: [r.topLeft, r.topRight, r.centerRight, r.bottomCenter, r.centerLeft],
/// radii: [8, 8, 8, 32, 8],
/// ),
/// ),
/// ...
/// ```
/// Optional [phase] parameter can be used to 'morph' [PathBuilderBorder] if
/// it is used by widgets that animate their shape (like [AnimatedContainer] or [Material]).
/// In such case it is passed to [pathBuilder] as an interpolation between the old
/// and new value:
/// ```dart
/// int idx = 0;
///
/// @override
/// Widget build(BuildContext context) {
/// return Material(
/// clipBehavior: Clip.antiAlias,
/// shape: PathBuilderBorder(
/// pathBuilder: _phasedPathBuilder,
/// phase: idx.toDouble(),
/// ),
/// color: idx == 0? Colors.teal : Colors.orange,
/// child: InkWell(
/// onTap: () => setState(() => idx = idx ^ 1),
/// child: const Center(child: Text('press me', textScaleFactor: 2)),
/// ),
/// );
/// }
///
/// Path _phasedPathBuilder(Rect bounds, double phase) {
/// print(phase);
/// final radius = phase * rect.shortestSide / 2;
/// return Path()
/// ..addRRect(RRect.fromRectAndRadius(rect, Radius.circular(radius)));
/// }
/// ```
///
/// You can also extend [PathBuilderBorder] if you want to add some
/// customizations, like [dimensions], [paint] etc.
class PathBuilderBorder extends OutlinedBorder {
const PathBuilderBorder({
required this.pathBuilder,
BorderSide side = BorderSide.none,
this.phase = 0,
this.painter,
this.foregroundPainter,
EdgeInsetsGeometry? dimensions,
}) : _dimensions = dimensions, super(side: side);
final PathBuilder pathBuilder;
final double phase;
final OnPaintFrame? painter;
final OnPaintFrame? foregroundPainter;
final EdgeInsetsGeometry? _dimensions;
@override
ShapeBorder? lerpFrom(ShapeBorder? a, double t) {
if (a is PathBuilderBorder && phase != a.phase) {
return PathBuilderBorder(
pathBuilder: pathBuilder,
side: side == a.side? side : BorderSide.lerp(a.side, side, t),
phase: ui.lerpDouble(a.phase, phase, t)!,
painter: painter,
foregroundPainter: foregroundPainter,
dimensions: EdgeInsetsGeometry.lerp(a.dimensions, dimensions, t),
);
}
return super.lerpFrom(a, t);
}
@override
EdgeInsetsGeometry get dimensions => _dimensions ?? EdgeInsets.zero;
@override
ui.Path getInnerPath(ui.Rect rect, {ui.TextDirection? textDirection}) {
return getOuterPath(rect, textDirection: textDirection);
}
@override
ui.Path getOuterPath(ui.Rect rect, {ui.TextDirection? textDirection}) {
return pathBuilder(rect, phase);
}
@override
void paint(ui.Canvas canvas, ui.Rect rect, {ui.TextDirection? textDirection}) {
painter?.call(canvas, rect, phase);
if (side != BorderSide.none) {
canvas.drawPath(pathBuilder(rect, phase), side.toPaint());
}
foregroundPainter?.call(canvas, rect, phase);
}
@override
ShapeBorder scale(double t) => this;
@override
OutlinedBorder copyWith({BorderSide? side}) {
return PathBuilderBorder(
pathBuilder: pathBuilder,
side: side ?? this.side,
phase: phase,
painter: painter,
foregroundPainter: foregroundPainter,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PathBuilderBorder &&
other.phase == phase;
}
@override
int get hashCode => phase.hashCode;
}
// ============================================================================
// ============================================================================
//
// example
//
// ============================================================================
// ============================================================================
main() {
runApp(MaterialApp(
home: Theme(
data: ThemeData(
cardTheme: CardTheme(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
color: Colors.grey.shade500,
elevation: 4,
),
),
child: Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
_MorphingButton0(),
_MorphingButton1(),
_ChatBubble(),
],
),
),
),
),
));
}
class _MorphingButton0 extends StatefulWidget {
@override
State<_MorphingButton0> createState() => _MorphingButton0State();
}
class _MorphingButton0State extends State<_MorphingButton0> {
int idx = 0;
final alignments = [
(const Alignment(-1, -0.25), const Alignment(-0.25, -1)),
(Alignment.topRight, Alignment.topRight),
(const Alignment(1, 0), const Alignment(0.25, 1)),
(const Alignment(-0.25, 1), Alignment.bottomLeft),
];
final colors = [Colors.indigo, Colors.orange];
@override
Widget build(BuildContext context) {
return SizedBox(
height: 175,
child: Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Center(
child: AnimatedContainer(
duration: Durations.extralong4 * 1.25,
clipBehavior: Clip.antiAlias,
curve: Curves.bounceOut,
decoration: ShapeDecoration(
shape: PathBuilderBorder(
pathBuilder: _phasedPathBuilder,
painter: _painter,
phase: idx.toDouble(),
),
shadows: const [BoxShadow(blurRadius: 4, offset: Offset(3, 3))],
color: colors[idx],
),
child: AspectRatio(
aspectRatio: 1.25,
child: Material(
type: MaterialType.transparency,
child: InkWell(
highlightColor: Colors.transparent,
splashColor: Colors.black26,
onTap: () => setState(() => idx = idx ^ 1),
child: Center(child: Text('animate', style: Theme.of(context).textTheme.titleLarge)),
),
),
),
),
),
),
),
);
}
void _painter(ui.Canvas canvas, Rect bounds, double phase) {
final s = Size.square(bounds.shortestSide);
final r = Alignment.center.inscribe(s, bounds);
final color = Color.lerp(Colors.cyan, Colors.white60, phase)!;
const delta = Offset(-32, 32);
final center = Offset.lerp(r.topRight + delta, r.bottomLeft - delta, phase)!;
final radius = ui.lerpDouble(r.shortestSide, r.shortestSide / 2, 0.5 - cos(2 * pi * phase) / 2)!;
final paint = Paint()
..blendMode = ui.BlendMode.colorDodge
..shader = ui.Gradient.radial(
center, radius, [Colors.transparent, color, Colors.transparent], [0, 0.5, 1],
);
final matrix = _rotatedMatrix(ui.lerpDouble(0.2, 0.7, phase)! * pi, r.center);
final r2 = Rect.fromCenter(
center: r.center,
width: r.shortestSide * 0.5,
height: r.shortestSide * 2,
);
final points = [
Offset.lerp(r2.topCenter, r2.topLeft, phase)!,
Offset.lerp(r2.bottomCenter, r2.bottomLeft, phase)!,
Offset.lerp(r2.topCenter, r2.topRight, phase)!,
Offset.lerp(r2.bottomCenter, r2.bottomRight, phase)!,
];
final paint2 = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 16
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6)
..color = Colors.white.withOpacity(0.2);
canvas
..save()
..clipPath(_phasedPathBuilder(bounds, phase))
..transform(matrix.storage)
..drawPoints(ui.PointMode.lines, points, paint2)
..drawPaint(paint)
..restore();
}
Path _phasedPathBuilder(Rect bounds, double phase) {
final points = alignments
.map((r) => Alignment.lerp(r.$1, r.$2, phase)!.withinRect(bounds))
.toList();
return Path()
..addPolygon(points, true);
}
Matrix4 _rotatedMatrix(double rotation, Offset anchor) => Matrix4.identity()
..translate(anchor.dx, anchor.dy)
..rotateZ(rotation)
..translate(-anchor.dx, -anchor.dy);
}
class _MorphingButton1 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButtonTheme(
data: ElevatedButtonThemeData(
style: ButtonStyle(
animationDuration: Durations.extralong4,
// overlayColor: MaterialStateProperty.all(Colors.white30),
shape: WidgetStateProperty.resolveWith(_shape),
backgroundColor: WidgetStateProperty.all(Colors.orange),
padding: WidgetStateProperty.all(const EdgeInsets.all(24)),
side: WidgetStateProperty.resolveWith(_side),
),
),
child: Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(8),
color: Colors.black12,
child: const Text('this is a "normal" [ElevatedButton], long-press it to see how it changes'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => debugPrint('pressed'),
child: Text('long press', style: Theme.of(context).textTheme.titleLarge),
),
],
),
),
),
);
}
OutlinedBorder? _shape(states) => PathBuilderBorder(
pathBuilder: _phasedPathBuilder,
phase: states.contains(WidgetState.pressed) ? 1 : 0,
);
BorderSide? _side(states) => states.contains(WidgetState.pressed) ?
const BorderSide(color: Colors.black, width: 3) :
const BorderSide(color: Colors.black54, width: 2);
Path _phasedPathBuilder(Rect bounds, double phase) {
final points = [
bounds.topLeft.translate(phase * 24, 0),
bounds.topRight,
bounds.bottomRight.translate(phase * -24, 0),
bounds.bottomLeft,
];
return Path()
..addPolygon(points, true);
}
}
class _ChatBubble extends StatefulWidget {
@override
State<_ChatBubble> createState() => _ChatBubbleState();
}
class _ChatBubbleState extends State<_ChatBubble> {
double phase = 1;
@override
Widget build(BuildContext context) {
const padding = EdgeInsets.only(left: 20);
return Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedContainer(
duration: Durations.extralong4,
constraints: const BoxConstraints(maxWidth: 175),
clipBehavior: Clip.antiAlias,
curve: Curves.ease,
decoration: ShapeDecoration(
shape: PathBuilderBorder(
pathBuilder: (bounds, phase) {
const lerp = ui.lerpDouble;
// M 6,4 C 5,4 4,4 0,3 C 3,2 4,2 6,0
final arrow = Path()
..moveTo(6, 4)
..cubicTo(5, 4, 4, 4, 0, 3)
..cubicTo(3, 2, 4, 2, 6, 0)
..close();
final arrowBounds = arrow.getBounds();
const maxRadius = 8.0;
final radius = lerp(maxRadius, 0, phase)!;
final scale = padding.left / arrowBounds.right;
final matrix = composeMatrix(
translate: Offset(padding.left, bounds.height - 4),
scale: lerp(scale, 0, phase)!,
anchor: arrowBounds.bottomRight,
);
final rrect = RRect.fromRectAndCorners(padding.deflateRect(bounds),
topLeft: Radius.circular(radius),
topRight: Radius.circular(radius),
bottomRight: Radius.circular(radius * 2),
);
return Path()
..addRRect(rrect)
..addPath(arrow, bounds.topLeft, matrix4: matrix.storage);
},
phase: phase,
dimensions: padding,
),
shadows: phase == 0? const [BoxShadow(blurRadius: 3, offset: Offset(1.5, 1.5))] : null,
gradient: LinearGradient(
colors: [
Color.lerp(Colors.grey.shade300, Colors.yellow.shade200, phase)!,
Color.lerp(Colors.deepPurple.shade200, Colors.amber.shade300, phase)!,
],
stops: const [0.7, 0.9],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: AnimatedCrossFade(
duration: Durations.long4,
firstChild: const Text('this rectangular shape will change when you press the button on the right ➜'),
secondChild: const Text('now it morphed into a nice chat balloon shape'),
crossFadeState: phase == 1? CrossFadeState.showFirst : CrossFadeState.showSecond,
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
style: const ButtonStyle(
shape: WidgetStatePropertyAll(RoundedRectangleBorder()),
padding: WidgetStatePropertyAll(EdgeInsets.all(4)),
),
onPressed: () => setState(() => phase = 1 - phase),
child: const Text('press to animate the shape'),
),
),
],
),
),
);
}
}
Matrix4 composeMatrix({
double scale = 1,
double rotation = 0,
Offset translate = Offset.zero,
Offset anchor = Offset.zero,
}) {
final double c = cos(rotation) * scale;
final double s = sin(rotation) * scale;
final double dx = translate.dx - c * anchor.dx + s * anchor.dy;
final double dy = translate.dy - s * anchor.dx - c * anchor.dy;
return Matrix4(c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
}
@AliEsmaeil
Copy link

@pskink , Amazing work such animations and clippings remind me of JavaFX, I'm following this beauty.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment