请添加图片描述

写在前面

用户选完目标之后,下一步就是输入个人信息了。第一个要填的是体重——这玩意儿是计算BMR(基础代谢率)的关键数据,填错了后面所有计算都跟着错。

但是,让用户手动输入数字体验太差了。你想啊,用户得点输入框、弹出键盘、输入数字、可能还要删掉重输……麻烦。

所以我们做了一个可滑动的标尺,用户左右滑动选择数值,直观又有趣。更重要的是,这个标尺支持三种单位切换:公斤(kg)、磅(lb)、英石(st)。切换单位的时候,标尺会自动换算并更新位置,不用用户自己算。

这篇文章就来把这个标尺从零开始撸出来。


先理清楚需求

动手之前,先想清楚要做什么:

核心功能:

  • 显示当前体重数值
  • 用户可以左右滑动标尺来调整数值
  • 支持 kg、lb、st 三种单位切换
  • 切换单位时,数值自动换算,标尺位置同步更新

技术难点:

  • 单位换算逻辑
  • 切换单位时标尺位置的重新计算
  • 滚动和数值的双向绑定

想清楚了,开干。


组件结构设计

体重输入是个人信息设置流程的一部分,我们把它做成一个独立的组件:

class _WeightStep extends StatefulWidget {
  final double value;
  final ValueChanged<double> onChanged;

  const _WeightStep({required this.value, required this.onChanged});

  
  State<_WeightStep> createState() => _WeightStepState();
}

两个参数:value 是当前体重值,onChanged 是数值变化时的回调。

这里有个重要的设计决策value 始终以公斤为单位存储。不管用户选的是磅还是英石,内部都用公斤。为啥?因为后端处理方便,不用关心用户用的什么单位,统一用公斤算就行。


状态定义

State 类里要存几个东西:

class _WeightStepState extends State<_WeightStep> {
  late ScrollController _scrollController;
  int _unitIndex = 2; // 0: lb, 1: st/lb, 2: kg
  late double _valueKg;

_scrollController 控制标尺的滚动,_unitIndex 记录当前选中的单位(默认是公斤),_valueKg 是内部存储的公斤值。

late 关键字的意思是"我保证用之前会初始化"。这样就不用把变量声明成可空类型,后面用的时候也不用到处加 !


单位换算这块得好好说说

三种单位的换算关系:

static const double _kgToLb = 2.20462;
static const double _lbToKg = 0.453592;
static const double _stoneToKg = 6.35029;

1公斤约等于2.2磅,1英石约等于6.35公斤。用 static const 定义常量,编译时就确定值,比运行时计算快。

为啥要定义两个方向的换算?

_kgToLb 是公斤转磅,用于显示;_lbToKg 是磅转公斤,用于存储。虽然数学上 _lbToKg = 1 / _kgToLb,但直接写出来更清晰,也避免了浮点数除法的精度问题。


各单位的范围

不同单位的最小最大值不一样:

double get _minValue {
  switch (_unitIndex) {
    case 0: return 66;   // lb (对应30kg)
    case 1: return 4.7;  // st (对应30kg)
    default: return 30;  // kg
  }
}

double get _maxValue {
  switch (_unitIndex) {
    case 0: return 440;  // lb (对应200kg)
    case 1: return 31.5; // st (对应200kg)
    default: return 200; // kg
  }
}

用 getter 方法而不是普通变量,是因为这个值会随着 _unitIndex 变化。每次访问 _minValue 都会重新计算,保证拿到的是当前单位对应的值。

换算成公斤都是30-200kg的范围,这个范围覆盖了绝大多数成年人的体重。太轻或太重的情况比较少见,先不考虑。


显示值的计算

用户看到的数值要根据当前单位来转换:

double get _displayValue {
  switch (_unitIndex) {
    case 0: return _valueKg * _kgToLb;
    case 1: return _valueKg / _stoneToKg;
    default: return _valueKg;
  }
}

String get _unitLabel {
  switch (_unitIndex) {
    case 0: return 'lb';
    case 1: return 'st';
    default: return 'kg';
  }
}

_displayValue 把内部的公斤值转成当前单位的显示值。比如内部存的是 60kg,用户选了磅,显示的就是 60 × 2.2 ≈ 132lb。


初始化滚动控制器

这块是核心逻辑之一:


void initState() {
  super.initState();
  _valueKg = widget.value;
  _initScrollController();
}

void _initScrollController() {
  final offset = (_displayValue - _minValue) * 10;
  _scrollController = ScrollController(
    initialScrollOffset: offset.clamp(0, double.infinity)
  );
  _scrollController.addListener(_onScroll);
}

滚动位置怎么算的?

标尺上每0.1个单位对应10像素的滚动距离。所以 (显示值 - 最小值) × 10 就是应该滚动到的位置。

举个例子:如果当前是60kg,最小值是30kg,那滚动位置就是 (60 - 30) × 10 = 300 像素。

clamp(0, double.infinity) 确保偏移量不会是负数。万一计算出来是负的(比如数据有问题),就用0。


滚动监听

用户滑动标尺时,要反向计算出对应的数值:

void _onScroll() {
  final displayValue = _minValue + _scrollController.offset / 10;
  
  if (displayValue >= _minValue && displayValue <= _maxValue) {
    double newKgValue;
    switch (_unitIndex) {
      case 0:
        newKgValue = displayValue * _lbToKg;
        break;
      case 1:
        newKgValue = displayValue * _stoneToKg;
        break;
      default:
        newKgValue = displayValue;
    }
    
    setState(() => _valueKg = double.parse(newKgValue.toStringAsFixed(2)));
    widget.onChanged(_valueKg);
  }
}

这段逻辑是滚动位置 → 显示值 → 公斤值的转换链。

先从滚动位置算出显示值:最小值 + 滚动偏移 / 10

然后根据当前单位,把显示值转成公斤值存储。

toStringAsFixed(2)parse 回来,是为了保留两位小数。不这么做的话,浮点数运算会产生很多小数位,比如 59.999999999997 这种。


单位切换——最难的部分

切换单位时,标尺要同步更新。这块逻辑我调了好久才调对:

void _onUnitChanged(int newIndex) {
  if (newIndex == _unitIndex) return;
  
  _scrollController.removeListener(_onScroll);
  _scrollController.dispose();
  
  setState(() {
    _unitIndex = newIndex;
  });
  
  _initScrollController();
}

为啥要销毁再重建 ScrollController?

因为切换单位后,标尺的范围变了,滚动位置也要重新计算。最简单的办法就是销毁旧的控制器,根据新单位创建新的。

步骤是这样的:

  1. 先移除滚动监听,不然销毁时会触发回调
  2. 销毁旧控制器
  3. 更新单位索引
  4. 重新初始化控制器(会根据新单位计算新的滚动位置)

关键点:因为内部始终用公斤存储,切换单位只是改变显示方式,数据不会丢失。比如用户在公斤模式下选了60kg,切到磅模式,显示的是132lb,但内部存的还是60kg。


构建UI

整体布局


Widget build(BuildContext context) {
  return Padding(
    padding: const EdgeInsets.symmetric(horizontal: 24),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const SizedBox(height: 20),
        const Text(
          "What's your current\nweight?",
          style: TextStyle(
            fontSize: 28,
            fontWeight: FontWeight.bold,
            color: AppColors.dark,
          ),
        ),

标题用换行让文字更易读。一行太长的话,用户得左右扫视,累。

        const SizedBox(height: 12),
        Text(
          'Weight data is used to estimate your\nBMR(basal metabolic rate).',
          style: TextStyle(
            fontSize: 14,
            color: Colors.grey.shade600,
            height: 1.5,
          ),
        ),

下面加一句说明,告诉用户为什么需要这个数据。height: 1.5 是行高,让多行文字之间有点间距,不那么挤。


单位选择器

Widget _buildUnitSelector() {
  final units = ['lb', 'st/lb', 'kg'];
  return Container(
    height: 48,
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(24),
    ),

外层是个白色圆角容器,高度48像素,圆角24(高度的一半),形成胶囊形状。

    child: Row(
      children: List.generate(3, (index) {
        final isSelected = _unitIndex == index;
        return Expanded(
          child: GestureDetector(
            onTap: () => _onUnitChanged(index),
            child: Container(
              margin: const EdgeInsets.all(4),
              decoration: BoxDecoration(
                color: isSelected ? AppColors.primary : Colors.transparent,
                borderRadius: BorderRadius.circular(20),
              ),
              child: Center(
                child: Text(
                  units[index],
                  style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w600,
                    color: isSelected ? Colors.white : Colors.grey.shade600,
                  ),
                ),
              ),
            ),
          ),
        );
      }),
    ),
  );
}

三个单位平分宽度(Expanded),选中的用主题色背景白色文字,未选中的透明背景灰色文字。

这种设计叫分段控件(Segmented Control),iOS上很常见。用户一眼就能看出当前选的是哪个。


数值显示

Center(
  child: RichText(
    text: TextSpan(
      children: [
        TextSpan(
          text: _displayValue.toStringAsFixed(1),
          style: const TextStyle(
            fontSize: 56,
            fontWeight: FontWeight.bold,
            color: AppColors.primary,
          ),
        ),
        TextSpan(
          text: ' $_unitLabel',
          style: const TextStyle(
            fontSize: 24,
            fontWeight: FontWeight.w500,
            color: AppColors.primary,
          ),
        ),
      ],
    ),
  ),
),

RichText 让数值和单位用不同字号显示。数值大(56),单位小(24),主次分明。

toStringAsFixed(1) 保留一位小数,比如显示 60.5 而不是 60.500000


标尺组件

这是整个页面最复杂的部分:

Widget _buildRuler() {
  final range = _maxValue - _minValue;
  final itemCount = (range * 10).toInt();
  final majorStep = _unitIndex == 2 ? 10 : (_unitIndex == 0 ? 10 : 5);

itemCount 是刻度总数。比如公斤模式下范围是30-200,共170个单位,每0.1一个刻度,就是1700个刻度。

majorStep 是主刻度间隔。公斤和磅每10个单位一个主刻度(显示数字),英石每5个(因为英石数值小,5个一标比较合适)。

  return SizedBox(
    height: 100,
    child: Stack(
      alignment: Alignment.center,
      children: [
        ListView.builder(
          controller: _scrollController,
          scrollDirection: Axis.horizontal,
          itemCount: itemCount,

ListView.builder 来构建刻度,因为刻度数量很多(上千个),用 builder 模式按需创建,性能好。

scrollDirection: Axis.horizontal 让列表横向滚动。


刻度绘制

          itemBuilder: (context, index) {
            final value = _minValue + index * 0.1;
            final isMajor = (value * 10).round() % (majorStep * 10) == 0;
            
            return SizedBox(
              width: 10,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const SizedBox(height: 24),
                  Container(
                    width: 1.5,
                    height: isMajor ? 35 : 18,
                    color: isMajor ? AppColors.dark : Colors.grey.shade400,
                  ),

每个刻度宽10像素。主刻度高35像素、深色,次刻度高18像素、浅色。

判断是否是主刻度的逻辑(value * 10).round() % (majorStep * 10) == 0

为啥要乘10再取整?因为浮点数直接取模会有精度问题。比如 30.0 % 10 可能不等于0,而是 9.999999...。乘10变成整数再算就没这问题了。

                  const SizedBox(height: 6),
                  SizedBox(
                    height: 16,
                    child: isMajor
                        ? Text(
                            _unitIndex == 1
                                ? value.toStringAsFixed(1)
                                : '${value.toInt()}',
                            style: TextStyle(
                              fontSize: 11,
                              color: Colors.grey.shade600,
                            ),
                          )
                        : null,
                  ),
                ],
              ),
            );
          },

主刻度下面显示数值。英石单位保留一位小数(因为英石数值小,整数不够精确),其他显示整数。


中间指示器

        Positioned(
          top: 0,
          child: Column(
            children: [
              const Icon(
                Icons.arrow_drop_down,
                color: AppColors.primary,
                size: 24,
              ),
              Container(
                width: 2,
                height: 40,
                color: AppColors.primary,
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

Positioned 把指示器固定在中间。用户滑动标尺时,指示器不动,指向的位置就是当前值。

箭头朝下,暗示"这里是你选的值"。


资源释放


void dispose() {
  _scrollController.dispose();
  super.dispose();
}

ScrollController 用完要释放,不然会内存泄漏。这是 Flutter 开发的基本功,养成习惯就好。


踩过的坑

1. 切换单位后标尺位置不对

一开始我只是更新了 _unitIndex,没有重建 ScrollController。结果切换单位后,标尺还停在原来的位置,数值对不上。

后来想明白了:切换单位后,同样的滚动位置对应的数值不一样了,必须重新计算。

2. 浮点数精度问题

滑动标尺时,数值会出现 59.99999999997 这种情况。用 toStringAsFixed(2)parse 回来解决了。

3. 滚动到边界时数值越界

用户可以把标尺滑到很远的地方,超出范围。加了 if (displayValue >= _minValue && displayValue <= _maxValue) 的判断,超出范围就不更新数值。


小结

体重输入组件的核心是单位换算逻辑

  • 内部始终用公斤存储,保证数据一致性
  • 显示时根据当前单位转换
  • 切换单位时重建滚动控制器,自动定位到正确位置

这种设计让用户可以用自己习惯的单位输入,而后端只需要处理一种单位,两边都方便。

下一篇我们来实现身高、性别、生日的输入,每种数据的输入方式都不一样,挺有意思的。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐