PrimitiveCard
A container widget with shadow, rounded corners, and padding - all rendered using CustomPaint and Canvas. Now supports custom shadow colors and tap interactions.
Overview
PrimitiveCard provides a Material-style card appearance using only primitive Flutter components. It demonstrates how shadows, rounded corners, and elevation can be implemented from scratch without relying on Flutter’s built-in Card widget. It also includes basic tap interaction with visual feedback (elevation reduction).
Primitives Used: CustomPaint, Canvas, RenderShiftedBox, GestureDetector
Basic Usage
import 'package:primitive_ui/primitive_ui.dart';
PrimitiveCard(
child: Text('Hello World'),
onTap: () => print('Card tapped!'),
)API Reference
Constructor
PrimitiveCard({
Key? key,
required Widget child,
Color color = const Color(0xFFFFFFFF),
double borderRadius = 8.0,
double elevation = 2.0,
Color shadowColor = const Color(0xFF000000),
EdgeInsets padding = const EdgeInsets.all(16.0),
VoidCallback? onTap,
String? semanticsLabel,
Duration duration = const Duration(milliseconds: 200),
Curve curve = Curves.easeInOut,
})Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
child | Widget | ✓ | — | The widget to display inside the card |
color | Color | ✗ | Color(0xFFFFFFFF) | Background color of the card |
borderRadius | double | ✗ | 8.0 | Corner radius in logical pixels (must be >= 0) |
elevation | double | ✗ | 2.0 | Shadow depth in logical pixels (must be >= 0) |
shadowColor | Color | ✗ | Color(0xFF000000) | Color of the shadow (e.g., Colors.blue.shade800) |
padding | EdgeInsets | ✗ | EdgeInsets.all(16.0) | Internal spacing around the child widget |
onTap | VoidCallback? | ✗ | null | Callback invoked when the card is tapped. Provides visual feedback. |
semanticsLabel | String? | ✗ | null | Accessibility label for screen readers |
duration | Duration | ✗ | Duration(milliseconds: 200) | Duration for implicit style animations |
curve | Curve | ✗ | Curves.easeInOut | Curve for implicit style animations |
Examples
Different Elevations and Custom Shadow
Low
PrimitiveCard(
elevation: 2.0,
child: Text('Low elevation'),
)Tap Interaction
PrimitiveCard(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Card tapped!')),
);
},
elevation: 4.0,
child: Center(child: Text('Tap me to see a snackbar!')),
)Custom Styling
PrimitiveCard(
color: Color(0xFFE3F2FD), // Light blue
borderRadius: 16.0,
elevation: 8.0,
padding: EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: Column(
children: [
Text(
'Card Title',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text('Card content goes here'),
],
),
)Animated Styles
PrimitiveCard implicitly animates changes to its style properties.
PrimitiveCard(
// Animate color and elevation when selected
color: _isSelected ? Colors.blue.shade50 : Colors.white,
elevation: _isSelected ? 8.0 : 2.0,
duration: Duration(milliseconds: 300),
curve: Curves.easeOutBack,
onTap: () => setState(() => _isSelected = !_isSelected),
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text(_isSelected ? 'Selected' : 'Tap to Select'),
),
)Nested Layout
PrimitiveCard(
elevation: 4.0,
child: VStack(
spacing: 12.0,
children: [
Text('Settings',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)
),
PrimitiveToggleSwitch(
value: notificationsEnabled,
onChanged: (value) {
setState(() => notificationsEnabled = value);
},
),
],
),
)Implementation Details
Rendering Pipeline
The PrimitiveCard uses a multi-layered rendering approach:
- Custom Painter (
_CardPainter) handles the visual rendering (including customizable shadow color). - Custom Layout (
_RenderCardLayout) manages child positioning with padding. - Canvas Operations draw the shadow and rounded rectangle.
- GestureDetector handles tap events, providing visual feedback by temporarily reducing elevation.
Implicit Animations
The component uses TweenAnimationBuilder to smoothly transition between styles.
- A private
_CardStyleclass aggregates all animatable properties (color,elevation,borderRadius,shadowColor). - When properties change, the builder interpolates between the old and new
_CardStyle. - This provides smooth visual transitions for all style changes without requiring an explicit
AnimationController.
Shadow Calculation
Shadow opacity is calculated based on elevation using the Material Design scale, and now respects the shadowColor parameter:
final double shadowOpacity = (elevation / 24.0).clamp(0.0, 0.3);
final Color effectiveShadowColor = shadowColor.withOpacity(shadowOpacity);This ensures that higher elevations produce darker shadows, with a maximum opacity of 0.3, and the shadow’s hue is determined by shadowColor.
Layout System
The card uses a custom RenderShiftedBox implementation that:
- Calculates child constraints by subtracting padding from available space
- Positions the child with padding offset
- Computes intrinsic dimensions including padding
Performance Optimization
The shouldRepaint() method only returns true when visual properties change:
bool shouldRepaint(_CardPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.borderRadius != borderRadius ||
oldDelegate.elevation != elevation ||
oldDelegate.shadowColor != shadowColor;
}This prevents unnecessary repaints when the card state hasn’t changed.
Common Patterns
Card Grid Layout
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
spacing: 16.0,
),
itemBuilder: (context, index) {
return PrimitiveCard(
elevation: 2.0,
onTap: () => print('Card $index tapped!'),
child: Center(child: Text('Card $index')),
);
},
)Related Components
- VStack - For vertical layouts inside cards
- ZStack - For layered content inside cards
- PrimitiveToggleSwitch - Common card content
Source Code
View the complete implementation in the GitHub repository .