scroll_edge_blur 0.1.1
scroll_edge_blur: ^0.1.1 copied to clipboard
Progressive blur for Flutter: iOS style edge blur for scroll views, toolbars and headers. A GPU shader on the backdrop, no snapshots, cheap while scrolling.
import 'package:flutter/material.dart';
import 'package:scroll_edge_blur/scroll_edge_blur.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await EdgeBlur.precache();
runApp(const DemoApp());
}
const _seed = Color(0xFFE0548B);
const _headerHeight = 52.0;
const _toolbarHeight = 60.0;
const _toolbarGap = 12.0;
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Scroll Edge Blur',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: _seed)),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.dark,
),
),
home: const GalleryPage(),
);
}
}
@immutable
class DemoSettings {
const DemoSettings({
this.sigma = 16,
this.topExtent = 72,
this.bottomExtent = 110,
this.tinted = true,
this.curve = Curves.easeIn,
this.top = true,
this.bottom = true,
});
final double sigma;
final double topExtent;
final double bottomExtent;
final bool tinted;
final Curve curve;
final bool top;
final bool bottom;
DemoSettings copyWith({
double? sigma,
double? topExtent,
double? bottomExtent,
bool? tinted,
Curve? curve,
bool? top,
bool? bottom,
}) {
return DemoSettings(
sigma: sigma ?? this.sigma,
topExtent: topExtent ?? this.topExtent,
bottomExtent: bottomExtent ?? this.bottomExtent,
tinted: tinted ?? this.tinted,
curve: curve ?? this.curve,
top: top ?? this.top,
bottom: bottom ?? this.bottom,
);
}
}
class GalleryPage extends StatefulWidget {
const GalleryPage({super.key});
@override
State<GalleryPage> createState() => _GalleryPageState();
}
class _GalleryPageState extends State<GalleryPage> {
final _settings = ValueNotifier(const DemoSettings());
@override
void dispose() {
_settings.dispose();
super.dispose();
}
void _openTuner() {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
// size to the controls instead of the default 9/16 cap
isScrollControlled: true,
builder: (context) => _Tuner(settings: _settings),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final safe = MediaQuery.paddingOf(context);
// a light wash of the background toward each edge keeps the title readable
final tint = scheme.surface.withValues(alpha: 0.55);
return Scaffold(
body: ValueListenableBuilder<DemoSettings>(
valueListenable: _settings,
builder: (context, s, _) {
return Stack(
children: [
ScrollEdgeBlur(
top:
s.top
? EdgeBlurStyle(
extent: s.topExtent,
sigma: s.sigma,
curve: s.curve,
tint: s.tinted ? tint : null,
fallbackColor: scheme.surface,
)
: null,
bottom:
s.bottom
? EdgeBlurStyle(
extent: s.bottomExtent,
sigma: s.sigma,
curve: s.curve,
tint: s.tinted ? tint : null,
fallbackColor: scheme.surface,
)
: null,
child: _PhotoGrid(
top: safe.top + _headerHeight + 8,
bottom: safe.bottom + _toolbarHeight + _toolbarGap * 2,
),
),
Positioned(
top: safe.top,
left: 20,
right: 20,
height: _headerHeight,
child: const _Header(),
),
Positioned(
left: 0,
right: 0,
bottom: safe.bottom + _toolbarGap,
child: Center(child: _Toolbar(onTune: _openTuner)),
),
],
);
},
),
);
}
}
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Row(
children: [
Text(
'Library',
style: text.headlineMedium?.copyWith(fontWeight: FontWeight.w800),
),
const Spacer(),
IconButton.filledTonal(
onPressed: () {},
icon: const Icon(Icons.search),
),
],
);
}
}
class _Toolbar extends StatelessWidget {
const _Toolbar({required this.onTune});
final VoidCallback onTune;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
color: scheme.surfaceContainerHighest.withValues(alpha: 0.7),
shape: const StadiumBorder(),
child: SizedBox(
height: _toolbarHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(onPressed: () {}, icon: const Icon(Icons.photo)),
IconButton(onPressed: () {}, icon: const Icon(Icons.favorite)),
IconButton(onPressed: () {}, icon: const Icon(Icons.share)),
const SizedBox(width: 4),
FilledButton.icon(
onPressed: onTune,
icon: const Icon(Icons.tune),
label: const Text('Tune'),
),
],
),
),
),
);
}
}
/// Colourful generated tiles, so there's plenty of detail to blur and no
/// network images.
class _PhotoGrid extends StatelessWidget {
const _PhotoGrid({required this.top, required this.bottom});
final double top;
final double bottom;
static const _icons = [
Icons.landscape,
Icons.local_florist,
Icons.pets,
Icons.beach_access,
Icons.music_note,
Icons.directions_bike,
Icons.icecream,
Icons.nightlife,
Icons.sailing,
Icons.park,
];
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: EdgeInsets.fromLTRB(4, top, 4, bottom),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 160,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
itemCount: 240,
itemBuilder: (context, i) {
final hue = (i * 23) % 360.0;
final a = HSLColor.fromAHSL(1, hue, 0.85, 0.62).toColor();
final b = HSLColor.fromAHSL(1, (hue + 70) % 360, 0.9, 0.42).toColor();
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(i % 7 == 0 ? 28 : 10),
gradient: LinearGradient(
begin: Alignment(-1 + (i % 3), -1),
end: const Alignment(1, 1),
colors: [a, b],
),
),
child: Stack(
children: [
Positioned(
right: -10,
bottom: -10,
child: Icon(
_icons[i % _icons.length],
size: 84,
color: Colors.white.withValues(alpha: 0.28),
),
),
Padding(
padding: const EdgeInsets.all(10),
child: Text(
'#${i + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
),
],
),
);
},
);
}
}
class _Tuner extends StatelessWidget {
const _Tuner({required this.settings});
final ValueNotifier<DemoSettings> settings;
static const _curves = {
'Ease in': Curves.easeIn,
'Linear': Curves.linear,
'Ease out': Curves.easeOut,
};
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<DemoSettings>(
valueListenable: settings,
builder: (context, s, _) {
void set(DemoSettings next) => settings.value = next;
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Slider(
label: 'Blur',
value: s.sigma,
max: 40,
onChanged: (v) => set(s.copyWith(sigma: v)),
),
_Slider(
label: 'Top extent',
value: s.topExtent,
max: 240,
onChanged: (v) => set(s.copyWith(topExtent: v)),
),
_Slider(
label: 'Bottom extent',
value: s.bottomExtent,
max: 240,
onChanged: (v) => set(s.copyWith(bottomExtent: v)),
),
const SizedBox(height: 8),
SegmentedButton<Curve>(
segments: [
for (final entry in _curves.entries)
ButtonSegment(value: entry.value, label: Text(entry.key)),
],
selected: {s.curve},
onSelectionChanged: (v) => set(s.copyWith(curve: v.first)),
),
const SizedBox(height: 4),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Tint'),
value: s.tinted,
onChanged: (v) => set(s.copyWith(tinted: v)),
),
Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('Top edge'),
selected: s.top,
onSelected: (v) => set(s.copyWith(top: v)),
),
FilterChip(
label: const Text('Bottom edge'),
selected: s.bottom,
onSelected: (v) => set(s.copyWith(bottom: v)),
),
],
),
if (!EdgeBlur.isSupported)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'This renderer has no shader filters (needs Impeller), '
'so a gradient is shown instead of the blur.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
);
},
);
}
}
class _Slider extends StatelessWidget {
const _Slider({
required this.label,
required this.value,
required this.max,
required this.onChanged,
});
final String label;
final double value;
final double max;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(width: 110, child: Text(label)),
Expanded(child: Slider(value: value, max: max, onChanged: onChanged)),
SizedBox(width: 36, child: Text(value.round().toString())),
],
);
}
}