Flutter CupertinoPicker 实战:打造带单位的身高体重选择器

在健康管理类应用中,身高体重选择器是用户信息录入的核心组件之一。传统的数字选择器往往无法直观展示单位(如"cm"或"kg"),而iOS风格的CupertinoPicker原生也不支持单位显示。本文将带你从零实现一个高度定制化的选择器组件,解决以下实际问题:

  1. 如何在不修改原生组件的前提下实现数值+单位的联动显示
  2. 处理高频滚动事件导致的性能问题
  3. 实现精确的视觉对齐和触摸区域控制
  4. 构建可复用的业务组件库

1. 需求分析与技术选型

1.1 业务场景拆解

在健康档案录入界面,用户需要选择:

  • 身高范围:100-250cm,步长1cm
  • 体重范围:30-200kg,步长0.5kg

设计稿要求:

  • 数值居左,单位居右
  • 滑动时数值和单位同步高亮
  • 支持默认值设置
  • 滑动结束才触发回调(防抖)

1.2 CupertinoPicker 的局限性

原生组件存在三个主要问题:

  1. 无法直接添加静态文本(如单位)
  2. onSelectedItemChanged 会实时触发(无防抖)
  3. 选中项样式定制困难
// 原生组件的基本用法
CupertinoPicker(
  itemExtent: 40,
  children: List.generate(10, (i) => Text('$i')),
  onSelectedItemChanged: (index) {
    print('实时触发: $index'); // 需要防抖处理
  },
)

1.3 技术方案对比

方案 优点 缺点
重写滚动控件 完全可控 开发成本高,易出兼容问题
组合现有组件 快速实现,维护成本低 需要处理布局细节
使用第三方库 开箱即用 定制灵活性差

我们选择 组合方案 :通过 Stack 叠加CupertinoPicker和单位文本,用 Positioned 精确定位。

2. 核心实现步骤

2.1 组件结构设计

class UnitPicker extends StatelessWidget {
  final List<int> values;
  final String unit;
  final ValueChanged<int> onChanged;

  const UnitPicker({...});
  
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        CupertinoPicker(...), // 数值选择器
        Positioned(...),      // 单位文本
      ],
    );
  }
}

2.2 单位定位技巧

关键点在于计算单位的垂直位置:

  • 选择器高度:200
  • 每项高度:40
  • 单位Y坐标 = (总高度/2) - (项高度/2)
Positioned(
  left: 100, // 根据实际文本宽度调整
  top: (200/2) - (40/2), // 垂直居中
  child: Text('cm', style: TextStyle(...)),
)

2.3 防抖处理实现

原生选择器在滚动时会频繁触发回调,需要通过Timer实现防抖:

Timer? _debounceTimer;

void _handleChange(int index) {
  _debounceTimer?.cancel();
  _debounceTimer = Timer(const Duration(milliseconds: 300), () {
    widget.onChanged(values[index]);
  });
}

3. 完整组件代码

以下是可直接复用的 HeightWeightPicker 组件:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class HeightWeightPicker extends StatefulWidget {
  final int minValue;
  final int maxValue;
  final double step;
  final String unit;
  final int initialValue;
  final ValueChanged<int> onChanged;

  const HeightWeightPicker({
    Key? key,
    required this.minValue,
    required this.maxValue,
    this.step = 1,
    required this.unit,
    required this.initialValue,
    required this.onChanged,
  }) : super(key: key);

  @override
  _HeightWeightPickerState createState() => _HeightWeightPickerState();
}

class _HeightWeightPickerState extends State<HeightWeightPicker> {
  late FixedExtentScrollController _controller;
  late List<int> _values;
  Timer? _debounceTimer;

  @override
  void initState() {
    super.initState();
    _values = _generateValues();
    _controller = FixedExtentScrollController(
      initialItem: _values.indexOf(widget.initialValue),
    );
  }

  List<int> _generateValues() {
    final values = <int>[];
    for (var i = widget.minValue; i <= widget.maxValue; i += widget.step) {
      values.add(i);
    }
    return values;
  }

  void _handleChange(int index) {
    _debounceTimer?.cancel();
    _debounceTimer = Timer(const Duration(milliseconds: 300), () {
      widget.onChanged(_values[index]);
    });
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 200,
      child: Stack(
        alignment: Alignment.center,
        children: [
          CupertinoPicker(
            scrollController: _controller,
            itemExtent: 40,
            onSelectedItemChanged: _handleChange,
            children: _values.map((value) {
              return Center(
                child: Text(
                  value.toString(),
                  style: const TextStyle(fontSize: 24),
                ),
              );
            }).toList(),
          ),
          Positioned(
            left: 80,
            child: Text(
              widget.unit,
              style: const TextStyle(
                fontSize: 18,
                color: CupertinoColors.label,
              ),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    _debounceTimer?.cancel();
    super.dispose();
  }
}

4. 业务场景应用

4.1 身高选择器配置

HeightWeightPicker(
  minValue: 100,
  maxValue: 250,
  step: 1,
  unit: 'cm',
  initialValue: 170,
  onChanged: (value) {
    print('选择的身高: $value cm');
  },
)

4.2 体重选择器配置

HeightWeightPicker(
  minValue: 30,
  maxValue: 200,
  step: 0.5,
  unit: 'kg',
  initialValue: 65,
  onChanged: (value) {
    print('选择的体重: $value kg'); 
  },
)

4.3 表单集成示例

Column(
  children: [
    const Text('身高(cm)'),
    HeightWeightPicker(...), // 身高选择器
    const SizedBox(height: 20),
    const Text('体重(kg)'),
    HeightWeightPicker(...), // 体重选择器
    ElevatedButton(
      onPressed: () => _submitForm(),
      child: const Text('保存'),
    ),
  ],
)

5. 高级优化技巧

5.1 性能优化方案

对于大范围数值(如100-250):

  • 使用 ListView.builder 替代全量生成
  • 实现视窗缓存提高滚动流畅度
ListView.builder(
  controller: _controller,
  itemExtent: 40,
  itemCount: _values.length,
  itemBuilder: (context, index) {
    return Text(_values[index].toString());
  },
)

5.2 视觉增强技巧

  1. 添加选中项高亮效果:
BoxDecoration(
  border: Border(
    top: BorderSide(color: Colors.grey[300]!),
    bottom: BorderSide(color: Colors.grey[300]!),
  ),
)
  1. 使用 CupertinoTheme 统一风格:
CupertinoTheme(
  data: CupertinoThemeData(
    textTheme: CupertinoTextThemeData(
      pickerTextStyle: TextStyle(...),
    ),
  ),
  child: CupertinoPicker(...),
)

5.3 多语言适配方案

通过扩展支持动态单位:

class IntlUnitPicker extends HeightWeightPicker {
  final String Function(BuildContext) unitBuilder;
  
  @override
  Widget build(BuildContext context) {
    final unit = unitBuilder(context);
    return super.build(context);
  }
}

使用时:

IntlUnitPicker(
  unitBuilder: (context) => Localizations.of<AppLocalizations>(context)!.heightUnit,
  ...
)

6. 避坑指南

6.1 常见问题排查

  1. 单位文本不显示

    • 检查 Stack 的尺寸约束
    • 确认 Positioned 的坐标计算
  2. 回调触发过于频繁

    • 调整防抖时长(300-500ms为宜)
    • 确保Timer在dispose时被取消
  3. 初始值不生效

    • 确认initialValue在min/max范围内
    • 检查scrollController初始化逻辑

6.2 手势冲突解决

当单位区域需要响应点击时:

GestureDetector(
  behavior: HitTestBehavior.translucent,
  onTap: () => _controller.jumpToItem(100),
  child: Text(widget.unit),
)

6.3 跨平台适配建议

Android平台可添加Material风格后备:

Theme.of(context).platform == TargetPlatform.android
  ? MaterialPicker(...)
  : CupertinoPicker(...)

更多推荐