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 小时前
每个 React 开发者都需要的 10 个浏览器 API Hooks
前端·javascript·面试
HelloReader1 小时前
Flutter ListenableBuilder让界面自动响应数据变化(十)
前端
yuki_uix1 小时前
深拷贝:JavaScript 引用类型的完全复制之道
前端·javascript
默默学前端2 小时前
JavaScript 中 call、apply、bind 的区别
开发语言·前端·javascript
宁雨桥2 小时前
前端设计模式面试题大全
前端·设计模式
Cg136269159742 小时前
JS函数表示
前端·html
在屏幕前出油2 小时前
02. FastAPI——路由
服务器·前端·后端·python·pycharm·fastapi
勿芮介2 小时前
【大模型应用】在window/linux上卸载OpenClaw
java·服务器·前端
摸鱼仙人~2 小时前
前端面试手写核心 Cheat Sheet(终极精简版)
前端