Flutter 进阶 UI搭建 iOS 风格通讯录应用(十一)

前言

恭喜你完成了前两个项目:Birdle 猜词游戏和维基百科阅读器!你已经掌握了 Widget 基础、布局、状态管理和 MVVM 架构。

从这一篇开始,我们进入全新的章节------Flutter UI 102 。我们将构建第三个应用:一个 iOS 风格的通讯录应用 Rolodex。在这个过程中,你会学到自适应布局、高级滚动、导航模式和 iOS 风格主题等进阶 UI 技巧。

本文基于官方教程的「Advanced UI Features」章节,今天的任务是搭建 Rolodex 项目、认识 Cupertino 组件库,并创建数据模型。


一、新应用要做什么?

Rolodex 是一个仿 iOS 通讯录的应用,最终效果包括:

  • 自适应布局:大屏幕显示侧边栏 + 详情面板,小屏幕用导航跳转
  • 高级滚动:使用 Sliver 实现可折叠的搜索栏和字母索引
  • 导航模式:基于栈的页面跳转(push/pop)
  • iOS 风格主题:使用 Cupertino 组件,支持亮色/暗色模式

1.1 Material vs Cupertino

前两个项目用的都是 MaterialApp(Material Design 风格),这是 Google 的设计语言。而这次我们用 CupertinoApp(Cupertino 风格),这是 Apple 的 iOS 设计语言。

两者的核心区别:

less 复制代码
// Material 风格(Google 设计语言)
// 前两个项目一直在用
MaterialApp(
  home: Scaffold(
    appBar: AppBar(title: Text('标题')),
    body: ...,
  ),
)

// Cupertino 风格(Apple iOS 设计语言)
// 本项目使用
CupertinoApp(
  home: CupertinoPageScaffold(
    navigationBar: CupertinoNavigationBar(middle: Text('标题')),
    child: ...,
  ),
)

虽然组件名字不同,但编程方式完全一样------都是 Widget 嵌套。Cupertino 组件在所有平台上都能运行(不只是 iOS),只是视觉风格模仿了 iOS。


二、创建 Rolodex 项目

2.1 创建项目并安装依赖

bash 复制代码
# 创建新项目
flutter create rolodex --empty

# 进入项目目录
cd rolodex

# 安装 Cupertino 图标包
# 提供了 iOS 风格的图标(如返回箭头、搜索图标等)
flutter pub add cupertino_icons

# 创建代码目录结构
# data/    → 数据模型
# screens/ → 页面组件
# theme/   → 主题配置
mkdir lib/data lib/screens lib/theme

2.2 替换 main.dart

scala 复制代码
// 导入 Cupertino 组件库(替代 Material)
// Cupertino 提供了 iOS 风格的按钮、导航栏、列表等组件
import 'package:flutter/cupertino.dart';

// 导入数据模型(下一步创建)
import 'package:rolodex/data/contact_group.dart';

// 全局状态:联系人分组的数据模型
// 使用 ValueNotifier 管理状态变化
final contactGroupsModel = ContactGroupsModel();

void main() {
  runApp(const RolodexApp());
}

// RolodexApp:应用根组件
// 使用 CupertinoApp 替代 MaterialApp
class RolodexApp extends StatelessWidget {
  const RolodexApp({super.key});

  @override
  Widget build(BuildContext context) {
    // CupertinoApp 提供 iOS 风格的应用框架
    // 包括 iOS 风格的路由动画、字体、颜色等
    return CupertinoApp(
      title: 'Rolodex',
      // CupertinoThemeData 配置 iOS 风格主题
      theme: const CupertinoThemeData(
        // barBackgroundColor 设置导航栏的背景色
        // CupertinoDynamicColor 自动适配亮色/暗色模式
        barBackgroundColor: CupertinoDynamicColor.withBrightness(
          color: Color(0xFFF9F9F9),      // 亮色模式:浅灰白
          darkColor: Color(0xFF1D1D1D),  // 暗色模式:深灰黑
        ),
      ),
      // CupertinoPageScaffold 是 iOS 风格的页面脚手架
      // 相当于 Material 的 Scaffold
      home: CupertinoPageScaffold(
        child: Center(child: Text('Hello Rolodex!')),
      ),
    );
  }
}

三、创建数据模型

3.1 Contact 类

lib/data/contact.dart 中创建联系人数据模型:

dart 复制代码
// Contact:单个联系人
// 包含 ID、姓、名、中间名(可选)、后缀(可选,如 Jr.、Sr.)
class Contact {
  Contact({
    required this.id,           // 唯一标识符
    required this.firstName,    // 名
    this.middleName,            // 中间名(可选)
    required this.lastName,     // 姓
    this.suffix,                // 后缀(可选,如 Jr.、III)
  });

  final int id;
  final String firstName;
  final String lastName;
  final String? middleName;  // ? 表示可空
  final String? suffix;
}

// 示例联系人数据
final johnAppleseed = Contact(id: 0, firstName: 'John', lastName: 'Appleseed');
final kateBell = Contact(id: 1, firstName: 'Kate', lastName: 'Bell');
final annaHaro = Contact(id: 2, firstName: 'Anna', lastName: 'Haro');
final danielHiggins = Contact(
  id: 3, firstName: 'Daniel', lastName: 'Higgins', suffix: 'Jr.',
);
// ... 更多联系人(完整列表见代码库文件)

// 所有联系人的集合
final Set<Contact> allContacts = <Contact>{
  johnAppleseed, kateBell, annaHaro, danielHiggins,
  // ... 完整列表
};

3.2 ContactGroup 类

lib/data/contact_group.dart 中创建联系人分组:

dart 复制代码
import 'dart:collection';
import 'package:flutter/cupertino.dart';
import 'contact.dart';

// ContactGroup:联系人分组
// 如"所有联系人"、"收藏"、"工作"等
class ContactGroup {
  factory ContactGroup({
    required int id,
    required String label,
    bool permanent = false,   // 是否为固定分组(不可删除)
    String? title,
    List<Contact>? contacts,
  }) {
    // 创建时自动按姓名排序
    final contactsCopy = contacts ?? <Contact>[];
    _sortContacts(contactsCopy);
    return ContactGroup._internal(
      id: id, label: label, permanent: permanent,
      title: title, contacts: contactsCopy,
    );
  }

  // 私有构造函数
  ContactGroup._internal({
    required this.id,
    required this.label,
    this.permanent = false,
    String? title,
    List<Contact>? contacts,
  }) : title = title ?? label,
       _contacts = contacts ?? const <Contact>[];

  final int id;
  final bool permanent;
  final String label;
  final String title;
  final List<Contact> _contacts;

  List<Contact> get contacts => _contacts;

  // 按首字母分组(用于字母索引滚动列表)
  // SplayTreeMap 自动按 key 排序(A、B、C...)
  AlphabetizedContactMap get alphabetizedContacts {
    final AlphabetizedContactMap contactsMap = AlphabetizedContactMap();
    for (final Contact contact in _contacts) {
      final String lastInitial = contact.lastName[0].toUpperCase();
      if (contactsMap.containsKey(lastInitial)) {
        contactsMap[lastInitial]!.add(contact);
      } else {
        contactsMap[lastInitial] = [contact];
      }
    }
    return contactsMap;
  }
}

// 按字母排序的联系人 Map 类型别名
typedef AlphabetizedContactMap = SplayTreeMap<String, List<Contact>>;

// 联系人排序函数:先按姓排序,再按名,再按中间名
void _sortContacts(List<Contact> contacts) {
  contacts.sort((Contact a, Contact b) {
    final int checkLastName = a.lastName.compareTo(b.lastName);
    if (checkLastName != 0) return checkLastName;
    final int checkFirstName = a.firstName.compareTo(b.firstName);
    if (checkFirstName != 0) return checkFirstName;
    if (a.middleName != null && b.middleName != null) {
      final int checkMiddleName = a.middleName!.compareTo(b.middleName!);
      if (checkMiddleName != 0) return checkMiddleName;
    } else if (a.middleName != null || b.middleName != null) {
      return a.middleName != null ? 1 : -1;
    }
    return a.id.compareTo(b.id);
  });
}

// 示例分组数据
final allPhone = ContactGroup(
  id: 0, permanent: true,
  label: 'All iPhone', title: 'iPhone',
  contacts: allContacts.toList(),
);
final friends = ContactGroup(
  id: 1, label: 'Friends',
  contacts: [allContacts.elementAt(3)],
);
final work = ContactGroup(id: 2, label: 'Work');

// 生成初始数据
List<ContactGroup> generateSeedData() {
  return [allPhone, friends, work];
}

// ContactGroupsModel:状态管理
// 使用 ValueNotifier 管理分组列表的变化
class ContactGroupsModel {
  ContactGroupsModel()
    : _listsNotifier = ValueNotifier(generateSeedData());

  // ValueNotifier:一种简化版的 ChangeNotifier
  // 它包裹一个值,当值改变时自动通知监听者
  final ValueNotifier<List<ContactGroup>> _listsNotifier;

  ValueNotifier<List<ContactGroup>> get listsNotifier => _listsNotifier;
  List<ContactGroup> get lists => _listsNotifier.value;

  // 根据 ID 查找分组
  ContactGroup findContactList(int id) {
    return lists[id];
  }

  // 释放资源
  void dispose() {
    _listsNotifier.dispose();
  }
}

四、ValueNotifier 简介

在维基百科阅读器中我们用了 ChangeNotifier。这次用的 ValueNotifier 是它的简化版:

scala 复制代码
// ChangeNotifier:通用版,可以有多个属性
// 需要手动调用 notifyListeners()
class ArticleViewModel extends ChangeNotifier {
  Summary? summary;
  bool loading = false;
  // 修改后需要手动调用 notifyListeners()
}

// ValueNotifier:简化版,只包裹一个值
// 修改 .value 时自动通知监听者,不需要手动调用
final counter = ValueNotifier<int>(0);
counter.value = 1; // 自动通知!无需调用 notifyListeners()

当你的状态只有一个值时,ValueNotifierChangeNotifier 更简洁。


五、本节知识点小结

CupertinoApp: Apple iOS 风格的应用框架,替代 MaterialApp。提供 iOS 风格的导航栏、按钮、列表等组件。可在所有平台运行,不只是 iOS。

CupertinoDynamicColor: 能自动适配亮色和暗色模式的颜色类。传入两个颜色值,系统会根据当前模式自动选择。

ValueNotifier: ChangeNotifier 的简化版,包裹一个值。当 .value 被修改时自动通知监听者,不需要手动调用 notifyListeners()

项目结构: 按职责划分目录------data/ 放数据模型、screens/ 放页面组件、theme/ 放主题配置,让代码组织更清晰。


六、下一步学习

项目骨架和数据模型已就绪。下一课我们将学习 自适应布局 (Adaptive Layouts),使用 LayoutBuilder 让应用在不同屏幕尺寸上自动切换布局方式。

我们下篇文章见!

参考资料:Flutter 官方教程 - Advanced UI Features

相关推荐
大圣编程1 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang1 小时前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆2 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜2 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
负责的蛋挞3 小时前
异步HttpModule的实现方式
java·服务器·前端
丹宇码农6 小时前
把 HLS 字幕玩出花:zwPlayer 如何让 M3U8 视频支持全文搜索、翻译与码率自适应
前端·javascript·音视频·hls·视频播放器
2501_943782356 小时前
【共创季稿事节】猜数字游戏:二分法思维与交互式反馈
前端·游戏·microsoft·harmonyos·鸿蒙·鸿蒙系统
GV191rLvq6 小时前
基于Socket实现的最简单的Web服务器【ASP.NET原理分析】
服务器·前端·asp.net
吠品7 小时前
LangChain 里 tool_call_id 为空?一次 MCP 工具集成的排查记录
前端