geometry_kit_widgets 0.2.0
geometry_kit_widgets: ^0.2.0 copied to clipboard
Flutter widgets for geometry_kit shapes — CustomPainter-backed, styled, themable, and composable. Includes circle, ellipse, rectangle, triangle, polygon, line, and a multi-shape canvas.
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.
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).
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.
