Flutter BottomSheet For Android Habiter

114 阅读3分钟

基于 modal_bottom_sheet 封装。

如果你习惯了 Android 开发时的体验,喜欢 Material 的设计,那么可以用这个封装好的 BottomSheet,尽可能还原了当初 material_dialogs 的使用习惯。

如果你有更好的建议或实现方式,或者觉得目前的实现有问题,不妨在评论区留下你的意见~

BottomSheet

import 'package:flutter/material.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';

/// 显示 Bottomsheet
showBottomSheetDialog({
  required BuildContext context,
  String? title,
  String? message,
  Widget? content,
  Widget? list,
  bool? fullscreen,
  bool dragHandler = true,
  List<BottomSheetTitleAction>? titleActions,
  bool cancelable = true,
  bool cancelableOutside = true,
  bool? secondaryConfirm,
  String? secondaryConfirmText,
  String? negativeText,
  void Function()? onNegative,
  String? positiveText,
  void Function(dynamic)? onPositive,
}) {
  bool containsMessage = message != null || content != null;
  bool secondaryConfirmChecked = secondaryConfirm ?? false;
  bool containsActionButtons = onNegative != null || onPositive != null;
  bool containsTitleActions = titleActions != null;
  bool containsTitle = title != null || containsTitleActions;

  Widget buildDragHandler() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Padding(
          padding: const EdgeInsets.only(top: 12),
          child: SizedBox(
            width: 40,
            height: 5,
            child: DecoratedBox(
              decoration: BoxDecoration(
                color: Colors.grey,
                borderRadius: BorderRadius.circular(12),
              ),
            ),
          ),
        ),
      ],
    );
  }

  Widget buildTitleWidget() {
    assert(title != null);

    return Text(
      title!,
      style: const TextStyle(
        fontSize: 22,
        fontWeight: FontWeightExt.semibold,
      ),
    );
  }

  Widget buildMessageWidget() {
    assert(containsMessage);

    Widget buildInnerMessageWidget() {
      if (content != null) {
        return SingleChildScrollView(child: SizedBox(width: double.infinity, child: content));
      } else {
        return SingleChildScrollView(child: Text(message!));
      }
    }

    return Flexible(
      child: Padding(
        padding: const EdgeInsets.only(
          left: AppDimens.pagePadding,
          top: AppDimens.itemPadding,
          right: AppDimens.pagePadding,
          bottom: AppDimens.itemPadding,
        ),
        child: buildInnerMessageWidget(),
      ),
    );
  }

  Widget buildTitleActionsWidget(BottomSheetTitleAction titleAction) {
    assert(containsTitleActions);

    // load from Icons
    if (titleAction.iconData != null) {
      return IconButton(
        onPressed: () => titleAction.onTap,
        icon: Icon(titleAction.iconData),
      );
    }

    // load from text
    if (!titleAction.name.isNullOrBlank) {
      return TextButton(
        onPressed: () => titleAction.onTap,
        child: Text(titleAction.name!),
      );
    }

    // load from custom icon
    return const Text("placeholder");
  }

  Widget buildSecondaryConfirmWidget() {
    return Builder(
      builder: (context) {
        return GestureDetector(
          onTap: () {
            secondaryConfirmChecked = !secondaryConfirmChecked;
            (context as Element).markNeedsBuild();
          },
          child: Padding(
            padding: const EdgeInsets.only(
              left: AppDimens.pagePadding,
              top: AppDimens.itemPadding,
              right: AppDimens.pagePadding,
            ),
            child: CheckboxLabel(
              value: secondaryConfirmChecked,
              label: secondaryConfirmText ?? 'secondary confirm',
              onChanged: (bool? newValue) {
                secondaryConfirmChecked = newValue ?? false;
                // 标记 Widget 树需要重新 Build,以刷新 UI。
                // 该方法来自于:https://book.flutterchina.club/chapter7/dailog.html#_7-7-4-%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86
                context.markRebuildUI();
              },
            ),
          ),
        );
      },
    );
  }

  double sheetMaxHeight;
  if (fullscreen == true) {
    sheetMaxHeight = double.infinity;
  } else {
    if (containsMessage && list != null) {
      sheetMaxHeight = double.infinity;
    } else {
      sheetMaxHeight = MediaQuery.of(context).size.height / 1.5;
    }
  }

  Widget buildActionButtonsWidget() {
    return Padding(
      padding: const EdgeInsets.all(AppDimens.itemPadding),
      child: OverflowBar(
        alignment: MainAxisAlignment.end,
        overflowAlignment: OverflowBarAlignment.end,
        overflowDirection: VerticalDirection.down,
        spacing: 0, // 未 overflow 时按钮之间的间距
        overflowSpacing: 0, // 如果 overflow 了,overflow 后按钮之间的间距
        children: [
          // 使用 TextButton,由于存在点击效果,所以默认显示时看上去有 padding 的样子,所以我们不需要再设置 overflow 的 spacing
          if (onNegative != null) ...[
            TextButton(
              child: Text(negativeText ?? S.current.cancel),
              onPressed: () {
                dismissBottomSheet(context);
                onNegative.call();
              },
            ),
          ],

          if (onPositive != null) ...[
            TextButton(
              child: Text(positiveText ?? S.current.confirm),
              onPressed: () {
                dismissBottomSheet(context);
                onPositive.call(null);
              },
            ),
          ]
        ],
      ),
    );
  }

  // show bottom sheet
  return showMaterialModalBottomSheet(
    context: context,

    /// true 全屏(忽略设置的高度);false自适应高度
    expand: fullscreen ?? false,

    /// 点击 BottomSheet 外部区域能否关闭 BottomSheet
    isDismissible: cancelableOutside,

    /// 是否可以拖拽,并且是否可以通过拖拽来关闭 BottomSheet
    enableDrag: cancelable,

    /// 指定 BottomSheet 在拖动时是否可以超出顶部边界
    bounce: true,

    /// 当用户拖动 BottomSheet 时,何时关闭 BottomSheet
    closeProgressThreshold: 0.6,

    // 背景
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.only(
        topLeft: Radius.circular(AppDimens.radius),
        topRight: Radius.circular(AppDimens.radius),
      ),
    ),
    clipBehavior: Clip.antiAlias,

    /// bottom sheet content
    builder: (context) {
      return ConstrainedBox(
        constraints: BoxConstraints(maxHeight: sheetMaxHeight),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min, // Column 高度自适应
          children: [
            // drag handler
            if (dragHandler) buildDragHandler(),
            // title
            if (containsTitle)
              Padding(
                padding: const EdgeInsets.only(
                  left: AppDimens.pagePadding,
                  top: AppDimens.itemPadding,
                  right: 0,
                ),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: [
                    // title
                    if (!title.isNullOrBlank) buildTitleWidget(),
                    const Spacer(),
                    // title actions
                    if (containsTitleActions)
                      ...titleActions.map((titleAction) => buildTitleActionsWidget(titleAction)),
                  ],
                ),
              ),
            // message
            if (containsMessage) buildMessageWidget(),
            // list view
            if (list != null) Flexible(child: list),
            // secondary confirm checkbox
            if (secondaryConfirm != null) buildSecondaryConfirmWidget(),
            // action buttons
            if (containsActionButtons) buildActionButtonsWidget(),
            // bottom padding
            SizedBox(height: containsActionButtons ? 18 : 38),
          ],
        ),
      );
    },
  );
}

dismissBottomSheet(BuildContext context) {
  Navigator.of(context).pop();
}

class BottomSheetTitleAction {
  String? name;
  String? icon;
  IconData? iconData;
  VoidCallback? onTap;

  BottomSheetTitleAction({
    this.name,
    this.icon,
    this.iconData,
    this.onTap,
  });
}

class TestText extends StatefulWidget {
  const TestText({super.key, required this.text});

  final String text;

  @override
  State<TestText> createState() => _TestTextState();
}

class _TestTextState extends State<TestText> {
  @override
  void initState() {
    super.initState();
    print("init widget: ${widget.text}");
  }

  @override
  void dispose() {
    super.dispose();
    print("dispose: ${widget.text}");
  }

  @override
  Widget build(BuildContext context) {
    return Text(widget.text);
  }
}

使用

                    showBottomSheetDialog(
                      context: context,
                      title: "Bottom Sheet",
                      titleActions: [
                        BottomSheetTitleAction(name: "Add", iconData: Icons.add),
                        BottomSheetTitleAction(name: "Done", iconData: Icons.done),
                      ],
                      // message: "Hhhh",
                      // content: const Column(
                      //   crossAxisAlignment: CrossAxisAlignment.start,
                      //   children: [
                      //     // SingleChildScrollView 会一次性加载所有
                      //     TestText(text: "kkk"),
                      //     TestText(text: "kkk"),
                      //     TestText(text: "kkk"),
                      //   ],
                      // ),
                      list: ListView.builder(
                        itemCount: 320,
                        padding: const EdgeInsets.only(top: 0),
                        itemBuilder: (BuildContext context, int index) {
                          // List.builder 会滚动加载
                          return ListTile(
                            title: TestText(text: "${index + 1}"),
                            onTap: () => Navigator.of(context).pop(index),
                          );
                        },
                      ),
                      secondaryConfirm: true,
                      secondaryConfirmText: "Are you ok?",
                      onNegative: () => print("negative"),
                      onPositive: (v) => print("positive"),
                    );