Flutter Rect in Custom Painting: Coordinate Spaces, Containment, and Clipping Decisions
Mixing local and global coordinate spaces is the most common cause of hit-test misses in Flutter custom painters. Here is how to use Rect factories, containment checks, and clipping with explicit coordinate intent.
26 Jan 2026, 17:42 UTC

Your custom slider works perfectly in a bare Scaffold. Drop it inside a ListView, scroll a few hundred pixels, and taps start missing the handle. The drawing code has not changed. The bug is almost always a coordinate-space mismatch: you are testing a local Rect against a global pointer, or clipping to bounds that live in a different origin than the canvas.
Treat every Rect as a labeled coordinate space, not just a box. Use factory constructors to declare intent, normalize to a single origin before calling contains or intersect, and compose clips functionally without mutating inputs.
Factories declare the coordinate space
Rect is an immutable value type. You do not mutate a box in place; you build a new one. The factory you choose documents where the numbers come from.
Rect.fromLTWH(left, top, width, height)fits layout boxes where you know the origin and the size.Rect.fromLTRB(left, top, right, bottom)fits measured edges, such as the result of a text layout or a parent constraint.Rect.fromCenter(center: Offset(...), width: ..., height: ...)fits centered hit targets, like a drag handle.
Choosing the wrong factory is not a syntax error, but it hides intent. A reader who sees fromLTWH immediately knows the values are an origin plus a size. A reader who sees fromLTRB knows the values are absolute edges. When you later translate or scale that Rect, the original stays untouched, which is useful for functional composition but easy to misuse if you lose track of which space you are in.
Normalize before you test or clip
contains and overlaps implement axis-aligned containment—that is, the box sides run parallel to the x and y axes, with no rotation. They are reliable only when both operands share the same origin. A pointer event gives you an Offset; if that offset is in global viewport coordinates and your Rect is in local widget coordinates, the test will fail as soon as a parent scrolls or transforms.
The fix is normalization. Convert the pointer to local space, or convert the Rect to global space—do not mix them. In a render object, globalToLocal converts a viewport offset into the coordinate system of that box. In a gesture detector, details.globalPosition is global; details.localPosition is relative to the detector widget. Pick one space and stick to it for the whole interaction.
Edge semantics deserve a unit test. Flutter's contains uses well-defined boundary rules, but whether the right and bottom edges are inclusive can vary by framework version and by how the Rect was constructed. Assuming inclusive-on-both-sides can cause off-by-one misses at the boundary. Write a small test with known coordinates and assert the result for an interior point and for a point exactly on each edge before you rely on it in production.
Compose visible regions without mutating inputs
intersect and union return new Rect instances. That immutability makes it easy to compose clipping regions: take the content bounds, intersect them with the viewport, and clip the canvas to the result. If the intersection is empty, you can skip painting entirely.
This is a practical performance decision in scrollable custom painters. Clipping to the visible rect reduces overdraw—painting pixels that are immediately covered or scrolled out of view—and unnecessary raster work, especially when your content is much larger than the screen. The trade-off is garbage-collection pressure. If you call intersect every frame inside paint, you create short-lived objects that the collector must handle. Cache the visible rect when the scroll offset has not changed, or compute it once per layout rather than once per paint.
A worked example: the scrolled slider handle
Consider a timeline widget inside a ListView. The painter draws a handle at local coordinates (100, 20) with size 40 × 40. A GestureDetector wraps the painter and reports taps.
// Local content bounds
final handleRect = Rect.fromLTWH(100, 20, 40, 40);
// BUG: details.globalPosition is in viewport coordinates
final bool hit = handleRect.contains(details.globalPosition);
// FIX: normalize the pointer to local space
final RenderBox box = context.findRenderObject() as RenderBox;
final Offset local = box.globalToLocal(details.globalPosition);
final bool hitFixed = handleRect.contains(local);
The same normalization applies to clipping. If the timeline is 4000 pixels wide and the viewport is 360 pixels, intersect the content rect with the visible slice before drawing.
final contentRect = Rect.fromLTWH(0, 0, 4000, 60);
final visibleRect = Rect.fromLTWH(scrollOffset, 0, size.width, size.height);
final clipRect = contentRect.intersect(visibleRect);
if (clipRect.isEmpty) return;
canvas.save();
canvas.clipRect(clipRect);
// draw ticks...
canvas.restore();
Limitations and a practical check
Rect is axis-aligned only. If your bounds are rotated or skewed, a Rect is the wrong representation; use a Path or a transform matrix instead.
To verify your setup, create two Rects with known coordinates in a unit test. Assert that contains returns true for an interior point and the expected result for points on each edge. Compute intersect for overlapping and non-overlapping pairs and check the resulting left, top, width, and height. Translate a Rect and confirm the original is unchanged while the new origin matches the offset. These checks take a few minutes and catch the most common coordinate-space errors before they reach a device.
Actionable takeaways
Name your variables by coordinate space: localHandleRect, viewportClipRect, globalBounds. The name is documentation. Cache stable bounds outside the paint loop. And when a hit test fails after scrolling, suspect the origin before you suspect the math.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.