Skip to Content
ArchitectureDesign Decisions

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:

  1. 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

  1. Box Shadow Blur:
Paint()..maskFilter = MaskFilter.blur(BlurStyle.normal, elevation);

❌ Rejected: Doesn’t match Material Design spec

  1. 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 motion

Color 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.lerp is 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 MainAxisAlignment distributes space vertically
  • How CrossAxisAlignment positions children horizontally
  • How MainAxisSize affects 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?

  1. Educational Value: Demonstrates the two-pass flex algorithm
  2. Common Pattern: Flex is fundamental in Flutter layouts
  3. Constraint Negotiation: Shows complex layout scenarios
  4. 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) and FlexFit.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 MouseRegion for hover (desktop)
  • Uses GestureDetector for press (all platforms)
  • Combines AnimationController with ScaleTransition

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 properties

How Positioning Works:

  • Uses ParentDataWidget to 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** - PrimitivesUI ComponentsLayout 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-explanatory

Principle: 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

  1. Progressive Complexity - Start simple (Card, Toggle) → Advanced (Input, Flex layouts, Positioning)
  2. Clear Abstraction Levels - Custom RenderBox → Layout → Interactive components
  3. Explicit Code - Easy to follow and understand
  4. Comprehensive Tests - Catches regressions and validates behavior
  5. Real-world Patterns - Variant systems, flex layouts, positioning mirror production code

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 curves
  4. Performance Metrics - Actual benchmark data comparing primitives to Flutter widgets
  5. 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.

Last updated on