0-introduction
课程简介
欢迎来到 React Native 入门课程。
- 目标受众: React Native 新手,或仅有少量尝试、刚刚入门的开发者。
- 学习方式: “边做边学” (Learn by doing),从零开始。
- 课程内容:
- 掌握使用 React Native 构建移动应用的基础知识。
- 学习核心组件的使用。
- 强调移动端开发与 Web 开发的异同之处。
- 先决条件:
- 强烈建议: 熟悉 JavaScript 和 TypeScript。
- 推荐: 了解 React,但不是必需的。
讲师介绍
- Kadi Kraman:
- 从早期就开始接触并使用 React Native。
- 曾任 PU map 应用的技术负责人。
- 活跃于 React 社区,做过多场技术分享。
- 主导构建了 React Conf 2024 的官方应用(已在 Google Play 和 App Store 上架,代码开源)。
- 目前在 Expo 工作。
课程要求
- 一台电脑 (Mac, Windows, 或 Linux)。
- 已安装 Node.js。
- 一部手机 (iPhone 或 Android)。
- 网络连接。
我们将要构建的应用
我们将从零开始构建一个名为 "Taskly" 的实用工具 App。
- 功能:
- 一个精美的购物清单 (Shopping list)。
- 一个提醒事项 (Reminder)。
- 第三个屏幕将有意留白,供学员自己动手实践,构建一个自己想要的功能。
课程材料说明
- 课程网站: 包含所有代码和资料。
- 命令: 所有需要在终端执行的命令,会显示在代码块中。
- 代码: 所有需要编写的代码,会放在可折叠的元素中(可能是完整文件,也可能是文件变更的 diff)。
- 检查点 (Checkpoint): 每个小节结束后,会有一个蓝色的检查点,链接到课程解决方案仓库的对应
commit,方便你核对代码。 - 提示 (Tips): 与当前主题相关的有用信息。
- 警告 (Warnings): 提醒常见的反模式、常见错误或提供调试建议。
1-react-native-project-setup
1. 环境准备 (Node.js 和包管理器)
- Node.js:
- 打开终端,运行
node -v来检查版本。 - 建议使用 LTS (长期支持) 版本,即偶数版本号,如
v18,v20,v22等。本课程使用v20。
- 打开终端,运行
- 包管理器:
- 讲师使用 Yarn Classic (
v1.22)。 - 你也可以使用
npm(随 Node.js 预装)、Yarn Modern、pnpm、bun等。
- 讲师使用 Yarn Classic (
2. 关于包管理器的选择 (Yarn/pnpm 注意事项)
- 问题: Yarn Modern 和 pnpm 默认的链接模式 (plug-n-play) 会移除
node_modules文件夹。这在 React Native 项目中会产生问题,因为 iOS 和 Android 的原生目录有时需要直接链接到node_modules中的文件。 - 解决方案:
- 如果你使用 Yarn Modern 或 pnpm,需要修改配置,使其生成
node_modules文件夹。 - 在
.yarnrc.yml(Yarn) 或.pnpmrc(pnpm) 文件中添加node-linker: hoisted。 - 这也是讲师在课程中选择使用 Yarn Classic 的原因。
- 如果你使用 Yarn Modern 或 pnpm,需要修改配置,使其生成
3. 创建新项目
- 基础命令:
npx create-expo-appnpx是 Node.js 自带的包执行器,无需额外安装。
- 查看帮助:
- 使用
h或-help参数可以查看命令的可用选项,这是一个好习惯。npx create-expo-app --help
- 使用
- 选择模板:
- Expo SDK 51 开始,默认模板不再是空白的,而是预装了导航、样式等内容,帮助新手快速上手。
- 本课程希望从零开始,所以需要选择一个空白模板。
- 使用
t或-template标志来选择模板。
- 使用指定的包管理器创建项目:
- 如果你想使用
yarn,命令格式如下:yarn create expo-app <app-name>
- 如果你想使用
- 最终使用的命令:
- 我们将应用命名为
taskly,并使用t标志来手动选择模板。
yarn create expo-app taskly -t- 在接下来的提示中,选择
Blank (TypeScript)模板。
- 我们将应用命名为
2-expo-go-on-ios-android
1. 启动项目
-
进入项目目录:
cd taskly。 -
项目结构很精简,主要包含
package.json,app.tsx(主入口文件),app.json(配置文件) 等。 -
在
package.json的scripts中可以看到start命令,它运行expo start。 -
运行启动命令:
yarn start # 或者 npm run start -
该命令会启动 Metro Bundler(JavaScript 打包器)并在终端显示一个二维码。React Native 应用包含两部分:JavaScript 端和原生应用端。此命令启动的是 JS 端。
2. 在手机上运行 (Expo Go)
我们将使用 Expo Go 这个应用来承载原生部分。
- 安装 Expo Go:
- 在你的手机(iPhone 或 Android)上,从 App Store 或 Google Play 商店搜索并下载 Expo Go。
- 在 iOS 上运行:
- 打开系统自带的 相机 App。
- 扫描终端中的二维码。
- 首次使用: Expo Go 会请求“查找并连接到本地网络上的设备”的权限,必须允许,否则无法连接到电脑上的打包器。
- 注意: iOS 版 Expo Go 应用内没有扫描按钮,这是苹果的商店政策限制。
- 在 Android 上运行:
- 打开 Expo Go 应用。
- 应用内有 "Scan QR code" 按钮,点击它。
- 允许相机权限后,扫描终端的二维码。
3. 调试菜单 (Debug Menu)
- 打开方式:
- 摇晃手机。
- iOS: 用三根手指长按屏幕。
- 主要功能:
- Reload: 重新加载应用。
- Show Performance Monitor: 显示性能监控。
- Open JavaScript Debugger: 打开 JS 调试器。
- Disable Fast Refresh: 禁用快速刷新。如果你的代码修改后应用不再自动更新,检查一下是否误关了此项。
4. 关于 TypeScript
- 强烈推荐使用 TypeScript。
- TS 是 JS 的超集,所有合法的 JS 代码也是合法的 TS 代码。
- 社区和企业正在大规模转向 TS。
- 本课程会非常轻量地介绍 TS,即使你从未用过,也推荐跟着做。
- (虽然讲师也提供了一个纯 JS 版本的代码仓库,但与 TS 版本差异极小,建议直接使用 TS)。
5. 连接问题排查 (-tunnel)
- 常见原因: 手机和电脑没有连接到同一个 Wi-Fi 网络。打包器运行在电脑的
localhost上,需要同一局域网才能访问。 - 解决方案:
- 如果无法连接(例如公司防火墙限制),可以使用
-tunnel标志启动项目:yarn start --tunnel - 这会通过 Ngrok 在互联网上创建一个公共通道,将你的本地打包器暴露出去。
- 这样,任何设备在任何网络下都可以通过扫描新的二维码来运行你的应用。
- 优点: 解决连接问题,方便与同事/朋友分享开发中的应用。
- 缺点: 速度比本地网络连接稍慢。
- 如果无法连接(例如公司防火墙限制),可以使用
3-react-native-frameworks-overview
1. 为什么使用框架 (Expo)?
- 你可能会疑惑,一个 React Native 入门课程为什么上来就用 Expo。
- Expo 是一个 React Native 框架。
- 从 React Conf 2024 开始,Meta (Facebook 官方) 推荐新应用使用 React Native 框架,并特别提到了 Expo。
2. 原生 React Native (Vanilla) 提供了什么?
- 在 iOS 和 Android 平台上运行 React 代码的能力。
- 使用底层原生组件的能力。
- 一些核心的构件块,如
<View>,<Text>,<ScrollView>等。
3. 原生 React Native 缺少什么?
几乎所有移动应用都需要的大量功能,在原生 React Native 中都不内置,例如:
- 导航 (Navigation)
- 推送通知 (Push notifications)
- 数据持久化存储 (Data storage)
- 使用手机摄像头 (Camera)
- 等等...
此外,一些任务虽然可以实现,但过程繁琐,例如:
- 修改应用图标。
- 添加自定义原生代码。
- 打包用于商店发布的应用。
这是有意为之的设计,因为将所有功能都内置会让 React Native 核心库变得极其臃肿,难以维护。因此,开发者需要依赖社区库来补充这些功能。
4. 框架的角色
- 对于新手来说,选择哪些社区库、如何确保它们协同工作,是一件非常困难的事。
- 框架 (Framework) 的作用 就是解决这个问题。
- 根据官方定义 (RFC),一个 React Native 框架是:
- 一个构建在 React Native 之上的、有凝聚力的框架。
- 它由一套 React Native 默认未提供的工具和库组成。
- 框架需要满足以下要求:
- 开源、流行、免费。
- 没有供应商锁定 (No vendor lock-in)。
5. 重要:Expo vs. Expo Go
这是一个非常重要的区别:
- Expo: 是我们要使用的开源 React Native 框架的名称。
- Expo Go: 是 Expo 框架的一小部分。它是一个沙盒环境 (sandbox),主要用于:
- 学习和教学。
- 快速原型设计。
- 快速入门。
- 不推荐使用 Expo Go 来构建你的生产级应用。
- 当你的项目需求超出了 Expo Go 的范围时(例如需要自定义原生代码),你会“毕业”到使用 Development Builds (开发构建),这将在中级课程中介绍。
4-setup-linting-and-formatting
1. 设置 ESLint
- Linting 对于代码质量非常重要,且设置过程很快。
- 从 Expo SDK 51 开始,Expo 内置了一个 Lint 命令。
- 在终端运行:
npx expo lint - 它会检测到你没有 ESLint 配置,并询问是否要为你配置一个。输入
y(yes)。 - 这会自动安装 ESLint 相关的包,并在项目根目录创建一个
.eslintrc.js配置文件。
2. 设置 Prettier
-
Prettier 是一个代码格式化工具,强烈推荐使用。它能让你专注于写代码,而不是纠结于代码格式。
-
安装 Prettier 及其与 ESLint 集成的配置包:
# 使用 yarn yarn add -D prettier eslint-config-prettier # 使用 npm/pnpm/bun npm install -D prettier eslint-config-prettier
3. 整合 Prettier 与 ESLint
- 为了让 ESLint 报告 Prettier 的格式问题,需要更新
.eslintrc.js文件。 - 在
extends数组中添加'prettier':// .eslintrc.js module.exports = { // ... extends: [ 'expo', 'prettier' // 添加这一行 ], // ... };
4. 自动修复格式问题
- 配置完成后,你可能会看到一些格式错误(例如 Prettier 默认使用双引号)。
- 你可以创建
.prettierrc.js文件来自定义 Prettier 规则,但课程中将使用默认设置。 - 手动修复:
- 运行 lint 命令并添加
-fix标志,可以自动修复所有可修复的格式问题。yarn lint --fix
- 运行 lint 命令并添加
- 自动修复:
- 在 VS Code 中安装 ESLint 插件,可以实现保存文件时自动格式化。
5-your-first-view-component-with-styles
1. 核心组件 (View 和 Text)
<View>组件:- 相当于 Web 开发中的
<div>。 - 是最基础的容器组件,用于布局和包裹其他组件。
- 相当于 Web 开发中的
<Text>组件:- 所有文本都必须包裹在
<Text>组件内。 - 与 Web 不同,你不能在 React Native 的 JSX 中直接渲染裸露的文本字符串。
- 所有文本都必须包裹在
2. 条件渲染的注意事项
- 在 Web 开发中,常用
&&进行条件渲染,例如condition && <Component />。 - 在 React Native 中这通常也有效,但存在一个陷阱:
- 如果
&&前的表达式结果是0或NaN(这些是 "falsy" 值),React Native 会尝试渲染这个数字,因为它不是null或false。- 由于数字
0没有被包裹在<Text>组件内,应用会报错:"Text strings must be rendered within a <Text> component"。
- 由于数字
- 如果
- 更安全的方式是使用三元运算符:
{condition ? <Component /> : null}
3. 样式 (Styling)
style属性: 组件通过style属性接收样式,该属性的值是一个 JavaScript 对象。- 属性命名: CSS 属性名需要转换成驼峰式命名 (camelCase),例如
background-color变为backgroundColor。 - 便利简写: React Native 提供了一些方便的样式简写:
paddingHorizontal: 同时设置paddingLeft和paddingRight。paddingVertical: 同时设置paddingTop和paddingBottom。
4. 布局 (Flexbox)
- React Native 中的所有布局都基于 Flexbox。
- 与 Web Flexbox 的主要区别:
display: 'flex'是默认值:所有<View>元素默认就是 Flex 容器,无需显式声明。flexDirection: 'column'是默认值:主轴方向默认为纵向(从上到下),而在 Web 中默认为row(横向)。
5. 单位 (Display Points)
- 样式中的数字(如
padding: 16,borderBottomWidth: 1)不是像素 (pixels)。 - 它们是显示点 (Display Points),一种与设备密度无关的单位。
- 1 个显示点在屏幕上可能对应多个物理像素,这取决于设备的像素密度 (pixel ratio)。
- 例如,在像素密度为
3的设备上,1 个显示点对应3x3的物理像素方块。 - 这意味着在高分辨率设备上,你需要提供更大尺寸的图片才能保证其显示清晰。
- 例如,在像素密度为
6. 代码组织和主题 (Theming)
StyleSheet.create:- 最佳实践是将行内样式移到文件底部的
StyleSheet.create对象中进行统一管理。 - 这是一种社区通用规范,可以提供一些样式有效性检查。
- 最佳实践是将行内样式移到文件底部的
- 创建主题文件:
- React Native 没有全局 CSS 的概念。
- 一个常见的模式是创建一个
theme.ts文件来存放共享的颜色、间距、字体大小等。 - 这样可以方便地在多个组件之间复用样式,保持应用风格的统一。
- 示例:
// theme.ts export const theme = { colors: { cerulean: "#_some_hex_code_", white: "#FFFFFF", }, spacing: { small: 8, medium: 16, }, };
6-create-a-button-with-pressable
1. 创建可交互按钮
<Button>组件:- React Native 提供了一个内置的
<Button>组件。 - 但它在生产中几乎从不使用,因为它渲染的是平台原生的、无法自定义样式的按钮。只适合快速测试。
- React Native 提供了一个内置的
- 自定义按钮的方式:
- 使用可触摸组件包裹任何你想让其变得可点击的元素。
Pressable: 新一代的可触摸组件,提供了更丰富的状态(如onHoverIn,onPressIn),并可以根据是否被按下传递不同的样式。TouchableOpacity: 非常常用和流行。当被按下时,它会自动降低其子元素的不透明度,产生一个反馈效果。- 可以通过
activeOpacity属性来控制按下的不透明度,例如activeOpacity={0.8}。
- 可以通过
2. 布局调整:并排与对齐
- 目标: 将列表项文本和删除按钮放在同一行。
- 实现: 在它们的父容器
<View>的样式中:flexDirection: 'row': 将主轴方向改为横向,使子元素并排排列。justifyContent: 'space-between': 在主轴上将子元素两端对齐,中间留出所有可用空间。alignItems: 'center': 在交叉轴上(此处是垂直方向)将子元素居中对齐。
3. 设置按钮样式
- 我们的按钮结构是
<TouchableOpacity>包裹一个<Text>。 TouchableOpacity的样式:backgroundColorpaddingborderRadius(圆角)
<Text>的样式:colorfontWeight: 'bold'(加粗)textTransform: 'uppercase'(文本转为大写)letterSpacing: 增加字母间距。
4. 使用 Alert 实现确认对话框
Alert是 React Native 内置的一个 API,用于弹出平台原生的对话框。- 导入:
import { Alert } from 'react-native'; - 使用: 调用
Alert.alert()方法。Alert.alert(title, message?, buttons?)
- 参数:
title(string): 对话框的标题。message(string, 可选): 对话框的详细内容。buttons(array, 可选): 一个按钮配置对象的数组。- 每个按钮对象可以包含:
text(string): 按钮上显示的文本。onPress(function): 点击按钮时触发的回调函数。style(string): 按钮的样式。可选值为:'default': 默认'cancel': 取消样式(在 iOS 上有特殊行为)'destructive': 破坏性操作样式(在 iOS 上会显示为红色)
- 每个按钮对象可以包含:
- 注意:
Alert的外观是平台相关的,在 iOS 和 Android 上看起来不一样。- 如果你想完全自定义对话框的样式,你需要自己用
Modal组件来构建一个。
7-react-components
1. 为什么要创建组件
- 如果将所有 UI 和逻辑都写在
App.tsx文件中,这个文件会变得异常庞大,难以维护和复用。 - 通过将 UI 拆分成独立的、可复用的组件 (Components),可以使代码结构更清晰、更模块化。
2. 创建 ShoppingListItem 组件
- 标准实践: 在项目根目录创建一个
components文件夹来存放所有可复用组件。 - 步骤:
- 在
components文件夹下创建新文件ShoppingListItem.tsx。 - 在文件中,导出一个名为
ShoppingListItem的函数组件。 - 将之前在
App.tsx中编写的列表项的 JSX、StyleSheet对象以及handleDelete等相关逻辑剪切到ShoppingListItem.tsx文件中。 - 在新文件中,补全所有缺失的导入,如
View,Text,TouchableOpacity,Alert,theme等。 - 在
App.tsx中,导入并使用这个新的<ShoppingListItem />组件。
- 在
3. 通过 Props 传递数据
-
组件需要能够接收外部数据(例如列表项的名称)才能变得动态和可复用。这通过
props(properties) 实现。 -
传递 Props: 在父组件 (
App.tsx) 中,像 HTML 属性一样传递数据。<ShoppingListItem name="咖啡" /> <ShoppingListItem name="茶" /> -
接收 Props: 在子组件 (
ShoppingListItem.tsx) 中定义并接收这些数据。-
使用 TypeScript 定义 Props 类型: 创建一个
Props类型来描述组件期望接收的数据及其类型。type Props = { name: string; // name 属性是必需的,且为字符串 };如果属性是可选的,可以在属性名后加 ?,如 name?: string;
-
在函数参数中解构 Props:
export function ShoppingListItem({ name }: Props) { // ... } -
在组件内部使用 Prop:
// 在 JSX 中渲染 <Text>{name}</Text> // 在逻辑中使用 Alert.alert(`确定要删除 ${name} 吗?`);
-
4. Linter 插件:清理未使用的样式
-
代码重构后,
App.tsx和ShoppingListItem.tsx中都可能存在不再被使用的样式。 -
eslint-plugin-react-native: 这是一个非常有用的 ESLint 插件。 -
no-unused-styles规则: 这个插件提供了一条规则,可以自动检测并高亮StyleSheet.create对象中未被使用的样式。 -
设置步骤:
-
安装插件:
yarn add -D eslint-plugin-react-native。 -
在
.eslintrc.js中配置:module.exports = { // ... plugins: [ 'react-native' // 在 plugins 数组中添加 ], rules: { 'react-native/no-unused-styles': 'warn' // 在 rules 中启用规则 } }; -
配置完成后,编辑器就会提示你哪些样式是多余的,可以安全删除。
-
8-passing-multiple-styles-to-component
1. 目标:实现“已完成”状态的 UI
- 为购物清单项添加一个“已完成”的状态。
- 在“已完成”状态下,UI 会有变化:
- 文本颜色变灰,并添加删除线。
- 背景和边框颜色变浅。
- 删除按钮也变灰。
2. 通过 Prop 传递状态
- 在
ShoppingListItem组件的Props类型中添加一个新的属性isCompleted。 - 将它定义为可选的布尔值,默认为
false。type Props = { name: string; isCompleted?: boolean; // 可选的布尔值 };
3. 核心技巧:为 style 属性传递样式数组
- React Native 组件的
style属性不仅可以接收单个样式对象,还可以接收一个样式对象的数组。 style={[styles.base, styles.override]}- 工作原理:
- 数组中的样式会从左到右依次合并。
- 后面的样式会覆盖前面样式中的同名属性。
- 条件样式的关键:
- 样式数组中可以包含
false,null, 或undefined等假值 (falsy values)。 - React Native 会自动忽略这些假值。
- 这使得添加条件样式变得非常简单和优雅。
- 样式数组中可以包含
4. 具体实现
-
利用样式数组和条件逻辑来应用“已完成”的样式。
-
示例:
// 为容器应用条件样式 <View style={[ styles.itemContainer, isCompleted && styles.completedContainer ]} /> // 为文本应用条件样式 <Text style={[ styles.itemText, isCompleted && styles.completedText ]} />isCompleted && styles.completedContainer:当isCompleted为true时,表达式结果为styles.completedContainer对象;当isCompleted为false时,结果为false,该项被忽略。
-
在
StyleSheet中定义新的样式:completedContainer: 修改backgroundColor和borderBottomColor。completedText:textDecorationLine: 'line-through'(添加删除线)color: 修改为灰色。
5. JSX 中的布尔属性简写
- 当你想要给一个组件传递值为
true的布尔属性时,有一种简写形式。 - 常规写法:
<ShoppingListItem isCompleted={true} /> - 简写形式:
<ShoppingListItem isCompleted /> - 这种简写只适用于值为
true的情况。
9-icon-buttons
1. 使用图标按钮
- 在移动端 UI 中,使用图标代替文本作为按钮是一种常见做法,可以节省屏幕空间,提升美感。
- 我们将使用
@expo/vector-icons这个库。- 它底层封装了
react-native-vector-icons。 - Expo 提供了一个非常方便的网站,可以用来搜索数千个可用的图标。
- 它底层封装了
2. 安装图标库 (@expo/vector-icons)
- 推荐的安装方式:
npx expo install @expo/vector-icons - 为什么使用
npx expo install?- 这个命令会确保你安装的库版本与你项目中使用的 Expo SDK 版本兼容。
- React Native 库(尤其是包含原生代码的库)通常与特定的 React Native 版本绑定。
expo install解决了版本兼容性问题,避免了许多潜在的错误。 - 它还会自动检测你项目中的包管理器(yarn, npm, pnpm)并使用它来安装。
- Expo Go 与原生库:
- 通常,添加一个包含原生代码的库需要重新编译你的原生应用。
- 但 Expo Go 是一个沙盒环境,它预装了许多常用的原生库,包括
@expo/vector-icons。 - 这就是为什么我们可以直接安装 JS 包并立即使用的原因,无需重新构建。
3. 实现图标按钮
-
步骤:
-
从图标网站 (https://icons.expo.fyi/) 找到你想要的图标,例如
AntDesign中的closecircleo。网站会提供给你需要复制的import语句和组件用法。 -
在
ShoppingListItem.tsx文件中导入该图标:import { AntDesign } from '@expo/vector-icons'; -
在
<TouchableOpacity>组件内部,用图标组件替换原来的<Text>组件。<TouchableOpacity onPress={handleDelete}> {/* <Text>DELETE</Text> 被替换为下面的图标 */} <AntDesign name="closecircleo" size={24} color="red" /> </TouchableOpacity> -
通过
props(如name,size,color) 来定制图标的样式。 -
可以根据
isCompleted属性来动态改变图标的颜色,实现完成状态下的灰色效果。
-
4. 关于 SVG 性能和替代方案
- 性能提示:
- 在 React Native 中,特别是在 Android 上,SVG 的性能并不总是最优的。
- 如果一个屏幕上有大量的 SVG,可能会导致应用变慢。
- 在 React Native 中应谨慎使用 SVG,小型的 PNG 图片有时是性能更好的选择。
- 替代方案:
- 对于图标按钮这样少量使用 SVG 的场景,性能完全没问题。
- 如果你想使用自定义的 SVG 图标,可以使用
react-native-svg库。 expo-image组件现在也支持渲染 SVG。
10-expo-router
1. 移动端导航简介
- 移动端导航与 Web 不同,包含 Tabs(标签页)、Modals(模态框)、Stack(堆栈)等概念。
- React Native 本身不内置导航库,需要额外安装。
- 本课程使用 Expo Router。
2. Expo Router 介绍
- Expo Router:一个基于文件系统的 React Native 导航库。
- 它构建于 React Navigation 之上,因此 React Navigation 的屏幕配置选项 (screen options) 同样适用于 Expo Router,可以查阅其文档获取更多细节。
- 工作原理:
app文件夹是所有路由的根目录。- 文件和文件夹的名称决定了屏幕的路径 (URL)。
app/index.tsx-> 根路径/app/home.tsx->/homeapp/products/index.tsx->/productsapp/products/[id].tsx-> 动态路由,如/products/123
- 布局文件 (
_layout.tsx):- 这是 Expo Router 的核心,用于定义文件夹内屏幕的布局方式。
- 每个文件夹最多只能有一个
_layout.tsx文件。 - 你可以在其中指定该层级的导航是 Stack, Tabs, 还是 Modal,并配置 Header 等。
3. 安装和配置 Expo Router
-
安装依赖:
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-barreact-native-safe-area-context: 用于处理刘海屏等异形屏的安全区域,Expo Router 会自动包裹屏幕。expo-linking: 用于实现深层链接 (Deep Linking)。
-
修改
package.json:- 找到
main字段,将其值从"expo/AppEntry.js"修改为:
"main": "expo-router/entry"- 这将应用的入口点切换到 Expo Router。
- 找到
-
调整文件结构:
- 在项目根目录创建一个新的文件夹
app。 - 将
App.tsx文件移动到app文件夹内。 - 将
app/App.tsx重命名为index.tsx。应用的入口路由必须是index文件。
- 在项目根目录创建一个新的文件夹
-
修改
app.json:- 添加
scheme字段,用于深层链接。你的应用会注册监听这个 scheme。
{ "expo": { "scheme": "taskly" } } - 添加
-
重启服务:
- 每次修改完原生或核心配置后,需要重启 Metro Bundler。
yarn start # 如果遇到问题,可以尝试清除缓存 # yarn start --reset-cache
4. 创建根布局 (app/_layout.tsx)
-
在
app文件夹下创建_layout.tsx。 -
默认情况下,即使没有布局文件,所有屏幕也会被渲染在一个 Stack 导航器中。创建布局文件是为了进行自定义配置。
-
布局文件必须默认导出一个组件 (
export default)。import { Stack } from "expo-router"; export default function Layout() { return ( <Stack> <Stack.Screen name="index" // 对应文件名 index.tsx options={{ title: "Shopping List", // 设置导航栏标题 }} /> </Stack> ); }<Stack>组件将所有子屏幕渲染为堆栈导航。<Stack.Screen>用于配置单个屏幕,name属性对应其文件名(不含扩展名),options用于自定义外观和行为。
5. 关于 Scheme 和链接
scheme意味着你可以使用taskly://这样的 URI 来打开你的应用。- 这是一种基础的 Deep Linking。更高级的方式是 Universal Links (iOS) 和 App Links (Android),它们使用标准的
https://链接并需要网站验证,配置更复杂。
11-stack-navigation
1. Stack (堆栈) 导航
- 概念:当你导航到一个新屏幕时,它会像卡片一样被“推入”并叠加在旧屏幕之上,形成一个堆栈。返回时,则会“弹出”最上层的屏幕。
- Expo Router 中,默认的导航方式就是 Stack。
2. 创建新屏幕
- 在
app目录下创建新文件即可创建新屏幕。 - 例如,创建
app/counter.tsx和app/idea.tsx。
3. 三种主要的导航方式
-
使用
<Link>组件 (声明式)- 从
expo-router导入Link组件。 - 使用
href属性指定目标路径。 <Link>组件内部可以直接写文本,它会被渲染成可点击的文本。
import { Link } from "expo-router"; <Link href="/counter"> <Text>Go to Counter</Text> </Link>; - 从
-
使用
useRouterHook (编程式)- 当你需要在某个事件(如按钮点击)后执行导航时,使用此方式。
- 从
expo-router导入useRouterhook。 router对象提供了push,navigate,replace等方法。push(): 总是将一个新屏幕推入堆栈顶层。navigate(): 如果目标屏幕已在堆栈中,会返回到该屏幕,而不是添加新的。replace(): 用新屏幕替换当前屏幕。
import { useRouter } from "expo-router"; import { TouchableOpacity, Text } from "react-native"; const router = useRouter(); <TouchableOpacity onPress={() => router.navigate("/idea")}> <Text>Go to Idea</Text> </TouchableOpacity>; -
使用导航器自带的返回按钮
- Stack 导航器会自动在非根屏幕的左上角添加一个返回按钮,点击即可返回上一级。
4. 平台差异
- Stack 导航的头部 (Header) 样式和切换动画在 iOS 和 Android 上是不同的。
- 这是有意为之的,为了让应用在各自的平台上看起来更“原生”。
- 例如,iOS 的标题默认居中,而 Android 默认居左。
- 你可以自定义这些样式,让它们在两个平台上保持一致。
12-modal-bottom-tab-navigation
1. Modal (模态) 导航
- 概念: 将一个屏幕以模态形式呈现在当前内容之上,通常用于执行独立任务或显示重要信息。
- 实现方式:
- 在
_layout.tsx文件中,为Stack.Screen添加options。 - 设置
presentation: 'modal'。
<Stack.Screen name="counter" options={{ presentation: "modal", title: "Counter", }} /> - 在
- 注意事项:
- 模态屏幕的定义层级需要在它要覆盖的屏幕之上或与之相邻。
- 在 Android 上,默认的模态动画和普通屏幕切换类似。可以通过
animation选项来统一,例如animation: 'slide_from_bottom'可以让 iOS 和 Android 都实现从底部滑入的效果。
2. Bottom Tab (底部标签页) 导航
-
这是移动应用中最常见的导航模式之一。
-
Expo Router 使其实现变得极其简单:
- 打开根布局文件
app/_layout.tsx。 - 将所有的
<Stack>组件重命名为<Tabs>。 - 将所有的
<Stack.Screen>组件重命名为<Tabs.Screen>。
import { Tabs } from 'expo-router'; export default function Layout() { // 之前是 <Stack> return ( <Tabs> {/* 之前是 <Stack.Screen> */} <Tabs.Screen name="index" ... /> <Tabs.Screen name="counter" ... /> <Tabs.Screen name="idea" ... /> </Tabs> ); } - 打开根布局文件
-
切换到 Tabs 导航后,页面底部就会出现标签栏,用于在不同屏幕间切换。
-
之前使用的
<Link>和useRouter导航方式依然有效。
3. 自定义底部标签页图标
-
默认情况下,标签页会显示一个占位符图标。
-
实现方式:
- 在
Tabs.Screen的options中,使用tabBarIcon属性。 tabBarIcon是一个函数,它返回一个 React 组件作为图标。- 该函数会接收
{ color, size, focused }作为参数,可以利用这些参数来动态调整图标样式。
import { AntDesign } from "@expo/vector-icons"; <Tabs.Screen name="index" options={{ title: "Shopping List", tabBarIcon: ({ color, size }) => ( <AntDesign name="shoppingcart" size={size} color={color} /> ), }} />; - 在
4. 全局配置 Tab 样式
- 可以在顶层的
<Tabs>组件上使用screenOptions来为所有标签页设置通用样式。 - 例如,设置激活状态下的颜色:
<Tabs screenOptions={{ tabBarActiveTintColor: theme.colors.cerulean, }} > {/* ...screens */} </Tabs>
13-nested-navigators
1. 嵌套导航 (Nesting Navigators)
- 在原生应用开发中,导航器可以无限层级地嵌套,例如在一个 Tab 内部嵌套一个 Stack,或者 Stack 内部再嵌套一个 Stack。
- 我们可以利用这个特性在一个标签页内实现独立的导航流程。
2. 将屏幕转换为文件夹 (实现嵌套)
-
目标: 将 "Counter" 标签页从一个单独的屏幕变成一个拥有自己导航堆栈的区域。
-
步骤:
-
创建文件夹: 在
app目录下,创建一个与原屏幕同名的新文件夹,例如counter。 -
移动和重命名: 将原来的
app/counter.tsx文件移动到app/counter/文件夹内,并将其重命名为index.tsx。- 这样做可以保持路由路径不变 (
/counter仍然指向这个屏幕)。
- 这样做可以保持路由路径不变 (
-
创建内部布局: 在
app/counter/文件夹内,创建一个新的_layout.tsx文件。这个文件将定义counter文件夹内部的导航行为(例如,一个 Stack 导航)。// app/counter/_layout.tsx import { Stack } from "expo-router"; export default function CounterLayout() { return <Stack />; } -
创建新屏幕: 在
app/counter/文件夹内创建其他屏幕,例如history.tsx。这个屏幕的路径将是/counter/history。
-
3. 处理双重导航头 (Double Header)
-
嵌套导航后,你可能会看到两个导航头(外部 Tab 导航的头和内部 Stack 导航的头)。
-
解决方法: 隐藏其中一个。
-
判断隐藏哪个: 在本例中,因为内部 Stack 会有多个屏幕(Counter 和 History),我们需要保留内部 Stack 的导航头来显示标题和返回按钮。因此,我们应该隐藏外部 Tab 导航在 "Counter" 标签页上显示的那个头。
-
实现: 在根布局 (
app/_layout.tsx) 中,找到对应的Tabs.Screen,并设置headerShown: false。// app/_layout.tsx <Tabs.Screen name="counter" // 对应的文件夹名 options={{ headerShown: false, // 隐藏外部导航头 ... }} />
4. 在导航头中添加按钮 (headerRight)
- 我们可以在内部布局 (
app/counter/_layout.tsx) 中为屏幕配置headerRight选项,在导航头右侧添加一个自定义按钮。 headerRight是一个返回 React 组件的函数。- 示例: 添加一个可点击的历史图标,用于导航到
history屏幕。// app/counter/_layout.tsx <Stack> <Stack.Screen name="index" options={{ title: "Counter", headerRight: () => ( <Link href="/counter/history" asChild> <Pressable> <MaterialIcons name="history" size={32} color="gray" /> </Pressable> </Link> ), }} /> <Stack.Screen name="history" options={{ title: "History" }} /> </Stack>
5. 提升用户体验 (hitSlop 和 asChild)
- 对于没有背景边框的图标按钮,用户的点击区域可能很小。
hitSlop:Pressable组件的一个属性,可以扩大组件的可点击热区,而不需要改变其视觉大小。hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
asChild: 当Link组件的直接子元素是另一个可以处理按压事件的组件(如Pressable或TouchableOpacity)时,需要给Link添加asChild属性。这会将导航功能“转移”给子组件,而不是渲染一个额外的<a>标签包裹层。
14-text-input
1. 文本输入 (TextInput)
- React Native 中用于接收用户键盘输入的组件是
<TextInput>。 - 与 Web 不同,React Native 没有统一的
<form>组件,需要单独处理每个输入。 - 基础用法:
<TextInput>默认没有任何样式,你需要手动添加borderColor,borderWidth,padding等样式才能让它可见。placeholder: 设置占位文本。value: 输入框的当前值。onChangeText: 文本变化时的回调函数,直接返回文本字符串,比 Web 的onChange事件更方便。
2. 受控组件 (Controlled Input)
- 使用
useStateHook 来管理输入框的状态是一种标准实践。 useState返回一个包含两个元素的数组:当前状态值和更新该状态的函数。
// 1. 引入 useState
import React, { useState } from "react";
// 2. 在组件中初始化状态
const [value, setValue] = useState("");
// 3. 将状态和更新函数绑定到 TextInput
<TextInput
placeholder="例如:咖啡"
value={value}
onChangeText={setValue} // 简写形式
/>;
3. 配置键盘行为
<TextInput>提供了多个 props 来优化键盘体验:keyboardType: 设置键盘类型,如'numeric','email-address'等。autoCapitalize,autoComplete,autoCorrect: 控制自动大写、自动完成和自动纠错。returnKeyType: 定义键盘右下角“返回键”的样式和功能,如'done','next','search'。
- 提交输入:
onSubmitEditing: 当用户点击键盘的“返回键”(如 "Done" 按钮)时触发的回调函数。- 这允许我们在不创建额外提交按钮的情况下处理表单提交。
4. 状态管理与列表渲染
-
管理列表数据: 使用另一个
useState来存储购物清单的数组。const [shoppingList, setShoppingList] = useState([]); -
动态渲染列表: 使用数组的
.map()方法来遍历shoppingList状态,并为每个项渲染一个<ShoppingListItem>组件。{ shoppingList.map((item) => ( <ShoppingListItem key={item.id} name={item.name} /> )); } -
添加新项目:
- 在
handleSubmit函数中,检查输入值是否为空。 - 创建一个包含新项目和所有旧项目的新数组。使用展开语法 (
...) 是一个好方法。
```javascript const newItem = { id: new Date().toISOString(), name: value }; const newShoppingList = [newItem, ...shoppingList];1. 使用 `setShoppingList(newShoppingList)` 更新状态。 2. 使用 `setValue('')` 清空输入框。 - 在
15-scrollview
1. 处理溢出内容
- 在 Web 上,当内容超出屏幕高度时,页面会自动出现滚动条。
- 在 React Native 中,默认情况是不会自动滚动的。超出的内容会被直接截断,无法查看。
2. ScrollView 组件
- 如果你需要一个区域可以滚动,必须明确地使用可滚动组件。
<ScrollView>是最基础的可滚动容器。- 用法: 只需将原来不可滚动的
<View>替换为<ScrollView>即可。
3. contentContainerStyle vs style
style: 应用于ScrollView组件本身的样式(外部容器)。contentContainerStyle: 应用于ScrollView内部内容的样式。- 关键区别: 如果你想给滚动内容添加
padding或margin,应该使用contentContainerStyle。如果直接在style中添加paddingTop,可能会导致最后一个元素被截断,因为内边距是加在外部容器上的。
4. stickyHeaderIndices (粘性头部)
-
ScrollView提供了一个非常有用的属性stickyHeaderIndices。 -
它接收一个数字数组,数组中的数字代表
ScrollView直接子元素的索引。 -
被指定的子元素在向上滚动时会“粘”在屏幕顶部,直到被下一个粘性头部推出。
-
示例:
<ScrollView stickyHeaderIndices={[0]}> {/* 索引为 0 的子元素,会变成粘性头部 */} <TextInput ... /> {/* 其他内容 */} {shoppingList.map(...)} </ScrollView> -
注意: 为了让粘性头部在滚动时覆盖下方内容,需要给它设置一个不透明的
backgroundColor。
16-flatlist
1. ScrollView 的问题
- 虽然
ScrollView可以实现滚动,但它会一次性渲染所有的子元素。 - 如果你有一个非常长的列表(成百上千项),一次性渲染所有项会消耗大量内存,导致性能问题甚至应用崩溃。
- 规则: 如果你是通过
.map()遍历一个数组来渲染列表,不应该使用ScrollView。
2. FlatList 组件
<FlatList>是 React Native 中用于渲染长列表的高性能组件。- 它在 Web 中没有直接的对应物。
- 核心优势 (虚拟化):
- 只会渲染当前屏幕可见区域内的列表项。
- 当你滚动时,它会自动卸载滚出屏幕的项,并渲染即将进入屏幕的项。
- 这大大降低了内存占用,保证了列表滚动的流畅性。
3. FlatList 的核心 Props
data: 接收要渲染的列表数据数组。renderItem: 一个函数,告诉FlatList如何渲染每一项。它会接收一个包含item的对象,你需要从{ item }中解构出列表项数据。keyExtractor: 一个函数,用于为列表中的每一项生成一个唯一的key。如果你的数据项中已经有了名为id或key的属性,FlatList会自动使用它们,这种情况下可以省略keyExtractor。
4. FlatList的其他有用 Props
ListHeaderComponent: 在列表顶部渲染一个组件。常用于放置搜索框或标题。ListEmptyComponent: 当data数组为空时,渲染这个组件。常用于显示“列表为空”的提示。stickyHeaderIndices: 与ScrollView一样,可以设置粘性头部。注意,ListHeaderComponent也算一个子元素,索引为0。
5. 从 ScrollView + .map() 迁移到 FlatList
- 用
<FlatList>替换<ScrollView>和.map()循环。 - 将数据数组传递给
dataprop。 - 将原来
.map()中的渲染逻辑移入renderItem函数。 - 如果列表顶部有其他元素(如
TextInput),将其移入ListHeaderComponent。 style,contentContainerStyle,stickyHeaderIndices等属性可以从ScrollView直接迁移过来。
<FlatList
data={shoppingList}
renderItem={({ item }) => (
<ShoppingListItem name={item.name} /* key 不再需要在这里传递 */ />
)}
keyExtractor={item => item.id} // 如果数据项中没有 id 或 key,则需要这个
ListHeaderComponent={<TextInput ... />}
ListEmptyComponent={<Text>你的购物清单是空的。</Text>}
/>
17-deleting-items
- 目标:实现购物清单项目的删除和标记完成/未完成功能。
- 实现删除功能
- 第一步:为
ShoppingListItem组件添加onDelete属性- 在
ShoppingListItem组件中,定义一个新的 props:onDelete。 - 该
prop是一个必需的函数类型 (() => any)。 - 当删除按钮被按下时,调用这个从父组件传入的
onDelete函数。
// ShoppingListItem.tsx const { onDelete } = props; // ... in the delete button's onPress onPress = { onDelete }; - 在
- 第二步:在主屏幕 (
index.tsx) 中实现删除逻辑- 创建一个名为
handleDelete的函数,它接收一个id(字符串)作为参数。
const handleDelete = (id: string) => { // ... logic };- 在渲染
ShoppingListItem组件时,传入onDelete属性。 - 使用箭头函数将
item.id预先绑定到handleDelete函数中。
<ShoppingListItem // ... other props onDelete={() => handleDelete(item.id)} /> - 创建一个名为
- 第三步:更新状态
- 在
handleDelete函数中,使用.filter()方法创建一个不包含要删除项目的新数组。 filter的逻辑是保留所有item.id不等于传入的id的项目。
const newShoppingList = shoppingList.filter((item) => item.id !== id);- 使用
setShoppingList将状态更新为这个新数组。
setShoppingList(newShoppingList); - 在
- 第一步:为
- 实现切换完成状态功能
- 第一步:让整个列表项可点击
- 在
ShoppingListItem组件中,将外部的<View>容器替换为<Pressable>组件,使其可以响应点击事件。
- 在
- 第二步:添加
onToggleComplete属性- 类似于
onDelete,为ShoppingListItem添加一个新的函数prop:onToggleComplete。 - 在
<Pressable>的onPress事件中调用onToggleComplete。
- 类似于
- 第三步:在主屏幕中实现切换逻辑
- 创建
handleToggleComplete函数,该函数同样接收item.id。 - 更新
ShoppingListItem类型定义,添加一个可选的completedAtTimestamp属性,类型为number。
type ShoppingListItem = { // ... other properties completedAtTimestamp?: number; };- 在
handleToggleComplete函数中,使用.map()方法遍历现有列表。 - 如果当前遍历项的
id与要切换的id匹配:- 返回一个包含该项目所有旧属性的新对象。
- 修改
completedAtTimestamp的值:- 如果它已存在(项目已完成),则将其设置为
undefined(标记为未完成)。 - 如果它不存在(项目未完成),则将其设置为当前时间戳
Date.now()(标记为完成)。
- 如果它已存在(项目已完成),则将其设置为
- 如果
id不匹配,直接返回原项目。
const newShoppingList = shoppingList.map((item) => { if (item.id === id) { return { ...item, completedAtTimestamp: item.completedAtTimestamp ? undefined : Date.now(), }; } return item; });- 使用
setShoppingList更新状态。
- 创建
- 第四步:动态更新 UI
- 在主屏幕渲染
ShoppingListItem时,根据completedAtTimestamp是否存在来动态设置isCompleted属性。
<ShoppingListItem // ... other props isCompleted={Boolean(item.completedAtTimestamp)} onToggleComplete={() => handleToggleComplete(item.id)} /> - 在主屏幕渲染
- 第一步:让整个列表项可点击
18-ordering-sorting
- 目标:优化 UI,添加完成状态图标,并根据更新时间和完成状态对列表进行排序。
- UI 优化:添加状态图标
- 第一步:添加图标
- 在
ShoppingListItem组件中,导入Entypo图标库。 - 在项目名称旁边渲染一个图标。
- 在
- 第二步:调整布局
- 默认情况下,图标和文本会因为父容器的
justifyContent: 'space-between'而分开。 - 将文本和图标包裹在一个新的
<View>中,并为这个新的<View>设置flexDirection: 'row'和gap,使它们并排显示。
- 默认情况下,图标和文本会因为父容器的
- 第三步:处理长文本溢出
- 为防止长文本破坏布局,给包含文本和图标的
<View>设置flex: 1。 - 给文本组件
<Text>本身也设置flex: 1,使其填充可用空间。 - 在
<Text>组件上使用numberOfLines={1}属性,可以使超出单行的文本被截断并显示省略号。
- 为防止长文本破坏布局,给包含文本和图标的
- 第四步:根据状态切换图标和颜色
- 使用三元运算符根据
isCompleted属性来决定显示哪个图标。 - 已完成:显示
check图标,颜色为灰色。 - 未完成:显示
circle图标,颜色为主色调 (cerulean)。
- 使用三元运算符根据
- 第一步:添加图标
- 实现列表排序
- 目标排序逻辑:
- 未完成的项目始终排在已完成的项目之前。
- 在各自的分组(未完成/已完成)内,最近更新(添加或切换状态)的项目排在最前面。
- 第一步:添加
lastUpdatedTimestamp- 在
ShoppingListItem的类型定义中,添加一个必需的lastUpdatedTimestamp属性,类型为number。 - 在
handleSubmit(添加新项目) 和handleToggleComplete(切换状态) 函数中,每次更新或创建项目时,都将lastUpdatedTimestamp设置为Date.now()。
- 在
- 第二步:创建排序函数
orderShoppingList- 该函数使用 JavaScript 的
Array.prototype.sort()方法。 sort()方法接收一个比较函数(a, b),并根据返回值决定排序:> 0:b排在a前面。< 0:a排在b前面。0:保持原始顺序。
- 排序逻辑实现:
- 比较完成状态:
- 如果
a未完成,b已完成,则a应该在前(返回负数)。 - 如果
a已完成,b未完成,则b应该在前(返回正数)。
- 如果
- 比较时间戳(如果完成状态相同):
- 无论两者是都完成还是都未完成,都用
b的lastUpdatedTimestamp减去a的lastUpdatedTimestamp。 - 这样时间戳较大的(即最新的)项目会得到一个正数结果,从而排在前面。
- 无论两者是都完成还是都未完成,都用
- 比较完成状态:
- 该函数使用 JavaScript 的
- 第三步:应用排序
- 在
FlatList的data属性中,将shoppingList数组用orderShoppingList函数包裹起来,确保每次渲染前都进行排序。
<FlatList data={orderShoppingList(shoppingList)} // ... other props /> - 在
- 效果:
- 标记一个项目为完成,它会动画移动到列表底部。
- 取消标记,它会动画移动到列表顶部。
- 新添加的项目也会出现在列表顶部。
- 目标排序逻辑:
19-data-persistence
-
目标:使用
AsyncStorage实现数据的本地持久化,使得应用关闭或刷新后购物清单数据不会丢失。 -
问题:当前购物清单数据存储在组件的
state中,属于内存存储,应用重启后会重置。 -
解决方案:使用
AsyncStorage,它是 React Native 的本地键值存储系统,类似于 Web 的localStorage,但是它是异步的。 -
实施步骤
-
第一步:安装
AsyncStoragenpx expo install @react-native-async-storage/async-storage -
第二步:创建存储工具函数 (
utils/storage.ts)- 创建两个核心的异步函数:
saveToStorage和getFromStorage。 saveToStorage(key, data):- 接收一个
key(字符串) 和要存储的data(任意可序列化对象)。 - 使用
JSON.stringify(data)将数据转换为字符串,因为AsyncStorage只能存储字符串。 - 调用
await AsyncStorage.setItem(key, stringifiedData)来保存数据。
- 接收一个
getFromStorage(key):- 接收一个
key(字符串)。 - 调用
await AsyncStorage.getItem(key)获取存储的字符串数据。 - 如果获取到数据,使用
JSON.parse(data)将其解析回 JavaScript 对象。 - 重要:将
JSON.parse包裹在try...catch块中,以防止因数据格式错误导致应用崩溃。如果解析失败,返回null。 - 如果没有获取到数据,也返回
null。
- 接收一个
- 创建两个核心的异步函数:
-
第三步:在主屏幕 (
index.tsx) 中集成存储功能-
加载初始数据:
- 使用
useEffectHook,并传入一个空依赖数组[],使其仅在组件首次加载时运行一次。 - 由于
useEffect的回调函数本身不能是async的,所以在其内部定义一个async函数(如fetchInitialData)并立即调用它。
useEffect(() => { const fetchInitialData = async () => { const data = await getFromStorage(storageKey); if (data) { setShoppingList(data); } }; fetchInitialData(); }, []);- 在这个函数中,调用
getFromStorage。如果成功获取到数据,就用setShoppingList来更新组件状态。
- 使用
-
保存更新后的数据:
- 在所有会修改
shoppingList状态的地方(handleSubmit,handleDelete,handleToggleComplete),在调用setShoppingList之后,紧接着调用saveToStorage,将最新的列表数据保存起来。 - 注意:传递给
saveToStorage的应该是更新后的列表,而不是更新前的状态。
// 例如,在 handleDelete 中: const newShoppingList = shoppingList.filter(...); setShoppingList(newShoppingList); saveToStorage(storageKey, newShoppingList); - 在所有会修改
-
-
-
最终效果:现在添加、删除或修改购物清单项目后,即使重新加载应用,数据也会被恢复。
20-layout-animation
-
目标:为列表项的添加、删除和排序添加平滑的动画效果。
-
动画简介
- React Native 中有多种动画实现方式。
- 高级动画通常使用
Reanimated和Gesture Handler库,代码较复杂。 - 对于简单的、应用到整个布局变化的动画,可以使用 React Native 内置的
LayoutAnimationAPI。
-
LayoutAnimation工作原理- 它能自动地将 UI 从上一个状态平滑地过渡到下一个状态。
- 你不需要手动定义动画的起始值和结束值,系统会自动处理。
- 使用非常简单:在即将触发 UI 更新的状态变更之前调用它。
-
实现步骤
-
导入
LayoutAnimationimport { LayoutAnimation } from "react-native"; -
调用
LayoutAnimation.configureNext()- 在所有调用
setShoppingList的函数中(例如handleSubmit,handleDelete,handleToggleComplete),在setShoppingList这行代码的正上方添加LayoutAnimation.configureNext()。 configureNext接收一个预设配置,最常用的是easeInEaseOut,它提供了缓入缓出的动画效果。
// 在 handleDelete 函数中 const handleDelete = (id: string) => { LayoutAnimation.configureNext(LayoutAnimation.presets.easeInEaseOut); const newShoppingList = shoppingList.filter(...); setShoppingList(newShoppingList); // ... }; - 在所有调用
-
应用到所有状态更新
- 将
LayoutAnimation.configureNext(...)添加到所有改变shoppingList状态的地方,确保所有列表变化都有动画。
- 将
-
-
核心要点
LayoutAnimation.configureNext()指示 React Native 为下一次的 UI 渲染(re-render)应用动画。- 它影响的是下一次组件重绘时的布局变化,而不是紧随其后的下一行代码。React 的状态更新可能是批处理的,
LayoutAnimation会作用于该批处理更新后的整体 UI 变化。
-
最终效果
- 当删除一个项目时,它会平滑地消失,下面的项目会平滑地上移。
- 当一个项目因为状态改变而重新排序时(如标记为完成),它会平滑地移动到列表的新位置。
21-haptics
-
目标:通过添加触觉反馈(Haptic Feedback)来增强用户与应用交互的体验。
-
什么是触觉反馈?
- 它是指设备(如手机)通过轻微的振动来响应用户的触摸操作。
- 这是一种在 Web 应用中没有,但在移动应用中很常见的体验增强方式。
- 使用时应有节制,以避免过度干扰用户。
-
实施步骤
-
安装
expo-haptics库npx expo install expo-haptics -
导入库
- 通常使用通配符导入,方便调用其下的各种方法和类型。
import * as Haptics from "expo-haptics"; -
了解主要的反馈类型
impactAsync: 产生一次冲击感振动,有不同强度 (Light,Medium,Heavy)。notificationAsync: 用于通知场景,有不同类型 (Success,Warning,Error),通常比impact更明显。selectionAsync: 用于选择操作时的轻微反馈。
-
-
在应用中添加触觉反馈
-
删除项目时 (
handleDelete):- 目标:提供中等强度的反馈。
- 在
handleDelete函数中,调用Haptics.impactAsync,并传入Medium强度。
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); -
切换完成状态时 (
handleToggleComplete):- 目标:区分“完成”和“取消完成”两种操作。
- 在
handleToggleComplete函数中,找到正在被切换状态的项目。 - 标记为未完成(item 之前是 completed):提供中等强度的冲击反馈。
if (item.completedAtTimestamp) { // If it was previously completed await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); }- 标记为已完成(item 之前是 incomplete):提供一次“成功”通知类型的反馈,这种反馈通常更强,更具提示性。
else { // If it's being marked as complete await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }
-
-
重要提示
- 触觉反馈无法在模拟器上体验,必须在真实的物理设备上进行测试。
- 不同平台(iOS/Android)和不同设备型号的振动反馈强度和感觉可能会有所不同。
22-push-notifications
-
目标:学习如何在应用中集成推送通知功能,重点是请求用户权限。
-
推送通知(Push Notifications)概述
- 远程通知 (Remote):由服务器发送,需要复杂的后端设置(如注册设备 token)。
- 本地通知 (Local):由应用本身在设备上预定和触发,无需服务器。本次课程主要实践本地通知。
-
核心概念:请求权限
- 在发送通知之前,必须首先征得用户的同意。
- 重要:应用只能向用户请求一次原生权限弹窗。一旦用户选择“不允许”,应用就无法再次弹出该请求。用户必须手动去设备的“设置”中为该应用开启通知权限。
-
实施步骤
-
安装所需库
npx expo install expo-notifications expo-deviceexpo-notifications: 用于处理通知的创建、发送和接收。expo-device: 用于检测应用是否运行在真实设备上(因为通知在模拟器上不起作用)。
-
创建权限请求工具函数 (
utils/registerForPushNotificationsAsync.ts)- 这是一个封装了请求权限复杂逻辑的辅助函数。
- 主要逻辑:
a. 使用
Device.isDevice检查是否为真机,如果不是则直接返回,避免在模拟器上操作。 b. 获取当前的权限状态 (granted,undetermined,denied)。 c. 如果状态已经是granted或denied,则无需再次请求,直接返回。 d. 如果状态是undetermined(从未请求过),则调用Notifications.requestPermissionsAsync(),这会触发系统的原生权限请求弹窗。 e. 包含 Android 平台特有的“渠道(Channel)”设置代码,这是高版本 Android 系统的要求。
-
在 UI 中添加触发按钮
- 在一个页面(例如 Counter 页面)上,添加一个“Request Permission”按钮 (
TouchableOpacity)。
- 在一个页面(例如 Counter 页面)上,添加一个“Request Permission”按钮 (
-
实现按钮的
onPress事件处理函数- 创建一个
async函数handleRequestPermission。 - 在函数内部,
await调用刚才创建的registerForPushNotificationsAsync()工具函数。 - 可以通过
console.log打印返回结果,查看权限状态。
- 创建一个
-
在真实设备上测试
- 在 iOS 模拟器上运行,函数会因为
Device.isDevice检查而直接返回null。 - 在安卓或 iOS 真机上运行时:
- 首次点击按钮,会弹出系统权限请求弹窗。
- 如果用户点击“允许”,后续调用该函数将返回
granted状态。 - 如果用户点击“不允许”,后续调用该函数将返回
denied状态,并且不会再有弹窗。
- 在 iOS 模拟器上运行,函数会因为
-
如何重置权限(供测试)
- 如果用户拒绝了权限,开发者无法通过代码再次请求。用户需要手动操作:
- iOS: 进入“设置”->“通知”,找到
Expo Go应用,然后打开通知开关。 - Android: 类似地在应用设置中找到通知权限并开启。
- iOS: 进入“设置”->“通知”,找到
- 如果用户拒绝了权限,开发者无法通过代码再次请求。用户需要手动操作:
-
23-scheduling-notifications
- 目标:在获得用户授权后,实际安排并发送一个本地推送通知。
- 实施步骤
- 修改权限请求逻辑为安排通知
- 将之前的
handleRequestPermission函数重命名为scheduleNotification。 - 在这个函数里,首先调用
registerForPushNotificationsAsync()来确保权限已获取。
- 将之前的
- 检查权限结果并发送通知
- 判断
registerForPushNotificationsAsync()的返回结果。 - 如果权限是 'granted':
- 导入
expo-notifications库:import * as Notifications from 'expo-notifications'; - 调用
await Notifications.scheduleNotificationAsync({...})来安排一个通知。 - 这个函数需要一个配置对象,包含两个主要部分:
content: 通知的显示内容,如title和body。trigger: 触发通知的条件。最简单的用法是设置一个秒数,例如{ seconds: 5 },表示在 5 秒后触发。也可以设置为特定日期、时间间隔等。
- 导入
- 如果权限未被授予:
- 可以弹出一个
Alert提示用户“无法安排通知,请在系统设置中为 Expo Go 开启通知权限”。 - 最好加上
if (Device.isDevice)的判断,避免在模拟器上弹出这个提示。
- 可以弹出一个
- 判断
- 测试通知
- 前景通知 vs. 背景通知:
- 前景 (Foreground): 当应用正打开并显示在屏幕上时,收到的通知默认不会在系统的通知栏中显示。需要额外代码来处理前景通知的显示。
- 背景 (Background): 当应用被最小化或关闭时,收到的通知会正常显示在系统的通知栏中。
- 测试流程: a. 在真机上运行应用。 b. 点击“Schedule Notification”按钮。 c. 立即将应用切换到后台(返回主屏幕)。 d. 等待设定的时间(例如 5 秒),系统通知栏中应出现你设置的通知。 e. 点击通知,可以重新打开应用。
- 前景通知 vs. 背景通知:
- 修改权限请求逻辑为安排通知
- 相关讨论:应用密钥文件(如
GoogleServices.json)与代码仓库- 问题: 这类包含项目特定标识符的文件是否应该放入
.gitignore? - 观点:
- 安全性: 这些文件被打包进你的应用二进制文件中,最终会分发到用户的设备上。任何能够解包应用的人都可以看到这些内容。因此,它们本质上不属于“后端机密”(Server-side Secrets)。不应在其中存放真正的 API 密钥(如 OpenAI Key)。
- 实践: 很多团队仍然会因为 GitHub 的密钥扫描警告或团队规范而将它们移出 Git 仓库。
- 解决方案:
- 对于真正的机密信息,应将其存放在服务器上,由应用通过 API 调用来间接使用。
- 对于
GoogleServices.json这类文件,如果不想提交到 Git,可以使用 EAS (Expo Application Services) 提供的eas secrets功能,在构建时安全地注入这些文件。Expo 官方文档有相关示例。
- 问题: 这类包含项目特定标识符的文件是否应该放入
24-creating-a-timer
1. 目标:创建倒计时提醒功能
- 我们将构建一个“计数器”或提醒功能,用于提醒用户定期执行某项任务(如换床单、洗车)。
- UI 将显示一个倒计时(天/小时/分钟/秒)。
- 当任务逾期时,背景会变成醒目的红色。
- 用户可以点击按钮表示“已完成”,这会重置倒计时并安排一个新的未来通知。
2. 实现每秒更新的 UI
-
为了让 UI 每秒钟自动更新,我们需要结合使用
useState和useEffect。 -
基本思路:
- 用
useState来存储一个随时间变化的值(如“已过去的秒数”)。 - 用
useEffect来设置一个定时器 (setInterval),该定时器每秒钟更新一次状态。
- 用
-
useEffect与setInterval的正确用法:- 设置定时器: 在
useEffect的回调函数中调用setInterval。定时器每隔 1000 毫秒(1 秒)执行一次。 - 函数式更新: 在
setInterval内部更新 state 时,使用函数式更新setSeconds(prev => prev + 1)。这可以避免因useEffect的闭包捕获到旧的 state 值而导致的问题。 - 清理定时器: 这是至关重要的一步。
useEffect的回调函数可以返回一个清理函数。这个函数会在组件卸载前被调用。我们必须在这里调用clearInterval来清除定时器,以防止内存泄漏或多个定时器同时运行。
useEffect(() => { // setInterval 返回一个 ID,用于后续清除 const intervalId = setInterval(() => { // 使用函数式更新来确保总是基于最新的 state 进行计算 setSecondsElapsed((currentVal) => currentVal + 1); }, 1000); // 返回一个清理函数 return () => { clearInterval(intervalId); }; }, []); // 空依赖数组意味着这个 effect 只在组件首次挂载时运行一次- 警告: 如果忘记清理定时器,每次组件重新加载(如在开发模式下的快速刷新)都会创建一个新的定时器,导致计数器越跳越快。
- 设置定时器: 在
3. 使用 date-fns 处理日期和时间
-
在需要处理复杂日期计算或格式化的项目中,推荐使用专门的日期库。
-
date-fns是一个非常流行且功能强大的库,它同样适用于 React Native。 -
常用功能:
format: 将日期格式化成指定的字符串。formatDistance: 计算两个日期之间的时间差,并以自然语言描述(如 "12 days ago")。
-
安装:
-
同样建议使用
npx expo install来确保版本兼容性。npx expo install date-fns -
date-fns是一个纯 JS 库,Expo 的兼容性检查系统可能不会将其标记为“SDK compatible”,但这不影响使用。
-
25-tracking-countdown-overdue-time
1. 创建 TimeSegment 可复用组件
- 为了显示倒计时的每一部分(如“10 秒”),我们创建一个名为
TimeSegment的可复用组件。 - 它接收
number和unit作为 props。 - 为了灵活控制样式(如在不同背景下改变文字颜色),它还接收一个可选的
textStyleprop。 - 使用 TypeScript 的
TextStyle类型可以确保传递给textStyleprop 的是一个有效的样式对象。
2. 管理倒计时的状态与逻辑
定义数据结构 (TypeScript)
- 创建一个
CountdownStatus类型来统一管理倒计时的所有状态。
import { Duration } from "date-fns";
type CountdownStatus = {
isOverdue: boolean;
distance: Duration; // Duration 是 date-fns 提供的类型
};
Duration对象包含years,months,days,hours,minutes,seconds等属性。
使用 useState 存储状态
- 使用
useState<CountdownStatus>(...)来存储整个状态对象。
在 useEffect 中计算时间差
- 之前的
setInterval逻辑保持不变,但其内部的计算逻辑需要更新。 - 使用
date-fns进行核心计算:- 判断是否逾期: 使用
isBefore(dueDate, Date.now())。如果截止日期在当前时间之前,则为逾期。 - 计算时间差: 使用
intervalToDuration({ start, end })。- 注意:
start日期必须早于end日期。因此需要根据是否逾期来动态调整start和end的值。
- 注意:
- 判断是否逾期: 使用
- 示例逻辑:
// 假设 timestamp 是截止日期的时间戳
const isOverdue = isBefore(timestamp, Date.now());
const distance = intervalToDuration({
start: isOverdue ? timestamp : Date.now(),
end: isOverdue ? Date.now() : timestamp,
});
// 更新状态
setStatus({ isOverdue, distance });
- 为了方便测试,可以先硬编码一个
timestamp,例如Date.now() + 10 * 1000(10 秒后)。
26-countdown-timer-ui
1. 条件渲染文本
- 根据
status.isOverdue的值,使用三元运算符来显示不同的提示文本。
<Text style={styles.heading}>
{status.isOverdue ? "Thing overdue by" : "Thing due in"}
</Text>
2. 渲染倒计时分段
- 使用之前创建的
<TimeSegment>组件来渲染倒计时的天、小时、分钟和秒。 - 将它们包裹在一个
<View>中,并使用flexDirection: 'row'使其水平排列。
<View style={styles.row}>
<TimeSegment unit="days" number={status.distance.days ?? 0} />
<TimeSegment unit="hours" number={status.distance.hours ?? 0} />
{/* ...minutes and seconds */}
</View>
- 使用空值合并操作符 (
?? 0) 来处理status.distance中可能为undefined的值。
3. 条件应用样式
- 这个模式我们已经很熟悉了:利用样式数组来动态添加或移除样式。
改变背景颜色
- 在容器
<View>的style属性中,根据status.isOverdue来决定是否添加红色背景的样式。
<View style={[styles.container, status.isOverdue && styles.containerLate]}>
{/* ... */}
</View>
改变文本颜色
- 在红色背景下,文本需要变成白色才能看清。
- 之前为
<TimeSegment>和标题文本预留了textStyle属性,现在正好派上用场。 - 创建一个
styles.whiteText样式,其中color: 'white'。 - 根据
status.isOverdue动态地传递这个样式。
// 用于标题
<Text style={[styles.heading, status.isOverdue && styles.whiteText]}>
...
</Text>
// 用于 TimeSegment
<TimeSegment
// ...
textStyle={status.isOverdue ? styles.whiteText : undefined}
/>
27-persisting-countdown-state
1. 目标:持久化倒计时状态
- 当前倒计时状态只存在于内存中,应用关闭后就会丢失。
- 我们需要使用
AsyncStorage来保存状态,以便在应用重新打开时恢复。
2. 定义持久化数据结构
- 定义一个
PersistedCountdownState类型,专门用于描述需要存入AsyncStorage的数据。 currentNotificationId: 存储当前已安排的通知 ID。这非常重要,因为当用户提前完成任务时,我们需要用这个 ID 来取消旧的通知,避免不必要的提醒。completedAtTimestamps: 一个时间戳数组,记录用户历次完成任务的时间,用于历史记录页面。
type PersistedCountdownState = {
currentNotificationId: string | undefined;
completedAtTimestamps: number[];
};
3. 状态管理流程
- 创建 State:
const [countdownState, setCountdownState] = useState<PersistedCountdownState | undefined>(undefined);- 初始值为
undefined,表示数据尚未从存储中加载。
- 加载状态:
- 使用一个单独的
useEffect,在组件挂载时从AsyncStorage中异步读取数据,并用setCountdownState更新状态。
- 使用一个单独的
- 计算截止时间:
- 修改原有的
useEffect,使其依赖于countdownState。 - 截止时间 (
timestamp) 现在根据countdownState中的completedAtTimestamps的最新一项来计算,再加上任务频率(例如 10 秒)。 - 如果从未完成过,则使用
Date.now()作为基准。
- 修改原有的
- 更新和保存状态 (完整流程):
- 当用户点击“我已完成”按钮时,触发
handlePress函数: - a. (可选) 取消旧通知: 检查
countdownState.currentNotificationId是否存在。如果存在,调用Notifications.cancelScheduledNotificationAsync()取消旧的通知。 - b. 安排新通知: 调用
scheduleNotification()安排一个新的通知,并获取其notificationId。 - c. 构建新状态: 创建一个新的
PersistedCountdownState对象。- 更新
currentNotificationId为新获取的 ID。 - 在
completedAtTimestamps数组的开头添加当前的时间戳 (Date.now())。
- 更新
- d. 更新内存状态: 调用
setCountdownState()更新组件内的状态,触发 UI 立即响应。 - e. 保存到存储: 调用
saveToStorage()将新的状态对象异步写入AsyncStorage。
- 当用户点击“我已完成”按钮时,触发
5. useEffect 的依赖项
- 用于计算时间差的
useEffect应该依赖于countdownState中实际影响计算的值,例如lastCompletedTimestamp。 useEffect(..., [lastCompletedTimestamp]);- 当
lastCompletedTimestamp改变时(即用户点击了按钮),这个 effect 会重新运行,先清理旧的setInterval,然后用新的截止时间设置一个新的setInterval。这确保了倒计时逻辑的正确更新。
28-activity-indicator
1. 问题:UI 闪烁 (Flicker)
- 当应用打开时,由于状态是从
AsyncStorage异步加载的,存在一个短暂的延迟。 - 在此期间,组件会先使用其初始状态(例如,
isOverdue: false,白色背景)进行渲染。 - 当异步数据加载完成后,状态更新,UI 才切换到真实状态(例如,
isOverdue: true,红色背景)。 - 这个过程导致了从白屏到红屏的视觉闪烁,影响用户体验。
2. 解决方案:使用加载指示器
- 在数据加载期间,显示一个全屏的加载动画,而不是不完整或不正确的 UI。
- React Native 提供了一个内置组件
<ActivityIndicator>来实现这个效果。
3. 实现步骤
-
添加
isLoading状态:- 在组件中添加一个新的
useState来追踪加载状态。 const [isLoading, setIsLoading] = useState(true);// 默认为 true
- 在组件中添加一个新的
-
更新加载状态:
- 在从
AsyncStorage获取到数据并设置完主要状态后,将加载状态设置为false。 setIsLoading(false);
- 在从
-
条件渲染:
- 在组件的
return语句的开头,检查isLoading状态。 - 如果
isLoading为true,则提前返回加载指示器 UI。 - 否则,返回正常的应用 UI。
if (isLoading) { return ( <View style={styles.activityIndicatorContainer}> <ActivityIndicator size="large" color="#0000ff" /> </View> ); } return ( <View style={...}> {/* 主要的应用界面 */} </View> ); - 在组件的
4. 全屏居中样式
- 一个非常常见的样式组合,用于让一个元素(如此处的加载指示器)在屏幕上全屏并居中。
activityIndicatorContainer: {
flex: 1, // 占据父容器所有可用空间
justifyContent: 'center', // 沿主轴(纵向)居中
alignItems: 'center', // 沿交叉轴(横向)居中
backgroundColor: 'white', // 设置背景色
}
29-storing-timer-history
1. 跨屏幕共享数据
AsyncStorage是一个全局存储,不限于单个屏幕或组件。- 只要使用相同的存储键 (storage key),任何屏幕都可以访问到同一份数据。
- 实践: 将
countdownStorageKey和PersistedCountdownState类型从index.tsx文件中export出来,以便在history.tsx中导入和使用。
2. 在 History 屏幕中加载数据
- 这里的逻辑与在主屏幕中加载数据的逻辑几乎完全相同:
- 用
useState创建一个countdownState来存储从AsyncStorage中读取的数据。 - 用
useEffect在组件挂载时,调用封装好的getFromStorage工具函数,传入共享的countdownStorageKey来获取数据。 - 用
setCountdownState将获取到的数据存入 state。
- 用
3. 使用 FlatList 渲染历史记录
completedAtTimestamps是一个数组,非常适合使用<FlatList>来高效地渲染。data:countdownState?.completedAtTimestamps ?? []renderItem: 接收{ item },这里的item就是一个时间戳数字。ListEmptyComponent: 为历史记录为空的情况提供友好的提示,例如“No history yet”。
4. 使用 date-fns 格式化时间戳
- 从存储中读取的时间戳是原始的数字格式,对用户不友好。
- 使用
date-fns的format函数可以将其转换为人类可读的日期和时间字符串。 - 用法:
import { format } from 'date-fns';format(timestamp, 'PPPpp')- 第一个参数是日期对象或时间戳。
- 第二个参数是格式化字符串。
date-fns提供了许多预设的格式,PPPpp是一个例子,可以生成类似 "Jul 21, 2021, 9:28 PM" 的格式。
// 在 renderItem 中
<Text>{format(item, "PPPpp")}</Text>
30-confetti-haptics
1. 目标:增加应用的“愉悦感”
- 在用户完成任务(点击按钮)时,通过触觉反馈和视觉效果(彩带)来提供积极的强化,提升用户体验。
2. 添加触觉反馈 (Haptics)
- 再次使用
expo-haptics库。 - 在
handlePress(或scheduleNotification) 函数中,调用触觉反馈。 - 这次使用
NotificationFeedbackType.Success,它会产生一个表示“成功”的、更强烈的震动效果。
import * as Haptics from "expo-haptics";
// 在按钮的 onPress 回调中
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
3. 添加彩带效果 (react-native-confetti-cannon)
-
这是一个有趣的第三方库,可以在屏幕上喷射出彩带。
-
安装:
npx expo install react-native-confetti-cannon -
核心概念:使用
useRef编程式触发动画- 彩带动画不应该在页面加载时自动播放,而是在用户点击按钮时触发。
useRef: React 的一个 Hook,可以创建一个可变的ref对象。将其附加到组件上,就可以获得对该组件实例的直接引用。- 步骤:
- 创建 ref:
const confettiRef = useRef(null); - 将 ref 绑定到组件:
<ConfettiCannon ref={confettiRef} ... /> - 在事件处理函数中调用组件方法:
confettiRef.current?.start();
- 创建 ref:
4. 动态定位彩带
- 问题: 不同设备的屏幕尺寸不同,硬编码彩带的发射位置(如
x: 100)会导致在不同设备上位置不一致。 - 解决方案: 使用 React Native 的
DimensionsAPI 获取当前设备的屏幕尺寸。 - 更好的方案:
useWindowDimensionsHookimport { useWindowDimensions } from 'react-native';const { width } = useWindowDimensions();- 优势: 与
Dimensions.get()只在应用启动时获取一次不同,useWindowDimensionsHook 会在屏幕尺寸变化(如设备旋转)时自动更新,使布局更具响应性。
- 实现: 将彩带的发射原点
origin设置为屏幕中心。<ConfettiCannon // ... origin={{ x: width / 2, y: -10 }} // y 设为负值,让彩带从屏幕顶部外发射 />
5. 其他配置
count: 彩带数量。fadeOut: 彩带落下后是否淡出。autoStart={false}: 禁用自动播放,以便我们通过ref来控制。
31-wrapping-up
课程回顾
本课程从零开始,带领我们构建了一个功能完整的 React Native 应用,涵盖了移动开发中的众多核心概念和技术:
- 基础组件:
<View>,<Text>,<ScrollView>, 以及高性能的<FlatList>。 - 样式: 学习了如何组织样式,并进行条件化应用。
- 导航: 使用 Expo Router 实现了:
- 底部标签页导航 (
Tabs) - 堆栈导航 (
Stack) - 模态框 (
Modal)
- 底部标签页导航 (
- 数据持久化: 使用
AsyncStorage在设备上保存和读取数据。 - 用户交互与反馈:
- 通过
<TextInput>接收用户输入。 - 使用
expo-haptics提供触觉反馈。 - 使用
expo-notifications发送本地推送通知。
- 通过
- 增加愉悦感: 通过添加彩带动画等视觉效果提升用户体验。
后续学习
- 课程有意在第三个屏幕留白,鼓励学员利用所学知识,动手实践,构建一个自己想要的实用小工具。
- 这为从入门到实践提供了一个很好的起点。