BottomSheet底部抽屉组件详解

一、BottomSheet组件概述

BottomSheet是Flutter中用于显示从屏幕底部滑出的面板组件,它可以是模态的(Modal)也可以是非模态的(Persistent)。BottomSheet常用于显示补充信息、菜单选项、表单输入等内容,是Material Design中重要的交互模式。

BottomSheet的设计理念

BottomSheet组件

模态BottomSheet

持久化BottomSheet

交互特性

应用场景

showModalBottomSheet

全屏遮罩

点击外部关闭

单次显示

Scaffold.bottomSheet

可拖动

持续显示

可编程控制

滑动手势

拖动手柄

展开/收起

动画流畅

菜单选择

表单输入

信息展示

操作确认

BottomSheet的优势在于它从屏幕底部滑出,符合用户单手操作的习惯。同时,它不会完全遮挡主内容,用户可以在保持上下文的情况下完成辅助操作。BottomSheet在移动应用中被广泛使用,如分享菜单、筛选选项、详情展示等场景。

二、BottomSheet的类型对比

模态vs持久化对比表

特性 Modal BottomSheet Persistent BottomSheet
API showModalBottomSheet() Scaffold.bottomSheet
显示方式 从底部滑出,带遮罩 嵌入在Scaffold中
关闭方式 点击外部或返回键 可编程控制,可拖动
适用场景 临时操作、单次使用 持续显示的内容、可拖动面板
遮罩 有半透明遮罩 无遮罩
动画 默认有滑出动画 默认无动画,可自定义

showModalBottomSheet参数

参数名 类型 说明 默认值
context BuildContext 上下文 必需
builder WidgetBuilder 构建器 必需
backgroundColor Color 背景颜色 null
elevation double 阴影高度 null
shape ShapeBorder 形状 null
constraints BoxConstraints 约束 null
isDismissible bool 是否可点击外部关闭 true
enableDrag bool 是否可拖动 true
isScrollControlled bool 是否滚动控制高度 false

三、模态BottomSheet使用

基础模态底部抽屉

class ModalBottomSheetPage extends StatelessWidget {
  const ModalBottomSheetPage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('模态BottomSheet'),
        backgroundColor: Colors.blue,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            _showModalBottomSheet(context);
          },
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.blue,
            foregroundColor: Colors.white,
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
          ),
          child: const Text('显示BottomSheet'),
        ),
      ),
    );
  }

  void _showModalBottomSheet(BuildContext context) {
    showModalBottomSheet(
      context: context,
      backgroundColor: Colors.white,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(
          top: Radius.circular(20),
        ),
      ),
      builder: (context) {
        return Container(
          padding: const EdgeInsets.all(16),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: 40,
                height: 4,
                margin: const EdgeInsets.only(bottom: 20),
                decoration: BoxDecoration(
                  color: Colors.grey[300],
                  borderRadius: BorderRadius.circular(2),
                ),
              ),
              const Text(
                '底部菜单',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                ),
              ),
              const SizedBox(height: 20),
              _buildMenuItem(
                context,
                Icons.photo_library,
                '相册',
                Colors.blue,
              ),
              _buildMenuItem(
                context,
                Icons.camera_alt,
                '相机',
                Colors.green,
              ),
              _buildMenuItem(
                context,
                Icons.insert_drive_file,
                '文件',
                Colors.orange,
              ),
              _buildMenuItem(
                context,
                Icons.location_on,
                '位置',
                Colors.red,
              ),
              const SizedBox(height: 16),
            ],
          ),
        );
      },
    );
  }

  Widget _buildMenuItem(
    BuildContext context,
    IconData icon,
    String title,
    Color color,
  ) {
    return ListTile(
      leading: Icon(icon, color: color, size: 28),
      title: Text(
        title,
        style: const TextStyle(fontSize: 16),
      ),
      onTap: () {
        Navigator.pop(context);
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('选择了:$title')),
        );
      },
    );
  }
}

代码实现要点

模态BottomSheet的实现需要关注以下几点:

  1. 使用showModalBottomSheet方法:这是Flutter提供的便捷方法,用于显示模态底部抽屉
  2. 设置shape属性:通过圆角矩形让BottomSheet看起来更加美观
  3. 添加拖动手柄:在顶部添加一个小横条,暗示用户可以拖动
  4. 控制高度:使用mainAxisSize: MainAxisSize.min让BottomSheet高度适应内容
  5. 关闭处理:在操作完成后使用Navigator.pop关闭BottomSheet
  6. 提供反馈:关闭后显示SnackBar,给用户操作确认

四、可滚动的模态BottomSheet

处理长内容列表

class ScrollableModalBottomSheetPage extends StatelessWidget {
  const ScrollableModalBottomSheetPage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('可滚动BottomSheet'),
        backgroundColor: Colors.purple,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            _showScrollableBottomSheet(context);
          },
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.purple,
            foregroundColor: Colors.white,
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
          ),
          child: const Text('显示可滚动列表'),
        ),
      ),
    );
  }

  void _showScrollableBottomSheet(BuildContext context) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (context) {
        return DraggableScrollableSheet(
          initialChildSize: 0.6,
          minChildSize: 0.3,
          maxChildSize: 0.9,
          builder: (context, scrollController) {
            return Container(
              decoration: const BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.vertical(
                  top: Radius.circular(20),
                ),
              ),
              child: Column(
                children: [
                  Container(
                    margin: const EdgeInsets.all(12),
                    child: Row(
                      children: [
                        Expanded(
                          child: Container(
                            height: 4,
                            decoration: BoxDecoration(
                              color: Colors.grey[300],
                              borderRadius: BorderRadius.circular(2),
                            ),
                          ),
                        ),
                        IconButton(
                          icon: const Icon(Icons.close),
                          onPressed: () => Navigator.pop(context),
                        ),
                      ],
                    ),
                  ),
                  const Padding(
                    padding: EdgeInsets.symmetric(horizontal: 16),
                    child: Text(
                      '选择城市',
                      style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                  ),
                  const SizedBox(height: 8),
                  Expanded(
                    child: ListView.builder(
                      controller: scrollController,
                      padding: const EdgeInsets.all(16),
                      itemCount: _cities.length,
                      itemBuilder: (context, index) {
                        return Card(
                          margin: const EdgeInsets.only(bottom: 8),
                          child: ListTile(
                            leading: CircleAvatar(
                              backgroundColor:
                                  Colors.primaries[index % Colors.primaries.length],
                              child: Text(
                                '${index + 1}',
                                style: const TextStyle(color: Colors.white),
                              ),
                            ),
                            title: Text(_cities[index]),
                            trailing: const Icon(Icons.chevron_right),
                            onTap: () {
                              Navigator.pop(context);
                              ScaffoldMessenger.of(context).showSnackBar(
                                SnackBar(content: Text('选择了:${_cities[index]}')),
                              );
                            },
                          ),
                        );
                      },
                    ),
                  ),
                ],
              ),
            );
          },
        );
      },
    );
  }

  final List<String> _cities = [
    '北京',
    '上海',
    '广州',
    '深圳',
    '杭州',
    '南京',
    '武汉',
    '成都',
    '重庆',
    '西安',
    '天津',
    '苏州',
    '长沙',
    '郑州',
    '青岛',
    '大连',
    '厦门',
    '福州',
    '济南',
    '合肥',
  ];
}

可滚动设计要点

当BottomSheet内容较多时,需要支持滚动和拖动:

  1. 设置isScrollControlled为true:允许BottomSheet控制自己的滚动行为
  2. 使用DraggableScrollableSheet:提供可拖动的滚动容器
  3. 设置高度范围:通过initialChildSize、minChildSize、maxChildSize控制高度
  4. 传递scrollController:将scrollController传递给ListView,实现联动滚动
  5. 添加关闭按钮:在右上角添加关闭按钮,提供明确的关闭入口
  6. 透明背景:将backgroundColor设置为Colors.transparent,由DraggableScrollableSheet处理背景

五、持久化BottomSheet

使用Scaffold的bottomSheet属性

class PersistentBottomSheetPage extends StatefulWidget {
  const PersistentBottomSheetPage({super.key});

  
  State<PersistentBottomSheetPage> createState() =>
      _PersistentBottomSheetPageState();
}

class _PersistentBottomSheetPageState
    extends State<PersistentBottomSheetPage> {
  bool _isSheetVisible = false;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('持久化BottomSheet'),
        backgroundColor: Colors.teal,
        foregroundColor: Colors.white,
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: List.generate(
          20,
          (index) => Card(
            margin: const EdgeInsets.only(bottom: 12),
            child: ListTile(
              leading: CircleAvatar(
                backgroundColor: Colors.teal.withOpacity(0.2),
                child: Icon(Icons.article, color: Colors.teal),
              ),
              title: Text('文章 ${index + 1}'),
              subtitle: Text('这是第${index + 1}篇文章的简介'),
              trailing: const Icon(Icons.chevron_right),
              onTap: () {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('打开文章 ${index + 1}')),
                );
              },
            ),
          ),
        ),
      ),
      bottomSheet: _isSheetVisible
          ? Container(
              decoration: BoxDecoration(
                color: Colors.white,
                boxShadow: [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.1),
                    blurRadius: 10,
                    offset: const Offset(0, -2),
                  ),
                ],
                borderRadius: const BorderRadius.vertical(
                  top: Radius.circular(20),
                ),
              ),
              padding: const EdgeInsets.all(16),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Row(
                    children: [
                      const Icon(Icons.music_note, color: Colors.teal),
                      const SizedBox(width: 12),
                      const Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              '正在播放',
                              style: TextStyle(
                                fontSize: 14,
                                color: Colors.grey,
                              ),
                            ),
                            Text(
                              '音乐播放器演示',
                              style: TextStyle(
                                fontSize: 16,
                                fontWeight: FontWeight.bold,
                              ),
                            ),
                          ],
                        ),
                      ),
                      IconButton(
                        icon: const Icon(Icons.skip_previous),
                        onPressed: () {},
                      ),
                      const SizedBox(width: 8),
                      IconButton(
                        icon: const Icon(Icons.play_arrow),
                        onPressed: () {},
                        style: IconButton.styleFrom(
                          backgroundColor: Colors.teal,
                          foregroundColor: Colors.white,
                        ),
                      ),
                      const SizedBox(width: 8),
                      IconButton(
                        icon: const Icon(Icons.skip_next),
                        onPressed: () {},
                      ),
                      IconButton(
                        icon: Icon(
                          _isSheetVisible ? Icons.expand_more : Icons.expand_less,
                        ),
                        onPressed: () {
                          setState(() {
                            _isSheetVisible = !_isSheetVisible;
                          });
                        },
                      ),
                    ],
                  ),
                  Slider(
                    value: 0.3,
                    onChanged: (value) {},
                    activeColor: Colors.teal,
                  ),
                ],
              ),
            )
          : null,
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            _isSheetVisible = !_isSheetVisible;
          });
        },
        backgroundColor: Colors.teal,
        foregroundColor: Colors.white,
        child: Icon(
          _isSheetVisible ? Icons.expand_less : Icons.expand_more,
        ),
      ),
    );
  }
}

持久化BottomSheet要点

持久化BottomSheet通过Scaffold的bottomSheet属性实现,适用于需要持续显示的内容:

  1. 编程控制显示隐藏:通过状态变量控制BottomSheet的显示和隐藏
  2. 添加阴影效果:使用BoxShadow让BottomSheet与主内容区分开来
  3. 提供收起按钮:添加收起/展开按钮,让用户可以主动控制
  4. 响应式设计:根据设备方向或屏幕尺寸调整BottomSheet的高度和内容
  5. 与FAB配合:FloatingActionButton可以控制BottomSheet的显示状态

六、BottomSheet的动画效果

自定义动画

class AnimatedBottomSheetPage extends StatelessWidget {
  const AnimatedBottomSheetPage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('动画BottomSheet'),
        backgroundColor: Colors.orange,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            _showAnimatedBottomSheet(context);
          },
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.orange,
            foregroundColor: Colors.white,
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
          ),
          child: const Text('显示动画BottomSheet'),
        ),
      ),
    );
  }

  void _showAnimatedBottomSheet(BuildContext context) {
    showBottomSheet(
      context: context,
      backgroundColor: Colors.transparent,
      builder: (context) {
        return AnimatedContainer(
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeInOut,
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: const BorderRadius.vertical(
              top: Radius.circular(20),
            ),
            boxShadow: [
              BoxShadow(
                color: Colors.black.withOpacity(0.2),
                blurRadius: 20,
                offset: const Offset(0, -5),
              ),
            ],
          ),
          child: Container(
            padding: const EdgeInsets.all(20),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                TweenAnimationBuilder<double>(
                  tween: Tween(begin: 0.0, end: 1.0),
                  duration: const Duration(milliseconds: 500),
                  builder: (context, value, child) {
                    return Opacity(
                      opacity: value,
                      child: Transform.translate(
                        offset: Offset(0, 20 * (1 - value)),
                        child: child,
                      ),
                    );
                  },
                  child: const Icon(
                    Icons.star,
                    size: 80,
                    color: Colors.orange,
                  ),
                ),
                const SizedBox(height: 20),
                const Text(
                  '动画演示',
                  style: TextStyle(
                    fontSize: 24,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 12),
                const Text(
                  '这是一个带有动画效果的BottomSheet',
                  style: TextStyle(
                    fontSize: 16,
                    color: Colors.grey,
                  ),
                ),
                const SizedBox(height: 24),
                ElevatedButton(
                  onPressed: () => Navigator.pop(context),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.orange,
                    foregroundColor: Colors.white,
                    minimumSize: const Size(double.infinity, 48),
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(10),
                    ),
                  ),
                  child: const Text('关闭'),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

动画效果实现要点

为BottomSheet添加动画可以提升用户体验:

  1. 使用AnimatedContainer:为容器添加颜色、圆角、阴影等动画效果
  2. TweenAnimationBuilder:实现透明度和位移的组合动画
  3. 曲线设置:使用Curves.easeInOut让动画更加自然
  4. 延迟动画:通过不同的duration实现元素的逐个出现效果
  5. 关闭动画:关闭时也会有平滑的过渡动画

七、BottomSheet的表单应用

在BottomSheet中实现表单输入

class FormBottomSheetPage extends StatelessWidget {
  const FormBottomSheetPage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('表单BottomSheet'),
        backgroundColor: Colors.indigo,
        foregroundColor: Colors.white,
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            _showFormBottomSheet(context);
          },
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.indigo,
            foregroundColor: Colors.white,
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
          ),
          child: const Text('添加新项目'),
        ),
      ),
    );
  }

  void _showFormBottomSheet(BuildContext context) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (context) {
        return Container(
          margin: const EdgeInsets.only(top: 100),
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.vertical(
              top: Radius.circular(20),
            ),
          ),
          padding: EdgeInsets.only(
            left: 16,
            right: 16,
            top: 16,
            bottom: MediaQuery.of(context).viewInsets.bottom + 16,
          ),
          child: FormBottomSheetContent(),
        );
      },
    );
  }
}

class FormBottomSheetContent extends StatefulWidget {
  
  State<FormBottomSheetContent> createState() => _FormBottomSheetContentState();
}

class _FormBottomSheetContentState extends State<FormBottomSheetContent> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _descriptionController = TextEditingController();
  String _category = '工作';

  
  void dispose() {
    _titleController.dispose();
    _descriptionController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Row(
            children: [
              const Text(
                '添加项目',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                ),
              ),
              const Spacer(),
              IconButton(
                icon: const Icon(Icons.close),
                onPressed: () => Navigator.pop(context),
              ),
            ],
          ),
          const SizedBox(height: 20),
          TextFormField(
            controller: _titleController,
            decoration: InputDecoration(
              labelText: '标题',
              prefixIcon: const Icon(Icons.title),
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(10),
              ),
            ),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return '请输入标题';
              }
              return null;
            },
          ),
          const SizedBox(height: 16),
          TextFormField(
            controller: _descriptionController,
            decoration: InputDecoration(
              labelText: '描述',
              prefixIcon: const Icon(Icons.description),
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(10),
              ),
            ),
            maxLines: 3,
          ),
          const SizedBox(height: 16),
          DropdownButtonFormField<String>(
            value: _category,
            decoration: InputDecoration(
              labelText: '分类',
              prefixIcon: const Icon(Icons.category),
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(10),
              ),
            ),
            items: const [
              DropdownMenuItem(value: '工作', child: Text('工作')),
              DropdownMenuItem(value: '生活', child: Text('生活')),
              DropdownMenuItem(value: '学习', child: Text('学习')),
              DropdownMenuItem(value: '娱乐', child: Text('娱乐')),
            ],
            onChanged: (value) {
              setState(() {
                _category = value!;
              });
            },
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                Navigator.pop(context);
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(
                    content: Text('已添加:${_titleController.text}'),
                    backgroundColor: Colors.green,
                  ),
                );
              }
            },
            style: ElevatedButton.styleFrom(
              backgroundColor: Colors.indigo,
              foregroundColor: Colors.white,
              padding: const EdgeInsets.symmetric(vertical: 16),
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(10),
              ),
            ),
            child: const Text('保存', style: TextStyle(fontSize: 16)),
          ),
        ],
      ),
    );
  }
}

表单BottomSheet要点

在BottomSheet中实现表单需要特别注意:

  1. 处理软键盘遮挡:使用MediaQuery.of(context).viewInsets.bottom为底部添加额外的padding
  2. 设置isScrollControlled为true:允许BottomSheet根据键盘位置调整高度
  3. 使用Form组件:利用Form的验证功能,确保表单数据的正确性
  4. 合理的内边距:为表单元素设置合适的间距,避免过于拥挤
  5. 关闭时的清理:在dispose中释放控制器资源
  6. 成功后的反馈:表单提交成功后关闭BottomSheet并显示提示

八、BottomSheet最佳实践

实践总结

BottomSheet最佳实践

类型选择

交互设计

性能优化

用户体验

模态用于临时操作

持久化用于持续内容

根据场景选择

避免混用

支持拖动手势

提供关闭按钮

流畅动画

清晰的视觉反馈

懒加载内容

避免过度嵌套

及时释放资源

优化滚动性能

合理的动画时长

适配不同屏幕

处理键盘遮挡

保持上下文

关键实践要点

  1. 选择合适的类型:对于需要临时显示的单次操作,使用模态BottomSheet;对于需要持续显示、可拖动的内容,使用持久化BottomSheet。

  2. 支持手势操作:启用拖动功能,让用户可以通过上下拖动来展开或收起BottomSheet,提供更自然的交互体验。

  3. 提供关闭入口:除了点击外部关闭外,还应该在右上角提供明确的关闭按钮,避免用户不知道如何关闭。

  4. 适配软键盘:当BottomSheet包含表单输入时,需要正确处理软键盘的弹出,确保输入框不被遮挡。

  5. 优化动画效果:设置合适的动画时长和曲线,让BottomSheet的显示和隐藏更加流畅自然。

  6. 控制高度范围:使用DraggableScrollableSheet时,设置合理的minChildSize和maxChildSize,避免BottomSheet过大或过小。

  7. 保持视觉一致性:BottomSheet的样式应该与应用整体风格保持一致,包括颜色、圆角、字体等。

  8. 处理返回键:对于模态BottomSheet,确保按下返回键能够正确关闭,同时不影响主界面的返回导航。

通过遵循这些最佳实践,可以创建出既美观又实用的BottomSheet,为用户提供优秀的交互体验。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐