Merging Dirty Rectangles with Rect.union: A Practical Guide for Android Custom Views
Learn how to combine clipping regions in Android custom views using Rect.union, with a worked example and the overflow and normalization risks to watch for.
19 May 2026, 14:31 UTC

When you build a custom View and track which pixels changed between frames, you usually end up with a list of small rectangles—one for each sprite that moved, one for each line of text that scrolled. Calling invalidate() on the whole view is easy, but on a large canvas it forces the system to redraw pixels that did not change. The useful takeaway: android.graphics.Rect.union(Rect other) computes the smallest axis-aligned rectangle that covers both regions in constant time, so you can invalidate exactly what changed.
The cost of over-invalidating a custom view
Android's rendering pipeline clips drawing to the dirty region you pass to invalidate(Rect). If you have two separate dirty regions and call invalidate() twice, the framework may schedule two draw passes. Sometimes that is fine. But when you need a single bounding rectangle—for example, to update a cached bitmap layer or to log the total affected area—you must merge the regions yourself.
Merging by hand is easy to get wrong. You might accidentally include an extra pixel on the right or bottom edge, or forget to handle the case where one rectangle is completely inside the other. Rect.union handles both cases correctly and runs in O(1) time with no loops.
How Rect.union computes the enclosing rectangle
Rect stores four integers: left, top, right, and bottom. The right and bottom edges are exclusive, meaning a Rect(10, 10, 50, 50) covers the pixels where x is 10 through 49 and y is 10 through 49. The pixel at (50, 50) is outside the rectangle.
The union(Rect other) method returns a new Rect whose left is the minimum of the two lefts, whose top is the minimum of the two tops, whose right is the maximum of the two rights, and whose bottom is the maximum of the two bottoms. Because it only compares four pairs of integers, it is effectively free even inside a high-frequency UI loop.
One important detail: the method does not modify the receiver. It allocates a fresh instance every time. If you are merging many rectangles in a tight loop, that allocation can create garbage-collection pressure.
A worked example
Suppose two dirty regions appear after a frame: one at Rect(10, 10, 50, 50) and another at Rect(30, 30, 80, 80). Their union should cover both, giving Rect(10, 10, 80, 80). You can verify this behavior with a local unit test in Android Studio.
Place the following test in your module's test source set, for example app/src/test/java/com/example/myapp/RectUnionTest.kt:
import android.graphics.Rect
import org.junit.Test
import org.junit.Assert.assertEquals
class RectUnionTest {
@Test
fun unionMergesDirtyRegions() {
val first = Rect(10, 10, 50, 50)
val second = Rect(30, 30, 80, 80)
val merged = first.union(second)
assertEquals(Rect(10, 10, 80, 80), merged)
}
}
Run it from the terminal in your project root with ./gradlew testDebugUnitTest (replace testDebugUnitTest with the variant name that matches your module). If you run this as a local JVM test, your build.gradle may need testOptions.unitTests.returnDefaultValues = true; otherwise run it as an instrumented test on an emulator or device with API level 21 or higher. The expected check is a green test. If the assertion fails, the most likely cause is that one of the input rectangles was not normalized—see the next section.
If you want to confirm that union is a simple inline method without hidden allocations, open the generated APK in Android Studio's APK Analyzer and inspect the bytecode for the calling class. You should see the four integer comparisons and a new Rect allocation, and nothing else.
Limits and safer patterns
Rect.union is simple, but it is not magic. Two constraints matter in production code.
Overflow. The method compares and combines raw integers. If your coordinates approach Integer.MAX_VALUE, the arithmetic inside the framework can overflow and produce nonsensical edges. When you work with very large canvases or unbounded scroll offsets, clamp values to a safe range before calling union.
Normalization. A Rect is valid only when left <= right and top <= bottom. If you pass an inverted rectangle—say Rect(50, 50, 10, 10)—the union result may also be invalid, with left greater than right. Always normalize dirty regions before merging them.
Allocation. As noted above, union(Rect) returns a new object. Android also provides an in-place overload, union(int left, int top, int right, int bottom), which updates the receiver instead of allocating. When you maintain a running dirty region across many small updates, prefer the coordinate overload:
// Kotlin
val dirty = Rect(first)
dirty.union(second.left, second.top, second.right, second.bottom)
// dirty now encloses both regions without a second allocation
Actionable takeaway
The next time you track dirty regions in a custom view, collapse them with Rect.union before calling invalidate. Normalize your inputs, clamp coordinates if your canvas is huge, and choose the coordinate overload when allocation matters. A quick unit test like the one above will confirm the merge behaves as expected on API level 21 and above.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.