Skip to Content
ComponentsPrimitiveToggleSwitch

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

Primitive Toggle Switch ShowcaseOpen in new tab ↗

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

ParameterTypeRequiredDefaultDescription
valueboolCurrent toggle state (true = on, false = off)
onChangedValueChanged<bool>Callback invoked when the user taps the switch
activeColorColorColor(0xFF2196F3)Track color when switch is ON (default: blue)
inactiveColorColorColor(0xFF9E9E9E)Track color when switch is OFF (default: grey)
widthdouble50.0Total width of the switch (must be > height)
heightdouble30.0Total height of the switch
semanticsLabelString?nullAccessibility label (defaults to "Toggle switch")

Examples

Custom Colors

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

Source Code

View the complete implementation in the GitHub repository .

Last updated on