Rendering Pipeline
Understand the complete journey from widget code to pixels on screen in Primitive UI.
Overview
When you write:
PrimitiveCard(
child: Text('Hello'),
)A complex series of operations transforms this into rendered pixels. Let’s trace the complete pipeline.
The Three Trees
Flutter maintains three parallel trees:
1. Widget Tree (Configuration)
PrimitiveCard
└─ CustomPaint
└─ _CardLayout
└─ TextCharacteristics:
- Immutable
- Rebuilt frequently
- Lightweight
- Describes desired state
2. Element Tree (Lifecycle)
_PrimitiveCardElement
└─ _CustomPaintElement
└─ _CardLayoutElement
└─ _TextElementCharacteristics:
- Mutable
- Long-lived
- Manages widget lifecycle
- Handles updates
3. RenderObject Tree (Rendering)
_RenderCardLayout
└─ _RenderCustomPaint
└─ RenderParagraphCharacteristics:
- Mutable
- Long-lived
- Performs layout
- Executes painting
- Handles hit testing
Complete Rendering Pipeline
Build Phase
When build() is called:
@override
Widget build(BuildContext context) {
return PrimitiveCard(
elevation: 4.0,
child: Text('Hello'),
);
}What Happens:
- New
PrimitiveCardwidget created - New
CustomPaintand_CardLayoutwidgets created - Widget tree assembled
Note: Widgets are cheap - creating them is fast!
Element Reconciliation
Elements compare old and new widgets:
// Simplified element update logic
void update(Widget newWidget) {
if (widget.runtimeType == newWidget.runtimeType) {
// Update existing element
_widget = newWidget;
_renderObject?.updateConfiguration(newWidget);
} else {
// Rebuild element tree
rebuild();
}
}Optimization: Elements are reused when widget types match.
RenderObject Update
When widget properties change:
@override
void updateRenderObject(
BuildContext context,
_RenderCardLayout renderObject,
) {
renderObject.padding = padding; // Triggers setter
}
// In RenderObject
set padding(EdgeInsets value) {
if (_padding == value) return; // Skip if unchanged
_padding = value;
markNeedsLayout(); // Schedule layout
}Layout Phase
RenderObjects calculate sizes and positions:
@override
void performLayout() {
// 1. Receive constraints from parent
final BoxConstraints constraints = this.constraints;
// 2. Layout child with deflated constraints
final childConstraints = constraints.deflate(_padding);
child.layout(childConstraints, parentUsesSize: true);
// 3. Calculate own size
size = Size(
child.size.width + _padding.horizontal,
child.size.height + _padding.vertical,
);
// 4. Position child
final childParentData = child.parentData as BoxParentData;
childParentData.offset = Offset(_padding.left, _padding.top);
}Constraint Flow:
Parent (400x600 max)
↓ deflate by 16px padding
Child (368x568 max)
↓ child sizes to (200x100)
Parent sizes to (232x132)Paint Phase
RenderObjects draw to canvas:
@override
void paint(PaintingContext context, Offset offset) {
// 1. Get canvas from context
final Canvas canvas = context.canvas;
// 2. Draw shadow
if (elevation > 0) {
canvas.drawShadow(shadowPath, shadowColor, elevation, true);
}
// 3. Draw background
canvas.drawRRect(cardRect, cardPaint);
// 4. Paint child
final childParentData = child.parentData as BoxParentData;
context.paintChild(child, childParentData.offset + offset);
}Paint Order: Bottom to top (background first, then child)
Compositing
Flutter combines layers:
[Layers]
Layer 1: Card shadow
Layer 2: Card background
Layer 3: Text
↓
[Compositor]
↓
[Screen]Hardware Acceleration: Layers are GPU-accelerated.
Hit Testing
When user taps screen:
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
// 1. Check if point is within bounds
if (!size.contains(position)) return false;
// 2. Test child
if (child != null) {
final childParentData = child.parentData as BoxParentData;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
return child.hitTest(result, position: transformed);
},
);
if (isHit) return true;
}
// 3. Add self to hit path
result.add(BoxHitTestEntry(this, position));
return true;
}Case Studies
Case Study 1: PrimitiveCard Rendering
Let’s trace a complete render of PrimitiveCard:
Initial Build
PrimitiveCard(
elevation: 4.0,
borderRadius: 12.0,
child: Text('Hello'),
)Widget Tree:
PrimitiveCard
└─ CustomPaint (painter: _CardPainter)
└─ _CardLayout (padding: EdgeInsets.all(16))
└─ Text('Hello')Layout Pass
Step 1: Root receives constraints (400x600 max)
Step 2: CustomPaint forwards constraints to _CardLayout
Step 3: _CardLayout.performLayout():
// Deflate constraints by padding (16px all sides)
childConstraints = BoxConstraints(
maxWidth: 400 - 32, // 368
maxHeight: 600 - 32, // 568
)
// Layout text
text.layout(childConstraints)
// Text sizes to (50x20)
// Size self
size = Size(50 + 32, 20 + 32) // (82, 52)
// Position text
textOffset = Offset(16, 16)Step 4: CustomPaint sizes to child’s size (82x52)
Step 5: Root sizes to CustomPaint size (82x52)
Paint Pass
Step 1: CustomPaint calls _CardPainter.paint():
void paint(Canvas canvas, Size size) {
// size = (82, 52)
// Create card shape
final cardRect = RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, 82, 52),
Radius.circular(12),
);
// Paint shadow
canvas.drawShadow(
Path()..addRRect(cardRect),
Color(0x26000000), // Calculated from elevation
4.0,
true,
);
// Paint background
canvas.drawRRect(
cardRect,
Paint()..color = Colors.white,
);
}Step 2: CustomPaint paints child (_CardLayout)
Step 3: _CardLayout paints child (Text) at offset (16, 16)
Step 4: Text renders “Hello” using font renderer
Case Study 2: HStack with Flex Layout
Let’s trace a flex layout scenario with HStack:
Initial Build
HStack(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Start'),
HCustomExpanded(
flex: 2,
child: Container(color: Colors.blue),
),
HCustomExpanded(
flex: 1,
child: Container(color: Colors.red),
),
Text('End'),
],
)Layout Pass
Step 1: Root receives constraints (400x100 max)
Step 2: _RenderHStack.performLayout() - Pass 1 (non-flex children):
// Layout 'Start' text
Text('Start').layout() → sizes to (40x20)
totalNonFlexWidth = 40
// Layout 'End' text
Text('End').layout() → sizes to (30x20)
totalNonFlexWidth = 70
// Calculate available space for flex children
totalSpacing = 8 * 3 = 24 // 4 children - 1
flexSpace = 400 - 70 - 24 = 306Step 3: Pass 2 (flex children):
// Expanded(flex: 2) gets 2/3 of flex space
blueContainer.layout(maxWidth: 306 * 2/3 = 204) → sizes to (204x100)
// Expanded(flex: 1) gets 1/3 of flex space
redContainer.layout(maxWidth: 306 * 1/3 = 102) → sizes to (102x100)Step 4: Position children with spacing:
// MainAxisAlignment.spaceBetween is ignored when flex is present
'Start' → x: 0
blueContainer → x: 48 (40 + 8)
redContainer → x: 260 (48 + 204 + 8)
'End' → x: 370 (260 + 102 + 8)Educational Value: Shows two-pass layout and flex space distribution.
Case Study 3: PrimitiveSlider Gesture Handling
Let’s trace a user interaction with PrimitiveSlider:
User Drags Thumb
Step 1: User touches down
GestureDetector.onHorizontalDragStart()
↓
_handleDragStart()
↓
setState(() => _isDragging = true)
↓
Build phase triggeredStep 2: User moves finger
GestureDetector.onHorizontalDragUpdate()
↓
_handleDragUpdate(details)
↓
// Convert global position to local
localDx = globalToLocal(details.globalPosition).dx
↓
// Map to value range
percent = localDx / width
newValue = min + percent * (max - min)
↓
widget.onChanged!(newValue)
↓
Parent rebuilds slider with new valueStep 3: Paint with new value
CustomPaint(
painter: _SliderPainter(
value: newValue, // Updated
// ...
),
)
↓
paint() calculates thumb position
thumbX = trackLeft + (percent * trackWidth)
↓
canvas.drawCircle(thumbX, centerY, radius)Frame Timeline:
- Frame 1: Touch → setState → build → layout → paint
- Frame 2-N: Drag updates → onChanged → rebuild → paint
- Each frame ~16ms @ 60fps
Educational Value: Shows gesture → state → render pipeline.
Case Study 4: PrimitiveButton State Changes
Let’s trace hover and press interactions:
Desktop Hover Interaction
Step 1: Mouse enters button area
MouseRegion.onEnter()
↓
setState(() => _isHovered = true)
↓
Build triggeredStep 2: Build with hover state
// Resolve colors based on hover
backgroundColor = variant == primary
? colorScheme.primary.withOpacity(0.9) // Slightly dimmed
: colorScheme.primary
AnimatedContainer(
duration: Duration(milliseconds: 150),
decoration: BoxDecoration(
color: backgroundColor, // Animated color change
),
)Step 3: User clicks
GestureDetector.onTapDown()
↓
_handleTapDown()
↓
setState(() => _isPressed = true)
_controller.forward() // Start scale animation
↓
Build triggeredStep 4: Scale animation
// Every frame for 100ms
ScaleTransition(
scale: 1.0 → 0.97, // Animated by controller
child: button,
)Step 5: User releases
GestureDetector.onTapUp()
↓
_handleTapUp()
↓
setState(() => _isPressed = false)
_controller.reverse() // Animate back to 1.0
widget.onPressed?.call()Educational Value: Multiple overlapping animations and state changes.
Performance Characteristics
Layout Performance
Fast Operations:
// Simple constraint deflation
childConstraints = constraints.deflate(padding);
// Direct size calculation
size = Size(width + padding.horizontal, height + padding.vertical);Slow Operations:
// Intrinsic size calculations (avoided in performLayout)
minWidth = child.getMinIntrinsicWidth(double.infinity);
// Multiple layout passes (also avoided)
child.layout(constraints1);
final size1 = child.size;
child.layout(constraints2); // DON'T DO THISPaint Performance
Efficient:
- Solid colors
- Simple shapes (RRect, Circle)
- Cached paint objects
- Hardware-accelerated operations
Expensive:
- Complex paths
- Many drawing operations
- Image filters
- Blending modes
shouldRepaint Optimization
@override
bool shouldRepaint(_CardPainter oldDelegate) {
// Only repaint if visual properties changed
return oldDelegate.color != color ||
oldDelegate.borderRadius != borderRadius ||
oldDelegate.elevation != elevation;
}Impact:
false: Reuses cached layer (fast!)true: Re-executes paint() (slower)
Animation Integration
How PrimitiveToggleSwitch Animates
class _PrimitiveToggleSwitchState extends State<PrimitiveToggleSwitch>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 200),
vsync: this, // Syncs with refresh rate
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return CustomPaint(
painter: _ToggleSwitchPainter(
animationValue: _animation.value, // 0.0 to 1.0
activeColor: widget.activeColor,
inactiveColor: widget.inactiveColor,
),
);
},
);
}
}Frame-by-Frame:
- User taps →
_controller.forward()starts - Every frame (~16ms):
- Animation ticks (value increases)
- AnimatedBuilder rebuilds
- New CustomPaint widget created
- Painter receives new animationValue
- shouldRepaint returns true (value changed)
- paint() executes with interpolated position/color
Layer Optimization
Repaint Boundaries
Flutter automatically creates repaint boundaries for performance:
// PrimitiveCard with animation inside
PrimitiveCard(
child: AnimatingWidget(), // Creates repaint boundary
)Without boundary: Entire card repaints on every frame
With boundary: Only animated child repaints
Manual Boundaries
RepaintBoundary(
child: PrimitiveCard(
child: FrequentlyUpdatingWidget(),
),
)Use when a subtree updates frequently but its parent doesn’t.
Memory Management
Widget Creation
// Every build creates new widgets
@override
Widget build(BuildContext context) {
return PrimitiveCard( // New instance
child: Text('Hello'), // New instance
);
}Memory: Widgets are small, allocation is cheap, GC is efficient.
RenderObject Reuse
// RenderObjects are reused
void updateRenderObject(BuildContext context, RenderBox renderObject) {
renderObject.padding = padding; // Update existing object
}Memory: RenderObjects are long-lived, updates are in-place.
Debugging the Pipeline
Layout Debugging
void debugPaintSize() {
// In RenderBox
debugPaintSizeEnabled = true;
}Shows layout boundaries and baselines.
Repaint Debugging
void debugPaintRepaint() {
debugRepaintRainbowEnabled = true;
}Colors repainted areas differently on each frame.
Performance Overlay
MaterialApp(
showPerformanceOverlay: true,
// ...
)Shows GPU/CPU usage and frame rendering time.
Common Pitfalls
Unnecessary Rebuilds
Bad:
// Creates new child on every build
PrimitiveCard(
child: Container(
child: SomeExpensiveWidget(),
),
)Good:
// Cache expensive child
final _child = SomeExpensiveWidget();
PrimitiveCard(
child: Container(
child: _child,
),
)
// Or use const
PrimitiveCard(
child: Container(
child: const SomeExpensiveWidget(),
),
)Layout Thrashing
Bad:
// Multiple layout passes
child.layout(constraints);
if (child.size.width > 100) {
child.layout(tighterConstraints); // Re-layout!
}Good:
// Single layout pass with correct constraints
final constraints = shouldConstrain
? tighterConstraints
: normalConstraints;
child.layout(constraints);Paint Inefficiency
Bad:
bool shouldRepaint(oldDelegate) => true; // Always repaintsGood:
bool shouldRepaint(oldDelegate) {
return oldDelegate.color != color; // Only when needed
}Best Practices
Rendering Optimization Tips:
- Use
constconstructors - Prevents unnecessary rebuilds - Implement
shouldRepaintcarefully - Avoid unnecessary painting - Break widget trees at natural boundaries - Helps with targeted rebuilds
- Use
RepaintBoundaryfor isolated animations - Prevents cascading repaints - Avoid layout in paint - Separation of concerns improves performance
- Cache expensive calculations - Don’t recalculate in build()
Next: Learn about Design Decisions that shaped Primitive UI.