Flutter底部弹出视图插件snapping_bottom_sheet的使用

发布于 1周前 作者 wuwangju 来自 Flutter

Flutter底部弹出视图插件snapping_bottom_sheet的使用

snapping_bottom_sheet 是一个可拖动、可滚动且可锁定的底部弹出视图插件。它提供了两种使用方式:作为 Widget 嵌入到你的 widget 树中,或者作为 BottomSheetDialog 显示。

示例

图片

Image

动图

Gif

如果动图无法加载,请点击这里

使用方法

作为 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 如果为 trueSnappingBottomSheet 将锁定到提供的 snappings。如果为 falseSnappingBottomSheet 将从 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,可以将回调传递给 SnappingBottomSheetlistener 字段,该回调将在 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

1 回复

更多关于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'),
              ),
            ],
          ),
        );
      },
    );
  }
}

代码说明:

  1. 依赖添加:在pubspec.yaml中添加snapping_bottom_sheet依赖。
  2. 初始化控制器:在_MyHomePageState中初始化一个SnappingBottomSheetController实例。
  3. 按钮触发:在页面的中心位置放置一个按钮,点击按钮时调用_showBottomSheet方法。
  4. 显示底部弹出视图_showBottomSheet方法使用showSnappingBottomSheet函数显示一个底部弹出视图,并设置了一些动画参数(snapSpec)和构建器(builder)。
  5. 底部弹出视图内容:在builder中返回一个包含文本和关闭按钮的列布局。

这样,当你点击按钮时,就会从底部弹出一个视图,并且你可以通过点击“Close”按钮来关闭它。这个示例展示了如何基本使用snapping_bottom_sheet插件。你可以根据需要进一步自定义和扩展这个底部弹出视图。

回到顶部