Skip to content

Instantly share code, notes, and snippets.

@yeasin50
Forked from pskink/round_polygon.dart
Created October 26, 2022 14:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save yeasin50/269dc11df86e2fe598bc9d6e2ea3cef0 to your computer and use it in GitHub Desktop.
Save yeasin50/269dc11df86e2fe598bc9d6e2ea3cef0 to your computer and use it in GitHub Desktop.
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