liquid_reveal 0.1.1
liquid_reveal: ^0.1.1 copied to clipboard
A liquid page reveal that measures its own box instead of the screen, so it works inside a card, a list row or a full page. Render object, no rebuilds.
import 'package:flutter/material.dart';
import 'package:liquid_reveal/liquid_reveal.dart';
void main() => runApp(const ExampleApp());
const List<({Color colour, String title, String blurb})>
kPages = <({Color colour, String title, String blurb})>[
(colour: Color(0xFF4F46E5), title: 'Measured', blurb: 'against its own box'),
(
colour: Color(0xFFEC4899),
title: 'Not the screen',
blurb: 'never MediaQuery',
),
(colour: Color(0xFF06B6D4), title: 'One render object', blurb: 'no rebuilds'),
(colour: Color(0xFF16A34A), title: 'Any size', blurb: 'card, row or page'),
];
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'liquid_reveal',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF4F46E5),
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const HomePage(),
);
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final LiquidRevealController _card = LiquidRevealController();
final LiquidRevealController _row = LiquidRevealController();
RevealSide _side = RevealSide.right;
@override
void dispose() {
_card.dispose();
_row.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('liquid_reveal')),
body: ListView(
padding: const EdgeInsets.all(24),
children: <Widget>[
Text(
'The same widget at three sizes. Drag any of them either way - '
'forwards reveals the next page from one edge, backwards reveals '
'the previous one from the other. The reveal is measured against '
'the box it sits in, so a short row needs a short drag and a full '
'page needs a long one.',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 20),
SegmentedButton<RevealSide>(
segments: const <ButtonSegment<RevealSide>>[
ButtonSegment<RevealSide>(
value: RevealSide.right,
label: Text('From the right'),
),
ButtonSegment<RevealSide>(
value: RevealSide.left,
label: Text('From the left'),
),
ButtonSegment<RevealSide>(
value: RevealSide.bottom,
label: Text('From the bottom'),
),
],
selected: <RevealSide>{_side},
onSelectionChanged: (Set<RevealSide> s) =>
setState(() => _side = s.first),
),
if (_side.axis == Axis.vertical)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'Heads up: a vertical reveal inside this scrolling page loses '
'the gesture to the scroll view. That is Flutter deciding, not '
'the package - put a vertical reveal somewhere that does not '
'scroll, or use the button below.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.tertiary,
),
),
),
const SizedBox(height: 28),
_Labelled(
title: 'A hero card, 220 pixels tall',
trailing: _Indicator(controller: _card),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
height: 220,
child: LiquidReveal(
itemCount: kPages.length,
controller: _card,
settings: LiquidRevealSettings(side: _side),
itemBuilder: (BuildContext context, int i) => _Page(index: i),
),
),
),
),
_Labelled(
title: 'A list row, 84 pixels tall - the same settings',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 84,
child: LiquidReveal(
itemCount: kPages.length,
controller: _row,
settings: LiquidRevealSettings(
side: _side,
duration: const Duration(milliseconds: 420),
),
itemBuilder: (BuildContext context, int i) =>
_Page(index: i, compact: true),
),
),
),
),
_Labelled(
title: 'One way only: allowReverse is off here',
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 120,
child: LiquidReveal(
itemCount: kPages.length,
settings: LiquidRevealSettings(
side: _side,
allowReverse: false,
),
itemBuilder: (BuildContext context, int i) =>
_Page(index: i, compact: true),
),
),
),
),
_Labelled(
title: 'Softer wave, and a drag only from the edge',
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
height: 160,
child: LiquidReveal(
itemCount: kPages.length,
settings: LiquidRevealSettings.soft.copyWith(
side: _side,
dragEdgeFraction: 0.25,
),
itemBuilder: (BuildContext context, int i) =>
_Page(index: i, compact: true),
),
),
),
),
Center(
child: Wrap(
spacing: 12,
alignment: WrapAlignment.center,
children: <Widget>[
OutlinedButton.icon(
onPressed: () {
_card.previous();
_row.previous();
},
icon: const Icon(Icons.skip_previous),
label: const Text('Previous from code'),
),
FilledButton.icon(
onPressed: () {
_card.next();
_row.next();
},
icon: const Icon(Icons.skip_next),
label: const Text('Reveal from code'),
),
],
),
),
const SizedBox(height: 24),
],
),
);
}
}
class _Labelled extends StatelessWidget {
const _Labelled({required this.title, required this.child, this.trailing});
final String title;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
),
?trailing,
],
),
const SizedBox(height: 12),
child,
],
),
);
}
/// Watches the controller rather than the pages, so nothing else rebuilds.
class _Indicator extends StatelessWidget {
const _Indicator({required this.controller});
final LiquidRevealController controller;
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: controller,
builder: (BuildContext context, Widget? _) => Row(
children: <Widget>[
for (int i = 0; i < kPages.length; i++)
Padding(
padding: const EdgeInsets.only(left: 6),
child: _Dot(
// The controller reports the reveal in flight, so the dots
// slide instead of jumping.
filled: _weight(i, controller.offset),
),
),
],
),
);
double _weight(int index, double offset) {
final double distance = (offset % kPages.length) - index;
return (1 - distance.abs()).clamp(0.0, 1.0);
}
}
class _Dot extends StatelessWidget {
const _Dot({required this.filled});
final double filled;
@override
Widget build(BuildContext context) => Container(
width: 8 + filled * 10,
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Color.lerp(
Theme.of(context).colorScheme.outlineVariant,
Theme.of(context).colorScheme.primary,
filled,
),
),
);
}
class _Page extends StatelessWidget {
const _Page({required this.index, this.compact = false});
final int index;
final bool compact;
@override
Widget build(BuildContext context) {
final ({Color colour, String title, String blurb}) page =
kPages[index % kPages.length];
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
page.colour,
Color.lerp(page.colour, Colors.black, 0.35)!,
],
),
),
child: Padding(
padding: EdgeInsets.all(compact ? 16 : 24),
child: Row(
children: <Widget>[
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
page.title,
style: TextStyle(
fontSize: compact ? 20 : 34,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
page.blurb,
style: const TextStyle(color: Colors.white70),
),
],
),
),
Text(
'${index + 1}',
style: TextStyle(
fontSize: compact ? 28 : 56,
fontWeight: FontWeight.bold,
color: Colors.white24,
),
),
],
),
),
);
}
}