Last active
June 17, 2024 16:04
-
-
Save pskink/adf730167a48b750a81f1dd197309312 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:math'; | |
import 'dart:ui' as ui; | |
import 'package:collection/collection.dart'; | |
import 'package:flutter/material.dart'; | |
// TODO | |
// * 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. It can contain either: | |
/// * [Offset] for an absolute point | |
/// * [Offset.relative] for a relative point | |
/// | |
/// For example: [Offset(100, 100), Offset(20, 0).relative, Offset(0, 30).relative] | |
/// is equal to [Offset(100, 100), Offset(120, 100), Offset(120, 130)] | |
/// | |
/// 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 List<Offset> absolutePoints; | |
if (!points.any((point) => point is _RelativeOffset)) { | |
// no relative [Offset] in [points] | |
absolutePoints = points; | |
} else { | |
// at least one relative [Offset] | |
Offset prevPoint = Offset.zero; | |
absolutePoints = points.map((point) { | |
return prevPoint = point is _RelativeOffset? prevPoint + point : point; | |
}).toList(); | |
} | |
final p = absolutePoints.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(radii![index] == 0 || 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) { | |
if (radii[i] != 0) { | |
// round corner | |
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); | |
} else { | |
// sharp corner | |
i == 0? | |
path.moveTo(p[i].dx, p[i].dy) : | |
path.lineTo(p[i].dx, p[i].dy); | |
} | |
i++; | |
} | |
return path..close(); | |
} | |
extension RelativeOffsetExtension on Offset { | |
Offset get relative => _RelativeOffset(dx, dy); | |
} | |
class _RelativeOffset extends Offset { | |
_RelativeOffset(super.dx, super.dy); | |
} | |
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, | |
}) : super(side: side); | |
final PathBuilder pathBuilder; | |
final double phase; | |
final OnPaintFrame? painter; | |
final OnPaintFrame? foregroundPainter; | |
@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, | |
); | |
} | |
return super.lerpFrom(a, t); | |
} | |
@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, 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; | |
} | |
// 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.2), | |
const Alignment(-0.5, 0.75), | |
const Alignment(0.5, 0), | |
const Alignment(-0.5, -0.75), | |
const Alignment(-1, -0.2), | |
], | |
color: Colors.deepPurple, | |
), | |
}; | |
@override | |
Widget build(BuildContext context) { | |
final names = [ | |
...shapes.keys, | |
'PathBuilderBorder', | |
'tabs', | |
'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) { | |
final children = { | |
'PathBuilderBorder': _PathBuilderBorderTest(), | |
'tabs': _Tabs(), | |
'dynamic #0': _MorphingButton0(), | |
'dynamic #1': _MorphingButton1(), | |
'dynamic #2': _MorphingButton2(), | |
'dynamic #3': _MorphingClipPath(), | |
}; | |
final child = children[key]; | |
if (child != null) { | |
return Builder(builder: (ctx) => child); | |
} | |
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 _PathBuilderBorderTest 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: PathBuilderBorder( | |
pathBuilder: (r, phase) => 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: PathBuilderBorder( | |
pathBuilder: (r, phase) => 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: PathBuilderBorder( | |
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: PathBuilderBorder( | |
pathBuilder: (r, phase) => 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, double phase) => roundPolygon( | |
points: [r.centerLeft, r.topRight, r.bottomRight, r.bottomLeft], | |
radii: [16, 100, 16, 16], | |
); | |
} | |
class _MorphingButton0 extends StatefulWidget { | |
@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: PathBuilderBorder( | |
pathBuilder: _phasedPathBuilder, | |
painter: _painter, | |
phase: idx.toDouble(), | |
), | |
shadows: kShadows, | |
color: colors[idx], | |
), | |
child: Material( | |
type: MaterialType.transparency, | |
child: InkWell( | |
highlightColor: Colors.transparent, | |
splashColor: Colors.black26, | |
onTap: () => setState(() => idx = idx ^ 1), | |
child: const Center(child: Text('press me', textScaleFactor: 2)), | |
), | |
), | |
); | |
} | |
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.deepPurple, Colors.white54, 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() | |
..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.15); | |
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 s = Size.square(bounds.shortestSide); | |
final r = Alignment.center.inscribe(s, bounds); | |
final morphedAlignments = [ | |
for (int i = 0; i < alignments[0].length; i++) | |
Alignment.lerp(alignments[0][i], alignments[1][i], phase)! | |
]; | |
final points = morphedAlignments.map((a) => a.withinRect(r)).toList(); | |
return roundPolygon( | |
points: points, | |
radius: 30, | |
); | |
} | |
Matrix4 _rotatedMatrix(double rotation, Offset anchor) => Matrix4.identity() | |
..translate(anchor.dx, anchor.dy) | |
..rotateZ(rotation) | |
..translate(-anchor.dx, -anchor.dy); | |
} | |
class _RoundPolygonShape extends ShapeBorder { | |
const _RoundPolygonShape({ | |
required this.alignments, | |
required this.drawBoundary, | |
}); | |
final List<Alignment> alignments; | |
final bool drawBoundary; | |
@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: 20, | |
); | |
} | |
@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 { | |
@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: PathBuilderBorder( | |
pathBuilder: _phasedPathBuilder, | |
phase: index, | |
), | |
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')), | |
), | |
), | |
), | |
), | |
), | |
), | |
], | |
), | |
), | |
], | |
); | |
} | |
Path _phasedPathBuilder(Rect bounds, double phase) { | |
final notchRect = Offset(bounds.left, bounds.top + _kTopPadding + phase * _kSectionSize) & const Size.square(_kSectionSize); | |
final points = [ | |
bounds.topLeft, | |
bounds.topRight, | |
bounds.bottomRight, | |
bounds.bottomLeft, | |
notchRect.bottomLeft, | |
notchRect.bottomRight, | |
notchRect.topRight, | |
notchRect.topLeft, | |
]; | |
return roundPolygon( | |
points: points, | |
radii: [ | |
0, 16, 16, 0, | |
_kSectionSize / 2, _kSectionSize / 2, _kSectionSize / 2, 8, | |
], | |
); | |
} | |
} | |
class _MorphingButton2 extends StatelessWidget { | |
@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) => PathBuilderBorder( | |
pathBuilder: _phasedPathBuilder, | |
phase: states.contains(MaterialState.pressed) ? 1 : 0, | |
); | |
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); | |
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 roundPolygon( | |
points: points, | |
radii: [ | |
phase * 16, 0, phase * 32, 0, | |
], | |
); | |
} | |
} | |
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); | |
} | |
} | |
class _Tabs extends StatefulWidget { | |
@override | |
State<_Tabs> createState() => _TabsState(); | |
} | |
class _TabsState extends State<_Tabs> { | |
int activeTab = 2; | |
final slant = Offset(8, 32); | |
@override | |
Widget build(BuildContext context) { | |
return ClipRect( | |
child: ListView.builder( | |
itemCount: 6, | |
itemBuilder: (ctx, k) { | |
return Align( | |
alignment: Alignment.topCenter, | |
heightFactor: 2 / 3, | |
child: AnimatedContainer( | |
margin: EdgeInsets.only(top: k == 0? 8 : 0), | |
height: activeTab == k? 9 * slant.dy : 3 * slant.dy, | |
duration: const Duration(milliseconds: 650), | |
clipBehavior: Clip.antiAlias, | |
decoration: ShapeDecoration( | |
color: k == activeTab? Colors.orange : k.isEven? Colors.blueGrey : Colors.teal, | |
shape: PathBuilderBorder( | |
pathBuilder: _pathBuilder, | |
phase: k == activeTab? 1 : 0, | |
), | |
shadows: const [BoxShadow(blurRadius: 4)], | |
), | |
child: Material( | |
type: MaterialType.transparency, | |
child: InkWell( | |
onTap: () => setState(() => activeTab = k), | |
), | |
), | |
), | |
); | |
}, | |
), | |
); | |
} | |
ui.Path _pathBuilder(ui.Rect b, double phase) { | |
final r = b.topLeft & Size(b.width, slant.dy); | |
final p = 1 - phase; | |
const radius = 12.0; | |
return roundPolygon( | |
// indices: | |
// 4-------5 | |
// / \ | |
// 2---3 6-----7 | |
// | | |
// | | |
// 1---------------------0 | |
// | |
points: [ | |
b.bottomRight, b.bottomLeft, r.bottomLeft, | |
Offset(64 * p + radius, 0).relative, slant.scale(1, -1).relative, | |
Offset.lerp(r.topLeft, r.topRight, 0.75)!, slant.relative, r.bottomRight, | |
], | |
radii: [ | |
0, 0, 0, | |
radius, radius, | |
radius, radius, 0, | |
], | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment