focus_outline 0.1.0 copy "focus_outline: ^0.1.0" to clipboard
focus_outline: ^0.1.0 copied to clipboard

A Flutter widget that displays an animated outline overlay when its child is focused.

example/lib/main.dart

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;
      },
    );
  }
}
3
likes
160
points
105
downloads
screenshot

Publisher

verified publishersaawhitelife.com

Weekly Downloads

A Flutter widget that displays an animated outline overlay when its child is focused.

Repository (GitHub)
View/report issues

Topics

#widget #animation #accessibility #overlay #focus

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on focus_outline