Render Tree
Understand the render tree and how Flutter paints UI elements on screen.
What is it?
The render tree is a tree of RenderObject objects that handle the actual layout, painting, and hit testing of your Flutter application. While the widget tree defines what to display and the element tree manages state, the render tree is responsible for how to display it on screen.
Why does it exist?
The render tree exists to:
- Handle layout calculations and constraints
- Perform actual painting to the canvas
- Manage hit testing for user interactions
- Optimize rendering performance
- Handle compositing and layers
- Manage repaint boundaries
- Coordinate with the rendering pipeline
Understanding the Render Tree
The render tree mirrors the widget tree but with render objects.
// Widget Tree (Configuration)
Container
└── Center
└── Text('Hello')
// Element Tree (State)
ContainerElement
└── CenterElement
└── TextElement
// Render Tree (Painting)
RenderConstrainedBox
└── RenderPositionedBox
└── RenderParagraph('Hello')
// Relationship:
// Widget → creates → Element → creates → RenderObject
// Each widget type creates a specific render object
// Render objects handle layout and painting
What's happening here? - Render tree handles actual rendering - Each render object knows its size and position - Render objects paint to the canvas - Tree mirrors the widget structure - Render objects are mutable
Types of Render Objects
Different render objects handle different layout and painting needs.
// 1. RenderBox - Most common render object
class CustomRenderBox extends RenderBox {
@override
void performLayout() {
// Handle layout
size = constraints.constrain(Size(100, 100));
}
@override
void paint(PaintingContext context, Offset offset) {
// Handle painting
context.canvas.drawRect(
offset & size,
Paint()..color = Colors.blue,
);
}
}
// 2. RenderParagraph - Renders text
// Used by Text widget
RenderParagraph(
text: TextSpan(text: 'Hello'),
textDirection: TextDirection.ltr,
)
// 3. RenderImage - Renders images
// Used by Image widget
RenderImage(
image: imageProvider,
width: 100,
height: 100,
)
// 4. RenderFlex - Handles flex layouts
// Used by Row and Column
RenderFlex(
direction: Axis.vertical,
mainAxisAlignment: MainAxisAlignment.center,
children: renderChildren,
)
// 5. RenderPositionedBox - Centers child
// Used by Center widget
RenderPositionedBox(
alignment: Alignment.center,
child: renderChild,
)
// 6. RenderStack - Overlays children
// Used by Stack widget
RenderStack(
alignment: Alignment.topLeft,
children: renderChildren,
)
// 7. RenderConstrainedBox - Applies constraints
// Used by Container with constraints
RenderConstrainedBox(
additionalConstraints: BoxConstraints.tight(Size(100, 100)),
child: renderChild,
)
What's happening here? - RenderBox is the base for most render objects - Different render objects handle different widgets - Each render object has specific properties - Render objects form a tree structure - Each handles its own layout and painting
Render Tree Building
Render objects are created from elements during the build process.
// Building the render tree
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
// 1. Widget tree is created
return Container(
color: Colors.blue,
child: const Center(
child: Text('Hello'),
),
);
}
}
// Behind the scenes:
// 1. Container widget creates RenderConstrainedBox
// 2. Center widget creates RenderPositionedBox
// 3. Text widget creates RenderParagraph
// Render object creation process
class MyCustomWidget extends LeafRenderObjectWidget {
const MyCustomWidget({super.key});
@override
RenderObject createRenderObject(BuildContext context) {
// Called when element is mounted
// Creates the render object
return CustomRenderBox();
}
@override
void updateRenderObject(
BuildContext context,
CustomRenderBox renderObject,
) {
// Called when widget updates
// Updates render object properties
}
}
// Render tree after build:
RenderConstrainedBox
└── RenderPositionedBox
└── RenderParagraph('Hello')
What's happening here? - Render objects are created during build - Each widget creates a specific render object - Render objects form a tree mirroring widgets - Render objects are updated when widgets change - The render tree is separate from widget tree
Layout Process
Render objects perform layout using constraints passed down the tree.
// Layout process flow
class CustomLayoutWidget extends RenderObjectWidget {
const CustomLayoutWidget({super.key});
@override
RenderObject createRenderObject(BuildContext context) {
return CustomRenderObject();
}
}
class CustomRenderObject extends RenderBox {
RenderBox? _child;
@override
void setupParentData(RenderObject child) {
if (child.parentData is! BoxParentData) {
child.parentData = BoxParentData();
}
}
@override
void performLayout() {
// 1. Get constraints from parent
final BoxConstraints constraints = this.constraints;
// 2. Calculate available space
final double maxWidth = constraints.maxWidth;
final double maxHeight = constraints.maxHeight;
// 3. Layout child if exists
if (_child != null) {
// 4. Pass constraints to child
_child!.layout(
constraints,
parentUsesSize: true,
);
// 5. Position child
final BoxParentData childParentData =
_child!.parentData! as BoxParentData;
childParentData.offset = Offset.zero;
// 6. Set own size based on child
size = _child!.size;
} else {
// 7. Set size if no child
size = constraints.constrain(Size(100, 100));
}
}
@override
void paint(PaintingContext context, Offset offset) {
// Paint child
if (_child != null) {
context.paintChild(_child!, offset);
}
}
}
// Layout constraints flow:
// 1. Parent passes constraints to child
// 2. Child computes its size
// 3. Child returns its size to parent
// 4. Parent positions child
// 5. Parent sets its own size
What's happening here? - Layout is top-down (constraints down) - Size is bottom-up (sizes up) - Parent controls child's constraints - Child chooses its own size - Parent positions the child - Both layout and painting happen
Painting Process
Render objects paint themselves and their children.
// Painting process
class CustomPainterWidget extends LeafRenderObjectWidget {
const CustomPainterWidget({super.key, required this.painter});
final CustomPainter painter;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderCustomPainter(painter: painter);
}
@override
void updateRenderObject(
BuildContext context,
_RenderCustomPainter renderObject,
) {
renderObject.painter = painter;
}
}
class _RenderCustomPainter extends RenderBox {
_RenderCustomPainter({required CustomPainter painter})
: _painter = painter;
CustomPainter _painter;
set painter(CustomPainter value) {
if (_painter == value) return;
_painter = value;
markNeedsPaint(); // Trigger repaint
}
@override
void performLayout() {
// Set size based on constraints
size = constraints.constrain(Size(200, 200));
}
@override
void paint(PaintingContext context, Offset offset) {
// 1. Create canvas
final Canvas canvas = context.canvas;
// 2. Save canvas state
canvas.save();
// 3. Translate to offset
canvas.translate(offset.dx, offset.dy);
// 4. Paint custom content
_painter.paint(canvas, size);
// 5. Restore canvas state
canvas.restore();
}
@override
void dispose() {
_painter.dispose();
super.dispose();
}
}
// Painting layers:
// 1. Canvas - drawing surface
// 2. Paint - style and color
// 3. Path - shape to draw
// 4. Offset - position on canvas
What's happening here? - paint() is called every frame - Canvas provides drawing methods - Save/restore handles transformations - markNeedsPaint triggers repaint - Painting is bottom-up (children first)
Hit Testing
Render objects handle user interactions through hit testing.
// Hit testing process
class InteractiveRenderObject extends RenderBox {
bool _isHovering = false;
bool _isPressed = false;
@override
bool hitTest(HitTestResult result, {required Offset position}) {
// 1. Check if point is within bounds
if (!size.contains(position)) {
return false; // Miss
}
// 2. Add to hit test result
result.add(BoxHitTestEntry(this, position));
// 3. Return true for hit
return true;
}
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
// 4. Handle the event
if (event is PointerDownEvent) {
_isPressed = true;
markNeedsPaint();
}
if (event is PointerUpEvent) {
_isPressed = false;
markNeedsPaint();
}
if (event is PointerHoverEvent) {
_isHovering = true;
markNeedsPaint();
}
if (event is PointerExitEvent) {
_isHovering = false;
markNeedsPaint();
}
}
@override
void paint(PaintingContext context, Offset offset) {
final Paint paint = Paint()
..color = _isPressed ? Colors.green :
_isHovering ? Colors.blue : Colors.grey;
context.canvas.drawRect(
offset & size,
paint,
);
}
}
// Hit test flow:
// 1. Pointer event arrives
// 2. Hit test starts at root
// 3. Traverses tree top-down
// 4. Finds hit render objects
// 5. Event dispatched to objects
// 6. Objects handle the event
What's happening here? - hitTest checks if point hits object - Hit test results track hits - handleEvent processes interactions - Renders visual feedback - Hit test order determines interaction
Repaint Boundaries
Repaint boundaries optimize painting performance.
// Repaint boundaries
class OptimizedWidget extends StatelessWidget {
const OptimizedWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
// 1. Repaint boundary for animation
RepaintBoundary(
child: AnimatedWidget(),
),
// 2. Repaint boundary for video
RepaintBoundary(
child: VideoWidget(),
),
// 3. Repaint boundary for static content
const RepaintBoundary(
child: StaticContent(),
),
],
);
}
}
// Custom repaint boundary
class CustomRepaintBoundary extends SingleChildRenderObjectWidget {
const CustomRepaintBoundary({super.key, super.child});
@override
RenderObject createRenderObject(BuildContext context) {
return RenderRepaintBoundary();
}
@override
void updateRenderObject(
BuildContext context,
RenderRepaintBoundary renderObject,
) {
// Configure boundary
}
}
// When to use repaint boundaries:
// 1. Animated widgets
// 2. Video or image rendering
// 3. Complex custom paintings
// 4. Widgets that update frequently
// 5. Isolate expensive paints
// When NOT to use repaint boundaries:
// 1. Simple static widgets
// 2. Widgets that don't repaint
// 3. Very small widgets
// 4. Overuse can hurt performance
What's happening here? - Repaint boundaries isolate repaint areas - Only dirty areas are repainted - Reduces painting overhead - Improves performance - Use for frequently updating widgets
Compositing
Compositing combines layers into the final image.
// Compositing process
class CompositeWidget extends StatelessWidget {
const CompositeWidget({super.key});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 1. Background layer
Container(color: Colors.blue),
// 2. Text layer
const Text('Hello'),
// 3. Image layer
Image.network('https://example.com/image.jpg'),
// 4. Overlay layer
Container(
color: Colors.black.withOpacity(0.5),
child: const Text('Overlay'),
),
],
);
}
}
// Layer types:
// 1. PictureLayer - Contains draw calls
// 2. TextureLayer - Contains video/GPU content
// 3. OffsetLayer - Positioned child layer
// 4. OpacityLayer - Applied opacity
// 5. ClipRectLayer - Clips content
// Compositing flow:
// 1. Each render object paints to layers
// 2. Layers are collected
// 3. Layers are composited in order
// 4. Final image is produced
// 5. Display on screen
What's happening here? - Each render object creates layers - Layers are composited together - Compositing creates final image - Order determines layering - GPU handles compositing
Debugging Render Tree
Tools for inspecting the render tree.
// Debug render tree
import 'package:flutter/rendering.dart';
class DebugRenderWidget extends StatelessWidget {
const DebugRenderWidget({super.key});
@override
Widget build(BuildContext context) {
// Enable debug painting
debugPaintSizeEnabled = true;
// Print render tree
debugDumpRenderTree();
// Print layer tree
debugDumpLayerTree();
// Print paint tree
debugDumpPaintTree();
// Print semantics tree
debugDumpSemanticsTree();
return const Text('Debug');
}
}
// Render tree debugging tips:
// 1. Check layout constraints
// 2. Verify sizes and positions
// 3. Inspect render object properties
// 4. View paint order
// 5. Check repaint boundaries
// 6. Monitor performance
// Flutter Inspector
// 1. Select widget
// 2. View render object
// 3. Check layout properties
// 4. View repaint boundaries
// 5. Highlight oversized widgets
What's happening here? - debugPaintSizeEnabled shows layout bounds - debugDumpRenderTree shows render hierarchy - Flutter Inspector visualizes render tree - Helps debug layout issues - Helps performance debugging
Best Practices
Use Repaint Boundaries Wisely
// Good - Repaint boundary for animation
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedBuilder(
animation: controller,
builder: (context, child) {
return Transform.rotate(
angle: controller.value,
child: child,
);
},
child: const Icon(Icons.refresh),
),
);
}
// Bad - Unnecessary repaint boundary
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: const Text('Hello'), // Static text doesn't need it
);
}
Avoid Complex Layouts
// Good - Simple layout
@override
void performLayout() {
size = constraints.constrain(Size(100, 100));
}
// Bad - Complex layout calculations
@override
void performLayout() {
// Heavy computations in layout
final double width = calculateWidth();
final double height = calculateHeight();
// Complex positioning
// Many nested layouts
}
Dispose Resources
// Good - Clean up resources
class CustomRenderObject extends RenderBox {
final AnimationController controller;
CustomRenderObject(this.controller) {
controller.addListener(markNeedsPaint);
}
@override
void dispose() {
controller.removeListener(markNeedsPaint);
super.dispose();
}
}
Common Mistakes
Not Using Repaint Boundaries
Wrong:
// Animating widget with no repaint boundary
@override
Widget build(BuildContext context) {
return AnimatedWidget(
animation: controller,
child: HeavyWidget(), // Rebuilds every frame
);
}
Correct:
// Wrap with repaint boundary
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedWidget(
animation: controller,
child: const HeavyWidget(),
),
);
}
Layout Constraints Too Tight
Wrong:
// Constraints too tight causing overflow
@override
void performLayout() {
size = constraints.smallest; // Too small
}
Correct:
// Use constraints properly
@override
void performLayout() {
size = constraints.constrain(Size(100, 100));
}
Summary
The render tree handles layout, painting, and hit testing in Flutter. Render objects perform layout using constraints, paint themselves and their children, and handle user interactions. Understanding the render tree helps you build efficient, performant applications.
Next Steps
Did You Know?
- Render objects are mutable
- The render tree is separate from the widget tree
- Repaint boundaries optimize performance
- Layout is top-down, painting is bottom-up
- Render objects handle hit testing
- Compositing combines layers
- The render tree is rebuilt when needed