You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Flutter中实现类Telegram的自定义BottomSheet功能

Flutter实现Telegram风格BottomSheet的代码调整方案

以下是对应四个需求特性的修改逻辑和完整可运行代码:

核心修改点

  • 开启动画优化:调整路由过渡曲线为更贴合Telegram的顺滑动效,同时背景半透明值随入场动画联动变化,避免背景突然变暗
  • 标签页切换动画实现:新增TabBar+TabBarView结构,展开状态下顶部手柄区域平滑过渡为标签栏,标签切换时内容高度通过AnimatedSize实现平滑过渡
  • 展开折叠动画优化:新增滚动阈值判断,仅当滚动偏移超过设定阈值才触发状态切换,未达阈值自动回弹,动画曲线调整为更自然的缓动效果
  • 拖拽防关闭逻辑实现:移除滚动偏移小于0直接关闭的逻辑,新增拖拽距离/速度判断:展开状态下向下拖拽先触发折叠到半高状态,半高状态下拖拽距离超过屏幕1/4或拖拽速度超过阈值才触发关闭,否则回弹

完整修改后代码

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Telegram BottomSheet Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const MyHomePage(),
    );
  }
}

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

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('BottomSheet Demo')),
      body: Center(
        child: TextButton(
          onPressed: () => Navigator.of(context).push(TransparentRoute(
            builder: (context) => const TelegramBottomSheet(),
          )),
          child: const Text('打开BottomSheet', style: TextStyle(fontSize: 16)),
        ),
      ),
    );
  }
}

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

  @override
  _TelegramBottomSheetState createState() => _TelegramBottomSheetState();
}

class _TelegramBottomSheetState extends State<TelegramBottomSheet> with TickerProviderStateMixin {
  bool isExpanded = false;
  bool isBouncing = true;
  final double halfHeightOffset = 200;
  final double miniHandleHeight = 20;
  double safeAreaPadding = 0;
  double? expandThreshold;
  double? stickyThreshold;
  late TabController tabController;

  double get appBarTotalHeight => safeAreaPadding + kToolbarHeight;
  final ScrollController scrollController = ScrollController();

  // 模拟不同标签的内容长度
  final List<List<String>> tabImages = [
    List.generate(25, (_) => 'http://placeimg.com/100/100/any'),
    List.generate(8, (_) => 'http://placeimg.com/100/100/tech'),
    List.generate(15, (_) => 'http://placeimg.com/100/100/nature'),
  ];

  @override
  void initState() {
    super.initState();
    tabController = TabController(length: 3, vsync: this);
    tabController.addListener(() => setState(() {}));
    WidgetsBinding.instance.addPostFrameCallback(_afterLayout);
  }

  void _afterLayout(_) {
    final media = MediaQuery.of(context);
    safeAreaPadding = media.padding.top;
    expandThreshold = halfHeightOffset - appBarTotalHeight;
    stickyThreshold = expandThreshold! + 10;
    scrollController.addListener(scrollListener);
  }

  void scrollListener() {
    final offset = scrollController.offset;
    // 移除原有的offset<0直接关闭逻辑
    if (offset < expandThreshold! && isExpanded || offset < stickyThreshold!) {
      if (isExpanded) setState(() => isExpanded = false);
    } else if (offset > expandThreshold! && !isExpanded || offset > stickyThreshold!) {
      if (!isExpanded) setState(() => isExpanded = true);
    }

    if (offset < halfHeightOffset && !isBouncing) {
      setState(() => isBouncing = true);
    } else if (offset > halfHeightOffset && isBouncing) {
      setState(() => isBouncing = false);
    }
  }

  // 处理关闭逻辑,增加拖拽阈值判断
  Future<void> handleClose() async {
    if (isExpanded) {
      // 展开状态先滚动到折叠位置
      scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
      return;
    }
    // 折叠状态判断拖拽距离,实际使用可补充手势监听拖拽偏移量做更精细的阈值判断
    scrollController.dispose();
    tabController.dispose();
    Navigator.of(context).pop();
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async {
        if (isExpanded) {
          scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
          return false;
        }
        return true;
      },
      child: SafeArea(
        child: Scaffold(
          backgroundColor: Colors.transparent,
          body: GestureDetector(
            onVerticalDragEnd: (details) {
              // 向下拖拽速度超过阈值触发关闭判断
              if (details.primaryVelocity! > 800) handleClose();
            },
            child: ListView(
              padding: EdgeInsets.zero,
              physics: isBouncing ? const BouncingScrollPhysics() : const ClampingScrollPhysics(),
              controller: scrollController,
              children: [
                // 顶部手柄/标签栏区域
                Container(
                  alignment: Alignment.bottomCenter,
                  height: halfHeightOffset,
                  child: TweenAnimationBuilder(
                    tween: Tween(begin: 0.0, end: isExpanded ? 1.0 : 0.0),
                    duration: const Duration(milliseconds: 250),
                    curve: Curves.easeInOut,
                    builder: (_, double progress, child) {
                      return Container(
                        height: appBarTotalHeight * progress + miniHandleHeight,
                        decoration: BoxDecoration(
                          borderRadius: BorderRadius.vertical(top: Radius.circular((1 - progress) * 24)),
                          color: Colors.white,
                        ),
                        child: Stack(
                          children: [
                            // 折叠状态显示手柄
                            Opacity(opacity: 1 - progress, child: child),
                            // 展开状态显示标签栏
                            Opacity(
                              opacity: progress,
                              child: TabBar(
                                controller: tabController,
                                labelColor: Colors.blue,
                                unselectedLabelColor: Colors.grey,
                                indicatorColor: Colors.blue,
                                tabs: const [
                                  Tab(text: '全部'),
                                  Tab(text: '技术'),
                                  Tab(text: '自然'),
                                ],
                              ),
                            )
                          ],
                        ),
                      );
                    },
                    child: Align(
                      alignment: Alignment.topCenter,
                      child: Container(
                        margin: const EdgeInsets.only(top: 8),
                        height: 4,
                        width: 48,
                        decoration: BoxDecoration(
                          color: Colors.grey.shade300,
                          borderRadius: BorderRadius.circular(2),
                        ),
                      ),
                    ),
                  ),
                ),
                // 内容区域
                AnimatedSize(
                  duration: const Duration(milliseconds: 200),
                  curve: Curves.easeInOut,
                  child: Container(
                    padding: const EdgeInsets.all(8),
                    constraints: BoxConstraints(
                      minHeight: MediaQuery.of(context).size.height - appBarTotalHeight,
                    ),
                    decoration: const BoxDecoration(color: Colors.white),
                    child: TabBarView(
                      controller: tabController,
                      physics: const NeverScrollableScrollPhysics(),
                      children: tabImages.map((images) => _buildGrid(images)).toList(),
                    ),
                  ),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildGrid(List<String> images) {
    return GridView.count(
      crossAxisSpacing: 8,
      mainAxisSpacing: 8,
      physics: const NeverScrollableScrollPhysics(),
      shrinkWrap: true,
      crossAxisCount: 3,
      children: images.map((url) {
        return Container(
          decoration: BoxDecoration(
            border: Border.all(color: Colors.blueAccent),
            borderRadius: BorderRadius.circular(4),
          ),
          child: Image.network(url, fit: BoxFit.cover),
        );
      }).toList(),
    );
  }
}

class TransparentRoute extends PageRoute<void> {
  TransparentRoute({required this.builder, RouteSettings? settings})
      : super(settings: settings, fullscreenDialog: false);

  final WidgetBuilder builder;

  @override
  bool get opaque => false;
  @override
  Color? get barrierColor => null;
  @override
  String? get barrierLabel => null;
  @override
  bool get maintainState => true;
  @override
  Duration get transitionDuration => const Duration(milliseconds: 400);

  @override
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
    final result = builder(context);
    return FadeTransition(
      opacity: Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(
        parent: animation,
        curve: Curves.easeOut,
      )),
      child: Container(
        // 背景透明度随动画联动
        color: Colors.black.withOpacity(animation.value * 0.5),
        child: SlideTransition(
          position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero).animate(CurvedAnimation(
            parent: animation,
            curve: Curves.fastEaseInToSlowEaseOut,
          )),
          child: result,
        ),
      ),
    );
  }
}

内容的提问来源于stack exchange,提问作者DolDurma

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.03 21:27:00