Why Flutter Text Gets Clipped Under Opacity/ShaderMask? Deep Dive into Skia Bounds Bug
The article explains a long‑standing Flutter bug where Text wrapped by Opacity or ShaderMask loses its descender glyphs on Android due to overly precise layer bounds calculated by Skia/FreeType, detailing how saveLayer, hinting, and glyph outline bounds interact, and offers practical workarounds to avoid the clipping.
Problem Overview
Flutter has a long‑standing font rendering issue that resurfaces in issue #161721: when a Text widget is wrapped by widgets that require a saveLayer operation, such as Opacity or ShaderMask, the bounds of the intermediate layer are calculated too tightly. This causes the lower part of glyphs with descenders (e.g., the tail of the letter g) to be clipped.
Reproduction Details
The bug is reproducible on Android (Pixel 7/8, API 34, etc.) when the font size is 47. Under these conditions, any Text containing a descender that is wrapped by Opacity or ShaderMask will have its lower part cut off.
Android devices with fontSize = 47 and Opacity / ShaderMask always clip the glyph.
Increasing the font size to 48 or larger makes the clipping disappear.
Similar behavior is observed with variable fonts on Android/Web.
The issue resembles the older #96322 bug where desktop/web text was clipped during ellipsis.
Technical Root Cause
The clipping originates from the way Skia/FreeType compute glyph bounds. When Opacity (or ShaderMask) is used, the child is painted to an intermediate buffer via saveLayer(bounds, paint). The bounds supplied are the layer’s clipping bounds. If these bounds are derived only from layout dimensions and line height, they ignore the occasional sub‑pixel overflow caused by font hinting, rounding, or glyph metrics, leading to a 1 px shortfall that the layer clips.
Specifically: Opacity paints the child to a buffer when its opacity is not exactly 0 or 1. The saveLayer call uses the calculated bounds as the clipping rectangle. ShaderMask also creates a layer for the child before applying the shader, so it suffers from the same “bounds too small” problem.
For glyphs like g that have a descender extending below the baseline, three factors contribute to the 1 px overflow:
Font hinting and rasterisation cause pixel‑level alignment rounding.
Line‑height metrics (ascent/descent/leading) do not perfectly match the actual pixel coverage of the glyph.
Floating‑point to integer conversion (ceil/floor) can differ by one pixel.
When the overflow is only a pixel, the layer’s clip discards it, making the glyph appear truncated. At font sizes ≥ 48 the rounding tends to align correctly, so the problem seems to disappear.
The core of the issue lies in SkTextBlob::bounds(), which obtains its bounds from SkScalerContext_FreeType::getBoundsOfCurrentOutlineGlyph. Skia maintains two kinds of bounds:
tight bounds : as small as possible, may cause clipping.
conservative bounds : larger, less likely to clip.
In this case both bound types evaluate to the same rectangle, confirming that the underlying glyph outline bounds from FreeType are already too small when hinting is enabled.
Hinting aligns glyph strokes to the pixel grid, which can produce a 1 px overshoot beyond the outline bounds, especially for descenders. Disabling hinting (forcing TextStyle::getFontHinting() to return SkFontHinting::kNone) makes the outline bounds and actual pixel coverage match, eliminating the clipping.
Impact of Impeller
Although Flutter now uses the Impeller GPU rendering backend, text rendering still relies on SkParagraph, which in turn depends on Skia’s rasterisation pipeline. Therefore the bug remains rooted in Skia, not Impeller.
Workarounds
Avoid wrapping Text with Opacity; instead apply transparency via TextStyle.color.
Do not use ShaderMask for text; use TextStyle.foreground combined with a custom shader applied directly to the paint.
When Opacity or ShaderMask is unavoidable, add 1–2 px padding to the Text so the layer bounds become larger.
Optionally switch the variable font to a static weight (e.g., set FontWeight.xxx) to reduce hinting effects.
Conclusion
The clipping bug is caused by Skia’s use of glyph outline bounds from FreeType, which become inaccurate when font hinting is active. The mismatch between calculated bounds and actual pixel coverage leads Opacity and ShaderMask layers to clip descenders. Until Skia adjusts its bound calculation or provides a toggle, developers should apply the above workarounds to avoid the issue.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final style = TextStyle(fontSize: 47);
return MaterialApp(
home: Scaffold(
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
colors: [Colors.black, Colors.red],
).createShader(bounds);
},
blendMode: BlendMode.srcIn,
child: Text("g", style: style),
),
Opacity(
opacity: 0.9,
child: Text("g", style: style),
),
Text("g", style: style)
],
),
),
),
);
}
}Sohu Tech Products
A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
