Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions apps/gallery/lib/components/ui/switch.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutterwindcss/flutterwindcss.dart';

import '_utils/tap_target.dart';

/// shadcn's switch sizes. `md` is shadcn's `default` size (`default` is a Dart
/// reserved word, so it cannot be an enum constant). Geometry: md = 32×18 track /
/// 16 thumb (shadcn `w-8 h-[1.15rem]` / `size-4`); sm = 24×14 / 12 (`w-6 h-3.5` /
/// `size-3`).
enum SwitchSize { sm, md }

/// A Material-free, themeable toggle switch — shadcn parity. Copy-paste source you
/// own.
///
/// **Intention-revealing & controlled:** `Switch(value: on, onChanged: (v) => …)`
/// reads as the artifact. Like shadcn/Radix it is **controlled** — the parent owns
/// `value`; a tap (or Space/Enter) calls `onChanged(!value)`. The widget holds no
/// value state, only the toggle *animation*.
///
/// **Animated, dependency-free.** A 200ms `easeInOut` `AnimationController` drives
/// one `t` (0 = off → 1 = on) that slides the thumb
/// (`AlignmentDirectional.lerp(centerStart, centerEnd, t)`) and lerps the track
/// color (`Color.lerp(input, primary, t)`). No `flutter_animate` — a state toggle
/// is a built-in implicit animation, so a copied `Switch` drags in nothing.
///
/// **Canonical interaction pattern (mirrors [Button]).** It sources its own
/// focus/hover via [FocusableActionDetector], activates on Space/Enter
/// (`ActivateIntent`), shows a focus-*visible* `ring`, and wraps its hit layer in
/// [MinTapTarget] for a ≥48px touch target around the ~18px visual (mobile-first).
/// `onChanged == null` disables it (`opacity-50`, non-interactive) — the Flutter
/// idiom.
///
/// **Accessibility:** `Semantics(toggled: value, enabled:, label:)` — a switch role
/// announcing on/off. A standalone switch is often named by an adjacent label;
/// pass [semanticLabel] when it has no other accessible name.
///
/// **Faithful deviations (documented, not bugs):** `shadow-xs` is dropped (the
/// shape + thumb are the affordance). The dark-only refinements
/// (`dark:…unchecked:bg-input/80` and the per-state dark thumb colors) are dropped
/// — the thumb is `background` in both brightnesses (faithful in light; in dark the
/// *unchecked* thumb is slightly lower-contrast, while the "on" state stays high-
/// contrast). Same precedent as Badge's/Input's dropped `dark:` refinements.
class Switch extends StatefulWidget {
const Switch({
super.key,
required this.value,
this.onChanged,
this.size = SwitchSize.md,
this.focusNode,
this.autofocus = false,
this.semanticLabel,
});

/// Whether the switch is on. The parent owns this (controlled).
final bool value;

/// Called with the toggled value when the user flips the switch. `null`
/// disables the switch (the Flutter idiom).
final ValueChanged<bool>? onChanged;

/// The switch size (shadcn `default` → [SwitchSize.md], or [SwitchSize.sm]).
final SwitchSize size;

/// External focus control. When null, the detector manages its own.
final FocusNode? focusNode;

/// Whether to focus the switch on first build.
final bool autofocus;

/// Screen-reader name (e.g. "Wi-Fi"). Omit when an adjacent label already names
/// the control.
final String? semanticLabel;

/// Whether the switch is interactive.
bool get enabled => onChanged != null;

@override
State<Switch> createState() => _SwitchState();
}

class _SwitchState extends State<Switch> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final CurvedAnimation _t;
bool _focused = false;

@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
value: widget.value ? 1 : 0,
);
_t = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
}

@override
void didUpdateWidget(Switch old) {
super.didUpdateWidget(old);
if (widget.value != old.value) {
_controller.animateTo(widget.value ? 1 : 0);
}
}

@override
void dispose() {
// Dispose the CurvedAnimation first — it removes its status listener from the
// controller — then the controller it wraps.
_t.dispose();
_controller.dispose();
super.dispose();
}

void _set(VoidCallback fn) {
if (mounted) setState(fn);
}

void _toggle() => widget.onChanged?.call(!widget.value);

/// Track/thumb geometry per size, in utility units (×4 logical px).
({double trackW, double trackH, double thumbSize, double pad}) _geometry() {
return switch (widget.size) {
SwitchSize.sm => (trackW: 6, trackH: 3.5, thumbSize: 3, pad: 0.5),
SwitchSize.md => (trackW: 8, trackH: 4.5, thumbSize: 4, pad: 0.5),
};
}

@override
Widget build(BuildContext context) {
final c = context.fw.colors;
final enabled = widget.enabled;
final g = _geometry();

// The thumb is static during the toggle (only its *position* animates), so it
// is passed as `AnimatedBuilder`'s `child` and built once — not rebuilt every
// frame (the canonical `AnimatedBuilder` idiom).
final thumb = const SizedBox.shrink().tw.size(g.thumbSize).bg(c.background).roundedFull;

final visual = AnimatedBuilder(
animation: _t,
child: thumb,
builder: (context, child) {
final t = _t.value;
final trackColor = Color.lerp(c.input, c.primary, t)!;
final thumbAlign =
AlignmentDirectional.lerp(
AlignmentDirectional.centerStart,
AlignmentDirectional.centerEnd,
t,
)!;

var track = Align(alignment: thumbAlign, child: child).tw
.w(g.trackW)
.h(g.trackH)
.px(g.pad)
.bg(trackColor)
.roundedFull
// Mirrors shadcn's `border border-transparent`: reserves the 1px
// border-box edge so the thumb's inner area is stable.
.border(1, color: const Color(0x00000000));
if (_focused && enabled) {
track = track.ring(3, color: c.ring.withValues(alpha: 0.5));
}
if (!enabled) {
track = track.opacity(0.5);
}
return track;
},
);

return Semantics(
toggled: widget.value,
enabled: enabled,
label: widget.semanticLabel,
child: FocusableActionDetector(
enabled: enabled,
focusNode: widget.focusNode,
autofocus: widget.autofocus,
mouseCursor: enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onShowFocusHighlight: (f) => _set(() => _focused = f),
shortcuts: const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
},
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
_toggle();
return null;
},
),
},
child: MinTapTarget(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled ? _toggle : null,
child: visual,
),
),
),
);
}
}
32 changes: 32 additions & 0 deletions apps/gallery/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'components/ui/button.dart';
import 'components/ui/card.dart';
import 'components/ui/badge.dart';
import 'components/ui/input.dart';
import 'components/ui/switch.dart';

void main() => runApp(const GalleryApp());

Expand Down Expand Up @@ -145,6 +146,10 @@ class GalleryApp extends StatelessWidget {
],
),
),
const SizedBox(height: 24),
Text('Switches').tw.textSize(20).weight(FwFontWeight.bold),
const SizedBox(height: 12),
const _SwitchDemo(),
],
),
),
Expand All @@ -171,3 +176,30 @@ class _Dot extends StatelessWidget {
);
}
}

/// A tiny stateful demo so the showcased [Switch]es actually toggle.
class _SwitchDemo extends StatefulWidget {
const _SwitchDemo();

@override
State<_SwitchDemo> createState() => _SwitchDemoState();
}

class _SwitchDemoState extends State<_SwitchDemo> {
bool _a = true;
bool _b = false;

@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(value: _a, onChanged: (v) => setState(() => _a = v), semanticLabel: 'Wi-Fi'),
const SizedBox(width: 16),
Switch(value: _b, size: SwitchSize.sm, onChanged: (v) => setState(() => _b = v)),
const SizedBox(width: 16),
const Switch(value: true), // disabled
],
);
}
}
1 change: 1 addition & 0 deletions apps/gallery/test/gallery_smoke_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ void main() {
expect(find.text('Create project'), findsWidgets);
expect(find.text('Badges'), findsWidgets);
expect(find.text('Inputs'), findsWidgets);
expect(find.text('Switches'), findsWidgets);
});
}
Binary file added apps/gallery/test/goldens/switch_grid_dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/gallery/test/goldens/switch_grid_light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/gallery/test/goldens/switch_grid_rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading