Skip to main content

10 Flutter Widgets Probably Haven’t Heard Of (But Should Be Using!)

· 35 min read
Majid Hajian
Developer Advocate

Cover

It’s been over 6 years since I started developing with Flutter and Dart, and yet I still come across features or widgets that I didn’t know existed. It’s fascinating how a framework you work with daily can still surprise you with its depth and versatility.

In this article, I want to share some of those hidden gems I’ve discovered lesser-known widgets and functionalities that can simplify your development process and add a unique touch to your apps. Moreover, we will go deeper into the implementation and explore how they are implemented to learn more about the patterns.

Let’s uncover some of these widgets together!

Imagine you’re building a feature where certain items in your app need a bold visual indicator, for example, a "SALE," "NEW," or "FEATURED" tag that calls for attention. You could go the custom route, but Flutter offers a much simpler and easier-to-use widget: the Banner widget.

Banner

This looks familiar, doesn’t it?

You are right! CheckedModeBanner uses this widget to display a banner saying "DEBUG" when running in debug mode, and MaterialApp builds one by default.

class CheckedModeBanner extends StatelessWidget {
const CheckedModeBanner({super.key, required this.child});

final Widget child;


Widget build(BuildContext context) {
Widget result = child;
assert(() {
result = Banner( // <--- That's the Banner widget!
message: 'DEBUG',
textDirection: TextDirection.ltr,
location: BannerLocation.topEnd,
child: result,
);
return true;
}());
return result;
}


void debugFillProperties(DiagnosticPropertiesBuilder properties) {
// LOGIC
}
}

Under the hood, the Banner widget relies on CustomPaint for rendering. The CustomPainter within the widget draws a diagonal stripe using a Path.

Let’s take a closer look:

class _BannerState extends State<Banner> {
BannerPainter? _painter;
// ...

Widget build(BuildContext context) {
// ...
_painter?.dispose();
_painter = BannerPainter(
message: widget.message,
textDirection: widget.textDirection ?? Directionality.of(context),
location: widget.location,
layoutDirection: widget.layoutDirection ?? Directionality.of(context),
color: widget.color,
textStyle: widget.textStyle,
shadow: widget.shadow,
);

return CustomPaint(foregroundPainter: _painter, child: widget.child);
}
// ...
}

The CustomPaint widget here renders the banner and delegates the drawing logic to the BannerPainter class. This painter uses Flutter's low-level canvas APIs to handle both the diagonal stripe and the accompanying text. The painter class goes further with its paint method, which orchestrates everything from rotations to text layout:

class BannerPainter extends CustomPainter {
// ...

void paint(Canvas canvas, Size size) {
// ...
canvas
..translate(_translationX(size.width), _translationY(size.height))
..rotate(_rotation)
..drawRect(_kRect, _paintShadow)
..drawRect(_kRect, _paintBanner);
const double width = _kOffset * 2.0;
_textPainter!.layout(minWidth: width, maxWidth: width);
_textPainter!.paint(
canvas,
_kRect.topLeft + Offset(
0.0, (_kRect.height - _textPainter!.height) / 2.0,
),
);
}
// ...
}

The Banner widget’s API is simple as the below:

  • message: Whether it’s "BETA", "SALE", or "NEW", this property defines the purpose of the banner.
  • location: With values like topStart, topEnd, bottomStart, and bottomEnd, the banner’s position is fully customizable.
enum BannerLocation {
topStart,
topEnd,
bottomStart,
bottomEnd,
}
  • color: This property sets the background color, ensuring the banner stands out without clashing with the underlying widget.
  • textStyle: A companion to the message, it allows you to fine-tune the appearance of the text, from font size to color to weight.
  • child: Perhaps the most crucial property, the child, represents the widget being wrapped by the banner. It could be anything—a card, a container, or even another complex widget.

Let’s now take a look at a full example:

Banner(
message: 'BETA',
location: BannerLocation.bottomStart,
color: Colors.green,
textStyle: TextStyle(
fontSize: 12,
color: Colors.white,
),
child: Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('New Feature: Try Now!'),
),
),
);

This widget was generally simple, but it has a lot to offer when you look at the source code, especially in terms of learning how to use Custom Painter.

Alright now let’s take a look at another widget, that you may have not heard.

MetaData Widget

The MetaData widget in Flutter is one of those widgets that often go unnoticed.

At first glance, it may not seem like much—it doesn’t render anything visible on the screen, nor does it directly modify the behavior of its child. However, MetaData attaches custom metadata to a widget subtree. This means that as Flutter developers, we can define additional semantic or structural information.

Consider the following example:

MetaData(
metaData: 'Custom tag for this widget',
child: Container(
height: 100,
width: 100,
color: Colors.blue,
),
);

Here, the MetaData property allows you to tag the widget subtree with any arbitrary information. This information doesn’t affect rendering or interaction but can be retrieved and processed by external systems. Imagine using this metadata during automated testing to identify widgets by custom metadata or attaching descriptions for custom analytics.

Let’s get a bit deeper and answer which scenarios this can be helpful in.

MetaData becomes apparent in the accessibility scenarios. While Flutter provides rich built-in accessibility support through widgets like Semantics, there are times when you need to add custom annotations. Using MetaData, you can tag widgets with additional information that external tools can interpret.

MetaData(
metaData: {'role': 'button', 'label': 'Custom Button'},
behavior: HitTestBehavior.translucent,
child: GestureDetector(
onTap: () => print('Button tapped'),
child: Container(
height: 50,
width: 150,
color: Colors.green,
child: Center(child: Text('Click Me')),
),
),
);

In this example, the MetaData field holds a map containing custom roles and labels for the widget.

The MetaData widget also plays a role in debugging and developer tools. For instance, if you’re working on a large and complex UI hierarchy, you can use metadata to tag specific sections of the tree. This tagging can help you trace widget interactions or state changes during runtime.

Another effective feature of MetaData lies in the behavior property. This property defines how the widget participates in hit testing. By default, the MetaData widget doesn’t interfere with hit tests, but you can configure it using one of the values from HitTestBehavior:

  • opaque: The widget captures all pointer events, even if the child doesn’t.
  • translucent: Pointer events pass through to widgets below the child.
  • deferToChild: Only the child handles pointer events.

For example, consider a scenario where you want to add metadata to an invisible widget that still participates in hit testing:

MetaData(
metaData: 'Invisible button',
behavior: HitTestBehavior.opaque,
child: Container(
height: 100,
width: 100,
color: Colors.transparent,
),
);

Here, the container is invisible but still captures pointer events, thanks to the HitTestBehavior.opaque configuration. This can be useful in cases where you need a tappable region that doesn’t have a visual representation.

Now, let’s look deeper at the source code. The MetaData widget itself is a straightforward wrapper:

class MetaData extends SingleChildRenderObjectWidget {
const MetaData({
super.key,
required this.metaData,
this.behavior = HitTestBehavior.deferToChild,
super.child,
});

final Object? metaData;
final HitTestBehavior behavior;


RenderMetaData createRenderObject(BuildContext context) {
return RenderMetaData(
metaData: metaData,
behavior: behavior,
);
}


void updateRenderObject(
BuildContext context, RenderMetaData renderObject) {
renderObject
..metaData = metaData
..behavior = behavior;
}
}

The widget itself doesn’t do much—it’s primarily a line to pass metadata to the RenderMetaData object. The real work happens at the rendering layer:

class RenderMetaData extends RenderProxyBox {
RenderMetaData({this.metaData, HitTestBehavior? behavior})
: _behavior = behavior ?? HitTestBehavior.deferToChild;

Object? metaData;

HitTestBehavior _behavior;


bool hitTest(BoxHitTestResult result, {required Offset position}) {
switch (_behavior) {
case HitTestBehavior.opaque:
return super.hitTest(result, position: position) || true;
case HitTestBehavior.translucent:
final hitTarget = super.hitTest(result, position: position);
return hitTarget || true;
case HitTestBehavior.deferToChild:
return super.hitTest(result, position: position);
}
}
}

The RenderMetaData class overrides the hitTest method to handle pointer events based on the specified behavior.

Alright, let’s talk about another widget now that you may have heard and seen it. I have also talked about it in the past, but there might be a chance that you still are not using it properly.

RepaintBoundary Widget

The RepaintBoundary widget's purpose is to limit the area of the screen that needs to be redrawn when visual updates occur by reducing unnecessary repaints, which drastically improves an app's performance, especially in scenarios with complex or frequently changing UI elements.

The RepaintBoundary widget works by creating a new compositing layer in Flutter’s rendering pipeline. Layers are an essential part of Flutter’s rendering model, allowing portions of the scene to be drawn independently. I talked about rendering pipe in Flutter quite extensively in my book FlutterEngineering.io.

When a RepaintBoundary is added to a widget, that widget and its subtree are isolated into their own compositing layer. This ensures that only that layer needs to be redrawn when a visual update happens within the subtree, not the entire screen.

RepaintBoundary

Here’s an example of how RepaintBoundary can be used:

RepaintBoundary(
child: Container(
width: 100,
height: 100,
color: Colors.blue,
child: Text('Optimized Painting'),
),
);

Let’s dig deeper into why RepaintBoundary is necessary and how it works under the hood!

Without it, Flutter’s rendering engine propagates visual updates upward through the widget tree, marking all ancestor widgets as dirty. This cascading effect can result in large portions of the screen being unnecessarily redrawn, even if the change is confined to a small area.

The RepaintBoundary is implemented at the rendering layer as a RenderRepaintBoundary object. This class overrides the paint method to control how the widget is drawn and when it needs to be repainted:

class RenderRepaintBoundary extends RenderProxyBox {

void paint(PaintingContext context, Offset offset) {
if (!isRepaintBoundary) {
super.paint(context, offset);
return;
}

final bool needsRepaint = _repaint;
if (needsRepaint) {
// Repainting logic
layer = context.pushLayer(
OffsetLayer(offset: offset),
super.paint,
offset,
oldLayer: layer,
);
} else {
context.paintChild(child!, offset);
}
}
}

The isRepaintBoundary flag determines whether this render object creates a compositing layer. If true, the render object isolates its subtree, preventing unnecessary repaints of its ancestors. The _repaint flag tracks whether the subtree needs to be redrawn, further optimizing performance by avoiding redundant work.

Flutter provides a debugging tool to visualize the impact of RepaintBoundary. By enabling the "Show Performance Overlay" option in the Flutter DevTools or adding debugRepaintRainbowEnabled = true; in your app, you can see the repaint boundaries as colored outlines on the screen. You can also add showPerformanceOverlay: true to MaterialApp to see the overall performance.

void main() {
debugRepaintRainbowEnabled = true;
runApp(
MaterialApp(
showPerformanceOverlay: true,
home: RepaintBoundaryExample(),
),
);
}

Areas within these boundaries are repainted independently, providing a clear view of how RepaintBoundary reduces the scope of visual updates.

Repaint

For example, if you have a scrolling list with images, wrapping each image in a RepaintBoundary ensures that scrolling doesn’t trigger unnecessary repaints of the images. This can greatly enhance performance in apps with complex layouts or animations.

Another practical use case is using RepaintBoundary with animations:

RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * pi,
child: child,
);
},
child: Image.asset('assets/image.png'),
),
);

By wrapping the animated widget in a RepaintBoundary, you ensure that only the animation is redrawn during each frame, leaving the rest of the screen unaffected.

Now, let me give you a full example of custom painting.

import 'package:flutter/material.dart';
import 'dart:math';

import 'package:flutter/rendering.dart';

class HeavyPainter extends CustomPainter {

void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;

// Draw a complex pattern with many circles
final random = Random();
for (int i = 0; i < 1000; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = random.nextDouble() * 10 + 5;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}


bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

class RepaintBoundaryExample extends StatefulWidget {

_RepaintBoundaryExampleState createState() => _RepaintBoundaryExampleState();
}

class _RepaintBoundaryExampleState extends State<RepaintBoundaryExample> {
bool _useRepaintBoundary = true;


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('RepaintBoundary Toggle Example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Toggle RepaintBoundary mode
ElevatedButton(
onPressed: () {
setState(() {
_useRepaintBoundary = !_useRepaintBoundary;
});
},
child: Text(
_useRepaintBoundary
? 'Disable RepaintBoundary'
: 'Enable RepaintBoundary',
),
),
const SizedBox(height: 20),
// Centered container for the painting logic
Container(
width: 300,
height: 300,
child: _useRepaintBoundary
? RepaintBoundary(
child: CustomPaint(
painter: HeavyPainter(),
),
)
: CustomPaint(
painter: HeavyPainter(),
),
),
const SizedBox(height: 20),
// An unrelated widget that causes frequent rebuilds
AnimatedCounter(),
],
),
),
);
}
}

class AnimatedCounter extends StatefulWidget {

_AnimatedCounterState createState() => _AnimatedCounterState();
}

class _AnimatedCounterState extends State<AnimatedCounter> {
int _count = 0;


Widget build(BuildContext context) {
return Column(
children: [
ElevatedButton(
onPressed: () {
setState(() {
_count++;
});
},
child: Text('Increment Counter'),
),
Text('Counter: $_count'),
],
);
}
}

void main() {
debugRepaintRainbowEnabled = true;
runApp(
MaterialApp(
showPerformanceOverlay: true,
home: RepaintBoundaryExample(),
),
);
}

And that’s how it will work.

Enjoying this article?

Subscribe to get our latest articles and product updates by email.

IntrinsicHeight Widget

The IntrinsicHeight widget’s purpose lies in resolving situations where varying heights might create visual imbalance or inconsistencies in your user interface.

The IntrinsicHeight widget may seem like a niche widget, but its value becomes obvious when working with dynamic layouts where maintaining proportionality is essential. Let me give you an example: imagine a row of widgets that contain text, icons, or other content of varying lengths. Without intervention, each child's widget’s height would be determined independently, potentially leading to misalignment.

Consider the code below:

IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 100,
color: Colors.blue,
child: Text('Short text'),
),
Container(
width: 100,
color: Colors.red,
child: Text('This is a much longer piece of text'),
),
],
),
);

In this example, the IntrinsicHeight widget ensures that both containers in the row stretch to the same height, matching the tallest child. This effect is achieved by computing the intrinsic height of each child widget and aligning them accordingly. Without IntrinsicHeight, the heights would be determined independently, leading to a visually unbalanced layout.

Intrinsic height

Let’s delve deeper into how IntrinsicHeight works under the hood.

It achieves its functionality by deferring the height calculation to its children. Each child widget reports its preferred intrinsic height and the tallest child dictates the height of the entire group.

The implementation involves the RenderIntrinsicHeight class, which measures and lays out the widget subtree.

class RenderIntrinsicHeight extends RenderBox {

void performLayout() {
double maxHeight = 0.0;
for (final RenderBox child in _children) {
child.layout(constraints, parentUsesSize: true);
maxHeight = math.max(
maxHeight, child.getMaxIntrinsicHeight(double.infinity),
);
}
size = constraints.constrain(Size(constraints.maxWidth, maxHeight));
for (final RenderBox child in _children) {
child.layout(BoxConstraints.tightFor(height: maxHeight));
}
}
}

This logic highlights two key steps:

  • Each child widget is asked for its intrinsic height using the getMaxIntrinsicHeight method.
  • The tallest height is applied uniformly to all children.

While the IntrinsicHeight widget is a handy widget, it’s important to avoid abusing it. Computing intrinsic dimensions can be expensive in terms of performance, especially when dealing with deeply nested or complex widget trees. Flutter’s rendering pipeline is optimized for constraints-based layouts, and relying on intrinsic dimensions can disrupt this optimization.

For example, wrapping an entire layout in IntrinsicHeight might lead to performance bottlenecks:

IntrinsicHeight(
child: Column(
children: List.generate(
100,
(index) => Container(
height: index % 2 == 0 ? 50 : 100,
color: index % 2 == 0 ? Colors.blue : Colors.red,
),
),
),
);

In this scenario, the IntrinsicHeight widget would need to calculate the intrinsic height of each container in the column, leading to a significant performance overhead. It’s best to restrict its usage to smaller, specific parts of the layout where the visual benefits outweigh the performance cost.

Performance Intrinsic Height

IntrinsicWidth Widget

The IntrinsicWidth widget in Flutter sizes its child to the child’s maximum intrinsic width. It is particularly useful in scenarios where unlimited width is available, and a child widget would otherwise expand infinitely. Instead of stretching, the child is constrained to its intrinsic width, creating a more suitable layout.

Additionally, wrapping a Column in an IntrinsicWidth ensures all its children are as wide as the widest child, enabling consistent alignment.

intrinsic width column

If I want to mention the key features and behavior of this widget, I can categorize them into two:

  • The constraints passed by IntrinsicWidth to its child adhere to the parent’s constraints. If the constraints are too small to satisfy the child’s maximum intrinsic width, the child will receive less width. Conversely, if the minimum width exceeds the child’s maximum intrinsic width, the child will be forced to expand.
  • Optional parameters like stepWidth and stepHeight snap the child's width and height to multiples of these values, enabling fine-tuned control over layout alignment.
IntrinsicWidth(
child: Row(
children: [
Container(
height: 50,
color: Colors.blue,
child: Text('Short'),
),
Container(
height: 50,
color: Colors.red,
child: Text('Much longer text'),
),
],
),
);

Here, the IntrinsicWidth widget ensures that the Row assigns widths to its children based on their intrinsic dimensions.

intrinsic width

The IntrinsicWidth calculates the intrinsic dimensions of its child or children and applies the largest width as a uniform constraint.

This logic is implemented in the RenderIntrinsicWidth class. Below is a simplified version of its layout logic:

class RenderIntrinsicWidth extends RenderBox {

void performLayout() {
double maxIntrinsicWidth = 0.0;

for (final RenderBox child in _children) {
final double childIntrinsicWidth = child.getMaxIntrinsicWidth(double.infinity);
maxIntrinsicWidth = math.max(maxIntrinsicWidth, childIntrinsicWidth);
}

size = constraints.constrain(Size(maxIntrinsicWidth, constraints.maxHeight));

for (final RenderBox child in _children) {
child.layout(BoxConstraints.tightFor(width: maxIntrinsicWidth));
}
}
}

Using IntrinsicWidth comes with a performance cost because it requires a speculative layout pass before the final layout. This can make layouts computationally expensive, especially in deeply nested trees or scenarios with a large number of children.

For instance, wrapping a ListView in an IntrinsicWidth is not recommended:

IntrinsicWidth(
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return Container(
height: 50,
color: index % 2 == 0 ? Colors.blue : Colors.red,
child: Text('Item $index'),
);
},
),
);

This would calculate intrinsic widths for every item in the list, introducing unnecessary overhead. Instead, use IntrinsicWidth only in specific parts of the UI where precise width alignment is critical.

KeyedSubtree Widget

The KeyedSubtree widget is particularly useful when you want to attach a key to an existing widget that doesn’t directly support keys or doesn’t expose its key as a constructor argument.

Here's how you might use it:

Widget build(BuildContext context) {
final myWidget = Text('Hello, Flutter');

return KeyedSubtree(
key: ValueKey('uniqueKey'),
child: myWidget,
);
}

I can think of why this might be useful for three use cases:

  • Flutter uses keys to determine which widgets in a list should be updated, reused or recreated when the widget tree is rebuilt. If two widgets in a list have the same type and properties, a key helps distinguish them.
  • When dealing with dynamically created widgets (e.g., in lists or grids), KeyedSubtree can be a lifesaver for attaching unique keys to prevent rendering issues.
  • Instead of rewriting or extending a widget class to include a key, you can simply wrap it in KeyedSubtree.

Flutter also provides the KeyedSubtree.ensureUniqueKeysForList method, which automatically assigns unique keys to items in a list:

final widgets = [
Text('Item 1'),
Text('Item 2'),
Text('Item 3'),
];

// Ensuring unique keys for each widget in the list
final keyedWidgets = KeyedSubtree.ensureUniqueKeysForList(widgets);

return Column(
children: keyedWidgets,
);

The KeyedSubtree class implementation is pretty straightforward. Let’s take a quick look:

class KeyedSubtree extends StatelessWidget {
const KeyedSubtree({super.key, required this.child});

KeyedSubtree.wrap(this.child, int childIndex)
: super(key: ValueKey<Object>(child.key ?? childIndex));

final Widget child;

static List<Widget> ensureUniqueKeysForList(List<Widget> items, {int baseIndex = 0}) {
if (items.isEmpty) {
return items;
}

final List<Widget> itemsWithUniqueKeys = <Widget>[
for (final (int i, Widget item) in items.indexed) KeyedSubtree.wrap(item, baseIndex + i),
];

assert(!debugItemsHaveDuplicateKeys(itemsWithUniqueKeys));
return itemsWithUniqueKeys;
}


Widget build(BuildContext context) => child;
}

When you explore the Flutter source code, you'll notice the KeyedSubtree class is used in various scenarios to maintain widget identity during transitions and animations. One notable example is its use in the Hero widget.

// ...
return SizedBox(
width: _placeholderSize?.width,
height: _placeholderSize?.height,
child: Offstage(
offstage: showPlaceholder,
child: TickerMode(
enabled: !showPlaceholder,
child: KeyedSubtree(key: _key, child: widget.child),
),
),
);
// ...

During a hero transition, a placeholder widget temporarily replaces the child widget. The KeyedSubtree ensures that the child retains its identity and state when the transition completes, preventing Flutter from recreating or misidentifying the widget.

You see, this is an example that you might have in your Flutter application.

Enjoying this article?

Subscribe to get our latest articles and product updates by email.

AutomaticKeepAlive Widget

The AutomaticKeepAlive widget and its associated classes are critical for managing widgets in lazy lists like ListView and PageView that need to persist state when scrolled out of view.

The AutomaticKeepAlive widget listens to KeepAliveNotification messages from its descendants. If a descendant requests to stay alive by dispatching this notification, the AutomaticKeepAlive widget ensures that the corresponding subtree is marked for retention. This behavior is particularly useful in scenarios involving scrollable lists or grids where widgets are dynamically built and often destroyed for memory optimization.

The AutomaticKeepAlive class is a stateful widget that wraps its child and ensures the child’s KeepAlive state is updated appropriately. Look at the full implementation here.

The main logic resides in the private state class _AutomaticKeepAliveState, which implements the mechanism to listen for KeepAliveNotification messages and handle their lifecycle.

One key part of the implementation is how the widget updates the child dynamically:

void _updateChild() {
_child = NotificationListener<KeepAliveNotification>(
onNotification: _addClient,
child: widget.child,
);
}

Here, the NotificationListener listens for KeepAliveNotification messages sent by its subtree. Whenever such a notification is triggered, the _addClient method is called to register the Listenable handle associated with the notification.

The _addClient method is central to the AutomaticKeepAlive lifecycle:

bool _addClient(KeepAliveNotification notification) {
final Listenable handle = notification.handle;
_handles ??= <Listenable, VoidCallback>{};
assert(!_handles!.containsKey(handle));
_handles![handle] = _createCallback(handle);
handle.addListener(_handles![handle]!);

if (!_keepingAlive) {
_keepingAlive = true;
final ParentDataElement<KeepAliveParentDataMixin>? childElement = _getChildElement();
if (childElement != null) {
_updateParentDataOfChild(childElement);
} else {
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!mounted) {
return;
}
final ParentDataElement<KeepAliveParentDataMixin>? childElement = _getChildElement();
assert(childElement != null);
_updateParentDataOfChild(childElement!);
});
}
}
return false;
}

This method performs several critical tasks:

  • A Listenable handle is associated with the notification and added to a map of active handles.
  • If the child widget is not yet fully built, it schedules a callback via SchedulerBinding to ensure that the keep-alive parent data is updated post-frame.
  • The _keepingAlive flag is set to true to indicate that the widget subtree should be kept.

The KeepAliveNotification class is a cornerstone of the AutomaticKeepAlive mechanism. Each notification is equipped with a Listenable handle that triggers events when the widget no longer requires being kept alive:

class KeepAliveNotification extends Notification {
const KeepAliveNotification(this.handle);

final Listenable handle;
}

The handle must be disposed of whenever the widget is removed from the tree to ensure that no stale notifications are emitted. This is why dispose methods in widgets using AutomaticKeepAliveClientMixin play a critical role.

Yet, perhaps the most important part of this class is the AutomaticKeepAliveClientMixin, which provides a convenient way to interact with the AutomaticKeepAlive system. By overriding the wantKeepAlive property and calling updateKeepAlive whenever its value changes, you can integrate seamlessly with the keep-alive mechanism:

mixin AutomaticKeepAliveClientMixin<T extends StatefulWidget> on State<T> {
bool get wantKeepAlive;

void updateKeepAlive() {
if (wantKeepAlive) {
_ensureKeepAlive();
} else {
_releaseKeepAlive();
}
}
}

In fact, here is where you can leverage DCM rules. DCM comes with several rules that can help to identity when listener or streams or instances that have dispose methods are not properly handled. For example, always-remove-listener, dispose-fields and dispose-class-fields are among many of the rules.

dart_code_metrics:
rules:
- dispose-fields:
ignore-blocs: false
ignore-get-x: false
- dispose-class-fields:
methods:
- someCustomDispose
- always-remove-listener

DCM is a code quality tool that helps your team move faster by reducing the time spent on code reviews, finding tricky bugs, identifying complex code, and unifying code style.

Here’s an example of using the mixin where you can leverage it in your Flutter application:

class StatefulItem extends StatefulWidget {

_StatefulItemState createState() => _StatefulItemState();
}

class _StatefulItemState extends State<StatefulItem>
with AutomaticKeepAliveClientMixin {
bool keepAlive = false;


bool get wantKeepAlive => keepAlive;


Widget build(BuildContext context) {
return CheckboxListTile(
value: keepAlive,
onChanged: (bool? value) {
setState(() {
keepAlive = value ?? false;
updateKeepAlive();
});
},
title: Text('Keep alive?'),
);
}
}

In this example, the state of the CheckboxListTile determines whether the widget should remain alive when scrolled out of view.

There are a few tips for using AutomaticKeepAlive effectively that I can think of:

  • Always trigger the KeepAliveNotification handle when a widget is deactivated. If you fail to do so, it can result in memory leaks, as the widget subtree might continue to be retained unnecessarily.

  • Using AutomaticKeepAlive indiscriminately for all widgets in a large list can increase memory usage. Reserve it for widgets where preserving state is important, such as form fields, focused elements, or dynamic inputs.

  • Use the AutomaticKeepAliveClientMixin in conjunction with StatefulWidget subclasses which helps for convenience and reliability. It abstracts much of the manual notification handling and ensures proper integration with the keep-alive mechanism.

  • Moreover, debugFillProperties to verify which widgets are being kept alive. This method is invaluable when troubleshooting keep-alive issues or memory leaks. Here is an example:

class KeepAliveItem extends StatefulWidget {
final int index;

const KeepAliveItem({Key? key, required this.index}) : super(key: key);


State<KeepAliveItem> createState() => _KeepAliveItemState();
}

class _KeepAliveItemState extends State<KeepAliveItem>
with AutomaticKeepAliveClientMixin {
bool _isKeptAlive = false;


bool get wantKeepAlive => _isKeptAlive;

void toggleKeepAlive() {
setState(() {
_isKeptAlive = !_isKeptAlive;
});
}


void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('index', widget.index));
properties.add(FlagProperty(
'isKeptAlive',
value: _isKeptAlive,
ifTrue: 'kept alive',
ifFalse: 'not kept alive',
showName: true,
));
}


Widget build(BuildContext context) {
super.build(context); // Required when using AutomaticKeepAliveClientMixin
return Card(
child: ListTile(
title: Text('Item ${widget.index}'),
subtitle: Text(_isKeptAlive ? 'Kept Alive' : 'Not Kept Alive'),
trailing: IconButton(
icon: Icon(
_isKeptAlive ? Icons.check_box : Icons.check_box_outline_blank,
),
onPressed: toggleKeepAlive,
),
),
);
}
}
  • Last but not least, understanding lifecycle triggers, the mix of dispose of, deactivate, and updateKeepAlive methods ensures that the widget interacts correctly with its lifecycle. Pay careful attention to these methods when implementing custom logic.

Let me end this widget with a more practical example, which I bet you have all used in your Flutter application.

import 'package:flutter/material.dart';

void main() => runApp(const PageViewExampleApp());

class PageViewExampleApp extends StatelessWidget {
const PageViewExampleApp({super.key});


Widget build(BuildContext context) {
return const MaterialApp(home: PageViewExample());
}
}

class PageViewExample extends StatefulWidget {
const PageViewExample({super.key});


State<PageViewExample> createState() => _PageViewExampleState();
}

class _PageViewExampleState extends State<PageViewExample> {
List<String> items = ['1', '2', '3', '4', '5'];

void _reverse() {
setState(() {
items = items.reversed.toList();
});
}


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('PageView Example')),
body: PageView.custom(
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return KeepAliveItem(
data: items[index],
key: ValueKey<String>(items[index]),
);
},
childCount: items.length,
findChildIndexCallback: (Key key) {
final String value = (key as ValueKey<String>).value;
return items.indexOf(value);
},
),
),
bottomNavigationBar: BottomAppBar(
child: TextButton(
onPressed: _reverse,
child: const Text('Reverse Items'),
),
),
);
}
}

class KeepAliveItem extends StatefulWidget {
const KeepAliveItem({super.key, required this.data});

final String data;


State<KeepAliveItem> createState() => _KeepAliveItemState();
}

class _KeepAliveItemState extends State<KeepAliveItem>
with AutomaticKeepAliveClientMixin {

bool get wantKeepAlive => true;


Widget build(BuildContext context) {
super.build(context);
return Center(
child: Text(
widget.data,
style: const TextStyle(fontSize: 24),
),
);
}
}

This example demonstrates keeping widgets alive in a PageView using AutomaticKeepAliveClientMixin.

AnimatedModalBarrier Widget

The AnimatedModalBarrier widget provides a visually engaging and functional way to block user interactions with widgets behind it. This widget acts as a modal barrier, typically used to obscure and prevent interaction with lower-priority widgets (e.g., underlying routes). It extends the functionality of the ModalBarrier by supporting animated color transitions, which enhance the user experience by providing smooth visual feedback during transitions between routes or modal overlays.

The AnimatedModalBarrier is primarily used in scenarios where you want to:

  • Prevent user interaction with widgets behind it.
  • Visually indicate the presence of a modal layer, such as a dialog or bottom sheet.
  • Provide dynamic, animated transitions in the barrier’s appearance (e.g., changing colors during route transitions).

Let’s take a look at an example:

AnimatedModalBarrier(
color: ColorTween(begin: Colors.transparent, end: Colors.black54)
.animate(animationController),
dismissible: true,
onDismiss: () {
Navigator.of(context).pop();
},
);

The AnimatedModalBarrier smoothly transitions from transparent to a semi-opaque black color as the modal appears.

The AnimatedModalBarrier extends AnimatedWidget, leveraging its listenable property to react to color animation changes. Here’s the critical implementation snippet:

class AnimatedModalBarrier extends AnimatedWidget {
const AnimatedModalBarrier({
super.key,
required Animation<Color?> color,
this.dismissible = true,
this.semanticsLabel,
this.barrierSemanticsDismissible,
this.onDismiss,
this.clipDetailsNotifier,
this.semanticsOnTapHint,
}) : super(listenable: color);

Animation<Color?> get color => listenable as Animation<Color?>;


Widget build(BuildContext context) {
return ModalBarrier(
color: color.value,
dismissible: dismissible,
semanticsLabel: semanticsLabel,
barrierSemanticsDismissible: barrierSemanticsDismissible,
onDismiss: onDismiss,
clipDetailsNotifier: clipDetailsNotifier,
semanticsOnTapHint: semanticsOnTapHint,
);
}
}

The build method creates a ModalBarrier widget, dynamically updating its color property based on the current animation value. This dynamic behavior is what differentiates AnimatedModalBarrier from ModalBarrier.

If I want to go a little deeper, I need to also mention the key properties:

  • color: Accepts an animated Color? value, which defines the barrier’s visual appearance. This is the core feature that allows seamless transitions during route changes or modal presentations.
  • dismissible: Determines whether tapping the barrier dismisses the current route. If true, tapping the barrier pops the route from the Navigator stack or triggers the optional onDismiss callback.
  • onDismiss: A callback invoked when the barrier is dismissed. If this is null, the default behavior pops the current route.
  • semanticsLabel: A label used for accessibility, providing context about the barrier’s function (e.g., "Tap to close dialog").
  • clipDetailsNotifier: A ValueNotifier<EdgeInsets> is used to adjust the barrier’s SemanticsNode.rect, defining the interactive area.

The AnimatedModalBarrier ensures accessibility compliance by integrating with Flutter’s Semantics system. It includes properties like semanticsLabel, barrierSemanticsDismissible, and semanticsOnTapHint to provide descriptive, accessible interactions for users with assistive technologies:

Semantics(
onTap: handleDismiss,
label: semanticsDismissible ? semanticsLabel : null,
child: MouseRegion(
cursor: SystemMouseCursors.basic,
child: ColoredBox(color: color.value),
),
);

This integration ensures that visually impaired users understand the barrier’s purpose and how to interact with it.

The AnimatedModalBarrier includes a private gesture detector to handle taps on the barrier:

class _ModalBarrierGestureDetector extends StatelessWidget {
const _ModalBarrierGestureDetector({required this.child, required this.onDismiss});

final Widget child;
final VoidCallback onDismiss;


Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{
_AnyTapGestureRecognizer: _AnyTapGestureRecognizerFactory(onAnyTapUp: onDismiss),
};

return RawGestureDetector(
gestures: gestures,
behavior: HitTestBehavior.opaque,
child: child,
);
}
}

This gesture detector listens for tap events and either trigger the onDismiss callback or plays an alert sound if the dismissible is false.

The clipDetailsNotifier property allows you to customize the interactive area of the barrier by clipping the semantics rect:

_SemanticsClipper(
clipDetailsNotifier: clipDetailsNotifier!,
child: barrier,
);

This widget is used in other widgets as well, and one of the most well-known ones is bottom_sheet.

// ...

Widget buildModalBarrier() {
if (barrierColor.a != 0 && !offstage) {
assert(barrierColor != barrierColor.withValues(alpha: 0.0));
final Animation<Color?> color = animation!.drive(
ColorTween(
begin: barrierColor.withValues(alpha: 0.0),
end: barrierColor,
).chain(
CurveTween(curve: barrierCurve),
),
);
return AnimatedModalBarrier(
color: color,
dismissible:
barrierDismissible,
semanticsLabel: barrierLabel,
barrierSemanticsDismissible: semanticsDismissible,
clipDetailsNotifier: _clipDetailsNotifier,
semanticsOnTapHint: barrierOnTapHint,
);
} else {
return ModalBarrier(
dismissible:
barrierDismissible,
semanticsLabel: barrierLabel,
barrierSemanticsDismissible: semanticsDismissible,
clipDetailsNotifier: _clipDetailsNotifier,
semanticsOnTapHint: barrierOnTapHint,
);
}
}

When you call showModalBottomSheet, the buildModalBarrier method ensures that an AnimatedModalBarrier is displayed as the overlay behind the modal content. The overlay fades in and out smoothly during the modal’s appearance and dismissal.

FittedBox Widget

The FittedBox widget ensures that the child widget fits within the available space while maintaining the desired aspect ratio or scaling behavior.

The FittedBox widget addresses the problem of rendering content that may not naturally fit within the constraints of its parent widget. For example, when dealing with images, text, or custom widgets in a layout with limited space, you can use FittedBox to scale or align the content appropriately.

Here’s a basic example:

FittedBox(
fit: BoxFit.contain,
child: Text(
'Hello, Flutter!',
style: TextStyle(fontSize: 50),
),
);

The FittedBox widget provides several properties to control its behavior:

  • fit: Determines how the child should be resized to fit within the parent. It accepts values from the BoxFit enum:
    • fill: Stretches the child to fill the parent, potentially distorting its aspect ratio.
    • contain: Scales the child to fit within the parent while preserving its aspect ratio.
    • cover: Scales the child to cover the parent completely while preserving its aspect ratio. Parts of the child may be clipped.
    • fitWidth: Scales the child to match the parent’s width while maintaining the aspect ratio.
    • fitHeight: Scales the child to match the parent’s height while maintaining the aspect ratio.
    • none: Does not scale the child.
    • scaleDown: Scales the child down to fit within the parent if it’s larger; otherwise, it does nothing.
  • alignment: Specifies how the child is aligned with the parent. It accepts values from the Alignment class, such as:
    • Alignment.center
    • Alignment.topLeft
    • Alignment.bottomRight

The FittedBox widget wraps its child in a Transform to apply scaling and alignment. During the layout phase, the scaling factors needed to make the child fit within the parent are calculated based on the provided fit and alignment properties.

FittedBox

Internally, FittedBox is implemented as follows:

class FittedBox extends SingleChildRenderObjectWidget {
const FittedBox({
super.key,
this.fit = BoxFit.contain,
this.alignment = Alignment.center,
super.child,
});

final BoxFit fit;
final AlignmentGeometry alignment;


RenderFittedBox createRenderObject(BuildContext context) {
return RenderFittedBox(
fit: fit,
alignment: alignment,
);
}


void updateRenderObject(
BuildContext context, RenderFittedBox renderObject) {
renderObject
..fit = fit
..alignment = alignment;
}
}

When you are using this widget, it’s good to keep in mind a few tips, let me elaborate:

  • Choose the right fit value based on how you want the content to scale, for example, use contain for keeping the proportions and cover when the content needs to completely fill the space.
  • The alignment property helps you position the child if it doesn’t fully cover the parent container.
  • While FittedBox is useful, using it too much can make your layout more complex or even affect performance, so only use it when scaling is truly necessary.
  • For better control, pair FittedBox with constrained widgets like Container or ConstrainedBox to clearly define the available space.
  • Last but not least, always test how FittedBox behaves with different child widgets, like images, text, or custom widgets, to make sure it works as intended.

Alright, that’s it. Let’s now talk about another interesting widget for accessibility.

SemanticsDebugger Widget

The SemanticsDebugger widget helps to visualize and debug the semantics tree. The SemanticsDebugger overlays a visual representation of the semantics tree on top of your app’s UI. This overlay displays rectangles and labels that correspond to the semantic nodes of your widgets, allowing you to see which elements are accessible and what information they provide. It highlights key accessibility properties such as labels, actions, and roles.

Here’s a basic usage example:

SemanticsDebugger(
child: MyApp(),
);

There are several key features with this widget; let me elaborate:

  • Each semantic node is shown with a colored box around it, marking the area it covers on the screen.
  • Inside these boxes, you’ll see text that explains the node’s labels, roles, and actions.
  • The debugger also shows how touch events, like taps and swipes, connect to these nodes.
  • Different colors are used to make it easy to spot which nodes overlap or are nested inside each other. This makes it much clearer to understand how everything works together.

Semantic debugger

The SemanticsDebugger widget builds on Flutter’s RenderObject system, creating a layer that overlays the visual representation of the semantics tree. Internally, it uses a custom render object to draw the semantic information on top of the app’s UI.

Here's the core implementation:

class SemanticsDebugger extends StatelessWidget {
const SemanticsDebugger({super.key, required this.child});

final Widget child;


Widget build(BuildContext context) {
return _SemanticsDebugger(
child: child,
);
}
}

class _SemanticsDebugger extends SingleChildRenderObjectWidget {
const _SemanticsDebugger({required Widget child}) : super(child: child);


RenderObject createRenderObject(BuildContext context) {
return RenderSemanticsDebugger();
}
}

The actual debugging logic is implemented in the RenderSemanticsDebugger class, which traverses the semantics tree and renders the bounding boxes, labels, and other annotations.

There are several cases in which I think you can leverage this widget; let’s talk about them.

Use SemanticsDebugger to verify that all interactive elements (buttons, links, etc.) are accessible and have meaningful semantic labels:

SemanticsDebugger(
child: Scaffold(
appBar: AppBar(title: Text('Accessibility Debugging')),
body: Column(
children: [
ElevatedButton(onPressed: () {}, child: Text('Tap me')),
Text('Static text content'),
],
),
),
);

This setup ensures that the ElevatedButton and Text widgets have appropriate semantic information.

Semantic label

In complex layouts, it’s easy to overlook accessibility for certain elements. SemanticsDebugger helps identify unintentional gaps in the semantics tree, such as missing labels or incorrectly defined roles.

SemanticsDebugger(
child: ListView.builder(
itemCount: 10,
itemBuilder: (context, index) {
return ListTile(
title: Text('Item $index'),
subtitle: Text('Subtitle $index'),
onTap: () {},
);
},
),
);

In this example, the debugger shows whether each ListTile is correctly represented in the semantics tree and whether its tap action is registered.

Semantic list

For fine-grained control over semantics, you can use the Semantics widget directly. This is particularly useful for custom widgets or scenarios where default semantics are insufficient:

Semantics(
label: 'Custom button',
onTap: () => print('Custom button tapped'),
child: Container(
color: Colors.blue,
width: 100,
height: 50,
child: Center(child: Text('Press')),
),
);

In this case, using SemanticsDebugger can verify that the custom label and tap action are properly registered.

SemanticsDebugger(
child: Column(
children: [
Semantics(
label: 'Accessible container',
child: Container(
color: Colors.green,
width: 100,
height: 100,
),
),
Semantics(
label: 'Button with custom action',
onTap: () => print('Button tapped'),
child: ElevatedButton(
onPressed: () {},
child: Text('Custom Action'),
),
),
],
),
);

Let me also mention a few tips that I can think of about this widget:

  • The SemanticsDebugger is a great tool to make sure all tappable widgets, like buttons and list items, are properly included in the semantics tree and have clear, meaningful labels. It helps you spot issues like overlapping or redundant nodes that might confuse assistive technologies.
  • For the best results, combine the SemanticsDebugger with accessibility tools like Android TalkBack or iOS VoiceOver to test your app thoroughly.
  • If you’re using custom widgets, make sure you define semantic properties like label, onTap, and role, and double-check that they work as expected.

Custom semantic

Spacing for Column and Row Widget

The Column and Row widgets are probably among the most used widgets in Flutter. One of the things that you may have done using these widgets to make some spacing between elements was using widgets such as SizedBox or Spacer. I bet many of you can relate to what I mean.

A recent addition to both widgets is the spacing property, which simplifies the process of adding consistent space between child widgets without requiring explicit SizedBox or Spacer widgets.

Here’s how you can use the spacing property in a Column:

Column(
spacing: 10.0,
children: [
Text('First'),
Text('Second'),
Text('Third'),
],
)

The same logic applies to a Row:

Row(
spacing: 15.0,
children: [
Icon(Icons.star),
Icon(Icons.favorite),
Icon(Icons.thumb_up),
],
)

The spacing property is implemented directly within the Column and Row widgets.

Spacing

When the children list is processed, the spacing is applied by injecting additional gaps between each child widget. This behavior can be observed in the rendering logic:


Widget build(BuildContext context) {
final List<Widget> spacedChildren = [];
for (int i = 0; i < children.length; i++) {
if (i > 0) {
spacedChildren.add(SizedBox(
width: isHorizontal ? spacing : 0,
height: isHorizontal ? 0 : spacing,
));
}
spacedChildren.add(children[i]);
}

return Flex(
direction: isHorizontal ? Axis.horizontal : Axis.vertical,
mainAxisAlignment: mainAxisAlignment,
mainAxisSize: mainAxisSize,
crossAxisAlignment: crossAxisAlignment,
textDirection: textDirection,
verticalDirection: verticalDirection,
textBaseline: textBaseline,
children: spacedChildren,
);
}

Here, the children list is iterated, and SizedBox widgets are conditionally added between adjacent children based on the spacing value.

The spacing property works seamlessly with other alignment and layout properties like mainAxisAlignment, crossAxisAlignment, and mainAxisSize. For example:

Column(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Aligned Text 1'),
Text('Aligned Text 2'),
Text('Aligned Text 3'),
],
)

Before we end, just to ensure you are using spacing in right place, DCM 1.26.0 has introduced a new rule prefer-spacing which suggests using the spacing argument of a Row, Column or Flex instead of using SizedBox widgets for adding gaps between children widgets.

dart_code_metrics:
rules:
- prefer-spacing

Alright, that was 10 Flutter widgets. Now, let’s just wrap up this article.

Conclusion

In this article, we’ve explored several lesser-known yet effective Flutter widgets, such as AnimatedModalBarrier, FittedBox, SemanticsDebugger, and more. These widgets not only enhance your app's functionality but also simplify your development process. It’s also nice to see what’s under the hood of these widgets and learn from them to apply to our Flutter applications.

This is just the beginning. Flutter and Dart are packed with hidden gems waiting to be uncovered. Stay tuned for the next article, which I will reveal in the next 10 unknown or less-used functionalities in Dart and Flutter. Remember to subscribe to our newsletter, YouTube, or social media to get the latest updates.

Enjoying this article?

Subscribe to get our latest articles and product updates by email.