Last active
July 29, 2024 18:20
-
-
Save pskink/7955c8f7a27a25e70d530648964cf2e5 to your computer and use it in GitHub Desktop.
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 '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
@pskink , Amazing work such animations and clippings remind me of
JavaFX
, I'm following this beauty.