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

Flutter如何限制FixedExtentScrollController滚动不超过指定索引

Flutter 双Cupertino时间选择器防倒序限制实现

核心思路

要实现结束时间不早于开始时间的限制,核心是统一时间度量做大小校验,动态监听滚动位置做越界纠正:

  • 将小时、分钟统一换算为当日总分钟数做比较,避免时分分开判断的逻辑漏洞
  • 监听所有选择器的滚动回调,一旦检测到结束时间小于开始时间,立刻通过滚动控制器纠正结束时间位置
  • 对结束时间选择器做范围限制:当结束小时等于开始小时时,分钟选项最小只能选和开始分钟相等的档位;当结束小时大于开始小时时,分钟无额外限制;结束小时不能小于开始小时
  • 修正原代码笔误:原分钟选择器调用durationPicker时错误传了inHour=true,会导致分钟档位生成错误,需改为传false

完整实现代码

Widget durationPicker(bool inHour, FixedExtentScrollController controller, void Function(int) onSelected) {
  return CupertinoPicker(
    scrollController: controller,
    magnification: 1.1,
    children: List.generate(
      inHour ? 24 : 12,
      (index) => Container(
        alignment: Alignment.center,
        child: Text(
          inHour ? '$index' : '${index * 5}',
        ),
      ),
    ),
    itemExtent: 40,
    onSelectedItemChanged: onSelected,
  );
}

class CupertinoDoubleTimePicker extends StatefulWidget {
  const CupertinoDoubleTimePicker({Key? key}) : super(key: key);

  @override
  State<CupertinoDoubleTimePicker> createState() => _CupertinoDoubleTimePickerState();
}

class _CupertinoDoubleTimePickerState extends State<CupertinoDoubleTimePicker> {
  late final FixedExtentScrollController _hourScrollControllerStart;
  late final FixedExtentScrollController _minuteControllerStart;
  late final FixedExtentScrollController _hourScrollControllerEnd;
  late final FixedExtentScrollController _minuteControllerEnd;

  // 存储当前选中的时分索引
  int _startHour = 0;
  int _startMinuteIdx = 0;
  int _endHour = 0;
  int _endMinuteIdx = 0;

  @override
  void initState() {
    super.initState();
    _hourScrollControllerStart = FixedExtentScrollController();
    _minuteControllerStart = FixedExtentScrollController();
    _hourScrollControllerEnd = FixedExtentScrollController();
    _minuteControllerEnd = FixedExtentScrollController();
  }

  @override
  void dispose() {
    _hourScrollControllerStart.dispose();
    _minuteControllerStart.dispose();
    _hourScrollControllerEnd.dispose();
    _minuteControllerEnd.dispose();
    super.dispose();
  }

  // 校验时间合法性,不合法时自动纠正结束时间
  void _validateTimeRange() {
    final startTotal = _startHour * 60 + _startMinuteIdx * 5;
    final endTotal = _endHour * 60 + _endMinuteIdx * 5;

    if (endTotal < startTotal) {
      // 结束时间早于开始时间,自动对齐到开始时间
      _endHour = _startHour;
      _endMinuteIdx = _startMinuteIdx;
      WidgetsBinding.instance.addPostFrameCallback((_) {
        _hourScrollControllerEnd.jumpToItem(_endHour);
        _minuteControllerEnd.jumpToItem(_endMinuteIdx);
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        // 开始时间选择器
        SizedBox(
          width: MediaQuery.of(context).size.width * 0.3,
          height: MediaQuery.of(context).size.height * 0.2,
          child: Row(
            children: [
              Expanded(
                child: durationPicker(true, _hourScrollControllerStart, (value) {
                  _startHour = value;
                  _validateTimeRange();
                }),
              ),
              const Text(':'),
              Expanded(
                // 原代码此处inHour传了true,已修正为false
                child: durationPicker(false, _minuteControllerStart, (value) {
                  _startMinuteIdx = value;
                  _validateTimeRange();
                }),
              ),
            ],
          ),
        ),
        // 结束时间选择器
        SizedBox(
          width: MediaQuery.of(context).size.width * 0.3,
          height: MediaQuery.of(context).size.height * 0.2,
          child: Row(
            children: [
              Expanded(
                child: durationPicker(true, _hourScrollControllerEnd, (value) {
                  _endHour = value;
                  // 如果结束小时等于开始小时,分钟索引不能小于开始分钟索引
                  if (_endHour == _startHour && _endMinuteIdx < _startMinuteIdx) {
                    _endMinuteIdx = _startMinuteIdx;
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      _minuteControllerEnd.jumpToItem(_endMinuteIdx);
                    });
                  }
                  // 如果结束小时小于开始小时,直接对齐到开始小时
                  if (_endHour < _startHour) {
                    _endHour = _startHour;
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      _hourScrollControllerEnd.jumpToItem(_endHour);
                    });
                  }
                  _validateTimeRange();
                }),
              ),
              const Text(':'),
              Expanded(
                // 原代码此处inHour传了true,已修正为false
                child: durationPicker(false, _minuteControllerEnd, (value) {
                  // 小时相等时,分钟不能小于开始分钟
                  if (_endHour == _startHour && value < _startMinuteIdx) {
                    _endMinuteIdx = _startMinuteIdx;
                    WidgetsBinding.instance.addPostFrameCallback((_) {
                      _minuteControllerEnd.jumpToItem(_endMinuteIdx);
                    });
                    return;
                  }
                  _endMinuteIdx = value;
                  _validateTimeRange();
                }),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

实现效果参考

双Cupertino时间选择器效果

可选优化点

  • 如果需要滚动过程中就无法越过限制位置,而不是滚动结束后再纠正,可以给控制器添加addListener监听滚动偏移,实时计算当前位置是否越界,一旦越界立刻跳回最大允许位置
  • 可以给不可选的时间档位置灰提示,只需要在生成durationPicker的children时判断当前索引是否小于最小允许值,将对应Text的颜色改为灰色即可

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.03 09:39:25