Fixing One-Pixel Edge Artifacts in Jetpack Compose drawBehind

Fixing One-Pixel Edge Artifacts in Jetpack Compose drawBehind

How one extra tab exposed a fragile rendering edge case in an inDrive design-system component — and how a small overscan made the result robust.A TabBar looked perfect with four tabs and broke with five. Here is how we traced a one-pixel seam to edge-aligned drawing and made the component robust. At inDrive, a design-system component is only finished when it behaves correctly in every supported state. While building a TabBar for our Android design system, we found a case that looked perfect with four tabs and failed with five: thin dark lines appeared on both outer edges of a white vertical gradient. The fix took only a few lines. Understanding why it worked was more valuable. Four tabs passed. Five tabs found the bug. The initial implementation The component needed a gradient behind its content. We used Modifier.drawBehind because it gives direct access to the component’s DrawScope and lets us paint across the full drawing area. A regular background modifier did not fit the modifier order and internal padding used by this component. Column( modifier = modifier .drawBehind { drawRect( brush = Brush.verticalGradient( colors = colors, ), ) }, ) { // TabBar content } The TabBar uses weighted rows to distribute its items. Adding a fifth tab changes the width allocated to every item and also changes the geometry used during animation. In that configuration, a dark seam became visible on both the left and right edges of the component. Figure 1. With five tabs, dark edge lines appear on both sides Why a one-pixel seam can appear When drawRect is called without an explicit size, Compose uses the size of the current drawing scope: fun drawRect( brush: Brush, topLeft: Offset = Offset.Zero, size: Size = this.size.offsetSize(topLeft), // ... ) Compose drawing APIs work in pixels and represent drawing coordinates with Float values. The Size type itself stores floating-point width and height values: value class Size(val packedValue: Long) { val width: Float get() = unpackFloat1(packedValue) val height: Float get() = unpackFloat2(packedValue) } Layout measurement often starts from whole pixels, but animations, transforms, interpolation, and geometry calculations can still place visual edges on fractional pixel boundaries. If a rectangle is drawn exactly to the visible boundary, rasterization and clipping may leave the final column of pixels only partially covered. The underlying background can then appear as a thin line. This does not mean that floating-point arithmetic or drawBehind is generally broken. It is a fragile edge condition that became visible only after the fifth tab changed the component’s geometry. The fix: overscan, then clip The gradient changes only along the vertical axis. Extending it slightly beyond the left and right edges therefore has no visual cost. We can draw a small horizontal overscan and clip the result to the component bounds: Column( modifier = modifier .clipToBounds() .drawBehind { val overscan = 1.dp.toPx() drawRect( brush = Brush.verticalGradient( colors = colors, ), topLeft = Offset( x = -overscan, y = 0f, ), size = Size( width = size.width + overscan * 2, height = size.height, ), ) }, ) { // TabBar content } The extra pixels absorb small edge-alignment errors, while clipping keeps the final output inside the component. The visual result stays the same, but the drawing is no longer dependent on perfect coverage at the exact boundary. Figure 2. After adding horizontal overscan, the five-tab state remains clean A production note on caching If the brush or other drawing objects are recreated frequently, drawWithCache can cache them until the drawing area or observed state changes. For a simple component, the difference may be small. However, the cached version is worth considering when the draw block allocates objects on every frame. What this taught us Test the smallest and largest supported states. The default state is rarely enough for reusable UI components. Include awkward geometry. Odd screen widths, different densities, weighted children, and animated transitions can reveal seams that never appear in static previews. Build a complete design-system showroom. Every state, item count, theme, direction, and interaction should be easy to inspect before release. Prefer robust visual bounds. For backgrounds that are invariant along one axis, a small overscan combined with clipping is often safer than drawing exactly to the edge. Treat platform abstractions as implementations, not magic. Reading the source is often the fastest way to replace assumptions with evidence. The wider engineering lesson The scale is obviously incomparable, but the same category of lesson appears in much larger systems. The Ariane 501 investigation traced the launch failure to specification and software-design errors in an inertial reference system, combined with testing that did not adequately represent the real flight domain. Small assumptions become dangerous when the operating range changes. In our case, the consequence was two dark pixels — not a failed launch. But the habit is the same: test the real range of states, question hidden assumptions, and make visual correctness resilient rather than accidental. Pixel-perfect is not a screenshot. It is a component that remains correct when its inputs change. Conclusion A fifth tab exposed a one-pixel rendering seam that the default component state had hidden. The solution was straightforward: extend a vertically invariant gradient slightly beyond the horizontal bounds and clip it back to the component. The more important outcome was a stronger testing rule for our design system: every reusable component must be reviewed across its full state space, including the combinations that seem least likely to fail. That is how we keep UI quality predictable at inDrive. References Android Developers — Graphics modifiers in Jetpack Composehttps://developer.android.com/develop/ui/compose/graphics/draw/modifiers Android Developers — Graphics in Compose and DrawScopehttps://developer.android.com/develop/ui/compose/graphics/draw/overview Android Developers — DrawScope API referencehttps://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope Android Open Source Project — Size.kthttps://android.googlesource.com/platform/frameworks/support/+/f2e05c341382db64d127118a13451dcaa554b702/compose/ui/ui-geometry/src/commonMain/kotlin/androidx/compose/ui/geometry/Size.kt European Space Agency — Ariane 501 Board of Inquiry reporthttps://sci.esa.int/s/wRdpjv8

Original Source

Read the full article at Hackernoon →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.