Primitives Explained
Learn about the foundational building blocks used in Primitive UI and why building from primitives provides unique insights into Flutter’s architecture.
What Are Primitives?
In Flutter, primitives are the lowest-level building blocks of the UI framework - the fundamental pieces that higher-level widgets are built upon. Primitive UI uses only these core components:
Core Primitives
- CustomPaint - Provides a canvas for custom drawing
- Canvas - Low-level drawing API for graphics
- GestureDetector - Handles touch and gesture input
- RenderBox - Base class for layout and painting
- RenderObject - Core of the rendering tree
Key Insight: By using only primitives, we bypass Flutter’s high-level widget layer and work directly with the rendering engine.
Why Build from Primitives?
Educational Value
Building from primitives provides deep understanding of:
- How rendering actually works - See the exact steps Flutter takes to draw pixels
- Layout algorithm mechanics - Understand constraint propagation and sizing
- Performance implications - Learn what makes widgets fast or slow
- Widget composition - Discover how complex widgets are assembled
What You Learn
// High-level (what you usually write)
Card(
child: Text('Hello'),
)
// Primitive-level (what's actually happening)
CustomPaint(
painter: _CardPainter(
color: color,
elevation: elevation,
borderRadius: borderRadius,
),
child: _CardLayout(
padding: padding,
child: Text('Hello'),
),
)CustomPaint & Canvas
CustomPaint Widget
CustomPaint is a widget that provides a canvas for drawing custom graphics.
Key Concepts:
- Takes a
CustomPainterfor foreground and/or background painting - Provides a
Canvasobject during paint phase - Can have a child widget that appears above/below the painting
In Primitive UI:
Used by PrimitiveCard, PrimitiveToggleSwitch, PrimitiveSlider, and PrimitiveCircularProgress for rendering.
Canvas API
Canvas provides low-level drawing operations:
// Drawing a shadow
canvas.drawShadow(path, shadowColor, elevation, true);
// Drawing a rounded rectangle
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, width, height),
Radius.circular(borderRadius),
),
paint,
);
// Drawing a circle
canvas.drawCircle(center, radius, paint);Performance:
- Hardware-accelerated on most platforms
- Efficient for simple shapes
- Composites into layers for optimal rendering
CustomPainter
A class that defines how to paint on a canvas:
class _CardPainter extends CustomPainter {
final Color color;
final double borderRadius;
final double elevation;
_CardPainter({
required this.color,
required this.borderRadius,
required this.elevation,
});
@override
void paint(Canvas canvas, Size size) {
// Drawing logic here
}
@override
bool shouldRepaint(_CardPainter oldDelegate) {
// Return true only if visual properties changed
return oldDelegate.color != color ||
oldDelegate.borderRadius != borderRadius ||
oldDelegate.elevation != elevation;
}
}shouldRepaint(): Critical for performance - only repaint when necessary.
RenderBox & Layout
Understanding RenderBox
RenderBox is the base class for render objects that use Cartesian coordinates.
Responsibilities:
- Layout - Calculate size and position of children
- Paint - Draw visual content
- Hit Testing - Determine if a point is inside the widget
Box Constraints
Flutter’s layout uses a constraint-based system:
class BoxConstraints {
final double minWidth;
final double maxWidth;
final double minHeight;
final double maxHeight;
}The Layout Protocol:
- Parent passes constraints down to child
- Child sizes itself within those constraints
- Child returns its size to parent
- Parent positions child
// In VStack's performLayout()
child.layout(childConstraints, parentUsesSize: true);
// Now child.size is set
totalHeight += child.size.height;Intrinsic Dimensions
Intrinsic dimensions help with layout decisions:
// Minimum width needed
double computeMinIntrinsicWidth(double height) {
return child.getMinIntrinsicWidth(height) + padding.horizontal;
}
// Maximum width wanted
double computeMaxIntrinsicWidth(double height) {
return child.getMaxIntrinsicWidth(height) + padding.horizontal;
}Performance Note: Intrinsic dimension calculations can be expensive. Use sparingly and cache when possible.
GestureDetector
Touch Input Handling
GestureDetector provides gesture recognition without any rendering:
GestureDetector(
onTap: _handleTap,
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
child: CustomPaint(...),
)In Primitive UI:
Used by PrimitiveToggleSwitch, PrimitiveButton, PrimitiveSlider, and PrimitiveInput for gesture detection and interaction.
Gesture Arena
Flutter uses a “gesture arena” to resolve competing gestures:
- Multiple gesture recognizers may claim the same touch
- Gesture arena determines which recognizer “wins”
- Winner gets the gesture, losers are rejected
Example: Scroll vs. Tap
- Both want the initial touch
- After movement threshold, scroll wins
- Tap is cancelled
RenderObject Tree
The Rendering Pipeline
Flutter’s rendering happens in three trees:
- Widget Tree - Immutable configuration (what you write)
- Element Tree - Manages widget lifecycle
- RenderObject Tree - Performs layout and painting
Widget build()
↓
Element manages
↓
RenderObject rendersIn Primitive UI
VStack (Widget)
↓
_VStackLayout (Widget)
↓
_RenderVStack (RenderBox)The RenderBox does the actual work:
performLayout()- Calculate positionspaint()- Draw to canvashitTest()- Handle touches
Paint Optimization
Layer Caching
Flutter automatically caches painting in layers:
// This gets cached as a layer
CustomPaint(
painter: _CardPainter(...),
child: child,
)Benefits:
- Reduces re-painting when nothing changed
- Hardware-accelerated composition
- Efficient scrolling and animations
shouldRepaint Strategy
Only repaint when visual properties change:
@override
bool shouldRepaint(_CardPainter oldDelegate) {
// Compare all visual properties
return oldDelegate.color != color ||
oldDelegate.borderRadius != borderRadius ||
oldDelegate.elevation != elevation;
}Bad:
bool shouldRepaint(_CardPainter oldDelegate) => true; // Always repaints!Good:
bool shouldRepaint(_CardPainter oldDelegate) {
return oldDelegate.color != color; // Only when needed
}Multi-Child Layout
Parent Data
Used to store child-specific layout information:
class _VStackParentData extends ContainerBoxParentData<RenderBox> {
// Offset is inherited from ContainerBoxParentData
// Can add custom fields here
}Iteration Pattern
RenderBox? child = firstChild;
while (child != null) {
final childParentData = child.parentData as _VStackParentData;
// Process child
child.layout(constraints, parentUsesSize: true);
// Move to next
child = childParentData.nextSibling;
}Comparison: Primitive vs High-Level
PrimitiveCard vs Card
High-Level Card:
Card(
elevation: 4.0,
child: Text('Hello'),
)Uses: Material → PhysicalShape → CustomPaint → Canvas
Primitive PrimitiveCard:
PrimitiveCard(
elevation: 4.0,
child: Text('Hello'),
)Uses: CustomPaint → Canvas (directly)
Differences:
- Card has more features (shape, theme integration, semantic labels)
- PrimitiveCard is simpler and more explicit
- Card is production-ready, PrimitiveCard is educational
VStack vs Column, HStack vs Row
High-Level Column:
Column(
children: [
Flexible(child: Text('A')),
Text('B'),
],
)Features: Flexible, Expanded, Spacer, baseline alignment, etc.
Primitive VStack:
VStack(
spacing: 8.0,
children: [
Text('A'),
Text('B'),
],
)Features: Simple spacing and alignment only.
Primitive HStack:
HStack(
spacing: 8.0,
children: [
Text('A'),
HCustomExpanded(child: Container()),
Text('B'),
],
)Features: Full flex support with custom Flexible/Expanded widgets.
Learning Value:
- VStack shows the core layout algorithm
- HStack demonstrates the flex algorithm and space distribution
- Column/Row add complexity for production features
- Understanding VStack/HStack helps you use Column/Row better
PrimitiveButton vs ElevatedButton/TextButton
High-Level Buttons:
ElevatedButton(
onPressed: () {},
child: Text('Click'),
)Uses: Material theme system, ink splash, complex state management
Primitive PrimitiveButton:
PrimitiveButton(
onPressed: () {},
variant: PrimitiveButtonVariant.primary,
child: Text('Click'),
)Uses: Direct GestureDetector, MouseRegion, AnimationController
Differences:
- Material buttons have ink splash effects
- PrimitiveButton uses simple scale animation
- PrimitiveButton shows explicit state management
- Educational focus on interaction patterns
PrimitiveInput vs TextField
High-Level TextField:
TextField(
decoration: InputDecoration(
labelText: 'Email',
),
)Uses: Material decoration system, automatic focus behavior
Primitive PrimitiveInput:
PrimitiveInput(
placeholder: 'Email',
variant: PrimitiveInputVariant.outline,
)Uses: Direct EditableText, manual focus management, custom decoration
Differences:
- TextField has built-in validation, labels, helpers
- PrimitiveInput shows low-level text input handling
- Educational focus on focus management and keyboard input
Performance Characteristics
CustomPaint Performance
Fast:
- Simple shapes (circles, rectangles)
- Solid colors
- Cached painters (shouldRepaint returns false)
Slow:
- Complex paths
- Many gradient stops
- Filters and effects
- Always repainting
Layout Performance
Efficient:
child.layout(constraints, parentUsesSize: true);
// Use child.size immediatelyInefficient:
child.layout(constraints, parentUsesSize: false);
// But then access child.size anyway
// Causes re-layout!Memory Management
Widget Lifecycle
Widgets are immutable and short-lived:
// New widget every build
PrimitiveCard(
elevation: 4.0,
child: Text('Hello'),
)RenderObject Lifecycle
RenderObjects are long-lived and mutable:
// Same RenderObject, updated properties
set elevation(double value) {
if (_elevation == value) return;
_elevation = value;
markNeedsLayout(); // Schedule re-layout
}Key Takeaways
What Primitive UI Teaches:
- Widgets are Configuration - They just describe what you want
- RenderObjects Do the Work - Layout, paint, hit test
- Constraints Flow Down, Sizes Flow Up - Core layout principle
- Painting is Expensive - Optimize shouldRepaint()
- Composition is Powerful - Build complex UIs from simple pieces
Next: Learn about the Rendering Pipeline to see how these primitives work together.