DEV Community

XNeuronal
XNeuronal

Posted on

The checkbox Android refused to paint: SVG, overflow:hidden, elevation, and why we redrew it in Skia

A checkbox is the least interesting component in any app, right up to the moment it stops drawing. This is the story of one that stopped drawing on Android only, on a Tuesday in May, thirty minutes after we had made it prettier. It is a small bug, but the diff trail shows something I see a lot in React Native work: the fix that ships is not the same thing as the cause that was proven, and it is worth knowing the difference.

The app is a React Native 0.81 project on the New Architecture. The component is a tickable ingredient list inside a recipe card. Everything below is copied from the repository or from the exact commits, with the file path above each block.

The checkbox that worked

Before anyone touched it, the checkbox was a plain View with a border, and a Text glyph inside when checked. Nothing to see, nothing to break.

frontend/src/components/memocard/ChecklistSection.tsx, before commit f729381:

<View style={[styles.checkbox, checked && styles.checkboxDone]}>
  {checked ? <Text style={styles.checkmark}></Text> : null}
</View>
Enter fullscreen mode Exit fullscreen mode
checkbox: {
  width: 22,
  height: 22,
  borderRadius: 7,
  borderColor: '#cbd5e1',
  borderWidth: 1.5,
  alignItems: 'center',
  justifyContent: 'center'
},
checkboxDone: {
  borderColor: colors.primary,
  backgroundColor: colors.primary
},
Enter fullscreen mode Exit fullscreen mode

It rendered identically on iOS and Android. It was also flat blue, and the design mockup was not flat blue.

The checkbox that matched the mockup

The mockup asked for a 135 degree cyan to blue gradient fill, a soft blue halo underneath, and a white check on top. React Native views cannot paint a gradient by themselves, so the first instinct was react-native-svg, which was already a dependency for icons and the logo. Commit f729381 replaced the flat box with a wrapper View hosting an absolutely positioned Svg and the same Text glyph over it.

frontend/src/components/memocard/ChecklistSection.tsx at commit f729381:

function CheckboxBox({ checked }: { checked: boolean }): React.JSX.Element {
  if (!checked) {
    return <View style={styles.checkboxEmpty} />;
  }
  return (
    <View style={styles.checkboxFilled}>
      <Svg
        width={CHECKBOX_SIZE}
        height={CHECKBOX_SIZE}
        style={StyleSheet.absoluteFill}
      >
        <Defs>
          <LinearGradient
            id="checkboxGradient"
            x1={0}
            y1={0}
            x2={CHECKBOX_SIZE}
            y2={CHECKBOX_SIZE}
            gradientUnits="userSpaceOnUse"
          >
            <Stop offset="0" stopColor={colors.cyan} />
            <Stop offset="1" stopColor={colors.primary} />
          </LinearGradient>
        </Defs>
        <Rect
          x={0}
          y={0}
          width={CHECKBOX_SIZE}
          height={CHECKBOX_SIZE}
          rx={CHECKBOX_RADIUS}
          ry={CHECKBOX_RADIUS}
          fill="url(#checkboxGradient)"
        />
      </Svg>
      <Text style={styles.checkmark}></Text>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

And the wrapper style, which is where the trouble lives:

frontend/src/components/memocard/ChecklistSection.tsx at commit f729381:

/** Filled box hosts the SVG gradient layer + the checkmark on top. The
 *  drop shadow reads as the soft "pressed-in" cyan halo from the mockup
 *  (rgba(59,109,248,0.30) at offset 0/2 with 8 blur radius). */
checkboxFilled: {
  width: CHECKBOX_SIZE,
  height: CHECKBOX_SIZE,
  borderRadius: CHECKBOX_RADIUS,
  alignItems: 'center',
  justifyContent: 'center',
  overflow: 'hidden',
  shadowColor: colors.primary,
  shadowOffset: { width: 0, height: 2 },
  shadowOpacity: 0.30,
  shadowRadius: 8,
  elevation: 3
},
Enter fullscreen mode Exit fullscreen mode

Read that style as three separate requests to the platform. borderRadius plus overflow: 'hidden' asks the wrapper to clip its children to a rounded shape. shadow* asks iOS for a blurred halo. elevation asks Android for the same halo, except that on Android a halo is not a paint effect, it is a z-axis property that the compositor uses to compute an outline and draw a shadow behind it.

On iOS the result matched the mockup on the first build. On Android, a checked item showed the blue halo and a completely empty square. No gradient. No check.

What we assumed, and what the diff says

The first hypothesis was react-native-svg. The library mounts its own native view, and a native view inside a clipped, elevated parent is a well known way to get nothing drawn on Android. The commit message of the fix, d0e80fe, states it that way: SVG plus overflow: 'hidden' plus elevation is a bad combination, the inner Svg never rendered.

The diff tells a more careful story. Look at the JSX again. The wrapper had two children, the Svg and a plain Text with a Unicode check mark. The text is not SVG. It is a stock ReactTextView. And it disappeared too. Whatever swallowed the gradient also swallowed a native text node, which means the SVG library was, at best, a co-suspect. The common parent of both victims is the wrapper style: a rounded View that clips its children and carries an elevation.

This matters because it changes where the fix has to go. If the SVG were the problem, swapping the renderer would be enough. If the wrapper is the problem, swapping the renderer changes nothing and removing overflow: 'hidden' is the actual fix. The commit did both at once.

The Skia rewrite

The project already shipped @shopify/react-native-skia for the animated orb on the home screen, so a Skia canvas cost no new dependency. The rewrite paints the whole box, rounded rectangle, gradient and check stroke, into a single Canvas. One native surface, no view hierarchy to clip, no text glyph to align.

frontend/src/components/memocard/ChecklistSection.tsx, current:

function CheckboxBox({ checked }: { checked: boolean }): React.JSX.Element {
  // Built once and reused for every row : a stylised ✓ shape sized to
  // sit comfortably inside the 22 px box with a touch of padding.
  const checkPath = useMemo(() => {
    const p = Skia.Path.Make();
    p.moveTo(5.5, 11.5);
    p.lineTo(9.5, 15.5);
    p.lineTo(16.5, 7);
    return p;
  }, []);

  if (!checked) {
    return <View style={styles.checkboxEmpty} />;
  }

  return (
    <View style={styles.checkboxFilled}>
      <Canvas style={styles.checkboxCanvas}>
        <RoundedRect
          x={0}
          y={0}
          width={CHECKBOX_SIZE}
          height={CHECKBOX_SIZE}
          r={CHECKBOX_RADIUS}
        >
          <LinearGradient
            start={vec(0, 0)}
            end={vec(CHECKBOX_SIZE, CHECKBOX_SIZE)}
            colors={[colors.cyan, colors.primary]}
          />
        </RoundedRect>
        <Path
          path={checkPath}
          color="white"
          style="stroke"
          strokeWidth={2.2}
          strokeCap="round"
          strokeJoin="round"
        />
      </Canvas>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two details are doing real work here. The check is a Path stroked with round caps, not a font glyph, so it looks the same on every device regardless of which font Android picks for U+2713. And the rounded rectangle is drawn by Skia, so the wrapper no longer needs to clip anything. Which brings us to the style that actually changed.

frontend/src/components/memocard/ChecklistSection.tsx, current:

checkboxFilled: {
  width: CHECKBOX_SIZE,
  height: CHECKBOX_SIZE,
  borderRadius: CHECKBOX_RADIUS,
  shadowColor: colors.primary,
  shadowOffset: { width: 0, height: 2 },
  shadowOpacity: 0.30,
  shadowRadius: 8,
  elevation: 3
},
checkboxCanvas: {
  width: CHECKBOX_SIZE,
  height: CHECKBOX_SIZE
},
Enter fullscreen mode Exit fullscreen mode

overflow: 'hidden' is gone. alignItems and justifyContent are gone because the canvas fills the box exactly. The shadow stays on the outer View, which is where Android wants it: the elevated view owns a rounded outline, the compositor draws the halo from that outline, and nothing inside asks to be clipped.

The pattern generalises. On Android, keep three responsibilities on three different nodes: the node that carries elevation, the node that clips with overflow: 'hidden', and the node that paints content. Putting all three on one View is not guaranteed to fail, but when it fails it fails silently, with no warning in Logcat and no red box, and it only fails on one platform.

What it cost

Almost nothing in dependencies, since Skia was already linked. About fifty lines changed in one file. The visible cost is elsewhere: the app now carries two vector renderers. react-native-svg still draws a dozen components, icons, the logo, the memory constellation. Skia draws the orb, the speech halo, a decay ring in the messaging layer, and now this checkbox. Anyone opening the codebase has to know which one to reach for, and the honest answer is historical rather than principled.

There was one unexpected payoff. Months later we needed a different mark for lists that record what happened rather than what is left to do: a bare gradient check with no box, deliberately not looking tappable. With the path already in Skia, it was a fifteen line component that reuses the same gradient and the same footprint.

frontend/src/components/memocard/ChecklistSection.tsx, current:

function LogCheckMark(): React.JSX.Element {
  const checkPath = useMemo(() => {
    const p = Skia.Path.Make();
    p.moveTo(4.5, 12);
    p.lineTo(9, 16.5);
    p.lineTo(17.5, 6);
    return p;
  }, []);

  return (
    <Canvas style={styles.checkboxCanvas}>
      <Path
        path={checkPath}
        style="stroke"
        strokeWidth={2.6}
        strokeCap="round"
        strokeJoin="round"
      >
        <LinearGradient
          start={vec(0, 0)}
          end={vec(CHECKBOX_SIZE, CHECKBOX_SIZE)}
          colors={[colors.cyan, colors.primary]}
        />
      </Path>
    </Canvas>
  );
}
Enter fullscreen mode Exit fullscreen mode

A gradient stroke in react-native-svg is possible, but it means another Defs, another string id, and another url(#...) reference that has to be unique across every instance on screen. In Skia the gradient is a child of the shape. That is the kind of ergonomic difference that quietly decides which renderer a team ends up preferring.

What we would do differently

We never bisected. The fix removed overflow: 'hidden' and replaced the SVG in the same commit, so to this day the repository cannot say whether react-native-svg was guilty or merely present. The right sequence would have taken ten minutes: keep the SVG version, delete the single overflow: 'hidden' line, rebuild on Android, look. If the gradient appears, the SVG was innocent and the Skia rewrite is a taste decision rather than a bug fix. If it does not, then the library really does mishandle that parent and the rewrite was necessary. Either answer is more useful than a commit message that names three suspects and convicts them together.

The second thing is about the timeline of that afternoon. The gradient landed at 12:31. The Android blank box was fixed at 13:05. The rest of the day went to an inline editor for the same card, and two of those later commits fixed edits that silently failed to save because a TextInput was unmounted before its onBlur could fire. Different bug, same shape: a component torn down before it finished its job, on a code path that produced no error. When a React Native symptom is "nothing happened", the first question is now always "which node got removed, and by whom", before any library gets blamed.

The checkbox is 22 pixels wide. It took two commits, one honest mistake in attribution, and a second renderer to get it to draw on both platforms. It draws now, and the list it lives in is at xneuronal.com.

Top comments (0)