Flutter实现IconButton与ElevatedButton悬浮固定效果的方法
Flutter 悬浮按钮与滚动内容实现方案
需求说明
- 左上角关闭IconButton保持悬浮,内容滚动时不移动,且不超出SafeArea范围
- 底部ElevatedButton固定悬浮,不受内容滚动影响
- 仅中间内容区域可滚动超出视图
原代码
import 'package:flutter/material.dart'; import 'package:icon_decoration/icon_decoration.dart'; import 'package:training/models/images_instance.dart'; class CustomDialog extends StatefulWidget { final int selectedIndex; const CustomDialog({super.key, required this.selectedIndex}); @override State<CustomDialog> createState() => _CustomDialogState(); } class _CustomDialogState extends State<CustomDialog> { ValueNotifier<double> animationValue = ValueNotifier<double>(0.0); double calculateItemPrice(int itemCount, double originalPrice) { return itemCount * originalPrice; } void addItem(int index) { setState(() { imagesInstances[index].itemCount++; animationValue.value = 0.5; }); } void removeItem(int index) { setState(() { if (imagesInstances[index].itemCount > 1) { imagesInstances[index].itemCount--; animationValue.value = -0.5; } }); } @override Widget build(BuildContext context) { Size size = MediaQuery.of(context).size; return Column( children: [ Stack( children: [ ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), ), child: Image.asset( imagesInstances[widget.selectedIndex].imageString!, width: size.width, // Stretch to the entire width height: size.height * 0.4, fit: BoxFit.cover, ), ), Positioned( top: 0, left: 0, child: Padding( padding: const EdgeInsets.all(8.0), child: CircleAvatar( backgroundColor: Colors.white, child: IconButton( onPressed: () { Navigator.pop(context); }, icon: const Icon( Icons.close, color: Colors.black, ), ), ), ), ), ], ), Container( color: Colors.white, child: Padding( padding: const EdgeInsets.only(bottom: 10, right: 10, left: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( imagesInstances[widget.selectedIndex].imageTitle!, style: const TextStyle( fontSize: 35, fontWeight: FontWeight.bold, ), ), const SizedBox( height: 10, ), Text( imagesInstances[widget.selectedIndex].imageDescription!, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.grey.withOpacity(0.8), ), ), const SizedBox( height: 10, ), SizedBox( width: double.infinity, height: 80, child: Stack( children: [ Text( 'EPG ${imagesInstances[widget.selectedIndex].itemPrice.toString()}', style: const TextStyle( fontSize: 25, fontWeight: FontWeight.bold, color: Colors.black, ), ), Positioned( right: 0, top: 10, child: FittedBox( child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(11), boxShadow: const [ BoxShadow( offset: Offset(1, 3), blurRadius: 4, color: Colors.grey, ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( onPressed: () { removeItem(widget.selectedIndex); }, icon: Icon( Icons.remove, color: imagesInstances[widget.selectedIndex] .itemCount > 1 ? const Color(0xffE66219) : const Color(0xffFECDB4), ), ), AnimatedSwitcher( transitionBuilder: (child, animation) { final offset = Offset(0, animationValue.value); return SlideTransition( position: Tween<Offset>( begin: offset, end: Offset.zero, ).animate(animation), child: child, ); }, duration: const Duration(milliseconds: 50), child: Text( '${imagesInstances[widget.selectedIndex].itemCount}', key: Key( '${imagesInstances[widget.selectedIndex].itemCount}'), style: const TextStyle(fontSize: 25), ), ), IconButton( color: const Color(0xffE66219), // Default color onPressed: () { addItem(widget.selectedIndex); }, icon: const Icon(Icons.add), ), ], ), ), ), ) ], ), ), ], ), ), ), const SizedBox( height: 10, ), Container( color: Colors.white, child: Column( children: [ Padding( padding: const EdgeInsets.only( left: 30, right: 15, top: 10, bottom: 10), child: Row( children: [ Stack( children: [ Transform.translate( offset: const Offset(12, 0), child: Transform( transform: Matrix4.diagonal3Values(-1, 1, 1), child: const DecoratedIcon( icon: Icon( Icons.chat_bubble, color: Colors.white, ), decoration: IconDecoration( border: IconBorder(width: 3, color: Colors.black), ), ), ), ), ], ), const Text( 'Any special requests?', style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: Colors.black, ), ), const Spacer(), const Text( 'Add note', style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: Colors.orange, ), ), ], ), ), const SizedBox( height: 20, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 15), child: ElevatedButton( onPressed: () {}, style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5), ), padding: const EdgeInsets.symmetric( vertical: 15, horizontal: 15), backgroundColor: Colors.orange[900], ), child: Row( children: [ const Text( 'Add to basket', style: TextStyle( fontSize: 20, ), ), const Spacer(), Text( 'EPG ${calculateItemPrice(imagesInstances[widget.selectedIndex].itemCount, imagesInstances[widget.selectedIndex].itemPrice!)}', style: const TextStyle( fontSize: 20, color: Colors.white, ), ), ], ), ), ) ], ), ), ], ); } } void showCustomDialog(BuildContext context, int selectedIndex) { showModalBottomSheet( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( topRight: Radius.circular(20), topLeft: Radius.circular(20), ), ), isScrollControlled: true, // Use isScrollControlled context: context, builder: (BuildContext context) { return SingleChildScrollView( child: CustomDialog(selectedIndex: selectedIndex), ); }, ); }
修改方案
核心思路是用Stack作为最外层容器,将悬浮组件(关闭按钮、底部按钮)放在顶层,中间可滚动内容放在底层,同时通过SafeArea确保组件不超出安全区域。
具体修改点
- 最外层改用Stack:替代原来的Column,让悬浮组件能覆盖在滚动内容之上
- 关闭按钮添加SafeArea:避免在刘海屏等设备上超出顶部安全区域
- 中间内容用SingleChildScrollView包裹:设置底部padding留出固定按钮的空间,确保内容滚动时不会被按钮遮挡
- 底部按钮固定定位:用Positioned将按钮固定在底部,同时适配SafeArea
修改后的完整代码
import 'package:flutter/material.dart'; import 'package:icon_decoration/icon_decoration.dart'; import 'package:training/models/images_instance.dart'; class CustomDialog extends StatefulWidget { final int selectedIndex; const CustomDialog({super.key, required this.selectedIndex}); @override State<CustomDialog> createState() => _CustomDialogState(); } class _CustomDialogState extends State<CustomDialog> { ValueNotifier<double> animationValue = ValueNotifier<double>(0.0); double calculateItemPrice(int itemCount, double originalPrice) { return itemCount * originalPrice; } void addItem(int index) { setState(() { imagesInstances[index].itemCount++; animationValue.value = 0.5; }); } void removeItem(int index) { setState(() { if (imagesInstances[index].itemCount > 1) { imagesInstances[index].itemCount--; animationValue.value = -0.5; } }); } @override Widget build(BuildContext context) { Size size = MediaQuery.of(context).size; final bottomButtonHeight = 100.0; // 底部按钮区域高度,用于设置滚动内容的padding return Stack( children: [ // 可滚动的内容区域 SingleChildScrollView( padding: EdgeInsets.only(bottom: bottomButtonHeight + 20), // 留出底部按钮的空间 child: Column( children: [ // 顶部图片区域 ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), ), child: Image.asset( imagesInstances[widget.selectedIndex].imageString!, width: size.width, height: size.height * 0.4, fit: BoxFit.cover, ), ), // 商品信息区域 Container( color: Colors.white, child: Padding( padding: const EdgeInsets.only(bottom: 10, right: 10, left: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( imagesInstances[widget.selectedIndex].imageTitle!, style: const TextStyle( fontSize: 35, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 10), Text( imagesInstances[widget.selectedIndex].imageDescription!, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Colors.grey.withOpacity(0.8), ), ), const SizedBox(height: 10), SizedBox( width: double.infinity, height: 80, child: Stack( children: [ Text( 'EPG ${imagesInstances[widget.selectedIndex].itemPrice.toString()}', style: const TextStyle( fontSize: 25, fontWeight: FontWeight.bold, color: Colors.black, ), ), Positioned( right: 0, top: 10, child: FittedBox( child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(11), boxShadow: const [ BoxShadow( offset: Offset(1, 3), blurRadius: 4, color: Colors.grey, ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( onPressed: () => removeItem(widget.selectedIndex), icon: Icon( Icons.remove, color: imagesInstances[widget.selectedIndex].itemCount > 1 ? const Color(0xffE66219) : const Color(0xffFECDB4), ), ), AnimatedSwitcher( transitionBuilder: (child, animation) { final offset = Offset(0, animationValue.value); return SlideTransition( position: Tween<Offset>( begin: offset, end: Offset.zero, ).animate(animation), child: child, ); }, duration: const Duration(milliseconds: 50), child: Text( '${imagesInstances[widget.selectedIndex].itemCount}', key: Key('${imagesInstances[widget.selectedIndex].itemCount}'), style: const TextStyle(fontSize: 25), ), ), IconButton( color: const Color(0xffE66219), onPressed: () => addItem(widget.selectedIndex), icon: const Icon(Icons.add), ), ], ), ), ), ) ], ), ), ], ), ), ), const SizedBox(height: 10), // 备注区域 Container( color: Colors.white, child: Padding( padding: const EdgeInsets.only(left: 30, right: 15, top: 10, bottom: 10), child: Row( children: [ Stack( children: [ Transform.translate( offset: const Offset(12, 0), child: Transform( transform: Matrix4.diagonal3Values(-1, 1, 1), child: const DecoratedIcon( icon: Icon( Icons.chat_bubble, color: Colors.white, ), decoration: IconDecoration( border: IconBorder(width: 3, color: Colors.black), ), ), ), ), ], ), const Text( 'Any special requests?', style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: Colors.black, ), ), const Spacer(), const Text( 'Add note', style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: Colors.orange, ), ), ], ), ), ), ], ), ), // 悬浮关闭按钮(带SafeArea) Positioned( top: 0, left: 0, child: SafeArea( child: Padding( padding: const EdgeInsets.all(8.0), child: CircleAvatar( backgroundColor: Colors.white, child: IconButton( onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close, color: Colors.black), ), ), ), ), ), // 悬浮底部按钮 Positioned( bottom: 0, left: 0, right: 0, child: SafeArea( child: Container( color: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 15), child: ElevatedButton( onPressed: () {}, style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5), ), padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 15), backgroundColor: Colors.orange[900], ), child: Row( children: [ const Text( 'Add to basket', style: TextStyle(fontSize: 20), ), const Spacer(), Text( 'EPG ${calculateItemPrice(imagesInstances[widget.selectedIndex].itemCount, imagesInstances[widget.selectedIndex].itemPrice!)}', style: const TextStyle(fontSize: 20, color: Colors.white), ), ],
相关产品推荐
相关产品推荐

