如何实现ModalBottomSheet中带记忆选中状态的自定义RadioButton及动画
1. 自定义带选中动画的RadioButton
先实现一个可复用的CustomRadioButton组件,内置选中/取消时的缩放、颜色过渡动画,同时自动监听状态变化触发动画:
class CustomRadioButton extends StatefulWidget { final String label; final String value; final String groupValue; final ValueChanged<String?> onChanged; const CustomRadioButton({ super.key, required this.label, required this.value, required this.groupValue, required this.onChanged, }); @override State<CustomRadioButton> createState() => _CustomRadioButtonState(); } class _CustomRadioButtonState extends State<CustomRadioButton> with SingleTickerProviderStateMixin { late AnimationController _animationController; late Animation<double> _scaleAnimation; late Animation<Color?> _colorAnimation; @override void initState() { super.initState(); _animationController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _scaleAnimation = Tween<double>(begin: 0.8, end: 1.2).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), ); _colorAnimation = ColorTween( begin: Colors.grey.shade300, end: Colors.blue.shade500, ).animate(_animationController); // 初始化时如果已选中,直接完成动画 if (widget.value == widget.groupValue) { _animationController.forward(); } } @override void didUpdateWidget(covariant CustomRadioButton oldWidget) { super.didUpdateWidget(oldWidget); // 选中状态变化时触发对应动画 if (widget.value == widget.groupValue && oldWidget.value != oldWidget.groupValue) { _animationController.forward(); } else if (widget.value != widget.groupValue && oldWidget.value == oldWidget.groupValue) { _animationController.reverse(); } } @override void dispose() { _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onTap: () => widget.onChanged(widget.value), child: AnimatedBuilder( animation: _animationController, builder: (context, child) { return Transform.scale( scale: _scaleAnimation.value, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: widget.value == widget.groupValue ? _colorAnimation.value : Colors.grey.shade100, borderRadius: BorderRadius.circular(8), border: Border.all( color: widget.value == widget.groupValue ? Colors.blue.shade500 : Colors.grey.shade300, ), ), child: Text( widget.label, style: TextStyle( color: widget.value == widget.groupValue ? Colors.white : Colors.black87, fontWeight: widget.value == widget.groupValue ? FontWeight.bold : FontWeight.normal, ), ), ), ); }, ), ); } }
2. 实现ModalBottomSheet的状态管理与自动定位
在父组件中维护选中值,打开弹窗时传入该值,同时通过ScrollController实现打开时自动滚动到已选中项:
class RadioModalDemo extends StatefulWidget { const RadioModalDemo({super.key}); @override State<RadioModalDemo> createState() => _RadioModalDemoState(); } class _RadioModalDemoState extends State<RadioModalDemo> { String? _selectedValue = 'option_2'; // 初始选中值 final ScrollController _scrollController = ScrollController(); final List<Map<String, String>> _options = [ {'label': '选项1', 'value': 'option_1'}, {'label': '选项2', 'value': 'option_2'}, {'label': '选项3', 'value': 'option_3'}, {'label': '选项4', 'value': 'option_4'}, {'label': '选项5', 'value': 'option_5'}, {'label': '选项6', 'value': 'option_6'}, {'label': '选项7', 'value': 'option_7'}, {'label': '选项8', 'value': 'option_8'}, ]; void _showModalBottomSheet() async { final result = await showModalBottomSheet<String>( context: context, isScrollControlled: true, builder: (context) { // 弹窗渲染完成后,滚动到已选中项位置 WidgetsBinding.instance.addPostFrameCallback((_) { if (_selectedValue != null) { final targetIndex = _options.indexWhere((opt) => opt['value'] == _selectedValue); if (targetIndex != -1) { // 计算滚动偏移量(每个选项高度+底部padding) final scrollOffset = targetIndex * 68.0; _scrollController.animateTo( scrollOffset, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); } } }); return Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: Container( height: MediaQuery.of(context).size.height * 0.6, padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '选择选项', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), Expanded( child: ListView.builder( controller: _scrollController, itemCount: _options.length, itemBuilder: (context, index) { final option = _options[index]; return Padding( padding: const EdgeInsets.only(bottom: 8), child: CustomRadioButton( label: option['label']!, value: option['value']!, groupValue: _selectedValue, onChanged: (value) => Navigator.pop(context, value), ), ); }, ), ), ], ), ), ); }, ); // 关闭弹窗后更新选中状态 if (result != null) { setState(() => _selectedValue = result); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Radio Modal Demo')), body: Center( child: ElevatedButton( onPressed: _showModalBottomSheet, child: Text('已选中: ${_options.firstWhere((opt) => opt['value'] == _selectedValue)['label']}'), ), ), ); } }
核心逻辑说明
- 自定义RadioButton动画:通过
didUpdateWidget监听选中状态变化,自动触发动画的正向/反向播放;初始化时如果是已选中项,直接完成动画。 - 自动定位:在弹窗渲染完成后(
addPostFrameCallback),计算已选中项的索引和对应滚动偏移量,调用animateTo实现平滑滚动。 - 状态持久化:父组件维护选中值,打开弹窗时传入该值,确保弹窗每次打开都能展示正确的选中状态和动画。
内容的提问来源于stack exchange,提问作者Farid Ahmadov
相关产品推荐
相关产品推荐

