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

如何实现ModalBottomSheet中带记忆选中状态的自定义RadioButton及动画

实现带动画的自定义RadioButton及ModalBottomSheet状态恢复

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.13 13:51:28