PrimitiveToggleSwitch
An animated toggle switch component built entirely from scratch using CustomPaint, GestureDetector, and AnimationController.
Overview
PrimitiveToggleSwitch provides a smooth, animated toggle switch using only primitive Flutter components. It demonstrates custom painting, gesture handling, and animation integration without relying on Flutter’s Material or Cupertino switch widgets.
Primitives Used: CustomPaint, Canvas, GestureDetector, AnimationController
Basic Usage
import 'package:primitive_ui/primitive_ui.dart';
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
bool _isEnabled = false;
@override
Widget build(BuildContext context) {
return PrimitiveToggleSwitch(
value: _isEnabled,
onChanged: (value) {
setState(() => _isEnabled = value);
},
);
}
}API Reference
Constructor
PrimitiveToggleSwitch({
Key? key,
required bool value,
required ValueChanged<bool> onChanged,
Color activeColor = const Color(0xFF2196F3),
Color inactiveColor = const Color(0xFF9E9E9E),
double width = 50.0,
double height = 30.0,
String? semanticsLabel,
})Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
value | bool | ✓ | — | Current toggle state (true = on, false = off) |
onChanged | ValueChanged<bool> | ✓ | — | Callback invoked when the user taps the switch |
activeColor | Color | ✗ | Color(0xFF2196F3) | Track color when switch is ON (default: blue) |
inactiveColor | Color | ✗ | Color(0xFF9E9E9E) | Track color when switch is OFF (default: grey) |
width | double | ✗ | 50.0 | Total width of the switch (must be > height) |
height | double | ✗ | 30.0 | Total height of the switch |
semanticsLabel | String? | ✗ | null | Accessibility label (defaults to "Toggle switch") |
Examples
Custom Colors
Success
PrimitiveToggleSwitch(
value: isEnabled,
onChanged: (value) => setState(() => isEnabled = value),
activeColor: Color(0xFF4CAF50), // Green
inactiveColor: Color(0xFFBDBDBD),
)Custom Size
// Large switch
PrimitiveToggleSwitch(
value: isEnabled,
onChanged: (value) => setState(() => isEnabled = value),
width: 70.0,
height: 40.0,
)
// Compact switch
PrimitiveToggleSwitch(
value: isEnabled,
onChanged: (value) => setState(() => isEnabled = value),
width: 40.0,
height: 24.0,
)Settings Panel
PrimitiveCard(
child: VStack(
spacing: 16.0,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Notifications'),
PrimitiveToggleSwitch(
value: notificationsEnabled,
onChanged: (value) {
setState(() => notificationsEnabled = value);
},
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Dark Mode'),
PrimitiveToggleSwitch(
value: darkModeEnabled,
onChanged: (value) {
setState(() => darkModeEnabled = value);
},
),
],
),
],
),
)Implementation Details
Animation System
The switch uses a CurvedAnimation with Curves.easeInOut for smooth state transitions:
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);Animation duration is set to 200ms for a responsive feel without being too abrupt.
Color Interpolation
Track color smoothly transitions between inactive and active states:
final Color trackColor = Color.lerp(
inactiveColor.withValues(alpha: 0.5),
activeColor,
animationValue,
)!;The inactive color is rendered at 50% opacity for a subdued appearance.
Thumb Positioning
The thumb (circular indicator) position is calculated based on animation value:
final double thumbX = thumbMinX + (thumbMaxX - thumbMinX) * animationValue;Where animationValue ranges from 0.0 (off) to 1.0 (on).
Visual Depth
A subtle shadow is applied to the thumb for visual depth:
final Paint shadowPaint = Paint()
..color = Color(0x40000000) // Semi-transparent black
..maskFilter = MaskFilter.blur(BlurStyle.normal, 2.0);
canvas.drawCircle(
thumbCenter.translate(0, 1.0), // Offset down slightly
thumbRadius,
shadowPaint,
);Performance Optimization
The shouldRepaint() method checks if animation or color values changed:
bool shouldRepaint(_ToggleSwitchPainter oldDelegate) {
return oldDelegate.animationValue != animationValue ||
oldDelegate.activeColor != activeColor ||
oldDelegate.inactiveColor != inactiveColor;
}Common Patterns
Disabled State
bool _isDisabled = true;
PrimitiveToggleSwitch(
value: isEnabled,
onChanged: _isDisabled
? null // Disable interaction
: (value) => setState(() => isEnabled = value),
inactiveColor: _isDisabled ? Colors.grey[300]! : Colors.grey,
)With Label
Row(
children: [
Text('Enable Feature'),
SizedBox(width: 16),
PrimitiveToggleSwitch(
value: isEnabled,
onChanged: (value) {
setState(() => isEnabled = value);
print('Feature ${value ? "enabled" : "disabled"}');
},
),
],
)Form Integration
class SettingsForm extends StatefulWidget {
@override
State<SettingsForm> createState() => _SettingsFormState();
}
class _SettingsFormState extends State<SettingsForm> {
bool _notifications = true;
bool _autoSave = false;
bool _analytics = false;
Map<String, bool> getSettings() {
return {
'notifications': _notifications,
'autoSave': _autoSave,
'analytics': _analytics,
};
}
@override
Widget build(BuildContext context) {
return VStack(
spacing: 12.0,
children: [
_buildSettingRow('Notifications', _notifications, (v) {
setState(() => _notifications = v);
}),
_buildSettingRow('Auto-save', _autoSave, (v) {
setState(() => _autoSave = v);
}),
_buildSettingRow('Analytics', _analytics, (v) {
setState(() => _analytics = v);
}),
],
);
}
Widget _buildSettingRow(
String label,
bool value,
ValueChanged<bool> onChanged,
) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
PrimitiveToggleSwitch(value: value, onChanged: onChanged),
],
);
}
}Design Rationale
Why 200ms Animation?
The 200ms duration provides a balance between:
- Responsiveness: Users perceive the change immediately
- Smoothness: Animation is visible and pleasing
- Efficiency: Short enough to not feel sluggish
Thumb Size Ratio
The thumb radius is 40% of track height, ensuring:
- Sufficient tap target size
- Visual clarity when in either position
- Comfortable padding within the track
Shadow Direction
Shadow is offset 1px downward to simulate light from above, following material design principles for natural depth perception.
Accessibility Considerations
Note: This primitive implementation does not include built-in accessibility features. For production use, consider:
- Adding semantic labels
- Implementing keyboard navigation
- Providing haptic feedback
- Including screen reader support
Related Components
- PrimitiveCard - Often contains toggle switches
- VStack - For vertical switch lists
Source Code
View the complete implementation in the GitHub repository .