English | 中文
Magnetic
A Flutter UI component inspired by the iOS SpriteKit library Magnetic: a floating “bubble picker” with physics, selection, and customization.
Features
- Bubbles float, collide, bounce, and gently attract to center.
- Drag individual items and drag the background (pan all bubbles with inertia).
- Tap to select/deselect, with single or multiple selection.
- Per-node customization: text, image, colors, border, radius, scale, spacing, etc.
- Custom
Pathshaped bubbles (hit-test by path; collisions/spacing approximated by convex hull). - Optional long-press to remove nodes (with animation).
Getting started
dart pub add fl_magnetic
Usage
import 'package:flutter/material.dart';
import 'package:fl_magnetic/fl_magnetic.dart';
class Demo extends StatefulWidget {
const Demo({super.key});
@override
State<Demo> createState() => _DemoState();
}
class _DemoState extends State<Demo> {
late final MagneticController controller = MagneticController(nodes: [
MagneticNode(id: '0', text: 'Rock'),
MagneticNode(id: '1', text: 'Jazz'),
MagneticNode(id: '2', text: 'Hip Hop'),
MagneticNode(id: '3', text: 'Classical'),
]);
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: MagneticView(
controller: controller,
allowsMultipleSelection: true,
enableItemDrag: true,
enableBackgroundDrag: true,
enableLongPressToRemove: true,
// Global spacing multiplier (collision radius multiplier).
spacingScale: 1.2,
onSelect: (n) => debugPrint('selected: ${n.text}'),
onDeselect: (n) => debugPrint('deselected: ${n.text}'),
onRemove: (n) => debugPrint('removed: ${n.text}'),
defaultStyle: const MagneticNodeStyle(
radius: 44,
color: Color(0xFFECEFF1),
selectedColor: Color(0xFF42A5F5),
// Per-node spacing multiplier (multiplies with spacingScale).
marginScale: 1.3,
),
),
),
);
}
}
API overview
MagneticView: the scene widget (physics + gestures).MagneticView.physics: provide a customMagneticPhysicsinstance.MagneticView.tuning: tune frame step / hull sampling / label fitting.MagneticController: manages nodes and selection (supports dynamic add/remove andresetSelection).MagneticController.selectedNodes: selected nodes innodesorder.MagneticNode: node model (text/image/style/etc).path(optional): custom non-circular shape.behavior(optional): custom render/hit-test/physics (similar to subclassingNodeon iOS).
LabelNode/ImageNode: convenience node types; alsoMagneticNode.label(...)/MagneticNode.image(...)factories.MagneticNodeStyle: visual style.marginScale: per-node collision/spacing multiplier.textMaxLines/minFontSize: default label multiline + adaptive font size.
MagneticView.spacingScale: global spacing multiplier (multiplies withmarginScale).MagneticView.animationBuilder: select/deselect/remove animation hook.MagneticPhysics: physics parameters + broad-phase options.MagneticViewTuning: view/algorithm tuning parameters.
Physics and tuning
All algorithm/performance knobs are configurable via MagneticPhysics and MagneticViewTuning.
MagneticView(
controller: controller,
physics: MagneticPhysics(
enableSpatialHash: true,
spatialHashThreshold: 32,
spatialHashCellSizeMultiplier: 2.0,
spatialHashMinCellSize: 1.0,
satCircleHullSides: 12,
samePositionEpsilon: 0.001,
centerAttractionEpsilon: 0.001,
collisionImpulse: 0.9,
copyProvidedHulls: true,
),
tuning: const MagneticViewTuning(
maxDtSeconds: 0.05,
initialVelocityScale: 80,
itemDragReleaseVelocityScale: 1.0,
backgroundDragReleaseVelocityScale: 0.7,
pathHullSamplesPerLength: 20,
pathHullMinSamples: 24,
pathHullMaxSamples: 160,
adaptiveLabelSearchIterations: 14,
),
)
Custom shape example
Path starPath(int points) {
final path = Path();
const outerR = 100.0;
const innerR = 45.0;
final step = pi / points;
for (var i = 0; i < points * 2; i++) {
final r = i.isEven ? outerR : innerR;
final a = -pi / 2 + step * i;
final p = Offset(cos(a) * r, sin(a) * r);
if (i == 0) {
path.moveTo(p.dx, p.dy);
} else {
path.lineTo(p.dx, p.dy);
}
}
path.close();
return path;
}
final node = MagneticNode(
text: 'Star',
path: starPath(5),
style: const MagneticNodeStyle(radius: 46),
);
path is automatically scaled and centered to the bubble size (driven by radius).
Custom node behavior example
class HexagonBehavior extends MagneticNodeBehavior {
const HexagonBehavior();
Path _hexPath(Size size) {
final w = size.width, h = size.height;
return Path()
..moveTo(w * 0.5, 0)
..lineTo(w, h * 0.25)
..lineTo(w, h * 0.75)
..lineTo(w * 0.5, h)
..lineTo(0, h * 0.75)
..lineTo(0, h * 0.25)
..close();
}
@override
Widget build(context, node, selected, style, anySelected) {
final bg = selected ? style.selectedColor : style.color;
final fg = selected ? style.selectedTextColor : style.textColor;
return LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, constraints.maxHeight);
final hex = _hexPath(size);
return CustomPaint(
painter: _HexPainter(hex, bg, style.strokeColor, style.strokeWidth),
child: Center(
child: Text(node.text, style: TextStyle(color: fg)),
),
);
},
);
}
@override
double collisionRadiusMultiplier(node, selected, style) => 1.15;
@override
bool hitTest(node, local, size, selected, style) {
return _hexPath(size).contains(local);
}
}
class _HexPainter extends CustomPainter {
final Path path;
final Color fill, stroke;
final double strokeWidth;
_HexPainter(this.path, this.fill, this.stroke, this.strokeWidth);
@override
void paint(Canvas canvas, Size size) {
canvas.drawPath(path, Paint()..color = fill);
if (strokeWidth > 0) {
canvas.drawPath(
path,
Paint()
..color = stroke
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth,
);
}
}
@override
bool shouldRepaint(covariant _HexPainter old) =>
old.path != path || old.fill != fill || old.stroke != stroke;
}
final node = MagneticNode(
text: 'Hex',
behavior: const HexagonBehavior(),
);
In this example, behavior.build/hitTest/collisionRadiusMultiplier control the node UI, hit region, and collision spacing.
Custom animation example
MagneticView(
controller: controller,
animationBuilder: (context, node, type, selected, animation, child) {
switch (type) {
case MagneticNodeAnimationType.select:
return ScaleTransition(
scale: Tween(begin: 1.0, end: 1.35).animate(
CurvedAnimation(parent: animation, curve: Curves.elasticOut),
),
child: child,
);
case MagneticNodeAnimationType.deselect:
return FadeTransition(
opacity: Tween(begin: 1.0, end: 0.6).animate(animation),
child: child,
);
case MagneticNodeAnimationType.remove:
return ScaleTransition(
scale: Tween(begin: 1.0, end: 0.0).animate(animation),
child: child,
);
}
},
)
Libraries
- fl_magnetic
- A Flutter library for creating magnetic, physics-based bubble UIs.