Flutter for OpenHarmony 健康管理App应用实战 - 体重输入实现
用户选完目标之后,下一步就是输入个人信息了。第一个要填的是体重——这玩意儿是计算BMR(基础代谢率)的关键数据,填错了后面所有计算都跟着错。但是,让用户手动输入数字体验太差了。你想啊,用户得点输入框、弹出键盘、输入数字、可能还要删掉重输……麻烦。所以我们做了一个可滑动的标尺,用户左右滑动选择数值,直观又有趣。更重要的是,这个标尺支持三种单位切换:公斤(kg)、磅(lb)、英石(st)。切换单位的时

写在前面
用户选完目标之后,下一步就是输入个人信息了。第一个要填的是体重——这玩意儿是计算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?
因为切换单位后,标尺的范围变了,滚动位置也要重新计算。最简单的办法就是销毁旧的控制器,根据新单位创建新的。
步骤是这样的:
- 先移除滚动监听,不然销毁时会触发回调
- 销毁旧控制器
- 更新单位索引
- 重新初始化控制器(会根据新单位计算新的滚动位置)
关键点:因为内部始终用公斤存储,切换单位只是改变显示方式,数据不会丢失。比如用户在公斤模式下选了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
更多推荐
所有评论(0)