forked from openfoodfacts/smooth-app
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Settings now use Modal Sheets 🤩 (openfoodfacts#4307)
- Loading branch information
Showing
19 changed files
with
862 additions
and
418 deletions.
There are no files selected for viewing
158 changes: 158 additions & 0 deletions
158
packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
import 'package:flutter/material.dart'; | ||
import 'package:flutter/semantics.dart'; | ||
import 'package:smooth_app/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet_route.dart'; | ||
import 'package:smooth_app/generic_lib/design_constants.dart'; | ||
|
||
Future<T?> showSmoothModalSheet<T>({ | ||
required BuildContext context, | ||
required WidgetBuilder builder, | ||
double? minHeight, | ||
}) { | ||
return showModalBottomSheet<T>( | ||
constraints: | ||
minHeight != null ? BoxConstraints(minHeight: minHeight) : null, | ||
isScrollControlled: minHeight != null, | ||
context: context, | ||
shape: const RoundedRectangleBorder( | ||
borderRadius: BorderRadius.vertical(top: ROUNDED_RADIUS), | ||
), | ||
builder: builder, | ||
useSafeArea: true, | ||
); | ||
} | ||
|
||
Future<T?> showSmoothDraggableModalSheet<T>({ | ||
required BuildContext context, | ||
required SmoothModalSheetHeader header, | ||
|
||
/// You must return a Sliver Widget | ||
required WidgetBuilder bodyBuilder, | ||
}) { | ||
return showDraggableModalSheet<T>( | ||
context: context, | ||
borderRadius: const BorderRadius.vertical(top: ROUNDED_RADIUS), | ||
headerBuilder: (_) => header, | ||
headerHeight: | ||
SmoothModalSheetHeader.computeHeight(context, header.closeButton), | ||
bodyBuilder: bodyBuilder, | ||
); | ||
} | ||
|
||
/// A non scrollable modal sheet | ||
class SmoothModalSheet extends StatelessWidget { | ||
const SmoothModalSheet({ | ||
required this.title, | ||
required this.body, | ||
this.closeButton = true, | ||
this.bodyPadding, | ||
this.closeButtonSemanticsOrder, | ||
}); | ||
|
||
final String title; | ||
final bool closeButton; | ||
final double? closeButtonSemanticsOrder; | ||
final Widget body; | ||
final EdgeInsetsGeometry? bodyPadding; | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
return ClipRRect( | ||
borderRadius: const BorderRadius.vertical(top: ROUNDED_RADIUS), | ||
child: DecoratedBox( | ||
decoration: const BoxDecoration( | ||
borderRadius: BorderRadius.vertical(top: ROUNDED_RADIUS), | ||
), | ||
child: Column( | ||
mainAxisSize: MainAxisSize.min, | ||
children: <Widget>[ | ||
SmoothModalSheetHeader( | ||
title: title, | ||
closeButton: closeButton, | ||
closeButtonSemanticsOrder: closeButtonSemanticsOrder, | ||
), | ||
Padding( | ||
padding: bodyPadding ?? const EdgeInsets.all(MEDIUM_SPACE), | ||
child: body, | ||
), | ||
], | ||
)), | ||
); | ||
} | ||
} | ||
|
||
class SmoothModalSheetHeader extends StatelessWidget { | ||
const SmoothModalSheetHeader({ | ||
required this.title, | ||
this.closeButton = true, | ||
this.closeButtonSemanticsOrder, | ||
}); | ||
|
||
final String title; | ||
final bool closeButton; | ||
final double? closeButtonSemanticsOrder; | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final Color primaryColor = Theme.of(context).primaryColor; | ||
|
||
return Container( | ||
color: primaryColor.withOpacity(0.2), | ||
padding: EdgeInsetsDirectional.only( | ||
start: VERY_LARGE_SPACE, | ||
top: VERY_SMALL_SPACE, | ||
bottom: VERY_SMALL_SPACE, | ||
end: VERY_LARGE_SPACE - (closeButton ? LARGE_SPACE : 0), | ||
), | ||
child: Row( | ||
children: <Widget>[ | ||
Expanded( | ||
child: Semantics( | ||
sortKey: const OrdinalSortKey(1.0), | ||
child: Text( | ||
title, | ||
maxLines: 1, | ||
overflow: TextOverflow.ellipsis, | ||
style: Theme.of(context).textTheme.titleLarge, | ||
), | ||
), | ||
), | ||
if (closeButton) | ||
Semantics( | ||
value: MaterialLocalizations.of(context).closeButtonTooltip, | ||
button: true, | ||
excludeSemantics: true, | ||
onScrollDown: () {}, | ||
sortKey: OrdinalSortKey(closeButtonSemanticsOrder ?? 2.0), | ||
child: Tooltip( | ||
message: MaterialLocalizations.of(context).closeButtonTooltip, | ||
enableFeedback: true, | ||
child: InkWell( | ||
onTap: () => Navigator.of(context).pop(), | ||
customBorder: const CircleBorder(), | ||
child: const Padding( | ||
padding: EdgeInsets.all(MEDIUM_SPACE), | ||
child: Icon(Icons.clear), | ||
), | ||
), | ||
), | ||
) | ||
], | ||
), | ||
); | ||
} | ||
|
||
static double computeHeight( | ||
BuildContext context, | ||
bool hasCloseButton, | ||
) { | ||
double size = VERY_SMALL_SPACE * 2; | ||
|
||
if (hasCloseButton == true) { | ||
size += (MEDIUM_SPACE * 2) + (Theme.of(context).iconTheme.size ?? 20.0); | ||
} else { | ||
size += Theme.of(context).textTheme.titleLarge?.fontSize ?? 15.0; | ||
} | ||
|
||
return size; | ||
} | ||
} |
200 changes: 200 additions & 0 deletions
200
packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_draggable_bottom_sheet.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
import 'package:flutter/material.dart'; | ||
import 'package:flutter/rendering.dart'; | ||
|
||
class SmoothDraggableBottomSheet extends StatefulWidget { | ||
const SmoothDraggableBottomSheet({ | ||
Key? key, | ||
required this.headerBuilder, | ||
required this.headerHeight, | ||
required this.bodyBuilder, | ||
this.initHeightFraction = 0.5, | ||
this.maxHeightFraction = 1.0, | ||
this.animationController, | ||
this.borderRadius, | ||
this.bottomSheetColor, | ||
this.draggableScrollableController, | ||
}) : assert(maxHeightFraction > 0.0 && maxHeightFraction <= 1.0), | ||
super(key: key); | ||
|
||
final double initHeightFraction; | ||
final double maxHeightFraction; | ||
final WidgetBuilder headerBuilder; | ||
final double headerHeight; | ||
final WidgetBuilder bodyBuilder; | ||
final DraggableScrollableController? draggableScrollableController; | ||
final AnimationController? animationController; | ||
final BorderRadius? borderRadius; | ||
final Color? bottomSheetColor; | ||
|
||
@override | ||
SmoothDraggableBottomSheetState createState() => | ||
SmoothDraggableBottomSheetState(); | ||
} | ||
|
||
class SmoothDraggableBottomSheetState | ||
extends State<SmoothDraggableBottomSheet> { | ||
late final DraggableScrollableController _controller; | ||
|
||
bool _isClosing = false; | ||
|
||
@override | ||
void initState() { | ||
super.initState(); | ||
_controller = | ||
widget.draggableScrollableController ?? DraggableScrollableController(); | ||
widget.animationController?.addStatusListener(_animationStatusListener); | ||
} | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
return NotificationListener<DraggableScrollableNotification>( | ||
onNotification: _scrolling, | ||
child: SafeArea( | ||
child: DraggableScrollableSheet( | ||
minChildSize: 0.0, | ||
maxChildSize: widget.maxHeightFraction, | ||
initialChildSize: widget.initHeightFraction, | ||
snap: true, | ||
controller: _controller, | ||
builder: (BuildContext context, ScrollController controller) { | ||
return DecoratedBox( | ||
decoration: BoxDecoration( | ||
borderRadius: widget.borderRadius, | ||
color: widget.bottomSheetColor ?? | ||
Theme.of(context).bottomSheetTheme.backgroundColor ?? | ||
Theme.of(context).scaffoldBackgroundColor, | ||
), | ||
child: Material( | ||
type: MaterialType.transparency, | ||
child: ClipRRect( | ||
borderRadius: widget.borderRadius, | ||
child: _SmoothDraggableContent( | ||
bodyBuilder: widget.bodyBuilder, | ||
headerBuilder: widget.headerBuilder, | ||
headerHeight: widget.headerHeight, | ||
currentExtent: _controller.isAttached | ||
? _controller.size | ||
: widget.initHeightFraction, | ||
scrollController: controller, | ||
cacheExtent: _calculateCacheExtent( | ||
MediaQuery.of(context).viewInsets.bottom, | ||
), | ||
), | ||
), | ||
), | ||
); | ||
}, | ||
), | ||
), | ||
); | ||
} | ||
|
||
@override | ||
void dispose() { | ||
widget.animationController?.removeStatusListener(_animationStatusListener); | ||
super.dispose(); | ||
} | ||
|
||
// Method will be called when scrolling. | ||
bool _scrolling(DraggableScrollableNotification notification) { | ||
if (_isClosing) { | ||
return false; | ||
} | ||
|
||
if (notification.extent <= 0.005) { | ||
_isClosing = true; | ||
Navigator.of(context).maybePop(); | ||
} | ||
|
||
return false; | ||
} | ||
|
||
// Method that listens for changing AnimationStatus, to track the closing of | ||
// the bottom sheet by clicking above it. | ||
void _animationStatusListener(AnimationStatus status) { | ||
if (status == AnimationStatus.reverse || | ||
status == AnimationStatus.dismissed) { | ||
_isClosing = true; | ||
} | ||
} | ||
|
||
double _calculateCacheExtent(double bottomInset) { | ||
const double defaultExtent = RenderAbstractViewport.defaultCacheExtent; | ||
if (bottomInset > defaultExtent) { | ||
return bottomInset; | ||
} else { | ||
return defaultExtent; | ||
} | ||
} | ||
} | ||
|
||
class _SmoothDraggableContent extends StatefulWidget { | ||
const _SmoothDraggableContent({ | ||
required this.currentExtent, | ||
required this.scrollController, | ||
required this.cacheExtent, | ||
required this.headerHeight, | ||
required this.headerBuilder, | ||
required this.bodyBuilder, | ||
Key? key, | ||
}) : super(key: key); | ||
|
||
final WidgetBuilder headerBuilder; | ||
final double headerHeight; | ||
final WidgetBuilder bodyBuilder; | ||
final double currentExtent; | ||
final ScrollController scrollController; | ||
final double cacheExtent; | ||
|
||
@override | ||
State<_SmoothDraggableContent> createState() => | ||
_SmoothDraggableContentState(); | ||
} | ||
|
||
class _SmoothDraggableContentState extends State<_SmoothDraggableContent> { | ||
final GlobalKey<State<StatefulWidget>> _contentKey = GlobalKey(); | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
return Scrollbar( | ||
child: CustomScrollView( | ||
cacheExtent: widget.cacheExtent, | ||
key: _contentKey, | ||
controller: widget.scrollController, | ||
slivers: <Widget>[ | ||
SliverPersistentHeader( | ||
pinned: true, | ||
delegate: _SliverHeader( | ||
child: widget.headerBuilder(context), | ||
height: widget.headerHeight, | ||
), | ||
), | ||
widget.bodyBuilder(context), | ||
], | ||
), | ||
); | ||
} | ||
} | ||
|
||
/// A fixed header | ||
class _SliverHeader extends SliverPersistentHeaderDelegate { | ||
_SliverHeader({required this.child, required this.height}) | ||
: assert(height > 0.0); | ||
|
||
final Widget child; | ||
final double height; | ||
|
||
@override | ||
Widget build(BuildContext context, _, __) { | ||
return child; | ||
} | ||
|
||
@override | ||
double get maxExtent => height; | ||
|
||
@override | ||
double get minExtent => height; | ||
|
||
@override | ||
bool shouldRebuild(_SliverHeader oldDelegate) => oldDelegate.height != height; | ||
} |
Oops, something went wrong.