在这里插入图片描述

衣橱管家App里,用户添加衣物之后,难免会发现有些信息填错了,或者想要修改一下分类、颜色之类的属性。这时候就需要一个编辑衣物的功能。

今天这篇文章,我来详细讲讲编辑衣物页面的实现。这个页面和添加衣物页面有很多相似之处,但也有一些关键的不同点需要注意。

编辑页面的特殊之处

编辑页面和添加页面最大的区别在于:编辑页面打开时,表单里要预填充已有的数据。用户看到的应该是当前衣物的信息,而不是空白的表单。

另外,编辑页面需要接收一个衣物对象作为参数,这样才知道要编辑哪件衣物。

页面基础结构

先来看EditClothingScreen的定义:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../models/clothing_item.dart';
import '../../providers/wardrobe_provider.dart';

class EditClothingScreen extends StatefulWidget {
  final ClothingItem item;

  const EditClothingScreen({super.key, required this.item});

  
  State<EditClothingScreen> createState() => _EditClothingScreenState();
}

这里用StatefulWidget是因为表单需要维护用户输入的状态,比如文本框的内容、下拉框的选中值等。
构造函数里有个required参数item,这就是要编辑的衣物对象,从上一个页面传过来的。
用final修饰item,因为这个参数在页面生命周期内不会改变,只是用来初始化表单数据。

状态变量的定义

State类里需要定义一堆变量来保存表单状态:

class _EditClothingScreenState extends State<EditClothingScreen> {
  final _formKey = GlobalKey<FormState>();
  late TextEditingController _nameController;
  late TextEditingController _brandController;
  late TextEditingController _priceController;

  late String _selectedCategory;
  late String _selectedColor;
  late String _selectedSeason;
  late DateTime _purchaseDate;
  late List<String> _selectedTags;

  final List<String> _categories = ['上衣', '裤子', '裙子', '外套', '鞋子', '配饰'];
  final List<String> _colors = ['白色', '黑色', '灰色', '红色', '蓝色', '绿色', '黄色', '粉色', '紫色', '棕色', '米色', '橙色'];
  final List<String> _seasons = ['春季', '夏季', '秋季', '冬季', '四季'];
  final List<String> _availableTags = ['休闲', '正装', '运动', '约会', '百搭', '经典', '时尚', '保暖', '清凉'];
}

_formKey用来标识Form组件,后面验证表单时会用到。
TextEditingController用来控制文本输入框,可以获取和设置输入框的内容。
late关键字表示这些变量会在initState里初始化,不是在声明时就赋值。
下面那几个List是选项数据,分类、颜色、季节、标签的可选值都在这里定义。

初始化和销毁

initState是初始化表单数据的地方:


void initState() {
  super.initState();
  _nameController = TextEditingController(text: widget.item.name);
  _brandController = TextEditingController(text: widget.item.brand);
  _priceController = TextEditingController(text: widget.item.price.toString());
  _selectedCategory = widget.item.category;
  _selectedColor = widget.item.color;
  _selectedSeason = widget.item.season;
  _purchaseDate = widget.item.purchaseDate;
  _selectedTags = List.from(widget.item.tags);
}

这里是编辑页面的关键,所有表单控件的初始值都从widget.item里取。
TextEditingController的构造函数可以传入初始文本,这样输入框打开就有内容了。
_selectedTags用List.from创建一个新列表,而不是直接赋值,这样修改_selectedTags不会影响原来的item.tags。

dispose方法要记得释放Controller:


void dispose() {
  _nameController.dispose();
  _brandController.dispose();
  _priceController.dispose();
  super.dispose();
}

TextEditingController用完要dispose,否则会造成内存泄漏。
这是Flutter开发的基本规范,养成好习惯很重要。

页面布局

build方法构建整个页面:


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('编辑衣物'),
      actions: [
        TextButton(
          onPressed: _saveChanges,
          child: const Text('保存', style: TextStyle(color: Colors.white)),
        ),
      ],
    ),
    body: Form(
      key: _formKey,
      child: SingleChildScrollView(
        padding: EdgeInsets.all(16.w),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 表单内容
          ],
        ),
      ),
    ),
  );
}

AppBar右上角放保存按钮,用TextButton而不是IconButton,因为"保存"两个字比图标更直观。
Form组件包裹所有表单控件,这样可以统一做表单验证。
SingleChildScrollView确保内容多的时候可以滚动,不会溢出屏幕。

名称输入框

名称是必填项,需要做验证:

TextFormField(
  controller: _nameController,
  decoration: InputDecoration(
    labelText: '名称',
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
  ),
  validator: (v) => v?.isEmpty ?? true ? '请输入名称' : null,
),
SizedBox(height: 16.h),

TextFormField是Form专用的输入框,支持validator验证。
validator返回null表示验证通过,返回字符串表示验证失败,字符串内容就是错误提示。
OutlineInputBorder给输入框加上边框,看起来更清晰。

分类下拉框

分类用DropdownButtonFormField实现:

DropdownButtonFormField<String>(
  value: _selectedCategory,
  decoration: InputDecoration(
    labelText: '分类',
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
  ),
  items: _categories.map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
  onChanged: (v) => setState(() => _selectedCategory = v!),
),
SizedBox(height: 16.h),

DropdownButtonFormField和普通的DropdownButton区别在于,它可以放在Form里,样式也和TextFormField保持一致。
value是当前选中的值,items是所有选项,onChanged是选中值变化时的回调。
setState触发页面重建,这样下拉框才会显示新选中的值。

颜色选择器

颜色选择用圆形色块来展示,比下拉框更直观:

Text('颜色', style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade700)),
SizedBox(height: 8.h),
Wrap(
  spacing: 8.w,
  runSpacing: 8.h,
  children: _colors.map((color) {
    final isSelected = color == _selectedColor;
    return GestureDetector(
      onTap: () => setState(() => _selectedColor = color),
      child: Container(
        width: 40.w,
        height: 40.w,
        decoration: BoxDecoration(
          color: ClothingItem.getColorFromName(color),
          shape: BoxShape.circle,
          border: Border.all(
            color: isSelected ? const Color(0xFFE91E63) : Colors.grey.shade300,
            width: isSelected ? 3 : 1,
          ),
        ),
        child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
      ),
    );
  }).toList(),
),
SizedBox(height: 16.h),

Wrap组件可以自动换行,当一行放不下所有色块时,会自动换到下一行。
spacing是水平间距,runSpacing是行间距。
选中的颜色用粗边框和对勾图标来标识,用户一眼就能看出当前选的是哪个颜色。
ClothingItem.getColorFromName是一个工具方法,把颜色名称转换成Color对象。

季节下拉框

季节选择和分类选择类似:

DropdownButtonFormField<String>(
  value: _selectedSeason,
  decoration: InputDecoration(
    labelText: '季节',
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
  ),
  items: _seasons.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
  onChanged: (v) => setState(() => _selectedSeason = v!),
),
SizedBox(height: 16.h),

季节有五个选项:春夏秋冬加上四季。四季表示这件衣服一年四季都能穿。
下拉框的样式和分类下拉框保持一致,整个表单看起来很统一。

品牌和价格输入

品牌和价格是可选项,不需要验证:

TextFormField(
  controller: _brandController,
  decoration: InputDecoration(
    labelText: '品牌',
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
  ),
),
SizedBox(height: 16.h),
TextFormField(
  controller: _priceController,
  keyboardType: TextInputType.number,
  decoration: InputDecoration(
    labelText: '价格',
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.r)),
  ),
),
SizedBox(height: 16.h),

价格输入框设置keyboardType为number,这样弹出的键盘是数字键盘,方便用户输入。
品牌和价格没有validator,因为这两个字段不是必填的。

标签选择

标签用FilterChip实现多选:

Text('标签', style: TextStyle(fontSize: 14.sp, color: Colors.grey.shade700)),
SizedBox(height: 8.h),
Wrap(
  spacing: 8.w,
  runSpacing: 8.h,
  children: _availableTags.map((tag) {
    final isSelected = _selectedTags.contains(tag);
    return FilterChip(
      label: Text(tag),
      selected: isSelected,
      selectedColor: const Color(0xFFE91E63).withOpacity(0.2),
      onSelected: (selected) {
        setState(() {
          if (selected) {
            _selectedTags.add(tag);
          } else {
            _selectedTags.remove(tag);
          }
        });
      },
    );
  }).toList(),
),
SizedBox(height: 32.h),

FilterChip是Material Design里的筛选芯片,天生支持选中和未选中两种状态。
selected属性控制是否选中,selectedColor是选中时的背景色。
onSelected回调里根据selected参数决定是添加还是移除标签。
标签可以多选,所以用List来存储,而不是单个String。

保存修改

保存按钮点击后执行_saveChanges方法:

void _saveChanges() {
  if (_formKey.currentState?.validate() ?? false) {
    final updatedItem = widget.item.copyWith(
      name: _nameController.text,
      category: _selectedCategory,
      color: _selectedColor,
      season: _selectedSeason,
      brand: _brandController.text,
      price: double.tryParse(_priceController.text) ?? 0,
      purchaseDate: _purchaseDate,
      tags: _selectedTags,
    );

    Provider.of<WardrobeProvider>(context, listen: false).updateClothing(updatedItem);
    Navigator.pop(context);
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('修改已保存')),
    );
  }
}

先调用validate()验证表单,只有验证通过才继续执行。
copyWith是ClothingItem类的方法,用来创建一个新对象,只修改指定的属性,其他属性保持不变。
Provider.of获取WardrobeProvider实例,listen: false表示不监听变化,因为这里只是调用方法,不需要重建UI。
Navigator.pop返回上一页,SnackBar显示保存成功的提示。

copyWith模式

copyWith是Dart里很常用的一种模式,用来创建不可变对象的修改版本:

// ClothingItem类里的copyWith方法大概长这样
ClothingItem copyWith({
  String? name,
  String? category,
  String? color,
  // ... 其他属性
}) {
  return ClothingItem(
    id: id,  // id不变
    name: name ?? this.name,
    category: category ?? this.category,
    color: color ?? this.color,
    // ... 其他属性
  );
}

这种模式的好处是,原对象不会被修改,而是创建一个新对象。
传入的参数如果是null,就用原来的值;如果不是null,就用新值。
这样调用的时候只需要传想修改的属性,其他属性自动保持不变。

表单验证的细节

表单验证是编辑页面很重要的一环:

// 验证逻辑
validator: (v) => v?.isEmpty ?? true ? '请输入名称' : null

// 触发验证
if (_formKey.currentState?.validate() ?? false) {
  // 验证通过
}

validator是每个表单控件的验证函数,返回null表示通过,返回字符串表示失败。
_formKey.currentState?.validate()会触发所有表单控件的validator,只有全部通过才返回true。
验证失败时,对应的输入框下方会显示红色的错误提示文字。

价格转换的处理

价格从字符串转换成数字需要注意异常处理:

price: double.tryParse(_priceController.text) ?? 0,

double.tryParse比double.parse更安全,解析失败时返回null而不是抛异常。
用??运算符提供默认值0,这样即使用户输入了非数字内容,也不会崩溃。

状态管理的配合

编辑页面和Provider的配合:

// 更新数据
Provider.of<WardrobeProvider>(context, listen: false).updateClothing(updatedItem);

WardrobeProvider里应该有个updateClothing方法,接收修改后的衣物对象,更新到数据列表里。
listen: false很重要,因为这里只是调用方法,不需要监听数据变化。如果设为true,会导致不必要的重建。

用户体验优化

编辑页面有几个用户体验的细节:

第一,打开页面时表单已经填好了数据,用户不需要重新输入。

第二,颜色选择用色块展示,比文字更直观。

第三,保存成功后自动返回上一页,并显示SnackBar提示。

第四,表单验证失败时,错误提示显示在对应的输入框下方,用户知道哪里出了问题。

// 保存成功的反馈
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
  const SnackBar(content: Text('修改已保存')),
);

先pop再showSnackBar,SnackBar会显示在上一个页面上,用户能看到保存成功的提示。
这种交互方式很常见,用户体验也很好。

和添加页面的区别

编辑页面和添加页面的主要区别:

第一,编辑页面需要接收一个item参数,添加页面不需要。

第二,编辑页面在initState里用item的数据初始化表单,添加页面用默认值。

第三,编辑页面调用updateClothing更新数据,添加页面调用addClothing添加数据。

第四,编辑页面的AppBar标题是"编辑衣物",添加页面是"添加衣物"。

// 编辑页面
class EditClothingScreen extends StatefulWidget {
  final ClothingItem item;  // 需要参数
  // ...
}

// 添加页面
class AddClothingScreen extends StatefulWidget {
  const AddClothingScreen({super.key});  // 不需要参数
  // ...
}

虽然两个页面很相似,但分开写更清晰,也更容易维护。
如果硬要合并成一个页面,需要加很多判断逻辑,代码会变得复杂。

总结

编辑衣物页面的实现涉及到表单处理、状态管理、数据验证等多个方面。关键点在于:

用initState初始化表单数据,用copyWith创建修改后的对象,用Provider更新全局状态。

表单验证确保用户输入的数据是有效的,用户体验的细节让操作更顺畅。

在OpenHarmony平台上,这套实现方式完全适用,Flutter的跨平台能力让我们可以用同一套代码在不同平台上运行。

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

Logo

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

更多推荐