Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active March 16, 2025 20:09
Show Gist options
  • Save pskink/bdd2a3a1b9b479018b3b7b5597e3f4d1 to your computer and use it in GitHub Desktop.
Save pskink/bdd2a3a1b9b479018b3b7b5597e3f4d1 to your computer and use it in GitHub Desktop.
import 'dart:convert';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// A function type that defines how control points for a Bézier curve are generated.
///
/// This function takes a list of [Offset] points and an index and returns a tuple of two [Offset]
/// values. These offsets represent the control points for the Bézier curve segment
/// at the specified index.
///
/// The function is used within the [getSmoothPath] function to allow customization
/// of the control point generation logic.
typedef ControlPointsBuilder = (Offset, Offset) Function(List<Offset> points, int index);
/// Generates a smooth path connecting a series of control points using cubic Bézier curves.
///
/// This function takes a list of control points and optionally additional start and end handles
/// to create a smooth path. It uses a stiffness factor to determine how closely the curve
/// follows the control points. Optionally, it can also compute segment metrics, returning
/// a list of tweens representing the lengths of each curve segment.
///
/// ### Parameters:
/// - [list]: A required list of control points as [Offset] values.
/// Must contain at least two points.
/// - [startHandle]: An optional [Offset] specifying the handle point to start the curve.
/// Defaults to a reflected handle computed from the first two points in [list].
/// - [endHandle]: An optional [Offset] specifying the handle point to end the curve.
/// Defaults to a reflected handle computed from the last two points in [list].
/// - [stiffnessFactor]: A [double] that controls the curvature stiffness. A higher value
/// creates sharper curves, while a lower value creates smoother curves. Defaults to 6.
/// - [controlPointsBuilder]: An optional [ControlPointsBuilder] that allows a custom function to
/// calculate the control points instead of the stiffness factor.
/// - [computeSegmentsMetrics]: A [bool] indicating whether to compute the metrics for
/// each curve segment. Defaults to `false`.
/// - [closePath]: if `true` creates a closed smooth path ensuring that the path forms a loop.
/// It makes it by connecting the last point to the first point and
/// optionally adding start and end handles to control the closure of the loop.
/// The function ensures a seamless loop by appending the first point to the list and using
/// the last and second points for the start and end handles, respectively, if not explicitly provided.
/// Defaults to `false`.
///
/// ### Returns:
/// A tuple containing:
/// - A [Path] object representing the smooth curve.
/// - A [List<Tween<double>>] containing tweens for each curve segment's start and end
/// positions if [computeSegmentsMetrics] is `true`. Otherwise, the list is empty.
///
/// ### Example:
/// ```dart
/// final points = [Offset(0, 0), Offset(50, 100), Offset(100, 50)];
/// final (path, segments) = getSmoothPath(
/// list: points,
/// stiffnessFactor: 4,
/// // controlPointsBuilder: fixedSmoothBuilder(50, 30),
/// computeSegmentsMetrics: true,
/// );
///
/// // Use the path in a CustomPainter or similar.
/// print('Generated path: $path');
/// print('Segments: $segments');
/// ```
///
/// ### Assertions:
/// - Ensures that [list] contains at least two points.
/// - Ensures that only one of `stiffnessFactor` or `controlPointsBuilder` are used.
///
/// ### Notes:
/// - The function ensures smooth transitions between control points by adding
/// reflected start and end handles when they are not explicitly provided.
/// - If segment metrics are enabled, they are computed after each segment is added
/// to the path, ensuring accurate segment lengths.
///
/// ### See Also:
/// - [Path] for more details on path creation.
/// - [Tween] for managing animations or transitions.
(Path, List<Tween<double>>) getSmoothPath({
required List<Offset> list,
Offset? startHandle,
Offset? endHandle,
double? stiffnessFactor,
ControlPointsBuilder? controlPointsBuilder,
bool computeSegmentsMetrics = false,
bool closePath = false,
}) {
assert(list.length >= 2, 'There must be at least 2 control points in the [list].');
assert(stiffnessFactor == null || controlPointsBuilder == null,
'you cannot use both [stiffnessFactor] and [controlPointsBuilder]'
);
if (closePath) {
startHandle ??= list.last;
endHandle ??= list[1];
list = [...list, list.first];
}
final points = [
startHandle ?? list[0] * 2.0 - list[1],
...list,
endHandle ?? list[list.length - 1] * 2.0 - list[list.length - 2],
];
final segments = <Tween<double>>[];
double segmentBegin = 0;
final path = Path()
..moveTo(points[1].dx, points[1].dy);
final controlPoints = List.generate(controlPointsBuilder == null? 0 : points.length - 2, (i) {
return controlPointsBuilder!(points, i + 1);
});
for (var i = 0; i < points.length - 3; i++) {
final p2 = points[i + 2];
final Offset controlPoint0;
final Offset controlPoint1;
if (controlPoints.isNotEmpty) {
controlPoint0 = controlPoints[i].$1;
controlPoint1 = controlPoints[i + 1].$2;
} else {
stiffnessFactor ??= 6;
final p0 = points[i + 0];
final p1 = points[i + 1];
final p3 = points[i + 3];
controlPoint0 = p1 + (p2 - p0) / stiffnessFactor;
controlPoint1 = p2 - (p3 - p1) / stiffnessFactor;
}
path.cubicTo(
controlPoint0.dx, controlPoint0.dy,
controlPoint1.dx, controlPoint1.dy,
p2.dx, p2.dy
);
if (computeSegmentsMetrics) {
final segmentEnd = path.computeMetrics().first.length;
segments.add(Tween(begin: segmentBegin, end: segmentEnd));
segmentBegin = segmentEnd;
}
}
return (path, segments);
}
/// A pre-made [ControlPointsBuilder] that creates smooth curves with a fixed distance for control points.
///
/// This function returns a [ControlPointsBuilder] that generates control points at a specified distance
/// from the pivot point. It allows you to control the curve's smoothness by specifying a fixed distance
/// for both the first and second control points relative to the main points in the list.
///
/// ### Parameters:
/// - [distance0]: The distance from the pivot point to the first control point.
/// - [distance1]: An optional distance from the pivot point to the second control point.
/// If not provided, it defaults to the value of [distance0].
///
/// ### Returns:
/// A [ControlPointsBuilder] function that, when called with a list of [Offset] points and an index,
/// returns a tuple containing two [Offset] values representing the control points.
///
/// ### Example:
/// ```dart
/// final controlPointBuilder = fixedSmoothBuilder(50, 30);
/// final (path, segments) = getSmoothPath(
/// list: points,
/// controlPointsBuilder: controlPointBuilder,
/// computeSegmentsMetrics: true,
/// );
/// ```
ControlPointsBuilder fixedSmoothBuilder(double distance0, [double? distance1]) {
return (List<Offset> points, int i) {
return getSmoothOffsets(
pivotOffset: points[i],
direction: (points[i + 1] - points[i - 1]).direction,
distance0: distance0,
distance1: distance1,
);
};
}
/// Calculates the two control point offsets for a smooth curve.
///
/// This function calculates two control points based on a pivot point, direction, and two distances.
/// It is typically used by [fixedSmoothBuilder] to generate control points at a fixed distance from the
/// main points.
///
/// ### Parameters:
/// - [pivotOffset]: The central [Offset] point around which the control points will be calculated.
/// - [direction]: The direction (angle in radians) along which the control points will extend.
/// Typically, this is the direction of the line connecting the two adjacent points.
/// - [distance0]: The distance from the [pivotOffset] to the first control point.
/// - [distance1]: The distance from the [pivotOffset] to the second control point. If null defaults to [distance0].
///
/// ### Returns:
/// A tuple containing two [Offset] values representing the calculated control points.
/// The first [Offset] is at the given [distance0] along the given [direction], and the second is at
/// [distance1] in the opposite direction from the pivot point.
///
(Offset, Offset) getSmoothOffsets({
required Offset pivotOffset,
required double direction,
required double distance0,
double? distance1,
}) => (
pivotOffset + Offset.fromDirection(direction, distance0),
pivotOffset - Offset.fromDirection(direction, distance1 ?? distance0),
);
// NOTE!
// you can cut off the rest of the code: its just an example
// =============================================================================
// =============================================================================
// =============================================================================
main() {
runApp(const MaterialApp(home: Scaffold(body: Foo())));
}
class Foo extends StatefulWidget {
const Foo({super.key});
@override
State<Foo> createState() => _FooState();
}
typedef Points = ({Offset startHandle, List<Offset> list, Offset endHandle});
class _FooState extends State<Foo> with TickerProviderStateMixin {
static const normalizedStartHandle = Offset(0.6, 0.1);
static const normalizedEndHandle = Offset(1.0, 2);
static final cities = [
City('Hamburg', Colors.deepPurple, const Offset(0.2213, 0.0500)),
City('Hannover', Colors.pink, const Offset(0.1872, 0.2462), Alignment.bottomCenter, Alignment.topCenter),
City('Berlin', Colors.orange, const Offset(0.6666, 0.2214)),
City('Dresden', Colors.blue.shade800, const Offset(0.7100, 0.4657)),
City('Frankfurt', Colors.red.shade800, const Offset(0.0500, 0.6219)),
City('Munich', Colors.green.shade800, const Offset(0.4286, 0.9500)),
];
Points? points;
final shapeNotifier = ValueNotifier(0);
int stage = 0;
bool ignoring = false;
late final controller = AnimationController.unbounded(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
late final turnController = AnimationController(
vsync: this,
duration: Durations.long4,
);
List<ui.Image> carLayers = [];
double delta = 1;
@override
void initState() {
super.initState();
const base64layers = [
'UklGRjACAABXRUJQVlA4TCMCAAAvJ4AFEIWc29qOzVV/xihVzcS2zc5JnZXW6qzOKVPZtm3btse2'
'5/lw3d/7Rsixtk2RlboWuV8cVuHZhMgWbs4CSF1C15CIiIzI3V1Ttw0M9HT1X/U4cBtJkZxl3oNP'
'kEgkhpllFXt5yzuf8ZQjbGaWaZLGBNdmm+uI577y2w2rzTUlOMdYGMPxt29wpEav6nMePa9ZVZ+n'
'wRFcDQ/sP06eojYcp6q91y2ssFEX1xty/zlNGsgjNTuKy0Gsn9GvFVZfRsQMO/MjWV2TrDjrAOmB'
'kqY2frqRN8aYcj87OqzCThL7LpFHeN8AQJTxqjmmS4MMALC2dbRcyZ8Z5sF/4giEG1wbi3UPwnpE'
'fTjaOuygX0ksyGI1x/rSQvpfjYc4AdGoF7E9IiimnoTxHU9VecQSJ3k71Rjl+KKyQZwSQf3/tkXs'
'ZVSCb85AYg9POcVbc9e7+tSQE3huLm9ii9cKf1VT7R1iX+d9yPVs/MRbin5vK4l1IvjZqDet8RwX'
'eZFLHMticnZhPSmqmLEFezh+j3gbIrlUZOt0J6Z1jirV+iejbJrXNgqr00dH8/NBz3KxBr/9JOWp'
'KZ/KmbyiGQyn+sZ2rJBIXLpLHiE713CcrHy0jA6YpsZxvwdNXX3NGlRQj6XfZSyI8S4fPzL/l0fN'
'CcdF7krkMr/6J1LeMEcKxYbunzfZ7r5s5XyW750jttuA68yTJCbp0eMs/EbDFVbosYNRUhAJAA==',
'UklGRmgAAABXRUJQVlA4TFwAAAAvJ4AFEHcw//M//2obSWrT7/9TB6RkdEAATiAwBoZE4uHgI3Cg'
'uFBU20r0nV0SgQgkIwtRLGEJE82uI/o/ATiXp7Q6w3SGjDgBvcwg/C5N6WWCjDjBFBNbncHYGA==',
];
base64layers.map(base64.decode).map(decodeImageFromList).wait.then((images) {
setState(() => carLayers = images);
});
controller.addStatusListener((status) {
setState(() {
stage = switch (status) {
AnimationStatus.forward => -1,
AnimationStatus.completed => controller.value.round(),
_ => -1,
};
ignoring = stage == -1;
});
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Text('note: you can drag the attached labels', style: Theme.of(context).textTheme.bodySmall),
),
Padding(
padding: const EdgeInsets.all(8),
child: IgnorePointer(
ignoring: ignoring,
child: FilledButton.icon(
onPressed: () async {
if (controller.isAnimating || turnController.isAnimating) return;
setState(() => ignoring = true);
final roundValue = controller.value.round();
if (delta > 0 && roundValue == cities.length - 1) {
await turnController.forward(from: 0);
delta = -delta;
} else
if (delta < 0 && roundValue == 0) {
await turnController.reverse(from: 1);
delta = -delta;
}
controller.animateTo(roundValue + delta, curve: Curves.ease);
},
label: const Text('click to animate next route stage'),
icon: const Icon(Icons.animation),
),
),
),
Expanded(
child: Container(
color: Colors.grey.shade300,
alignment: Alignment.center,
padding: const EdgeInsets.all(8),
child: AspectRatio(
aspectRatio: 6 / 10,
child: LayoutBuilder(
builder: (context, constraints) {
points ??= _scalePoints(constraints.biggest);
return CustomPaint(
painter: FooPainter(
points: points!,
controller: controller,
turnController: turnController,
cities: cities,
carLayers: carLayers,
repaint: Listenable.merge([shapeNotifier, controller, turnController]),
),
child: CustomMultiChildLayout(
delegate: FooDelegate(
points: points!.list,
cities: cities,
relayout: shapeNotifier,
),
children: [
..._buildChildren()
],
),
);
},
),
),
),
),
],
);
}
Iterable<Widget> _buildChildren() sync* {
for (int i = 0; i < cities.length; i++) {
// circle on the path
yield LayoutId(
id: 'circle$i',
child: SizedBox.fromSize(
size: const Size.square(24),
child: FadeTransition(
opacity: Animation.fromValueListenable(controller, transformer: (d) {
final delta = (d - i).abs();
return ui.lerpDouble(0, 1, delta / 0.4)!.clamp(0, 1);
}),
child: DecoratedBox(
decoration: ShapeDecoration(
color: cities[i].color,
shape: const CircleBorder(),
shadows: const [BoxShadow(blurRadius: 2, offset: Offset(2, 2))],
),
),
),
),
);
// label next to the circle
yield LayoutId(
id: 'label$i',
child: AnimatedContainer(
duration: Durations.medium4,
margin: const EdgeInsets.all(6),
decoration: ShapeDecoration(
color: stage == i? Colors.orange : Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.horizontal(left: Radius.circular(16)),
),
),
child: GestureDetector(
onPanUpdate: (d) {
points!.list[i] += d.delta;
shapeNotifier.value++;
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Text(cities[i].name,
style: stage == i?
const TextStyle(fontWeight: FontWeight.w900) :
const TextStyle(fontWeight: FontWeight.normal)
),
),
),
),
);
}
}
Points _scalePoints(ui.Size size) {
return (
startHandle: normalizedStartHandle.scale(size.width, size.height),
list: cities.map((c) => c.normalizedOffset.scale(size.width, size.height)).toList(),
endHandle: normalizedEndHandle.scale(size.width, size.height),
);
}
@override
void dispose() {
shapeNotifier.dispose();
controller.dispose();
super.dispose();
}
}
class City {
City(this.name, this.color, this.normalizedOffset, [this.inAnchor = Alignment.centerRight, this.outAnchor = Alignment.centerLeft]);
final String name;
final Color color;
final Offset normalizedOffset;
final Alignment inAnchor;
final Alignment outAnchor;
}
class FooPainter extends CustomPainter {
FooPainter({
required this.points,
required this.controller,
required this.turnController,
required this.cities,
required this.carLayers,
super.repaint,
});
final Points points;
final AnimationController controller;
final AnimationController turnController;
final List<City> cities;
final List<ui.Image> carLayers;
@override
void paint(Canvas canvas, Size size) {
final (path, segments) = getSmoothPath(
startHandle: points.startHandle,
list: points.list,
endHandle: points.endHandle,
computeSegmentsMetrics: true,
);
// add dummy "sentinel" segment
segments.add(Tween(begin: segments.last.end, end: segments.last.end));
// draw the whole thin path
_drawThinPath(canvas, path);
// timeDilation = 10;
final index = controller.value.floor();
final metric = path.computeMetrics().first;
final paint = Paint();
for (int i = 0; i <= index; i++) {
final tween = segments[i];
final endDistance = tween.transform(controller.value % 1);
// draw path segment
_drawSegment(i, canvas, paint, tween, (i < index? tween.end! : endDistance), metric);
// draw car
if (carLayers.isNotEmpty && i == index) {
_drawCar(i, canvas, endDistance, metric);
}
}
}
_drawThinPath(Canvas canvas, Path path) {
final paint = Paint()
..style = PaintingStyle.stroke
..color = Colors.black87
..strokeWidth = 1;
canvas.drawPath(path, paint);
}
_drawSegment(int i, Canvas canvas, Paint paint, Tween<double> tween, double end, ui.PathMetric metric) {
for (double d = tween.begin!; d < end; d += 3.5) {
final tangent = metric.getTangentForOffset(d)!;
final t = (d - tween.begin!) / (tween.end! - tween.begin!);
final color = Color.lerp(cities[i].color, cities[i + 1].color, t)!;
canvas.drawCircle(tangent.position, 4, paint..color = color);
}
}
_drawCar(int i, Canvas canvas, double carDistance, ui.PathMetric metric) {
final tangent = metric.getTangentForOffset(carDistance)!;
final colorA = cities[i].color;
final colorB = colorA != cities.last.color? cities[i + 1].color : colorA;
final color = Color.lerp(colorA, colorB, controller.value % 1)!;
final anchor = Offset(carLayers[0].width / 2, carLayers[0].height / 2);
final matrix = composeMatrix(
rotation: pi * turnController.value - tangent.angle,
anchor: anchor,
translate: tangent.position,
);
final carShadowPaint = Paint()
..maskFilter = const MaskFilter.blur(ui.BlurStyle.normal, 3)
..color = Colors.black;
final carBodyPaint = Paint()
..colorFilter = ColorFilter.mode(color, BlendMode.modulate);
final carShadowOffset = Offset.fromDirection(pi / 4 + tangent.angle - pi * turnController.value, 8);
final carSize = Size(carLayers[0].width.toDouble(), carLayers[0].height.toDouble());
final carShadowRect = RRect.fromRectAndRadius(
carShadowOffset & carSize,
const Radius.circular(6),
);
canvas
..save()
..transform(matrix.storage)
..drawRRect(carShadowRect, carShadowPaint)
..drawImage(carLayers[0], Offset.zero, carBodyPaint)
..drawImage(carLayers[1], Offset.zero, Paint())
..restore();
}
@override
bool shouldRepaint(FooPainter oldDelegate) => true;
}
class FooDelegate extends MultiChildLayoutDelegate {
FooDelegate({
required this.points,
required this.cities,
super.relayout,
});
final List<Offset> points;
final List<City> cities;
@override
void performLayout(Size size) {
final constraints = BoxConstraints.loose(size);
for (int i = 0; i < points.length; i++) {
final o = points[i];
final circleSize = layoutChild('circle$i', constraints);
final labelSize = layoutChild('label$i', constraints);
final circleOffset = Offset(o.dx, o.dy) - circleSize.center(Offset.zero);
final circleRect = circleOffset & circleSize;
final labelRect = circleRect.alignSize(labelSize, cities[i].inAnchor, cities[i].outAnchor);
positionChild('circle$i', circleRect.topLeft);
positionChild('label$i', labelRect.topLeft);
}
}
@override
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) => true;
}
extension AlignSizeToRectExtension on Rect {
Rect alignSize(Size size, Alignment inputAnchor, Alignment outputAnchor, [Offset extraOffset = Offset.zero]) {
final inputOffset = inputAnchor.withinRect(this);
final outputOffset = outputAnchor.alongSize(size);
Offset offset = inputOffset - outputOffset;
if (extraOffset != Offset.zero) offset += extraOffset;
return offset & size;
}
}
Matrix4 composeMatrix({
double scale = 1,
double rotation = 0,
Offset translate = Offset.zero,
Offset anchor = Offset.zero,
}) {
final double c = cos(rotation) * scale;
final double s = sin(rotation) * scale;
final double dx = translate.dx - c * anchor.dx + s * anchor.dy;
final double dy = translate.dy - s * anchor.dx - c * anchor.dy;
return Matrix4(c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment