geometry_kit_widgets 0.3.0
geometry_kit_widgets: ^0.3.0 copied to clipboard
Flutter widgets for geometry_kit shapes — CustomPainter-backed, styled, themable, and composable. Includes circle, ellipse, rectangle, triangle, polygon, line, a multi-shape canvas, and a 3D scene for [...]
geometry_kit_widgets #
Flutter widgets for geometry_kit shapes — CustomPainter-backed, styled, and composable.


Quick start #
import 'package:flutter/material.dart';
import 'package:geometry_kit/geometry_kit.dart';
import 'package:geometry_kit_widgets/geometry_kit_widgets.dart';
class Demo extends StatelessWidget {
const Demo({super.key});
@override
Widget build(BuildContext context) {
return GeoCircle.fromGeometry(
circle: const Circle(radius: 40, center: Point(100, 100)),
style: const ShapeStyle.stroked(Colors.indigo, width: 2),
size: const Size(200, 200),
);
}
}
Two ways to render #
Standalone — each Geo* widget owns its own CustomPaint. Drop into any layout:
Column(children: [
GeoCircle(radius: 30, ...),
Text('label'),
GeoTriangle(...),
])
Multi-shape GeometryCanvas — share a single paint pass across many shapes (cheaper, layered, unified hit-testing in later phases):
GeometryCanvas(
size: const Size(400, 300),
shapes: [
StyledShape(
Circle(center: Point(100, 100), radius: 50),
style: const ShapeStyle.filled(Colors.blue),
),
StyledShape(
Triangle(Point(200, 50), Point(300, 200), Point(150, 200)),
style: const ShapeStyle.stroked(Colors.red, width: 2),
),
],
)
Both paths use the same painter classes — pick whichever fits the layout.
Phase 1 widgets #
| Widget | Geometry |
|---|---|
GeoCircle |
Circle |
GeoEllipse |
Ellipse |
GeoRectangle |
Rectangle |
GeoTriangle |
Triangle |
GeoQuadrilateral |
Quadrilateral |
GeoPolygon |
Polygon |
GeoLine |
Line |
Each widget exposes two constructors: a flat one (build geometry from primitives) and .fromGeometry (pass an existing geometry instance).
Styling #
ShapeStyle bundles fill, stroke, opacity, dash pattern, cap, and join into one immutable object:
const ShapeStyle(); // 1px black stroke
const ShapeStyle.filled(Colors.indigo); // solid fill
const ShapeStyle.stroked(Colors.red, width: 3); // 3px red stroke
const ShapeStyle(
fillColor: Colors.blue,
strokeColor: Colors.indigo,
strokeWidth: 2,
opacity: 0.7,
dashPattern: DashPattern([8, 4]),
);
Gradients #
Pass any Flutter Gradient (linear, radial, sweep) on fillGradient or
strokeGradient. Gradients win over solid colors. Resolved against the
shape's axis-aligned bounding rect at paint time:
const ShapeStyle(
fillGradient: RadialGradient(
colors: [Color(0xFFFFF6C8), Color(0xFFFFD15C), Color(0xFFE08A1F)],
stops: [0.0, 0.55, 1.0],
),
strokeColor: null,
strokeWidth: 0,
);
const ShapeStyle.gradientFilled(
LinearGradient(colors: [Color(0xFF4FA3E3), Color(0xFF1B4F8B)]),
);
opacity does not modulate gradients — encode alpha directly into the
gradient color stops. GeoLine ignores strokeGradient (asserted in debug):
zero-area bounds make shader resolution degenerate.
Paint escape hatch #
For Skia features ShapeStyle does not surface directly (MaskFilter,
ImageFilter, BlendMode, custom shaders), use fillPaintBuilder /
strokePaintBuilder. The hook receives the resolved base Paint plus the
shape's bounds:
Paint glow(Paint base, Rect bounds) =>
base..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
const ShapeStyle(
fillColor: Color(0x55FFD15C),
fillPaintBuilder: glow,
);
Prefer top-level or static builders — ShapeStyle equality compares them
by reference, so a fresh closure each build will defeat
shouldRepaint short-circuiting.
Theming + field-level merge #
Wrap a subtree in ShapeStyleTheme to share a default style:
ShapeStyleTheme(
data: const ShapeStyle.stroked(Colors.teal, width: 2),
child: Column(children: [
GeoCircle(radius: 30), // teal stroke
GeoTriangle(...), // teal stroke
GeoLine(..., style: const ShapeStyle.stroked(Colors.red)), // override
]),
)
Resolution: explicit.merge(ambient) per field — fields the override
leaves null fall back to the closest ShapeStyleTheme, otherwise to the
constructor default (strokeWidth: 1.0, strokeCap: butt, …). All
scalar fields (strokeWidth, opacity, strokeCap, strokeJoin) are
nullable; pass null explicitly via the raw constructor (not copyWith)
to opt a field into theme inheritance.
ShapeStyleTheme(
data: const ShapeStyle(strokeColor: Colors.teal, strokeWidth: 2),
child: GeoCircle(
radius: 30,
// dashes here, teal stroke + 2px width inherited from theme
style: const ShapeStyle(
strokeColor: null,
strokeWidth: null,
dashPattern: DashPattern([6, 4]),
),
),
)
Animating styles #
ShapeStyle.lerp interpolates colors and the strokeWidth / opacity
scalars; gradients, dash patterns, enum fields, and PaintBuilders snap
at t >= 0.5. Drop-in implicit animation via AnimatedShapeStyle:
AnimatedShapeStyle(
style: highlighted ? hotStyle : coolStyle,
duration: const Duration(milliseconds: 250),
child: const GeoCircle(radius: 30),
)
AnimatedShapeStyle supplies the interpolated style to descendants via a
ShapeStyleTheme, so any Geo* widget without an explicit style (or
with a partial style that nulls the relevant fields) follows the animation.
For explicit driving use ShapeStyleTween with an AnimationController.
Coordinate mapping #
Default = Flutter native (top-left origin, Y-down). Inject a CoordinateMapper for math conventions:
GeoCircle(
radius: 30,
mapper: CoordinateMapper.yUp(const Size(200, 200)),
size: const Size(200, 200),
)
// Origin at the canvas center, Y-up:
GeometryCanvas(
size: const Size(400, 300),
mapper: CoordinateMapper.centered(const Size(400, 300)),
shapes: [...],
)
Built-in mappers: identity, yUp(size), centered(size, {yUp = true}).
Clipping & layering #
Every Geo* widget and GeometryCanvas accepts clipBehavior (default Clip.hardEdge). Shapes that extend past size are clipped to widget bounds — they don't bleed onto neighbors. Set Clip.none to allow overflow.
GeoCircle(
radius: 120,
size: const Size(80, 80),
clipBehavior: Clip.none, // circle draws outside the 80x80 box
)
GeometryCanvas defaults to backgroundColor: null (transparent) so widgets stacked underneath remain visible.
Clipping arbitrary widgets to a shape #
ShapeWidget hosts any child widget clipped to a geometry shape, with an
optional ShapeStyle overlay drawn above the clipped child:
ShapeWidget(
shape: const Circle(center: Point(50, 50), radius: 50),
size: const Size(100, 100),
style: const ShapeStyle.stroked(Colors.white, width: 2),
child: Image.network('https://picsum.photos/seed/earth/200'),
)
For raw control, ShapeClipper is a CustomClipper<Path> you can pass
to Flutter's ClipPath directly. And shapeToPath(shape, mapper) returns
the same canvas-space Path the painters use — useful for hit-test
regions, reflowing text inside a shape, or animated path morphs.
ClipPath(
clipper: ShapeClipper(
shape: Polygon(vertices),
mapper: CoordinateMapper.centered(size),
),
child: child,
)
shapeToPath supports Circle, Ellipse, Rectangle, Triangle,
Quadrilateral, Polygon, and Line. Clipping a Line is degenerate
(zero-area path) — documented but not asserted.
Tap + hover interactivity #
Every closed Geo* widget (GeoCircle, GeoEllipse, GeoRectangle,
GeoTriangle, GeoQuadrilateral, GeoPolygon) accepts optional onTap
and onHoverChanged callbacks. Hit-test is shape-precise — pointer events
inside the host widget rect but outside the shape outline are ignored.
GeoRectangle.cornerRadius is honoured (taps in the cut-off corners
correctly miss).
GeoCircle(
radius: 30,
center: const Offset(50, 50),
size: const Size(100, 100),
style: const ShapeStyle.filled(Colors.indigo),
onTap: () => print('tapped'),
onHoverChanged: (hovering) => setState(() => _hot = hovering),
)
Pair with AnimatedShapeStyle to fade hover styles smoothly:
AnimatedShapeStyle(
style: _hot ? hotStyle : coolStyle,
duration: const Duration(milliseconds: 150),
child: GeoCircle(radius: 30, onHoverChanged: (v) => setState(() => _hot = v)),
)
StyledShape exposes the same callbacks; GeometryCanvas walks shapes
top-down on each pointer event and dispatches to the topmost shape under
the pointer. A common pattern is to reorder the shape list on tap so the
tapped shape moves to the top of the paint stack:
List<String> _order = ['blue', 'orange', 'green'];
GeometryCanvas(
size: const Size(300, 220),
shapes: _order.map((id) => StyledShape(
_circleFor(id),
id: id,
style: _styleFor(id),
onTap: () => setState(() {
_order = [..._order.where((e) => e != id), id];
}),
)).toList(),
)
Hover supports both mouse pointers and stylus / touch hover. GeoLine
does not expose interaction (zero-area path makes shape-precise hit-test
degenerate); reach for a filled Geo* widget or a raw GestureDetector
when line interaction is needed.
3D scenes #
GeoScene draws geometry_kit_3d solids with an orbit
camera, flat shading and picking. It runs on the normal Flutter Canvas, so it
works on every platform with no GPU setup.
GeoScene(
camera: const OrbitCamera(yaw: 35, pitch: 25, distance: 7),
onTap: (hit) => debugPrint(hit?.node.id),
children: [
...GeoPath3.grid(extent: 3, step: 0.5),
GeoSolid(
Box.fromBase(center: Point3.zero, width: 1, depth: 1, height: 1.5),
style: const ShapeStyle(fillColor: Colors.indigo, strokeColor: Colors.black),
id: 'crate',
),
const GeoLabel3(Point3(0, 1.8, 0), 'crate'),
],
)
| Node | Draws |
|---|---|
GeoSolid |
Any Solid, shaded by the scene light; edges from strokeColor; optional gradient |
GeoPath3 |
A 3D polyline (on top of solids, or underneath with overlay: false) |
GeoLabel3 |
Text pinned to a 3D point |
GeoHandle |
A fixed-size dot or square at a 3D point, drawn on top and picked on screen |
Give a GeoSolid a transform (Transform3) to place a local-space solid:
the mesh is cached once and moved at paint time, so animating or dragging it
never re-triangulates.
Gradients on solids #

Give a GeoSolid a gradient to colour it with a SolidGradient instead of
fillColor. The gradient is laid across the solid's local bounding box,
so it moves, turns and scales with the solid's transform. Opacity and edge
outlines still come from style.
Positions are Anchor3s: points in that box, -1 to 1 on each axis.
| Axis | -1 | 1 |
|---|---|---|
x |
left | right |
y |
bottom | top |
z |
back | front |
Constants cover the usual spots: Anchor3.center, .top, .bottom,
.left, .right, .front, .back, .topLeftFront, .bottomRightBack.
Linear: from start to end. The two anchors pick the side the
gradient starts from; swap them (or use reversed) to flip it.
GeoSolid(
Box.cube(center: Point3.zero, size: 1),
gradient: const SolidGradient.linear(
start: Anchor3.topLeftFront,
end: Anchor3.bottomRightBack,
colors: [Colors.teal, Colors.orange, Colors.purple],
stops: [0, 0.4, 1], // optional; evenly spread when left out
),
)
Radial: outward from center, reaching the last colour at radius
(in anchor units). Both are optional: the centre defaults to the top, and
without a radius the gradient reaches right across the solid.
GeoSolid(
const Sphere(center: Point3.zero, radius: 1),
gradient: const SolidGradient.radial(
center: Anchor3.top,
colors: [Colors.amber, Colors.deepPurple],
),
)
Keep a radial centre off the middle of a ball: every point on a sphere is the same distance from its middle, so it would come out one colour.
Colours are set at each mesh vertex and blended across triangles. A
two-colour linear gradient that spans the box is exact as is. Radial
gradients, three or more colours, or ends inside the box bend within a
face, so those solids are drawn on a finer mesh, with up to 16 times the
triangles on big flat faces (see SolidGradient.needsRefinement and
SceneMesh.refined).
Gestures: drag to orbit, pinch or scroll to zoom. onTap / onHover report a
SceneHit (the node plus distance, point and normal). Set onDragSolid and
dragging a solid moves it on the horizontal plane instead of orbiting.
For custom tools, pass a SceneInteraction as interaction: it sees each
pointer before the camera and can claim the gesture. SceneViewport turns
pointer positions into rays, picks solids and GeoHandles, and finds drag
planes. geometry_kit_editor builds selection,
drag-to-move and undo on top of this.
How it renders: each solid is triangulated, back faces are culled, triangles are
sorted back to front and filled with one drawVertices call. That is fine for
charts, rooms and simple models up to a few thousand triangles. There is no
depth buffer, so faces that cut through each other can draw in the wrong order.
For heavy models, export solid.toMesh() to a GPU renderer instead.
Other features #
Semantics(label: ...)baked into every widget for accessibility.RepaintBoundarywraps everyCustomPaintto keep nearby state changes from forcing a full repaint.- Dash patterns are drawn via shared
PathMetricshelper — no extra dependency.
See also #
The example/ app ships interactive demos covering every widget, theme
overrides, dash patterns, coordinate mappers, multi-shape canvas, gradient
PaintBuilderstyling (animated solar system with sliders for orbit speed and planet scale), shape clipping (gradient circle, text in triangle, plus a live image-clip playground with vertex-count + rotation sliders and a network/Flutter-logo/gradient source picker), and tap + hover interactivity (per-Geo*callbacks plus a canvas demo where tapping a shape reorders it to the top of the paint stack). The 3D tab covers a solids tour, cone slice, bar chart, room, draw-and-extrude, picking & contact, vase export and the editor demo (with solid, linear and radial fills for the selection); each shows the live scene, its controls and the code that builds it.
cd example && flutter run
Roadmap #
- Phase 2:
GeoArc,GeoRing,GeoCapsule,GeoBezierCurve,GeoSpline,GeoPolyline,GeoRay. (Tap/hover interactivity +GeometryCanvashit-testing already shipped in 0.2.0.) - Phase 3: drag, grid + axes overlay, debug parameters (
showBoundingBox,showCenter,showVertices). - Phase 4: tooltips, image export, dark mode presets.
- Deferred (gated on demand): multi-ancestor
ShapeStyleThememerge chain,ShapeStyleResolverbuilder for context-driven theming, shader cache for animated gradient grids. Seeplan/decorative_styling.md.

