Clip Behavior
Summary
Flutter now defaults to not clip except for a few specialized widgets
(such as ClipRect
). To override the no-clip default,
explicitly set clipBehavior
in widgets constructions.
Context
Flutter used to be slow because of clips. For example, the Flutter gallery app benchmark had an average frame rasterization time of about 35ms in May 2018, where the budget for smooth 60fps rendering is 16ms. By removing unnecessary clips and their related operations, we saw an almost 2x speedup from 35ms/frame to 17.5ms/frame.
The biggest cost associated with clipping at that time is that Flutter
used to add a saveLayer
call after each clip (unless it was a simple
axis-aligned rectangle clip) to avoid the bleeding edge artifacts
as described in Issue 18057. Such behaviors were universal to
material apps through widgets like Card
, Chip
, Button
, and so on,
which resulted in PhysicalShape
and PhysicalModel
clipping their content.
A saveLayer
call is especially expensive in older devices because
it creates an offscreen render target, and a render target switch
can sometimes cost about 1ms.
Even without saveLayer
call, a clip is still expensive
because it applies to all subsequent draws until it’s restored.
Therefore a single clip may slow down the performance on
hundreds of draw operations.
In addition to performance issues, Flutter also suffered from
some correctness issues as the clip was not managed and implemented
in a single place. In several places, saveLayer
was inserted
in the wrong place and it therefore only increased the performance
cost without fixing any bleeding edge artifacts.
So, we unified the clipBehavior
control and its implementation in
this breaking change. The default clipBehavior
is Clip.none
for most widgets to save performance, except the following:
-
ClipPath
defaults toClip.antiAlias
-
ClipRRect
defaults toClip.antiAlias
-
ClipRect
defaults toClip.hardEdge
-
Stack
defaults toClip.hardEdge
-
EditableText
defaults toClip.hardEdge
-
ListWheelScrollView
defaults toClip.hardEdge
-
SingleChildScrollView
defaults toClip.hardEdge
-
NestedScrollView
defaults toClip.hardEdge
-
ShrinkWrappingViewport
defaults toClip.hardEdge
Migration guide
You have 4 choices for migrating your code:
- Leave your code as is if your content does not need to be clipped (for example, none of the widgets’ children expand outside their parent’s boundary). This will likely have a positive impact on your app’s overall performance.
- Add
clipBehavior: Clip.hardEdge
if you need clipping, and clipping without anti-alias is good enough for your (and your clients’) eyes. This is the common case when you clip rectangles or shapes with very small curved areas (such as the corners of rounded rectangles). - Add
clipBehavior: Clip.antiAlias
if you need anti-aliased clipping. This gives you smoother edges at a slightly higher cost. This is the common case when dealing with circles and arcs. - Add
clip.antiAliasWithSaveLayer
if you want the exact same behavior as before (May 2018). Be aware that it’s very costly in performance. This is likely to be only rarely needed. One case where you might need this is if you have an image overlaid on a very different background color. In these cases, consider whether you can avoid overlapping multiple colors in one spot (for example, by having the background color only present where the image is absent).
For the Stack
widget specifically, if you previously used
overflow: Overflow.visible
, replace it with clipBehavior: Clip.none
.
For the ListWheelViewport
widget, if you previously specified
clipToSize
, replace it with the corresponding clipBehavior
:
Clip.none
for clipToSize = false
and
Clip.hardEdge
for clipToSize = true
.
Code before migration:
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Stack(
overflow: Overflow.visible,
children: const <Widget>[
SizedBox(
width: 100.0,
height: 100.0,
),
],
),
),
),
);
Code after migration:
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: Stack(
clipBehavior: Clip.none,
children: const <Widget>[
SizedBox(
width: 100.0,
height: 100.0,
),
],
),
),
),
);
Timeline
Landed in version: various
In stable release: 2.0.0
References
API documentation:
Relevant issues:
Relevant PRs:
- PR 5420: Remove unnecessary saveLayer
- PR 18576: Add Clip enum to Material and related widgets
- PR 18616: Remove saveLayer after clip from dart
- PR 5647: Add ClipMode to ClipPath/ClipRRect and PhysicalShape layers
- PR 5670: Add anti-alias switch to canvas clip calls
- PR 5853: Rename clip mode to clip behavior
- PR 5868: Rename clip to clipBehavior in compositing.dart
- PR 5973: Call drawPaint instead of drawPath if there’s clip
- PR 5952: Call drawPath without clip if possible
- PR 20205: Set default clipBehavior to Clip.none and update tests
- PR 20538: Expose clipBehavior to more Material Buttons
- PR 20751: Add customBorder to InkWell so it can clip ShapeBorder
- PR 20752: Set the default clip to Clip.none again
- PR 21012: Add default-no-clip tests to more buttons
- PR 21703: Default clipBehavior of ClipRect to hardEdge
- PR 21826: Missing default hardEdge clip for ClipRectLayer