Flutter底部弹出视图插件snapping_bottom_sheet的使用
Flutter底部弹出视图插件snapping_bottom_sheet的使用
snapping_bottom_sheet
是一个可拖动、可滚动且可锁定的底部弹出视图插件。它提供了两种使用方式:作为 Widget
嵌入到你的 widget 树中,或者作为 BottomSheetDialog
显示。
示例
图片
动图
如果动图无法加载,请点击这里
使用方法
作为 Widget
这种用法可以将 SnappingBottomSheet
永久显示在其他 widget 上方,如示例所示:
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: AppBar(
title: Text('Simple Example'),
),
body: SnappingBottomSheet(
elevation: 8,
cornerRadius: 16,
snapSpec: const SnapSpec(
snap: true,
snappings: [0.4, 0.7, 1.0],
positioning: SnapPositioning.relativeToAvailableSpace,
),
body: Center(
child: Text('This widget is below the SnappingBottomSheet'),
),
builder: (context, state) {
return Container(
height: 500,
child: Center(
child: Text('This is the content of the sheet'),
),
);
},
),
);
}
作为 BottomSheetDialog
这种用法可以通过调用 showSnappingBottomSheet
函数并返回一个 SnappingBottomSheetDialog
实例来显示 SnappingBottomSheet
:
void showAsBottomSheet() async {
final result = await showSnappingBottomSheet(
context,
builder: (context) {
return SnappingBottomSheetDialog(
elevation: 8,
cornerRadius: 16,
snapSpec: const SnapSpec(
snap: true,
snappings: [0.4, 0.7, 1.0],
positioning: SnapPositioning.relativeToAvailableSpace,
),
builder: (context, state) {
return Container(
height: 400,
child: Center(
child: Material(
child: InkWell(
onTap: () => Navigator.pop(context, 'This is the result.'),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'This is the content of the sheet',
style: Theme.of(context).textTheme.bodyText1,
),
),
),
),
),
);
},
);
}
);
print(result); // This is the result.
}
锁定行为
SnappingBottomSheet
可以锁定到多个位置或不锁定。你可以通过传递一个 SnapSpec
实例来自定义锁定行为:
参数 | 描述 |
---|---|
snap |
如果为 true ,SnappingBottomSheet 将锁定到提供的 snappings 。如果为 false ,SnappingBottomSheet 将从 minExtent 滑动到 maxExtent ,然后开始滚动(如果内容大于可用高度)。 |
snappings |
当用户结束拖动交互时,SnappingBottomSheet 将锁定到这些位置。最小值和最大值将表示 SnappingBottomSheet 滑动的边界,超过该边界将开始滚动。 |
positioning |
可以设置为以下三个值之一:SnapPositioning.relativeToAvailableSpace - 相对于 SnappingBottomSheet 可扩展的总可用高度定位锁定位置。所有值必须在 0 和 1 之间。SnapPositioning.relativeToSheetHeight - 相对于 sheet 的总高度定位锁定位置。所有值必须在 0 和 1 之间。SnapPositioning.pixelOffset - 在固定像素偏移处定位锁定位置。 |
onSnap |
当 SnappingBottomSheet 锁定到某个位置时调用的回调函数。 |
预建锁定
锁定 | 描述 |
---|---|
SnapSpec.headerFooterSnap |
使头部和尾部完全可见,不考虑 SnappingBottomSheet 的垂直填充。 |
SnapSpec.headerSnap |
使头部完全可见,不考虑 SnappingBottomSheet 的顶部填充。 |
SnapSpec.footerSnap |
使尾部完全可见,不考虑 SnappingBottomSheet 的底部填充。 |
SnapSpec.expanded |
展开整个 SnappingBottomSheet 。 |
SheetController
SheetController
可用于手动更改 SnappingBottomSheet
的状态。只需将 SheetController
实例传递给 SnappingBottomSheet
即可。注意,这些方法只能在 SnappingBottomSheet
渲染后使用,但在渲染前调用它们不会抛出异常。
方法 | 描述 |
---|---|
expand() |
将 SnappingBottomSheet 扩展到最大位置。 |
collapse() |
将 SnappingBottomSheet 收缩到最小位置。 |
snapToExtent() |
将 SnappingBottomSheet 锁定到任意位置。位置将被限制在最小值和最大值之间。如果滚动偏移量 > 0,则 SnappingBottomSheet 将首先滚动到顶部,然后再滑动到指定位置。 |
scrollTo() |
将 SnappingBottomSheet 滚动到指定偏移量。如果 SnappingBottomSheet 尚未达到其最大位置,它将首先锁定到最大位置,然后再滚动到指定偏移量。 |
rebuild() |
调用 SnappingBottomSheet 的所有构建器以重建子组件。此方法可用于反映 SnappingBottomSheet 子组件的变化,而无需在父组件上调用 setState(() {}); 以提高性能。 |
show() |
如果 SnappingBottomSheet 之前被隐藏,则将其视觉上显示出来。注意,对于 SnappingBottomSheetDialog ,调用此方法不会产生效果。 |
hide() |
将 SnappingBottomSheet 视觉上隐藏,直到再次调用 show() 。注意,对于 SnappingBottomSheetDialog ,调用此方法不会产生效果。 |
Headers and Footers
Headers 和 Footers 是 SnappingBottomSheet
的 UI 元素,将显示在 SnappingBottomSheet
的顶部或底部,并且不会滚动。示例如下:
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: AppBar(
title: Text('Simple Example'),
),
body: Stack(
children: <Widget>[
SnappingBottomSheet(
elevation: 8,
cornerRadius: 16,
snapSpec: const SnapSpec(
snap: true,
snappings: [112, 400, double.infinity],
positioning: SnapPositioning.pixelOffset,
),
builder: (context, state) {
return Container(
height: 500,
child: Center(
child: Text(
'This is the content of the sheet',
style: Theme.of(context).textTheme.bodyText1,
),
),
);
},
headerBuilder: (context, state) {
return Container(
height: 56,
width: double.infinity,
color: Colors.green,
alignment: Alignment.center,
child: Text(
'This is the header',
style: Theme.of(context).textTheme.bodyText1.copyWith(color: Colors.white),
),
);
},
footerBuilder: (context, state) {
return Container(
height: 56,
width: double.infinity,
color: Colors.yellow,
alignment: Alignment.center,
child: Text(
'This is the footer',
style: Theme.of(context).textTheme.bodyText1.copyWith(color: Colors.black),
),
);
},
),
],
),
);
}
ListViews and Columns
SnappingBottomSheet
的子组件不允许有无限(无界)的高度。因此,当使用 ListView
时,确保将 shrinkWrap
设置为 true
并将 physics
设置为 NeverScrollableScrollPhysics
。同样,当使用 Column
作为 SnappingBottomSheet
的子组件时,确保将 mainAxisSize
设置为 MainAxisSize.min
。
Material Effects
为了在与 sheet 交互时改变 UI,可以将回调传递给 SnappingBottomSheet
的 listener
字段,该回调将在 sheet 滑动或滚动时调用当前的 SheetState
。然后可以根据需要重新构建 UI。当将 SnappingBottomSheet
用作 bottomSheetDialog
时,也可以使用 SheetController.rebuild()
来重新构建 sheet,以更改某些参数。
Contribution
如果你觉得这个包缺少某些功能,欢迎创建 pull request。
完整示例代码
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:snapping_bottom_sheet/snapping_bottom_sheet.dart';
const Color mapsBlue = Color(0xFF4185F3);
void main() => runApp(
const MaterialApp(
title: 'Example App',
debugShowCheckedModeBanner: false,
home: Example(),
),
);
class Example extends StatefulWidget {
const Example({super.key});
@override
ExampleState createState() => ExampleState();
}
class ExampleState extends State<Example> {
SheetController controller = SheetController();
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
body: SafeArea(child: Column(children: [Expanded(child: buildSheet())])),
);
}
Widget buildSheet() {
return SnappingBottomSheet(
duration: const Duration(milliseconds: 900),
controller: controller,
color: Colors.white,
shadowColor: Colors.black26,
elevation: 12,
maxWidth: 500,
cornerRadius: 16,
cornerRadiusOnFullscreen: 0.0,
closeOnBackdropTap: true,
closeOnBackButtonPressed: true,
addTopViewPaddingOnFullscreen: true,
isBackdropInteractable: true,
border: Border.all(
color: Colors.grey.shade300,
width: 3,
),
snapSpec: SnapSpec(
snap: true,
positioning: SnapPositioning.relativeToAvailableSpace,
snappings: const [
SnapSpec.headerFooterSnap,
0.5,
0.75,
SnapSpec.expanded,
],
onSnap: (state, snap) {
log('Snapped to $snap');
},
),
parallaxSpec: const ParallaxSpec(
enabled: true,
amount: 0.35,
endExtent: 0.6,
),
liftOnScrollHeaderElevation: 12.0,
liftOnScrollFooterElevation: 12.0,
body: _buildBody(),
headerBuilder: buildHeader,
footerBuilder: buildFooter,
// builder: buildChild,
customBuilder: buildInfiniteChild,
);
}
Widget buildHeader(BuildContext context, SheetState state) {
return CustomContainer(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16),
shadowColor: Colors.black12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const SizedBox(height: 2),
Align(
alignment: Alignment.topCenter,
child: CustomContainer(
width: 16,
height: 4,
borderRadius: 2,
color: Colors.grey.withOpacity(.5 * (1 - interval(0.7, 1.0, state.progress))),
),
),
const SizedBox(height: 8),
Row(
children: <Widget>[
const Text(
'5h 36m',
style: TextStyle(
color: Color(0xFFF0BA64),
fontSize: 22,
),
),
const SizedBox(width: 8),
Text(
'(353 mi)',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 21,
),
),
],
),
const SizedBox(height: 8),
const Text(
'Fastest route now due to traffic conditions.',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
const SizedBox(height: 8),
],
),
);
}
Widget buildFooter(BuildContext context, SheetState state) {
Widget button(
Icon icon,
Text text,
VoidCallback onTap, {
BorderSide? border,
Color? color,
}) {
final child = Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
icon,
const SizedBox(width: 8),
text,
],
);
const shape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
);
return border == null
? ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(shape: shape),
child: child,
)
: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(shape: shape),
child: child,
);
}
return CustomContainer(
shadowDirection: ShadowDirection.top,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: Colors.white,
shadowColor: Colors.black12,
child: Row(
children: <Widget>[
button(
const Icon(
Icons.navigation,
color: Colors.white,
),
const Text(
'Start',
style: TextStyle(
color: Colors.white,
fontSize: 15,
),
),
() async {
// Inherit from context...
await SheetController.of(context)!.hide();
Future.delayed(const Duration(milliseconds: 1500), () {
// or use the controller
controller.show();
});
},
color: mapsBlue,
),
const SizedBox(width: 8),
SheetListenerBuilder(
buildWhen: (oldState, newState) =>
oldState.isExpanded != newState.isExpanded,
builder: (context, state) {
final isExpanded = state.isExpanded;
return button(
Icon(
!isExpanded ? Icons.list : Icons.map,
color: mapsBlue,
),
Text(
!isExpanded ? 'Steps & more' : 'Show map',
style: const TextStyle(
fontSize: 15,
color: Colors.black,
),
),
!isExpanded
? () => controller.scrollTo(state.maxScrollExtent)
: controller.collapse,
color: Colors.white,
border: BorderSide(
color: Colors.grey.shade400,
width: 2,
),
);
},
),
],
),
);
}
Widget buildInfiniteChild(
BuildContext context,
ScrollController controller,
SheetState state,
) {
return ListView.separated(
controller: controller,
itemBuilder: (context, index) => Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Text('$index'),
),
separatorBuilder: (context, index) => const Divider(),
itemCount: 100,
);
}
Future<void> showBottomSheetDialog(BuildContext context) async {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final controller = SheetController();
bool isDismissable = false;
await showSnappingBottomSheet(
context,
// The parentBuilder can be used to wrap the sheet inside a parent.
// This can be for example a Theme or an AnnotatedRegion.
parentBuilder: (context, sheet) {
return Theme(
data: ThemeData.dark(),
child: sheet,
);
},
// The builder to build the dialog. Calling rebuilder on the dialogController
// will call the builder, allowing react to state changes while the sheet is shown.
builder: (context) {
return SnappingBottomSheetDialog(
controller: controller,
duration: const Duration(milliseconds: 500),
snapSpec: const SnapSpec(
snap: true,
initialSnap: 0.7,
snappings: [
0.3,
0.7,
],
),
scrollSpec: const ScrollSpec(
showScrollbar: true,
),
color: Colors.teal,
maxWidth: 500,
minHeight: 700,
isDismissable: isDismissable,
dismissOnBackdropTap: true,
isBackdropInteractable: true,
onDismissPrevented: (backButton, backDrop) async {
HapticFeedback.heavyImpact();
if (backButton || backDrop) {
const duration = Duration(milliseconds: 300);
await controller.snapToExtent(0.2, duration: duration, clamp: false);
await controller.snapToExtent(0.4, duration: duration);
// or Navigator.pop(context);
}
// Or pop the route
// if (backButton) {
// Navigator.pop(context);
// }
log('Dismiss prevented');
},
builder: (context, state) {
return Container(
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Confirm purchase',
style: textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: Text(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent sagittis tellus lacus, et pulvinar orci eleifend in.',
style: textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 24),
Icon(
isDismissable ? Icons.check : Icons.error,
color: Colors.white,
size: 56,
),
],
),
],
),
);
},
footerBuilder: (context, state) {
return Container(
color: Colors.teal.shade700,
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Cancel',
style: textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
TextButton(
onPressed: () {
if (!isDismissable) {
isDismissable = true;
SheetController.of(context)!.rebuild();
} else {
Navigator.pop(context);
}
},
child: Text(
'Approve',
style: textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
},
);
},
);
}
Widget _buildBody() {
return Stack(
children: <Widget>[
buildMap(),
Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.fromLTRB(
0, MediaQuery.of(context).padding.top + 16, 16, 0),
child: FloatingActionButton(
backgroundColor: Colors.white,
onPressed: () async {
await showBottomSheetDialog(context);
},
child: const Icon(
Icons.layers,
color: mapsBlue,
),
),
),
),
],
);
}
Widget buildMap() {
return Column(
children: <Widget>[
Expanded(
child: Image.asset(
'assets/map.jpeg',
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
fit: BoxFit.cover,
),
),
],
);
}
}
希望这个示例对你有所帮助!如果有任何问题或需要进一步的帮助,请随时提问。
更多关于Flutter底部弹出视图插件snapping_bottom_sheet的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html
更多关于Flutter底部弹出视图插件snapping_bottom_sheet的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html
当然,以下是如何在Flutter项目中使用snapping_bottom_sheet
插件的一个简单示例。这个插件允许你创建一个底部弹出的视图,类似于iOS中的底部表单表(Bottom Sheet)。
首先,你需要在你的pubspec.yaml
文件中添加snapping_bottom_sheet
依赖:
dependencies:
flutter:
sdk: flutter
snapping_bottom_sheet: ^x.y.z # 请替换为最新版本号
然后运行flutter pub get
来安装依赖。
接下来,在你的Dart文件中,你可以按照以下方式使用SnappingBottomSheet
:
import 'package:flutter/material.dart';
import 'package:snapping_bottom_sheet/snapping_bottom_sheet.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Snapping Bottom Sheet Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
late SnappingBottomSheetController _bottomSheetController;
@override
void initState() {
super.initState();
_bottomSheetController = SnappingBottomSheetController();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Snapping Bottom Sheet Demo'),
),
body: Center(
child: ElevatedButton(
onPressed: () => _showBottomSheet(context),
child: Text('Show Bottom Sheet'),
),
),
);
}
void _showBottomSheet(BuildContext context) {
showSnappingBottomSheet<void>(
context: context,
controller: _bottomSheetController,
snapSpec: SnapSpec.inset(
duration: const Duration(milliseconds: 300),
inset: 50.0,
),
builder: (BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('This is a bottom sheet!'),
SizedBox(height: 16.0),
ElevatedButton(
onPressed: () => _bottomSheetController.close(),
child: Text('Close'),
),
],
),
);
},
);
}
}
代码说明:
- 依赖添加:在
pubspec.yaml
中添加snapping_bottom_sheet
依赖。 - 初始化控制器:在
_MyHomePageState
中初始化一个SnappingBottomSheetController
实例。 - 按钮触发:在页面的中心位置放置一个按钮,点击按钮时调用
_showBottomSheet
方法。 - 显示底部弹出视图:
_showBottomSheet
方法使用showSnappingBottomSheet
函数显示一个底部弹出视图,并设置了一些动画参数(snapSpec
)和构建器(builder
)。 - 底部弹出视图内容:在
builder
中返回一个包含文本和关闭按钮的列布局。
这样,当你点击按钮时,就会从底部弹出一个视图,并且你可以通过点击“Close”按钮来关闭它。这个示例展示了如何基本使用snapping_bottom_sheet
插件。你可以根据需要进一步自定义和扩展这个底部弹出视图。