focus_outline 0.1.0
focus_outline: ^0.1.0 copied to clipboard
A Flutter widget that displays an animated outline overlay when its child is focused.
import 'dart:developer' as developer;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:focus_outline/focus_outline.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Focus Outline Demo',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
home: const MyHomePage(title: 'Focus Outline Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
bool _nestedSwitchValue = false;
bool _nestedCheckboxValue = true;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title)),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('Use arrows or Tab to navigate through elements', style: TextStyle(fontSize: 16)),
const SizedBox(height: 20),
const SectionHeader(title: 'Buttons'),
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
FocusOutline(
child: ElevatedButton(onPressed: () {}, child: const Text('Elevated Button')),
),
FocusOutline(
color: Colors.red,
child: FilledButton(onPressed: () {}, child: const Text('Filled Button')),
),
FocusOutline(
color: Colors.green,
borderRadius: BorderRadius.circular(20),
child: OutlinedButton(onPressed: () {}, child: const Text('Outlined Button (Green)')),
),
FocusOutline(
child: TextButton(onPressed: () {}, child: const Text('Text Button')),
),
FocusOutline(
shape: FocusOutlineShape.oval,
child: IconButton(onPressed: () {}, icon: const Icon(Icons.add)),
),
],
),
const SizedBox(height: 20),
const SectionHeader(title: 'Shapes'),
Text(
'Rectangle with varying borderRadius, and oval for circular-ish controls.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_buildShapeDemoButton(label: 'Rect r=0', borderRadius: BorderRadius.zero),
_buildShapeDemoButton(label: 'Rect r=8', borderRadius: BorderRadius.circular(8)),
_buildShapeDemoButton(label: 'Rect r=20', borderRadius: BorderRadius.circular(20)),
_buildShapeDemoButton(label: 'Rect r=32', borderRadius: BorderRadius.circular(32)),
FocusOutline(
shape: FocusOutlineShape.oval,
padding: 10,
child: IconButton(onPressed: () {}, icon: const Icon(Icons.check)),
),
],
),
const SizedBox(height: 20),
const SectionHeader(title: 'Stroke styles'),
Text('Solid, dashed, and dotted focus outlines.', style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
FocusOutline(
child: OutlinedButton(onPressed: () {}, child: const Text('Solid')),
),
FocusOutline(
strokeBorderColor: Colors.black,
strokeBorderWidth: 1,
child: OutlinedButton(onPressed: () {}, child: const Text('Blue + black border')),
),
FocusOutline(
strokePattern: FocusOutlineStrokePattern.dashed,
child: OutlinedButton(onPressed: () {}, child: const Text('Dashed')),
),
FocusOutline(
strokePattern: FocusOutlineStrokePattern.dotted,
strokeCap: StrokeCap.round,
child: OutlinedButton(onPressed: () {}, child: const Text('Dotted')),
),
],
),
const SizedBox(height: 20),
const SectionHeader(title: 'Animations'),
Text(
'These are all built-in FocusOutline animations. Use Tab (Desktop/Web) to quickly preview.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_buildAnimationDemoButton(label: 'Scale', animationBuilder: FocusOutlineAnimation.scale),
_buildAnimationDemoButton(label: 'Fade', animationBuilder: FocusOutlineAnimation.fade),
_buildAnimationDemoButton(label: 'FadeScale', animationBuilder: FocusOutlineAnimation.fadeScale),
],
),
const SizedBox(height: 12),
Text(
'Continuous transforms (no custom FocusOutline animations)',
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 10),
const Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_ContinuousTransformButton(label: 'Rotate', effect: _ContinuousTransformEffect.rotate),
_ContinuousTransformButton(label: 'Scale', effect: _ContinuousTransformEffect.scale),
_ContinuousTransformButton(label: 'Translate', effect: _ContinuousTransformEffect.translate),
],
),
const SizedBox(height: 12),
Text(
'Stroke reveal (all combinations)',
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 10),
Wrap(spacing: 10, runSpacing: 10, children: <Widget>[..._buildStrokeRevealAnimationButtons()]),
const SizedBox(height: 20),
const SectionHeader(title: 'Input Fields'),
const FocusOutline(
child: TextField(
decoration: InputDecoration(labelText: 'Standard TextField', border: OutlineInputBorder()),
),
),
const SizedBox(height: 10),
const FocusOutline(
color: Colors.purple,
child: TextField(
decoration: InputDecoration(
labelText: 'Another TextField (Purple Focus)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(height: 20),
const SectionHeader(title: 'Custom Focusable Widgets'),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_buildCustomFocusableBox(label: 'Box 1', color: Colors.amber),
_buildCustomFocusableBox(label: 'Box 2', color: Colors.cyan),
_buildCustomFocusableBox(label: 'Box 3', color: Colors.lime),
],
),
const SizedBox(height: 20),
const SectionHeader(title: 'Switches & Checkboxes'),
Row(
children: <Widget>[
FocusOutline(
shape: FocusOutlineShape.oval,
padding: 0,
child: Switch(value: true, onChanged: (_) {}),
),
const SizedBox(width: 20),
FocusOutline(
shape: FocusOutlineShape.oval,
padding: 0,
child: Checkbox(value: true, onChanged: (_) {}),
),
],
),
const SizedBox(height: 20),
const SectionHeader(title: 'List View Items'),
Container(
height: 300,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
return FocusOutline(
child: ListTile(
leading: const Icon(Icons.list),
title: Text('List Item $index'),
onTap: () {},
trailing: FocusOutline(
shape: FocusOutlineShape.oval,
child: IconButton(icon: const Icon(Icons.info_outline), onPressed: () {}),
),
),
);
},
),
),
const SizedBox(height: 20),
const SectionHeader(title: 'Nested Focusable Widgets'),
FocusOutline(
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Card tapped')));
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Focusable Card (tap or tab into nested controls)',
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
FocusOutline(
child: ElevatedButton(onPressed: () {}, child: const Text('Button')),
),
FocusOutline(
shape: FocusOutlineShape.oval,
child: IconButton(onPressed: () {}, icon: const Icon(Icons.favorite_border)),
),
FocusOutline(
shape: FocusOutlineShape.oval,
padding: 0,
child: Switch(
value: _nestedSwitchValue,
onChanged: (bool value) {
setState(() {
_nestedSwitchValue = value;
});
},
),
),
FocusOutline(
shape: FocusOutlineShape.oval,
padding: 0,
child: Checkbox(
value: _nestedCheckboxValue,
onChanged: (bool? value) {
setState(() {
_nestedCheckboxValue = value ?? false;
});
},
),
),
],
),
const SizedBox(height: 12),
const FocusOutline(
child: TextField(
decoration: InputDecoration(labelText: 'Nested TextField', border: OutlineInputBorder()),
),
),
],
),
),
),
),
),
const SizedBox(height: 20),
],
),
),
),
floatingActionButton: FocusOutline(
shape: FocusOutlineShape.oval,
color: Colors.orange,
child: FloatingActionButton(onPressed: () {}, tooltip: 'Increment', child: const Icon(Icons.add)),
),
);
}
Widget _buildAnimationDemoButton({
required String label,
required Widget Function(BuildContext, Animation<double>, Widget) animationBuilder,
}) {
return FocusOutline(
animationBuilder: animationBuilder,
child: OutlinedButton(onPressed: () {}, child: Text(label)),
);
}
Widget _buildShapeDemoButton({required String label, required BorderRadius borderRadius}) {
return FocusOutline(
borderRadius: borderRadius,
child: OutlinedButton(onPressed: () {}, child: Text(label)),
);
}
List<Widget> _buildStrokeRevealAnimationButtons() {
const List<FocusOutlineStrokeStart> starts = <FocusOutlineStrokeStart>[
FocusOutlineStrokeStart.topCenter,
FocusOutlineStrokeStart.bottomCenter,
FocusOutlineStrokeStart.leftCenter,
FocusOutlineStrokeStart.rightCenter,
FocusOutlineStrokeStart.topLeft,
FocusOutlineStrokeStart.topRight,
FocusOutlineStrokeStart.bottomLeft,
FocusOutlineStrokeStart.bottomRight,
];
String startShort(FocusOutlineStrokeStart start) {
return switch (start) {
FocusOutlineStrokeStart.topCenter => 'TC',
FocusOutlineStrokeStart.bottomCenter => 'BC',
FocusOutlineStrokeStart.leftCenter => 'LC',
FocusOutlineStrokeStart.rightCenter => 'RC',
FocusOutlineStrokeStart.topLeft => 'TL',
FocusOutlineStrokeStart.topRight => 'TR',
FocusOutlineStrokeStart.bottomLeft => 'BL',
FocusOutlineStrokeStart.bottomRight => 'BR',
};
}
String startLong(FocusOutlineStrokeStart start) {
return switch (start) {
FocusOutlineStrokeStart.topCenter => 'topCenter',
FocusOutlineStrokeStart.bottomCenter => 'bottomCenter',
FocusOutlineStrokeStart.leftCenter => 'leftCenter',
FocusOutlineStrokeStart.rightCenter => 'rightCenter',
FocusOutlineStrokeStart.topLeft => 'topLeft',
FocusOutlineStrokeStart.topRight => 'topRight',
FocusOutlineStrokeStart.bottomLeft => 'bottomLeft',
FocusOutlineStrokeStart.bottomRight => 'bottomRight',
};
}
final List<Widget> widgets = <Widget>[];
for (final FocusOutlineStrokeStart start in starts) {
widgets.add(
Tooltip(
message: 'strokeReveal(start: ${startLong(start)}, type: bidirectional)',
child: FocusOutline(
animationBuilder: FocusOutlineAnimation.strokeReveal(start: start),
child: OutlinedButton(onPressed: () {}, child: Text('${startShort(start)} Bi')),
),
),
);
}
for (final FocusOutlineStrokeStart start in starts) {
for (final FocusOutlineStrokeDirection direction in <FocusOutlineStrokeDirection>[
FocusOutlineStrokeDirection.clockwise,
FocusOutlineStrokeDirection.counterclockwise,
]) {
final String dirShort = direction == FocusOutlineStrokeDirection.clockwise ? 'CW' : 'CCW';
final String dirLong = direction == FocusOutlineStrokeDirection.clockwise ? 'clockwise' : 'counterclockwise';
widgets.add(
Tooltip(
message: 'strokeReveal(start: ${startLong(start)}, type: unidirectional, direction: $dirLong)',
child: FocusOutline(
animationBuilder: FocusOutlineAnimation.strokeReveal(
start: start,
type: FocusOutlineStrokeType.unidirectional,
direction: direction,
),
child: OutlinedButton(onPressed: () {}, child: Text('${startShort(start)} Uni $dirShort')),
),
),
);
}
}
return widgets;
}
Widget _buildCustomFocusableBox({required String label, required Color color}) {
return FocusOutline(
child: Material(
color: color,
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
developer.log('Custom box tapped: $label', name: 'focus_outline.example');
},
borderRadius: BorderRadius.circular(8),
child: SizedBox(width: 80, height: 80, child: Center(child: Text(label))),
),
),
);
}
}
class SectionHeader extends StatelessWidget {
const SectionHeader({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
);
}
}
enum _ContinuousTransformEffect { rotate, scale, translate }
class _ContinuousTransformButton extends StatefulWidget {
const _ContinuousTransformButton({required this.label, required this.effect});
final String label;
final _ContinuousTransformEffect effect;
@override
State<_ContinuousTransformButton> createState() => _ContinuousTransformButtonState();
}
class _ContinuousTransformButtonState extends State<_ContinuousTransformButton> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1600))..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final Widget outlinedButton = OutlinedButton(onPressed: () {}, child: Text(widget.label));
final Widget focusOutline = FocusOutline(child: outlinedButton);
return AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget? child) {
final double t = _controller.value;
final double phase = 2 * math.pi * t;
// Small, subtle transforms so the button remains readable and usable.
final double rotateRadians = 0.12 * math.sin(phase);
final double scale = 1.0 + (0.06 * math.sin(phase));
final Offset translate = Offset(0, 6 * math.sin(phase));
final Widget transformed = switch (widget.effect) {
_ContinuousTransformEffect.rotate => Transform.rotate(angle: rotateRadians, child: focusOutline),
_ContinuousTransformEffect.scale => Transform.scale(scale: scale, child: focusOutline),
_ContinuousTransformEffect.translate => Transform.translate(offset: translate, child: focusOutline),
};
return transformed;
},
);
}
}