Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active January 27, 2024 10:13
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save pskink/adf730167a48b750a81f1dd197309312 to your computer and use it in GitHub Desktop.
Save pskink/adf730167a48b750a81f1dd197309312 to your computer and use it in GitHub Desktop.
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