Design Decisions
Explore the architectural decisions, trade-offs, and rationale behind Primitive UI’s implementation.
Project Goals
Primary: Educational Value
Primitive UI was built to teach how Flutter’s rendering engine works:
What It Achieves:
- ✅ Demonstrates core rendering concepts
- ✅ Shows layout algorithm mechanics
- ✅ Illustrates custom painting techniques
- ✅ Reveals performance optimization strategies
What It Sacrifices:
- ❌ Feature completeness (compared to Material/Cupertino widgets)
- ❌ Production-ready robustness (error handling, edge cases)
- ❌ Advanced accessibility (focuses on basic semantic labels)
- ❌ Full platform parity (some desktop-specific features limited)
Secondary: Clean Implementation
Code is written to be readable and understandable:
// Clear, explicit variable names
final double thumbMinX = thumbPadding + thumbRadius;
final double thumbMaxX = trackWidth - thumbPadding - thumbRadius;
// Documented calculations
// Shadow opacity scales with elevation using Material Design spec
final double shadowOpacity = (elevation / 24.0).clamp(0.0, 0.3);
// Structured in logical sections
void performLayout() {
// 1. Calculate constraints
// 2. Layout children
// 3. Calculate own size
// 4. Position children
}Component Selection
Why These Components?
Primitive UI includes a carefully selected set of components that each demonstrate different aspects of Flutter’s rendering system:
UI Components (6)
PrimitiveCard - Demonstrates:
- CustomPaint usage
- Shadow rendering
- Custom layout with padding
- shouldRepaint optimization
PrimitiveToggleSwitch - Demonstrates:
- Gesture handling
- Animation integration
- Color interpolation
- State management
PrimitiveButton - Demonstrates:
- Stateful interactions (hover, press)
- Scale animations
- Variant-based styling
- MouseRegion for desktop
- Conditional rendering (loading states)
PrimitiveSlider - Demonstrates:
- Drag gesture handling
- Value mapping and clamping
- Real-time visual feedback
- TweenAnimationBuilder for smooth transitions
- Semantic accessibility
PrimitiveInput - Demonstrates:
- EditableText integration
- Focus management
- Keyboard input handling
- State-based styling (focus, hover, error)
- Complex widget composition
PrimitiveCircularProgress - Demonstrates:
- Rotation animations
- Indeterminate vs determinate states
- Canvas arc drawing
- Continuous animation loops
Layout Components (3)
VStack - Demonstrates:
- Vertical multi-child layout
- Constraint system
- Intrinsic sizing
- RTL support
HStack - Demonstrates:
- Horizontal multi-child layout
- Flex-based sizing (Flexible/Expanded)
- MainAxisAlignment distribution
- Complex constraint negotiation
ZStack - Demonstrates:
- Layered painting
- Alignment geometry
- Fit modes
- Paint ordering
Together: These components provide comprehensive coverage of Flutter’s rendering patterns, from basic painting to complex gesture handling and layout algorithms.
PrimitiveCard Design
Why CustomPaint Over Material?
Option 1: Use Material Widget
Material(
elevation: elevation,
borderRadius: BorderRadius.circular(borderRadius),
child: Padding(
padding: padding,
child: child,
),
)Chosen: Direct CustomPaint
CustomPaint(
painter: _CardPainter(
color: color,
elevation: elevation,
borderRadius: borderRadius,
),
child: _CardLayout(padding: padding, child: child),
)Rationale:
- Shows exactly how rendering works
- No hidden abstraction layers
- Clear cause and effect
- Educational transparency
Shadow Implementation
Decision: Use Canvas.drawShadow()
Alternatives Considered:
- Multiple Colored Layers:
// Draw multiple RRects with decreasing opacity
for (int i = 0; i < elevation; i++) {
canvas.drawRRect(
rrect.shift(Offset(0, i)),
Paint()..color = Colors.black.withOpacity(0.1 / i),
);
}❌ Rejected: Too slow, inaccurate shadows
- Box Shadow Blur:
Paint()..maskFilter = MaskFilter.blur(BlurStyle.normal, elevation);❌ Rejected: Doesn’t match Material Design spec
- Canvas.drawShadow():
canvas.drawShadow(path, shadowColor, elevation, true);✅ Chosen: Platform-native, accurate, performant
Padding Strategy
Decision: Custom RenderShiftedBox
Why Not Padding Widget?
// Could wrap child
Padding(
padding: padding,
child: child,
)Chosen Approach:
class _RenderCardLayout extends RenderShiftedBox {
// Manual layout with padding
void performLayout() {
// 1. Deflate constraints by padding
// 2. Layout child
// 3. Add padding back to size
// 4. Position child with padding offset
}
}Rationale:
- Demonstrates RenderShiftedBox implementation
- Shows constraint deflation pattern
- Teaches manual layout with padding
- Implements intrinsic sizing correctly
- One fewer widget in tree
Additional Features:
- Tap interaction with
GestureDetector - Visual feedback (elevation changes on press)
- Implicit animations via
TweenAnimationBuilder - Smooth transitions between states
PrimitiveToggleSwitch Design
Animation Approach
Decision: AnimationController with CurvedAnimation
Why 200ms Duration?
Testing showed:
- 100ms: Too fast, jarring
- 200ms: Smooth and responsive ✅
- 300ms: Too slow, feels sluggish
Why Curves.easeInOut?
Curves.linear // ❌ Mechanical
Curves.easeIn // ❌ Starts slow
Curves.easeOut // ❌ Ends abruptly
Curves.easeInOut // ✅ Natural motionColor Interpolation
Decision: Lerp entire color
final Color trackColor = Color.lerp(
inactiveColor.withOpacity(0.5),
activeColor,
animationValue,
)!;Why Not Separate RGB Interpolation?
// Could interpolate each channel
final r = lerpDouble(inactive.red, active.red, value);
final g = lerpDouble(inactive.green, active.green, value);
final b = lerpDouble(inactive.blue, active.blue, value);Rationale:
Color.lerpis optimized- Handles alpha channel correctly
- More concise
- Standard Flutter approach
Thumb Size Ratio
Decision: 40% of track height
final double thumbRadius = trackHeight * 0.4;Tested Ratios:
- 30%: Too small, hard to see
- 35%: Better, still small
- 40%: Good balance ✅
- 45%: Tight fit, less padding
- 50%: Touches track edges
Layout Component Designs
VStack Design
Alignment Options
Decision: Use Flutter’s standard alignment enums
VStack(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [...],
)Why Use Standard Enums?
Rationale:
- Familiarity: Developers already know these from Column
- Educational: Shows how standard Flutter enums work
- Progressive Learning: VStack mirrors Column’s API, making concepts transferable
- Complete Support: Includes all MainAxisAlignment modes (start, center, end, spaceBetween, spaceAround, spaceEvenly)
What It Demonstrates:
- How
MainAxisAlignmentdistributes space vertically - How
CrossAxisAlignmentpositions children horizontally - How
MainAxisSizeaffects stack sizing (max vs min) - RTL support through
TextDirection
Flex Support (Both VStack & HStack)
Flexible and Expanded Patterns
Decision: Full flex support in both VStack and HStack
// Vertical flex
VStack(
children: [
Text('Fixed'),
VCustomExpanded(
child: Container(color: Colors.red),
),
Text('Fixed'),
],
)
// Horizontal flex
HStack(
children: [
Text('Fixed'),
HCustomExpanded(
child: Container(color: Colors.blue),
),
Text('Fixed'),
],
)Why Include Flex?
- Educational Value: Demonstrates the two-pass flex algorithm
- Common Pattern: Flex is fundamental in Flutter layouts
- Constraint Negotiation: Shows complex layout scenarios
- Real-world Usage: Mirrors Row/Column behavior
How It Works:
- Pass 1: Layout non-flex children, calculate remaining space
- Pass 2: Distribute remaining space to flex children based on flex factors
- Supports both
FlexFit.tight(Expanded) andFlexFit.loose(Flexible)
HStack Design
MainAxisAlignment Distribution
Decision: Support all 6 alignment modes
enum MainAxisAlignment {
start, center, end,
spaceBetween, spaceAround, spaceEvenly,
}Educational Value:
- Shows space distribution algorithms
- Demonstrates when each mode is useful
- Teaches the math behind layout
RTL Support
Decision: Full RTL support with TextDirection
Both VStack and HStack properly handle right-to-left text direction:
// HStack resolves positioning based on text direction
if (textDirection == TextDirection.rtl) {
currentX -= child.size.width;
childParentData.offset = Offset(currentX, y);
currentX -= betweenSpace;
} else {
childParentData.offset = Offset(currentX, y);
currentX += child.size.width + betweenSpace;
}Rationale:
- Demonstrates proper internationalization
- Shows alignment resolution
- Teaches TextDirection handling
- Production-ready behavior
Interactive Component Designs
PrimitiveButton Design
Variant System
Decision: Six distinct button variants
enum PrimitiveButtonVariant {
primary, secondary, destructive,
outline, ghost, link,
}Why These Variants?
- primary/secondary/destructive: Common semantic actions
- outline/ghost: Visual hierarchy without color
- link: Minimal style for inline actions
Educational Value:
- Shows conditional styling patterns
- Demonstrates color scheme usage
- Teaches variant-based component design
State Management
Decision: Track hover, pressed, disabled states
bool _isHovered = false;
bool _isPressed = false;
bool get _effectiveDisabled => widget.isDisabled || widget.isLoading;Why Multiple States?
- Hover: Desktop interaction feedback
- Pressed: Touch/click visual feedback
- Disabled: Prevents interaction
- Loading: Async action indicator
Interaction:
- Uses
MouseRegionfor hover (desktop) - Uses
GestureDetectorfor press (all platforms) - Combines
AnimationControllerwithScaleTransition
PrimitiveSlider Design
Gesture Handling
Decision: Support both tap and drag
GestureDetector(
onTapDown: (details) => _handleTapDown(details, constraints),
onHorizontalDragStart: (details) => _handleDragStart(details, constraints),
onHorizontalDragUpdate: (details) => _handleDragUpdate(details, constraints),
onHorizontalDragEnd: _handleDragEnd,
)Why Both Gestures?
- Tap: Quick value selection
- Drag: Precise value adjustment
- Real-world UX: Matches native sliders
Value Mapping
Decision: Map screen coordinates to value range
final double percent = relativeDx / effectiveWidth;
final double newValue = widget.min + percent * (widget.max - widget.min);Educational Value:
- Linear interpolation math
- Coordinate system transformations
- Clamping and bounds checking
PrimitiveInput Design
EditableText Integration
Decision: Use low-level EditableText instead of TextField
EditableText(
controller: _controller,
focusNode: _focusNode,
style: textStyle,
cursorColor: colorScheme.primary,
// ...
)Why EditableText?
- Primitives Focus: Lower-level than TextField
- Full Control: Custom decoration and layout
- Educational: Shows text input fundamentals
What You Learn:
- Focus management
- Cursor customization
- Text selection handling
- Input masking and validation
Variant System
Decision: Three input styles
enum PrimitiveInputVariant {
outline, // Full border
filled, // Background color
flushed, // Bottom border only
}Rationale:
- Common design patterns
- Different visual weights
- Shows border customization
PrimitiveCircularProgress Design
Indeterminate Animation
Decision: Continuous rotation for indeterminate state
if (widget.value == null) {
_controller.repeat();
}
// In painter
if (isIndeterminate) {
canvas.rotate(progress * 2 * math.pi);
canvas.drawArc(/* fixed arc */);
}Why Rotation?
- Visual Feedback: Shows activity
- Standard Pattern: Matches platform conventions
- Simplicity: Single animation controller
Determinate vs Indeterminate
Decision: Support both modes with same component
// Indeterminate
PrimitiveCircularProgress()
// Determinate
PrimitiveCircularProgress(value: 0.7)Educational Value:
- Conditional rendering logic
- Animation control patterns
- Null safety for optional behavior
ZStack Design
Fit Modes
Decision: Three fit modes (loose, expand, passthrough)
enum ZStackFit {
loose, // StackFit.loose equivalent
expand, // StackFit.expand equivalent
passthrough, // StackFit.passthrough equivalent
}Why These Three?
- loose: Most common - children size themselves
- expand: Common pattern - children fill stack
- passthrough: Educational - shows constraint passing
Educational Value:
- Shows how constraints flow through layout
- Demonstrates different sizing strategies
- Each mode teaches a different constraint pattern
CustomPositioned Support
Decision: Full positioned child support via CustomPositioned
What We Provide:
ZStack(
children: [
Container(width: 200, height: 200, color: Colors.blue),
CustomPositioned(
left: 10,
top: 10,
right: 10,
bottom: 10,
width: 50,
height: 50,
child: Icon(Icons.close),
),
],
)Why Include Positioning?
- Educational Value: Demonstrates parent data pattern
- Real-world Use: Badges, overlays, and absolute positioning are common
- Layout Algorithm: Shows two-pass layout (non-positioned first, positioned second)
- Constraint Calculation: Teaches how positioned constraints are computed from edges
Implementation Details:
// Two-pass layout algorithm
1. Layout non-positioned children → determine stack size
2. Layout positioned children with calculated constraints
3. Position all children based on their propertiesHow Positioning Works:
- Uses
ParentDataWidgetto attach positioning data - Calculates constraints based on
left,right,width, etc. - Non-positioned children use alignment
- Positioned children ignore alignment
Rationale:
- Positioned is a fundamental layout concept
- Demonstrates advanced parent data usage
- Shows constraint negotiation complexity
- Teaches the difference between aligned and absolute positioning
API Design Principles
Explicit Over Implicit
// ✅ Explicit
PrimitiveCard(
color: Color(0xFFFFFFFF),
borderRadius: 8.0,
elevation: 2.0,
padProgressive Complexity** - Start simple (VStack) → Advanced (HStack with flex)
2. **Clear Abstraction Levels** - Primitives → UI Components → Layout Components
3. **Explicit Code** - Easy to follow and understand
4. **Comprehensive Tests** - Catches regressions
5. **Real-world Patterns** - Button variants, input states, sliders match familiar UIs
### What We'd Change
1. **Add More Examples** - Real-world usage patterns and edge cases
2. **Platform Differences** - Document web vs mobile vs desktop behavior differences
3. **Animation Details** - More detailed explanations about vsync, frame timing, and animation curves
4. **Performance Metrics** - Actual benchmark data comparing primitives to Flutter widgets
5. **Accessibility** - More comprehensive semantic support beyond basic labels
6. **Error Handling** - Better validation and error messages for constraint violations
```dart
const PrimitiveCard({
required this.child,
this.elevation = 2.0,
}) :Progressive complexity works** - Simple components (Card) → Complex components (Button, Input)
- **Trade-offs are necessary** - Education vs features, simplicity vs flexibility
- **Good defaults matter** - 200ms animations, 40% thumb size, 6px border radius
- **Documentation is crucial** - Code should be self-explanatory
- **Testing validates behavior** - Not just that it works, but that it works correctly
- **Real patterns are valuable** - Button variants, flex layouts, and gestures mirror production code
- Catch errors early
- Clear error messages
- Better debugging experience
- Document constraints
### Required vs Optional
**Decision Matrix:**
| Parameter | Required? | Reason |
|-----------|-----------|--------|
| child | ✅ Yes | No card without content |
| value | ✅ Yes | Toggle needs state |
| onChanged | ✅ Yes | Toggle needs callback |
| children | ✅ Yes | Layout needs content |
| color | ❌ No | Sensible default exists |
| elevation | ❌ No | Common default (2.0) |
| spacing | ❌ No | Could be 0.0 |
## Performance Trade-offs
### Layout Efficiency vs Features
**Chosen:** Simple, fast layout
```dart
// Fast but limited
void performLayout() {
child.layout(constraints, parentUsesSize: true);
size = child.size;
}Not Chosen: Complex but flexible
// Slower but supports flex
void performLayout() {
// Calculate flex
// Distribute space
// Multiple layout passes
}Rationale: Performance clarity over feature completeness
Paint Optimization
Chosen: Careful shouldRepaint
bool shouldRepaint(oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.borderRadius != borderRadius ||
oldDelegate.elevation != elevation;
}Not Chosen: Always repaint
bool shouldRepaint(oldDelegate) => true;Impact:
- Cached repaints: ~0.1ms
- Full repaints: ~2-5ms
- 20-50x performance improvement
Testing Strategy
What We Test
✅ Widget Construction:
testWidgets('PrimitiveCard builds', (tester) async {
await tester.pumpWidget(
MaterialApp(home: PrimitiveCard(child: Text('Test'))),
);
expect(find.text('Test'), findsOneWidget);
});✅ Property Updates:
testWidgets('VStack spacing updates', (tester) async {
// Test spacing changes trigger relayout
});✅ Interactions:
testWidgets('Toggle switches on tap', (tester) async {
await tester.tap(find.byType(PrimitiveToggleSwitch));
// Verify state changed
});✅ Accessibility:
testWidgets('Slider has semantic labels', (tester) async {
// Verify semantic properties for screen readers
});What We Don’t Test
❌ Pixel-perfect rendering (too brittle) ❌ Performance benchmarks (platform-dependent) ❌ Complex accessibility flows (limited implementation)
Rationale: Focus on behavioral correctness
Documentation Philosophy
Code Comments
What Gets Commented:
// Shadow opacity scales with elevation using Material Design spec
final double shadowOpacity = (elevation / 24.0).clamp(0.0, 0.3);What Doesn’t:
// NOT: Increment counter
counter++; // Self-explanatoryPrinciple: Comment the “why”, not the “what”
API Documentation
Complete dartdoc for public API:
/// A card component built from scratch using CustomPaint.
///
/// Features:
/// - Rounded corners (customizable border radius)
/// - Shadow/elevation effect
/// - Custom background color
/// - Padding for child content
class PrimitiveCard extends StatelessWidget {
/// The child widget to display inside the card
final Widget child;
// ...
}Lessons Learned
What Worked Well
- Progressive Complexity - Start simple (Card, Toggle) → Advanced (Input, Flex layouts, Positioning)
- Clear Abstraction Levels - Custom RenderBox → Layout → Interactive components
- Explicit Code - Easy to follow and understand
- Comprehensive Tests - Catches regressions and validates behavior
- Real-world Patterns - Variant systems, flex layouts, positioning mirror production code
What We’d Change
- Add More Examples - Real-world usage patterns and edge cases
- Platform Differences - Document web vs mobile vs desktop behavior differences
- Animation Details - More detailed explanations about vsync, frame timing, and curves
- Performance Metrics - Actual benchmark data comparing primitives to Flutter widgets
- Error Handling - Better validation and error messages for constraint violations
Conclusion
Key Takeaways:
- Primitives teach fundamentals - Building from scratch reveals how things work
- Trade-offs are necessary - Education vs features, simplicity vs flexibility
- Good defaults matter - 200ms animations, 40% thumb size, etc.
- Documentation is crucial - Code should be self-explanatory
- Testing validates behavior - Not just that it works, but that it works correctly
Primitive UI represents deliberate choices to maximize learning while maintaining clean, performant code. Understanding these decisions helps you make better choices in your own Flutter development.