dupe_modal_bottom_sheet 1.0.0
dupe_modal_bottom_sheet: ^1.0.0 copied to clipboard
A customizable bottom sheet with Hero animation support, implementing all features of showModalBottomSheet.
import 'package:flutter/material.dart';
import 'package:dupe_modal_bottom_sheet/dupe_modal_bottom_sheet.dart';
void main() {
runApp(const MyApp());
}
/// Root application widget.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomePage(),
);
}
}
/// Home page demonstrating the usage of DupeModalBottomSheet.
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Example')),
body: Center(
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
showDupeModalBottomSheet(
context: context,
useSafeArea: true,
isScrollControlled: true,
// Configure rounded corners and clipping
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
),
clipBehavior: Clip.antiAlias,
builder: (context) => const GalleryPage(),
);
},
child: const Text('showDupeModalBottomSheet'),
),
ElevatedButton(
onPressed: () {
showModalBottomSheet(
context: context,
useSafeArea: true,
isScrollControlled: true,
// Configure rounded corners and clipping
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
),
clipBehavior: Clip.antiAlias,
builder: (context) => const GalleryPage(),
);
},
child: const Text('showModalBottomSheet'),
),
],
),
),
);
}
}
/// Gallery page displaying a grid of images with Hero animation support.
class GalleryPage extends StatelessWidget {
const GalleryPage({super.key});
/// Sample image URLs for demonstration.
static const List<String> images = [
'https://ak-d.tripcdn.com/images/1lo6h12000cjjzfat9B44_C_1200_800_Q70.webp',
'https://ak-d.tripcdn.com/images/01066120008ro8p3n4406_C_1200_800_Q70.webp',
'https://ak-d.tripcdn.com/images/1lo0g12000cjsyzga0E7B_C_1200_800_Q70.webp',
'https://ak-d.tripcdn.com/images/0106p120008ro8sd3B5B2_C_1200_800_Q70.webp',
'https://ak-d.tripcdn.com/images/1lo5912000cjjyzw4303D_C_1200_800_Q70.webp',
'https://ak-d.tripcdn.com/images/350r190000016fjbi68C7_C_1200_800_Q70.webp',
'https://ak-d.tripcdn.com/images/1lo0y12000cjjzdxn9694_C_1200_800_Q70.webp',
'https://ak-d.tripcdn.com/images/0100p120008rn6wly74FD_C_1200_800_Q70.webp',
];
@override
Widget build(BuildContext context) {
return FractionallySizedBox(
heightFactor: 0.9,
child: Scaffold(
appBar: AppBar(
title: const Text('Gallery'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
),
body: SafeArea(
minimum: const EdgeInsets.all(20),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 20,
mainAxisSpacing: 20,
),
itemCount: images.length,
itemBuilder: (context, index) {
final imageUrl = images[index];
return GestureDetector(
onTap: () {
Navigator.push(
context,
PageRouteBuilder(
opaque: false,
barrierColor: Colors.transparent,
pageBuilder: (context, _, __) => PreviewPage(
imageUrl: imageUrl,
heroTag: 'hero_$index',
),
),
);
},
child: Hero(
tag: 'hero_$index',
placeholderBuilder: (context, heroSize, child) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[300],
),
);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(imageUrl, fit: BoxFit.cover),
),
),
);
},
),
),
),
);
}
}
/// Full-screen image preview page with interactive dismiss gestures.
///
/// Supports vertical drag to dismiss with scale and opacity animations.
class PreviewPage extends StatefulWidget {
/// The URL of the image to display.
final String imageUrl;
/// The Hero tag for the shared element transition.
final String heroTag;
const PreviewPage({super.key, required this.imageUrl, required this.heroTag});
@override
State<PreviewPage> createState() => _PreviewPageState();
}
class _PreviewPageState extends State<PreviewPage> {
/// Current vertical drag offset.
double _dragOffset = 0;
/// Background opacity based on drag distance.
double _backgroundOpacity = 1.0;
/// Image scale based on drag distance.
double _scale = 1.0;
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
/// Handles vertical drag updates to animate opacity and scale.
void _onVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
_dragOffset += details.delta.dy;
if (_dragOffset < 0) _dragOffset = 0;
_backgroundOpacity = (1 - (_dragOffset / 400)).clamp(0.0, 1.0);
_scale = (1 - (_dragOffset / 1000)).clamp(0.5, 1.0);
});
}
/// Handles drag end to dismiss or reset the preview.
void _onVerticalDragEnd(DragEndDetails details) {
if (_dragOffset > 100) {
Navigator.pop(context);
} else {
setState(() {
_dragOffset = 0;
_backgroundOpacity = 1.0;
_scale = 1.0;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black.withValues(alpha: _backgroundOpacity),
body: GestureDetector(
onVerticalDragUpdate: _onVerticalDragUpdate,
onVerticalDragEnd: _onVerticalDragEnd,
child: Stack(
children: [
Center(
child: Transform.translate(
offset: Offset(0, _dragOffset),
child: Transform.scale(
scale: _scale,
child: Hero(
tag: widget.heroTag,
child: Image.network(widget.imageUrl),
),
),
),
),
if (_dragOffset == 0)
Positioned(
top: MediaQuery.of(context).padding.top + 10,
left: 10,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 30),
onPressed: () => Navigator.pop(context),
),
),
],
),
),
);
}
}