ambient_border_glow 0.1.0
ambient_border_glow: ^0.1.0 copied to clipboard
An animated glow that hugs the edges of a box - the whole screen, a widget you point it at, or any shape you can express as an SVG path.
import 'package:ambient_border_glow/ambient_border_glow.dart';
import 'package:flutter/material.dart';
import 'shapes.dart';
void main() => runApp(const DemoApp());
const _bg = Color(0xFF0B0B0F);
const _card = Color(0xFF16161D);
const _text = Color(0xFFF2F2F7);
const _dim = Color(0xFF8E8E9A);
const _accent = Color(0xFF5E5CE6);
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Ambient Border Glow',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: _bg),
home: const DemoPage(),
);
}
/// Rounded shapes, tracked by key. The glow reads each one's corners from the
/// same radius it is drawn with, so the two cannot drift.
const _rectShapes = <String, (Size, GlowRadius)>{
'Pill': (Size(104, 52), GlowRadius.all(999)),
'Circle': (Size(72, 72), GlowRadius.all(999)),
'Square': (Size(72, 72), GlowRadius.all(0)),
'Card': (Size(104, 72), GlowRadius.all(16)),
'Tall pill': (Size(48, 104), GlowRadius.all(999)),
'Bubble': (
Size(104, 72),
GlowRadius.only(topLeft: 22, topRight: 22, bottomRight: 22, bottomLeft: 4),
),
'Leaf': (Size(84, 84), GlowRadius.only(topLeft: 34, bottomRight: 34)),
'Wedge': (
Size(104, 72),
GlowRadius.only(topLeft: 4, topRight: 36, bottomRight: 4, bottomLeft: 36),
),
};
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> {
bool _pinned = true;
Set<GlowEdge> _edges = {...allEdges};
GlowDirection _direction = GlowDirection.cw;
double _thickness = 7;
double _smoothing = 0.6;
String _palette = 'rainbow';
String? _rect;
String? _shape;
final _rectKeys = {for (final k in _rectShapes.keys) k: GlobalKey()};
final _shapeKeys = {for (final k in customShapes.keys) k: GlobalKey()};
final _rectHost = GlobalKey();
final _shapeHost = GlobalKey();
static const _palettes = <String, List<Color>>{
'rainbow': GlowPalettes.rainbow,
'aurora': GlowPalettes.aurora,
'ember': GlowPalettes.ember,
'ocean': GlowPalettes.ocean,
'mono': GlowPalettes.mono,
};
@override
Widget build(BuildContext context) {
final visible = _pinned;
return Scaffold(
body: Stack(
children: [
ListView(
padding: const EdgeInsets.fromLTRB(20, 60, 20, 60),
children: [
const Text(
'Ambient Border Glow',
style: TextStyle(
color: _text,
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
const Text(
'The full-screen glow reacts to every control below. Corners '
'round only where two selected edges meet.',
style: TextStyle(color: _dim, fontSize: 14, height: 1.4),
),
const SizedBox(height: 20),
_section('Full-screen glow', [
Row(
children: [
_button(
_pinned ? 'Turn off' : 'Turn on',
_pinned,
() => setState(() => _pinned = !_pinned),
),
],
),
]),
_section('Edges', [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final e in allEdges)
_chip(e.name, _edges.contains(e), () {
setState(() {
_edges = {..._edges};
_edges.contains(e) ? _edges.remove(e) : _edges.add(e);
});
}),
],
),
const SizedBox(height: 10),
Text(
'${_edges.length} of 4 selected.',
style: const TextStyle(color: _dim, fontSize: 12),
),
]),
_section('Motion', [
Wrap(
spacing: 8,
children: [
for (final d in GlowDirection.values)
_chip(
d.name,
_direction == d,
() => setState(() => _direction = d),
),
],
),
const SizedBox(height: 12),
_slider(
'thickness',
_thickness,
1,
20,
(v) => setState(() => _thickness = v),
),
_slider(
'cornerSmoothing',
_smoothing,
0,
1,
(v) => setState(() => _smoothing = v),
),
]),
_section('Palette', [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final name in _palettes.keys)
_chip(
name,
_palette == name,
() => setState(() => _palette = name),
),
],
),
]),
_sectionKeyed(
'Rounded shapes - tracked by key',
_rectHost,
overlay: _rect == null
? null
: AmbientBorderGlow(
visible: visible,
targetKey: _rectKeys[_rect],
relativeTo: _rectHost,
radius: _rectShapes[_rect]!.$2,
thickness: 4,
bleed: 16,
direction: _direction,
colors: _palettes[_palette]!,
),
[
const Text(
'Tap a shape. The glow measures that widget and wraps its '
'box, with the same radius it is drawn with.',
style: TextStyle(color: _dim, fontSize: 12, height: 1.5),
),
const SizedBox(height: 16),
Wrap(
spacing: 18,
runSpacing: 18,
children: [
for (final entry in _rectShapes.entries)
_swatch(
label: entry.key,
selected: _rect == entry.key,
onTap: () => setState(
() => _rect = _rect == entry.key ? null : entry.key,
),
child: Container(
key: _rectKeys[entry.key],
width: entry.value.$1.width,
height: entry.value.$1.height,
decoration: BoxDecoration(
color: _rect == entry.key
? _accent
: const Color(0xFF242430),
borderRadius: _borderRadius(entry.value.$2),
),
),
),
],
),
],
),
_sectionKeyed(
'Custom outlines - any shape',
_shapeHost,
overlay: _shape == null
? null
: AmbientBorderGlow(
visible: visible,
targetKey: _shapeKeys[_shape],
relativeTo: _shapeHost,
path: customShapes[_shape],
thickness: 4,
bleed: 20,
direction: _direction,
colors: _palettes[_palette]!,
),
[
const Text(
'Hand the glow a path and it traces any outline you can '
'express as SVG. The swatch and the glow come from the same '
'function, so they agree by construction.',
style: TextStyle(color: _dim, fontSize: 12, height: 1.5),
),
const SizedBox(height: 16),
Wrap(
spacing: 18,
runSpacing: 18,
children: [
for (final entry in customShapes.entries)
_swatch(
label: entry.key,
selected: _shape == entry.key,
onTap: () => setState(
() =>
_shape = _shape == entry.key ? null : entry.key,
),
child: SizedBox(
key: _shapeKeys[entry.key],
width: 76,
height: 76,
child: CustomPaint(
painter: _ShapeSwatch(
entry.value,
_shape == entry.key
? _accent
: const Color(0xFF242430),
),
),
),
),
],
),
],
),
],
),
// Last in the stack, no safe-area padding: it paints over everything
// and runs right up to the physical screen edges.
if (_rect == null && _shape == null)
AmbientBorderGlow(
visible: visible,
edges: _edges,
radius: const GlowRadius.display(fallback: 44),
cornerSmoothing: _smoothing,
thickness: _thickness,
direction: _direction,
colors: _palettes[_palette]!,
),
],
),
);
}
BorderRadius _borderRadius(GlowRadius r) => BorderRadius.only(
topLeft: Radius.circular(r.topLeft),
topRight: Radius.circular(r.topRight),
bottomRight: Radius.circular(r.bottomRight),
bottomLeft: Radius.circular(r.bottomLeft),
);
Widget _section(String title, List<Widget> children) =>
_sectionKeyed(title, null, children);
Widget _sectionKeyed(
String title,
Key? key,
List<Widget> children, {
Widget? overlay,
}) => Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF2A2A35)),
),
// The key sits on the Stack, not the Container: the glow measures
// against this box and positions inside it, so both have to be the
// same box or the two coordinate spaces differ by the padding.
child: Stack(
key: key,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: const TextStyle(
color: _dim,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
const SizedBox(height: 10),
...children,
],
),
?overlay,
],
),
);
Widget _chip(String label, bool selected, VoidCallback onTap) =>
GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: selected ? _accent : null,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected ? _accent : const Color(0xFF2A2A35),
),
),
child: Text(
label,
style: TextStyle(
color: selected ? Colors.white : _dim,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
);
Widget _button(String label, bool on, VoidCallback onTap) => Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 11),
alignment: Alignment.center,
decoration: BoxDecoration(
color: on ? null : _accent,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A2A35)),
),
child: Text(
label,
style: const TextStyle(
color: _text,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
);
Widget _slider(
String label,
double value,
double min,
double max,
ValueChanged<double> onChanged,
) => Row(
children: [
SizedBox(
width: 140,
child: Text(label, style: const TextStyle(color: _text, fontSize: 13)),
),
Expanded(
child: Slider(
value: value,
min: min,
max: max,
activeColor: _accent,
onChanged: onChanged,
),
),
SizedBox(
width: 44,
child: Text(
value.toStringAsFixed(2),
style: const TextStyle(color: _dim, fontSize: 12),
),
),
],
);
Widget _swatch({
required String label,
required bool selected,
required VoidCallback onTap,
required Widget child,
}) => GestureDetector(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
child,
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
color: selected ? _text : _dim,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
/// Fills a custom outline from the exact path the glow is handed, so what you
/// see filled is what gets traced.
class _ShapeSwatch extends CustomPainter {
_ShapeSwatch(this.builder, this.color);
final GlowPathBuilder builder;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final box = GlowPathBox(x: 0, y: 0, width: size.width, height: size.height);
canvas.drawPath(parseSvgPath(builder(box)), Paint()..color = color);
}
@override
bool shouldRepaint(_ShapeSwatch old) =>
old.color != color || old.builder != builder;
}