BottomSheet底部抽屉组件详解

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使用

基础模态底部抽屉

dart 复制代码
class ModalBottomSheetPage extends StatelessWidget {
  const ModalBottomSheetPage({super.key});

  @override
  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

处理长内容列表

dart 复制代码
class ScrollableModalBottomSheetPage extends StatelessWidget {
  const ScrollableModalBottomSheetPage({super.key});

  @override
  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属性

dart 复制代码
class PersistentBottomSheetPage extends StatefulWidget {
  const PersistentBottomSheetPage({super.key});

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

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

  @override
  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的动画效果

自定义动画

dart 复制代码
class AnimatedBottomSheetPage extends StatelessWidget {
  const AnimatedBottomSheetPage({super.key});

  @override
  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中实现表单输入

dart 复制代码
class FormBottomSheetPage extends StatelessWidget {
  const FormBottomSheetPage({super.key});

  @override
  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 {
  @override
  State<FormBottomSheetContent> createState() => _FormBottomSheetContentState();
}

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

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

  @override
  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,为用户提供优秀的交互体验。

相关推荐
zilikew3 小时前
Flutter框架跨平台鸿蒙开发——文字朗读器APP的开发流程
flutter·华为·harmonyos
lbb 小魔仙3 小时前
【Harmonyos】开源鸿蒙跨平台训练营DAY3:HarmonyOS + Flutter + Dio:从零实现跨平台数据清单应用完整指南
flutter·开源·harmonyos
2601_949575863 小时前
Flutter for OpenHarmony二手物品置换App实战 - 列表性能优化实现
flutter·性能优化
Miguo94well3 小时前
Flutter框架跨平台鸿蒙开发——歌词制作器APP的开发流程
flutter·华为·harmonyos·鸿蒙
晚霞的不甘3 小时前
Flutter for OpenHarmony 进阶实战:打造 60FPS 流畅的物理切水果游戏
javascript·flutter·游戏·云原生·正则表达式
雨季6663 小时前
构建 OpenHarmony 文本高亮关键词标记器:用纯字符串操作实现智能标注
开发语言·javascript·flutter·ui·ecmascript·dart
b2077213 小时前
Flutter for OpenHarmony 身体健康状况记录App实战 - 体重趋势实现
python·flutter·harmonyos
b2077213 小时前
Flutter for OpenHarmony 身体健康状况记录App实战 - 个人中心实现
android·java·python·flutter·harmonyos
灰灰勇闯IT3 小时前
Flutter for OpenHarmony:布局组件实战指南
前端·javascript·flutter