如何在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
相关产品推荐
相关产品推荐

