Last active
March 16, 2025 20:09
-
-
Save pskink/bdd2a3a1b9b479018b3b7b5597e3f4d1 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: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