Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active July 2, 2024 19:50
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);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment