-
-
Save yeasin50/269dc11df86e2fe598bc9d6e2ea3cef0 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:collection'; | |
import 'dart:ui' as ui; | |
import 'dart:math'; | |
import 'package:flutter/material.dart'; | |
import 'package:collection/collection.dart'; | |
// TODO | |
// * add short-circuit for a case when point's radius is zero? | |
// * check if [radius] / [radii] are not too big? | |
// * use [Path.arcToPoint] so [radius] (and [radii]) can be specified as [Radius] | |
/// Return a [Path] that describes a polygon with "rounded" corners | |
/// defined by [points] and [radii] / [radius]. | |
/// | |
/// The corners are defined by [points] list. | |
/// You have to specify either [radii] or [radius] parameters but not both. | |
/// When using [radii] its length must match the length of [points] list. | |
Path roundPolygon({ | |
required List<Offset> points, | |
List<double>? radii, | |
double? radius, | |
}) { | |
assert( | |
(radii == null) ^ (radius == null), | |
'either radii or radius has to be specified (but not both)' | |
); | |
assert( | |
radii == null || radii.length == points.length, | |
'if radii list is specified, its size has to match points list size' | |
); | |
radii ??= List.filled(points.length, radius!); | |
final p = points.cycled(); | |
final directions = p.mapIndexed((int index, ui.Offset point) { | |
final delta = p[index + 1] - point; | |
assert(delta != Offset.zero, 'any two adjacent points have to be different'); | |
return delta.direction; | |
}).toList().cycled(); | |
final angles = p.mapIndexed((int index, ui.Offset point) { | |
final nextDelta = p[index + 1] - point; | |
final prevDelta = p[index - 1] - point; | |
final angle = prevDelta.direction - nextDelta.direction; | |
assert(angle != 0); | |
return angle; | |
}).toList(); | |
final distances = angles.mapIndexed((i, a) { | |
return radii![i] / sin(a / 2); | |
}); | |
final path = Path(); | |
int i = 0; | |
for (final distance in distances) { | |
final direction = directions[i] + angles[i] / 2; | |
// if normalizedAngle > pi, it means 'concave' corner | |
final normalizedAngle = angles[i] % (2 * pi); | |
var center = p[i] + Offset.fromDirection(direction, normalizedAngle < pi? distance : -distance); | |
final rect = Rect.fromCircle(center: center, radius: radii[i]); | |
final startAngle = directions[i - 1] + (normalizedAngle < pi? 1.5 * pi : -1.5 * pi); | |
final sweepAngle = (normalizedAngle < pi? pi : -pi) - angles[i]; | |
path.arcTo(rect, startAngle, sweepAngle, i == 0); | |
i++; | |
} | |
return path..close(); | |
} | |
/// Simple [OutlinedBorder] implementation, [pathBuilder] in most cases returns | |
/// a [Path] returned by [roundPolygon] function (but you can of course return | |
/// any [Path] you want or combine / transform two or more [Path]s as well). | |
class PolygonBorder extends OutlinedBorder { | |
const PolygonBorder({ | |
required this.pathBuilder, | |
BorderSide side = BorderSide.none, | |
}) : super(side: side); | |
final ui.Path Function(ui.Rect) pathBuilder; | |
@override | |
EdgeInsetsGeometry get 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); | |
} | |
@override | |
void paint(ui.Canvas canvas, ui.Rect rect, {ui.TextDirection? textDirection}) { | |
if (side != BorderSide.none) { | |
canvas.drawPath(pathBuilder(rect), side.toPaint()); | |
} | |
} | |
@override | |
ShapeBorder scale(double t) => this; | |
@override | |
OutlinedBorder copyWith({BorderSide? side}) { | |
return PolygonBorder( | |
pathBuilder: pathBuilder, | |
side: side ?? this.side, | |
); | |
} | |
} | |
// double toDegrees(a) => a * 180 / pi; | |
extension _CyclicListExtension<T> on List<T> { | |
List<T> cycled() => _CyclicList<T>(this); | |
} | |
class _CyclicList<T> with ListMixin<T> { | |
_CyclicList(this.list); | |
List<T> list; | |
@override | |
int get length => list.length; | |
@override | |
set length(int newLength) => throw UnsupportedError('setting length not supported'); | |
@override | |
operator [](int index) => list[index % list.length]; | |
@override | |
void operator []=(int index, value) => throw UnsupportedError('indexed assignmnet not supported'); | |
} | |
// ============================================================================ | |
// ============================================================================ | |
// | |
// example | |
// | |
// ============================================================================ | |
// ============================================================================ | |
main() { | |
runApp(const MaterialApp( | |
home: Scaffold( | |
body: _RoundPolygonTest(), | |
), | |
)); | |
} | |
const kShadows = [BoxShadow(blurRadius: 4, offset: Offset(3, 3))]; | |
class _RoundPolygonTest extends StatefulWidget { | |
const _RoundPolygonTest(); | |
@override | |
State<_RoundPolygonTest> createState() => _RoundPolygonTestState(); | |
} | |
class _RoundPolygonTestState extends State<_RoundPolygonTest> { | |
String key = 'static #0'; | |
bool drawBoundary = false; | |
final shapes = { | |
'static #0': _ShapeEntry( | |
alignments: [ | |
Alignment.topLeft, | |
Alignment.topRight, | |
Alignment.bottomLeft, | |
], | |
color: Colors.orange, | |
), | |
'static #1': _ShapeEntry( | |
alignments: [ | |
Alignment.topLeft, | |
Alignment.topRight, | |
Alignment.bottomRight, | |
Alignment.bottomCenter, | |
], | |
color: Colors.teal, | |
), | |
'static #2': _ShapeEntry( | |
alignments: [ | |
Alignment.topLeft, | |
Alignment.topRight, | |
Alignment.bottomRight, | |
Alignment.bottomCenter, | |
Alignment.center, | |
const Alignment(-1, 0.25), | |
], | |
color: Colors.indigo, | |
), | |
'static #3': _ShapeEntry( | |
alignments: [ | |
Alignment.topLeft, | |
Alignment.topRight, | |
Alignment.bottomRight, | |
Alignment.bottomLeft, | |
const Alignment(-1, 0.25), | |
const Alignment(-0.25, 0.25), | |
const Alignment(-0.25, -0.25), | |
const Alignment(-1, -0.45), | |
], | |
color: Colors.deepPurple, | |
), | |
}; | |
@override | |
Widget build(BuildContext context) { | |
final names = [ | |
...shapes.keys, | |
'PolygonBorder', | |
'dynamic #0', | |
'dynamic #1', | |
'dynamic #2', | |
'dynamic #3', | |
]; | |
return Padding( | |
padding: const EdgeInsets.all(8.0), | |
child: Column( | |
children: [ | |
CheckboxListTile( | |
title: const Text('show shape outline'), | |
value: drawBoundary, | |
onChanged: (v) => setState(() => drawBoundary = v!), | |
), | |
ListTile( | |
title: Row( | |
children: [ | |
const Text('sample: '), | |
DropdownButton<String>( | |
items: names.map((name) => DropdownMenuItem( | |
value: name, | |
child: Text(name), | |
)).toList(), | |
value: key, | |
onChanged: (k) => setState(() => key = k!), | |
), | |
], | |
), | |
), | |
Expanded( | |
child: _getChild(key), | |
), | |
], | |
), | |
); | |
} | |
Widget _getChild(String key) { | |
switch (key) { | |
case 'PolygonBorder': return _PolygonBorder(); | |
case 'dynamic #0': return _MorphingButton0(drawBoundary: drawBoundary); | |
case 'dynamic #1': return _MorphingButton1(drawBoundary: drawBoundary); | |
case 'dynamic #2': return _MorphingButton2(drawBoundary: drawBoundary); | |
case 'dynamic #3': return _MorphingClipPath(); | |
} | |
final shape = shapes[key]!; | |
return Container( | |
clipBehavior: Clip.antiAlias, | |
decoration: ShapeDecoration( | |
shape: _RoundPolygonShape( | |
alignments: shape.alignments, | |
drawBoundary: drawBoundary, | |
), | |
shadows: kShadows, | |
color: shape.color, | |
), | |
child: Material( | |
type: MaterialType.transparency, | |
child: InkWell( | |
highlightColor: Colors.transparent, | |
splashColor: Colors.deepOrange, | |
onTap: () {}, | |
), | |
), | |
); | |
} | |
} | |
class _ShapeEntry { | |
_ShapeEntry({ | |
required this.alignments, | |
required this.color, | |
}); | |
final List<Alignment> alignments; | |
final Color color; | |
} | |
class _PolygonBorder extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return ColoredBox( | |
color: Colors.black12, | |
child: Column( | |
children: [ | |
Wrap( | |
children: [ | |
ActionChip( | |
label: const Text('shaped ActionChip'), | |
backgroundColor: Colors.teal, | |
onPressed: () {}, | |
labelPadding: const EdgeInsets.only(bottom: 12.0), | |
shape: PolygonBorder( | |
pathBuilder: (r) => roundPolygon( | |
points: [r.topLeft, r.topRight, r.centerRight, r.bottomCenter, r.centerLeft], | |
radii: [8, 8, 8, 32, 8], | |
), | |
), | |
), | |
const SizedBox( | |
width: 4, | |
), | |
ActionChip( | |
label: const Text('and another'), | |
backgroundColor: Colors.deepOrange, | |
onPressed: () {}, | |
labelPadding: const EdgeInsets.only(top: 12.0), | |
shape: PolygonBorder( | |
pathBuilder: (r) => roundPolygon( | |
points: [r.centerLeft, r.topCenter, r.centerRight, r.bottomRight, r.bottomLeft], | |
radii: [8, 32, 8, 8, 8], | |
), | |
), | |
), | |
], | |
), | |
Expanded( | |
child: Container( | |
clipBehavior: Clip.antiAlias, | |
decoration: ShapeDecoration( | |
gradient: const LinearGradient(colors: [Colors.teal, Colors.indigo]), | |
shadows: kShadows, | |
shape: PolygonBorder( | |
pathBuilder: _myShapeBuilder, | |
) | |
), | |
child: Material( | |
type: MaterialType.transparency, | |
child: InkWell( | |
highlightColor: Colors.transparent, | |
splashColor: Colors.deepOrange, | |
onTap: () {}, | |
child: const Align( | |
alignment: Alignment(0.5, 0), | |
child: Text('Container', textScaleFactor: 2), | |
), | |
), | |
), | |
), | |
), | |
const SizedBox( | |
height: 16, | |
), | |
Expanded( | |
child: Card( | |
clipBehavior: Clip.antiAlias, | |
elevation: 4, | |
shape: PolygonBorder( | |
pathBuilder: (r) => roundPolygon( | |
points: [r.topLeft, r.topRight, r.bottomRight, r.centerLeft], | |
radii: [16, 16, 100, 16], | |
), | |
), | |
child: InkWell( | |
highlightColor: Colors.transparent, | |
splashColor: Colors.blueGrey, | |
onTap: () {}, | |
child: const Align( | |
alignment: Alignment(0.5, 0), | |
child: Text('Card', textScaleFactor: 2), | |
), | |
), | |
), | |
), | |
], | |
), | |
); | |
} | |
Path _myShapeBuilder(Rect r) => roundPolygon( | |
points: [r.centerLeft, r.topRight, r.bottomRight, r.bottomLeft], | |
radii: [16, 100, 16, 16], | |
); | |
} | |
class _MorphingButton0 extends StatefulWidget { | |
const _MorphingButton0({ | |
required this.drawBoundary, | |
}); | |
final bool drawBoundary; | |
@override | |
State<_MorphingButton0> createState() => _MorphingButton0State(); | |
} | |
class _MorphingButton0State extends State<_MorphingButton0> { | |
int idx = 0; | |
final alignments = [ | |
[ | |
Alignment.topLeft, | |
Alignment.topRight, | |
const Alignment(1, 0), | |
const Alignment(-0.25, 1), | |
], | |
[ | |
const Alignment(-0.25, -1), | |
Alignment.topRight, | |
const Alignment(0.25, 1), | |
Alignment.bottomLeft, | |
], | |
]; | |
final colors = [Colors.indigo, Colors.teal]; | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedContainer( | |
duration: const Duration(milliseconds: 1250), | |
clipBehavior: Clip.antiAlias, | |
curve: Curves.bounceOut, | |
decoration: ShapeDecoration( | |
shape: _RoundPolygonShape( | |
alignments: alignments[idx], | |
drawBoundary: widget.drawBoundary, | |
), | |
shadows: kShadows, | |
color: colors[idx], | |
), | |
child: Material( | |
type: MaterialType.transparency, | |
child: InkWell( | |
highlightColor: Colors.transparent, | |
splashColor: Colors.deepOrange, | |
onTap: () => setState(() => idx = idx ^ 1), | |
child: const Center(child: Text('press me', textScaleFactor: 2)), | |
), | |
), | |
); | |
} | |
} | |
class _RoundPolygonShape extends ShapeBorder { | |
const _RoundPolygonShape({ | |
required this.alignments, | |
required this.drawBoundary, | |
}); | |
final List<Alignment> alignments; | |
final bool drawBoundary; | |
@override | |
ShapeBorder? lerpFrom(ShapeBorder? a, double t) { | |
if (a is _RoundPolygonShape) { | |
final morphedAlignments = [ | |
for (int i = 0; i < alignments.length; i++) | |
Alignment.lerp(a.alignments[i], alignments[i], t)! | |
]; | |
return _RoundPolygonShape( | |
alignments: morphedAlignments, | |
drawBoundary: drawBoundary, | |
); | |
} | |
return super.lerpFrom(a, t); | |
} | |
@override | |
EdgeInsetsGeometry get dimensions => EdgeInsets.zero; | |
@override | |
ui.Path getInnerPath(ui.Rect rect, {ui.TextDirection? textDirection}) => getOuterPath(rect); | |
@override | |
ui.Path getOuterPath(ui.Rect rect, {ui.TextDirection? textDirection}) { | |
final s = Size.square(rect.shortestSide); | |
final r = Alignment.center.inscribe(s, rect); | |
final points = alignments.map((a) => a.withinRect(r)).toList(); | |
return roundPolygon( | |
points: points, | |
radius: 30, | |
); | |
} | |
@override | |
void paint(ui.Canvas canvas, ui.Rect rect, {ui.TextDirection? textDirection}) { | |
if (drawBoundary) { | |
final s = Size.square(rect.shortestSide); | |
final r = Alignment.center.inscribe(s, rect); | |
final points = alignments.map((a) => a.withinRect(r)).toList(); | |
canvas.drawPath(Path()..addPolygon(points, true), Paint() | |
..color = Colors.indigo | |
..style = PaintingStyle.stroke | |
..strokeWidth = 0 | |
); | |
} | |
} | |
@override | |
ShapeBorder scale(double t) => this; | |
} | |
class _MorphingButton1 extends StatefulWidget { | |
const _MorphingButton1({ | |
required this.drawBoundary, | |
}); | |
final bool drawBoundary; | |
@override | |
State<_MorphingButton1> createState() => _MorphingButton1State(); | |
} | |
const _kTopPadding = 30.0; | |
const _kSectionSize = 80.0; | |
class _MorphingButton1State extends State<_MorphingButton1> { | |
double index = 0; | |
@override | |
Widget build(BuildContext context) { | |
return Stack( | |
children: [ | |
AnimatedContainer( | |
duration: const Duration(milliseconds: 400), | |
curve: Curves.easeIn, | |
decoration: ShapeDecoration( | |
shape: _NotchedShape( | |
index: index, | |
drawBoundary: widget.drawBoundary, | |
), | |
shadows: kShadows, | |
gradient: const LinearGradient( | |
colors: [Colors.grey, Colors.blueGrey], | |
), | |
), | |
), | |
Positioned( | |
top: _kTopPadding, | |
child: Column( | |
children: [ | |
for (int i = 0; i < 4; i++) | |
SizedBox.fromSize( | |
size: const Size.square(_kSectionSize), | |
child: Align( | |
alignment: Alignment.center, | |
child: AnimatedContainer( | |
width: 64, | |
height: 64, | |
clipBehavior: Clip.antiAlias, | |
duration: const Duration(milliseconds: 400), | |
curve: Curves.easeIn, | |
decoration: BoxDecoration( | |
shape: BoxShape.circle, | |
color: i == index? Colors.deepOrange : Colors.blueGrey, | |
boxShadow : i == index? null : kShadows, | |
), | |
child: Material( | |
type: MaterialType.transparency, | |
child: InkWell( | |
highlightColor: Colors.transparent, | |
splashColor: Colors.deepPurple, | |
onTap: () => setState(() => index = i.toDouble()), | |
child: Padding( | |
padding: const EdgeInsets.all(12.0), | |
child: FittedBox(child: Text('$i')), | |
), | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
), | |
], | |
); | |
} | |
} | |
class _NotchedShape extends ShapeBorder { | |
const _NotchedShape({ | |
required this.index, | |
required this.drawBoundary, | |
}); | |
final double index; | |
final bool drawBoundary; | |
@override | |
ShapeBorder? lerpFrom(ShapeBorder? a, double t) { | |
if (a is _NotchedShape) { | |
return _NotchedShape( | |
index: ui.lerpDouble(a.index, index, t)!, | |
drawBoundary: drawBoundary, | |
); | |
} | |
return super.lerpFrom(a, t); | |
} | |
@override | |
EdgeInsetsGeometry get dimensions => EdgeInsets.zero; | |
@override | |
ui.Path getInnerPath(ui.Rect rect, {ui.TextDirection? textDirection}) => getOuterPath(rect); | |
@override | |
ui.Path getOuterPath(ui.Rect rect, {ui.TextDirection? textDirection}) { | |
return roundPolygon( | |
points: _getPoints(rect), | |
radii: [ | |
0, 16, 16, 0, | |
_kSectionSize / 2, _kSectionSize / 2, _kSectionSize / 2, 8, | |
], | |
); | |
} | |
@override | |
void paint(ui.Canvas canvas, ui.Rect rect, {ui.TextDirection? textDirection}) { | |
if (drawBoundary) { | |
List<ui.Offset> points = _getPoints(rect); | |
canvas.drawPath(Path()..addPolygon(points, true), Paint() | |
..color = Colors.indigo | |
..style = PaintingStyle.stroke | |
..strokeWidth = 0 | |
); | |
} | |
} | |
List<ui.Offset> _getPoints(ui.Rect rect) { | |
final notchRect = Offset(rect.left, rect.top + _kTopPadding + index * _kSectionSize) & const Size.square(_kSectionSize); | |
return [ | |
rect.topLeft, | |
rect.topRight, | |
rect.bottomRight, | |
rect.bottomLeft, | |
notchRect.bottomLeft, | |
notchRect.bottomRight, | |
notchRect.topRight, | |
notchRect.topLeft, | |
]; | |
} | |
@override | |
ShapeBorder scale(double t) => this; | |
} | |
class _MorphingButton2 extends StatelessWidget { | |
const _MorphingButton2({ | |
required this.drawBoundary, | |
}); | |
final bool drawBoundary; | |
@override | |
Widget build(BuildContext context) { | |
return ElevatedButtonTheme( | |
data: ElevatedButtonThemeData( | |
style: ButtonStyle( | |
animationDuration: const Duration(milliseconds: 1000), | |
shape: MaterialStateProperty.resolveWith(_shape), | |
backgroundColor: MaterialStateProperty.resolveWith(_backgroundColor), | |
padding: MaterialStateProperty.all(const EdgeInsets.all(24)), | |
side: MaterialStateProperty.resolveWith(_side), | |
), | |
), | |
child: Center( | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
Container( | |
padding: const EdgeInsets.all(8), | |
color: Colors.green.shade300, | |
child: const Text('widgets below are normal [ElevatedButton]s, long-press them to see how they change'), | |
), | |
const SizedBox(height: 16), | |
ElevatedButton( | |
onPressed: () => debugPrint('pressed 0'), | |
child: const Text('press me', textScaleFactor: 2.25), | |
), | |
const SizedBox(height: 16), | |
ElevatedButton( | |
style: ButtonStyle( | |
backgroundColor: MaterialStateProperty.resolveWith(_backgroundColor1), | |
), | |
onPressed: () => debugPrint('pressed 1'), | |
child: const Text('press me too', textScaleFactor: 1.75), | |
), | |
const SizedBox(height: 16), | |
ElevatedButton( | |
style: ButtonStyle( | |
backgroundColor: MaterialStateProperty.resolveWith(_backgroundColor1), | |
), | |
onPressed: () => debugPrint('pressed 2'), | |
child: const Text('and me', textScaleFactor: 1.25), | |
), | |
], | |
), | |
), | |
); | |
} | |
OutlinedBorder? _shape(states) => _SkewedShape( | |
phase: states.contains(MaterialState.pressed) ? 1 : 0, | |
drawBoundary: drawBoundary, | |
); | |
ui.Color? _backgroundColor(states) => states.contains(MaterialState.pressed) ? | |
Colors.deepOrange : Colors.indigo; | |
ui.Color? _backgroundColor1(states) => states.contains(MaterialState.pressed) ? | |
Colors.red.shade900 : Colors.green.shade900; | |
BorderSide? _side(states) => states.contains(MaterialState.pressed) ? | |
const BorderSide(color: Colors.black, width: 3) : | |
const BorderSide(color: Colors.black54, width: 2); | |
} | |
class _SkewedShape extends OutlinedBorder { | |
const _SkewedShape({ | |
required this.phase, | |
required this.drawBoundary, | |
BorderSide side = BorderSide.none, | |
}) : super(side: side); | |
final double phase; | |
final bool drawBoundary; | |
@override | |
ShapeBorder? lerpFrom(ShapeBorder? a, double t) { | |
// debugPrint('lerpFrom $a $t'); | |
if (a is _SkewedShape) { | |
return _SkewedShape( | |
phase: ui.lerpDouble(a.phase, phase, t)!, | |
drawBoundary: drawBoundary, | |
side: BorderSide.lerp(a.side, side, t), | |
); | |
} | |
return super.lerpFrom(a, t); | |
} | |
@override | |
EdgeInsetsGeometry get dimensions => EdgeInsets.zero; | |
@override | |
ui.Path getInnerPath(ui.Rect rect, {ui.TextDirection? textDirection}) => getOuterPath(rect); | |
@override | |
ui.Path getOuterPath(ui.Rect rect, {ui.TextDirection? textDirection}) { | |
return roundPolygon( | |
points: _getPoints(rect), | |
radii: [ | |
phase * 16, 0, phase * 32, 0, | |
], | |
); | |
} | |
@override | |
void paint(ui.Canvas canvas, ui.Rect rect, {ui.TextDirection? textDirection}) { | |
if (drawBoundary) { | |
List<ui.Offset> points = _getPoints(rect); | |
canvas.drawPath(Path()..addPolygon(points, true), Paint() | |
..color = Colors.indigo | |
..style = PaintingStyle.stroke | |
..strokeWidth = 0 | |
); | |
} | |
canvas.drawPath(getOuterPath(rect), side.toPaint()); | |
} | |
List<ui.Offset> _getPoints(ui.Rect rect) { | |
return [ | |
rect.topLeft.translate(phase * 24, 0), | |
rect.topRight, | |
rect.bottomRight.translate(phase * -24, 0), | |
rect.bottomLeft, | |
]; | |
} | |
@override | |
ShapeBorder scale(double t) => this; | |
@override | |
OutlinedBorder copyWith({BorderSide? side}) { | |
return _SkewedShape( | |
phase: phase, | |
drawBoundary: drawBoundary, | |
side: side ?? this.side, | |
); | |
} | |
} | |
class _MorphingClipPath extends StatefulWidget { | |
@override | |
State<_MorphingClipPath> createState() => _MorphingClipPathState(); | |
} | |
class _MorphingClipPathState extends State<_MorphingClipPath> with SingleTickerProviderStateMixin { | |
late final controller = AnimationController( | |
vsync: this, | |
duration: const Duration(milliseconds: 1000), | |
); | |
@override | |
Widget build(BuildContext context) { | |
return Column( | |
children: [ | |
ElevatedButton( | |
onPressed: () { | |
controller.value < 0.5? controller.forward() : controller.reverse(); | |
}, | |
child: const Text('click to animate'), | |
), | |
Expanded( | |
child: ClipPath( | |
clipper: _MorphingClipPathClipper(controller), | |
child: Stack( | |
fit: StackFit.expand, | |
children: const [ | |
ColoredBox(color: Colors.black38), | |
GridPaper(color: Colors.indigo), | |
FlutterLogo(), | |
], | |
), | |
), | |
), | |
], | |
); | |
} | |
} | |
class _MorphingClipPathClipper extends CustomClipper<Path> { | |
_MorphingClipPathClipper(this.controller) : super(reclip: controller); | |
final AnimationController controller; | |
@override | |
Path getClip(Size size) { | |
// debugPrint('${controller.value}'); | |
final rect = Offset.zero & size; | |
final r0 = Alignment.topCenter.inscribe(Size(size.width * 0.75, 100), rect); | |
final r1 = Alignment.bottomRight.inscribe(size / 1.5, rect); | |
final r = Rect.lerp(r0, r1, controller.value)!; | |
final radius = controller.value * size.shortestSide / 3; | |
final path = roundPolygon( | |
points: [r.topLeft, r.topRight, r.bottomRight, r.bottomLeft], | |
radii: [0, radius, 0, radius], | |
); | |
final matrix = _compose( | |
scale: 1 - 0.5 * sin(pi * controller.value), | |
rotation: pi * controller.value, | |
translate: r.center, | |
anchor: r.center, | |
); | |
return path.transform(matrix.storage); | |
} | |
@override | |
bool shouldReclip(CustomClipper<Path> oldClipper) => false; | |
Matrix4 _compose({ | |
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