ReacteNative-示例-全-
ReacteNative 示例(全)
原文:
zh.annas-archive.org/md5/6c72813d39d3c6a9eb1e81035f8a1a7b译者:飞龙
前言
React Native 是一个非常强大的框架,它使得以网络为中心的程序员能够更容易地在多个平台上进行开发。在这本书中,你将学习如何使用 React Native 构建移动应用,这些应用可以部署到 iOS App Store 和 Google Play。
本书涵盖的内容
第一章,第一个项目 - 创建基本待办事项应用,开始了使用 React Native 编写待办事项应用的过程。你将规划该应用并了解 StyleSheet、Flexbox 和 ES6 的概览。你还将使用 React Native SDK 的四个不同部分来创建应用的构建块。
第二章,待办事项应用的高级功能,深入探讨了第一章中开始构建的应用。你将学习如何处理导航、日期和时间选择、构建按钮,并为应用创建一个自定义可折叠和动画组件。你还将将这些课程内容转化为应用的 Android 版本。
第三章,第二个项目 - 预算应用,将开始本书的第二个项目。你将规划一个支出跟踪应用,为 React Native 安装第三方矢量图标库,创建可以在整个应用中使用的工具文件,并创建一个模态组件。
第四章,预算应用的高级功能,是第二个项目的延续。你将学习如何为用户创建一个类似下拉组件,以便从项目列表中进行选择,并为应用创建标签导航。
第五章,第三个项目 - Facebook 客户端,将开始本书的第三个也是最后一个项目。你将规划一个连接到第三方 Facebook SDK 的应用,将 SDK 安装到你的项目中,允许用户使用 Facebook 凭证登录,然后进行信息请求。
第六章,使用 Facebook 客户端的高级功能,总结了上一章开始的项目。你将学习如何为应用构建下拉刷新机制,为用户渲染图片,允许用户在不离开应用的情况下打开链接,然后使用这些课程内容制作应用的 Android 版本。
第七章,添加 Redux,介绍了流行的 Redux 架构。你将学习如何将第二章中的待办事项应用转换为支持 Redux 原则的应用。
第八章,部署你的应用,展示了如何打包、上传并使你的应用可在 Apple iOS 应用商店和 Google Play 商店下载。你还将获得一些创建应用标志和截图的技巧,以及如何启动应用的 beta 测试。
第九章,额外的 React Native 组件,深入探讨了我们在本书其他部分未能涵盖的 React Native SDK 的部分。在其中,你将构建一个游乐场风格的 app,学习 SDK 的不同部分。你将从任何第三方端点获取数据,控制用户的振动马达,通过你的 app 中的链接打开其他已安装的应用,等等。你还将学习如何将第四章中的预算应用转换为 Android,因为那个章节的空间有限。
你需要这本书什么
在硬件方面,你需要一台 Mac 来使用这本书。本书的内容以 iOS 为主,要开发 iOS 应用,你必须拥有苹果电脑。可选地,iOS 和 Android 设备对于在设备上测试应用会有所帮助,但不是必需的。本书的最后一章有一个 API 需要物理设备来测试(振动),另一个 API 在物理设备上测试会更简单(链接)。
你需要为你的 Mac 安装 React Native SDK。安装说明可以在facebook.github.io/react-native/docs/getting-started.html找到。安装 React Native SDK 的先决条件可以在该页面上找到。
安装 Xcode 和 Android Studio 的说明也可以在该页面上找到,用于在你的机器上安装 React Native SDK。
这本书面向的对象
如果你热衷于学习使用革命性的移动开发工具 React Native 来构建原生移动应用,那么这本书就是为你准备的。具备 JavaScript 的经验会有所帮助。
习惯用法
在这本书中,你会找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"基于这种布局,我们可以看到我们应用的 iOS 版本入口点是index.ios.js,并且生成了一个特定的iOS文件夹(以及相应的Android文件夹)。"
代码块设置如下:
class Tasks extends Component {
render () {
return (
<View style = {{ flex: 1, justifyContent: 'center',
alignItems: 'center', backgroundColor: '#F5FCFF'
}}>
<Text style = {{ fontSize: 20, textAlign:
'center', margin: 10 }}>
Welcome to React Native!
</Text>
</View>
)
}
}
任何命令行输入或输出如下所示:
react-native init Tasks
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"当你打开开发者菜单时,你会看到以下选项。"
警告或重要注意事项以如下框的形式出现。
小贴士和技巧看起来像这样。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要发送一般反馈,请简单地将电子邮件发送到feedback@packtpub.com,并在邮件主题中提及本书的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些东西可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的“支持”标签上。
-
点击“代码下载与勘误”。
-
在搜索框中输入本书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买本书的地方。
-
点击“代码下载”。
文件下载完成后,请确保使用最新版本的软件解压缩或提取文件夹:
-
Windows 版的 WinRAR / 7-Zip
-
Mac 版的 Zipeg / iZip / UnRarX
-
Linux 版的 7-Zip / PeaZip
本书代码包也托管在 GitHub 上github.com/PacktPublishing/React-Native-By-Example。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们吧!
下载本书的颜色图像。
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/ReactNativeByExample_ColorImages.pdf下载此文件。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
在互联网上,版权材料盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过copyright@packtpub.com与我们联系,并附上涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面所提供的帮助。
询问
如果您对本书的任何方面有问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。
第一章:第一个项目 - 创建基本待办事项应用程序
在前言中我们已经为 React Native 开发设置了环境,现在让我们开始开发应用程序。在整个本书中,我将使用我最初开始的项目名称——Tasks来指代这个应用程序。在本章中,我们将涵盖以下主题:
-
规划待办事项应用程序应具备的功能
-
基本项目架构
-
介绍
StyleSheet,这是 React Native 用于处理样式的组件 -
Flexbox 概述,这是一种受 CSS 启发的布局模式,用于在 React Native 中进行样式设计
-
熟悉 ES6,我们将使用的新 JavaScript 语法
-
使用
TextInput、ListView、AsyncStorage、Input、状态和属性创建Tasks的构建块 -
了解 iOS 模拟器的开发者菜单,这有助于我们在编写应用程序时
初始化新项目
由于已经安装了 React Native SDK,因此初始化新的 React Native 项目就像使用以下命令行一样简单:
react-native init Tasks
让 React Native 命令行界面工作一会儿,然后完成后再打开名为Tasks的目录。
从那里开始,在 iOS 模拟器中运行您的应用程序就像输入以下命令一样简单:
react-native run-ios
这将启动构建和编译您的 React Native 应用程序的过程,启动 iOS 模拟器,将应用程序导入模拟器,并启动它。每次您对应用程序进行更改时,您都可以立即重新加载并看到这些更改。
功能规划
在编写任何代码之前,我想花时间规划我在项目中的目标,并确定一个最小可行产品(MVP)作为构建任何高级功能之前的目标。这有助于确定我们应用程序的关键组件,以便我们有一个可以运行的原型。
对我来说,最小可行产品(MVP)是一种将我的想法量化为可以互动并用于验证任何假设或捕捉任何边缘情况的方法,同时将所需的时间降到最低。以下是我如何进行功能规划:
-
我正在构建的产品做什么?
-
理想情况下,有哪些突出特点使这个应用程序脱颖而出?
-
在前面的列表中,哪些功能是构建一个工作产品所必需的?一旦你知道了必要的功能,就删除所有不提供基本功能的东西。
-
考虑其设计,但暂时不要对每个细节都过于纠结。
带着这些意图,以下是我想到的:
-
这是一个让我能够创建和跟踪任务列表的应用程序。这些可以小到购物清单,也可以大到长期目标。
-
我希望为每个独特的任务设置一个提醒,这样我就可以有序地完成每个任务。理想情况下,列表中的项目可以按类别分组。类别分组可能可以通过某种像图标这样的东西来简化。这样,我也可以通过图标对列表进行排序和筛选。
-
从一开始,唯一必要的事情是,我可以用一个文本输入字段来输入任务,将其渲染到项目列表中,并在完成时标记它们;其他所有事情都是次要的。
现在我们对应用有了更清晰的了解,让我们分解一些我们可以采取的具体步骤来实现它:
-
让我们生成一个默认项列表。这些项不需要手动输入,因为我们只想在应用本身中看到我们的列表被填充。
-
之后,你的用户应该能够使用文本字段和原生键盘输入他们自己的任务。
-
接下来,我想使那个列表可滚动,以防我的任务列表超过了整个垂直屏幕的高度。
-
然后,我们应该通过某种视觉指示器让项目标记为完成。
就这些了!这是我们目前拥有的四个目标。正如我之前提到的,其他所有事情目前都是次要的。现在,我们只想尽快推出一个最小可行产品(MVP),然后我们将在之后根据我们的意愿对其进行调整。
让我们继续前进,开始思考架构。
项目架构
下一个重要的事情是我想要解决的问题是架构;这是关于我们的 React Native 应用如何布局的问题。虽然我们为这本书构建的项目旨在单独完成,但我坚信,始终以期望下一个人查看它时是一个脾气暴躁的斧头杀手的方式来编写和架构代码是很重要的。这里的想法是使任何人都能查看你的应用程序的结构,并能够跟随。
首先,让我们看看 React Native CLI 是如何构建我们的项目的;每个相关文件的注释都记在双斜杠(//)的右侧:
|Tasks // root folder
|__Android*
|__ios*
|__node_modules
|__.buckconfig
|__.flowconfig
|__.gitignore
|__.watchmanconfig
|__index.android.js // Android entry point
|__index.ios.js // iOS entry point
|__package.json // npm package list
Android和iOS文件夹将深入几层,但这都是其构建过程的一部分,我们目前不需要担心这一点。
根据这个布局,我们可以看到我们应用的 iOS 版本入口是index.ios.js,并且生成了一个特定的iOS文件夹(以及相应的Android文件夹)。
而不是使用这些特定平台的文件夹来存储仅适用于一个平台的组件,我建议在这些文件夹旁边创建一个名为app的文件夹,它将封装我们编写的所有逻辑。
在这个app文件夹内,我们将有包含我们的组件和资源的子文件夹。对于组件,我希望将它的样式表与其 JS 逻辑耦合在其自己的文件夹中。
此外,组件文件夹不应该嵌套,否则会变得非常难以跟踪和搜索。相反,我更喜欢使用一种命名约定,使一个组件与其父/子/兄弟的关系立即显而易见。
这就是我的建议结构将看起来:
|Tasks
|__app
|____components
|______TasksList
|________index.js
|________styles.js
|______TasksListCell
|________index.js
|________styles.js
|______TasksListInput
|________index.js
|________styles.js
|____images
|__Android
|__ios
|__node_modules
|__.buckconfig
|__.flowconfig
|__.gitignore
|__.watchmanconfig
|__index.android.js
|__index.ios.js
|__package.json
只从快速观察中,你可能能够推断出TasksList是处理屏幕上显示的任务列表的组件。TasksListCell将是列表中的每一行,而TasksListInput将处理键盘输入字段。
这非常基础,我们可以进行一些优化。例如,我们可以考虑 iOS 和 Android 的平台特定扩展,以及为 Redux 构建更进一步的架构;但出于这个特定应用的目的,我们只需从基础开始。
样式表
React Native 的核心视觉组件接受一个名为style的属性,其名称和值与 CSS 的命名约定大致相同,但有一个主要例外——kebab-case 被替换为 camelCase,这与 JavaScript 中的命名方式相似。例如,CSS 属性background-color在 React Native 中会转换为backgroundColor。
为了可读性和重用,将内联样式拆分到自己的styles对象中是有益的,通过使用 React Native 的StyleSheet组件定义所有样式到styles对象中,并在组件的render方法中引用它。
再进一步,对于大型应用,最好将样式表分离到自己的 JavaScript 文件中以提高可读性。让我们看看这些如何比较,使用为我们生成的非常注解的 Hello World 示例。这些示例将只包含使我的观点成立的必要代码。
内联样式
内联样式是在你的代码标记内定义的样式。看看这个示例:
class Tasks extends Component {
render () {
return (
<View style = {{ flex: 1, justifyContent: 'center',
alignItems: 'center', backgroundColor: '#F5FCFF'
}}>
<Text style = {{ fontSize: 20, textAlign:
'center', margin: 10 }}>
Welcome to React Native!
</Text>
</View>
)
}
}
在前面的代码中,你可以看到内联样式如何创建一个非常复杂和混乱的混乱,特别是当我们想要将多个样式属性应用到每个组件上时。在大型应用中,我们这样编写样式并不实用,所以让我们将样式拆分成一个StyleSheet对象。
使用样式表,在同一个文件中
这就是组件如何访问同一文件中创建的StyleSheet:
class Tasks extends Component {
render () {
return (
<View style = { styles.container }>
<Text style = { styles.welcome }>
Welcome to React Native!
</Text>
</View>
)
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF'
},
welcome: {
fontSize: 20,
textAlign: 'center',
margin: 10
}
)};
这样要好得多。我们将样式移动到一个对象中,这样我们就可以引用它,而无需反复重写相同的内联样式。然而,我们面临的问题是文件非常长,包含大量的应用逻辑,未来的维护者可能需要滚动查看一行又一行的代码才能找到样式。我们可以更进一步,将样式分离到它们自己的模块中。
作为导入模块的样式表
在你的组件中,你可以像下面这样导入你的样式:
import styles from './styles.js';
class Tasks extends Component {
render(){
return (
<View style = { styles.container }>
<Text style = { styles.welcome }>
Welcome to React Native!
</Text>
</View>
)
}
}
然后,你可以在一个单独的文件中定义它们:
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF'
},
welcome: {
fontSize: 20,
textAlign: 'center',
margin: 10
}
)};
export default styles;
这要好得多。通过将我们的样式逻辑封装到自己的文件中,我们正在分离我们的关注点,使每个人都能更容易地阅读它。
Flexbox
你可能注意到了我们的 StyleSheet 中有一个名为 flex 的属性。这与 Flexbox 有关,Flexbox 是一种 CSS 布局系统,它可以在不同屏幕尺寸之间提供布局的一致性。React Native 中的 Flexbox 与其 CSS 规范类似,只有一些差异。需要注意的最重要差异是,在 React Native 中,默认的 flex 方向被反转到 column,而在 Web 中是 row,默认将项目对齐到 stretch 属性,而不是浏览器中的 flex-start,React Native 中的 flex 参数只支持单个数字作为其值。
随着我们通过这些项目,我们将深入了解 Flexbox;我们将从查看基础知识开始。
flex
你的布局的 flex 属性在操作上与 CSS 中的操作略有不同。在 React Native 中,它接受一个单个数字。如果这个数字是正数(意味着大于 0),具有此属性的组件将变得灵活。
flexDirection
你的布局也接受一个名为 flexDirection 的属性。这个属性有四个选项:row、row-reverse、column 和 column-reverse。这些选项决定了你的 flex 容器子项的布局方向。
使用 ES6 编写
ECMAScript 版本 6(ES6)是 JavaScript 语言的最新规范。它也被称为 ES2016。它为 JavaScript 带来了新的特性和语法,这些是你在本书中取得成功应该熟悉的内容。
首先,require 语句现在是 import 语句。它们用于从外部模块或脚本中导入函数、对象等。在过去,为了在文件中包含 React,我们会写类似这样的内容:
var React = require('react');
var Component = React.Component;
使用 ES6 的 import 语句,我们可以将其重写为:
import React, { Component } from 'react';
在花括号周围导入 Component 的操作称为解构赋值。这是一种赋值语法,允许我们从数组或对象中提取特定数据到变量中。通过解构赋值导入 Component,我们可以在代码中直接调用 Component;它自动声明为具有相同名称的变量。
接下来,我们将用两个不同的语句替换 var:let 和 const。第一个语句 let 声明了一个块级作用域变量,其值可以被修改。第二个语句 const 声明了一个块级作用域变量,其值不能通过重新赋值或重新声明来改变。
在先前的语法中,导出模块通常使用 module.exports 完成。在 ES6 中,这通过 export default 语句来实现。
构建应用
回到几页前的列表,这是我想在应用中做的第一件事:
- 让我们生成一个默认项的列表。这些项不必手动输入;我们只想看到我们的列表在应用本身中被填充。
ListView
当查看 React Native 组件的文档时,你可能会注意到一个名为ListView的组件。这是一个旨在显示垂直滚动数据列表的核心组件。
下面是如何ListView工作的。我们将创建一个数据源,用数据块数组填充它,创建一个以该数组作为数据源的ListView组件,并在其renderRow回调中传递一些 JSX,该回调将获取数据并为数据源中的每个数据块渲染一行。
从高层次来看,它看起来是这样的:
class TasksList extends Component {
constructor (props) {
super (props);
const ds = new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2 });
this.state = {
dataSource: ds.cloneWithRows(['row 1', 'row 2'])
};
}
render () {
return (
<ListView
dataSource = { this.state.dataSource }
renderRow = { (rowData) => <Text>
{ rowData } </Text> }
/>
);
}
}
让我们看看发生了什么。在我们组件的constructor中,我们创建了一个ListViewDataSource的实例。一个新的ListViewDataSource的构造函数接受一个参数,该参数可以包含以下四个之一:
-
getRowData(dataBlob,sectionID,rowID) -
getSectionHeaderData(dataBlob,sectionID) -
rowHasChanged(previousRowData,nextRowData) -
sectionHeaderHasChanged(previousSectionData,nextSectionData)
getRowData是一个获取渲染行所需数据的函数。你可以按自己的喜好自定义该函数,并将其传递给ListViewDataSource的构造函数,但如果未指定,ListViewDataSource将提供默认值。
getSectionHeaderData是一个函数,它接受一个数据块和一个部分 ID,并返回仅用于渲染部分标题所需的数据。像getRowData一样,如果没有指定,它将提供默认值。
rowHasChanged是一个函数,它作为性能优化设计,仅重新渲染其源数据已更改的任何行。与getRowData和getSectionHeaderData不同,你需要传递自己的rowHasChanged版本。先前的示例,它接受当前和之前的行值并返回一个布尔值以显示它是否已更改,是最常见的实现。
sectionHeaderHasChanged是一个可选函数,它比较部分标题的内容以确定它们是否需要重新渲染。
然后,在我们的TasksView构造函数中,我们的状态接收一个名为dataSource的属性,其值等于调用我们之前创建的ListViewDataSource实例上的cloneWithRows。cloneWithRows接受两个参数:一个dataBlob和一个rowIdentities。dataBlob是传递给它的任何任意数据块,而rowIdentities代表行标识符的二维数组。rowIdentities是一个可选参数——它不包括在先前的示例代码中。我们的示例代码传递了一个硬编码的数据块——两个字符串:'row 1'和'row 2'。
现在也很重要地提到,我们dataSource中的数据是不可变的。如果我们想稍后更改它,我们必须从dataSource中提取信息,对其进行修改,然后替换dataSource中的数据。
在 TasksList 中渲染的 ListView 组件本身可以接受许多不同的属性。其中最重要的一个,我们在我们的示例中使用,是 renderRow。
renderRow 函数从你的 ListView 的 dataSource 中获取数据,并为你的 dataSource 中的每一行数据返回一个要渲染的组件。在我们的前一个例子中,renderRow 从我们的 dataSource 中的每个字符串中获取数据,并在 Text 组件中渲染它。
使用前面的代码,以下是 TasksList 将如何渲染。因为我们还没有给它添加样式,所以你会看到 iOS 状态栏覆盖了第一行:

太好了!没有太多可以看的,但我们已经完成了一些事情:我们创建了一个 ListView 组件,传递了一些数据,并将这些数据渲染到了我们的屏幕上。让我们退后一步,在我们的应用程序中正确地创建这个组件。
创建 TasksList 组件
回到之前提出的文件结构,你的项目应该看起来像这样:

让我们从编写我们的第一个组件——TasksList 模块开始。
我们首先需要做的是导入我们对 React 的依赖:
import React, { Component } from 'react';
然后,我们将从 React Native (react-native) 库中导入我们需要的构建块:
import {
ListView,
Text
} from 'react-native';
现在,让我们编写组件。在 ES6 中创建新组件的语法如下:
export default class TasksList extends Component {
...
}
从这里,让我们给它一个在创建时触发的构造函数:
export default class TasksList extends Component {
constructor (props) {
super (props);
const ds = new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
});
this.state = {
dataSource: ds.cloneWithRows([
'Buy milk',
'Walk the dog',
'Do laundry',
'Write the first chapter of my book'
])
};
}
}
我们的构造函数在 TasksList 的状态中设置一个 dataSource 属性,等于一个硬编码的字符串数组。我们的首要目标仍然是简单地在一个屏幕上渲染一个列表。
接下来,我们将利用 TasksList 组件的 render 方法来完成这个任务:
render () {
return (
<ListView
dataSource={ this.state.dataSource }
renderRow={ (rowData) =>
<Text> { rowData } </Text> }
/>
);
}
合并起来,代码应该看起来像这样:
// Tasks/app/components/TasksList/index.js
import React, { Component } from 'react';
import {
ListView,
Text
} from 'react-native';
export default class TasksList extends Component {
constructor (props) {
super (props);
const ds = new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
});
this.state = {
dataSource: ds.cloneWithRows([
'Buy milk',
'Walk the dog',
'Do laundry',
'Write the first chapter of my book'
])
};
}
render () {
return (
<ListView
dataSource={ this.state.dataSource }
renderRow={ (rowData) =>
<Text>{ rowData }</Text> }
/>
);
}
}
太好了!这应该就足够了。然而,我们需要将这个组件链接到我们应用程序的入口点。让我们跳转到 index.ios.js 并做一些更改。
将 TasksList 链接到 index
我们 iOS 应用程序的入口点是 index.ios.js,它渲染的所有内容都从这里开始。现在,如果你使用 react-native run-ios 命令启动 iOS 模拟器,你将看到我们在前言中熟悉的相同的 Hello World 示例应用程序。
我们现在需要做的是将我们刚刚构建的 TasksList 组件链接到 index,并自动移除所有不必要的 JSX。让我们继续清除 Tasks 组件的 render 方法中的几乎所有内容,除了顶层的 View 容器。当你完成时,它应该看起来像这样:
class Tasks extends Component {
render () {
return (
<View style={styles.container}>
</View>
);
}
}
我们希望在 View 容器中插入 TasksList。然而,在我们这样做之前,我们必须让 index 文件能够访问该组件。让我们使用一个 import 语句来完成:
import TasksList from './app/components/TasksList';
虽然这个 import 语句只是指向我们的 TasksList 组件所在的文件夹,但 React Native 智能地寻找一个名为 index 的文件,并将其分配给我们想要的。
现在TasksList已经可以供我们使用了,让我们将其包含在Tasks的render方法中:
export default class Tasks extends Component {
render () {
return (
<View style={styles.container}>
<TasksList />
</View>
);
}
}
如果你不再运行 iOS 模拟器,让我们使用之前提到的react-native run-ios命令将其重新启动并运行。一旦加载完成,你应该看到以下内容:

这太棒了!一旦加载完成,让我们通过按键盘上的Command + D来打开 iOS 模拟器开发者菜单,并搜索一个可以帮助我们在创建应用程序时节省时间的选项。
在本节的结尾,你的index.ios.js文件应该看起来像这样:
// Tasks/index.ios.js
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
View
} from 'react-native';
import TasksList from './app/TasksList';
export default class Tasks extends Component {
render() {
return (
<View style={styles.container}>
<TasksList />
</View>
);
}
}
以下代码渲染了TasksList组件:
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
}
});
AppRegistry.registerComponent('Tasks', () => Tasks);
iOS 模拟器开发者菜单
当你打开开发者菜单时,你会看到以下选项:

我想要介绍一下这个菜单中的一些选项,这将帮助你使应用程序的开发过程更加顺畅。一些选项在这里没有涵盖,但你可以在 React Native 文档中阅读有关这些选项的内容。
首先,我们将介绍重载的选项:
-
重载:这个选项会重新加载你的应用程序代码。类似于在键盘上使用Command + R,重载选项会将你带到应用程序流程的开始。
-
启用实时重载:开启实时重载会导致你的应用程序在你在项目中保存文件时自动执行重载操作。实时重载很棒,因为你一旦启用它,每次你保存文件时,你的应用程序都会显示其最新的更改。重要的是要知道,重载和启用实时重载都会执行一个完整的重载操作,包括重置你的应用程序状态。
-
启用热重载:热重载是 React Native 在 2016 年 3 月引入的一个新功能。如果你在 Web 上使用过 React,这个术语可能对你来说很熟悉。热重载的想法是保持你的应用程序运行,并在运行时注入新代码,这样可以防止你像重载(或扩展到启用实时重载)那样丢失应用程序状态。
-
在开启实时重载的情况下构建功能的一个瓶颈是,当你处理一个多层深度的功能并且依赖于你的应用程序状态来正确地记录对其的更改时。这会给你的应用程序编写和重载的反馈循环增加几秒钟。热重载可以解决这个问题,让你的反馈循环减少到一秒或两秒以下。
-
在热重载方面需要注意的一点是,在其当前版本中,它并不完美。React Native 文档指出,在某些情况下,你需要使用常规的重载来重置你的应用程序,因为热重载失败了。
-
同样重要的是要知道,如果你在应用程序中添加新的资产或修改原生 Objective-C/Swift 或 Java/C++代码,你的应用程序在更改生效之前需要完全重新构建。
接下来的一组选项与调试有关:
-
远程调试 JS:启用此功能将在您的机器上打开 Chrome,并带您到一个 Chrome 标签页,允许您使用 Chrome 开发者工具来调试您的应用程序。
-
显示检查器:类似于在 Web 上检查元素,您可以使用 React Native 开发中的检查器来检查您的应用程序中的任何元素,并打开影响该元素的部分代码和源代码。您还可以通过这种方式查看每个特定元素的性能。
使用开发者菜单,我们将启用热重载。这将给我们提供关于我们正在编写的代码的最快反馈循环,使我们能够高效地工作。
现在我们已经启用了热重载并有一个基本的任务列表渲染到屏幕上,是时候考虑输入了--我们稍后再回来讨论样式。
TextInput
构建最小可行产品(MVP)的第二个目标如下:
- 我们的用户应该能够使用文本字段和原生键盘输入他们自己的任务
为了成功创建这个输入,我们必须将问题分解为一些必要的要求:
-
我们需要一个输入字段,以便弹出键盘进行输入
-
当我们点击键盘外部时,键盘应该自动隐藏
-
当我们成功添加一个任务时,它需要添加到
TasksList中的dataSource,它存储在其状态中 -
需要将任务列表存储在应用程序的本地,这样在状态重置时不会删除我们创建的所有任务列表
-
我们还应该解决几条道路上的分歧:
-
当用户在键盘上按回车键时会发生什么?它会自动创建一个任务吗?或者,我们实现并支持换行?
-
是否有一个专门的 添加此任务 按钮?
-
成功添加任务的动作会导致键盘消失,需要用户再次点击输入字段吗?或者,我们允许用户在点击键盘外部之前继续添加任务?
-
我们支持多少个字符?任务的长度有多长才算太长?如果用户超过这个限制,我们的软件用户将得到什么样的反馈?
-
这需要吸收很多信息,所以让我们一步一步来!我将建议我们现在忽略重大决策,先实现屏幕上的输入,然后让这个输入添加到我们的任务列表中。
由于输入应该保存到状态并在 ListView 中渲染,因此输入组件作为 ListView 的兄弟组件是有意义的,这样它们就可以共享相同的状态。
从架构上讲,TasksList 组件将看起来是这样的:
|TasksList
|__TextInput
|__ListView
|____RowData
|____RowData
|____...
|____RowData
React Native 的 API 中有一个 TextInput 组件,它满足了我们对键盘输入的需求。其代码是可定制的,并允许我们将输入添加到我们的任务列表中。
这个 TextInput 组件可以接受多种属性。我在这里列出了我们将使用的属性,但 React Native 的文档将提供更多深度:
-
autoCorrect: 这是一个布尔值,用于开启和关闭自动更正。默认设置为true -
onChangeText: 这是一个回调,当输入字段的文本发生变化时触发。组件的值作为参数传递给回调 -
onSubmitEditing: 这是一个回调,当单行输入的提交按钮被按下时触发 -
returnKeyType: 这设置返回键的标题为许多不同的字符串之一;done、go、next、search和send是两个平台都支持的五个选项
我们可以将当前任务分解为几个小步骤:
-
在
index.ios.js中更新容器样式,使其内容占据整个屏幕而不是只是中央 -
将一个
TextInput组件添加到TasksList组件的render方法中 -
为
TextInput组件创建一个提交处理程序,该处理程序将文本字段的值添加到ListView -
提交后清除
TextInput的内容,为下一个要添加的任务留下一个空白字段
抽点时间尝试将这个第一个功能添加到我们的应用中!在下一节中,我将分享一些我的结果截图,并分解我为它编写的代码。
这里有一个屏幕来展示我这一阶段的输入:

它符合前面章节中列出的四个基本要求:内容不在屏幕中央,顶部渲染了一个TextInput组件,提交处理程序将TextInput组件的值添加到ListView,并且一旦发生,TextInput的内容就会被清空。
让我们看看代码,看看我是如何处理的——你的可能不同!:
// Tasks/index.ios.js
import React, { Component } from 'react';
import {
AppRegistry,
View
} from 'react-native';
import TasksList from './app/components/TasksList';
export default class Tasks extends Component {
render() {
return (
<View>
<TasksList />
</View>
);
}
}
AppRegistry.registerComponent('Tasks', () => Tasks);
这是TasksList的更新样式:
// Tasks/app/components/TasksList/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1
}
});
export default styles;
我在这里所做的就是移除了容器的justifyContent和alignItems属性,这样项目就不会仅限于显示的中央。
接下来是TasksList组件,我进行了一些重大更改:
// Tasks/app/components/TasksList/index.js
import React, { Component } from 'react';
import {
ListView,
Text,
TextInput,
View
} from 'react-native';
import styles from './styles';
export default class TasksList extends Component {
constructor (props) {
super (props);
const ds = new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
});
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
}),
listOfTasks: [],
text: ''
};
}
构造函数现在将三件事保存到状态中:我们的本地ListView.DataSource实例、一个空字符串以跟踪TextInput的值,以及一个用于存储任务列表的数组。
render函数创建了一个dataSource的引用,我们将使用它来为ListView组件,克隆状态中存储的listOfTasks数组。再次强调,ListView只呈现纯文本:
render () {
const dataSource =
this.state.ds.cloneWithRows(this.state.listOfTasks);
TextInput组件有几个选项。它将其输入字段的value绑定到我们的状态中的text值,随着字段的编辑而重复更改。通过在键盘上按下完成键提交它时,它触发一个名为_addTask的回调:
return (
<View style={ styles.container }>
<TextInput
autoCorrect={ false }
onChangeText={ (text) => this._changeTextInputValue(text) }
onSubmitEditing={ () => this._addTask() }
returnKeyType={ 'done' }
style={ styles.textInput }
value={ this.state.text }
/>
它渲染一个ListView组件,其中_renderRowData方法负责返回组件的每一行:
<ListView
dataSource={ dataSource }
enableEmptySections={ true }
renderRow={ (rowData) => this._renderRowData(rowData) }
/>
</View>
);
}
我喜欢在我自己创建的 React 组件的方法名前加上下划线,这样我就可以从默认的生命周期方法中视觉上区分它们。
_addTask 方法使用 ES6 中引入的数组扩展运算符来创建一个新的数组,并将现有数组的值复制过来,将最新的任务添加到列表的末尾。然后,我们将它分配给状态中的 listOfTasks 属性。记住,我们必须将组件状态视为不可变对象,直接向其推送将是一个反模式:
_addTask () {
const listOfTasks = [...this.state.listOfTasks, this.state.text];
this.setState({
listOfTasks
});
this._changeTextInputValue(''
}
最后,我们调用 _changeTextInputValue 以清空 TextInput 框:
_changeTextInputValue (text) {
this.setState({
text
});
}
_renderRowData (rowData) {
return (
<Text>{ rowData }</Text>
)
}
}
目前,只返回待办事项列表项的名称就足够了。
在 _addTask 方法中设置 listOfTasks 属性和在 _changeTextInputValue 中设置 text 属性时,我正在使用 ES6 的新特性,称为简写属性名,将值分配给与值同名的键。这相当于我写下以下内容:
this.setState({
listOfTasks: listOfTasks,
text: text
})
继续前进,你可能注意到,当你刷新应用程序时,你会丢失你的状态!这对于待办事项列表应用来说是不切实际的,因为我们不应该期望用户在重新打开应用时重新输入相同的列表。我们想要的是将此任务列表本地存储在设备上,以便我们可以在需要时访问它。这就是 AsyncStorage 发挥作用的地方。
AsyncStorage
AsyncStorage 组件是一个简单的键值存储,它对您的 React Native 应用程序全局可用。它是持久的,这意味着 AsyncStorage 中的数据将在退出或重新启动应用程序或手机时继续存在。如果你已经使用过 HTML 的 LocalStorage 和 SessionStorage,那么 AsyncStorage 将看起来很熟悉。它适用于轻量级使用,但 Facebook 建议你在 AsyncStorage 之上使用抽象层,以处理更复杂的情况。
如其名所示,AsyncStorage 是异步的。如果你还没有接触过异步 JavaScript,这意味着这个存储系统的方法可以与你的其他代码并发运行。AsyncStorage 的方法返回一个 Promise——一个表示尚未完成但预期将来会完成的操作的对象。
AsyncStorage 中的每个方法都可以接受一个回调函数作为参数,并在 Promise 履行后触发该回调。这意味着我们可以编写我们的 TasksList 组件来处理这些承诺,在需要时保存和检索我们的任务数组。
关于 AsyncStorage 的最后一件事——它是一个简单的键值存储。它期望其键和值都是字符串,这意味着我们需要使用 JSON.stringify 将发送到存储的数据转换为字符串,并在检索时使用 JSON.parse 将其转换回数组。
玩转 AsyncStorage 并更新你的 TasksList 组件以支持它。以下是你希望使用 AsyncStorage 达成的目标:
-
一旦
TasksList加载,我们希望查看是否在本地存储中存在任何任务。如果存在,向用户展示这个列表。如果不存在,从空数组开始存储。数据应该始终在重启后持久化。 -
当输入任务时,我们应该更新任务列表,将更新后的列表保存到
AsyncStorage中,然后更新ListView组件。
这是最终写出的代码:
// TasksList/app/components/TasksList/index.js
...
import {
AsyncStorage,
...
} from 'react-native';
...
从 React Native SDK 中导入 AsyncStorage API。
export default class TasksList extends Component {
...
componentDidMount () {
this._updateList();
}
在 componentDidMount 生命周期中调用 _updateList 方法。
...
async _addTask () {
const listOfTasks = [...this.state.listOfTasks, this.state.text];
await AsyncStorage.setItem('listOfTasks',
JSON.stringify(listOfTasks));
this._updateList();
}
将 _addTask 更新为使用 async 和 await 关键字以及 AsyncStorage。有关使用 async 和 await 的详细信息,请参阅以下内容:
...
async _updateList () {
let response = await AsyncStorage.getItem('listOfTasks');
let listOfTasks = await JSON.parse(response) || [];
this.setState({
listOfTasks
});
this._changeTextInputValue('');
}
}
在 _updateTask 中,我们使用 AsyncStorage 做的是获取使用 listOfTasks 键本地存储的值。从这里,我们解析结果,将字符串转换回数组。然后,我们检查数组是否存在,如果返回 null,则将其设置为空数组。最后,我们通过更新 listOfTasks 并触发 _changeTextInputValue 来重置 TextInput 值来设置我们组件的状态。
上述示例还使用了 ES7 规范提案中的一部分新 async 和 await 关键字,并且可以与 React Native 一起使用。
使用 Async 和 Await 关键字
通常,为了处理异步函数,我们会将其与一些承诺链式调用,以便获取我们的数据。我们可以这样写 _updateList:
_updateList () {
AsyncStorage.getItem('listOfTasks');
.then((response) => {fto
return JSON.parse(response);
})
.then((parsedResponse) => {
this.setState({
listOfTasks: parsedResponse
});
});
}
然而,这可能会变得相当复杂。相反,我们将使用 async 和 await 关键字来创建一个更简单的解决方案:
async _updateList () {
let response = await AsyncStorage.getItem('listOfTasks');
let listOfTasks = await JSON.parse(response) || [];
this.setState({
listOfTasks
});
this._changeTextInputValue('');
}
_updateList 前面的 async 关键字将其声明为异步函数。它自动为我们返回承诺,并可以利用 await 关键字告诉 JS 解释器暂时退出异步函数,并在异步调用完成后恢复运行。这对我们来说很棒,因为我们可以在单个函数中以顺序表达我们的意图,并且仍然获得与承诺相同的精确结果。
自定义 RenderRow 组件
我们列表中的最后一项,即要有一个可用的最小可行产品,是允许每个任务被标记为完成。这就是我们将创建 TasksListCell 组件并在 ListView 的 renderRow 函数中渲染该组件,而不是仅仅显示文本的地方。
我们对这个组件的目标应该是以下内容:
-
从父组件接受文本作为 prop,并在
TasksListCell中渲染它 -
将
listOfTasks更新为接受对象数组而不是字符串数组,允许每个对象跟踪任务的名称以及它是否已完成 -
当任务被点击时,提供某种视觉指示器,将任务标记为完成,不仅在视觉上,而且在任务的
data对象中,这样在应用程序重新加载时也能持久化。
自定义 RenderRow 示例
让我们看看我是如何创建这个组件的:
// Tasks/app/components/TasksList/index.js
...
import TasksListCell from '../TasksListCell';
...
export default class TasksList extends Component {
...
async _addTask () {
const singleTask = {
completed: false,
text: this.state.text
}
首先,任务现在在数组中以对象的形式表示。这允许我们为每个任务添加属性,例如其完成状态,并为未来的添加留出空间。
const listOfTasks = [...this.state.listOfTasks, singleTask];
await AsyncStorage.setItem('listOfTasks',
JSON.stringify(listOfTasks));
this._updateList();
}
...
_renderRowData (rowData, rowID) {
return (
<TasksListCell
completed={ rowData.completed }
id={ rowID }
onPress={ (rowID) => this._completeTask(rowID) }
text={ rowData.text }
/>
)
}
...
}
_renderRowData 方法也被更新,以渲染新的 TasksListCell 组件。四个 props 被共享到 TasksListCell:任务的完成状态、其行标识符(由 renderRow 提供)、一个用于更改任务完成状态的回调函数,以及该任务本身的详细信息。
这就是 TasksListCell 组件的编写方式:
// Tasks/app/components/TasksListCell/index.js
import React, { Component, PropTypes } from 'react';
import {
Text,
TouchableHighlight,
View
} from 'react-native';
export default class TasksListCell extends Component {
static propTypes = {
completed: PropTypes.bool.isRequired,
id: PropTypes.string.isRequired,
onLongPress: PropTypes.func.isRequired,
onPress: PropTypes.func.isRequired,
text: PropTypes.string.isRequired
}
使用 PropTypes 明确声明组件期望接收的数据。 继续阅读以了解 React 中 prop 验证的解释。
constructor (props) {
super (props);
}
render () {
const isCompleted = this.props.completed ? 'line-through' : 'none';
const textStyle = {
fontSize: 20,
textDecorationLine: isCompleted
};
使用三元运算符来计算任务完成时的样式。
return (
<View>
<TouchableHighlight
onPress={ () => this.props.onPress(this.props.id) }
underlayColor={ '#D5DBDE' } >
<Text style={ textStyle }>{ this.props.text }</Text>
</TouchableHighlight>
</View>
)
}
}
前面的组件为列表中的每个任务提供了一个 TouchableHighlight,当点击项目时,给我们视觉上的不透明度反馈。它还会触发 TasksListCell 的 _completeTask 方法,随后调用传递给它的 onPress prop,并对单元格的样式进行视觉更改,通过在任务的水平中心画一条线来标记它为完成。
React 中的 prop 验证
通过为组件声明一个 propTypes 对象,我可以指定给定组件期望的 props 和它们的类型。这对我们代码的未来维护者很有帮助,并在 props 错误输入或缺失时提供有用的警告。
要利用 prop 验证,首先从 React 导入 PropTypes 模块:
import { PropTypes } from 'react';
然后,在我们的组件中,我们给它一个静态属性 propTypes:
class Example extends Component {
static propTypes = {
foo: PropTypes.string.isRequired,
bar: PropTypes.func,
baz: PropTypes.number.isRequired
}
}
在前面的示例中,foo 和 baz 是 Example 组件的必需 props。foo 预期是一个字符串,而 baz 预期是一个数字。另一方面,bar 预期是一个函数,但不是必需的 props。
超越 MVP
现在我们已经完成了一个非常基础的 MVP,下一个目标是向应用程序添加一些功能,使其变得完整。
这就是我之前提到的某些有用的功能:
我希望为每个独特的任务设置一个提醒,这样我就可以有序地完成每个任务。理想情况下,列表中的项目可以按类别分组。类别分组可能可以通过类似图标的东西来简化。这样,我也可以通过图标对列表进行排序和筛选。
除了功能之外,我们还应该调整应用程序的样式,使其看起来更好。在我的示例代码中,应用程序的组件与 iOS 的状态栏冲突,并且行格式完全没有设置。我们应该给应用程序一个自己的身份。
下一章将更深入地探讨我们的 MVP,并将其转变为一个功能齐全且样式丰富的应用程序。我们还将探讨如果应用程序是为 Android 编写的,我们会做哪些不同的事情。
摘要
在本章中,你通过规划一个最小可行产品版本的待办事项应用开始了你的学习,其中包括向列表中添加任务并将它们标记为已完成。然后,你学习了 React Native 中的基本样式设计,使用 Flexbox,并熟悉了 ES6 规范的新语法和功能。你还发现了 iOS 模拟器的调试菜单,这是一个编写应用的非常有用的工具。
之后,你创建了一个ListView组件来渲染一系列项目,然后实现了一个TextInput组件来保存用户输入并将它渲染到Listview中。接着,你使用了AsyncStorage来持久化用户添加到应用中的数据,利用新的async和await关键字编写了干净的异步函数。最后,你实现了一个TouchableHighlight单元格,用于标记任务为已完成。
第二章:高级功能与待办事项应用程序的样式设计
在为 Tasks 应用程序构建了 MVP(最小可行产品)之后,现在是时候深入构建高级功能,并对应用程序进行样式设计,使其看起来更美观。本章将探讨以下主题:
-
利用
NavigatorIOS组件构建一个编辑屏幕,以便添加任务的详细信息 -
使用
DatePickerIOS捕获任务截止日期和时间 -
为我们的应用程序创建一个自定义可折叠组件,并利用
LayoutAnimation来实现流畅的过渡 -
为我们的 UI 构建一个
Button组件,以清除待办事项的截止日期 -
保存已编辑任务的资料,如果适用,则渲染截止日期
-
将应用程序移植到 Android,用
DatePickerAndroid和TimePickerAndroid替换DatePickerIOS,用Navigator替换NavigatorIOS,并探索在决定使用哪个组件时的控制流程
导航器和 NavigatorIOS
在移动应用程序中实现导航有助于我们控制用户如何与我们的应用程序互动和体验。它让我们为那些原本没有任何上下文的情况赋予上下文——例如,在 Tasks 中,向用户展示一个尚未选择的任务的编辑视图是没有意义的;只有当用户选择编辑任务时,才向用户展示此视图,这样可以构建情境上下文和意识。
React Native 的 Navigator 组件负责处理应用程序中不同视图之间的转换。浏览文档时,您可能会注意到存在一个 Navigator 和 NavigatorIOS 组件。Navigator 在 iOS 和 Android 上都可用,并使用 JavaScript 实现。另一方面,NavigatorIOS 仅适用于 iOS,并且是 iOS 原生 UINavigationController 的包装器,它动画化并按照您从任何 iOS 应用程序中期望的方式表现。
在本章的后面部分,我们将更详细地探讨 Navigator。
关于 NavigatorIOS 的重要说明
虽然 NavigatorIOS 支持 UIKit 动画,并且是构建 Tasks iOS 版本的绝佳选择,但需要记住的是,NavigatorIOS 事实上是 React Native SDK 的一个社区驱动组件。Facebook 从一开始就公开表示,它在自己的应用程序中大量使用了 Navigator,但 NavigatorIOS 组件未来改进和添加的所有支持都将直接来自开源贡献。
查看 NavigatorIOS
NavigatorIOS 组件在您的 React Native 应用程序的最顶层设置。我们将提供至少一个对象,标识为 routes,以便识别我们应用程序中的每个视图。此外,NavigatorIOS 会查找一个 renderScene 方法,该方法负责渲染我们应用程序中的每个场景。以下是一个使用 NavigatorIOS 渲染基本场景的示例:
import React, { Component } from 'react';
import {
NavigatorIOS,
Text
} from 'react-native';
export default class ExampleNavigation extends Component {
render () {
return (
<NavigatorIOS
initialRoute={{
component: TasksList,
title: 'Tasks'
}}
style={ styles.container }
/>
);
}
}
这只是一个基本的示例。我们正在初始化 NavigatorIOS 组件,并以一个简单的 text 组件作为基本路由进行渲染。我们真正感兴趣的是在 routes 之间切换以编辑任务。让我们将这个目标分解成一系列更容易处理的子任务:
-
创建一个新的
EditTask组件。它可以从一个带有一些填充信息的简单屏幕开始。 -
设置
NavigatorIOS以在任务长按时路由到EditTask。 -
为
EditTask构建逻辑,使其能够接受作为组件属性的精确任务以渲染特定于任务的数据。添加适当的输入字段,以便此组件可以从编辑屏幕标记为完成,以及具有设置截止日期和标签的能力。 -
当编辑保存时,添加逻辑将编辑后的数据保存到
AsyncStorage。
我们将花一些时间来完成每个步骤,并在必要时进行回顾。花几分钟时间构建一个简单的 EditTask 组件,然后参考我是如何构建的。
一个简单的 EditTasks 组件
在我的应用程序文件夹结构中,我的 EditTasks 组件嵌套如下:
|Tasks
|__android
|__app
|____components
|______EditTask
|______TasksList
|______TasksListCell
|__ios
|__node_modules
|__...
这是一个基本的组件,只是为了在屏幕上显示一些内容:
// Tasks/app/components/EditTask/index.js
import React, { Component } from 'react';
import {
Text,
View
} from 'react-native';
import styles from './styles';
export default class EditTask extends Component {
render () {
return (
<View style={ styles.editTaskContainer }>
<Text style={ styles.editTaskText }>Editing Task</Text>
</View>
);
}
}
之前的代码现在返回要渲染到屏幕上的文本。
现在是时候设置 NavigatorIOS 以与 TasksList 顺利协作了:
// Tasks/app/components/EditTask/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
editTaskContainer: {
flex: 1,
paddingTop: Navigator.NavigationBar.Styles.General.TotalNavHeight
},
editTaskText: {
fontSize: 36
}
})
export default styles;
首先,我们应该修改 TasksList 以使其:
-
添加一个名为
_editTask的函数,将EditTask组件推送到 Navigator -
将
_editTask函数作为名为onLongPress的属性传递给TasksListCell
然后,我们应该修改 EditTask 以确保其 render 方法中的 TouchableHighlight 组件在其自己的 onLongPress 回调期间调用此属性:
// Tasks/app/components/TasksList/index.js
...
import EditTask from '../EditTask';
...
export default class TasksList extends Component {
...
render () {
...
return (
<View style={ styles.container }>
...
<ListView
...
automaticallyAdjustContentInsets={ false }
style={ styles.listView }
/>
</View>
);
}
我们添加了一个布尔值,用于禁用内容内边距的自动调整。默认设置为 true,我们在 Input 和 ListView 组件之间看到了 ~55px 的内边距。在我们的组件和 EditTask 的样式设置中,我们开始导入 Navigator 组件。
这样我们就可以设置容器 paddingTop 属性,考虑到导航栏的高度,以便内容不会被留在导航栏后面。这种情况发生的原因是导航栏在组件加载完成后被渲染。
调用 NavigatorIOS 的 push 方法,渲染我们刚刚导入的 EditTask 组件:
...
_editTask (rowData) {
this.props.navigator.push({
component: EditTask,
title: 'Edit'
});
}
将 TasksListCell 分配一个名为 onLongPress 的回调,执行我们刚刚定义的 _editTask 方法:
_renderRowData (rowData, rowID) {
return (
<TasksListCell
...
onLongPress={ () => this._editTask() }
/>
)
}
...
}
将 paddingTop 属性设置为 Navigator 的高度,解决了我们的导航栏隐藏其后面的应用内容的问题:
// Tasks/app/components/TasksList/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
...
paddingTop: Navigator.NavigationBar.Styles.General.TotalNavHeight
...
});
export default styles;
使用 DatePickerIOS
在Tasks中,一个关键特性是能够为任务到期时设置提醒。理想情况下,我们的用户可以为任务完成设定日期和时间,以便他们能够被提醒到期日期。为了实现这一点,我们将使用一个名为DatePickerIOS的 iOS 组件。这是一个可以用于我们应用程序中的日期和时间选择器组件。
这里列出了我们将与DatePickerIOS组件一起使用的两个属性。如果你感兴趣,React Native 文档中还有其他属性:
-
date: 这是两个必需属性之一,用于跟踪当前选定的日期。理想情况下,此信息存储在渲染DatePickerIOS组件的状态中。date应该是 JavaScript 中的Date对象实例。 -
onDateChange: 这是另一个必需的属性,当用户在组件中更改日期或时间时触发。它接受一个参数,即表示新日期和时间的Date对象。
下面是一个简单的DatePicker组件的示例:
// Tasks/app/components/EditTask/index.js
...
import {
DatePickerIOS,
...
} from 'react-native';
...
export default class EditTask extends Component {
constructor (props) {
super (props);
this.state = {
date: new Date()
}
}
它创建一个新的 JavaScript Date对象实例并将其保存到状态中。
render () {
return (
<View style={ styles.editTaskContainer }>
<DatePickerIOS
date={ this.state.date }
onDateChange={ (date) => this._onDateChange(date) }
style={ styles.datePicker }
/>
</View>
);
}
这会导致使用组件状态中的date值作为同名的属性来渲染DatePickerIOS组件。
当用户与DatePickerIOS组件交互时,在组件状态中更改date的回调:
_onDateChange (date) {
this.setState({
date
});
}
}
这就是渲染后的DatePicker的外观:

这还有许多不足之处。首先,DatePickerIOS组件始终可见!通常,当我们在 iOS 应用程序中与这类选择器交互时,它是折叠的,只有当点击时才会展开。我们想要复制的正是这种确切的经验,即渲染一个可触摸的行,显示当前设置的到期日期或类似未设置到期日期的内容,当行被点击时,动画展开DatePickerIOS。
编写可折叠组件
我们的可折叠组件应实现以下目标:
-
当点击时,它应显示和隐藏传递给它的其他组件
-
这个组件将伴随动画,增强我们应用程序的用户体验
-
组件不应对其显示和隐藏的数据类型做出任何假设;它不应严格特定于
DatePickerIOS,以防我们将来想要将组件用于其他目的
我们需要利用 React Native 的出色LayoutAnimation API,该 API 旨在让我们创建流畅且富有意义的动画。
首先,我在项目的components文件夹中创建了一个名为ExpandableCell的组件,如下所示:
|Tasks
|__android
|__app
|____EditTask
|____ExpandableCell
|____TasksList
|____TasksListCell
|__ios
|__...
布局动画 API
我们的目标是在EditTask中点击date/time组件,然后使其向下展开以显示隐藏的DatePickerIOS组件。React Native 有一个名为LayoutAnimation的 API,允许我们创建自动动画布局。
LayoutAnimation 包含三个表示默认动画曲线的方法:easeInEaseOut、linear 和 spring。这些决定了动画在其过渡过程中的行为。你可以在 componentWillUpdate 生命周期方法下简单地调用这三个方法之一,如果组件状态的变化触发了重新渲染,LayoutAnimation 将将其动画添加到你的更改中。
要隐藏和显示传递给 ExpandableCell 的子组件,我可以根据组件是否应该显示或隐藏来操作其 maxHeight 样式。此外,我可以通过将 overflow 属性设置为 hidden 来在不需要时隐藏组件。
花些时间隐藏传递给 ExpandableCell 的子组件,并设置一些逻辑来根据需要显示和隐藏此内容。准备好后,查看我的实现。
基本 ExpandableCell 实现
这是我们开始构建 ExpandableCell 的方法:
// Tasks/app/components/ExpandableCell/index.js
import React, { Component, PropTypes } from 'react';
import {
LayoutAnimation,
Text,
TouchableHighlight,
View
} from 'react-native';
import styles from './styles';
export default class ExpandableCell extends Component {
这将 title 设置为组件期望的 PropTypes 字符串:
static propTypes = {
title: PropTypes.string.isRequired
}
现在我们跟踪组件 state 中的布尔值 expanded。默认情况下,我们的子组件不应可见:
constructor (props) {
super (props);
this.state = {
expanded: false
}
}
设置此组件更改时的 LayoutAnimation 样式:
componentWillUpdate () {
LayoutAnimation.linear();
}
将 TouchableHighlight 组件包裹在 ExpandableCell 的 Text 组件周围。当按下时,它会调用 _onExpand 方法:
render () {
return (
<View style={ styles.expandableCellContainer }>
<View>
<TouchableHighlight
onPress={ () => this._expandCell() }
underlayColor={ '#D3D3D3' }
>
在组件未展开的情况下,向此 View 的样式添加一个 maxHeight 属性,使用三元运算符:
<Text style={ styles.visibleContent }>
{ this.props.title}</Text>
</TouchableHighlight>
</View>
<View style={ [styles.hiddenContent,
this.state.expanded ? {} : {maxHeight: 0}]}>
这将渲染组件本身嵌套的任何子组件:
{ this.props.children }
</View>
</View>
)
}
以下是一个回调,用于在组件状态中切换 expanded 布尔值:
_expandCell () {
this.setState({
expanded: !this.state.expanded
});
}
}
这是 ExpandableCell 的样式:
// Tasks/app/components/ExpandableCell/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
expandableCellContainer: {
flex: 1,
padding: 10,
paddingTop: 0
},
hiddenContent: {
overflow: 'hidden'
},
visibleContent: {
fontSize: 24
}
})
在 EditTask 中的基本实现如下所示:
// Tasks/app/components/EditTask/index.js
...
import ExpandableCell from '../ExpandableCell';
export default class EditTask extends Component {
...
渲染一个带有标题的 ExpandableCell 组件:
render () {
return (
<View style={ styles.editTaskContainer }>
<ExpandableCell title={ 'Due On' }>
在 ExpandableCell 内嵌套 DatePickerIOS 以使其最初保持隐藏:
<DatePickerIOS
...
/>
</ExpandableCell>
</View>
);
}
...
}
理想情况下,此组件将显示以下之一:
-
如果存在,则选择任务的截止日期
-
如果不存在截止日期,则选择日期的空白占位符
我们将在稍后处理诸如清除截止日期等问题,但现在,我们应该修改 EditTask,使其传递给 ExpandableCell 的 title 属性取决于任务是否分配了截止日期。组件当前应该看起来是这样的:

这是解决这个问题的方法。自上一个示例以来,唯一更改的文件是 EditTask 组件:
// Tasks/app/components/EditTask/index.js
...
import moment from 'moment';
...
export default class EditTask extends Component {
...
render () {
const noDueDateTitle = 'Set Reminder';
const dueDateSetTitle = 'Due On ' + this.state.formattedDate;
设置两个字符串以显示 ExpandableCell 的 title 属性。
return (
<View style={ styles.editTaskContainer }>
<ExpandableCell
title={ this.state.dateSelected ?
dueDateSetTitle : noDueDateTitle }>
使用三元运算符来决定传递给 ExpandableCell 的字符串。
...
</ExpandableCell>
</View>
);
}
_formatDate (date) {
return moment(date).format('lll');
}
我还从 npm 导入了 moment 以使用其强大的日期格式化功能。Moment 是一个非常流行、广泛使用的库,它允许我们使用 JavaScript 操作日期。安装它就像打开项目根目录的终端并输入以下内容一样简单:
npm install --save moment
MomentJS 库有很好的文档,其主页位于 momentjs.com,将展示你如何使用它的所有方法。对于这个文件,我使用了 Moment 的格式化方法,并设置为显示缩写月份名称,后跟数字日期和年份,以及时间。
使用 'lll' 标志格式化的 Moment 日期示例如下:
Dec 25, 2016 12:01 AM
使用 Moment 格式化日期有不同的方式,我鼓励你玩一玩这个库,找到最适合你的日期格式。
将 dateSelected 设置为 true,并将日期的 Moment 格式版本添加到状态中,这将反过来触发此组件的 render 方法再次更新传递给 ExpandableCell 的 title 字符串:
_onDateChange (date) {
this.setState({
...
dateSelected: true,
formattedDate: this._formatDate(date)
});
}
}
到本节结束时,你的应用应该看起来像以下截图:

使用 onLayout
在我们前面的例子中,我们不需要指定 DatePickerIOS 组件在展开时的高度。然而,可能存在需要手动获取组件尺寸的场景。
为了计算组件的高度,我们可以利用其 onLayout 属性来触发一个回调,然后使用该回调保存传递给回调的属性。onLayout 属性是一个在挂载和布局更改时被调用的事件,它给事件对象一个 nativeEvent 对象,该对象嵌套了组件的布局属性。以 DatePickerIOS 为例,你可以像这样将其 onLayout 属性传递一个回调:
<DatePickerIOS
date={ this.state.date }
onDateChange={ (date) => this._onDateChange(date) }
onLayout={ (event) => this._getComponentDimensions(event) }
style={ styles.datePicker }
/>
onLayout 事件提供了以下属性:
event: {
nativeEvent: {
layout: {
x: //some number
y: //some number
width: //some number
height: //some number
}
}
}
按钮
让我们为 EditTask 组件构建一个 清晰的截止日期 按钮,并且只有当待办事项已选择截止日期时才选择性地启用它。React Native 中的 Button 组件应该能帮助我们快速渲染。
Button 组件接受一些属性;以下四个将在我们的应用程序中使用:
-
color:这是一个字符串(或字符串化的十六进制值),用于设置 iOS 上的文本颜色或 Android 上的背景颜色 -
disabled:这是一个布尔值,如果设置为true,则禁用按钮;默认为false -
onPress:这是一个在按钮被按下时触发的回调 -
title:这是要在按钮内显示的文本
一个示例 Button 组件可以渲染如下:
<Button
color={ 'blue' }
disabled={ this.state.buttonDisabled }
onPress={ () => alert('Submit button pressed') }
title={ 'Submit' }
/>
修改 EditTask 以使其具有以下功能:
-
它在其状态中包含一个布尔值,标题为
expanded,用于控制ExpandableCell的打开/关闭状态。 -
它修改了
ExpandableCell的渲染,以接受expanded和onPress属性。expanded属性应指向EditTask状态中的expanded布尔值,而onPress属性应触发一个翻转expanded布尔值的方法。 -
将
onLayout回调添加到DatePickerIOS以计算其高度,并将其保存到状态中。 -
包含一个具有
title属性的Button组件,提示用户清除截止日期。给它一个onPress属性,当按下时会清除状态中的dateSelected布尔值。如果dateSelected布尔值设置为false,则选择性地禁用它。
清除截止日期示例
下面是我为了使按钮能够清除选定的日期并展开/折叠我们的单元格以良好地工作所做的事情:
// Tasks/app/components/EditTask/index.js
...
import {
Button,
...
} from 'react-native';
...
export default class EditTask extends Component {
constructor (props) {
...
this.state = {
...
expanded: false
}
}
render () {
...
return (
<View style={ styles.editTaskContainer }>
<View style={ [styles.expandableCellContainer,
{ maxHeight: this.state.expanded ?
this.state.datePickerHeight : 40 }]}>
我在ExpandableCell周围包裹了一个新的View。其样式根据EditTask状态中的展开Boolean进行修改。如果组件被展开,则其maxHeight属性设置为子组件的高度。否则,它被设置为40像素。
然后,将expanded和onPress属性传递给此组件:
<ExpandableCell
...
expanded={ this.state.expanded }
onPress={ () => this._onExpand() }
>
在onLayout事件期间调用_getDatePickerHeight:
<DatePickerIOS
...
onLayout={ (event) => this._getDatePickerHeight(event) }
/>
</ExpandableCell>
</View>
Button组件也被封装在其自己的View中。这样做是为了使Button和ExpandableCell堆叠在一起:
<View style={ styles.clearDateButtonContainer }>
<Button
color={ '#B44743' }
disabled={ this.state.dateSelected ? false : true }
onPress={ () => this._clearDate() }
title={ 'Clear Date' }
/>
</View>
</View>
);
}
将状态中的dateSelected布尔值设置为false,更改ExpandableCell传递的title:
_clearDate () {
this.setState({
dateSelected: false
});
}
这将DatePickerIOS组件的宽度保存到状态中:
_getDatePickerHeight (event) {
this.setState({
datePickerHeight: event.nativeEvent.layout.width
});
}
_onExpand () {
this.setState({
expanded: !this.state.expanded
});
}
}
我向此组件的StyleSheet添加了clearDateButtonContainer样式:
// Tasks/app/components/EditTask/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
...
clearDateButtonContainer: {
flex: 1
}
})
export default styles;
让我们继续工作并在这个屏幕上添加一些更多功能。接下来,我们应该有一个字段来编辑任务名称,紧随其后的是一个用于切换任务完成或不完成状态的Switch组件。
开关
Switch是一个渲染布尔输入并允许用户切换的组件。
使用Switch,我们将使用以下属性:
-
onValueChange: 这是一个回调,当开关的值改变时,会使用新的开关值被调用 -
value: 这是一个布尔值,用于确定开关是否设置为'开启'位置;默认为false
一个简单的Switch组件可能看起来像这样:
<Switch
onValueChange={ (value) =? this.setState({ toggled: value })}
value={ this.state.toggled }
/>
如前所述,Switch有两个必需的属性:其value和一个当切换时更改其值的回调。
使用这些知识,让我们对TasksList组件进行修改,使其将每行的completed、due、formattedDate和text属性传递给EditTask组件以供使用。
然后,向EditTask组件添加一些修改,使其:
-
期望其
propTypes声明中包含completed、due、formattedDate和text属性。 -
包含一个预加载待办事项列表项名称的
TextInput字段,并允许用户编辑名称。 -
添加一个预加载待办事项列表项完成状态的
Switch组件。当切换时,其完成状态应改变。
这是我想出的解决方案:
// Tasks/app/components/TasksList/index.js
...
export default class TasksList extends Component {
...
_editTask (rowData) {
this.props.navigator.push({
...
passProps: {
completed: rowData.completed,
due: rowData.due,
formattedDate: rowData.formattedDate,
text: rowData.text
},
...
});
}
...
}
将EditTask所需的四个字段传递进去,以便视图可以访问渲染待办事项列表项的现有详细信息。如果行不包含这些字段之一或多个,它将传递undefined。
声明此组件期望的四个propTypes。由于当应用程序创建待办事项列表项时,只有completed和text是设置的,因此它们被标记为必需属性。
// Tasks/app/components/EditTask/index.js
import React, { Component, PropTypes } from 'react';
...
import {
...
Switch,
TextInput,
...
} from 'react-native';
...
export default class EditTask extends Component {
static propTypes = {
completed: PropTypes.bool.isRequired,
due: PropTypes.string,
formattedDate: PropTypes.string,
text: PropTypes.string.isRequired
}
constructor (props) {
super (props);
this.state = {
completed: this.props.completed,
date: new Date(this.props.due),
expanded: false,
text: this.props.text
}
}
在状态中使用props被认为是一种反模式,但在这里我们有很好的理由,因为我们将会作为组件的一部分修改这些属性。
在下一节中,我们还将创建一个保存按钮,以便我们可以保存待办事项的更新详情,因此我们需要在状态中有一个本地可用的数据副本来反映EditTask组件的更改。
渲染一个TextInput组件来处理更改待办事项列表项的名称:
render () {
...
return (
<View style={ styles.editTaskContainer }>
<View>
<TextInput
autoCorrect={ false }
onChangeText={ (text) => this._changeTextInputValue(text) }
returnKeyType={ 'done' }
style={ styles.textInput }
value={ this.state.text }
/>
</View>
在ExpandableCell下方但在清除截止日期Button上方渲染Switch:
...
<View style={ styles.switchContainer } >
<Text style={ styles.switchText } >
Completed
</Text>
<Switch
onValueChange={ (value) => this._onSwitchToggle(value) }
value={ this.state.completed }
/>
</View>
...
</View>
);
}
以下回调方法更改TextInput和Switch的值:
_changeTextInputValue (text) {
this.setState({
text
});
}
...
_onSwitchToggle (completed) {
this.setState({
completed
});
}
}
为新组件添加一些样式改进:
// Tasks/app/components/EditTask/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
...
switchContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
maxHeight: 50,
padding: 10
},
switchText: {
fontSize: 16
},
textInput: {
borderColor: 'gray',
borderWidth: 1,
height: 40,
margin: 10,
padding: 10
}
})
export default styles;
保存按钮
在本节中,我们将在导航栏的右上角创建一个标签为Save的按钮。当它被点击时,必须发生以下两件事:
-
用户对待办事项所做的更改(如名称、完成状态和截止日期)必须保存到
AsyncStorage,覆盖其以前的详细信息 -
TasksList必须更新,以便用户能够立即看到他们所做的更改
使用 React Native 渲染Save按钮很容易。将被推送到NavigatorIOS的对象需要接收以下两个键/值对:
-
rightButtonTitle:这是一个字符串,用于显示该区域的文本 -
onRightButtonPress:这是一个在按下该按钮时触发的回调
从表面上看,这似乎很简单。然而,我们不能从渲染的子组件传递任何信息到NavigatorIOS的onRightButtonPress方法。相反,我们必须在我们的TasksList组件内部保留我们做出的更改的副本,并在DatePickerIOS、TextInput和EditTask中的Switch组件更新时更新它们。
// Tasks/app/components/TasksList/index.js
...
export default class TasksList extends Component {
constructor (props) {
...
this.state = {
currentEditedTaskObject: undefined,
...
};
}
...
_completeTask (rowID) {
const singleUpdatedTask = {
...this.state.listOfTasks[rowID],
completed: !this.state.listOfTasks[rowID].completed
};
this._saveAndUpdateSelectedTask(singleUpdatedTask, rowID);
}
这不再是一个异步函数。利用async/await的部分被拆分为_saveAndUpdateSelectedTask。
将当前编辑的任务对象设置为状态:
_editTask (rowData, rowID) {
this.setState({
currentEditedTaskObject: rowData
});
为右按钮添加一个onRightButtonPress回调和字符串:
this.props.navigator.push({
...
onRightButtonPress: () => this._saveCurrentEditedTask(rowID),
rightButtonTitle: 'Save',
向EditTask传递四个新函数来处理项目的详细信息:
passProps: {
changeTaskCompletionStatus: (status) =>
this._updateCurrentEditedTaskObject('completed', status),
changeTaskDueDate: (date, formattedDate) =>
this._updateCurrentEditedTaskDueDate
(date, formattedDate),
changeTaskName: (name) =>
this._updateCurrentEditedTaskObject('text', name),
clearTaskDueDate: () =>
this._updateCurrentEditedTaskDueDate(undefined, undefined),
}
});
}
为_editTask添加参数以接受:
_renderRowData (rowData, rowID) {
return (
<TasksListCell
...
onLongPress={ () => this._editTask(rowData, rowID) }
...
/>
)
}
这是之前在componentDidMount中找到的逻辑。由于_saveCurrentEditedTask需要调用它,因此将其拆分为自己的函数:
async _saveAndUpdateSelectedTask (newTaskObject, rowID) {
const listOfTasks = this.state.listOfTasks.slice();
listOfTasks[rowID] = newTaskObject;
await AsyncStorage.setItem('listOfTasks',
JSON.stringify(listOfTasks));
this._updateList();
}
要保存当前编辑的任务,我们将对象和rowID传递给_saveAndUpdateSelectedtask,然后对导航器调用pop:
_saveCurrentEditedTask (rowID) {
this._saveAndUpdateSelectedTask(this.state.currentEditedTaskObject,
rowID);
this.props.navigator.pop();
}
此函数更新当前编辑的任务对象的date和formattedDate:
_updateCurrentEditedTaskDueDate (date, formattedDate) {
this._updateCurrentEditedTaskObject ('due', date);
this._updateCurrentEditedTaskObject ('formattedDate',
formattedDate);
}
以下函数接受一个键和一个值,创建一个带有新值的currentEditedTaskObject的克隆,并将其设置在状态中:
_updateCurrentEditedTaskObject (key, value) {
let newTaskObject = Object.assign({},
this.state.currentEditedTaskObject);
newTaskObject[key] = value;
this.setState({
currentEditedTaskObject: newTaskObject
});
}
...
}
最后两个函数的目的是更新正在编辑的对象的 TasksList 本地状态副本。这是出于两个原因:
-
我们对
EditTask所做的任何更新,例如更改名称、完成状态和截止日期,目前都不会传播到其父组件 -
此外,我们不能仅仅将
EditTask中的值指向作为 props 传递的内容,因为EditTask组件不会在传递给它的 props 发生变化时重新渲染。
EditTask 获得了几个更改,包括组件预期的新 propTypes:
// Tasks/app/components/EditTask/index.js
...
export default class EditTask extends Component {
static propTypes = {
changeTaskCompletionStatus: PropTypes.func.isRequired,
changeTaskDueDate: PropTypes.func.isRequired,
changeTaskName: PropTypes.func.isRequired,
clearTaskDueDate: PropTypes.func.isRequired,
...
}
EditTask 收到的更改涉及调用作为 props 传递给它的函数来更新父组件的数据以保存:
...
render () {
...
const dueDateSetTitle = 'Due On ' +
this.state.formattedDate || this.props.formattedDate;
...
}
_changeTextInputValue (text) {
...
this.props.changeTaskName(text);
}
_clearDate () {
...
this.props.clearTaskDueDate();
}
...
_onDateChange (date) {
...
this.props.changeTaskDueDate(date, formattedDate);
}
...
_onSwitchToggle (completed) {
...
this.props.changeTaskCompletionStatus(completed);
}
}
TasksListCell 修改
最后,我们希望编辑由我们的 ListView 渲染的每一行,以显示截止日期(如果存在)。
为了做到这一点,我们将不得不编写一些条件逻辑来显示格式化的日期,如果分配给我们要渲染的任务项,这将也是一个创建自定义 styles 文件夹的好时机,因为我们将需要它。
花些时间创建您版本的此功能。我的解决方案如下:
// Tasks/app/components/TasksListCell/index.js
...
import styles from './styles';
您可能会注意到上面的导入语句中,TasksListCell 现在导入了它的 StyleSheet。
将 formattedDate 添加到 propTypes 中作为可选字符串:
export default class TasksListCell extends Component {
static propTypes = {
...
formattedDate: PropTypes.string,
}
...
render () {
...
return (
<View style={ styles.tasksListCellContainer }>
<TouchableHighlight
...
>
<View style={ styles.tasksListCellTextRow }>
<Text style={ [styles.taskNameText,
{ textDecorationLine: isCompleted }] }>
{ this.props.text }
</Text>
调用 _getDueDate 来渲染截止日期的字符串,如果存在:
<Text style={ styles.dueDateText }>
{ this._getDueDate() }
</Text>
</View>
</TouchableHighlight>
</View>
)
}
_getDueDate () {
if (this.props.formattedDate && !this.props.completed) {
return 'Due ' + this.props.formattedDate;
}
return '';
}
}
此组件已被修改以支持显示截止日期的第二行文本,但前提是它存在。
逻辑设置为仅在任务未标记为完成时显示截止日期,这样用户就不会在看到他们已经完成的任务的截止日期时感到困惑。
此外,还添加了样式以使两行显示在同一行:
// Tasks/app/components/TasksListCell/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
dueDateText: {
color: 'red',
flex: 1,
fontSize: 12,
paddingTop: 0,
textAlign: 'right'
},
taskNameText: {
fontSize: 20
},
tasksListCellContainer: {
flex: 1
},
tasksListCellTextRow: {
flex: 1
}
});
export default styles;
这就是它的样子:

到目前为止,这是一个相当不错的应用程序,您将能够使用我们在下一个项目中获得的技能对其进行更多改进。随着我们结束这个项目,我想将您的注意力转向我经常收到的问题:
我们如何在 Android 上做这件事?
这是一个很好的问题,我们将在本书每个项目的末尾进行探索。我将假设您已经设置了您的开发环境,以便在 React Native 中开发 Android 应用。如果没有,请在继续之前先做这件事。如果您对开发 Android 没有兴趣,请随意跳过这部分内容,继续下一章!
修改 Android 任务
首先,我们需要在我们的应用的 Android 文件夹下创建一个新的 local.properties 文件,并指向 Android SDK 目录。添加以下行,其中 USERNAME 是您的机器用户名:
// Tasks/android/local.properties
sdk.dir = /Users/USERNAME/Library/Android/sdk
如果您的 Android SDK 安装在与前一个示例不同的位置,您需要修改此文件以指向正确的位置。
然后,启动一个Android 虚拟设备(AVD),在项目根目录下执行react-native run-android命令。您将看到以下屏幕,这与我们最初为 iOS 构建Tasks时的默认模板看起来几乎一样:

在 Android 上工作时,按RR重新加载应用,并使用Command + M进入开发者菜单。
您可能会发现,当远程 JS 调试开启时,从简单事物(如TouchableHighlight阴影和导航)的动画可能会非常缓慢。在撰写本文时,一些技术解决方案正在被提出以解决这个问题,但在此期间,强烈建议您根据需要启用和禁用远程 JS 调试。
导航器
Navigator组件的工作方式与其原生 iOS 组件略有不同,但它仍然非常强大。使用Navigator的一个变化是,您的路由应该明确定义。我们可以通过设置路由数组并根据我们访问的路由渲染特定的场景来实现这一点。以下是一个示例:
export default class Tasks extends Component {
render () {
const routes = [
{ title: 'First Component', index: 0 },
{ title: 'Second Component', index: 1 }
];
创建一个routes数组,如前述代码所示。
您可能会注意到,我们从一开始就明确定义了我们的路由,设置了一个初始路由,然后在这里将属性传递给每个路由的组件:
return (
<Navigator
initialRoute={{ index: 0 }}
renderScene={ (routes, navigator) =>
this._renderScene(routes, navigator) } />
)
}
传递给_renderScene的路由对象包含一个passProps对象,我们可以在推送导航时设置它。
在将组件推送到Navigator时,我们不传递组件,而是传递一个index;这是Navigator的_renderScene方法确定要向用户显示哪个场景的地方。以下是推送Navigator的方式:
_renderScene (route, navigator) {
if (route.index === 0) {
return (
<FirstComponent
title={ route.title }
navigator={ navigator } />
)
}
if (route.index === 1) {
return (
<SecondComponent
navigator={ navigator }
details={ route.passProps.details } />
)
}
}
}
这是我们使用导航器组件推送不同路由的方式。请注意,与NavigatorIOS中传递组件的方式不同,我们传递的是路由的索引:
_renderAndroidNavigatorView () {
this.props.navigator.push({
index: 1,
passProps: {
greeting: 'Hello World'
}
});
}
如果您将此与我们在 iOS 中渲染EditTask的方式进行比较,您会注意到我们根本就没有设置导航栏。Android 应用通常通过Drawer和ToolbarAndroid组件的组合来处理导航,我们将在稍后的项目中解决这些问题。这将帮助我们的应用看起来和感觉就像任何 Android 应用一样。
导航器示例
以下代码是导航器的示例:
// index.android.js
import React, { Component } from 'react';
import {
AppRegistry,
Navigator,
} from 'react-native';
import TasksList from './app/components/TasksList';
import EditTask from './app/components/EditTask';
class Tasks extends Component {
render () {
const routes = [
{ title: 'Tasks', index: 0 },
{ title: 'Edit Task', index: 1 }
];
再次,为我们的应用建立路由。
return (
<Navigator
initialRoute={{ index: 0}}
renderScene={ (routes, navigator) =>
this._renderScene(routes, navigator) }/>
);
}
导入Navigator组件并为用户渲染它。它从index:``0开始,返回TasksList组件。
如果索引是0,则返回TasksList。这是默认的route:
_renderScene (route, navigator) {
if (route.index === 0) {
return (
<TasksList
title={ route.title }
navigator={ navigator } />
)
}
如果路由索引是 1,则返回EditTask。它将通过passProps方法接收上述属性:
if (route.index === 1) {
return (
<EditTask
navigator={ navigator }
route={ route }
changeTaskCompletionStatus={
route.passProps.changeTaskCompletionStatus }
changeTaskDueDate={ route.passProps.changeTaskDueDate }
changeTaskName={ route.passProps.changeTaskName }
completed={ route.passProps.completed }
due={ route.passProps.due }
formattedDate={ route.passProps.formattedDate }
text={ route.passProps.text }
/>
)
}
}
}
AppRegistry.registerComponent('Tasks', () => Tasks);
在这个阶段,无需进行进一步修改,我们就可以创建新的待办事项并将它们标记为已完成。然而,由于Navigator组件的推送方法接受的参数与 iOS 的推送方法不同,我们将在TasksList文件中创建一些条件逻辑来适应它。
平台
当你的文件在 iOS 和 Android 功能之间的差异很小,使用相同的文件是可以的。利用Platform API,我们可以识别用户所使用的移动设备类型,并条件性地将他们引导到特定的路径。
与你的其他 React Native 组件一起导入Platform API:
import { Platform } from 'react-native';
然后在组件中调用其OS属性:
_platformConditional () {
if (Platform.OS === 'ios') {
doSomething();
}
if (Platform.OS === 'android') {
doSomethingElse();
}
}
这使我们能够控制应用所走的路径,并允许进行一些代码复用。
Android 特定文件如果需要创建一个仅在 Android 设备上运行的文件,只需将其命名为<FILENAME>.android.js,就像两个索引文件一样。React Native 将确切知道要构建哪个文件,这让我们能够在需要添加大量逻辑而一个通用的index.js文件无法处理时创建特定平台的组件。将文件命名为<FILENAME>.ios.js以设置 iOS 特定文件。
使用Platform API,我们可以创建条件逻辑来决定Navigator应根据用户的平台如何推送下一个组件。导入Platform API:
// Tasks/app/components/TasksList/index.js
...
import {
...
Platform,
...
} from 'react-native';
根据用户的平台修改TextInput的样式,使其具有与其平台相呼应的设计语言。在 Android 上,它通常显示为没有边框的单条下划线;因此,我们在该组件的 Android 特定样式中消除了边框:
...
export default class TasksList extends Component {
...
render () {
...
return (
<View style={ styles.container }>
<TextInput
...
style={ Platform.os === 'IOS' ? styles.textInput :
styles.androidTextInput }
...
/>
...
</View>
);
}
我将_editTask函数改为运行条件逻辑。如果我们的平台是 iOS,我们调用_renderIOSEditTaskComponent;否则,我们的平台必须是 Android,我们调用_renderAndroidEditTaskComponent代替:
_editTask (rowData, rowID) {
...
if (Platform.OS === 'ios') {
return this._renderIOSEditTaskComponent(rowID);
}
return this._renderAndroidEditTaskComponent(rowID);
}
_renderAndroidEditTaskComponent (rowID) {
this.props.navigator.push({
index: 1,
passProps: {
changeTaskCompletionStatus: (status) =>
this._updateCurrentEditedTaskObject('completed', status),
changeTaskDueDate: (date, formattedDate) =>
this._updateCurrentEditedTaskDueDate(date, formattedDate),
changeTaskName: (name) =>
this._updateCurrentEditedTaskObject('text', name),
clearTaskDueDate: () =>
this._updateCurrentEditedTaskDueDate(undefined, undefined),
completed: this.state.currentEditedTaskObject.completed,
due: this.state.currentEditedTaskObject.due,
formattedDate:
this.state.currentEditedTaskObject.formattedDate,
text: this.state.currentEditedTaskObject.text
}
})
}
上述代码将EditTask的index推送到导航器。它传递了 iOS 版本的应用之前传递的相同属性。
_renderIOSEditTaskComponent的内容与_editTask之前包含的内容相同:
_renderIOSEditTaskComponent (rowID) {
this.props.navigator.push({
...
});
}
...
}
在以下代码中,我们为TextInput添加了一个自定义的 Android 样式,省略了边框:
// Tasks/app/components/EditTask/styles.js
...
const styles = StyleSheet.create({
androidTextInput: {
height: 40,
margin: 10,
padding: 10
},
...
});
DatePickerAndroid 和 TimePickerAndroid
在 Android 上设置时间和日期与 iOS 大不相同。在 iOS 上,你有一个包含日期和时间的DatePickerIOS组件。在 Android 上,这被分为两个原生模态,DatePickerAndroid用于日期,TimePickerAndroid用于时间。它不是一个用于渲染的组件,而是一个异步函数,它打开模态并等待自然结束,然后再应用逻辑。
要打开其中一个,将其包裹在一个异步函数中:
async renderDatePicker () {
const { action, year, month, day } = await DatePickerAndroid.open({
date: new Date()
});
if (action === DatePickerAndroid.dismissedAction) {
return;
}
// do something with the year, month, and day here
}
DatePickerAndroid和TimePickerAndroid组件都返回一个对象,我们可以通过使用 ES6 解构赋值来获取每个对象的属性,如前一个片段所示。
由于这些组件默认将渲染为模态,所以我们也没有必要使用为 iOS 版本的应用构建的ExpandableCell组件。为了实现 Android 特定的日期和时间选择器,我们应该创建一个处理此功能的 Android 特定EditTask组件。
而不是扩展单元格,我们应该创建另一个 Button 组件来打开和关闭对话框。
在下一节给出的示例中,我克隆了 EditTask 的 iOS index.js 文件,并将其重命名为 index.android.js,然后再对其进行修改。省略了未从 iOS 版本更改的任何代码。已删除的内容也已注明。
DatePickerAndroid 和 TimePickerAndroid 示例
从导入语句中移除 DatePickerIOS 和 ExpandableCell:
// Tasks/app/components/EditTask/index.android.js
...
import {
...
DatePickerAndroid,
TimePickerAndroid,
} from 'react-native';
...
我已从该组件的 constructor 函数中移除了状态中的 expanded 布尔值:
export default class EditTask extends Component {
...
这个新的 DatePicker 按钮在按下时会调用 _showAndroidDatePicker。它放置在 TextInput 下方并替换了 ExpandableCell:
render () {
...
return (
<View style={ styles.editTaskContainer }>
...
<View style={ styles.androidButtonContainer }>
<Button
color={ '#80B546' }
title={ this.state.dateSelected ? dueDateSetTitle :
noDueDateTitle }
onPress={ () => this._showAndroidDatePicker() }
/>
</View>
清除截止日期的 Button 没有发生变化,但其样式已更改:
<View style={ styles.androidButtonContainer }>
</View>
</View>
);
}
一个异步函数在 DatePickerAndroid 上调用 open,提取 action、year、month 和 day,将它们设置为状态,然后调用 _showAndroidTimePicker:
async _showAndroidDatePicker () {
const options = {
date: this.state.date
};
const { action, year, month, day } = await
DatePickerAndroid.open(options);
if (action === DatePickerAndroid.dismissedAction) {
return;
}
this.setState({
day,
month,
year
});
this._showAndroidTimePicker();
}
以下是我们之前用于 _showAndroidDatePicker 的相同策略,但在最后调用 _onDateChange:
async _showAndroidTimePicker () {
const { action, minute, hour } = await TimePickerAndroid.open();
if (action === TimePickerAndroid.dismissedAction) {
return;
}
this.setState({
hour,
minute
});
this._onDateChange();
}
使用 DatePickerAndroid 和 TimePickerAndroid 返回的五个组合值创建一个新的 Date 对象:
...
_onDateChange () {
const date = new Date(this.state.year, this.state.month,
this.state.day, this.state.hour, this.state.minute);
...
}
...
}
我已移除 _getDatePickerHeight 和 _onExpand,因为它们与 EditTask 的部分相关,这些部分在 Android 版本的 app 中不可用。我还为此组件添加了一些样式更改:
// Tasks/app/components/EditTask/styles.js
...
const styles = StyleSheet.create({
androidButtonContainer: {
flex: 1,
maxHeight: 60,
margin: 10
},
...
textInput: {
height: 40,
margin: 10,
padding: 10
}
});
保存更新
由于我们不在 Android 版本的 app 中使用导航栏,我们应该创建一个处理相同保存逻辑的保存按钮。
首先,我们应该修改 index.android.js 以将 saveCurrentEditedTask 属性从 TasksList 组件传递给 EditTask:
// index.android.js
...
class Tasks extends Component {
...
_renderScene (route, navigator) {
...
if (route.index === 1) {
return (
<EditTask
...
saveCurrentEditedTask={ route.passProps
.saveCurrentEditedTask }
...
/>
)
}
}
}
然后,修改 TasksList 以在 _renderAndroidEditTaskComponent 中将 _saveCurrentEditedTask 方法传递给 EditTask:
// Tasks/app/components/TasksList/index.js
...
export default class TasksList extends Component {
...
_renderAndroidEditTaskComponent (rowID) {
this.props.navigator.push({
...
passProps: {
...
saveCurrentEditedTask: () =>
this._saveCurrentEditedTask(rowID),
...
}
})
}
...
}
在此之后,修改 EditTask 的 Android 版本以包含一个新按钮,当按下时会调用其 saveCurrentEditedTask 方法:
// Tasks/app/components/EditTask/index.android.js
...
export default class EditTask extends Component {
static propTypes = {
...
saveCurrentEditedTask: PropTypes.func.isRequired,
...
}
render () {
...
return (
<View style={ styles.editTaskContainer }>
...
<View style={ styles.saveButton }>
<Button
color={ '#4E92B5' }
onPress={ () => this.props.saveCurrentEditedTask() }
title={ 'Save Task' }
/>
</View>
</View>
);
}
...
}
最后,使用新的 saveButton 属性添加一些样式:
// Tasks/app/components/EditTask/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
...
saveButton: {
flex: 1,
marginTop: 20,
maxHeight: 70,
},
...
});
BackAndroid
我们需要处理的最后一件事是返回按钮。每个 Android 设备上都有一个通用的返回按钮,无论是硬件还是软件实现。我们需要使用 BackAndroid API 来检测返回按钮的按下并设置我们自己的自定义功能。如果我们不这样做,每次按下返回按钮时,应用程序都会自动关闭。
要使用它,我们可以在 componentWillMount 生命周期事件中添加一个事件监听器,当检测到返回按钮按下时会弹出导航器。我们还可以在组件卸载时移除监听器。
在 componentWillMount 期间,向 BackAndroid API 添加一个 hardwareButtonPress 事件的事件监听器,当触发时调用 _backButtonPress:
// Tasks/app/components/EditTask/index.android.js
...
import {
BackAndroid,
...
} from 'react-native';
...
export default class EditTask extends Component {
...
componentWillMount () {
BackAndroid.addEventListener('hardwareButtonPress', () =>
this._backButtonPress());
}
如果组件被卸载,则移除相同的监听器:
componentWillUnmount () {
BackAndroid.removeEventListener('hardwareButtonPress', () =>
this._backButtonPress())
}
使用 _backButtonPress 在导航器上调用 pop:
...
_backButtonPress () {
this.props.navigator.pop();
return true;
}
...
}
摘要
这是一章很长的内容!我们完成了很多事情。首先,我们使用NavigatorIOS来建立自定义路由,并创建了一个组件来编辑待办事项的详细信息,包括将其标记为已完成和添加截止日期。
然后,我们构建了一个具有流畅动画的自定义、可重用组件,用于展开和折叠子组件,使得DatePickerIOS可以根据需要展开和折叠。之后,我们实现了逻辑,以便使用导航栏保存我们对任务所做的更改。
我们还将我们的应用程序移植到支持 Android 操作系统!我们首先将NavigatorIOS替换为Navigator,使用Platform API 根据用户所使用的移动设备类型触发条件逻辑,并通过在每个索引文件后附加.android和.ios来创建 iOS 和 Android 特定的组件。
我们通过在 Android 上渲染日期和时间选择器完成了对 Android 的移植,这两个选择器是两个独立的弹出窗口,并在我们特定的EditTask组件内创建了一个保存按钮,以便我们的用户可以保存他们所做的更改。最后,通过使用BackAndroid API 监听返回按钮的点击,允许我们的用户从编辑待办事项返回到待办事项列表屏幕,而不是完全离开应用程序。
第三章:我们的第二个项目 - 预算应用
在过去,我发现保持月度预算是一件困难的事情。对于我们的第二个项目,我们将构建一个应用程序,通过让我们设定一个月内希望花费的目标来跟踪我们的预算,然后允许我们进入应用程序并按简单标签分类支出。在任何时候,我们都可以查看我们这个月的进度并查看我们前几个月的结果。
在本章中,我们将涵盖以下主题:
-
规划我们的第二个应用程序,
Expenses -
为 React Native 安装流行的矢量图标库
-
构建一系列将在我们的应用中使用的辅助方法
-
创建一个允许我们输入支出的模态窗口
-
渲染当前月份的列表,显示月份的进度
开始
像往常一样,让我们通过在命令行中使用以下语句来初始化一个新的 React Native 项目:
react-native init Expenses
当 React Native CLI 在构建我们的项目时,我们应该规划出应用程序的功能。
应用程序规划
一旦这个应用完成,我们希望它能够以下方式运行:
-
当应用程序启动时,如果尚未设置该月的预算,它应该要求用户输入他们的月度目标并将其保存在
AsyncStorage中。 -
一旦设置了该月的预算,用户应看到一个包含添加该月支出按钮的屏幕。
-
点击该按钮后,用户应看到一个模态窗口,允许他们输入支出的详细信息:名称、金额、购买日期以及一个用于分类项目的图标。该模态窗口应允许用户取消或保存他们所做的条目。
-
如果保存了支出,它应该在主屏幕上的列表中渲染,该列表包含添加更多支出的按钮。
-
此列表还应突出显示用户为该月设定的预算,以及一个进度指示器,显示他们离达到预算有多近。
-
应用程序还应包含一个第二个标签页,用户可以在其中查看他们前几个月的历史记录。
-
用户应能够向任何月份添加支出,并删除任何支出。
在这个项目的第一部分,我们将处理列表的上半部分。
让我们先安装矢量图标库,因为我们将在这个应用中使用它。
安装矢量图标
React Native 最受欢迎的矢量图标库之一是react-native-vector-icons。它包含来自不同来源的大量图标,包括 FontAwesome 和谷歌的 Material 图标库。
安装这个矢量图标库需要几个步骤,但我们将首先将其下载:
npm install react-native-vector-icons --save
现在它已经作为一个模块安装,但我们仍然需要将其链接到我们的项目,以便这个应用知道在哪里查找文件。这是因为我们构建的每个应用程序都不会利用 iOS 和 Android 平台的所有原生功能。包括支持所有可想象的原生功能的二进制文件将增加我们应用程序的大小;相反,任何依赖于原生 iOS 和 Android 代码的组件都必须手动链接到我们的应用程序,从而让我们的应用程序能够访问这些代码片段。
链接这个矢量图标库的简单方法是以下命令行:
react-native link
之前的命令将自动将具有原生依赖关系的库链接到您的项目。
文件夹结构
以下结构包括我们将在这个章节中构建的组件:
|Expenses
|__app
|____components
|______AddExpenses
|______AddExpensesModal
|______CurrentMonthExpenses
|______EnterBudget
|______ExpandableCell
|______ExpenseRow
|____utils
|______dateMethods.js
|______storageMethods.js
|____App.js
|____styles.js
|__ios
|__index.ios.js
工具
utils 文件夹存储了我们将要在我们的应用程序中使用的辅助方法。dateMethods 处理我们将要使用以获取日期不同部分的不同方法,而 storageMethods 处理对 AsyncStorage 的访问。
我们从应用程序规划中的第一个要点开始,当应用程序启动时,如果尚未设置本月的预算,应该提示用户输入他们的月度目标并将其保存到 AsyncStorage。
根据前面的意图,我们想要做以下事情:
-
获取当前月份和年份
-
从
AsyncStorage中检索存储我们费用的对象,并检查月份和年份以检查是否已设置预算 -
如果还没有,那么提示用户输入本月的预算并将其保存到
AsyncStorage
让我们创建一些处理日期的辅助方法。
日期方法
这些是 dateMethods.js 文件应该包含的内容:
-
一个将月份数字映射到其名称的对象
-
四个方法获取日期的不同部分。每个方法都应该接受一个可选的日期对象,如果没有传入,则创建一个新的
Date对象实例:-
getYear: 这个方法获取年份数字并返回其字符串形式 -
getMonth: 这个方法获取月份数字并将其作为字符串返回 -
getDay: 这个方法获取天数字并将其作为字符串返回 -
getMonthString: 这个方法使用之前创建的对象返回月份的名称
-
这就是我的 dateMethods 文件在完成前面的要点后的样子。这是一个将月份数字映射到月份字符串名称的对象:
// Expenses/app/utils/dateMethods.js
const monthNames = {
1: 'January',
2: 'February',
3: 'March',
4: 'April',
5: 'May',
6: 'June',
7: 'July',
8: 'August',
9: 'September',
10: 'October',
11: 'November',
12: 'December'
}
下一个方法获取当前年份并将其作为字符串返回:
export const getYear = (date) => {
date = date || new Date();
return date.getFullYear().toString();
}
这个方法获取当前月份(零索引)并返回它是第几个月:
export const getMonth = (date) => {
date = date || new Date();
const zeroIndexedMonth = date.getMonth();
return (zeroIndexedMonth + 1).toString();
}
下一个方法获取天并将其作为字符串返回:
export const getDay = (date) => {
date = date || new Date();
return date.getDate().toString();
}
这个方法根据月份的数字返回月份的名称:
export const getMonthString = (monthInt) => {
if (typeof monthInt === 'string') {
monthInt = parseInt(monthInt);
}
return monthNames[monthInt];
}
现在,是时候创建一些方法来访问 AsyncStorage。
存储方法
我们将在应用程序中存储的 listOfExpenses 将是一个多层对象。
从视觉上看,我们将构建成这样:
listOfExpenses = {
2017: {
01: {
budget: 500,
expenses: [
{
amount: '4',
category: 'Coffee',
date: 'Jan 12, 2017'
description: 'Latte @ Coffeeshop'
},
{
amount: '1.50',
category: 'Books',
date: 'Jan 17, 2017'
description: 'Sunday Newspaper'
}
]
}
}
}
我们想要为存储创建的方法涉及以下内容:
-
getAsyncStorage: 这将检索AsyncStorage中的支出列表 -
setAsyncStorage: 接受一个对象并将其保存到AsyncStorage中作为支出列表 -
checkCurrentMonth: 这个方法允许我们接受一个月份和年份作为字符串化的数字,并找出是否为该月份和年份设置了预算,如果没有设置,则返回false,如果设置了,则返回预算 -
saveMonthlyBudget: 接受一个月份和年份作为字符串化的数字和一个预算作为数字,然后创建该month对象并将其存储在我们的支出列表中的正确年份,最后将其保存到AsyncStorage中 -
saveExpenseToMonth: 接受一个月份和年份作为字符串化的数字以及一个单独的expense对象,然后将其保存到该月份和年份的预算中 -
resetAsyncStorage: 这是一个特定于开发的方法,它将清除AsyncStorage中的数据,这样我们就可以在我们需要的时候清除我们的列表 -
logAsyncStorage: 这是一个特定于开发的方法,用于记录当前存储在AsyncStorage中的对象,这样我们就可以在需要时查看它
从 React Native 导入AsyncStorage API 和dateMethods实用文件:
// Expenses/app/utils/storageMethods.js
import { AsyncStorage } from 'react-native';
import * as dateMethods from './dateMethods';
获取存储中键为expenses的对象并返回它:
export const getAsyncStorage = async () => {
let response = await AsyncStorage.getItem('expenses');
let parsedData = JSON.parse(response) || {};
return parsedData;
}
用作为参数传递的对象覆盖存储中的expenses对象:
export const setAsyncStorage = (expenses) => {
return AsyncStorage.setItem('expenses', JSON.stringify(expenses));
}
从dateMethods获取month和year,然后从存储中获取expenses对象。如果该对象不存在或没有给定年份和/或月份的数据,则返回false,否则返回预算:
export const checkCurrentMonthBudget = async () => {
let year = dateMethods.getYear();
let month = dateMethods.getMonth();
let response = await getAsyncStorage();
if (response === null || !response.hasOwnProperty(year) ||
!response[year].hasOwnProperty(month)) {
return false;
}
return response[year][month].budget;
}
在saveMonthlyBudget中,我们获取expenses对象,然后检查结果是否存在;这样我们就可以在需要时用默认的空对象初始化AsyncStorage,这对于之前还没有在应用中输入数据的用户来说很重要:
export const saveMonthlyBudget = async (month, year, budget) => {
let response = await getAsyncStorage();
if (!response.hasOwnProperty(year)) {
response[year] = {};
}
if (!response[year].hasOwnProperty(month)) {
response[year][month] = {
budget: undefined,
expenses: [],
spent: 0
}
}
response[year][month].budget = budget;
await setAsyncStorage(response);
return;
}
我们还沿途进行检查,看我们的expenses对象是否有与传递给它的特定年份相关的对象,然后是否该year对象指向我们指向的特定month;如果没有,我们创建它。在设置包含输入的budget、expenses数组和已花费的金额(默认为零)的month对象后,我们将其直接保存回AsyncStorage。
以下代码调用setAsyncStorage并传递一个空对象,从而清除expenses对象:
export const resetAsyncStorage = () => {
return setAsyncStorage({});
}
从存储中获取expenses对象并将其记录到控制台:
export const logAsyncStorage = async () => {
let response = await getAsyncStorage();
console.log('Logging Async Storage');
console.table(response);
}
App.js 和 index.ios.js
App.js将作为我们应用导航的初始路由。它将处理决定是否显示当前月份的支出或提示输入该月预算的逻辑
根目录下的index.ios.js文件将按照与本书第一个项目Tasks中结构化的方式修改:
// Expenses/index.ios.js
import React, { Component } from 'react';
import {
AppRegistry,
NavigatorIOS,
StyleSheet
} from 'react-native';
import App from './app/App';
export default class Expenses extends Component {
render() {
return (
<NavigatorIOS
initialRoute={{
component: App,
title: 'Expenses'
}}
style={ styles.container }
/>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
}
});
AppRegistry.registerComponent('Expenses', () => Expenses);
现在,让我们创建App.js文件,并在其componentDidMount生命周期中执行以下操作:
-
在加载时,我们应该使用我们的
storageMethods文件来找出当前月份是否设置了预算:-
如果当前月份的预算已经设置,我们应该使用
Text组件将其渲染到屏幕上,让用户可以看到 -
如果尚未设置,让我们抛出一个基本的警告,让用户看到相同的内容
-
这就是我构建App组件的方式:
// Expenses/app/App.js
import React, { Component } from 'react';
import styles from './styles';
import {
Text,
View
} from 'react-native';
import * as storageMethods from './utils/storageMethods';
export default class App extends Component {
constructor (props) {
super ();
this.state = {
budget: undefined
}
}
检查当前月份的预算并将其设置在状态中。如果没有预算,提醒用户:
async componentWillMount () {
let response = await storageMethods.checkCurrentMonthBudget();
if (response !== false) {
this.setState({
budget: response
});
return;
}
alert('You have not set a budget for this month!');
}
如果设置了,渲染一个显示当前月份预算的Text元素:
render () {
return (
<View style={ styles.appContainer }>
<Text>
Your budget is { this.state.budget || 'not set' }!
</Text>
</View>
)
}
}
marginTop属性抵消了导航栏的高度:
// Expenses/app/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
appContainer: {
flex: 1,
marginTop: Navigator.NavigationBar.Styles.General.TotalNavHeight
}
});
export default styles;
接下来,让我们创建一个组件,让用户知道他们这个月的预算。
EnterBudget组件
输入预算的组件应该执行以下操作:
-
提示用户使用数字输入输入他们这个月的预算
-
包含一个按钮,允许他们保存预算。保存后,我们将执行以下操作:
-
让父
App.js组件使用在storageMethods文件中创建的saveMonthlyBudget来保存输入的预算 -
更新父
App.js组件以反映输入的预算 -
从
EnterBudget组件中退出并返回到App.js组件
-
我们还应该修改App.js组件,使其执行以下操作:
-
如果尚未设置预算,将
EnterBudget组件推送到导航器中。这应该替换当前提醒用户他们尚未设置预算的调用。这个组件不应该包含返回按钮,这样用户就必须输入这个月的预算。 -
将当前月份的名称以字符串形式传递给
EnterBudget组件。 -
将当前月份和年份以数字形式存储在其本地状态中,以便在需要时引用它们
-
包含一个方法,在用户在
EnterBudget组件中保存一个数字后,用新的预算更新自己。这应该以传递给它的 prop 的形式出现。
和往常一样,花些时间自己构建这个组件。当你完成时,继续阅读并查看我提出的解决方案。
EnterBudget组件示例
构建和链接这个组件涉及到更改App.js文件。让我们先看看这个文件,因为它将属性传递给EnterBudget组件:
// Expenses/app/App.js
...
import EnterBudget from './components/EnterBudget';
export default class App extends Component {
...
在状态中设置month和year,然后调用_updateBudget:
componentWillMount () {
this.setState({
month: dateMethods.getMonth(),
year: dateMethods.getYear()
});
this._updateBudget();
}
将EnterBudget推送到导航器并传递两个 props。隐藏导航栏,这样用户就不能不输入这个月的预算就离开:
...
_renderEnterBudgetComponent () {
this.props.navigator.push({
component: EnterBudget,
navigationBarHidden: true,
passProps: {
monthString: dateMethods.getMonthString( this.state.month),
saveAndUpdateBudget: (budget) =>
this._saveAndUpdateBudget(budget)
}
});
}
将预算保存到存储中。这个参数是从EnterBudget组件传递过来的:
async _saveAndUpdateBudget (budget) {
await storageMethods.saveMonthlyBudget(this.state.month,
this.state.year, budget);
this._updateBudget();
}
在componentWillMount中找到的,如果存在,则在状态中设置budget并渲染EnterBudget,如果不存在:
async _updateBudget () {
let response = await storageMethods.checkCurrentMonthBudget();
if (response !== false) {
this.setState({
budget: response
});
return;
}
this._renderEnterBudgetComponent();
}
}
接下来,让我们看看新的EnterBudget组件。
// Expenses/app/components/EnterBudget/index.js
import React, { Component, PropTypes } from 'react';
import {
Text,
TextInput,
Button,
View
} from 'react-native';
import styles from './styles';
import * as dateMethods from '../../utils/dateMethods';
export default class EnterBudget extends Component {
明确定义这个组件期望的props:
static propTypes = {
monthString: PropTypes.string.isRequired,
saveAndUpdateBudget: PropTypes.func.isRequired
}
constructor (props) {
super(props);
this.state = {
budget: undefined
}
}
将TextInput字段的值存储在状态中。使用数字TextInput提示用户输入他们这个月的预算:
render () {
let month = dateMethods.getMonthString(dateMethods.getMonth());
return (
<View style={ styles.enterBudgetContainer }>
<Text style={ styles.enterBudgetHeader }>
Enter Your { this.props.monthString } Budget
</Text>
<Text style={ styles.enterBudgetText }>
What's your spending goal?
</Text>
<TextInput
style={ styles.textInput }
onChangeText={ (budget) => this._setBudgetValue(budget) }
value={ this.state.budget }
placeholder={ '0' }
keyboardType={ 'numeric' }
/>
Button 在按下时调用 _saveAndUpdateBudget,如果 TextInput 为空则禁用:
<View>
<Button
color={ '#3D4A53' }
disabled={ !this.state.budget }
onPress={ () => this._saveAndUpdateBudget() }
title={ 'Save Budget' }
/>
</View>
</View>
)
}
以下代码从 App 组件调用 saveAndUpdateBudget 和 pop:
_saveAndUpdateBudget () {
this.props.saveAndUpdateBudget(this.state.budget);
this.props.navigator.pop();
}
最后,_setBudgetValue 设置 TextInput 的值:
_setBudgetValue (budget) {
this.setState({
budget
});
}
}
此组件还接收了一些如下所示的样式:
// Expenses/app/components/EnterBudget/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
与之前的容器一样,我们导入 Navigator 以通过其 height 来偏移顶部边距:
enterBudgetContainer: {
flex: 1,
marginTop: Navigator.NavigationBar.Styles.General.TotalNavHeight
},
EnterBudget 中的标题、文本和输入字段的样式如下:
enterBudgetHeader: {
color: '#3D4A53',
fontSize: 24,
margin: 10,
textAlign: 'center'
},
enterBudgetText: {
color: '#3D4A53',
fontSize: 16,
margin: 10,
textAlign: 'center'
},
textInput: {
height: 40,
borderColor: '#86B2CA',
borderWidth: 1,
color: '#3D4A53',
margin: 10,
padding: 10,
textAlign: 'center'
}
});
export default styles;
到本节结束时,你应该有一个看起来是这样的 EnterBudget 组件:

干得好!在下一节中,我们将对 App.js 进行样式设计并添加一个打开模态框的按钮。
添加支出的容器和模态框
在规划此应用程序时,我写道一旦设置了月份的预算,用户应该看到一个包含添加当月支出按钮的屏幕。
按钮的行为也得到了详细说明,我们说当点击该按钮时,用户应该看到一个模态框,允许他们输入支出的详细信息——名称、金额、购买日期以及用于分类的图标。模态框应允许用户取消或保存他们所做的条目。
我们可以创建一个组件来添加支出,该组件将包含 Button 和 Modal,其中 Modal 默认为隐藏状态,除非由 Button 激活。
让我们从创建一个名为 AddExpenses 的组件开始,它将首先执行以下操作:
-
接受
month和year作为属性 -
渲染一个
Button,当按下时,现在将提醒用户
此外,我们应在 App.js 中渲染 AddExpenses 组件:
// Expenses/app/components/AddExpenses/index.js
import React, { Component, PropTypes } from 'react';
import {
Button,
View
} from 'react-native';
export default class AddExpenses extends Component {
static propTypes = {
month: PropTypes.string.isRequired,
year: PropTypes.string.isRequired
}
我们渲染的 Modal 将利用以下属性。我还渲染了一个 Button,最终将启动此模态框:
constructor (props) {
super (props);
}
render () {
return (
<View>
<Button
color={ '#86B2CA' }
onPress={ () => alert('Add Expenses Button pressed!') }
title={ 'Add Expense' }
/>
</View>
)
}
}
这些是 App 组件的更改:
// Expenses/app/App.js
...
import AddExpenses from './components/AddExpenses';
...
export default class App extends Component {
...
render () {
return (
<View style={ styles.appContainer }>
将 month 和 year 传递给 AddExpenses:
<AddExpenses
month={ this.state.month }
year={ this.state.year }
/>
</View>
)
}
...
}
到目前为止,屏幕上应该有一个按钮被渲染:

干得好!接下来,我们将创建一个当按钮被按下时打开的模态框。
查看模态框
模态框让我们能够在另一个视图上呈现内容。在 React Native 中,我们可以使用 Modal 标签来渲染一个模态框。任何在 Modal 标签内的子元素都将被渲染在其中。
模态框有几个属性我们可以利用。以下列出的属性将用于本项目,尽管 React Native 文档中还有更多可用:
-
animationType:这控制模态框出现时对用户的动画方式。有三个选项:从底部滑动、淡入和没有动画。 -
onRequestClose:这是一个回调,当模态框被关闭时触发。 -
transparent:这是一个布尔值,用于确定模态框的透明度。 -
visible:这是一个布尔值,用于确定模态框是否可见。
由于这个模态将封装大量逻辑,让我们创建一个新的AddExpensesModal组件,该组件将返回此模态。它应该执行以下操作:
-
包含一个初始时隐藏的
Modal组件 -
从
AddExpenses组件接受month和year属性 -
从
AddExpenses接受一个作为属性的modalVisible布尔值 -
渲染一个包含当前月份和年份的字符串
我们还应该更新现有的AddExpenses组件以执行以下操作:
-
当按下
AddExpenses按钮时渲染AddExpensesModal组件,传递month、year和modalVisible属性 -
修改现有的按钮以切换模态的可见性
让我们从查看AddExpenses开始:
// Expenses/app/components/AddExpenses/index.js
...
import AddExpensesModal from '../AddExpensesModal';
export default class AddExpenses extends Component {
...
constructor (props) {
super (props);
在状态中跟踪modalVisible布尔值:
this.state = {
modalVisible: false
}
}
将布尔值、month和year传递给AddExpensesModal:
render () {
return (
<View>
<AddExpensesModal
modalVisible={ this.state.modalVisible }
month={ this.props.month }
year={ this.props.year }
/>
修改Button以调用_toggleModal而不是alert:
<Button
color={ '#86B2CA' }
onPress={ () => this._toggleModal() }
title={ 'Add Expense' }
/>
</View>
)
}
在状态中翻转modalVisible布尔值:
_toggleModal () {
this.setState({
modalVisible: !this.state.modalVisible
});
}
}
这是构建AddExpensesModal的方法:
// Expenses/app/components/AddExpensesModal/index.js
import React, { Component, PropTypes } from 'react';
import {
Modal,
Text,
View
} from 'react-native';
import styles from './styles';
export default class AddExpensesModal extends Component {
明确声明预期的props及其数据类型:
static propTypes = {
modalVisible: PropTypes.bool.isRequired,
month: PropTypes.string.isRequired,
year: PropTypes.string.isRequired
}
constructor (props) {
super (props);
}
渲染一个带有slide动画的模态框。可见性由modalVisible布尔值控制:
render () {
return (
<Modal
animationType={ 'slide' }
transparent={ false }
visible={ this.props.modalVisible }
>
在Modal中渲染一个包含Text的View:
<View style={ styles.modalContainer }>
<Text>
This is a modal to enter your { this.props.month + ' ' +
this.props.year } budget.
</Text>
</View>
</Modal>
)
}
}
这是AddExpensesModal的样式:
// Expenses/app/components/AddExpensesModal/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
marginTop: Navigator.NavigationBar.Styles.General.TotalNavHeight
}
});
export default styles;
标题和TextInput字段
您可能会注意到,我们目前还没有关闭此模态或添加任何数据以创建我们列表中新费用的方法。让我们通过添加以下内容来改变这一点:
-
一个提示用户添加费用的标题
-
一个提示用户输入费用名称的正常
TextInput字段 -
一个设置为数字键盘的数字
TextInput字段,提示用户输入费用的金额
这里是我对AddExpensesModal所做的更改:
// Expenses/app/components/AddExpensesModal/index.js
...
import {
...
TextInput,
...
} from 'react-native';
...
export default class AddExpensesModal extends Component {
...
存储两个TextInput字段的amount和description值:
constructor (props) {
super (props);
this.state = {
amount: '',
description: '',
}
}
AddExpensesModal的render方法将其显示的任何组件作为其子组件包裹:
render () {
return (
<Modal
animationType={ 'slide' }
transparent={ false }
visible={ this.props.modalVisible }
>
<View style={ styles.modalContainer }>
<Text style={ styles.headerText }>
Add an Expense
</Text>
<View style={ styles.amountRow }>
<Text style={ styles.amountText }>
Amount
</Text>
创建一个专门用于数字amount输入的TextInput字段:
<TextInput
keyboardType={ 'numeric' }
onChangeText={ (value) => this._changeAmount(value) }
placeholder={ '0' }
style={ styles.amountInput }
value={ this.state.amount }
/>
</View>
创建一个专门用于描述的TextInput字段:
<Text style={ styles.descriptionText }>
Description
</Text>
<TextInput
onChangeText={ (value) => this._changeDescription(value) }
placeholder={ 'Book on React Native development' }
style={ styles.descriptionInput }
value={ this.state.description }
/>
</View>
</Modal>
)
}
这两个方法在状态中设置amount和description值:
_changeAmount(amount) {
this.setState({
amount
});
}
_changeDescription(description) {
this.setState({
description
});
}
}
此组件已添加新的样式:
// Expenses/app/components/AddExpensesModal/styles.js
...
const styles = StyleSheet.create({
三个与金额相关的样式用于输入费用金额的行。
amountInput: {
borderColor: '#86B2CA',
borderRadius: 10,
borderWidth: 1,
color: '#3D4A53',
height: 40,
margin: 10,
padding: 10,
width: 200
},
amountRow具有justifyContent属性为space-between,以均匀地分隔Text和TextInput组件:
amountRow: {
flexDirection: 'row',
justifyContent: 'space-between'
},
amountText: {
color: '#3D4A53',
margin: 10,
marginLeft: 20,
paddingTop: 10
},
这些样式处理description和header元素:
descriptionInput: {
borderColor: '#86B2CA',
borderRadius: 10,
borderWidth: 1,
color: '#3D4A53',
height: 40,
margin: 10,
padding: 10
},
descriptionText: {
color: '#3D4A53',
marginBottom: 5,
marginLeft: 20,
marginRight: 10,
marginTop: 10
},
headerText: {
color: '#7D878D',
fontSize: 18,
fontWeight: 'bold',
marginBottom: 15,
textAlign: 'center'
},
...
});
您的AddExpensesModal现在应该看起来像这样:

DatePickerIOS plus ExpandableCell
在下一步中,您应该修改AddExpensesModal组件以包含以下内容:
-
一个仅设置日期(不包含时间)的
DatePickerIOS组件,即在该日期发生的费用。如果没有指定,则默认为今天的日期:- 您应该导入并将
DatePickerIOS包裹在我们为Tasks构建的ExpandableCell组件周围。
- 您应该导入并将
-
一行文本,解释了费用发生的日期。
这是我将ExpandableCell添加到AddExpensesModal的方式:
// Expenses/app/components/AddExpensesModal/index.js
...
import {
DatePickerIOS,
...
} from 'react-native';
import moment from 'moment';
import ExpandableCell from '../ExpandableCell';
...
export default class AddExpensesModal extends Component {
...
constructor (props) {
super (props);
现在在状态中保存了两个新属性:当前的date和expanded布尔值:
this.state = {
...
date: new Date(),
expanded: false
}
}
在第二章的ExpandableCell模块中,没有新增代码,高级功能与待办事项应用样式设置。
render () {
const expandableCellTitle = 'Date: ' + moment(this.state.date).
format('ll') + ' (tap to change)';
return (
<Modal
animationType={ 'slide' }
transparent={ false }
visible={ this.props.modalVisible }
>
...
<View style={ [styles.expandableCellContainer,
{ maxHeight: this.state.expanded ?
this.state.datePickerHeight : 40 }]}>
ExpandableCell组件的位置紧接在开支描述的TextInput之后:
<ExpandableCell
expanded={ this.state.expanded }
onPress={ () => this._onExpand() }
title={ expandableCellTitle }>
DatePickerIOS组件的mode设置为date,这样就不能选择时间:
<DatePickerIOS
date={ this.state.date }
mode={ 'date' }
onDateChange={ (date) => this._onDateChange(date) }
onLayout={ (event) => this._getDatePickerHeight(event)
}
/>
</ExpandableCell>
</View>
</View>
</Modal>
)
}
获取DatePickerIOS高度的逻辑与第二章:高级功能与待办事项应用样式设置中的逻辑相同:
...
_getDatePickerHeight (event) {
this.setState({
datePickerHeight: event.nativeEvent.layout.width
});
}
在此组件中可以找到三个新方法,它们都来自我们早期的项目Tasks,由ExpandableCell及其DatePickerIOS子组件处理。
_onDateChange (date) {
this.setState({
date
});
}
_onExpand () {
this.setState({
expanded: !this.state.expanded
});
}
}
为此组件的样式设置仅涉及一个新属性:
// Expenses/app/components/AddExpensesModal/styles.js
expandableCellContainer: {
flex: 1
},
到目前为止,你的应用看起来将如下截图所示:

更新做得很好!AddExpensesModal将最终拥有许多供用户交互的字段。
你可能已经注意到,当与ExpandableCell交互时,键盘不会自动关闭,这可能导致用户无法访问的信息。
是时候查看ScrollView组件,学习如何关闭键盘。
ScrollView
默认情况下,软件键盘上的回车键处理我们应用的关闭操作。然而,在数字键盘上不存在回车键。相反,我们可以用ScrollView组件替换我们的顶级View。
ScrollView组件围绕你的其他组件包裹,提供滚动功能。
需要注意的一个重要事项是ScrollView要求其所有子元素在样式中都拥有height属性。如果没有,则子元素将不会渲染。
让我们快速将AddExpensesModal围绕其Modal组件封装的View替换为ScrollView。
ScrollView 示例
这是更新AddExpensesModal以包含ScrollView的方式:
// Expenses/app/components/AddExpensesModal/index.js
...
import {
...
ScrollView,
...
} from 'react-native';
...
export default class AddExpensesModal extends Component {
...
将模态框的View替换为ScrollView。现在View内部的maxHeight现在是height:
render () {
...
return (
<Modal
animationType={ 'slide' }
transparent={ false }
visible={ this.props.modalVisible }
>
<ScrollView style={ styles.modalContainer }>
...
<View style={ [styles.expandableCellContainer,
{ height: this.state.expanded ?
this.state.datePickerHeight : 40 }]}>
...
</View>
</ScrollView>
</Modal>
)
}
之前是Modal组件直接子元素的View容器已被替换为ScrollView。
封装ExpandableCell的View中的maxHeight属性已被更改为height属性,以便在ScrollView中渲染。
保存开支
下一步是允许将条目保存到应用中。让我们再次修改AddExpensesModal并添加以下功能;你还需要将一些方法添加到storageMethods以及AddExpenses组件中:
-
一个用于保存开支的按钮,以下条件:
-
只有当模态框的所有字段都已填写时,才应启用。
-
当按下时,开支名称、金额和日期应保存到
AsyncStorage。 -
日期应该使用 Moment 以与
ExpandableCell标题相同的方式格式化。 -
当此逻辑完成时,模态应该关闭,输入的信息应该被清除。模态的关闭应该作为从父
AddExpenses组件传递的 prop,因为它已经有一个方法可以切换模态:-
在
storageMethods中应该创建一个新的辅助方法来处理将支出保存到AsyncStorage的逻辑。 -
在
storageMethods中应该编写另一个辅助方法来计算每月的每一项支出并将其设置为该特定月份的spent属性。当向该月的expenses数组添加新的支出时,应该触发此方法,然后在将支出保存到AsyncStorage之前修改该月的spent属性。 -
另一个用于取消支出、关闭
AddExpensesModal并清除之前输入的任何信息的按钮。它还应该能够访问来自AddExpenses的相同方法来切换模态。
-
-
作为提醒,在本章的早期,我们以以下方式可视化了我们支出列表中的单个对象:
{
amount: '4',
category: 'Coffee',
date: 'Jan 12, 2017'
description: 'Latte @ Coffeeshop'
},
目前不必担心 category 键;这将在下一章中介绍。
我首先做了的是去 storageMethods 中创建两个新的方法:getTotalSpentForMonth 和 saveItemToBudget。第一个函数 getTotalSpentForMonth 接收一个数组并遍历它。它通过使用 parseInt 将字符串转换为数字来返回总支出金额:
// Expenses/app/utils/storageMethods.js
...
const getTotalSpentForMonth = (array) => {
let total = 0;
array.forEach((elem) => {
total += parseInt(elem.amount)
});
return total;
}
第二个函数 saveItemToBudget 是一个异步函数,它首先接收 month、year 和 expenseObject 作为参数:
export const saveItemToBudget = async (month, year, expenseObject)
=> {
let response = await getAsyncStorage();
let newExpensesArray = [
...response[year][month].expenses,
expenseObject
];
let newTotal = getTotalSpentForMonth(newExpensesArray);
response[year][month].expenses = newExpensesArray;
response[year][month].spent = newTotal;
await setAsyncStorage(response);
return true;
}
...
它获取存储在 AsyncStorage 中的 expenses 对象,使用数组扩展运算符 (...) 创建一个新的数组,并将新的 expenseObject 参数添加到其中,然后使用新数组调用 getTotalSpentForMonth。
之后,它将新数组分配给该月的 expenses 属性,并计算新的总支出金额。最后,它将其保存到 AsyncStorage。
我修改的下一个文件是 AddExpenses:
// Expenses/app/components/AddExpenses/index.js
...
export default class AddExpenses extends Component {
...
render () {
return (
<View>
<AddExpensesModal
modalVisible={ this.state.modalVisible }
month={ this.props.month }
toggleModal={ () => this._toggleModal() }
year={ this.props.year }
/>
...
</View>
)
}
...
}
在前面的代码中,我将 _toggleModal 传递给 AddExpensesModal 组件,以便它可以切换模态的可见和不可见状态。
// Expenses/app/components/AddExpensesModal/index.js
...
import {
Button,
...
} from 'react-native';
...
export default class AddExpensesModal extends Component {
static propTypes = {
...
toggleModal: PropTypes.func.isRequired,
}
...
render () {
...
return (
<Modal
...
>
两个 按钮 在封装 ExpandableCell 的 View 之后渲染。只有当每个字段都包含一个值时,保存按钮才可用:
<ScrollView style={ styles.modalContainer }>
...
<Button
color={ '#86B2CA' }
disabled={ !(this.state.amount && this.state.description) }
onPress={ () => this._saveItemToBudget() }
title={ 'Save Expense' }
/>
<Button
color={ '#E85C58' }
onPress={ () => this._clearFieldsAndCloseModal() }
title={ 'Cancel' }
/>
</ScrollView>
</Modal>
)
}
...
以下代码将 amount 和 description 值设置为空字符串,以清除它们:
_clearFieldsAndCloseModal () {
this.setState({
amount: '',
description: ''
});
以下代码创建一个 expense 对象并从 storageMethods 中调用 saveItemToBudget,将其传递进去。然后,它清除 amount 和 description。
...
async _saveItemToBudget () {
const expenseObject = {
amount: this.state.amount,
date: moment(this.state.date).format('ll'),
description: this.state.description
};
let month = this.state.date.getMonth() + 1;
let year = this.state.date.getFullYear();
await storageMethods.saveItemToBudget(month, year,
expenseObject);
this._clearFieldsAndCloseModal();
}
}
到目前为止,你的 AddExpensesModal 应该几乎完成了:

你做得很好!让我们转换一下思路,开始处理我们月份支出的渲染。我们将在下一章中重新访问这个组件,以便我们可以添加通过图标对支出进行分类的功能。
显示当前月份的支出
在本章中我们之前提到的下一个特性是,如果支出被保存,它应该随后在主屏幕上的列表中渲染,该列表包含添加更多支出的按钮。
在本节中,我们将创建这个列表。我们应该创建一个名为 CurrentMonthExpenses 的组件,并对现有文件进行修改以支持它。
你应该在 storageMethods 中添加一个新函数,该函数接受月份和年份,返回该月份和年份的预算、支出列表和支出金额。
CurrentMonthExpenses 组件应该执行以下操作:
-
渲染一个显示当前月份名称和预算的标题。
-
显示从
AsyncStorage中检索的月份支出ListView,并添加一些样式和格式。至少,它应该包括支出的描述以及金额。-
正在渲染的
ListView应该是其自己的组件,这样我们就可以在下一章中重用它来显示前几个月的数据。 -
在
ListView中渲染的行应该也作为其组件来编写。
-
然后,你的 App.js 文件应该执行以下操作:
-
在
AddExpenses组件之前渲染CurrentMonthExpenses。 -
当组件挂载时加载此列表。
-
当
AddExpenses添加新的支出时,更新CurrentMonthExpenses中的ListView。
因此,你可能需要修改 AddExpenses 以执行以下操作:
- 接受一个回调作为属性,当模态框被切换时,该回调将传播到
App.js。
当你完成前面的程序后,回来查看我写的代码。
CurrentMonthExpenses 示例
我首先做的事情是将一个名为 getMonthObject 的函数添加并导出到 storageMethods.js 文件中:
// Expenses/app/utils/storageMethods.js
...
export const getMonthObject = async (month, year) => {
let response = await getAsyncStorage();
if (response[year] && response[year][month]) {
return response[year][month];
}
}
...
getMonthObject 方法从 AsyncStorage 中获取 expenses 对象,检查 year 和 month 对象的存在性,如果可能的话返回它。以下是如何在新的 currentMonthExpenses 组件中使用该方法的示例:
// Expenses/app/components/CurrentMonthExpenses/index.js
import React, { Component, PropTypes } from 'react';
import {
ListView,
Text,
View
} from 'react-native';
import styles from './styles';
import * as dateMethods from '../../utils/dateMethods';
import * as storageMethods from '../../utils/storageMethods';
import ExpenseRow from '../ExpenseRow';
export default class CurrentMonthExpenses extends Component {
static propTypes = {
budget: PropTypes.string.isRequired,
expenses: PropTypes.array.isRequired,
month: PropTypes.string.isRequired,
spent: PropTypes.number.isRequired,
year: PropTypes.string.isRequired,
}
我首先设置一个 ListView.DataSource 实例,以期待 ListView 被渲染:
constructor (props) {
super (props);
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
}),
}
}
CurrentMonthExpenses 的 render 方法为我们的 month 和 budget 创建一个标题,然后创建一个 ListView。这个 ListView 也使用了 renderSeparator 属性,它渲染一条水平线来分隔列表中的项目。
render () {
const dataSource = this.state.ds.cloneWithRows
(this.props.expenses || []);
与 expenses 数组和 budget 相关的数据作为属性从其父组件 App.js 传递到该组件:
return (
<View style={ styles.currentMonthExpensesContainer }>
<View style={ styles.currentMonthExpensesHeader }>
<Text style={ styles.headerText }>
Your { dateMethods.getMonthString(this.props.month)
+ ' ' + this.props.year } budget:
</Text>
<Text style={ styles.subText }>
{ this.props.budget }
</Text>
</View>
<ListView
automaticallyAdjustContentInsets={ false }
dataSource={ dataSource }
enableEmptySections={ true }
renderRow={ (rowData, sectionID, rowID) =>
this._renderRowData(rowData, rowID) }
renderSeparator={ (sectionID, rowID) =>
this._renderRowSeparator(sectionID, rowID) }
/>
</View>
)
}
_renderRowData 函数使用 ExpenseRow 组件渲染单个支出行,我们将在下一节中查看该组件。然后,_renderRowSeparator 返回一个包含分隔线样式的简单视图。请在此处查看:
_renderRowData (rowData, rowID) {
if (rowData) {
return (
<ExpenseRow
amount={ rowData.amount }
description={ rowData.description }
/>
)
}
}
_renderRowSeparator (sectionID, rowID) {
return (
<View
key={ rowID }
style={ styles.rowSeparator }
/>
)
}
};
这是CurrentMonthExpenses的样式:
// Expenses/app/components/CurrentMonthExpenses/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
currentMonthExpensesContainer: {
flex: 1,
},
currentMonthExpensesHeader: {
height: 80,
},
headerText: {
color: '#7D878D',
fontSize: 24,
marginBottom: 10,
marginTop: 10,
textAlign: 'center'
},
rowSeparator: {
backgroundColor: '#7D878D',
flex: 1,
height: StyleSheet.hairlineWidth,
marginLeft: 15,
marginRight: 15
},
subText: {
color: '#3D4A53',
fontSize: 18,
fontWeight: 'bold',
textAlign: 'center'
},
});
export default styles;
我们组件的样式是标准的,尽管rowSeparator是一个新的属性。这个组件的高度被设置为StyleSheet的hairlineWidth属性。这就是我们在ListView中渲染分隔每一行个体的细线的做法。
// Expenses/app/components/ExpenseRow/index.js
import React from 'react';
import {
Text,
View
} from 'react-native';
import styles from './styles';
export default (props) => {
return (
<View style={ styles.expenseRowContainer }>
<Text style={ styles.descriptionText }>
{ props.description }
</Text>
<Text style={ styles.amountText }>
{ props.amount }
</Text>
</View>
)
}
这种语法可能对你来说看起来很新,花点时间来了解它是有价值的。你在这里看到的是一个无状态函数组件。这是一个接受任何数量的传入 props 的函数,并返回一个可以在 React 应用中使用组件的函数。
ExpenseRow被编写为无状态函数组件的原因是我们不打算向其中添加任何复杂的逻辑或使用任何 React 生命周期事件。
App.js的render方法有两个重大变化:首先,我们在AddExpenses之前渲染CurrentMonthExpenses,其次,我们向AddExpenses传递一个回调,标题为updateCurrentMonthExpenses,指向这个组件中同名的_updateCurrentMonthExpenses方法。我们很快就会看到它:
// Expenses/app/App.js
...
import CurrentMonthExpenses from './components/CurrentMonthExpenses';
...
export default class App extends Component {
...
render () {
return (
<View style={ styles.appContainer }>
<CurrentMonthExpenses
budget={ this.state.budget || '0' }
expenses={ this.state.expenses }
month={ this.state.month }
spent={ this.state.spent || 0 }
year={ this.state.year }
/>
<AddExpenses
month={ this.state.month }
updateCurrentMonthExpenses={ () =>
this._updateCurrentMonthExpenses() }
year={ this.state.year }
/>
</View>
)
}
_updateBudget的主要变化是我们正在触发_updateCurrentMonthExpenses,这样当用户打开应用时,我们可以填充支出列表:
...
async _updateBudget () {
let response = await storageMethods.checkCurrentMonthBudget();
if (response !== false) {
this.setState({
budget: response
});
this._updateCurrentMonthExpenses();
return;
}
this._renderEnterBudgetComponent();
}
下面是执行大部分工作的函数。作为一个异步函数,它开始使用我们在本节早期构建的storageMethods中的getMonthObject函数,然后检查它是否存在。如果存在,它将设置budget、expenses数组以及已花费的amount到状态中。这反过来又触发了重新渲染,将任何更改的值传递到CurrentMonthExpenses组件。
async _updateCurrentMonthExpenses () {
let responseObject = await
storageMethods.getMonthObject(this.state.month, this.state.year);
if (responseObject) {
this.setState({
budget: responseObject.budget,
expenses: responseObject.expenses,
spent: responseObject.spent
});
}
}
}
在AddExpense组件中,_toggleModal方法现在触发updateCurrentMonthExpenses回调,这样当模态框切换时,最新的支出列表会被传递给CurrentMonthExpenses组件:
// Expenses/app/components/AddExpense/index.js
...
export default class AddExpenses extends Component {
static propTypes = {
...
updateCurrentMonthExpenses: PropTypes.func.isRequired
}
...
_toggleModal (boolean) {
this.setState({
modalVisible: !this.state.modalVisible
});
this.props.updateCurrentMonthExpenses();
}
}
下面是CurrentExpenses模态框现在的样子:

你到目前为止的进步做得很好!这只是我们构建这个应用的开始,我们将在下一章做更多的事情。
摘要
在这一章中,我们开始构建我们的预算应用。我们安装了一个流行的矢量图标库,发现了如何在 Xcode 中链接这个库到我们的项目,然后编写了我们应用的基本版本。
这包括了一个基本的辅助库,用于管理日期方法,另一个用于管理存储。
在应用中,我们创建了一个提示让用户输入他们这个月的预算,并确保在让他们添加支出之前收集了这些数据。然后,我们使用模态框来显示和隐藏用户添加新支出到应用的字段,并更新了ListView组件以反映新添加的支出。
下一章将更加深入。我们将最终充分利用那个矢量图标库,让用户通过图标对他们的支出进行分类,然后通过创建我们应用的第二个部分来查看过去几个月的数据,这个部分由标签栏控制。此外,我们还将创建一个进度视图来跟踪用户在本月已经花费的总金额,以便他们能更好地跟踪自己的支出。
第四章:使用支出应用的高级功能
在上一章中,我们开始着手开发一个简单命名为Expenses的支出跟踪应用。在为该应用编写了一些基本功能后,我们的下一个目标是继续开发该应用并添加新的功能,使其功能完善。本章将涵盖以下主题:
-
利用
react-native-vector-icons库在我们的应用中使用图标 -
学习如何使用
Picker组件来渲染下拉菜单,例如一个可以接受任何项目数组的用户选择界面 -
更新我们的列表视图以显示支出类别图标,并显示当前花费的进度条
-
创建一个第二个视图来渲染之前月份的支出
-
使用
Icon.TabBarIOS组件在当前月份和之前月份的视图之间切换 -
允许删除当前月份和之前月份添加的支出
由于本章内容广泛,关于将此应用修改为在 Android 设备上运行的章节已移至第九章《额外的 React Native 组件》。
使用矢量图标
在第三章《我们的第二个项目 - 预算应用》中,我们提到在点击该按钮后,用户应看到一个模态窗口,允许他们输入他们的支出详情:支出的名称、金额、购买日期以及用于分类的图标。该模态窗口应允许用户取消或保存他们所做的条目。
在我们的第一个练习中,我们应该做以下几件事情:
-
首先编写一个处理图标的工具文件:
-
此文件应包含一个对象,其中包含类别字符串化名称和我们的矢量图标库中的图标名称。
-
此文件还应包含一个方法,该方法接受图标的名称、所需大小和颜色,并返回该图标作为组件。将其视为无状态函数组件。
-
让我们看看我们如何利用上一章中安装的矢量图标库。
我们可以使用以下语句导入react-native-vector-icons:
import Icon from ' react-native-vector-icons/FontAwesome';
这将Icon的引用映射到使用 Font Awesome 图标集的组件。
要使用它,您可以像这样渲染Icon组件:
<Icon name="rocket" size={ 30 } color="#900" />
name属性告诉库从其集合中拉取哪个图标。您可以在fontawesome.io找到 Font Awesome 包含的所有图标列表。
我们的应用将包含以下 12 个图标:
-
家 -
购物车 -
餐具 -
电影 -
汽车 -
咖啡 -
飞机 -
购物袋 -
书籍 -
啤酒 -
游戏手柄 -
插头
这些图标所代表的类别将按以下顺序排列:
-
家
-
杂货
-
餐厅
-
娱乐
-
汽车
-
咖啡
-
旅行
-
购物
-
书籍
-
饮料
-
爱好
-
公用事业
考虑到这些,我们应该创建一个辅助文件,使我们能够渲染图标。
图标方法
iconMethods文件将比我们之前的辅助方法简单得多。使用iconMethods,我们的目标是保持一个映射类别名称和图标的对象,然后导出一个函数,帮助我们返回react-native-vector-icon组件。
import React from 'react';
import Icon from 'react-native-vector-icons/FontAwesome';
const expenses = [
{ amount: '4', category: 'coffee', description: 'Latte' },
{ amount: '1.50', category: 'books', description: 'Sunday Paper' },
{ amount: '35', category: 'car', description: 'Gas' },
{ amount: '60', category: 'restaurant', description: 'Steak dinner' }
];
categories对象让我们可以快速访问类别和图标名称:
export const getIconComponent = (categoryName, size, color) => {
return (
<Icon
name={ categories[categoryName].iconName }
size={ size || 30 }
color={ color || '#3D4A53' }
/>
);
}
这里,我们有一个无状态函数getIconComponent,它接受一个类别的name以及可选的size和color,然后返回我们的应用中的Icon组件。
现在我们已经构建了iconMethods文件,是时候创建一个Picker组件来选择一个类别了。
Picker
到目前为止,在这本书中,我们使用了DatePickerIOS和DatePickerAndroid让用户选择日期。每个平台也可以访问本地的Picker组件,我们可以填充一个选择数组,并允许我们的用户与之交互。
构建Picker很简单。我们首先在组件的render方法中编写一个Picker,并用Picker.Item子项填充它:
<Picker>
<Picker.Item
label='Hello'
value='hello'
/>
</Picker>
然后,我们可以给Picker一些属性。以下是在这个练习中使用的一些属性:
-
onValueChange:这是一个在项目被选中时触发的回调。它传递两个参数:itemValue和itemPosition -
selectedValue:这是对Picker列表当前值的引用
选择一个类别
我们将修改现有的AddExpensesModal组件,以添加以下功能:
-
在
ExpandableCell/DatePickerIOS组件下方创建一个Button,用于选择我们费用的类别。 -
当按钮被按下时,应该渲染一个
Picker组件供用户交互。这个Picker应该具有以下功能:-
通过映射数组而不是硬编码每一个十二个类别,我们使用
Picker项在我们的应用中包含十二个类别的列表。 -
有一个回调将选定的值设置为我们的费用选定的类别。接下来:
-
将这个
Picker作为AddExpensesModal组件内的ExpandableCell组件的子组件渲染,以便在不使用时可以折叠。 -
在
AddExpensesModal中,在日期和保存/取消按钮之间渲染前一个ExpandableCell组件。 -
使用
iconMethods中的getIconComponent函数渲染选定的类别图标(如果适用),并对其样式进行设置,使其在页面上与Picker组件的ExpandableCell在同一行显示。 -
修改
AddExpensesModal,使其提交按钮在没有用户设置类别时也禁用。
-
-
自从我们在上一章中查看它们以来,AddExpensesModal组件及其样式经历了显著的变化。
// Expenses/app/components/AddExpensesModal/index.js
...
import {
...
Picker,
...
} from 'react-native';
...
import * as iconMethods from '../../utils/iconMethods';
...
export default class AddExpensesModal extends Component {
...
constructor (props) {
super (props);
this.state = {
amount: '',
category: undefined,
categoryPickerExpanded: false,
date: new Date(),
description: '',
datePickerExpanded: false
}
}
每个ExpandableCell的expanded键被替换为两个单独的布尔值。
expandableCellTitle被替换为每个ExpandableCell的一个字符串:
render () {
const expandableCellDatePickerTitle = ...
const expandableCellCategoryPickerTitle = 'Category: ' +
(this.state.category ? iconMethods.categories
[this.state.category].name : 'None (tap to change)')
原始 ExpandableCell 的渲染已被修改以适应变量和函数名称的变化,使其更具体于其子组件:
return (
<Modal
animationType={ 'slide' }
transparent={ false }
visible={ this.props.modalVisible }
>
<ScrollView style={ styles.modalContainer }>
...
<View style={ [styles.expandableCellContainer,
{ height: this.state.datePickerExpanded ? this.state.
datePickerHeight : 40 }]}>
<ExpandableCell
expanded={ this.state.datePickerExpanded }
onPress={ () => this._onDatePickerExpand() }
title={ expandableCellDatePickerTitle }>
<DatePickerIOS
date={ this.state.date }
mode={ 'date' }
onDateChange={ (date) => this._onDateChange(date) }
onLayout={ (event) => this._getDatePickerHeight(event)
}
/>
</ExpandableCell>
</View>
<View style={ [styles.expandableCellContainer,
{ height: this.state.categoryPickerExpanded ? 200 : 40 }]}>
<View style={ styles.categoryIcon }>
{ this.state.category && iconMethods.
getIconComponent(this.state.category) }
</View>
这是新添加的 ExpandableCell 组件,在其下方渲染一个 Picker:
<ExpandableCell
expanded={ this.state.categoryPickerExpanded }
onPress={ () => this._onCategoryPickerExpand() }
title={ expandableCellCategoryPickerTitle }>
<Picker
onValueChange={ (value, index) =>
this._setItemCategory(value) }
selectedValue={ this.state.category }>
{ this._renderCategoryPicker() }
</Picker>
</ExpandableCell>
</View>
用于保存费用的 Button 组件已被修改以检查类别的存在,以便允许其保存:
<Button
color={ '#86B2CA' }
disabled={ !(this.state.amount &&
this.state.description && this.state.category) }
onPress={ () => this._saveItemToBudget() }
title={ 'Save Expense' }
/>
...
</ScrollView>
</Modal>
)
}
_clearFieldsAndCloseModal 方法已更新以适应新字段:
...
_clearFieldsAndCloseModal () {
this.setState({
amount: '',
category: undefined,
categoryPickerExpanded: false,
date: new Date(),
datePickerExpanded: false,
description: ''
});
this.props.toggleModal()
}
_onDatePickerExpand 方法只是将旧 _onExpand 方法的重命名,而 _onCategoryPickerExpand 是特定于 Picker 类别的:
...
_onCategoryPickerExpand () {
this.setState({
categoryPickerExpanded: !this.state.categoryPickerExpanded
})
}
_onDatePickerExpand () {
this.setState({
datePickerExpanded: !this.state.datePickerExpanded
});
}
通过将类别名称数组映射到新元素来渲染每个 Picker.Item:
_renderCategoryPicker () {
var categoryNames = Object.keys(iconMethods.categories);
return categoryNames.map((elem, index) => {
return (
<Picker.Item
key={ index }
label={ iconMethods.categories[elem].name }
value={ elem }
/>
)
})
}
_setItemCategory 函数在 Picker 类别的 onValueChange 回调中触发:
_setItemCategory (category) {
this.setState({
category
});
}
将 _saveItemToBudget 中的 category 属性保存到存储中:
async _saveItemToBudget () {
const expenseObject = {
amount: this.state.amount,
category: this.state.category,
date: moment(this.state.date).format('ll'),
description: this.state.description
};
await storageMethods.saveItemToBudget(this.props.month,
this.props.year, expenseObject);
this._clearFieldsAndCloseModal();
}
}
这就是新的 Picker 组件应有的样子:

我为 categoryIcon 添加了样式:
// Expenses/app/components/AddExpensesModal/styles.js
import { Dimensions, Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
...
categoryIcon: {
flex: 1,
marginLeft: Dimensions.get('window').width - 50,
position: 'absolute'
},
上述代码使用 Dimensions API 将 marginLeft 设置为屏幕宽度减去 50 像素。
...
expandableCellContainer: {
flex: 1,
flexDirection: 'row'
},
...
});
export default styles;
上述代码已更新样式,包含 flexDirection 为 row。
更新 CurrentMonthExpenses 和 ExpenseRow
到目前为止,我们一直在渲染没有类别的 CurrentMonthExpenses 和 ExpenseRow 组件,因为没有之前存在。让我们带着以下目标更新它们:
-
CurrentMonthExpenses应该访问每个项目的类别并将其传递给ExpenseRow -
ExpenseRow应该在费用行中渲染适当的分配图标,以便我们的用户可以快速了解他们花预算的部分是什么 -
我们应该使用在
StorageMethods中创建的resetAsyncStorage方法在修改ExpenseRow之前清除任何当前的费用列表,以确保未分配类别的先前费用不会引起任何问题
更新这些组件后,查看我的解决方案,如下所示:
// Expenses/app/App.js
...
export default class App extends Component {
...
componentWillMount () {
storageMethods.resetAsyncStorage();
...
}
...
}
上述代码使用了一次(之后没有保存到文件中)来清除存储。
然后,我将 CurrentMonthExpenses 组件更新为将分配给费用的 category 作为属性传递给 ExpenseRow 组件:
// Expenses/app/components/CurrentMonthExpenses/index.js
...
export default class CurrentMonthExpenses extends Component {
...
_renderRowData (rowData, rowID) {
if (rowData) {
return (
<ExpenseRow
amount={ rowData.amount }
category={ rowData.category }
description={ rowData.description }
/>
)
}
}
...
};
之后,我创建了一个新的 View 来渲染类别图标:
// Expenses/app/components/ExpenseRow/index.js
...
import * as iconMethods from '../../utils/iconMethods';
...
export default (props) => {
return (
<View style={ styles.expenseRowContainer }>
<View style={ styles.icon }>
{ iconMethods.getIconComponent(props.category) }
</View>
...
</View>
)
}
对于样式,添加了以下 icon 属性:
// Expenses/app/components/ExpenseRow/styles.js
const styles = StyleSheet.create({
...
icon: {
flex: 1,
marginLeft: 10
}
});
添加图标后,您的应用应如下所示:

更新做得很好!在下一节中,我们应该使用进度指示器给用户展示他们剩余预算的视觉化。
使用 ProgressViewIOS 更新 App.js
在上一章规划此应用时,我们写道这个列表还应突出显示用户为该月设定的预算以及一个进度指示器,显示他们离达到预算有多近。
使用 ProgressViewIOS,我们可以描绘用户向月度限额的进展。以下属性将用于本项目:
-
progress:这是一个介于0和1之间的数字,用于跟踪进度条的值 -
progressTintColor:这是一个设置进度条颜色的字符串
您可以这样渲染一个ProgressViewIOS组件:
<View>
<ProgressViewIOS
progress={ 0.75 }
progressTintColor={ '#86B2CA' }
/>
</View>
在本节中,我们应该更新Expenses以执行以下操作:
-
首先,我们应该修改
storageMethods中的checkCurrentMonthBudget函数,使其也返回该月的花费金额 -
然后,
App.js应该更新其_updateBudget函数,以考虑checkCurrentMonthBudget返回的花费金额 -
最后,将一个
ProgressViewIOS组件添加到CurrentMonthExpenses:-
它应该有一个函数来计算其
progress属性 -
它还应该显示当前花费的金额作为字符串
-
ProgressViewIOS 示例
首先,我们需要向我们的storageMethods文件中添加一个条目,以获取当前月份的花费金额,以便我们可以计算ProgressViewIOS的progress属性。这可以通过修改checkCurrentMonthBudget来实现:
// Expenses/app/utils/storageMethods.js
export const checkCurrentMonthBudget = async () => {
let year = dateMethods.getYear();
let month = dateMethods.getMonth();
let response = await getAsyncStorage();
if (response === null || !response.hasOwnProperty(year) ||
!response[year].hasOwnProperty(month)) {
return false;
}
let details = response[year][month];
return {
budget: details.budget,
spent: details.spent
}
}
在这里,我们返回一个包含budget和spent金额的对象,而不是仅仅budget。这意味着我们还需要修改App组件的_updateBudget方法接收我们的响应数据的方式。
以下对App组件的添加仅显示了_updateBudget异步方法,因为它是我们唯一修改以适应storageMethods的checkCurrentMonthBudget方法更改的部分:
// Expenses/app/App.js
...
async _updateBudget () {
let response = await storageMethods.checkCurrentMonthBudget();
if (response !== false) {
this.setState({
budget: response.budget,
spent: response.spent
});
return;
}
this._renderEnterBudgetComponent();
}
将预算作为字符串渲染的文本块已被修改,以显示当前花费的金额:
// Expenses/app/components/CurrentMonthExpenses/index.js
...
import {
ProgressViewIOS,
...
} from 'react-native';
...
export default class CurrentMonthExpenses extends Component {
...
render () {
...
return (
<View style={ styles.currentMonthExpensesContainer }>
<View style={ styles.currentMonthExpensesHeader }>
...
<Text style={ styles.subText }>
{ this.props.spent } of { this.props.budget } spent
</Text>
一个ProgressViewIOS组件也紧随前面的文本块之后挂载,将其progress属性指向一个名为_getProgressViewAmount的函数,该函数为我们计算它:
<ProgressViewIOS
progress={ this._getProgressViewAmount() }
progressTintColor={ '#A3E75A' }
style={ styles.progressView }
/>
</View>
...
</View>
)
}
以下代码是一个简单的除法,用于获取百分比:
_getProgressViewAmount () {
return this.props.spent/this.props.budget;
}
...
};
将margin设置为10,以便它不会达到屏幕边缘:
// Expenses/app/components/CurrentMonthExpenses/styles.js
...
const styles = StyleSheet.create({
...
progressView: {
margin: 10
},
...
});
export default styles;
将两个属性的fontSize都减少到16,并将alignSelf设置为center:
// Expenses/app/components/ExpenseRow/styles.js
...
const styles = StyleSheet.create({
amountText: {
alignSelf: 'center',
color: '#86B2CA',
flex: 1,
fontSize: 16,
marginRight: 10,
textAlign: 'right'
},
descriptionText: {
alignSelf: 'center',
color: '#7D878D',
fontSize: 16,
textAlign: 'left'
},
});
export default styles;
由于这些变化,您的应用现在应该有一个进度指示器,显示用户距离达到本月预算还有多远。
多亏了App.js中的_updateCurrentMonthExpenses函数,保存新的月度花费将导致花费金额和进度指示器相应更新,而无需编写任何新的逻辑:

看起来很棒!我们的下一步是创建一个第二个视图,让我们可以查看所有月份的花费,这反过来又让我们使用 TabBar 在两个视图之间切换。
前几个月份花费的视图
我们的前几个月份花费视图应该是一个渲染我们预算中现有月份列表的视图。它应该给出月份、该月的预算分配,并且年份通过某种标题分隔。然后,在点击时,它应该转到每个月份的摘要,显示我们之前输入的月份花费。
我们应该在名为 mockDataMethods 的新工具文件中创建一些函数,以帮助我们模拟前几个月的数据并填充我们的应用程序:
// Expenses/app/utils/mockDataMethods.js
import { setAsyncStorage } from './storageMethods';
const years = ['2017', '2016', '2015'];
const months = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
const expenses = [
{ amount: '4', category: 'coffee', description: 'Latte' },
{ amount: '1.50', category: 'books', description: 'Sunday Paper' },
{ amount: '35', category: 'car', description: 'Gas' },
{ amount: '60', category: 'restaurant', description: 'Steak dinner' }
];
如上所述,已创建并填充了年份、月份和支出数组的数组。
const mockObject = {
budget: 500,
expenses: expenses,
spent: 100.5
};
我为每个月创建了一个模拟对象:
export const mockPreviousMonthExpenses = async () => {
let mockedPreviousMonthsExpensesObject = {};
years.forEach((year) => {
mockedPreviousMonthsExpensesObject[year] = {};
months.forEach((month) => {
if (year === '2017' && (parseInt(month) > 1)) {
return;
}
mockedPreviousMonthsExpensesObject[year][month] =
Object.assign({}, mockObject);
});
});
setAsyncStorage(mockedPreviousMonthsExpensesObject);
}
此函数遍历 years 数组,并在该年份的属性中创建一个空对象。然后,它在其中进行另一个循环,为该 year 中的 month 创建一个类似的对象,并将 mockObject 分配给它。在这个过程中,如果尝试为 2017 年 1 月之后月份创建任何模拟支出,它还会停止函数。
然后,我们使用我们的 setAsyncStorage 函数将我们的模拟支出对象分配为应用程序的真实来源:
// Expenses/app/App.js
import { mockPreviousMonthExpenses } from './utils/mockDataMethods';
...
export default class App extends Component {
...
componentWillMount () {
mockPreviousMonthExpenses();
storageMethods.logAsyncStorage();
...
}
...
}
在 App.js 的 componentWillMount 生命周期中,我们可以调用在 mockDataMethods 中创建的 mockPreviousMonthExpenses 函数,以用此模拟数据填充我们的本地存储。
此外,我们使用 storageMethods 中的 logAsyncStorage 方法将信息记录到控制台,以便我们可以看到我们的模拟数据已被保存到存储中以便以后使用。
完成此步骤后,你应该从 App.js 中删除 mockPreviousMonthExpenses 函数,因为它不需要持续调用。以下是我们的模拟支出将看起来如何。你可以通过在对象上调用 console.table 并打开 Chrome 开发者工具来查看你的结果。

完成此步骤后,我们应该创建一个新的组件来显示这些详细信息。由于我们想要一个包含某种标题以分隔年份的列表,我们将渲染一个 ListView 并使用 renderSectionHeader 属性在我们的应用程序中创建分区。
带有分区标题的 ListView
为了设置 ListView 以适应分区标题,我们需要对我们创建 ListViews 的方式做一些修改。
首先,当我们实例化一个新的 ListView DataSource 时,我们将传递第二个回调 sectionHeaderHasChanged。像 rowHasChanged 一样,这个回调检查分区标题是否已更改。在你的代码中,它看起来像这样:
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2,
sectionHeaderHasChanged: (s1, s2) => s1 !== s2
}),
}
然后,我们不再调用 cloneWithRows,而是调用同名的 cloneWithRowsAndSections 函数:
const dataSource = this.state.ds.cloneWithRowsAndSections
(this.state.listOfExpenses);
最后,你的 ListView 组件现在应该接受一个函数作为其 renderSectionHeader 属性,该函数将渲染应用程序的分区标题:
<ListView
...
renderSectionHeader={ (sectionData, sectionID) =>
this._renderSectionHeader(sectionData, sectionID) }
/>
_renderSectionHeader (sectionData, sectionID) {
return (
<View>
<Text>{ sectionID }</Text>
</View>
)
}
现在你已经了解了如何将分区标题应用到你的 ListView 组件上,现在是时候应用这些知识了。让我们创建一个新的组件,PreviousMonthsList。此组件应该执行以下操作:
-
目前,将
App.js中CurrentMonthExpenses的渲染替换为你的PreviousMonthsList组件,这样你可以在编写组件时查看你的进度。 -
从
AsyncStorage获取支出列表并将其保存到组件状态 -
渲染一个带有部分标题的
ListView,显示月份名称作为字符串以及该月的预算数字以及我们mocked支出提供的每年部分标题。 -
为行和部分标题设置不同的样式,以便彼此区分。
一旦构建了你的组件版本,检查我构建的那个:
// Expenses/app/App.js
...
import PreviousMonthsList from './components/PreviousMonthsList';
export default class App extends Component {
...
render () {
return (
<View style={ styles.appContainer }>
<PreviousMonthsList />
...
</View>
)
}
...
}
我首先将PreviousMonthsList导入到App.js中,并用PreviousMonthsList替换了render方法中CurrentMonthExpenses被挂载的位置。这样,我就可以更简单地工作在这个组件上,知道我做的任何更改都会立即显现。
在完成此组件后,我将App.js恢复到其原始状态(在创建PreviousMonthsList组件之前):
// Expenses/app/components/PreviousMonthsList/index.js
import React, { Component } from 'react';
import {
ListView,
Text,
View
} from 'react-native';
import styles from './styles';
import * as dateMethods from '../../utils/dateMethods';
import * as storageMethods from '../../utils/storageMethods';
export default class PreviousMonthsList extends Component {
constructor (props) {
super (props);
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2,
sectionHeaderHasChanged: (s1, s2) => s1 !== s2
}),
listOfExpenses: {}
};
}
将新的sectionHeaderHasChanged函数传递给保存在组件状态中的DataSource实例。
在componentWillMount生命周期事件中,我异步调用storageMethods中的getAsyncStorage函数,并将结果保存到组件状态的listOfExpenses属性中:
async componentWillMount () {
let result = await storageMethods.getAsyncStorage();
this.setState({
listOfExpenses: result
});
}
将dataSource常量分配给保存在组件状态中的ListView.DataSource实例的cloneWithRowsAndSections方法,以便在应用中提供我们支出列表的部分标题:
render () {
const dataSource = this.state.ds.cloneWithRowsAndSections
(this.state.listOfExpenses);
return (
<View style={ styles.previousMonthsListContainer }>
<ListView
automaticallyAdjustContentInsets={ false }
dataSource={ dataSource }
renderRow={ (rowData, sectionID, rowID) => this._
renderRowData(rowData, rowID) }
renderSectionHeader={ (sectionData, sectionID) => this._
renderSectionHeader(sectionData, sectionID) }
renderSeparator={ (sectionID, rowID) => this._
renderRowSeparator(sectionID, rowID) }
/>
</View>
)
}
处理渲染行数据、部分标题和分隔符的三个无状态函数中没有什么特别之处。
_renderRowData (rowData, rowID) {
return (
<View style={ styles.rowDataContainer }>
<Text style={ styles.rowMonth }>
{ dateMethods.getMonthString(rowID) }
</Text>
<Text style={ styles.rowBudget }>
{ rowData.budget }
</Text>
</View>
)
}
_renderRowSeparator (sectionID, rowID) {
return (
<View
key={ sectionID + rowID }
style={ styles.rowSeparator }
/>
)
}
_renderSectionHeader (sectionData, sectionID) {
return (
<View style={ styles.sectionHeader }>
<Text style={ styles.sectionText }>
{ sectionID }
</Text>
</View>
)
}
}
rowDataContainer样式被赋予一个flexDirection属性,设置为row,以便在它内部渲染的两个字符串位于同一行:
// Expenses/app/components/PreviousMonthsList/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
previousMonthsListContainer: {
flex: 1
},
rowBudget: {
color: '#86B2CA',
flex: 1,
fontSize: 20,
marginRight: 10,
textAlign: 'right'
},
rowDataContainer: {
flex: 1,
flexDirection: 'row',
marginTop: 10,
height: 30
},
sectionHeader有自己的样式,以便可以设置特定的height和backgroundColor,从而在视觉上将其与PreviousMonthsList组件中的数据行区分开来:
rowMonth: {
color: '#7D878D',
flex: 1,
fontSize: 20,
marginLeft: 10,
textAlign: 'left'
},
rowSeparator: {
backgroundColor: '#7D878D',
flex: 1,
height: StyleSheet.hairlineWidth,
marginLeft: 15,
marginRight: 15
},
sectionHeader: {
height: 20,
backgroundColor: '#86B2CA'
},
sectionText: {
color: '#7D878D',
marginLeft: 10
}
});
export default styles;
在这一点上,通过临时将PreviousMonthsList渲染到CurrentMonthExpenses之前的位置,我们得到了一个看起来像这样的视图:

上个月的支出
我们想要执行的下一步是创建一个视图,显示每个月的支出,然后允许用户通过点击这些月份之一进入该视图。当用户在PreviousMonthsList组件中长按月份时,应导航到该视图。
幸运的是,我们已经有了一个可以为我们处理这个的组件。在上一个章节中,我们构建了CurrentMonthExpenses组件,用于渲染给定月份的支出。
到目前为止,在我们的应用中,CurrentMonthExpenses仅在App.js中渲染。它渲染的容器有一个顶部边距偏移,以适应导航栏。
如果我们想要重用CurrentMonthExpenses组件来渲染任何月份的支出,我们应该构建一些逻辑,以便在通过PreviousMonthsList导航到组件时,选择性地包含等于导航栏高度的顶部边距偏移。
这可以通过让CurrentMonthExpenses接受一个可选的布尔值作为属性,然后将其currentMonthExpensesContainer样式中的顶部边距设置为导航栏的高度来实现:
// Expenses/app/components/CurrentMonthExpenses/index.js
...
import {
...
Navigator,
} from 'react-native';
...
export default class CurrentMonthExpenses extends Component {
static propTypes = {
...
isPreviousMonth: PropTypes.bool,
}
...
render () {
...
return (
<View style={ [styles.currentMonthExpensesContainer,
this.props.isPreviousMonth ? {marginTop: Navigator.
NavigationBar.Styles.General.TotalNavHeight} : {}] }>
...
</View>
)
}
...
};
导入Navigator以便我们能够访问其导航栏的高度。CurrentMonthExpenses组件的propTypes已更新,以期望一个可选的布尔值,标题为isPreviousMonth。
然后,CurrentMonthExpenses的render方法检查是否已将isPreviousMonth布尔值传递给组件。如果是,它将marginTop属性添加到currentMonthExpensesContainer样式,其值等于导航栏的高度。
现在是时候将PreviousMonthsList中的每一行链接到导航到CurrentMonthExpenses组件。修改您的PreviousMonthsList组件,使其执行以下操作:
-
导入并包装每个正在渲染的行上的
TouchableHighlight组件。 -
当按下其中一行时,您的应用应导航到
CurrentMonthExpenses。 -
作为导航到
CurrentMonthExpenses的一部分,您的应用导航器应传递给它所有它期望的属性(如CurrentMonthExpenses的propTypes对象中指定的),以及可选的isPreviousMonth属性设置为true。
当您完成此步骤后,查看我提出的解决方案:
// Expenses/app/components/PreviousMonthsList/index.js
...
import {
...
TouchableHighlight,
} from 'react-native';
import CurrentMonthExpenses from '../CurrentMonthExpenses';
...
export default class PreviousMonthsList extends Component {
...
_renderRowData (rowData, sectionID, rowID) {
return (
<View style={ styles.rowDataContainer }>
<TouchableHighlight
onPress={ () => this._renderSelectedMonth(rowData,
sectionID, rowID) }
style={ styles.rowDataTouchableContainer }>
...
</TouchableHighlight>
</View>
)
}
我修改了PreviousMonthsList的_renderRowData方法,将其包装在一个TouchableHighlight组件周围。由于TouchableHighlight只能接受一个直接子组件,我将两个文本元素包装在一个View中。
_renderSelectedMonth方法将CurrentMonthExpenses推送到导航器,并传递给它该组件所有预期的属性。我使用了Number.toString原型方法将rowData.budget数字转换为字符串,因为CurrentMonthExpenses期望其budget属性为字符串:
...
_renderSelectedMonth (rowData, sectionID, rowID) {
this.props.navigator.push({
component: CurrentMonthExpenses,
title: dateMethods.getMonthString(rowID) + ' ' + sectionID,
passProps: {
budget: rowData.budget.toString(),
expenses: rowData.expenses,
isPreviousMonth: true,
month: rowID,
spent: rowData.spent,
year: sectionID
}
})
}
}
我将rowDataTouchableContainer样式的height设置为30像素,并将textRow的flexDirection属性设置为row,这样其中的两个Text组件就能正确渲染:
// Expenses/app/components/PreviousMonthsList/styles.js
...
const styles = StyleSheet.create({
...
rowDataTouchableContainer: {
flex: 1,
height: 30
},
...
textRow: {
flex: 1,
flexDirection: 'row'
}
});
...
当用户按下前一个月的名称时,他们将现在看到以下视图:

现在,我们的用户在应用中有两条不同的路径可以选择:他们可以在App.js中查看添加当前月份的费用列表,或者他们可以查看他们之前所有月份的费用存档。在下一节中,我们将学习如何实现标签栏来显示这两个视图。
实现TabBarIOS
TabBarIOS在屏幕底部渲染一个标签导航栏。该栏可以包含多个图标,每个图标负责不同的视图。
TabBarIOS渲染的不同标签被认为是标签栏的项目。它们被声明为TabBarIOS.Item组件,并且作为TabBarIOS的子组件嵌套。
然而,使用我们导入的 react-native-vector-icons 库,我们希望使用 Font Awesome 图标进行导航。我们不会将 TabBarIOS.Item 组件作为 TabBarIOS 组件的子组件渲染,而是用 Icon.TabBarItemIOS 替换它们。
Icon.TabBarItemIOS 是一个组件,其行为方式与 TabBarIOS.Item 完全相同,但它有一些特定的额外属性。在我们的实现中,我们将使用以下属性:
-
onPress:这是一个当用户点击标签时触发的回调。这应该至少在您的状态中将选定的组件设置为布尔值。 -
selected:这是一个布尔值,用于确定特定的标签是否在应用的前台。 -
title:这是一个显示在图标下方的文本的字符串。 -
iconName:这是一个映射到你希望显示的图标的字符串。 -
iconSize:这是一个指定图标大小的数字。
iconColor 和 selectedIconColor 需要将 renderAsOriginal 设置为 true,以便这些子级设置覆盖父 TabBarIOS 组件的 tintColor 和 unselectedTintColor 属性。
下面是一个示例 TabBarIOS 组件可能的样子:
<TabBarIOS>
<Icon.TabBarItemIOS
iconName={ 'home' }
iconSize={ 20 }
onPress={ () => this._setSelectedTab('home') }
selected={ this.state.selectedTab === 'home' }
title={ 'home' }
>
{ this._renderHomeView() }
</Icon.TabBarItemIOS>
</TabBarIOS>
如你所见,TabBarIOS 包围了 Icon.TabBarItemIOS 子组件。这些子组件各自处理自己的图标类型、大小和标题。它们还围绕一个渲染各自视图的函数。
现在,是你尝试的时候了。让我们通过以下步骤将 TabBarIOS 组件构建到我们的应用中:
-
使用
TabBarIOS将应用分成 当前月份 和 上个月份 的支出。为每个选择一个合适的图标! -
根据需要调整样式,以适应
TabBarIOS的任何尺寸。 -
为每个
PreviousMonthsList提供自己的NavigatorIOS组件,这样当PreviousMonthsList导航到上个月份的支出列表时,TabBarIOS组件不会消失。 -
从
index.ios.js中的根级NavigatorIOS组件中移除导航栏,以便PreviousMonthsList的导航组件不会渲染第二个导航栏。
一旦你为应用构建了标签导航,请返回并查看我写的版本:
// Expenses/index.ios.js
...
export default class Expenses extends Component {
render() {
return (
<NavigatorIOS
...
navigationBarHidden={ true }
/>
);
}
}
...
对 index.ios.js 根文件所做的唯一更改是添加了 navigationBarHidden 属性,将其设置为 true 以确保导航栏不会显示。
// Expenses/app/App.js
...
import {
NavigatorIOS,
...
} from 'react-native';
...
在 App.js 组件中,我现在正在显式地将项目设置为默认值,以便依赖于该属性的组件在属性未定义时不会抛出错误。此外,expenses 键已被修改为对象——这将是 StorageMethods 中的 getAsyncStorage 方法返回的完整对象,而不仅仅是当前月份的:
export default class App extends Component {
constructor (props) {
super();
this.state = {
budget: '',
expenses: {},
selectedTab: 'currentMonth',
}
}
整个 expenses 对象将被传递到 PreviousMonthsList 中,允许它渲染所有支出数据,并在通过 AddExpenses 组件向前一个月添加新项目时更新。
旧的 expenses 数组,它仅引用当前月份,现在被明确标记为 currentMonthExpenses。
组件的状态中还有一个新项目--selectedTab。它默认为 currentMonth,这是我们打算渲染的最左侧标签。
componentWillMount () {
this.setState({
spent: 0,
currentMonthExpenses: [],
month: dateMethods.getMonth(),
year: dateMethods.getYear()
});
...
}
App.js 的渲染方法已经完全重写。现在它返回一个 TabBarIOS 组件和两个 Icon.TabBarItemIOS 子组件,每个组件渲染不同的视图:
render () {
return (
<TabBarIOS>
<Icon.TabBarItemIOS
iconName={ 'usd' }
iconSize={ 20 }
onPress={ () => this._setSelectedTab('currentMonth') }
title={ 'Current Month' }
selected={ this.state.selectedTab === 'currentMonth' }
>
{ this._renderCurrentMonthExpenses(this.state.
currentMonthExpenses) }
</Icon.TabBarItemIOS>
<Icon.TabBarItemIOS
iconName={ 'history' }
iconSize={ 20 }
onPress={ () => this._setSelectedTab('previousMonths') }
title={ 'Previous Months' }
selected={ this.state.selectedTab === 'previousMonths' }
>
{ this._renderPreviousMonthsList(this.state.expenses) }
</Icon.TabBarItemIOS>
</TabBarIOS>
)
}
_renderCurrentMonthExpenses 方法包含此组件的旧 render 方法:
_renderCurrentMonthExpenses () {
return (
<View style={ styles.appContainer }>
<CurrentMonthExpenses
budget={ this.state.budget }
expenses={ this.state.currentMonthExpenses }
month={ this.state.month }
spent={ this.state.spent }
year={ this.state.year }
/>
<AddExpenses
month={ this.state.month }
updateExpenses={ () => this._updateExpenses() }
year={ this.state.year }
/>
</View>
)
}
_renderPreviousMonthsList 方法返回一个初始路由为 PreviousMonthsList 的 NavigatorIOS 组件。App.js 状态中的 expenses 键通过 passProps 传递给 PreviousMonthsList:
...
_renderPreviousMonthsList () {
return (
<NavigatorIOS
initialRoute={{
component: PreviousMonthsList,
title: 'Previous Months',
passProps: {
expenses: this.state.expenses
}
}}
style={ styles.previousMonthsContainer }
/>
)
}
_setSelectedTab 函数将 App.js 组件状态中的 selectedTab 属性更改为传递给它的参数。这是由 Icon.TabBarItemIOS 的 onPress 属性触发的回调:
...
_setSelectedTab (selectedTab) {
this.setState({
selectedTab
});
}
最后的更改是 _updateCurrentMonthExpenses 现在调用 storageMethods.js 中的 getAsyncStorge 而不是 getMonthObject,并且已重命名为 _updateExpenses 以反映其新功能:
...
async _updateBudget () {
...
this._updateExpenses();
...
}
...
}
async _updateExpenses () {
let response = await storageMethods.getAsyncStorage();
if (response) {
let currentMonth = response[this.state.year][this.state.month];
this.setState({
budget: currentMonth.budget,
currentMonthExpenses: currentMonth.expenses,
expenses: response,
spent: currentMonth.spent
});
}
}
}
previousMonthsContainer 样式应用于渲染 PreviousMonthsList 的 NavigatorIOS 实例。它不包含等于导航栏高度的顶部边距,因为这会导致导航栏本身具有等于其自身高度的顶部边距:
// Expenses/app/styles.js
...
const styles = StyleSheet.create({
...
previousMonthsContainer: {
flex: 1,
marginBottom: 48
}
});
export default styles;
对 PreviousMonthsList 的微小更改是我现在正在检查传递给它的 expenses 对象,并且在 render 方法中的 cloneWithRowsAndSections 调用现在已更新以反映这一点。
此外,componentWillMount 生命周期方法已被完全移除,因为我们现在作为属性获取所有数据。
// Expenses/app/components/PreviousMonthsList/index.js
import React, { Component, PropTypes } from 'react';
...
export default class PreviousMonthsList extends Component {
static propTypes = {
expenses: PropTypes.object.isRequired
}
...
render () {
const dataSource = this.state.ds.cloneWithRowsAndSections
(this.props.expenses);
...
}
...
}
在此组件中,previousMonthsListContainer 的 marginTop 属性已设置为导航栏高度:
// Expenses/app/components/PreviousMonthsList/styles.js
import { Navigator, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
previousMonthsListContainer: {
...
marginTop: Navigator.NavigationBar.Styles.General.TotalNavHeight,
},
...
});
export default styles;
最后,AddExpenses 组件已经调整,以反映将 _updateCurrentExpenses 函数名称更改为 _updateExpenses:
// Expenses/app/AddExpenses/index.js
...
export default class AddExpenses extends Component {
static propTypes = {
...
updateExpenses: PropTypes.func.isRequired,
...
}
...
_toggleModal (boolean) {
...
this.props.updateExpenses();
}
}
这是实现 TabBarIOS 后你的应用应该看起来的样子:

我们还应该对应用做最后一件事,那就是让用户能够删除支出。
删除支出
为了让用户能够从任何月份删除支出,我们需要做以下几件事:
-
在
storageMethods中添加一个方法来更新expenses对象。它应该接受月份、年份和支出数组。然后,它应该用这个新数组覆盖AsyncStorage中特定月份和年份组合的支出数组,并重新计算支出金额。 -
将
TouchabbleHighlight组件包裹在每一行周围,当长按时,会弹出一个提示框询问用户是否希望删除该项目。 -
如果用户确认他们想要删除该项目,那么我们应该删除它。应该创建一个新的当前月份支出数组,不包含已删除的项目,并使用我们制作的
storageMethods函数保存。 -
当删除一项支出时,我们应该更新当前视图以反映被删除的项目和更新的支出金额。
-
如果用户取消,则警报应关闭且不应进行任何更改。
花些时间构建这个功能;当你完成时,查看我为这部分编写的代码。
首先,我修改了 StorageMethods 文件:
// Expenses/app/utils/StorageMethods.js
...
export const saveItemToBudget = async (month, year, expenseObject) => {
let response = await getAsyncStorage();
let newExpensesArray = [
...response[year][month].expenses,
expenseObject
];
return updateMonthExpensesArray(month, year, newExpensesArray);
}
export const updateMonthExpensesArray = async (month, year, array) => {
let response = await getAsyncStorage();
let newTotal = getTotalSpentForMonth(array);
response[year][month].expenses = array;
response[year][month].spent = newTotal;
await setAsyncStorage(response);
return true;
}
我首先编写了 updateMonthExpensesArray 方法。当我编写它时,我发现代码的大部分与 saveItemToBudget 执行的代码相似,所以我重构了 saveItemToBudget 以调用新的 updateMonthExpensesArray 方法。这允许我在文件中重用一些代码,而不是写两次。
当 CurrentMonthExpenses 组件删除一个项目时,应执行 App.js 中的 _updateExpenses 方法,以便 AsyncStorage 使用不包含已删除支出的新数组更新月份和年份:
// Expenses/app/App.js
...
export default class App extends Component {
...
_renderCurrentMonthExpenses () {
return (
<View style={ styles.appContainer }>
<CurrentMonthExpenses
...
updateExpenses={ () => this._updateExpenses() }
/>
...
</View>
)
}
_updateExpenses 方法也被传递到 PreviousMonthsList 组件中,以便它可以将其传播到其自己的 CurrentMonthExpenses 的渲染,用于前几个月:
_renderPreviousMonthsList () {
return (
<NavigatorIOS
initialRoute={{
...
passProps: {
...
updateExpenses: () => this._updateExpenses()
}
}}
/>
)
}
...
}
PreviousMonthsList 进行了一些小的调整以支持添加 updateExpenses 函数:
// Expenses/app/components/PreviousMonthsList/index.js
...
export default class PreviousMonthsList extends Component {
static propTypes = {
...
updateExpenses: PropTypes.func.isRequired
}
...
_renderSelectedMonth (rowData, sectionID, rowID) {
this.props.navigator.push({
...
passProps: {
...
updateExpenses: () => this.props.updateExpenses(),
}
});
}
}
render 方法已更改,将 ListView 的 dataSource 常量设置为接受 CurrentMonthExpenses 状态中的支出值(如果存在)--这将被用于在发生删除后视觉上更新组件,以排除已删除的项目。
这是因为 PreviousMonthsList 是其自己的 NavigatorIOS 实例的初始路由,因此不会从 App.js 接收更新的属性:
// Expenses/app/components/CurrentMonthExpenses/index.js
...
import {
Alert,
...
} from 'react-native';
...
export default class CurrentMonthExpenses extends Component {
static propTypes = {
...
updateExpenses: PropTypes.func.isRequired,
}
...
render () {
const dataSource = this.state.ds.cloneWithRows
(this.state.expenses || this.props.expenses || []);
...
}
_cancelAlert 方法关闭出现的 Alert 对话框:
...
_cancelAlert () {
return false;
}
以下条件块处理了用户可能想要从列表中删除多个项目的可能性:
async _deleteItem (rowID) {
let newExpensesArray;
if (this.state.expenses) {
newExpensesArray = [...this.state.expenses];
}
if (!this.state.expenses) {
newExpensesArray = [...this.props.expenses];
}
_deleteItem 方法接收被删除行的 ID,克隆支出数组,使用 Array 原型的 splice 方法从其中删除该特定索引,然后使用我在 StorageMethods 文件中创建的 updateMonthExpensesArray 方法从 AsyncStorage 中删除它。
然后,我在本地状态中设置了一个 expenses 值,使其等于新的支出数组,并从 App.js 调用 updateExpenses 方法以更新应用中的数据:
newExpensesArray.splice(rowID, 1);
await storageMethods.updateMonthExpensesArray
(this.props.month, this.props.year, newExpensesArray);
this.setState({
expenses: newExpensesArray
});
this.props.updateExpenses();
}
每个 ExpenseRow 组件都会传递这个 _onLongPress 方法,该方法接收其 rowID 并为用户创建一个警报提示。根据所选选项,它要么取消警报,要么删除所选行:
...
_onLongPress (rowID) {
const alertOptions = [
{text: 'Cancel', onPress: () => this._cancelAlert() },
{text: 'Delete', style: 'destructive', onPress: () =>
this._deleteItem(rowID)}
];
Alert.alert('Delete Item', 'Do you wish to delete
this item?', alertOptions)
}
_renderRowData 方法已更改,以将 onLongPress 属性传递给 ExpenseRow:
_renderRowData (rowData, rowID) {
if (rowData) {
return (
<ExpenseRow
...
onLongPress={ () => this._onLongPress(rowID) }
/>
)
}
}
...
};
ExpenseRow的TouchableHighlight组件已被更新,以添加一个onLongPress回调,该回调执行从CurrentMonthExpenses作为属性传入的onLongPress方法。这就是触发警报的方式,给用户提供了删除添加的费用的选项:
// Expenses/app/components/ExpenseRow/index.js
...
export default (props) => {
return (
<TouchableHighlight
onLongPress={ () => props.onLongPress() }
...
>
...
</TouchableHighlight>
)
}
这就结束了我们对这个应用 iOS 版本的修改工作!由于这是一个构建起来很大的应用,我不希望在这个章节中带你们详细了解 Android 的修改。相反,我将在这个应用上进行的修改,以便在 Android 设备上构建,可以在第九章的末尾找到,额外的 React Native 组件。
在下一章中,我们将开始一个全新的项目。
摘要
在本章中,我们通过利用我们之前安装的矢量图标库来直观地显示用户的费用,完成了我们的费用跟踪应用的构建。我们还构建了一个第二个视图来查看前几个月的费用,并编写了一个函数来模拟所需的数据,以直观地验证我们的应用是否按预期工作。然后,我们使用标签导航将这两个视图分开,并允许用户从应用中删除费用。
第五章:第三项目 - Facebook 客户端
到目前为止,我们主要构建了仅处理用户提供的信息的应用程序。然而,许多应用程序倾向于从网络上的其他来源发送和接收数据。在本书的第三个也是最后一个项目中,我们将构建一个可以访问外部 Facebook API 的应用程序,以便用户可以访问他们的个人资料。
在本章中,您将完成以下任务:
-
计划我们的 Facebook 应用“朋友”,决定它应该具备哪些关键因素
-
获取访问 Facebook API 的权限并安装 iOS 和 Android 的官方 SDK
-
使用 Facebook API 的登录SDK 授予应用适当的权限
-
使用
GraphRequest和GraphRequestManager从 Facebook API 获取信息 -
使用
ActivityIndicator让用户直观地知道数据正在加载 -
开始构建我们 Facebook 应用的基本功能
规划应用
“朋友”将是我们将构建的第一个完整的示例,展示 React Native 的强大功能。它将涉及许多动态部分,因此深入规划应用是很好的。在基本层面上,访问 Facebook Graph API 给我们以下权限:
-
登录
-
查看您的动态
-
查看您动态上的帖子列表及其评论和点赞
-
在您的动态上添加新的帖子或评论
-
浏览您上传到 Facebook 个人资料的照片及其评论和点赞
-
查看您已确认参加的活动
-
重新发现您喜欢的页面列表
如前几章所述,我们希望将其分解为小规模的成就。到本章结束时,“朋友”应用应实现以下功能:
-
提醒用户(如果尚未登录)登录 Facebook,并使用 SDK 自动保存其身份验证令牌
-
在动态加载时,显示旋转动画以可视化数据正在加载
-
显示用户的动态
-
对于动态上的每篇帖子,显示帖子的内容以及评论和点赞的数量
-
点击时,加载并显示该特定帖子的评论链
-
允许读者对特定帖子的评论进行回复或创建新的帖子
关于 Facebook API
在我们继续之前,关于我们可以通过 Facebook API 获得的访问级别做一个说明——您只能获取已登录用户的个人信息。具体用户的好友列表通过 Facebook 的 API 无法访问,但可以访问一小部分也安装了相同应用的好友。由于在我们的项目中这并不很有用,我故意省略了它。
虽然用户的帖子和个人照片肯定会有一个包含发表评论的人的姓名和照片的评论列表,但使用当前版本的 Facebook API 无法访问这些好友的个人资料。
获取 Facebook API 凭证
这看起来是一个很好的起点。然而,在我们开始之前,我们需要将我们的应用注册到 Facebook 上。请访问 Facebook 的开发者网站并选择添加新应用。撰写本文时,网址是 developers.facebook.com。
一旦您注册了您的应用,请从 developers.facebook.com/docs/ios/ 下载 iOS 的 Facebook SDK,并将其内容解压缩到您的 Documents 文件夹中,命名为 FacebookSDK。请保持此文件夹打开;我们很快就会用到它。
之后,前往您应用的仪表板并注意 App ID。您稍后也需要这个信息。您可以在以下位置找到它:

在下一节中,我们将探讨如何安装官方的 React Native Facebook SDK。
在 iOS 和 Android 上安装 Facebook SDK
使用以下命令行初始化一个新的 React Native 项目:
react-native init Friends
然后,使用命令行导航到您刚刚创建的新项目。
React Native 的 Facebook SDK 通过 npm 在名为 react-native-fbsdk 的包中提供。我们将这样安装它:
npm install --save react-native-fbsdk
现在,按照以下步骤链接 SDK:
react-native link react-native-fbsdk
现在,按照 GitHub 上 react-native-fbsdk 仓库中的详细说明操作,该仓库位于 github.com/facebook/react-native-fbsdk。由于安装说明可能会随时更改,我强烈建议您使用该仓库中的说明。
之后,使用我们之前看到的流程(如需复习,请参阅第四章,使用 Expenses 应用的高级功能)安装 react-native-vector-icons 库。
一旦您为该项目初始化了应用并安装了 Facebook SDK 和 react-native-vector-icons 库,就到了开始玩耍的时候了。
使用 Facebook SDK 登录
我们可以在应用中尝试的第一件事是登录用户。FSBDK 有一个内置的组件称为 LoginButton,当按下时,它将使用 WebView 在应用内部将用户发送到登录屏幕。如果登录成功,将为您保存一个访问令牌,供您的应用使用,而无需您亲自跟踪它。
首先,将 FBSDK 仓库的 README 中的 LoginButton 片段添加到您的应用的 index 文件中。您将得到类似以下的内容:
// Friends/index.ios.js
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
View
} from 'react-native';
import {
AccessToken,
LoginButton
} from 'react-native-fbsdk';
从 react-native-fbsdk 仓库导入 AccessToken 和 LoginButton 模块,使用解构符号。
export default class Friends extends Component {
render() {
return (
<View style={ styles.container }>
<LoginButton
readPermissions={["public_profile", "user_photos",
"user_posts", "user_events", "user_likes"]}
readPermissions 属性接受一个字符串数组,并请求用户特定的只读权限,这些权限等于传入的数组。
Facebook API 有很多不同的权限可以请求,为了本项目的目的,我们将请求以下权限:
-
public_profile:这提供了访问用户公共 Facebook 资料中的一部分内容。这包括他们的 ID、姓名、个人资料图片等。 -
user_events:这是一个列表,其中包含一个人正在举办或已响应的事件。 -
user_likes:这是用户点击赞的 Facebook 页面的集合。 -
user_photos:这是用户上传或标记的照片。 -
user_posts:这是用户时间线上的帖子。
onLoginFinished方法被编写为异步的:
async onLoginFinished={
async (error, result) => {
if (error) {
} else if (result.isCancelled) {
alert("login is cancelled.");
} else {
const data = await AccessToken.getCurrentAccessToken()
alert(data);
}
}
}
onLogoutFinished={() => alert("logout.")}
/>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
}
});
AppRegistry.registerComponent('Friends', () => Friends);
尽管LoginButton还有一些其他属性可用,但在前面代码中展示的三个是我们需要关注的。以下是每个属性的含义:
-
publishPermissions:这表示在按钮按下时请求登录用户的发布权限。 -
onLoginFinished:这是一个在登录请求完成或产生错误时被调用的回调。 -
onLogoutFinished:这是一个在注销请求完成后调用的回调。
如果一切顺利,你将看到以下带有 Facebook 登录按钮的屏幕--居中的“使用 Facebook 登录”:

通过点击此标志,你将被带到WebView组件内的登录页面,该组件处理 Facebook 登录。
登录后,用户将看到一个提示,要求读取权限等于我们通过LoginButton组件作为属性传递的readPermissions数组中请求的权限:

一旦您的用户获得授权,您将能够从 Facebook 的 Graph API 中获取数据。
使用 Facebook Graph API
FBSDK 允许我们使用GraphRequest和GraphRequestManager类来创建请求并执行这些请求。
GraphRequest用于创建对 Graph API 的请求,而GraphRequestManager用于执行该请求。
GraphRequest
要实例化一个新的GraphRequest,我们可以传递最多三个参数:
-
graphPath:这是一个与 Graph API 端点相关的字符串,表示我们希望触发的端点。例如,要获取登录用户的信息,将使用graphPath为/me。 -
config:这是一个可选的对象,可以配置请求。该对象接受的属性都是可选的:-
httpMethod:这是一个描述此请求 HTTP 方法的字符串,例如GET或POST。 -
version:这是一个描述要使用的特定 Graph API 版本的字符串。 -
parameters:这是一个包含请求参数的对象。 -
accessToken:这是请求使用的访问令牌的字符串版本。
-
-
callback:这是一个在请求完成或失败时触发的回调函数。
一个示例GraphRequest实例将看起来像这样:
const requestMyPhotos = new GraphRequest('/me/photos/uploaded',
null, this._responseInfoCallback);
_responseInfoCallback (error, result) {
if (error) {
console.log('Error fetching data: ' + error.toString())
} else {
console.log(result);
}
}
为了执行此请求,我们将使用GraphRequestManager。
GraphRequestManager
GraphRequestManager队列请求 Facebook Graph API,并在被指示时执行它。
它可以访问以下方法:
-
addRequest: 这是一个接受GraphRequest实例并将请求推入GraphRequestManager队列中的函数。它还将回调推入一个单独的requestCallbacks队列,以便在请求完成或失败时执行。 -
addBatchCallback: 这个方法接受一个可选的回调,在请求批次完成时执行。每个GraphRequestManager实例只能接受一个回调,调用该回调并不表示批次中的每个图请求都成功--它唯一表明的是整个批次已完成执行。 -
start: 这个方法接受一个可选的数字,其值等于超时时间。如果没有传入,则默认超时时间为 0。当调用GraphRequestManager.start时,GraphRequestManager将按照先入先出的顺序向 Facebook Graph API 发起一系列请求,并在适用的情况下执行每个请求的回调函数。
在前面的示例中添加,一个GraphRequestManager请求看起来像这样:
new GraphRequestManager().addRequest(requestMyPhotos).start();
此请求创建了一个新的GraphRequestManager实例,包括其自己的新批次,将前面的requestMyPhotos任务添加到批次中,然后启动它。从这里开始,Facebook Graph API 将返回某种形式的数据。
在GraphRequest的requestMyPhotos实例中传递的回调将执行,记录错误或请求的结果。
创建我们的第一个请求
是时候创建我们的第一个请求来验证我们收到的访问令牌是否有效了。
在index.ios.js中的Friends组件内,让我们做以下几件事情:
-
创建一个名为
_getFeed的方法,该方法创建一个针对您的 Facebook 动态的GraphRequest。此方法应从/me/feed端点获取数据,并引用一个回调函数,当该GraphRequest完成时执行。您可以跳过GraphRequest可以可选接受的config对象。 -
在相同的方法
_getFeed中,创建一个新的GraphRequestManager实例,并将GraphRequest实例添加到其中;然后启动GraphRequestManager。 -
对于由
_getFeed引用的回调,当您的GraphRequest完成时,记录它接收到的错误或结果。 -
在
LoginButton的onLoginFinished回调中调用_getFeed。
当您完成时,结果应该看起来像这样:
// Friends/index.ios.js
...
import {
...
GraphRequest,
GraphRequestManager,
} from 'react-native-fbsdk';
export default class Friends extends Component {
render() {
return (
<View style={ styles.container }>
<LoginButton
...
onLoginFinished={
async (error, result) => {
...
} else {
await AccessToken.getCurrentAccessToken();
this._getFeed();
我不是在提醒访问令牌,而是在调用_getFeed。
}
}
}
...
/>
</View>
);
}
通过传递期望的端点和请求完成后要触发的回调来创建一个新的GraphRequest实例:
_getFeed () {
const infoRequest = new GraphRequest('/me/feed', null,
this._responseInfoCallback);
现在,创建一个新的GraphRequestManager实例,将infoRequest对象添加到其中,然后启动请求:
new GraphRequestManager().addRequest(infoRequest).start();
}
请求完成后,它将记录结果或遇到的错误:
_responseInfoCallback (error, result) {
if (error) {
console.log('Error fetching data: ', error.toString());
return;
}
console.log(result);
}
}
...
在你的 iOS 模拟器和远程调试已打开的情况下,登录时查看浏览器控制台:

这太棒了!这表明我们已经与 Graph API 建立了联系,并且它接受我们给出的访问令牌。现在,让我们创建一个单独的graphMethods.js实用文件,我们可以在不同的组件中使用。
图形方法
此文件的目标是创建一些与 Facebook Graph API 交互的常用方法,并将它们导出,以便我们可以在应用程序的不同组件中使用。
就像我们为Expenses创建的实用文件一样,这个graphMethods文件应该位于一个名为utils的文件夹中,该文件夹位于项目根目录下的app文件夹内:

创建此实用文件,并让它执行以下操作:
-
创建一个名为
makeSingleGraphRequest的函数,该函数接受一个请求作为参数,创建一个新的GraphRequestManager实例,将请求传递给GraphRequestManager,然后调用GraphRequestManager的start方法。 -
创建并导出一个名为
getFeed的函数,该函数接受一个回调,创建一个新的指向/me/feed的GraphRequest,并使用该回调,然后调用makeSingleGraphRequest。
一旦你的版本完成,请查看下面的我的版本:
// Friends/app/utils/graphMethods.js
import {
GraphRequest,
GraphRequestManager
} from 'react-native-fbsdk';
const makeSingleGraphRequest = (request) => {
return new GraphRequestManager().addRequest(request).start();
}
export const getFeed = (callback) => {
const request = new GraphRequest('/me/feed', null, callback);
makeSingleGraphRequest(request)
}
NavigatorIOS 和 App 组件
现在,让我们使用App.js文件创建一个App组件。在项目的app文件夹中创建此文件:

此组件应包含与之前我们在index.ios.js中拥有的类似逻辑--我们将很快用NavigatorIOS组件替换index.ios.js文件。
你的新App组件应该是本章早期编写的index.ios.js文件的反映,除了它应该导入并使用graphMethods文件而不是特定组件的_getFeed方法。
完成此任务后,请参考我的版本:
// Friends/app/App.js
import React, { Component } from 'react';
import {
View
} from 'react-native';
import {
AccessToken,
LoginButton
} from 'react-native-fbsdk';
由于GraphRequest和GraphRequestManager在graphMethods中被导入,我可以在前面的代码中的import语句中省略它们。
我正在使用解构符号从graphMethods导入getFeed方法。这将在未来很有用,因为该文件将填充更多的辅助方法:
import { getFeed } from './utils/graphMethods';
由于GraphRequest的回调包含error和result参数,我传递它们,这样_responseInfoCallback就可以使用它们:
import styles from './styles';
export default class App extends Component {
render() {
return (
<View style={ styles.container }>
<LoginButton
readPermissions={["public_profile", "user_photos",
"user_posts", "user_events", "user_likes"]}
onLoginFinished={
async (error, result) => {
if (error) {
} else if (result.isCancelled) {
alert("login is cancelled.");
} else {
await AccessToken.getCurrentAccessToken();
getFeed((error, result) =>
this._responseInfoCallback(error, result))
}
}
}
onLogoutFinished={() => alert("logout.")}
/>
</View>
);
}
_responseInfoCallback (error, result) {
if (error) {
console.log('Error fetching data: ', error.toString());
return;
}
console.log(result);
}
}
这里是App组件的基本样式:
// Friends/app/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
}
});
export default styles;
干得好!下一步是将项目根目录下的index.ios.js进行重构,执行以下操作:
-
从 React Native SDK 导入
NavigatorIOS以及你刚刚创建的App组件 -
渲染根
NavigatorIOS组件,将其App组件作为其初始路由传递
当你完成这部分后,可以查看我的解决方案:
// Friends/index.ios.js
import React, { Component } from 'react';
import {
AppRegistry,
NavigatorIOS,
StyleSheet,
} from 'react-native';
import App from './app/App';
export default class Friends extends Component {
render() {
return (
<NavigatorIOS
initialRoute={{
component: App,
title: 'Friends'
}}
style={ styles.container }
/>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5FCFF',
}
});
AppRegistry.registerComponent('Friends', () => Friends);
现在是时候为用户创建一个登录提示,这样他们只有在未登录时才能看到LoginButton组件。
创建登录提示
我们首先应该考虑我们的应用将如何表现。当它启动时,我们应该使用 FBSDK 的AccessToken API 检查是否有可用的访问令牌。如果没有,那么我们的用户未登录,我们应该显示登录按钮,就像我们在之前的Expense项目中需要预算一样。
如果/当用户登录时,我们应该获取他们的数据源,将其加载到组件状态中,然后将其记录到控制台以显示我们已获取它。
我们首先应该做的是修改App组件,使其:
-
在
componentWillMount事件中,我们使用AccessTokenAPI 的getCurrentAccessToken方法检查用户是否已登录。-
如果用户未登录,我们应该提醒用户他们未登录。在下一节中,我们将用我们创建的登录界面替换这部分内容。
-
如果用户已登录,我们应该调用
graphMethods的getFeed方法。
-
-
此外,它应该不再渲染
LoginButton组件——这部分内容将在稍后放入不同的组件中。相反,让我们让App组件暂时渲染一个字符串,显示“已登录”。
需要花费时间进行这些更改,然后检查下面的代码以查看我的工作示例:
// Friends/app/App.js
...
import {
Text,
...
} from 'react-native';
import {
...
} from 'react-native-fbsdk';
我已经移除了LoginButton对App的导入,因为它将被拆分为不同的组件。
componentWillMount逻辑调用_checkLoginStatus方法:
...
export default class App extends Component {
componentWillMount () {
this._checkLoginStatus();
}
App组件的render方法中的LoginButton组件已被替换为Text块。_responseInfoCallback函数没有更改也没有被删除:
render() {
return (
<View style={ styles.container }>
<Text>Logged In</Text>
</View>
);
}
async _checkLoginStatus函数与之前渲染的LoginButton组件的onLoginFinished回调类似:
async _checkLoginStatus ( ){
const result = await AccessToken.getCurrentAccessToken();
if (result === null) {
alert('You are not logged in!');
return;
}
getFeed((error, result) => this._responseInfoCallback(error,
result));
}
...
}
如果用户在刷新应用时未登录,他们将看到以下消息:

在你的进步上做得很好!对于下一步,在app文件夹中创建一个名为components的文件夹,在该文件夹中创建一个包含index和styles文件的LoginPage文件夹:

现在,在我们创建LoginPage的同时,让我们再次修改App组件。App组件应该执行以下操作:
-
导入
LoginPage组件 -
当用户未登录时,使用导航器的
push方法推送LoginPage组件;用此逻辑替换代码中提醒用户未登录的部分 -
将
_checkLoginStatus回调传递给LoginPage组件,以便当用户登录时,我们可以使用App组件检查登录状态,并在/me/feed中记录他们的帖子列表。
LoginPage组件应该执行以下操作:
-
包含一个视图,该视图围绕我们在本章中之前渲染的
LoginButton组件。 -
有一个
onLoginFinished回调,它执行以下操作:-
如果登录操作被取消,将错误记录到控制台。
-
如果登录操作成功,调用传递给它的
getFeed回调以及导航器的pop方法。
-
当你完成时,你的结果应该看起来像这样:
// Friends/app/App.js
...
import LoginPage from './components/LoginPage';
export default class App extends Component {
...
async _checkLoginStatus ( ){
...
if (result === null) {
this.props.navigator.push({
component: LoginPage,
title: 'Log In to Facebook',
navigationBarHidden: true,
passProps: {
getFeed: () => getFeed()
}
});
return;
}
...
}
...
}
而不是提醒用户他们未登录,我现在如果用户未登录,将通过应用导航器推送LoginPage组件。这是我编写的LoginPage组件的方式:
// Friends/app/components/LoginPage/index.js
import React, { Component } from 'react';
import {
View
} from 'react-native';
import {
LoginButton
} from 'react-native-fbsdk';
import styles from './styles';
export default class LoginPage extends Component {
render() {
return (
<View style={ styles.container }>
<LoginButton
readPermissions={["public_profile", "user_photos",
"user_posts", "user_events", "user_likes"]}
onLoginFinished={
(error, result) => {
if (error) {
console.log('Error logging in: ', error.toString());
return;
}
前面的部分如果在登录过程中发生错误,将记录错误。
在以下代码中,我们记录了用户取消登录过程的事实:
if (result.isCancelled) {
console.log('login was cancelled');
return;
}
然而,如果登录成功,我们调用getFeed和navigator.pop方法。
this.props.getFeed();
this.props.navigator.pop();
}
}
onLogoutFinished={() => alert("logout.")}
/>
</View>
);
}
}
LoginPage的样式表与Expenses/app/styles.js中找到的完全相同,因此为了简洁起见,我将其省略。
很大的进步!在下一节中,我们将创建一些存储方法来处理 Facebook 的 Graph API 的速率限制。
优化 API
Facebook 的 Graph API 当前的限制是每小时每个用户 200 次调用。这意味着如果你的应用有 100 个用户,你每小时可以调用 20,000 次。这个限制是总体的,这意味着任何单个用户可以在那个小时内消耗掉所有的 20,000 次调用。
为了减少我们对 API 发出的网络调用次数,我们应该调整我们的App组件,将 feed 数据保存在AsyncStorage中,并且只有在用户手动提示时才刷新其数据。
我们可以开始创建与Expenses中相似的AsyncStorage方法:
// Friends/app/utils/storageMethods.js
import { AsyncStorage } from 'react-native';
export const getAsyncStorage = async (key) => {
let response = await AsyncStorage.getItem(key);
let parsedData = JSON.parse(response) || {};
return parsedData;
}
export const setAsyncStorage = async (key, value, callback) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
if (callback) {
return callback();
}
return true;
}
对于这个应用,我们将在AsyncStorage中存储不同的键值对;因此,我们希望明确传递getAsyncStorage和setAsyncStorage方法一个键。
resetAsyncStorage和logAsyncStorage方法与我们之前在Expenses中使用的方法保持相同:
export const resetAsyncStorage = (key) => {
return setAsyncStorage(key, {});
}
export const logAsyncStorage = async (key) => {
let response = await getAsyncStorage(key);
console.log('Logging Async Storage');
console.table(response);
}
接下来,修改App.js中的_checkLoginStatus方法,使其执行以下操作:
-
如果用户已登录,调用
storageMethods中的getAsyncStorage方法来检查feed属性中是否存在数据。-
如果存在
feed属性,我们应该将其结果保存到App组件的状态中,名称相同。在这种情况下,我们不会调用getFeed。 -
如果键不存在,我们应该调用
getFeed。
-
现在,让我们修改App.js中的_requestInfoCallback方法,以便如果它不包含错误,它将执行以下操作:
-
使用
storageMethods中的setAsyncStorage方法保存response.data数组,使用feed作为传入的键。 -
将相同的数组保存到
App组件的本地状态中。
我的版本看起来是这样的:
// Friends/app/App.js
...
import { getAsyncStorage, setAsyncStorage } from './utils
/storageMethods';
...
export default class App extends Component {
...
async _checkLoginStatus () {
...
const feed = await getAsyncStorage('feed');
if (feed && feed.length > 0) {
this.setState({
feed
});
return;
}
如果存在feed数组,将其设置为本地状态。
否则,调用getFeed:
getFeed((error, result) => this._responseInfoCallback
(error, result));
}
_responseInfoCallback (error, result) {
...
setAsyncStorage('feed', result.data);
this.setState({
feed: result.data
});
}
}
这个更改首先检查我们在应用中保存的任何 feed 数据,然后再求助于为该数据发出外部 API 调用。在下一章中,我们将探讨一个允许我们按需刷新此数据的组件。
我们下一步应该采取的措施是让用户知道数据正在加载,这样他们就不会长时间看到一个静态屏幕。我们将使用 ActivityIndicator 组件来实现这一点。
使用 ActivityIndicator
ActivityIndicator 组件显示一个圆形加载指示器,可以让用户可视化一个 加载 动作。这对于整体用户体验很有帮助,因为用户不应该感觉他们的操作没有达到他们的目的。
我们将在本应用中使用以下两个 ActivityIndicator 属性:
-
animating:这是一个布尔值,用于显示或隐藏组件。它默认为true。 -
size:这是组件的物理大小。在 iOS 上,你的选项是两个字符串之一:small和large。在 Android 上,除了这两个字符串外,你还可以传递一个数字。此属性默认为small。
我们应该修改我们的应用程序,以便在从 Graph API 加载数据时显示这个 ActivityIndicator。
让我们修改 App 组件,以便在数据尚未保存到 App 组件状态的 feed 属性时,条件性地渲染 ActivityIndicator 组件。
我想出的解决方案如下:
// Friends/app/App.js
...
import {
ActivityIndicator,
...
} from 'react-native';
...
export default class App extends Component {
constructor (props) {
super (props);
this.state = {
feed: undefined,
spinning: true
}
}
在初始化时设置 App 组件状态中的 feed 和 spinning 值。
调用新的 _renderView 方法来条件性地确定要渲染的内容:
...
render() {
return (
<View style={ styles.container }>
{ this._renderView() }
</View>
);
}
修改 _checkLoginStatus 以在加载数据时将 spinning 属性设置为 false:
async _checkLoginStatus () {
...
if (feed && feed.length > 0) {
this.setState({
feed,
spinning: false
});
return;
}
...
}
检查 ActivityIndicator 是否仍然需要旋转。如果是,则返回 ActivityIndicator 组件。如果不是,则返回原始的 Text 组件:
_renderView () {
if (this.state.spinning) {
return (
<ActivityIndicator
animating={ this.state.spinning }
size={ 'large' }
/>
);
}
return (
<Text>Logged In</Text>
)
}
与 _checkLoginStatus 类似,修改 _responseInfoCallback 以将 spinning 设置为 false:
_responseInfoCallback (error, result) {
...
setAsyncStorage('feed', result.data);
this.setState({
feed: result.data,
spinning: false
});
}
}
现在,我们应该将我们从 Graph API 收到的数据显示在 ListView 中。
创建一个标准的 ListView
下一步是获取从 Graph API 收到的数据并将其渲染到视图中。
目前,App 组件状态中的 feed 数组包含 25 个对象。每个对象包含以下键值对:
-
created_time:这是帖子创建的日期和时间 -
id:这是一个标识符,它将使我们能够获取帖子的详细信息 -
story:这是一个可选的帖子描述,它添加了上下文,例如帖子是否包含基于位置的签到,是否是共享记忆或链接等 -
message:这是用户为这个帖子亲自写的可选消息
每个帖子都包含几个边,就像图数据结构中的节点一样。对于 Friends,我们将访问以下边:
-
/likes:这是喜欢这个特定帖子的用户列表 -
/comments:这些是对该帖子的评论 -
/attachments:这些是与该帖子关联的媒体附件
在我们可以访问边之前,我们应该渲染一个 ListView 组件,以连贯的方式显示这 25 个帖子。花些时间创建一个 ListView,使其执行以下操作:
-
以单独的行渲染 25 篇帖子
-
有条件逻辑,仅在故事和消息存在时显示
如果你已经完成了这本书中的前两个项目,ListView 对你来说不是什么新鲜事。
在你的 components 文件夹内创建一个名为 FeedList 的新组件。在这个文件中,创建一个 ListView 组件,它从传入的 prop 中获取数组并渲染一个标准的 ListView。
然后,创建一个新的辅助文件,称为 dateMethods。它应该包含一个接受日期字符串并返回格式化日期的函数。我喜欢用 MomentJS 做这类事情,但你可以随意这样做。
此外,创建另一个名为 FeedListRow 的组件,它将负责渲染 FeedList 的每一行。
之后,在 App.js 中,导入你创建的 FeedList 组件,并在 _renderData 中当前放置 Text 组件的位置渲染它。确保传递 feed 数组,以便它有数据可以渲染。用 FeedList 替换旧的 Text 组件:
// Friends/app/App.js
...
import FeedList from './components/FeedList';
...
Text 不再导入:
export default class App extends Component {
...
_renderView () {
if (this.state.spinning) {
...
}
return (
<FeedList
feed={ this.state.feed }
navigator={ this.props.navigator }
/>
);
}
...
}
接下来,FeedList 组件从 App 组件的状态中接收 feed 数组,并渲染一个标准的 ListView,明确传递每篇帖子的详细信息:
// Friends/app/components/FeedList/index.js
import React, { Component } from 'react';
import {
ListView,
View
} from 'react-native';
import FeedListRow from '../FeedListRow';
import styles from './styles';
export default class FeedList extends Component {
实例化一个新的 ListView.DataSource 对象:
constructor (props) {
super (props);
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
})
}
}
使用从 App 传入的 feed 数组来渲染 ListView,如下所示:
render () {
const dataSource = this.state.ds.cloneWithRows
(this.props.feed || []);
使用 FeedListRow 为每个单独的行渲染一个 ListView 组件,如下所示:
return (
<View style={ styles.container }>
<ListView
automaticallyAdjustContentInsets={ false }
dataSource={ dataSource }
renderRow={ (rowData, sectionID, rowID) =>
<FeedListRow
createdTime={ rowData.created_time }
message={ rowData.message }
navigator={ this.props.navigator }
postID={ rowData.id }
story={ rowData.story }
/>
}
renderSeparator={ (sectionID, rowID) =>
<View
key={ rowID }
style={ styles.separator }
/>
}
/>
</View>
)
}
}
separator 获得了自己的样式,用于分隔每一篇帖子,如下所示:
// Friends/app/components/FeedList/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
marginTop: 65
},
separator: {
flex: 1,
height: StyleSheet.hairlineWidth,
marginLeft: 15,
marginRight: 15,
backgroundColor: '#1d2129'
}
});
export default styles;
使用从 Facebook API 获取的日期字符串,然后用 moment 格式化它:
// Friends/app/utils/dateMethods.js
import moment from 'moment';
export const getDateTimeString = (date) => {
return moment(date).format('lll');
}
在 FeedListRow 中,从刚刚创建的 dateMethods 文件中导入 getDateTimeString 方法:
// Friends/app/components/FeedListRow/index.js
import React, { Component } from 'react';
import {
Text,
TouchableHighlight,
View
} from 'react-native';
import { getDateTimeString } from '../../utils/dateMethods';
为了未来的导航目的,将 TouchableHighlight 组件包裹起来,如下所示:
import styles from './styles';
export default class FeedListRow extends Component {
render () {
return (
<View style={ styles.container }>
<TouchableHighlight
onPress={ () => this._navigateToPostView() }
underlayColor={ '#D3D3D3' }
>
<View>
<Text style={ styles.created }>
{ this._renderCreatedString() }
</Text>
{ this._renderStoryString() }
<Text style={ styles.message }>
{ this._renderMessageString() }
</Text>
</View>
</TouchableHighlight>
</View>
)
}
现在是一个占位函数,我们稍后会修改它。
_navigateToPostView () {
// TODO: Push to navigator
console.log('pushed');
}
渲染帖子数据某些部分的方法。
_renderCreatedString () {
return 'Posted ' + getDateTimeString(this.props.createdTime);
}
_renderMessageString () {
return this.props.message
}
_renderStoryString () {
if (this.props.story) {
return (
<Text style={ styles.story }>
{ this.props.story }
</Text>
)
}
}
}
这是为 FeedListRow 构建的样式:
// Friends/app/components/FeedListRow/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
margin: 10
},
created: {
color: '#365899',
fontWeight: 'bold',
marginBottom: 5
},
story: {
marginBottom: 5,
textDecorationLine: 'underline'
}
});
export default styles;
你会注意到这个组件的 _navigateToPostView 方法有一个注释的任务要处理。这是本练习下一步的基础,我们将在下一章中直接跳到那里。
摘要
这是一个很长的章节,感谢你一直陪伴着我!在本章中,我们获得了访问 Facebook Graph API 的权限,为 iOS 和 Android 安装了 Facebook SDK,并开始使用 Facebook SDK 来让用户登录应用,并使用他们的访问令牌获取他们的帖子数据并将其渲染到屏幕上。
在此过程中,你还使用了一个 ActivityIndicator 组件来向用户直观地传达我们正在加载数据。
在下一章中,我们将大幅增加内容。那里见。
第六章:高级 Facebook 应用功能
现在我们已经获得了访问 Facebook 的 Graph API 的权限,是时候完成我们应用的构建了。
在本章中,我们将:
-
通过从 Graph APIs 获取更多数据来继续构建我们的 Facebook 连接应用
Friends,例如从我们动态中的每个现有帖子获取媒体附件、评论和点赞数量 -
为我们的应用添加一个下拉刷新机制,允许用户重新加载数据
-
了解
Image组件,它将允许我们在应用中渲染图片 -
发现 WebView,在本地可用的
View组件中打开链接 -
为应用添加一个注销屏幕
-
对应用进行修改以构建 Android 版本
让我们继续上一章的遗留内容,并扩展我们的FeedListRow组件。
创建 PostView
在第五章的结尾,“第三项目 - Facebook 客户端”,我们创建了一个带有TouchableHighlight的FeedListRow组件,当按下时会触发以下函数:
// Friends/app/components/FeedListRow/index.js
...
_navigateToPostView () {
console.log('pushed');
}
...
我们将构建一个PostView组件,当在FeedListRow中按下TouchableHighlight组件时,用户将导航到该组件,并在_navigfateToPostView函数中替换当前的登录以处理该导航。
这个PostView组件在加载时应该在AsyncStorage中查找该帖子的详细信息,并在存在的情况下加载它们。如果不存在,则应向 Facebook Graph API 请求帖子的详细信息并将它们保存到AsyncStorage中供将来使用。
我们感兴趣的是帖子的附件、评论和点赞。由于 Facebook 上的每个帖子都分配了一个唯一的帖子 ID,我们还可以在AsyncStorage中将包含附件、评论和点赞数据的对象保存到该帖子 ID 下作为其键。
首先,我们将在storageMethods.js中创建一个新的函数,该函数执行以下功能:
-
接受帖子 ID 和批处理回调作为参数
-
创建三个单独的
GraphRequest实例,每个实例对应我们将获取的三个边缘(附件、评论和点赞),并将返回的数据保存到对象中 -
启动一个
GraphRequestManager,链接三个GraphRequest实例,并传入批处理回调,从而将返回的数据对象传递给批处理回调函数
然后,创建一个PostView组件,执行以下操作:
-
它渲染与
FeedListRow创建的相同的故事和消息字符串,以便用户保留他们点击的内容的上下文。 -
它使用一种存储方法来检查与该特定帖子 ID 相关的数据是否存在。如果存在,则
PostView将使用它。如果不存在,则应使用我们新的存储方法来获取该帖子 ID 的附件、评论和点赞。 -
传入我们新存储方法的批处理回调应包括将结果保存到
AsyncStorage中,其键与帖子 ID 相同。 -
它将帖子的评论和点赞数以视觉形式显示在一行中。
最后,修改 FeedListRow 组件,使其使用其现有的 _navigateToPostView 方法导航到 PostView,并传递任何必要的属性。
创建一个 resultsObject 来存储每个独特的 GraphRequest 的结果:
// Friends/app/utils/graphMethods.js
...
export const getPostDetails = (id, batchCallback) => {
let resultsObject = {
attachments: undefined,
comments: undefined,
likes: undefined
}
在前面的代码中的三个 GraphRequest 实例中,使用给定的帖子 ID 调用其相应的 attachments、comments 和 likes 边从 API。然后,将这些结果保存到 resultsObject 中,其键对应于相应的键:
const attachmentsRequest = new GraphRequest('/' + id +
'/attachments', null, (error, response) => {
if (error) {
console.log(error);
}
resultsObject.attachments = response.data;
});
const commentsRequest = new GraphRequest('/' + id + '/comments',
null, (error, response) => {
if (error) {
console.log(error);
}
resultsObject.comments = response.data;
});
const likesRequest = new GraphRequest('/' + id + '/likes', null,
(error, response) => {
if (error) {
console.log(error);
}
resultsObject.likes = response.data;
});
最后,创建一个新的 GraphRequestManager 实例,并将所有三个请求以及作为参数传递给此函数的 batchCallback 添加到其中。将 resultsObject 传递给 batchCallback 以使该回调能够访问从 attachments、comments 和 likes 边获得的数据:
new GraphRequestManager()
.addRequest(attachmentsRequest)
.addRequest(commentsRequest)
.addRequest(likesRequest)
.addBatchCallback(() => batchCallback(resultsObject))
.start();
}
然后,导入将在该组件中使用到的各种不同的辅助方法,如下所示:
// Friends/app/components/PostView/index.js
import React, { Component } from 'react';
import {
ActivityIndicator,
Text,
TouchableHighlight,
View
} from 'react-native';
import { getAsyncStorage, setAsyncStorage } from '../../utils/storageMethods';
import { getDateTimeString } from '../../utils/dateMethods';
import { getPostDetails } from '../../utils/graphMethods';
import styles from './styles';
将状态中的 loading 布尔值设置为 true 以用于 ActivityIndicator:
export default class PostView extends Component {
constructor (props) {
super (props);
this.state = {
loading: true
}
}
在 componentWillMount 期间,获取存储在此帖子 ID 键下的对象。检查数据是否存在:如果不存在数据,getAsyncStorage 被配置为返回一个空对象。如果这是 true,则调用 _getPostDetails;否则,将详细信息保存到本地状态:
async componentWillMount () {
const result = await getAsyncStorage(this.props.postID);
if (Object.keys(result).length === 0) {
this._getPostDetails();
return;
}
this._savePostDetailsToState(result);
}
就像 FeedListRow 一样,如果适用,渲染创建日期、故事和信息。有条件地调用 _renderActivityIndicator 或 _renderDetails,取决于 loading 布尔值。最后,渲染一个分隔符,以期待向此组件添加评论:
render () {
return (
<View style={ styles.container }>
<View>
<Text style={ styles.created }>
{ this._renderCreatedString() }
</Text>
{ this._renderStoryString() }
<Text>
{ this._renderMessageString() }
</Text>
</View>
<View>
{ this.state.loading ? this._renderActivityIndicator() :
this._renderDetails() }
</View>
<View style={ styles.separator } />
</View>
)
}
调用我们在 graphMethods 中刚刚创建的 getPostDetails 方法,并传递一个回调,该回调使用 getPostDetails 的结果对象将内容保存到状态中;然后将其保存到 AsyncStorage 中,键等于此帖子的 ID:
async _getPostDetails () {
await getPostDetails(this.props.postID, (result) => {
this._savePostDetailsToState(result);
setAsyncStorage(this.props.postID, result);
});
}
渲染一个 ActivityIndicator 组件:
_renderActivityIndicator () {
return (
<ActivityIndicator
animating={ this.state.spinning }
size={ 'large' }
/>
)
}
按如下方式渲染此帖子拥有的 Likes 和 Comments 数量:
_renderCreatedString () {
return 'Posted ' + getDateTimeString(this.props.createdTime);
}
_renderDetails () {
return (
<View style={ styles.detailsContainer }>
<Text style={ styles.detailsRow }>
{ this.state.likes.length } Likes, {
this.state.comments.length } Comments
</Text>
</View>
)
}
_renderCreatedString、_renderMessageString 和 _renderStoryString 方法与 FeedListRow 中的方法保持不变:
_renderMessageString () {
return this.props.message
}
_renderStoryString () {
if (this.props.story) {
return (
<Text style={ styles.story }>
{ this.props.story }
</Text>
)
}
}
将此帖子的 attachments、comments 和 likes 边的数据保存到状态中,并关闭旋转的 ActivityIndicator:
_savePostDetailsToState (data) {
this.setState({
attachments: data.attachments,
comments: data.comments,
likes: data.likes,
loading: false
});
}
}
这是 PostView 的样式:
// Friends/app/components/PostView/index.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
margin: 10,
marginTop: 75,
},
created: {
color: '#365899',
fontWeight: 'bold',
marginBottom: 5
},
detailsContainer: {
flexDirection: 'row',
justifyContent: 'space-between'
},
detailsRow: {
color: '#365899',
marginBottom: 15,
marginTop: 15,
textAlign: 'left'
},
separator: {
height: 2,
marginLeft: 15,
marginRight: 15,
backgroundColor: '#365899'
},
story: {
marginBottom: 5,
textDecorationLine: 'underline'
}
});
export default styles;
最后,修改 FeedListRow 中的 _navigateToPostView 函数:
// Friends/app/components/FeedListRow/index.js
...
export default class FeedListRow extends Component {
...
_navigateToPostView () {
this.props.navigator.push({
component: PostView,
passProps: {
createdTime: this.props.createdTime,
message: this.props.message,
postID: this.props.postID,
story: this.props.story
}
});
}
...
}
接下来,我们将添加一个 ListView 来填充该帖子的评论,并在 PostView 中的分隔线下方渲染它。
向 PostView 添加评论
在这一步中,我们将编辑 PostView 以包含一个 ListView 来渲染所有评论。由于 PostView 会在 componentWillMount 生命周期方法加载信息后将其评论数据保存到其状态中,我们可以使用这些数据来渲染评论。
首先,创建一个组件来容纳这个 ListView;让我们称它为 CommentList。它应该执行以下操作:
-
包含一个由
PostView通过属性传递给它的评论列表 -
使用这些评论渲染一个
ListView: -
行应该由子组件
CommentListRow渲染
您的CommentListRow组件应该执行以下操作:
-
每行应包含其发布者的名字和他们写的消息
-
使用
ListView组件分隔每个评论
最后,更新PostView,使其在PostView的render方法中直接在分隔符下方渲染CommentList。实例化一个新的ListView.DataSource实例:
// Friends/app/components/CommentList/index.js
import React, { Component } from 'react';
import {
ListView,
Text,
View
} from 'react-native';
import CommentListRow from '../CommentListRow';
import styles from './styles';
export default class CommentList extends Component {
constructor (props) {
super (props);
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
})
}
}
设置dataSource常量,传入comments属性或一个空数组:
render () {
const dataSource = this.state.ds.cloneWithRows(this.props.comments || []);
每一行应该是一个新的CommentListRow组件:
return (
<View style={ styles.container }>
<ListView
automaticallyAdjustContentInsets={ false }
dataSource={ dataSource }
renderRow={ (rowData, sectionID, rowID) =>
<CommentListRow
message={ rowData.message }
name={ rowData.from.name } />
}
为每个评论渲染一个分隔符:
renderSeparator={ (sectionID, rowID) =>
<View
key={ rowID }
style={ styles.separator } />
} />
</View>
)
}
}
这是CommmentList样式块的样式:
// Friends/app/components/CommentList/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1
},
separator: {
flex: 1,
height: StyleSheet.hairlineWidth,
marginLeft: 15,
marginRight: 15,
backgroundColor: '#1d2129'
}
});
export default styles;
接下来,让我们看看CommentListRow:
// Friends/app/components/CommentListRow/index.js
import React, { Component } from 'react';
import {
Text,
View
} from 'react-native';
import styles from './styles';
export default (props) => {
return (
<View style={ styles.container }>
<View style={ styles.header }>
<Text style={ styles.name }>
{ props.name }
</Text>
</View>
<View style={ styles.body }>
<Text style={ styles.comment }>
{ props.message }
</Text>
</View>
</View>
)
}
一个简单的无状态函数组件返回带有发布者名字和他们的评论的评论行。以下代码块包含CommentListRow的样式:
// Friends/app/components/CommentListRow/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
body: {
marginBottom: 20,
marginLeft: 30,
marginRight: 30,
marginTop: 10,
},
comment: {
color: '#1d2129'
},
container: {
flex: 1
},
header: {
marginTop: 5,
marginLeft: 10,
marginRight: 10
},
name: {
color: '#1d2129',
fontWeight: 'bold'
}
});
export default styles;
最后,让我们看看对PostView所做的更改:
// Friends/app/components/PostView/index.js
...
import CommentList from '../CommentList';
export default class PostView extends Component {
...
render () {
return (
<View style={ styles.container }>
...
<View style={ styles.separator } />
<View style={ styles.commentListContainer }>
<CommentList comments={ this.state.comments } />
</View>
</View>
)
}
...
}
上述代码导入并渲染CommentList在分隔符下方。
commentListContainer样式看起来是这样的:
// Friends/app/components/PostView/styles.js
commentListContainer: {
flex: 1,
marginTop: 20
}
在这一点上,我们应该继续完善PostView,添加我们在本章开头描述的其他功能。在下一节中,我们将探讨如何在用户帖子或单个帖子中添加更多数据时刷新我们已有的现有数据。
使用 RefreshControl 重新加载数据
下拉刷新交互最初是在 2008 年创建的流行 Twitter iOS 应用Tweetie中构思的。这种交互涉及用户将屏幕向下拉,直到达到某个阈值,然后释放以表示他们想要刷新屏幕内容。
使用 React Native SDK,我们可以使用RefreshControl来获得相同的下拉刷新交互,并允许我们的用户在应用中随意重新加载数据。
本章我们将使用以下RefreshControl属性:
-
onRefresh:这是一个在执行刷新操作时被调用的函数 -
refreshing:这是一个布尔值,表示视图是否应该被动画化 -
tintColor:这是刷新指示器的颜色 -
title:这是一个在刷新指示器下方显示的字符串 -
titleColor:这是标题的颜色
要使用RefreshControl,将其渲染到具有refreshControl属性的ListView或ScrollView组件中。
对于我们的实现,我们首先想要修改App.js,使其执行以下操作:
-
在其状态中包含一个
refreshControlSpinning布尔值 -
修改当前
_checkLoginStatus函数,将获取存储中数据逻辑移动到其自己的函数_getFeedData中;新的_getFeedData函数在完成后也应该将refreshControlSpinning布尔值切换到false -
包含一个函数
_refreshFeedList,用于刷新帖子,将refreshControlSpinning设置为true,然后调用新的_getFeedData函数 -
将
refreshControlSpinning布尔值和_refreshFeedList函数传递给它渲染的FeedList组件
然后,修改 FeedList 以执行以下操作:
-
将
RefreshControl组件渲染到ListView的refreshControl属性中 -
将其旋转属性指向
App.js中的refreshControlSpinning布尔值 -
将
onRefresh属性指向App.js中的_refreshFeedList函数。
这里是我的对 App 组件的修改:
// Friends/app/App.js
...
export default class App extends Component {
constructor (props) {
...
this.state = {
...
refreshControlSpinning: false
}
}
我们在状态中添加了一个新的 refreshControlSpinning 布尔值。旧 spinner 布尔值被重命名为 activityIndicatorSpinning。在 _checkLoginStatus 的最后一行被拆分成自己的方法,以便稍后在以下片段中重用。同时,更新传递给 LoginPage 的 getFeed 属性,以反映新的拆分方法:
async _checkLoginStatus () {
...
if (result === null) {
this.props.navigator.push({
...
passProps: {
getFeed: () => _getFeed()
}
});
...
}
this._getFeed();
}
_getFeed () {
getFeed((error, result) => this._responseInfoCallback
(error, result));
}
让我们将 refreshControlSpinning 和 _refreshFeedList 传递给 FeedList:
_renderView () {
...
return (
<FeedList
...
refreshControlSpinning={ this.state.refreshControlSpinning }
refreshFeedList={ () => this._refreshFeedList() }
/>
);
}
将 refreshControlSpinning 布尔值设置为 true 并调用 _getFeed:
_refreshFeedList () {
this.setState({
refreshControlSpinning: true
});
this._getFeed();
}
一旦数据已加载到状态和 AsyncStorage 中,将 refreshControlSpinning 设置为 false:
_responseInfoCallback (error, result) {
...
this.setState({
refreshControlSpinning: false
...
});
}
}
向 ListView 添加一个 refreshControl 属性,它指向 _renderRefreshControl:
// Friends/app/components/FeedList/index.js
import {
...
RefreshControl,
} from 'react-native';
...
export default class FeedList extends Component {
...
render () {
...
return (
<View style={ styles.container }>
<ListView
refreshControl={ this._renderRefreshControl() }
...
/>
</View>
)
}
返回 RefreshControl 组件。它的 onRefresh 属性指向 App.js 中的 _refreshFeedList 方法,并且它刷新布尔值也指向 App.js 中的 refreshControlSpinning 属性:
_renderRefreshControl () {
return (
<RefreshControl
onRefresh={ () => this.props.refreshFeedList() }
refreshing={ this.props.refreshControlSpinning }
tintColor={ '#365899' }
title={ 'Refresh Feed' }
titleColor={ '#365899' }
/>
)
}
}
下一步是将任何图像附件渲染到 PostView 中。
渲染图像
要使用 React Native 显示图像,我们使用 Image 组件。它允许我们从本地和远程源显示图像。你还可以像为任何其他 React 组件添加样式一样为图像添加样式。
在本章中,我们将使用以下属性来为我们的 Image 组件设置样式:
-
resizeMode: 我们将使用以下字符串之一:-
cover: 这会均匀地缩放图像并保持其宽高比,使得图像的宽度和高度将等于或大于封装Image组件的视图。 -
contain: 这个字符串也会均匀地缩放图像并保持其宽高比,使得图像的宽度和高度将等于或小于封装Image组件的视图。 -
stretch: 这会独立地缩放宽度和高度,并可以改变源图像的宽高比。 -
repeat: 这会将图像重复以覆盖封装视图的整个框架。此选项在 iOS 上也保持原始大小和宽高比,但在 Android 上则不保持。 -
center: 这会将图像居中。
-
-
source: 这将是渲染的图像的远程 URL 或本地路径。 -
style: 这是一个样式对象。
在基本层面上,你可以这样加载静态图像资源:
<Image source={ require('../images/my-icon.png') } />
此外,你也可以对远程的做同样的处理:
<Image
source={{ uri: 'https://www.link-to-my-image.com/image.png' }}
style={{
width: 400,
height: 400
}} />
用户动态中每一条带有图像的帖子都可以使用 Image 组件来渲染该图像。
从 Facebook Graph API 结构图像的方式如下:
attachments: [{
media: {
image: {
height: 400,
src: 'https://www.link-to-my-image.com/image.png',
width: 400
}
}
}]
在此基础上,让我们首先创建一个名为imageMethods的新工具文件。此文件应执行以下操作:
-
从 React Native 导入
DimensionsAPI。 -
导出
getHeightRatio函数,该函数接受图像的高度和宽度,并返回图像应有的高度。我们可以通过执行以下操作来计算它:-
获取用户设备的宽度尺寸,并从中减去一定的量以适应左右边距。
-
使用这个边距偏移量,并将其除以图像的原始宽度以获得所需的比率。
-
返回将高度乘以比例得到正确图像高度的乘积结果。
- 导出另一个函数
getWidthOffset,它接受用户的设备宽度并返回它,减去一定的量以适应左右边距。为了代码重用,我们应该将其用作getHeightRatio的第一个要点的一部分。
- 导出另一个函数
-
修改PostView以执行以下操作:
-
考虑到较长的图片,顶级
View应替换为ScrollView组件。 -
如果帖子已加载完成并且
attachments数组中包含任何图像,则渲染帖子attachments数组中的第一张图像。 -
Image组件应将其resizeMode属性设置为contain,以便图像不会超出屏幕。它应该有一些左和右边距,以便它不会接触到屏幕边缘,其宽度和高度应由imageMethods文件计算。 -
这种渲染应放置在帖子的详细信息(时间、消息和故事)下方,但在点赞和评论数量上方。
获取gridWidthOffset,将其除以图像的width,然后将图像的height除以这个结果,如下所示:
// Friends/app/utils/imageMethods.js
import { Dimensions } from 'react-native';
export const getHeightRatio = (height, width) => {
return height * (getWidthOffset()/width);
}
获取用户的width,然后从中减去20像素:
export const getWidthOffset = () => {
return Dimensions.get('window').width - 20;
}
将Image、ScrollView和imageMethods导入到PostView组件中:
// Friends/app/components/PostView/index.js
import {
...
Image,
ScrollView,
} from 'react-native';
import { getHeightRatio, getWidthOffset } from '../../utils/imageMethods';
预计到较长的帖子,将顶级视图替换为ScrollView。添加条件逻辑以触发_renderAttachments,将其放在调用_renderDetails之前:
...
export default class PostView extends Component {
...
render () {
return (
<ScrollView style={ styles.container }>
...
<View>
{ !this.state.loading && this._renderAttachments() }
</View>
...
</ScrollView>
)
}
为涉及某些照片/相册的非常特定边缘情况分配subattachments:
...
_renderAttachments () {
let attachment = this.state.attachments[0]
let media;
if (attachment && attachment.hasOwnProperty('subattachments')) {
attachment = attachment.subattachments.data[0];
}
检查media属性的存在,如下所示:
if (attachment && attachment.hasOwnProperty('media')) {
media = attachment.media;
}
如果media属性存在并且包含image属性,则渲染Image:
if (media && media.image) {
返回具有确定属性的Image组件:
const imageObject = media.image;
return (
<Image
resizeMode={ 'contain' }
source={{ uri: imageObject.src }}
style={{
marginRight: 10,
marginTop: 30,
width: getWidthOffset(),
height: getHeightRatio(imageObject.height,
imageObject.width)
}}
/>
)
}
}
...
}
PostView的container样式已更改,省略了marginTop属性:
// Friends/app/components/PostView/styles.js
commentListContainer: {
flex: 1,
marginTop: 20
}
commentListContainer样式与新的ScrollView组件相匹配。
现在图像已经渲染,我们应该处理其他类型的附件--链接。
使用 WebView 渲染链接
当用户选择一个链接时,在您的应用程序中渲染该链接是有益的,这样用户就不会被抛出应用程序并进入他们的浏览器。为了使用 React Native 完成此任务,我们将使用WebView组件。
WebView组件在原生、应用程序包含的视图中渲染 Web 内容。对于这个应用程序,我们将使用其众多属性中的其中一个:
source:这将在WebView中加载带有可选头部的 URI 或静态 HTML。
渲染WebView组件很简单:
import {
WebView
} from 'react-native';
class WebViewSample extends Component {
render () {
return (
<WebView
source={{uri: 'https://www.google.com'}} />
)
}
}
并非所有帖子都包含附件中的链接。当它们包含链接时,其层次结构如下:
attachments: [{
title: 'Link to Google'
url: 'https://www.google.com'
}]
让我们做一些修改以适应WebView。首先,创建一个名为WebViewComponent的新组件;它应该是一个无状态的函数,返回一个WebView,并将其source设置为它接收的属性中的任何链接。
然后,修改PostView,使其执行以下功能:
-
如果帖子中包含图片,则直接在渲染图片的地方渲染按钮。
-
该按钮仅在帖子的第一个附件与链接相关联时渲染。按钮应包含链接的标题,并且当点击时,导航到您的
WebViewComponent以打开链接。
从 iOS 9 开始,未加密的 HTTP 链接被 iOS 的 App Transport Security 自动阻止。您可以在 Xcode 项目中项目文件的Info.plist文件中逐个案例地将这些链接列入白名单。苹果公司不推荐这样做,并将在不久的将来要求所有提交的应用遵守这项新政策。
以下是一个无状态的函数组件,它只返回一个带有source URI 的WebView:
// Friends/app/components/WebViewComponent/index.js
import React, { Component } from 'react';
import {
WebView
} from 'react-native';
export default (props) => {
return (
<WebView
source={{ uri: props.url }}
/>
)
}
导入Button和WebViewComponent依赖项:
// Friends/app/components/PostView/index.js
import {
Button,
...
} from 'react-native';
import WebViewComponent from '../WebViewComponent';
如果PostView已加载完成,则条件调用_renderLink:
...
export default class PostView extends Component {
...
render () {
return (
<ScrollView style={ styles.container }>
...
<View>
{ !this.state.loading && this._renderLink() }
</View>
...
</ScrollView>
)
}
获取第一个附件对象:
...
_renderLink () {
let attachment = this.state.attachments[0];
let link;
let title;
再次检查subattachments:
if (attachment && attachment.hasOwnProperty('subattachments')) {
attachment = attachment.subattachments.data[0];
}
如果title是空字符串或未定义,则将其通用地命名为Link:
if (attachment && attachment.hasOwnProperty('url')) {
link = attachment.url;
title = attachment.title || 'Link';
渲染一个在按下时调用_renderWebView的Button:
return (
<Button
color={ '#365899' }
onPress={ () => this._renderWebView(link) }
title={ title }
/>
)
}
}
将用户导航到WebViewComponent,并发送提供的 URL。
_renderWebView (url) {
this.props.navigator.push({
component: WebViewComponent,
passProps: {
url
}
});
}
...
}
我们对这个应用程序的最后一点润色是让用户能够从应用程序中注销。
使用 TabBarIOS 注销
我们的最后一步是为用户添加一个注销页面。使用TabBarIOS组件和react-native-vector-icons,我们将创建一个标签视图,允许用户注销。
让我们为此进行一些修改。首先,我们需要修改App.js,使其执行以下功能:
-
导入
TabBarIOS和react-native-vector-icons依赖项 -
如果活动指示器没有旋转,则在
_renderView方法中返回一个TabBarIOS组件 -
在
App组件的状态中添加一个selectedTab字符串以跟踪当前选择的标签,默认为FeedList组件 -
有单独的函数来渲染
FeedList和LoginPage组件而不进行导航 -
向
LoginPage传递一个回调,该回调执行_checkLoginStatus方法 -
修改其
container样式,不再对任何项目进行居中或对齐
然后,修改LoginPage组件,使其onLogoutFinished回调执行_checkLoginStatus。将新依赖项导入到项目中:
// Friends/app/App.js
import {
TabBarIOS,
...
} from 'react-native';
...
import Icon from 'react-native-vector-icons/FontAwesome';
在状态中存储selectedTab字符串,默认为feed:
...
export default class App extends Component {
constructor (props) {
...
this.state = {
...
selectedTab: 'feed'
}
}
使用之前相同的逻辑渲染FeedList组件:
...
_renderFeedList () {
return (
<FeedList
feed={ this.state.feed }
navigator={ this.props.navigator }
refreshControlSpinning={ this.state.refreshControlSpinning }
refreshFeedList={ () => this._refreshFeedList() }
/>
)
}
渲染LoginPrompt组件,传递_checkLoginStatus方法:
_renderLoginPrompt () {
return (
<LoginPage checkLoginStatus={ () => this._checkLoginStatus() } />
)
}
当用户使用以下代码注销时,这将导致应用导航回LoginPage:
_renderView () {
...
return (
<View style={ styles.container }>
<TabBarIOS>
<Icon.TabBarItemIOS
title={ 'Feed' }
selected={ this.state.selectedTab === 'feed' }
iconName={ 'newspaper-o' }
iconSize={ 20 }
onPress={ () => this._setSelectedTab('feed') }
>
{ this._renderFeedList() }
</Icon.TabBarItemIOS>
<Icon.TabBarItemIOS
title={ 'Sign Out' }
selected={ this.state.selectedTab === 'signOut' }
iconName={ 'sign-out' }
iconSize={ 20 }
onPress={ () => this._setSelectedTab('signOut') }
>
{ this._renderLoginPrompt() }
</Icon.TabBarItemIOS>
</TabBarIOS>
</View>
)
}
_renderView中之前存在_renderFeedList内容的地方现在渲染TabBarIOS组件。
...
_setSelectedTab (selectedTab) {
this.setState({
selectedTab
});
}
}
之前的代码将状态中的selectedTab属性设置为用户点击的任何标签:
// Friends/app/styles.js
container: {
flex: 1,
backgroundColor: '#F5FCFF',
}
之前的代码从container属性中移除了所有其他样式,这样标签栏的图标就不会被强制居中显示在屏幕上:
// Friends/app/components/LoginPage/index.js
...
export default class LoginPage extends Component {
render() {
return (
<View style={ styles.container }>
<LoginButton
...
onLogoutFinished={() => this.props.checkLoginStatus() }
/>
</View>
);
}
}
在LoginButton的onLogoutFinished属性中的上一个警报调用已被替换为触发checkLoginStatus。
在这个应用中,你所有的进步都做得很好!下一步是针对 Android 开发进行修改。
移植到 Android
我们将为这个应用进行的 Android 修改与为Expenses所做的修改类似,这将在第九章,额外的 React Native 组件中稍后讨论。我们对Friends所做的修改如下:
-
将
TabBarIOS替换为DrawerLayoutAndroid和ToolbarAndroid -
创建
Drawer和DrawerRow组件以支持DrawerLayoutAndroid -
在根级别的
index.android.js文件中使用Navigator -
创建
App组件的 Android 特定版本 -
为 Android 特定的样式更新
FeedList -
修改
FeedListRow以支持 Android 导航 -
向
PostView添加BackAndroid和Navigator支持
关于DrawerLayoutAndroid和ToolbarAndroid的深入解释可以在第九章,额外的 React Native 组件中找到。
添加DrawerLayoutAndroid和ToolbarAndroid
让我们从为 Android 版本的“朋友”添加基于工具栏/抽屉的导航开始。我们需要首先创建一个名为Drawer的组件,该组件执行以下功能:
-
这接受一个作为属性的路线数组。
-
这将返回一个包含每个路线作为行的
ListView。每一行都应该包含一个TouchableHighlight组件,当点击时,将调用一个名为navigateTo的属性,我们最终将其传递到Drawer中。
我们还应该将Drawer渲染的行拆分成一个名为DrawerRow的独立组件。这个组件应该执行以下操作:
-
接受行的名称作为属性并在
Text元素中渲染该名称 -
调用
setNativeProps,以便其父TouchableHighlight组件将渲染此自定义组件
实例化一个新的ListView.DataSource:
// Friends/app/components/Drawer/index.js
import React, { Component } from 'react';
import {
ListView,
Text,
TouchableHighlight,
View
} from 'react-native';
import DrawerRow from '../DrawerRow';
import styles from './styles';
export default class Drawer extends Component {
constructor (props) {
super (props);
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
})
}
}
渲染一个带有分隔符的ListView组件。将我们行的渲染委托给_renderDrawerRow方法:
render () {
const dataSource = this.state.ds.cloneWithRows
(this.props.routes || []);
return (
<View style={ styles.container }>
<ListView
automaticallyAdjustContentInsets={ false }
dataSource={ dataSource }
enableEmptySections={ true }
renderRow={ (rowData, sectionID, rowID) =>
this._renderDrawerRow(rowData, sectionID, rowID) }
renderSeparator={ (sectionID, rowID) =>
<View
key={ rowID }
style={ styles.separator } />
} />
</View>
)
}
在自定义DrawerRow组件周围包裹一个TouchableHighlight,传递给它路由的名称。在TouchableHighlight的onPress方法中调用 props 中的navigateTo方法,传递给它row的index:
_renderDrawerRow (rowData, sectionID, rowID) {
return (
<View>
<TouchableHighlight
style={ styles.row }
onPress={ () => this.props.navigateTo(rowData.index) }>
<DrawerRow
routeName={ rowData.title } />
</TouchableHighlight>
</View>
)
}
}
接下来,创建了DrawerRow组件:
// Friends/app/components/Drawer/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1
},
separator: {
height: StyleSheet.hairlineWidth,
marginLeft: 10,
marginRight: 10,
backgroundColor: '#000000'
}
})
export default styles;
以下代码调用setNativeProps,因为DrawerRow被包裹在TouchableHighlight中:
// Friends/app/components/DrawerRow/index.js
import React, { Component } from 'react';
import {
Text,
View
} from 'react-native';
import styles from './styles';
export default class DrawerRow extends Component {
setNativeProps (props) {
this._root.setNativeProps(props)
}
渲染路由的名称:
render () {
return (
<View
style={ styles.container }
ref={ component => this._root = component }
{ ...this.props }>
<Text style={ styles.rowTitle }>
{ this.props.routeName }
</Text>
</View>
)
}
}
这里是我为DrawerRow创建的样式:
// Friends/app/components/DrawerRow/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
height: 40,
padding: 10
},
rowTitle: {
fontSize: 20,
textAlign: 'left'
}
})
export default styles;
将抽屉与朋友集成
接下来,我们将修改根index.android.js文件,使其执行以下操作:
-
渲染一个包裹着
Icon.ToolbarAndroid和Navigator的DrawerLayoutAndroid组件。 -
导入并设置
DrawerLayoutAndroid的renderNavigationView为创建的Drawer组件。 -
创建一个回调以打开
DrawerLayoutAndroid。 -
编写一个名为
_navigateTo的回调,用于导航到给定的索引。将其作为属性传递给LoginPage。 -
使用
Navigator中的renderScene回调导入并渲染App、LoginPage、PostView和WebViewComponent组件:
// Friends/index.android.js
import React, { Component } from 'react';
import {
AppRegistry,
DrawerLayoutAndroid,
Navigator,
StyleSheet,
View
} from 'react-native';
import App from './app/App';
import Drawer from './app/components/Drawer';
import LoginPage from './app/components/LoginPage';
import PostView from './app/components/PostView';
import WebViewComponent from './app/components/WebViewComponent';
import Icon from 'react-native-vector-icons/MaterialIcons';
让我们导入所有必要的依赖项,包括 React Native SDK 组件/API、Navigator渲染的每个自定义组件,以及来自react-native-vector-icons的 Material 图标包。
export default class Friends extends Component {
constructor (props) {
super (props);
this.state = {
visibleRoutes: [
{ title: 'My Feed', index: 0 },
{ title: 'Log Out ', index: 1 }
]
}
}
建立要传递给Drawer组件的可见路由数组。
render() {
const routes = [
{ title: 'My Feed', index: 0 },
{ title: 'Sign In/Log Out', index: 1 },
{ title: 'Post Details', index: 2 },
{ title: 'Web View', index: 3 }
];
return (
<View style={styles.container}>
<DrawerLayoutAndroid
drawerLockMode={ 'unlocked' }
ref={ 'drawer' }
renderNavigationView={ () => this._renderDrawerLayout() }
>
渲染一个DrawerLayoutAndroid组件,其renderNavigationView属性委托给_renderDrawerLayout;给组件设置一个ref为drawer,这样我们就可以在_openDrawer中引用它。
<Icon.ToolbarAndroid
titleColor="#fafafa"
navIconName="menu"
height={ 56 }
backgroundColor="#365899"
onIconClicked={ () => this._openDrawer() }
/>
渲染Icon.ToolbarAndroid组件以包含汉堡菜单。它的onIconClicked回调执行_openDrawer。
<Navigator
initialRoute={{ index: 0 }}
ref={ 'navigator' }
renderScene={ (routes, navigator) =>
this._renderScene(routes, navigator) }
/>
</DrawerLayoutAndroid>
</View>
);
}
渲染Navigator,将其初始路由设置为App组件的index。将renderScene委托给_renderScene方法。给navigator一个ref,这样我们就可以在_navigateTo中引用它。
_checkLoginStatus () {
this._navigateTo(0);
}
上述代码导航到App组件,这会触发它检查用户的登录状态。
_openDrawer () {
this.refs['drawer'].openDrawer();
}
_openDrawer方法在DrawerLayoutAndroid组件上调用openDrawer。
_navigateTo (index) {
this.refs['navigator'].push({
index,
passProps: {
checkLoginStatus: () => this._checkLoginStatus()
}
});
this.refs['drawer'].closeDrawer();
}
_navigateTo方法将给定的index推送到navigator。给定一个checkLoginStatus属性,该属性将被用于LoginPage组件。最后关闭drawer。
_renderDrawerLayout () {
return (
<Drawer
navigateTo={ (index) => this._navigateTo(index) }
routes={ this.state.visibleRoutes }
/>
);
}
_renderDrawerLayout方法渲染Drawer组件,将其_navigateTo方法作为属性传递,以及路由数组。
_renderScene (route, navigator) {
if (route.index === 0) {
return (
<App
title={ route.title }
navigator={ navigator }
/>
);
}
_renderScene方法负责渲染所有四个可用的路由。
if (route.index === 1) {
return (
<LoginPage
title={ route.title }
navigator={ navigator }
{ ...route.passProps }
/>
);
}
if (route.index === 2) {
return (
<PostView
title={ route.title }
navigator={ navigator }
{ ...route.passProps }
/>
);
}
if (route.index === 3) {
return (
<WebViewComponent
title={ route.title }
navigator={ route.navigator }
{ ...route.passProps }
/>
);
}
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5FCFF',
}
});
AppRegistry.registerComponent('Friends', () => Friends);
创建 App.js 的 Android 版本
现在,我们应该为“朋友”创建一个特定的 Android App组件。首先,将位于Friends/app/App.js的现有App.js文件重命名为App.ios.js,并创建一个名为App.android.js的新文件。
此文件应包含与App.ios.js类似的逻辑,但应删除任何对 iOS 特定组件的引用,例如TabBarIOS。此外,任何导航事件应更新以支持Navigator逻辑。
这是我的做法:
// Friends/app/App.android.js
...
以下三个项目从导入语句中删除:NavigatorIOS, TabBarIOS, 和 LoginPage:
export default class App extends Component {
constructor (props) {
...
}
状态中的 selectedTab 属性从 constructor 中移除:
...
async _checkLoginStatus () {
...
if (result === null) {
this.props.navigator.push({
index: 1,
passProps: {
getFeed: () => this._getFeed()
}
});
return;
}
...
}
componentWillMount 和 render 方法与 iOS 版本保持一致。在 _checkLoginStatus 中的导航方法被修改为传递一个 index 而不是 LoginPage 组件:
...
_renderView () {
...
return this._renderFeedList();
}
_getFeed, _renderFeedList, 和 _renderLoginPrompt 方法也没有被修改。在 _renderView 中,我不再返回 TabBarIOS,而是返回对 _renderFeedList 的调用。
...
}
最后,_refreshFeedList 和 _responseInfoCallback 方法也没有改变。然而,由于 _setSelectedTab 是一个 TabBarIOS 特定的方法,所以它从 App.android.js 中被移除。
修改 FeedList
在 Android 上,FeedList 的样式需要根据条件进行更改,以便其 container 样式不包含 marginTop 属性。修改 FeedList 以执行以下功能:
-
从 React Native 中导入
PlatformAPI。 -
条件检查用户的平台,并根据检查结果在 iOS 设备上提供容器样式或一个新的不包含
marginTop属性的 Android 特定样式。
这里是我的 FeedList 对 Android 的修改:
// Friends/app/components/FeedList/index.js
...
import {
Platform,
...
} from 'react-native';
...
export default class FeedList extends Component {
...
render () {
...
return (
<View style={ Platform.OS === 'ios' ? styles.container :
styles.androidContainer }>
...
</View>
)
}
...
}
我导入了 Platform API 并使用三元运算符来检查用户的操作系统,根据检查结果在 FeedList 的 render 方法中将顶层 View 组件分配一个适用的样式:
// Friends/app/components/FeedList/styles.js
androidContainer: {
flex: 1
},
我将 androidContainer 样式添加到 FeedList 的 StyleSheet 中。
在 FeedListRow 中支持 Navigator
接下来,我们必须更新 FeedListRow 以执行以下操作:
-
导入
PlatformAPI -
修改
navigateToPostView以检查用户的操作系统并使用适当的语法为每个操作系统推送PostView
我创建了 propsObject 来存储分配给 passProps 的对象,这样我就不必再次重写它:
// Friends/app/components/FeedListRow/index.js
...
import {
Platform,
...
} from 'react-native';
...
export default class FeedListRow extends Component {
...
_navigateToPostView () {
const propsObject = {
createdTime: this.props.createdTime,
message: this.props.message,
postID: this.props.postID,
story: this.props.story
};
这里我们查看 iOS 的条件逻辑:
if (Platform.OS === 'ios') {
this.props.navigator.push({
component: PostView,
passProps: propsObject
});
return;
}
由于 iOS 逻辑以 return 语句结束,所以在 Android 上使用 Navigator 的 push。
this.props.navigator.push({
index: 2,
passProps: propsObject
});
}
...
}
添加 PostView 导航器和 BackAndroid 支持
现在,让我们对 PostView 组件进行以下修改:
-
导入
Platform和BackAndroidAPI -
在
componentWillMount和componentWillUnmount中添加和移除BackAndroid的监听器。 -
在组件中编写一个回调来处理 Android 上的返回按钮点击,结果调用导航器的
pop。 -
创建类似于
FeedListRow的条件逻辑来推送WebViewComponent
我在 componentWillMount 生命周期中创建了一个 BackAndroid 的事件监听器:
// Friends/app/components/PostView/index.js
...
import {
BackAndroid,
Platform,
...
} from 'react-native';
...
export default class PostView extends Component {
...
async componentWillMount () {
BackAndroid.addEventListener('hardwareButtonPress', () =>
this._backButtonPress());
...
}
同样,我在 componentWillUnmount 中移除了那个事件监听器:
componentWillUnmount () {
BackAndroid.removeEventListener('hardwareButtonPress', () =>
this._backButtonPress())
}
此方法在按下返回按钮时在 navigator 上调用 pop:
...
_backButtonPress () {
this.props.navigator.pop();
return true;
}
在 iOS 上推送 WebViewComponent 的条件逻辑如下:
...
_renderWebView (url) {
if (Platform.OS === 'ios') {
this.props.navigator.push({
component: WebViewComponent,
passProps: {
url
}
});
return;
}
相同功能的条件逻辑,但在 Android 上如下:
this.props.navigator.push({
index: 3,
passProps: {
url
}
});
}
}
摘要
恭喜!您已经成功构建了三款 React Native 应用程序,贯穿整本书的学习过程。在本章中,您学习了如何将下拉刷新交互添加到应用程序中,让您的应用用户能够通过一个众所周知的手势快速刷新数据。然后,您使用了Image组件,将远程图片渲染到您的应用程序中。接下来,您为应用程序创建了一个WebView组件,使用户能够在不离开应用进入系统浏览器的情况下查看与 Web 相关的内容。最后,您进行了必要的修改,以创建应用程序的 Android 版本。
第七章:Redux
现在我们已经有机会尝试 React Native,是时候深入研究一些严肃的架构了。你可能会遇到我们之前应用程序的一些问题是,我们的组件最终封装了大量的逻辑,有些文件运行了数百行。在本章中,我们将介绍一种新的应用程序架构,以减少组件中的冗余。在本章中,我们将做以下几件事:
-
了解 Redux,该架构将帮助我们管理 React Native 应用程序的状态和数据流
-
在我们的应用程序中安装 Redux 的依赖项
-
重构
Tasks,我们的待办事项列表应用程序,以使用 Redux
介绍 Redux
Redux 是一个非常受欢迎的库,许多开发者使用它来帮助编写他们的 React 应用程序。在其 GitHub 仓库中,Redux 将自己定位为 JavaScript 应用的可预测状态容器。而不是让每个组件管理自己的独立状态,Redux 建议整个 React 应用程序由一个单一的状态来管理。这个单一的状态随后通过每个组件传播,并允许大多数应用程序的逻辑存在于可重用的函数中。
Redux 的三个原则
你可以通过引用关于状态的三个关键原则来描述 Redux:它需要成为你应用程序的唯一真相来源,它是只读的,并且只能由纯函数修改。
单一状态树
在 Redux 中,而不是让每个组件管理自己的状态,我们处理一个包含我们应用程序中所有逻辑的单个状态树。例如,对于我们在前两章中构建的 Tasks 应用程序,你可以将其可视化如下:
{
cellExpanded: false,
tasks: [
{
title: 'Buy Milk',
completed: false,
dueDate: undefined
},
{
title: 'Walk Dog',
completed: true,
dueDate: undefined
}
],
}
这有助于使我们的应用程序更容易调试,因为我们只处理一个对象树,当我们查看它包含的信息时。
状态是只读的
应用程序的状态永远不应该被直接修改。相反,它应该只作为动作分发和还原器与之交互的结果进行修改。
使用纯函数进行更改
纯函数的概念来自函数式编程,可以总结如下:
-
给定相同的参数,纯函数总是返回相同的结果
-
无论我们的应用状态如何,纯函数都能够执行
-
纯函数作用域之外的外部变量不能被它修改
这三个原则与 Redux 生态系统的三个主要部分相关联:actions(动作)、reducers(还原器)和 store(存储)。
Actions(动作)是我们间接修改只读状态的方式。Reducers(还原器)是执行这种修改的纯函数。Redux 中的单一 store(存储)是我们状态存在的地方。
Actions(动作)
Actions(动作)是包含信息的简单对象,它将数据从你的应用程序发送到应用程序的存储。你应用程序处理的所有逻辑都将通过动作传递 - 你的存储永远不会从不是动作的来源接收任何数据。
一个动作需要一个type属性,它定义了已发生的用户动作的类型。动作类型以字符串形式表示。它们可以硬编码到对象本身中,或作为常量传入。例如:
export function addTask(taskName) {
return {
type: 'ADD_TASK',
taskName: taskName
}
}
// With constants
const ADD_TASK = 'ADD_TASK';
export function addTask(taskName) {
return {
type: ADD_TASK,
taskName: taskName
}
}
这些函数将作为属性提供给你的应用程序中的所有组件,并且可以在任何时候调用。当调用一个动作时,商店会将该动作分发给应用程序中的每个减少器。只有正确选择的减少器,通过条件逻辑选择,此时才会触发,并执行更改应用程序状态的代码。
减少器
在纯 JavaScript 中,有一个名为reduce的数组原型方法。这个本地减少函数的目的是在运行回调和初始值通过整个数组的所有内容后返回一个单一减少值。
在 Redux 中,减少器是一个函数,它接收你的应用程序状态以及从动作传递给它的相关信息,然后在执行代码后返回一个单一减少值作为你应用程序的状态。
作为良好实践,减少器应该限制在每个文件中只有一个,以保持清晰。
关于 Redux 中的减少器,我们需要了解两个非常重要的事情:
-
应用程序状态永远不会被修改。相反,返回一个带有任何更改值的副本。
-
由于每个减少器在动作发生时都会触发,以决定该动作是否与其相关,因此如果发生任何未知(对该特定减少器)的动作,我们必须返回前一个应用程序状态。
从前面的示例来看,这是一个ADD_TASK动作的减少器看起来:
const task = (state = [], action) => {
使用 ES6 的默认参数语法,如果状态不存在,则提供一个空数组,action对象由动作创建者传入:
switch(action.type) {
case 'ADD_TASK':
return [
...state,
{
taskName: action.taskName
}
]
default:
return state;
}
}
存储
商店是一个将动作和减少器结合在一起的对象。它为我们做以下事情:
-
包含应用程序状态
-
通过名为
getState的方法提供对状态的访问 -
分发动作,然后减少器使用这些动作来修改该状态
关于 Redux 需要注意的一点是,在给定应用程序中,你将只有一个商店。如果我们想将逻辑拆分成多个处理程序,实际上我们会通过名为减少器组合的方法来拆分减少器,这是我们一旦需要就会查看的内容。
下面是一个 Redux 中示例商店的样貌。假设我们之前有任务减少器,还有一个如下所示:
import { combineReducers, createStore, compose } from 'redux';
const defaultState = {
task,
dueDate
}
这些是动作的示例:
const addTask = function(taskName) {
return {
type: 'ADD_TASK',
taskName: taskName
}
}
const changeDueDate = function(dueDate) {
return {
type: 'CHANGE_DUE_DATE',
dueDate: dueDate
}
}
这些是我们放置减少器的地方:
const task = // Reducer to add a new task to the list
const dueDate = // Reducer to modify a task's due date
const rootReducer = combineReducers(task, dueDate);
const store = createStore(rootReducer, defaultState);
在一个组件内部,我们可以简单地按照以下方式调用一个动作:
this.props.addTask('Buy Milk')
但是等等,我们的组件如何知道这个动作是可用的?我们究竟在哪里暴露属性?我们使用一个名为React-Redux的库来完成这个任务,它包含我们想要利用的两个东西。
第一个是 connect 方法,它将我们的 React Native 应用程序连接到 Redux 存储。我们感兴趣传递给 connect 的两个参数是 mapStateToProps 和 mapDispatchToProps。
如果指定了 mapStateToProps,它是一个订阅状态树更新的函数。每当状态树更新时,mapStateToProps 都会被调用,并将它的返回值合并到组件的 props 中。返回值需要是一个对象。下面是一个快速示例:
const mapStateToProps = (state) => {
return {
tasks: state.tasks
}
}
mapDispatchToProps 将我们的 dispatch 方法映射到组件的 props。作为一个函数,我们可以调用 bindActionCreators 并传入我们的 action creators 以及一个 dispatch 调用,这样它们就可以直接被调用。下面是它的样子:
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actionCreators from '../actions';
const mapDispatchToProps = (dispatch) => {
return bindActionCreators(actionCreators, dispatch);
}
然后,假设 Main 是我们应用程序的入口点,我们将使用 connect 将它们组合起来:
import Main from './Main';
const App = connect(mapStateToProps, mapDispatchToProps)(Main)
为了传播我们的存储(以及与之相关的所有 actions 和 reducers),我们将使用 React-Redux 的 Provider 来包装我们的应用程序,并将我们的存储作为 props 传递给它。这允许我们的 React 应用程序中的组件自然地继承这些 props。它发生在根级别,如下所示:
return (
<Provider store={ store }>
<App />
</Provider>
)
这是一个相当多的设置,如果你对其有效性有所怀疑,我强烈建议你继续阅读本章的其余部分:Redux 非常有用,因为它将让我们编写更干净、更容易维护的组件,并有助于未来的贡献者更好地理解我们的代码库。
我们的下一步是安装 Redux,让我们开始吧。
安装 Redux
我们将使用 npm 来安装 Redux。它还需要一些依赖项,我们将一次性安装所有这些依赖项。确保你位于项目文件夹的根目录中,然后执行以下操作:
npm install --save redux react-redux redux-thunk
下面是我们将要安装到项目中的三个包的简要概述:
-
Redux:这是库本身。 -
React-Redux:这是一个提供 React 绑定的库。Redux 并非专门绑定到 React,这个库将使我们能够轻松地访问Provider组件,在父级传递我们的 props。 -
Redux-Thunk:这是一个中间件,它将帮助我们使用 actions 进行异步调用,并且由于我们将调用AsyncStorage,因此它非常有用。
现在我们已经安装了这三个包,是时候开始设置我们的架构了。
Redux 架构
当我们使用 Redux 时,我们应用程序的架构将与之前略有不同。目前,我们的项目中的 app 目录看起来是这样的:
|app
|__components
|____DatePickerDialogue
|____EditTask
|____ExpandableCell
|____TasksList
|____TasksListCell
Redux 要求我们以不同的方式思考我们如何接近应用程序的架构,我们将在 app 目录中添加一些新的文件夹:
|app
|__containers
|__components
|__reducers
|__index.js
Redux 中的容器
容器是我们将要映射 dispatch 方法以及与应用程序状态连接的方法,这些方法连接到组件和 Redux。components 文件夹仍然存在,但我们将重构其中的内容,使其不依赖于基于组件的状态。
从现在开始,每次我们通常渲染一个组件时,我们都会渲染其容器。
剩余的文件夹结构
reducers文件夹将包含一个单独的 reducer 文件,它处理所有修改我们应用程序状态的逻辑。
应用文件夹中找到的index.js文件将处理我们的 Redux 设置,并由 iOS 和 Android 的根index文件渲染。
在我们的根index.ios.js和index.android.js文件中,你将看到以下内容:
// TasksRedux/index.js
import Tasks from './app';
import { AppRegistry } from 'react-native';
AppRegistry.registerComponent('Tasks', () => Tasks);
规划 Redux 转换
我们将采取的将我们的应用转换为 Redux 的方法将涉及多个步骤:
-
首先,我们应该开始搭建一个 Redux 项目,创建一个 store,将
AppContainer包裹在一个Provider中,并创建一些基本的操作和 reducer 来处理非常基本的功能--我们可以稍后考虑持久化存储。 -
然后,我们将开始将
TasksList组件转换为 Redux,通过创建TasksListContainer并将我们的操作和状态树映射到TasksList组件。在我们第一章,“第一个项目 - 创建基本待办事项应用”和第二章,“高级功能与美化待办事项应用”中构建的Tasks组件的其他组件将暂时保持不变。 -
之后,我们将修改
TasksList组件,利用其容器,通过从中移除所有组件无关的逻辑来实现。 -
我们将对
EditTask组件重复这一系列步骤。 -
最后,我们应该解决对
AsyncStorageAPI 的异步调用问题。 -
在旅途中,我们应该抓住每一个机会对
Tasks的 Android 版本进行修改,将其转换为 Redux 架构。
创建入口点
位于app/index.js的index文件将作为我们应用的入口点。iOS 和 Android 版本的Tasks都将调用它,它将设置我们的 Redux 架构。首先,我们将导入所有必要的依赖项。如果我们还没有为这些项目创建任何适用的文件或文件夹,不要担心;我们很快就会这样做:
// TasksRedux/app/index.js
import React from 'react';
import AppContainer from './containers/AppContainer';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import listOfTasks from './reducers';
接下来,让我们设置我们的 store。
设置我们的 store
要设置我们的 store,我们需要使用 Redux 的createStore方法,然后传递一个 reducer。从高层次来看,它看起来是这样的:
let store = createStore(task)
此外,由于我们知道我们将在应用中处理异步调用,我们还应该设置Redux-Thunk以支持它。
要这样做,将applyMiddleware函数作为createStore的第二个参数传递。将thunk作为applyMiddleware的参数传递:
let store = createStore(listOfTasks, applyMiddleware(thunk));
最后,我们将导出一个无状态的函数,该函数返回被Provider包裹的应用容器:
export default function Tasks (props) {
return (
<Provider store={ store }>
<AppContainer />
</Provider>
)
}
在设置完成后,我们的index.js文件将看起来如下:
// TasksRedux/app/index.js
import React from 'react';
import AppContainer from './containers/AppContainer';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import listOfTasks from './reducers';
let store = createStore(listOfTasks, applyMiddleware(thunk));
export default function Tasks (props) {
return (
<Provider store={ store }>
<AppContainer />
</Provider>
)
}
现在我们已经创建了此文件,让我们构建应用容器。如果您还没有创建,请在app文件夹内创建一个containers文件夹,然后为 Android 和 iOS 创建单独的AppContainer文件。
构建应用容器
应用容器将提供一个基本的NavigatorIOS路由,渲染我们的TasksList容器。它看起来与我们之前在根索引文件中的类似:
// TasksRedux/app/containers/AppContainer.ios.js
import React, { Component } from 'react';
import {
NavigatorIOS,
StyleSheet
} from 'react-native';
import TasksListContainer from '../containers/TasksListContainer';
export default class App extends Component {
render () {
return (
<NavigatorIOS
initialRoute={{
component: TasksListContainer,
title: 'Tasks'
}}
style={ styles.container }
/>
)
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5FCFF'
}
});
AppContainer和根索引文件之间的主要区别是它没有调用AppRegistry.registerComponent。这部分仍然由根索引文件处理。
导入我们将用于Navigator的两个路由:
// TasksRedux/app/containers/AppContainer.android.js
import React, { Component } from 'react';
import {
Navigator,
} from 'react-native';
import TasksListContainer from './TasksListContainer';
import EditTaskContainer from './EditTaskContainer';
设置routes数组:
class Tasks extends Component {
render () {
const routes = [
{ title: 'Tasks', index: 0 },
{ title: 'Edit Task', index: 1 }
];
此函数处理渲染不同路由的逻辑:
return (
<Navigator
initialRoute={{ index: 0}}
renderScene={ (routes, navigator) =>
this._renderScene(routes, navigator) }/>
);
}
_renderScene (route, navigator) {
if (route.index === 0) {
return (
<TasksListContainer
title={ route.title }
navigator={ navigator }
/>
)
}
if (route.index === 1) {
return (
<EditTaskContainer
title={ route.title }
navigator={ navigator }
/>
)
}
}
}
AppRegistry.registerComponent('Tasks', () => Tasks);
接下来,我们将开始创建动作和 reducers,以期待构建TasksList容器。
创建动作
让我们创建一些有助于我们应用程序的动作。在Tasks应用程序中,我们有以下动作的功能:
-
从
AsyncStorage获取任务 -
创建新任务
-
编辑任务名称
-
标记/取消标记任务为完成
-
显示/隐藏可展开组件
-
保存对任务的更改
-
清除对任务的更改
-
添加截止日期
-
删除截止日期
这是一个编辑任务名称的动作示例:
export function editTaskName (title, index) {
return {
type: 'EDIT_TASK_NAME',
title: title,
index: index
}
};
当命名一个动作时,我们希望将动作类型与用户交互导致的确切事件相关联。在这种情况下,用户编辑了任务名称。此动作还将传递给我们的 reducer 一个title和index,以便 reducer 可以搜索状态以找到提供的index的任务,并编辑其title。
初始时,我们有三项动作我们想要确保在TasksList中工作:添加任务,更改其完成状态,以及修改TextInput的值:
// TasksRedux/app/actions/index.js
let currentIndex = 0;
const ADD_TASK = 'ADD_TASK';
const CHANGE_COMPLETION_STATUS = 'CHANGE_COMPLETION_STATUS';
const CHANGE_INPUT_TEXT = 'CHANGE_INPUT_TEXT';
export function addTask (text) {
return {
type: ADD_TASK,
index: currentIndex++,
text,
. }
}
每个动作都是一个导出的函数,它返回一个对象,其中包含动作类型以及任何其他键值对,这些键值对包含我们 reducers 交互的数据:
export function changeCompletionStatus (index) {
return {
type: CHANGE_COMPLETION_STATUS,
index
}
}
export function changeInputText (text) {
return {
type: CHANGE_INPUT_TEXT,
text
}
}
这些现在就足够了——随着我们将越来越多的应用程序转换为支持 Redux,我们将逐步构建其他动作。接下来,让我们为这三个动作构建 reducers。
构建 reducers
让我们看看我们如何构建 reducers 来处理创建新任务并将其保存到我们的任务列表中。
假设我们正在处理以下状态树:
{
tasks: [
{
title: 'Buy Milk',
completed: false,
dueDate: undefined
},
{
title: 'Walk Dog',
completed: true,
dueDate: undefined
}
],
}
为了编写一个纯函数,我们想要确保我们不是修改状态,而是重新分配我们的状态以包含更新的更改。
在一个不纯的函数中,我们可能会这样做:
不要这样做!
function addTask (state, action) {
switch(action.type) {
case 'ADD_TASK':
state.tasks.push({
title: action.title,
completed: false
});
return state;
default:
return state;
}
};
我们在这里所做的是修改我们状态树的 tasks 数组,将其中的新任务推入。这可能导致后续调试时出现问题。相反,我们想要做的事情可以分解成一系列可执行步骤:
-
创建当前状态的副本。
-
创建我们复制的状态的任务的副本。
-
将我们的新任务添加到这个副本的末尾。
-
将此复制的数组分配为新任务的复制的状态值。
-
将复制的状态分配为我们的新当前状态。
singleTask 减少器处理影响列表中单个任务的逻辑。此减少器的结果立即被主 listOfTasks 减少器访问:
// TasksRedux/app/reducers/index.js
const singleTask = (state = {}, action) => {
在此事件中,我们返回一个包含新任务详情的对象。
switch(action.type) {
case 'ADD_TASK':
return {
completed: false,
due: undefined,
index: action.index,
text: action.text
}
singleTask 减少器在 listOfTasks 减少器中的迭代期间被调用。在这里,如果单个任务的索引与我们要交互的索引相匹配,我们使用扩展运算符返回现有对象,并切换其完成状态:
case 'CHANGE_COMPLETION_STATUS':
if (state.index !== action.index) {
return state;
}
return {
...state,
completed: !state.completed
}
这设置了一个默认状态,将其传递给 listOfTasks:
default:
return state;
}
}
let defaultState = {
listOfTasks: [],
text: ''
}
listOfTasks 减少器是所有动作首先触发的地方。然后它使用 switch 语句来确定正在调用的动作类型,并根据该动作类型返回一个新的状态对象:
const listOfTasks = (state = defaultState, action) => {
如果我们要添加一个任务,通过包含使用扩展运算符构建的更新后的 listOfTasks 数组的扩展运算符返回状态对象,调用 singleTask 减少器并传递一个空对象和最初传递给那里的动作对象:
switch(action.type) {
case 'ADD_TASK':
return {
...state,
listOfTasks: [...state.listOfTasks, singleTask({}, action)],
text: ''
}
如果我们更改任务的完成状态,我们在状态中的 listOfTasks 数组上调用 map,然后对任务中的每个元素调用 singleTask,传递当前任务对象和动作对象给它:
case 'CHANGE_COMPLETION_STATUS':
return {
...state,
listOfTasks: state.listOfTasks.map((element) => {
return singleTask(element, action);
})
}
更新状态树中 TextInput 组件的 text 属性:
case 'CHANGE_INPUT_TEXT':
return {
...state,
text: action.text
}
default:
return state;
}
}
export default listOfTasks;
总结来说,listOfTasks 是我们的父级减少器,处理我们应用程序的整体逻辑,而 singleTask 处理列表中单个项目的相关信息。
创建 TasksList 容器
现在我们已经有了动作和减少器,我们将创建一个容器,将我们的分发方法和状态连接到 TasksList 组件。
首先,我们将从 react-redux 中导入 connect 模块,以及我们在 TasksList 中打算分发的任何动作,以及 TasksList 组件本身:
// TasksRedux/app/containers/TasksListContainer/index.js
import { connect } from 'react-redux';
import {
addTask,
changeCompletionStatus,
changeInputText,
} from '../../actions';
import TasksList from '../../components/TasksList';
然后,我们将创建三个方法,这些方法将导致使用 mapDispatchToProps 向我们的状态树分发函数:
const mapDispatchToProps = (dispatch) => {
return {
addTask: (text) => {
dispatch(addTask(text));
},
changeCompletionStatus: (rowID) => {
dispatch(changeCompletionStatus(rowID))
},
onChangeText: (text) => {
dispatch(changeInputText(text));
},
}
}
之后,我们将映射我们打算传递给 TasksList 的 prop 的状态,包括我们从顶级中已有的 Navigator:
const mapStateToProps = (state, { navigator }) => {
return {
listOfTasks: state.listOfTasks || [],
navigator: navigator,
text: state.text || ''
}
}
最后,我们将 mapStateToProps 和 mapDispatchToProps 函数连接到 TasksList 组件并导出它:
export default connect(mapStateToProps, mapDispatchToProps)(TasksList);
现在,让我们看看我们如何重构 TasksList 组件。
Redux 连接的 TasksList 组件
在 Redux 中,组件将保留其 JSX 标记,但任何不属于该特定组件的逻辑都由我们的状态树保持,并通过动作和减少器进行修改:
// TasksRedux/app/components/TasksList/index.js
import React, { Component } from 'react';
import {
ListView,
Platform,
TextInput,
View
} from 'react-native';
由于未来任何对存储方法的调用都将由我们的动作和减少器处理,因此已从该组件中移除了 React Native API 和组件,如 AsyncStorage。
创建一个新的 ListView.DataSource 实例,因为它特定于该组件:
import TasksListCell from '../TasksListCell';
import styles from './styles';
export default class TasksList extends Component {
constructor (props) {
super (props);
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
}),
};
}
为我们状态树中的 ListView 创建 dataSource 常量,包含 listOfTasks 数组:
render () {
const dataSource =
this.state.ds.cloneWithRows(this.props.listOfTasks);
onChangeText 和 onSubmitEditing 等回调现在调用映射到 TasksListContainer 的动作:
return (
<View style={ styles.container }>
<TextInput
autoCorrect={ false }
onChangeText={ (text) => this.props.onChangeText(text) }
onSubmitEditing={ () => this.props.addTask(this.props.text) }
returnKeyType={ 'done' }
style={ Platform.OS === 'ios' ? styles.textInput :
styles.androidTextInput }
value={ this.props.text }
/>
这将传递 TasksListCell 所需的 onLongPress 回调一个占位符:
<ListView
automaticallyAdjustContentInsets={ false }
dataSource={ dataSource }
enableEmptySections={ true }
renderRow={ (rowData, sectionID, rowID) =>
this._renderRowData(rowData, rowID) }
style={ styles.listView }
/>
</View>
);
}
_renderRowData (rowData, rowID) {
return (
<TasksListCell
completed={ rowData.completed }
formattedDate={ rowData.formattedDate }
id={ rowID }
onLongPress={ () => alert('placeholder') }
onPress={ () =>
this.props.changeCompletionStatus(rowData.index) }
text={ rowData.text }
/>
)
}
}}
将 EditTasks 转换为 Redux
在 EditTasks 中,我们引入了一些新的动作和减少器。这些包括:
-
将当前所选任务设置为在
TasksList中按下的任务 -
处理在
EditTask屏幕中按下取消或保存按钮时的更改 -
切换所选任务为完成状态
-
更改所选任务的名字
-
添加、修改和删除截止日期
-
展开可展开的单元格以显示和隐藏
DatePicker组件
对于前两个项目,这些修改将以三种新动作的形式出现,放置在 TasksList 容器中,因为这是这些事件将发生或定义并传递给 EditTask 导航器的组件。
我们的状态树还需要包含以下新属性:
-
DatePicker 组件中格式化和未格式化的日期,指向所选的任务
-
一个与
EditTask屏幕中当前所选任务相关的对象 -
一个指示
ExpandableCell是否可见的指示 -
在 EditScreen 视图中选择日期的指示
有了这个想法,让我们从我们的行动开始转换!
为 EditTask 添加动作
这里是针对 EditTask 的动作文件中的新增内容:
// TasksRedux/app/actions/index.js
...
const CHANGE_CURRENTLY_EDITED_TASK = 'CHANGE_CURRENTLY_EDITED_TASK';
const CHANGE_SELECTED_TASK_COMPLETED = 'CHANGE_SELECTED_TASK_COMPLETED';
const CHANGE_SELECTED_TASK_DUE_DATE = 'CHANGE_SELECTED_TASK_DUE_DATE';
const SAVE_SELECTED_TASK_DETAILS = 'SAVE_SELECTED_TASK_DETAILS';
const EDIT_SELECTED_TASK_NAME = 'EDIT_SELECTED_TASK_NAME';
const EXPAND_CELL = 'EXPAND_CELL';
const REMOVE_SELECTED_TASK_DUE_DATE = 'REMOVE_SELECTED_TASK_DUE_DATE';
const RESET_SELECTED_TASK = 'RESET_SELECTED_TASK';
这些是新常量,描述了 EditTask 组件将带给应用的不同的动作。
这些函数很简单,因为它们在零到两个值之间传递所需的动作类型,以便我们的减少器处理:
...
export function changeCurrentlyEditedTask (selectedTaskObject) {
return {
type: CHANGE_CURRENTLY_EDITED_TASK,
selectedTaskObject: selectedTaskObject
}
}
export function changeSelectedTaskCompleted (value) {
return {
type: CHANGE_SELECTED_TASK_COMPLETED,
value
}
}
export function changeSelectedTaskDueDate (date) {
return {
type: CHANGE_SELECTED_TASK_DUE_DATE,
date
}
}
saveSelectedTaskDetails 动作比其他动作更复杂。它从 EditTask 组件中获取一个对象,然后将其分解为不同的属性,以便我们的减少器可以处理。
export function editSelectedTaskName (text) {
return {
type: EDIT_SELECTED_TASK_NAME,
text
}
}
export function expandCell (currentlyExpanded) {
return {
type: EXPAND_CELL,
expanded: currentlyExpanded
}
}
export function resetSelectedTask () {
return {
type: RESET_SELECTED_TASK
}
}
export function removeSelectedTaskDueDate () {
return {
type: REMOVE_SELECTED_TASK_DUE_DATE
}
}
export function saveSelectedTaskDetails (object) {
return {
type: SAVE_SELECTED_TASK_DETAILS,
completed: object.completed,
date: object.due || undefined,
formattedDate: object.formattedDate || undefined,
index: object.index,
text: object.text
}
}
EditTask 的减少器
我们还应该更新我们的减少器以处理这些新引入的动作。我们应该做以下事情:
-
扩展
defaultState对象,包括date、dateSelected、expanded、formattedDate和selectedTaskObject属性 -
添加一个辅助函数以使用 MomentJS 格式化日期
-
为
singleTask减少器创建一个新的 switch 情况以处理SAVE_SELECTED_TASK_DETAILS动作 -
为用户正在编辑的
selectedTaskObject建立一个新的selectedTask减少器以临时存储和修改 -
扩展
listOfTasks减少器以处理每个新动作,在必要时将其委派给singleTask或selectedTask减少器
defaultState 对象已扩大以容纳 EditTask 将使用的新信息,例如 ExpandableCell 的 expanded 状态:
// TasksRedux/app/reducers/index.js
import moment from 'moment';
const defaultState = {
...
date: undefined,
dateSelected: false,
expanded: false,
formattedDate: undefined,
selectedTaskObject: undefined,
}
使用 MomentJS 格式化日期:
const _formatDate = (date) => {
if (date) {
return moment(date).format('lll');
}
}
这两个方法没有进行任何更改:
const singleTask = (state = {}, action) => {
switch(action.type) {
case 'ADD_TASK':
...
case 'CHANGE_COMPLETION_STATUS':
...
这是为了保存用户已选用于编辑的任务的详细信息:
case 'SAVE_SELECTED_TASK_DETAILS':
if (state.index !== action.index) {
return state;
}
return {
...state,
completed: action.completed,
due: action.date,
formattedDate: action.formattedDate,
text: action.text
}
这是一个新创建的红 ucer,用于处理用户当前正在编辑的任务对象:
default:
return state;
}
}
const selectedTask = (state = {}, action) => {
我们的 Redux 状态存储用户正在编辑的对象,因为用户在任何时候都可以简单地决定取消任何更改。将更改保存在临时对象中,只有在用户按下保存按钮后才会将它们保存到状态中,这样我们就可以避免撤销用户所做的任何更改:
switch(action.type) {
case 'CHANGE_SELECTED_TASK_COMPLETED':
return {
...state,
completed: action.value
}
case 'CHANGE_SELECTED_TASK_DUE_DATE':
return {
...state,
due: action.date || undefined,
formattedDate: action.date ?
_formatDate(action.date) : undefined
}
与 singleTask 红 ucer 类似,selectedTask 通过主 listOfTasks 红 ucer 访问:
case 'EDIT_SELECTED_TASK_NAME':
return {
...state,
text: action.text
}
case 'REMOVE_SELECTED_TASK_DUE_DATE':
return {
...state,
due: undefined,
formattedDate: undefined
}
default:
return state;
}
}
const listOfTasks = (state = defaultState, action) => {
switch(action.type) {
case 'ADD_TASK':
...
case 'CHANGE_COMPLETION_STATUS':
...
以下两个情况没有进行更改。
case 'CHANGE_CURRENTLY_EDITED_TASK':
const date = action.selectedTaskObject.due || new Date();
const formattedDate = _formatDate(date);
const hasDueDate = action.selectedTaskObject.due ? true : false
return {
...state,
date: date,
dateSelected: hasDueDate,
formattedDate: formattedDate,
selectedTaskObject: action.selectedTaskObject
}
这是设置我们 Redux 状态中的 selectedTaskObject 属性的代码。它还设置了 DatePicker 组件的日期、dateSelected 和 formattedDate 属性。
此情况也没有更改:
case 'CHANGE_INPUT_TEXT':
...
这是第一次从 listOfTasks 调用 selectedTask 红 ucer。它更改了当前正在编辑的任务的完成状态:
case 'CHANGE_SELECTED_TASK_COMPLETED':
return {
...state,
selectedTaskObject: selectedTask
(state.selectedTaskObject, action)
}
此情况更改了所选任务的截止日期,以及 date、dateSelected 和 formattedDate 属性:
case 'CHANGE_SELECTED_TASK_DUE_DATE':
return {
...state,
date: action.date,
dateSelected: action.date ? true : false,
formattedDate: action.date ? _formatDate(action.date) :
undefined,
selectedTaskObject: selectedTask
(state.selectedTaskObject, action)
}
更改所选任务的名字:
case 'EDIT_SELECTED_TASK_NAME':
return {
...state,
selectedTaskObject: selectedTask(state.selectedTaskObject,
action)
}
处理 ExpandableCell 的 expanded 属性:
case 'EXPAND_CELL':
return {
...state,
expanded: !action.expanded
}
如果用户在 EditTask 屏幕上按下 Cancel,则会执行以下操作:
case 'RESET_SELECTED_TASK':
return {
...state,
expanded: false,
selectedTask: undefined,
}
从所选任务中移除截止日期:
case 'REMOVE_SELECTED_TASK_DUE_DATE':
return {
...state,
dateSelected: false,
selectedTaskObject: selectedTask(state.selectedTaskObject,
action)
}
最后,这会将所选任务永久保存到 listOfTasks 数组中:
case 'SAVE_SELECTED_TASK_DETAILS':
return {
...state,
expanded: false,
listOfTasks: state.listOfTasks.map((element) => {
return singleTask(element, action)
})
}
default:
...
}
}
export default listOfTasks;export default listOfTasks;
更新 TasksListContainer 以适应 EditTask
现在我们应该更新 TasksListContainer 和 TasksList 组件。首先,TasksListContainer 应该执行以下操作:
-
导入
changeCurrentlyEditedTask、resetSelectedTask和saveSelectedTaskDetails动作并将它们添加到mapDispatchToProps方法。 -
将 Redux 状态中的
date、formattedDate和selectedTaskObject属性导入并添加到mapStateToProps方法。
看看以下代码:
// TasksRedux/app/containers/TasksListContainer
...
import {
...
changeCurrentlyEditedTask,
resetSelectedTask,
saveSelectedTaskDetails
} from '../../actions';
如上所述,我导入了三个新动作。
...
const mapDispatchToProps = (dispatch) => {
return {
...
changeCompletionStatus: (index) => {
dispatch(changeCompletionStatus(index));
},
resetSelectedTask: () => {
dispatch(resetSelectedTask());
},
saveSelectedTaskDetails: (selectedTaskObject) => {
dispatch(saveSelectedTaskDetails(selectedTaskObject));
}
}
}
将三个新动作映射到 TasksList 的调度方法。
const mapStateToProps = (state, { navigator }) => {
return {
...
date: state.date,
formattedDate: state.formattedDate,
selectedTaskObject: state.selectedTaskObject,
}
}
...
将 Redux 状态中的三个新值映射到 TasksList 的 props。
更新 TasksList 组件
接下来,让我们对 TasksList 进行修改,以便它支持 EditTask。它应该执行以下操作:
-
为我们导入
EditTaskContainer以推送到导航器。 -
添加
PlatformAPI 以支持 Android 设备。 -
修改
TasksListCell的onLongPress回调以调用一个函数,该函数首先将当前选定的任务添加到 Redux 状态,然后导航用户到EditTaskContainer。它应包含一个Cancel和Save按钮。 -
Cancel按钮应触发一个函数,该函数pops导航器并重置 Redux 状态中的selectedTaskObject值。 -
Save按钮应触发一个函数,该函数pops导航器并将selectedTaskObject保存到 Redux 状态中的listOfTasks数组。
这里是我的对 TasksList 组件的修改:
// TasksRedux/app/components/TasksList/index.js
...
import {
...
Platform,
} from 'react-native';
import EditTaskContainer from '../../containers/EditTaskContainer';
...
export default class TasksList extends Component {
...
以下代码将 _cancelEditingTask 函数添加到在导航器上调用 pop 并然后触发 resetSelectedTask 动作:
_cancelEditingTask () {
this.props.navigator.pop();
this.props.resetSelectedTask();
}
将 TasksListCell 的 onLongPress 回调函数改为调用以下 _onLongPress 函数:
_renderRowData (rowData, rowID) {
return (
<TasksListCell
...
onLongPress={ () => this._onLongPress(rowData) }
/>
)
}
如果用户使用的是 iOS 设备,将 EditTaskContainer push 到导航器,并传递一个字符串给左右按钮,并给它们按下时触发的回调:
_onLongPress (rowData) {
this.props.changeCurrentlyEditedTask(rowData);
if (Platform.OS === 'ios') {
this.props.navigator.push({
component: EditTaskContainer,
title: this.props.selectedTaskText,
leftButtonTitle: 'Cancel',
rightButtonTitle: 'Save',
onLeftButtonPress: () => this._cancelEditingTask(),
onRightButtonPress: () => this._saveEditedTask()
});
return;
}
在 Android 设备上,只需 push AppContainer.android.js 中指定的路由索引:
this.props.navigator.push({
index: 1
});
}
保存编辑后的任务时,首先 pop 导航器,然后触发 saveSelectedTaskDetails 动作:
_saveEditedTask () {
this.props.navigator.pop();
this.props.saveSelectedTaskDetails(this.props.selectedTaskObject);
}
}
创建 EditTask 容器
EditTaskContainer 将以与 TasksListContainer 相同的方式组合。它将执行以下操作:
-
从你的动作文件中导入与
EditTask组件相关的动作: -
导入
EditTask和connect模块。 -
包含一个
mapDispatchToProps方法来映射你导入的动作。 -
在 Redux 状态的任何部分调用
mapStateToProps,以便EditTask可以访问: -
在
mapDispatchToProps、mapStateToProps和EditTask组件上调用connect:
这些是 EditTask 将要使用的动作:
// TasksRedux/app/containers/EditTaskContainer
import { connect } from 'react-redux';
import {
changeSelectedTaskCompleted,
changeSelectedTaskDueDate,
editSelectedTaskName,
expandCell,
removeSelectedTaskDueDate,
resetSelectedTask,
saveSelectedTaskDetails
} from '../../actions';
resetSelectedTask 和 saveSelectedTaskDetails 动作专门映射到 Android 版本应用的 EditTaskContainer:
import EditTask from '../../components/EditTask';
const mapDispatchToProps = (dispatch) => {
return {
changeCompletedStatus: (value) => {
dispatch(changeSelectedTaskCompleted(value));
},
changeTextInputValue: (text) => {
dispatch(editSelectedTaskName(text))
},
clearDate: () => {
dispatch(removeSelectedTaskDueDate());
},
onDateChange: (date) => {
dispatch(changeSelectedTaskDueDate(date));
},
onExpand: (currentlyExpanded) => {
dispatch(expandCell(currentlyExpanded))
},
resetSelectedTask: () => {
dispatch(resetSelectedTask());
},
saveSelectedTaskDetails: (selectedTaskObject) => {
dispatch(saveSelectedTaskDetails(selectedTaskObject));
}
}
}
EditTask 应该能够从状态树访问以下数据:
const mapStateToProps = (state) => {
return {
date: state.date,
dateSelected: state.dateSelected,
expanded: state.expanded,
formattedDate: state.formattedDate,
selectedTaskObject: state.selectedTaskObject,
}
}
最后,将所有东西连接起来:
export default connect(mapStateToProps, mapDispatchToProps)(EditTask);
修改 iOS 版本的 EditTask 组件
接下来,我们将修改 EditTask 组件。它应该:
-
几乎与我们在 第二章 的 高级功能与美化待办事项应用 末尾所拥有的
EditTask组件相同,具有 Android 和 iOS 特定版本: -
用我们可以向状态树分发的动作替换任何操作数据的方法:
由于 datePickerHeight 是在 DatePickerIOS 的 onLayout 事件中设置的,我们将将其保留在本地状态中:
// TasksRedux/app/components/EditTask/index.ios.js
import React, { Component } from 'react';
import {
Button,
DatePickerIOS,
Switch,
Text,
TextInput,
View
} from 'react-native';
import ExpandableCell from '../ExpandableCell';
import styles from './styles';
export default class EditTask extends Component {
constructor (props) {
super (props);
this.state = {
datePickerHeight: undefined
}
}
TextInput 在文本改变时触发 changeTextInputValue 动作,并从我们状态树中的 selectedTaskObject 获取其值:
render () {
const noDueDateTitle = 'Set Reminder';
const dueDateSetTitle = 'Due On ' +
this.props.selectedTaskObject.formattedDate;
return (
<View style={ styles.editTaskContainer }>
<View>
<TextInput
autoCorrect={ false }
onChangeText={ (text) =>
this.props.changeTextInputValue(text) }
returnKeyType={ 'done' }
style={ styles.textInput }
value={ this.props.selectedTaskObject.text }
/>
在这里,ExpandableCell 组件保持不变,但它将 expanded 布尔值和 title 确定逻辑委托给我们的 Redux 状态,并在按下时触发 onExpand 动作:
</View>
<View style={ [styles.expandableCellContainer,
{ maxHeight: this.props.expanded ?
this.state.datePickerHeight : 40 }]}>
<ExpandableCell
childrenHeight={ this.state.datePickerHeight }
expanded={ this.props.expanded }
onPress={ () => this.props.onExpand(this.props.expanded) }
title={ this.props.due ? dueDateSetTitle : noDueDateTitle }>
如您所见,唯一剩余的基于组件的逻辑是与 _getDatePickerHeight 相关。所有其他函数都由状态树处理:
<DatePickerIOS
date={ this.props.date }
onDateChange={ (date) => this.props.onDateChange(date) }
onLayout={ (event) => this._getDatePickerHeight(event) }
/>
</ExpandableCell>
</View>
<View style={ styles.switchContainer } >
<Text style={ styles.switchText } >
Completed
</Text>
<Switch
onValueChange={ (value) =>
this.props.changeCompletedStatus(value) }
value={ this.props.selectedTaskObject.completed }
/>
</View>
<View style={ styles.clearDateButtonContainer }>
<Button
color={ '#B44743' }
disabled={ this.props.dateSelected ? false : true }
onPress={ () => this.props.clearDate() }
title={ 'Clear Date' }
/>
</View>
</View>
);
}
_getDatePickerHeight (event) {
this.setState({
datePickerHeight: event.nativeEvent.layout.width
});
}
}
修改 Android 版本的 EditTask 组件
对于我们来说,从 第二章 的 EditTask 组件,即 高级功能与美化待办事项应用,取来并对其进行修改最为简单。此外,在修改过程中参考此组件更新的 iOS 版本也可以有所帮助。
特别地,对于 Android 版本的 EditTask,我们想要做以下操作:
-
用通过
props提供的状态树替换任何对本地状态的引用 -
替换任何不必要的本地方法调用,改为分发动作
-
保持本地方法以保存任务和打开日期和时间选择器
-
更新按返回按钮触发的回调,除了在导航器上调用
pop外,还重置selectedTaskObject
我所做的修改显示了 EditTask/index.android.js 和 EditTask/index.ios.js 之间的差异。
我从导入语句中移除了 ExpandableCell:
// TasksRedux/app/components/EditTask/index.android.js
...
import {
...
BackAndroid,
DatePickerAndroid,
TimePickerAndroid,
} from 'react-native';
我从状态中移除了 datePickerHeight 值,因为 Android 组件不处理 ExpandableCell:
...
export default class EditTask extends Component {
constructor (props) {
super (props);
}
添加和删除 Android 返回按钮的事件监听器与第二章 Chapter 2,高级功能与美化待办事项应用 相同:
componentWillMount () {
BackAndroid.addEventListener('hardwareButtonPress', () =>
this._backButtonPress());
}
componentWillUnmount () {
BackAndroid.removeEventListener('hardwareButtonPress', () =>
this._backButtonPress())
}
对 TextInput 组件没有进行任何更改。
render () {
...
return (
<View style={ styles.editTaskContainer }>
<View>
...
</View>
打开 DatePickerAndroid 的 Button 被修改为引用 selectedTaskObject 的 due 属性以确定要渲染的文本:
<View style={ styles.androidButtonContainer }>
<Button
color={ '#80B546' }
title={ this.props.selectedTaskObject.due ?
dueDateSetTitle : noDueDateTitle }
onPress={ () => this._showAndroidDatePicker() }
/>
</View>
对 Switch 组件、清除日期 Button 或保存 Button 没有进行任何更改。保存 Button 专属于此应用的 Android 版本,因为保存编辑任务的逻辑在 iOS 的导航栏中处理:
<View style={ styles.switchContainer } >
...
</View>
<View style={ styles.androidButtonContainer }>
...
</View>
<View style={ styles.saveButton }>
<Button
color={ '#4E92B5' }
onPress={ () => this._saveSelectedTaskDetails() }
title={ 'Save Task' }
/>
</View>
</View>
);
}
修改 _backButtonPress 方法,使其也分发 resetSelectedTask 动作:
_backButtonPress () {
this.props.navigator.pop();
this.props.resetSelectedTask();
return true;
}
我还修改了 _saveSelectedTaskDetails 方法,以分发 saveSelectedTasks 动作:
_saveSelectedTaskDetails () {
this.props.navigator.pop();
this.props.saveSelectedTaskDetails(this.props.selectedTaskObject);
}
将 _showAndroidDatePicker 修改为不在状态中保留 day、month 和 year 值。相反,它直接将数据传递给 _showAndroidTimePicker 以立即使用:
...
async _showAndroidDatePicker () {
const options = {
date: this.props.date
};
const { action, year, month, day } = await
DatePickerAndroid.open(options);
this._showAndroidTimePicker (day, month, year);
}
同样,_showAndroidTimePicker 被修改为接受那些 day、month 和 year 值。然后它立即使用这三个值以及 TimePickerAndroid 返回的 hour 和 minute 创建一个新的 Date 对象,并分发带有新 Date 对象的 onDateChange 动作:
async _showAndroidTimePicker (day, month, year) {
const { action, minute, hour } = await TimePickerAndroid.open();
if (action === TimePickerAndroid.dismissedAction) {
return;
}
const date = new Date(year, month, day, hour, minute);
this.props.onDateChange(date);
}
}
到目前为止,我们几乎完成了对 Redux 的迁移!唯一剩下的是像以前一样持久化我们的任务列表。
创建一个用于异步保存的 StorageMethods 文件
目前,任何刷新或退出应用程序都会清除我们的任务列表。这并不适合一个非常有用的应用程序,因此我们现在将修改我们的动作以从 AsyncStorage 存储和检索我们的任务列表。
让我们在 app 中创建一个 utils 文件夹,然后在 utils 中创建一个名为 storageMethods.js 的文件:

此文件将包含两个函数:
-
getAsyncStorage:此方法从AsyncStorage中获取listOfTasks项目并返回它 -
saveAsyncStorage:接受一个数组并将其保存到AsyncStorage中的listOfTasks键下
如果你在这本书的前几个项目中工作过,这部分对你来说会很熟悉:
// TasksRedux/app/utils/storageMethods.js
import { AsyncStorage } from 'react-native';
export const getAsyncStorage = async () => {
let response = await AsyncStorage.getItem('listOfTasks');
let parsedData = JSON.parse(response) || [];
return parsedData;
}
这使用了异步函数以提高可读性,从 AsyncStorage 中获取 listOfTasks 的值,将其解析以将其转换回数组,然后返回它。
export const saveAsyncStorage = async (listOfTasks) => {
return AsyncStorage.setItem('listOfTasks',
JSON.stringify(listOfTasks));
}
同样,接受一个数组,然后将AsyncStorage中的listOfTasks键设置为该数组的字符串化版本。
订阅存储的变化。
为了在状态树发生变化时更新AsyncStorage中的listOfTasks键,我们将调用存储的subscribe方法。这创建了一个变化监听器,在每次分派动作并且状态树可能发生变化时被调用。
它接受一个回调作为其参数,并且在这个参数内部,我们可以调用存储的getState方法来访问状态树并从中检索我们想要的任何值。
让我们修改应用文件夹中找到的索引文件,以便它订阅存储的变化,触发一个回调,该回调调用saveAsyncStorage并传递我们状态树中最新的listOfTasks数组版本:
// TasksRedux/app/index.js
...
import { saveAsyncStorage } from './utils/storageMethods';
...
store.subscribe(() => {
saveAsyncStorage(store.getState().listOfTasks);
});
...
由于我们的应用程序状态的其他部分没有持久化的需求,listOfTasks是唯一被保存到AsyncStorage中的项。
创建一个 thunk。
Redux-Thunk库是围绕你的动作创建器的包装器,允许它们在将预期的动作分派到 Redux 存储以由还原器处理之前执行异步任务。
这是我们创建 thunk 的方式:在创建动作的文件中,我们将导出一个函数,该函数返回一个自定义的异步函数,并将现有的dispatch方法传递给它。在这个自定义函数中,我们将获取对getAsyncStorage方法的调用结果。
然后,在同一个方法内部,我们将分派一个私有函数,我们也在同一个文件中创建这个私有函数。这个私有函数将返回动作类型,以及我们希望传递的任何参数:
// TasksRedux/app/actions/index.js
import { getAsyncStorage } from '../utils/storageMethods';
我已经从该文件中移除了currentIndex变量,因为我们不再依赖于硬编码的数字来设置任务的索引。
首先,创建一个用于设置任务列表和索引的动作常量:
const SET_LIST_OF_TASKS_AND_INDEX = 'SET_LIST_OF_TASKS_AND_INDEX';
创建getListOfTasksAndIndex动作。
export function getListOfTasksAndIndex () {
return async (dispatch) => {
let response = await getAsyncStorage();
dispatch(setListOfTasksAndIndex(response, response.length));
}
}
这个setListOfTasksAndIndex函数没有被导出,因为它只被getListOfTasksAndIndex调用。我们使用数组的长度来为新添加的任务设置索引:
function setListOfTasksAndIndex (listOfTasks, index) {
return {
type: SET_LIST_OF_TASKS_AND_INDEX,
index,
listOfTasks,
}
}
修改我们的还原器。
我们需要修改我们的还原器文件,以便它执行以下操作:
-
将
currentIndex属性添加到其defaultState对象中。 -
当任务被添加到我们的状态树时设置任务的
index。 -
在添加新任务时增加我们状态树中
currentIndex属性的值。 -
包含一个用于
SET_LIST_OF_TASKS_AND_INDEX动作的 switch 情况,将我们的状态树中的currentIndex和listOfTasks属性设置为我们的getListOfTasksAndIndexthunk 的结果。
// TasksRedux/app/reducers/index.js
const defaultState = {
currentIndex: undefined,
...
}
...
const singleTask = (state = {}, action) => {
switch(action.type) {
case 'ADD_TASK':
return {
...
index: action.index,
}
...
}
}
在singleTask子还原器中的ADD_TASK情况设置了添加的任务的索引。
对selectedTask子还原器没有进行任何更改。
...
const listOfTasks = (state = defaultState, action) => {
switch(action.type) {
case 'ADD_TASK':
...
return {
currentIndex: ++state.currentIndex,
...
}
在前面的代码中,listOfTasks还原器中的ADD_TASK情况将状态树的currentIndex增加一个,并将其设置为新的currentIndex。
...
case 'SET_LIST_OF_TASKS_AND_INDEX':
return {
...state,
currentIndex: action.index,
listOfTasks: action.listOfTasks
}
}
}
export default listOfTasks;
在前面的代码中,SET_LIST_OF_TASKS_AND_INDEX情况将currentIndex和listOfTasks属性设置在我们的状态树中,这些属性是通过在getListOfTasksAndIndexthunk 中调用getAsyncStorage返回的结果。
更新 TasksListContainer
接下来,我们需要更新TasksListContainer,使其执行以下操作:
-
将
getListOfTasksAndIndex操作和currentIndex值映射到其属性 -
修改
addTask操作以期望和发送一个index参数
这些是我最终得到的更改:
// TasksRedux/containers/TasksListContainer/index.js
...
import {
...
getListOfTasksAndIndex,
} from '../../actions';
import TasksList from '../../components/TasksList';
const mapDispatchToProps = (dispatch) => {
return {
addTask: (text, index) => {
dispatch(addTask(text, index));
},
...
getListOfTasksAndIndex: () => {
dispatch(getListOfTasksAndIndex());
},
...
}
}
const mapStateToProps = (state, { navigator }) => {
return {
currentIndex: state.currentIndex,
...
}
}
...
修改 TasksList 组件
最后,我们将编辑TasksList,使其执行以下操作:
-
在
componentWillMount生命周期事件期间派发getListOfTasksAndIndex操作 -
将状态树的
currentIndex传递给TextInput的onSubmitEditing回调作为第二个参数
在componentWillMount生命周期事件期间调用getListOfTasksAndIndex,确保当用户打开应用时TasksList具有最新的listOfTasks数组版本:
// TasksRedux/app/components/TasksList/index.js
...
export default class TasksList extends Component {
...
componentWillMount () {
this.props.getListOfTasksAndIndex();
}
将this.props.currentIndex作为调用addTask方法的第二个参数添加,这样我们就可以明确地为每个任务提供一个唯一的索引:
render () {
...
return (
<View style={ styles.container }>
<TextInput
onSubmitEditing={ () => this.props.addTask
(this.props.text, this.props.currentIndex) }
...
/>
...
</View>
);
}
...
}
就这样!我们已经成功地将我们的待办事项列表应用Tasks转换为支持 Redux。
摘要
在本章中,我们学习了使用 Redux 的 React 开发基础!我们首先创建操作,将意图分发给我们的 Redux 存储。然后,我们编写了减少器来处理那个意图并更新我们的状态树。我们还构建了一个存储,它整合了我们的减少器和中间件。
之后,我们使用 Connect 方法将一个容器包裹在 React 组件周围,使其能够访问我们选择的任何操作和状态树的部分。
我们还将现有的EditTask和TasksList组件转换为更少依赖于本地状态,并使用状态树中的逻辑。
在本章的后面部分,我们发现了如何通过使用Redux-Thunk暂时延迟动作的派发,首先执行必要的异步调用。这,结合订阅我们的存储以获取任何更新,使我们能够拥有一个完全持久的应用,该应用使用AsyncStorage来保持其数据。
最后,我们确保了每一步都保留了我们在本章开始时引入的 Android 支持。
在下一章中,我们将做一些改变。我们花了所有这些时间构建应用程序,但没有时间考虑如何将它们分享给世界。在下一章中,我们将学习如何将你制作的应用程序上传到 Apple App Store 和 Google Play Store。
第八章:部署您的应用
开发者将他们的应用发布到不同的市场,以便将它们分发给最终用户。对于 iOS,是苹果应用商店。在 Android 上,主要选择是谷歌应用商店。
如果您因为 React Native 的力量而涉足移动开发,这对你来说将是一个全新的世界。由于我在 Web 方面有背景,我最初在发布我的第一个移动应用时感到有些迷茫——毕竟,我们习惯于将应用部署到服务器上。
在本章中,您将执行以下操作:
-
了解提交应用到苹果应用商店和谷歌应用商店的要求
-
了解应用标志和截图的重要性
-
为我们的应用编写描述
-
对于 iOS,使用 Xcode 验证我们的应用并将其打包提交到 iTunes Connect
-
将 iOS 应用提交给苹果进行审查
-
使用 TestFlight 为用户创建内部和外部 beta 测试
-
使用开发者控制台添加并提交我们的安卓应用到谷歌应用商店
-
学习如何向测试用户发送我们安卓应用的 alpha 和 beta 版本
基本要求
在基本层面上,要将您的应用提交到 App Store 或 Google Play,您将需要您选择平台(s)的开发者会员资格。
对于 iOS,您需要一个苹果开发者会员资格。这个年度的付费会员资格让您能够在苹果的所有平台上发布——iOS、macOS 和 tvOS。它还为您提供了将应用程序的预生产版本首先发送给 beta 测试者的选项。
在安卓设备上进行分发,最受欢迎的渠道是谷歌应用商店。它覆盖了所有官方的、谷歌支持的安卓设备,并需要一个发布者账户。
对于这两个应用,我们还需要提供应用图标,这样我们的用户就不会在设备上安装一个空白图标,并且需要提供截图,以便他们在下载前有一个更好的了解。
在我们继续之前,应该指出的是,我们构建的应用,包括截图,可能不会被任何一家应用商店的编辑委员会接受销售。我们正在使用这个项目作为拥有一个可工作的应用,以便实际提交应用程序的过程。
创建开发者会员资格
在这两个商店中注册开发者会员资格的过程是不同的。由于这些先决条件和步骤可能会随时更改,我将省略详细的步骤说明。
注册苹果开发者计划
要注册苹果开发者账户,请将您的浏览器指向developer.apple.com。
加入苹果开发者计划需要一个苹果 ID。截至本书出版时,会员每年需支付 99 美元的会员费。
注册谷歌应用商店发布者账户
要开始您的发布者账户,请将您的浏览器指向play.google.com/apps/publish。
要注册 Google Play Publisher 账户,你需要一个 Google 账户——通常,这是你的 Gmail 地址。截至本书出版时,该计划要求支付 25 美元的单次费用才能加入。
使你的应用程序看起来很棒
除了在适当的平台开发者程序中拥有会员资格外,将你的应用程序提交到任一商店还需要你提供应用程序的标志、应用程序的截图和描述。
创建图标
图标是进入你应用程序的门户。这是用户将与之交互以访问应用程序的方式。为了将应用程序提交到你选择的适当市场,它必须有一个图标。
因为图标是应用程序的品牌,仅仅创建一个纯色阴影并在上面添加一些文字已经不够了——用户期望更多。
个人来说,我喜欢使用 Sketch (www.sketchapp.com/) 来设计我的应用程序图标。虽然我的设计技能不是最好的,但我发现它简单直观。
你可以为你的应用程序获取图标的方式有很多。你可以雇佣某人(无论是本地还是在线)来设计一个,或者使用大量的应用程序自己创建一个。
当我需要为提交到 App Store 和 Google Play 的不同尺寸的应用程序图标生成图标时,我首选的服务是 MakeAppIcon (makeappicon.com),它作为一个拖放服务在网络上提供,同时也是一个独立的 Mac 应用程序。
拍摄应用截图
屏幕截图可以让你的潜在应用程序客户一窥你的应用程序的外观。
创建截图的一个快速方法是直接在设备或模拟器上运行应用程序时拍摄它们,然后将这些截图上传到 App Store 或 Google Play。
你可以使用各种服务为你的截图添加额外的美学元素。有一个服务会将你的截图置于 iOS 设备的照片中,让你的潜在客户看到应用程序在物理设备上的外观。
要将设备/背景等元素添加到我的屏幕截图上,LaunchKit 提供了一个出色的 Sketch-to-App-Store 插件(sketchtoappstore.com/),它可以帮助实现这一功能,尽管可用于将截图应用于其上的设备有些过时——最新的 iOS 设备是 iPhone 6 Plus,唯一可用的 Android 设备是 Nexus 5。
此外,还有一些服务可以帮助去除状态栏,以防你不想在屏幕截图中显示它——特别是如果你在状态栏内容繁多的设备上截图,或者你的截图在状态栏上显示的时间不同,而你希望以一致的方式展示它们。
我喜欢使用来自 Mac App Store 的 Status Barred (itunes.apple.com/us/app/status-barred/id413853485?mt=12) 来从我的屏幕截图中裁剪状态栏。
编写描述
没有一个合适的描述,您的潜在客户将不知道您的应用做什么,或者没有足够的信息来决定是否安装您的应用。无论您的应用是免费还是付费,缺乏合适的描述都将对您的应用产生负面影响。
简单的一行也不行——您想要向您的潜在客户清楚地传达他们为什么要下载您的应用。它做什么?它有哪些功能会让用户感兴趣?
总是抓住机会为您的应用写一个详细的描述。
综合所有内容
不管你是否喜欢,一个好的描述,加上一个漂亮的图标和截图,确实会在潜在客户决定是否下载你的应用时起到决定性的作用。
一旦您拥有了开发者会员资格,有了应用图标、截图和描述,您就可以开始提交您的应用,让全世界都能享受它。
接下来,我们将介绍如何将我们的 Facebook 客户端“朋友”提交到 App Store 和 Google Play。
Apple App Store
我们首先需要为我们的应用提供一个 iOS App ID。在 Apple 开发者门户developer.apple.com中,选择证书、标识符和配置文件:

一旦您进入管理您的证书、标识符和配置文件的门户,选择 App IDs 选项,然后点击右上角的添加按钮:

这将带您到一个页面,允许您注册一个 App ID。给它起一个名字(不要使用特殊字符),然后决定您是否会给应用一个显式 App ID 或通配符 App ID,接着宣布您打算在应用中启用哪些服务。
如果您实现了如推送通知、应用内购买和 Apple Pay 等特定于应用的服务,则需要一个显式 App ID。
如果您不打算使用任何特定于应用的服务,Apple 建议您使用通配符 App ID:

配置完成后,您将被要求确认您的 App ID。如果一切看起来都正确,点击注册按钮完成 App ID 的注册:

创建了“朋友”的 App ID 后,我们的下一步是打开 Xcode 中的项目,并对我们的项目做一些修改。
在 Xcode 中修改 Bundle ID
打开您的“朋友”仓库所在的文件夹,进入ios文件夹。打开Friends.xcodeproj,它将在 Xcode 中自动打开。
在 Xcode 中,转到您的项目的一般选项卡,然后在签名下选择团队下拉菜单。使用您的 Apple ID 和密码登录。一旦这样做,就会为您自动生成一个签名证书。
然后,在 Identity 下,将您的 Bundle Identifier 设置为您之前在 Apple 开发者门户中添加的应用 ID。之后,我们将为应用程序添加一个图标。我已在此章节所属的存储库中添加了您可以使用此原因的图标。
在 Xcode 中添加应用程序图标
在仍然处于 General 选项卡的情况下,选择 App Icons Source 下拉菜单右侧的箭头:

之后,您将被带到屏幕,允许您将图像资源拖放到相应的标志上。如果最终将错误尺寸的图标添加到错误的拖放区域,Xcode 将提供警告:

每个拖放区域下方的文本显示了每个图标的目标分辨率。
一旦您设置了应用程序图标,就是时候创建应用程序存档以提交到 iTunes Connect。
创建存档
首先,让我们确保在 Xcode 打开时进入 Mac 工具栏,选择 Product | Scheme | Edit Scheme,以确保我们的项目方案设置正确存档了应用程序的发布版本而不是调试版本:

一旦打开此面板,选择左侧的 Archive 选项并确认构建配置设置为 Release:

现在,关闭此窗口并转到此下拉菜单,它允许您在 Xcode 中选择构建和运行应用程序的设备:

Xcode 不允许您创建目标为 iOS 模拟器设备的应用程序存档。如果您尝试这样做,该选项将在 Product 菜单下简单地变灰。
点击此下拉菜单并选择 Generic iOS Device:

最后,回到 Xcode 的 Product 菜单并选择 Archive:

现在,Xcode 将构建和存档您的应用程序,以便将其准备好分发到 iTunes Connect。如果一切顺利,您将看到以下存档列表,右侧有一些选项:

保持此屏幕打开,因为我们很快就会需要它。现在,让我们进入 iTunes Connect 以将应用程序添加到我们的账户中。
iTunes Connect
iTunes Connect 是苹果开发者提交其应用程序的门户。要进入,请将浏览器指向 itunesconnect.apple.com 并在需要时登录。您将进入 iTunes Connect 仪表板。在仪表板上,选择 My Apps,这是顶部行左侧的第一个应用程序。
选择后,您将到达一个列表,列出了您已提交到 iTunes Connect 的应用程序及其状态。

要提交新应用程序,请选择左上角的添加符号并选择 New App:

您将被要求在出现的模态窗口中填写有关您应用的详细信息。选择您的平台(iOS),为应用命名,主要语言,Bundle ID(映射到您在开发者门户中创建的应用 ID,这同时也是您在 Xcode 中的应用的 Bundle ID),以及一个唯一的 SKU:

完成此步骤后,您将被带到有关您应用的摘要页面。现在让我们回到 Xcode,完成验证和提交我们的应用到 iTunes Connect 的过程。
在 Xcode 中验证我们的应用
在 Xcode 的最后屏幕中选择“验证”,这将导致出现此摘要屏幕:

点击“验证”按钮。让它与 iTunes Connect 通信,如果一切检查无误,您将收到“验证成功”提示。如果是这种情况,点击“完成”,然后让我们开始上传我们的应用。
按下“上传到 App Store”按钮,您将收到一个类似请求,要求您选择您的开发团队,然后点击上传:

到此为止,您项目的存档将开始上传并验证其资产与 App Store 的过程。请给它一些时间,一旦完成,您将看到“上传成功”提示。一旦完成,让我们立即回到 iTunes Connect。
提交我们的应用进行审查
在我们可以提交我们的应用进行审查之前,我们还有一些最终步骤。首先,我们需要向其中添加截图。在本章的文件夹中,我已经添加了一些您可以尝试的截图。
在左侧列标的“1.0 准备提交”标签下选择我们的应用,然后将我们的截图拖到拖放区:

然后,为应用提供描述、一些关键词和支持以及营销 URL。完成这些后,向下滚动并选择要提交的构建--这将是我们在上一节通过 Xcode 上传到 App Store 的构建:

iTunes Connect 会自动调整用于 5.5 英寸显示屏的图片大小,以便较小的显示类型不需要自己的独特图片,除非您希望自行分配。

您会发现通过 Xcode 上传的构建会自动填充在出现的模态窗口中,其中包含您在 Xcode 中分配的相应版本和构建号:

一旦选择,在 iTunes Connect 中的“构建”部分将更新您的应用信息:

下一步是在“常规应用信息”下包含一个 1024 × 1024 的应用图标,并填写版权信息和您的联系信息:

然后,提供您的联系信息,以及您希望 App Store 审阅者阅读的任何注释,以及如果需要使用应用的所有功能,请提供演示账户。
最后,决定您的应用在 App Store 审阅者批准后是否立即发布:

当你填写完所有这些详细信息后,请点击顶部的“提交审查”按钮。这就完成了!给自己鼓掌,并等待苹果的审查团队回复,无论是批准还是拒绝。
使用 TestFlight 进行 iOS 应用测试
TestFlight 是苹果几年前收购的一项服务,它与 iTunes Connect 完全集成。它允许你在将 iOS 应用发布到 App Store 之前,通过一组精选的测试者进行测试。
测试是重要的,因为它让开发者能够在实际使用场景中收集关于他们应用的反馈,并发现他们可能没有注意到的错误。
你可以使用 TestFlight 运行两种测试:内部和外部测试。
在内部测试中,你的团队中分配了开发者或管理员角色的用户可以私下测试应用,让你能够快速从他们那里获得反馈。最多可以有 25 名用户参与内部测试。
对于外部测试,你可以邀请任何组织外的用户来测试你的应用。可供外部测试的应用可以接受多达 2,000 名用户,并且你的应用必须在开始测试之前由苹果进行审查。
根据我的经验,Beta App 审查过程比正式的 App 审查过程要短得多,后者是将你的应用发布给所有用户。
输入测试信息
将你的应用通过 TestFlight 发布的第一步是登录到 iTunes Connect,选择你的一个应用,然后选择 TestFlight 选项卡。然后,填写 Test Information 表单以提供一些关于你的应用的详细信息:

保存这些详细信息后,你可以开始设置内部或外部测试。
使用 TestFlight 创建内部测试
要创建内部测试,请在侧边栏中选择“内部测试”选项,然后点击“选择要测试的版本”,这将使以下模态可见:

如果你已上传了多个应用版本,你应该会看到它们在这里出现。选择你希望发送给测试者的版本,然后点击“下一步”按钮。如果你的应用尚未配置为声明其出口合规性,你将被带到模态的第二个问题,询问你的应用是否设计用于使用、包含或集成加密。
接下来,你可以点击“内部测试者”框添加至少一个测试者。此列表将显示你通过 iTunes Connect 添加的任何用户。如果你到目前为止还没有添加任何人到你的团队,你将只会看到你自己作为列出的测试者。
要更改这一点,请返回到主 iTunes Connect 门户并选择“用户和角色”:

选择 + 图标添加用户,并填写多页表单以继续:

一旦您添加了要发送内部测试的用户,您可以在要测试的 beta 测试应用的 TestFlight 标签页的内部测试部分中选择他们。当您选择了所有人后,点击“开始测试”按钮,您的用户将收到一封电子邮件通知,邀请他们加入测试!
使用 TestFlight 创建外部 beta 测试
创建外部测试的过程与内部测试有一些不同之处。首先,在您的应用获得外部 beta 测试批准之前,您必须向苹果提交您的应用进行审查。当您点击外部测试的“添加构建到测试”按钮时,您将看到以下提示,以便填写更多关于应用的详细信息:

这是为了向您的用户明确说明他们应该测试应用哪些部分,以及为他们提供的阅读描述。您还需要提供一个他们可以发送反馈的电子邮件地址,以及您应用的营销 URL。
然后,您将被带到这个模态框的另外两个部分,您需要为 Beta App Review 团队提供详细信息。第一部分将包括您的联系信息,以防苹果希望就您的应用与您联系,以及添加测试账户供 Beta App Review 团队使用的能力:

之后,您将被要求提供应用的描述,以及团队在审查您的应用时应注意的事项:

您必须先确保您的应用通过了 Beta App Review,才能开始外部测试:

同时,您可以通过以下三种选项之一添加测试用户:

第一个方法是输入测试者的姓名和电子邮件地址来添加新测试者,这将弹出一个模态框,允许您这样做:

下一个选项允许您添加过去测试过您任何应用的现有外部测试用户。该选项将为您提供一份带有复选框的列表,以便您选择要添加的用户。
最后一个选项是导入外部测试用户的 CSV 文件。
一旦您添加了至少一个用户,并且您的构建已通过 Beta App Review 的批准,您就可以提交应用并开始 beta 测试!
这就是我们要介绍的有关 iOS App Store 和 iTunes Connect 的所有内容。在下一节中,我们将探讨如何将 Friends for Android 上传到 Google Play Store。
Google Play Store
要将应用提交到 Google Play Store,我们需要执行以下步骤:
-
添加应用图标
-
为我们的应用提供一个独特的包标识符
-
生成已签名的 APK 文件
-
将 APK 文件上传到 Google Play 开发者门户
-
为我们的应用添加截图和描述
为 Android 添加应用图标
为了给您的 Android 应用添加图标,我们将导航到以下文件夹:android/app/src/main/res。
然后,将重命名为 ic_launcher.png 的图标图像添加到每个包含相应尺寸的四个文件夹中:
-
mipmap-hdpi: 72 × 72 -
mipmap-mdpi: 48 × 48 -
mipmap-xhdpi: 96 × 96 -
mipmap-xxhdpi: 144 × 144
添加这些图像后,运行 react-native run-android 命令来构建你的应用,你应该会在你的 Android 虚拟设备的首页上看到图标已更新。
为了方便起见,本章代码仓库中已经提供了 Friends 安卓版本的图标。
创建唯一的包标识符
如果你的 APK 的包名与 Google Play 商店中已存在的另一个应用相同,则不会将其上传到 Google Play 开发者控制台。为了给它一个独特的名字,我们首先打开 Android Studio 并将我们的应用 Android 文件夹导入其中。
从 Android Studio 欢迎屏幕中选择“导入项目”(Eclipse ADT、Gradle 等)。然后导航到你的项目仓库,并仅导入其中的 Android 文件夹。
之后,打开 AndroidManifest.xml 并更改第二行的包名。
然后,右键单击包含包标识符的 app 文件夹,选择“重构”|“重命名”。然后,给你的包起一个新的名字:

现在打开 android/app 文件夹中的 build.gradle,并将任何旧包标识符替换为新的。你做的更改将自动应用。
生成签名 APK 文件
首先,在你的终端中,我们将生成一个私钥签名:
$ keytool -genkey -v -keystore friends-release-key.keystore -alias
friends -key-alias -keyalg RSA
-keysize 2048 -validity 10000
你将为你的 keystore 和密钥设置密码。你的发布密钥将被标记为 friends-release-key,除非你选择其他名称。
此命令中的 validity 标志设置了密钥的有效期为 x 天。在这种情况下,由于我们的命令,它是 10000。
此 keystore 文件将位于你运行命令时终端提示符所在的目录。将其移动到你的 Friends 应用仓库中的 android/app 目录下。
重要!将你的 keystore 文件提交到 Git 或任何其他版本控制系统不是一个好主意。确保将其添加到 .gitignore 文件中。
然后,打开你的 gradle.properties 文件并添加以下信息:
// Friends/android/gradle.properties
MYAPP_RELEASE_STORE_FILE=friends-release-key.keystore
MYAPP_RELEASE_KEY_ALIAS=friends-key-alias
MYAPP_RELEASE_STORE_PASSWORD=YOURPASSWORDHERE
MYAPP_RELEASE_KEY_PASSWORD=YOURPASSWORDHERE
将 YOURPASSWORDHERE 替换为你为发布商店和发布密钥创建的密码。
接下来,我们将向你的应用的 build.gradle 文件中添加签名配置:
// Friends/android/app/build.gradle
...
android {
...
signingConfigs {
release {
storeFile file(MYAPP_RELEASE_STORE_FILE)
storePassword MYAPP_RELEASE_STORE_PASSWORD
keyAlias MYAPP_RELEASE_KEY_ALIAS
keyPassword MYAPP_RELEASE_KEY_PASSWORD
}
}
...
buildTypes {
release {
...
signingConfig signingConfigs.release
}
}
...
}
...
之后,让我们生成 APK。回到终端,切换到 Android 目录并运行以下命令:
./gradlew assembleRelease
构建过程可能需要几分钟。一旦完成,你将在 android/app/build/outputs/apk/app-release.apk 下找到生成的签名 APK。
如果你想在 Android 虚拟设备上测试它,只需将你的目录切换回项目的根目录,并运行以下命令:
react-native run-android --variant=release
如果一切看起来都正确,那么让我们提交应用到 Google Play 商店!
提交到 Google Play
访问play.google.com/apps/publish的 Google Play 开发者控制台。
使用你的谷歌账户登录后,选择“在 Google Play 上发布安卓应用”选项,这是在可见卡片区域的最左上角:

你将被提示为你的应用提供一个默认语言和标题:

一旦创建了应用,你将被带到允许你编辑应用详细信息的页面。
要提交一个应用,你需要填写以下信息:
-
高分辨率 512 × 512 图标
-
特效图形
-
两个非安卓 TV 的截图
-
类别和内容评级
-
应用简短和完整描述
-
上传我们之前创建的 APK
-
至少针对一个国家进行分发
-
指向应用隐私政策的 URL
-
定价信息
-
声明应用是否包含广告
使用左侧菜单在四个必需标签页之间导航:上传你的 APK、Play 商店上的列表、内容评级和定价信息。
当所有详情都已填写完毕,让我们导航到侧边栏中的“管理发布”部分,开始将你的应用到 Google Play 商店推出的过程:
截至 2017 年 3 月,谷歌计划弃用侧边栏中的 APK 页面,并鼓励大家开始迁移到“管理发布”页面。

选择“生产”并将带你到以下页面:

在 Google Play 开发者控制台中,如果你选择了你的应用并进入侧边栏中的“管理发布”选项,你将被要求上传一个新的 APK 或从库中添加一个现有的 APK。在这种情况下,我选择了从库中添加 APK,因为我之前已经上传了Friends APK文件。
然后我被要求从我的现有 APK 列表中选择:

之后,为应用提供一个发布名称和关于这次发布新内容的描述,然后按底部右角的“审查”按钮。
在下一步中,你将需要在你向公众推出之前审查你的发布详情。一旦你确认详情正确,请按底部右角的“开始推出到生产”按钮。你将收到确认提示。
一旦确认,应用将开始推出到 Google Play 商店!

安卓应用的 Alpha 和 Beta 测试
在“管理发布”页面,你可能已经注意到有按钮可以管理你的应用的 Alpha 和 Beta 发布。
在功能上,目前 Alpha 测试和 Beta 测试之间没有区别。然而,你可以通过为一个版本运行 Alpha 测试,为另一个版本运行 Beta 测试,同时测试你应用的两个不同版本。
每个测试都可以针对每个轨道可用的三种不同的测试方法之一运行:
-
公开:这允许任何拥有订阅链接的人订阅
-
私密:这里您需要具体邀请人们通过他们的电子邮件地址加入测试
-
使用 Google Groups 或 Google+ 社区进行测试:这里只允许您指定的组或社区中的用户加入测试
您还需要为每个测试轨道创建一个应用发布版本,其工作流程与将应用部署到生产环境相同。
一旦您为测试创建了发布版本,系统将为您生成一个链接,以便您在测试人员之间分发,他们可以在自己的设备上安装应用。
摘要
这就是本章的全部内容!您已经迈出了第一步,开始发布您用 React Native 为 iOS 和 Android 设备构建的应用。
在本章中,我们了解了注册开发者账户以将我们的应用到苹果应用商店和谷歌应用商店发布的要求和成本。然后我们探讨了向这些市场提交应用的要求,包括应用图标和截图。
我们还为两个平台构建并上传了应用文件,为我们的 Android 应用生成了一个签名 APK,然后提交了应用以进行分发。最后,我们发现了如何通过 iOS 的 TestFlight 和 Android 的谷歌开发者控制台使我们的应用可用于预发布测试。
在最后一章中,我们将探讨我们无法融入现有三个项目的 React Native SDK 的部分,并了解如何使用它们。
第九章:其他 React Native 组件
在整本书中,我们提到了许多 React Native SDK。然而,有些组件的添加并不适合我们构建的应用程序。
在本章中,我们将介绍其中的一些,以便你能够在 React Native 框架中获得一些扩展实践。你将学习以下内容:
-
编写一个游乐场应用程序,我们将添加在先前项目中没有使用过的 React Native API 组件和部分
-
使用 Fetch API 对第三方资源进行网络调用
-
利用 Vibration API 使用户的手机产生物理振动
-
利用 Linking API 让您的应用通过已注册的链接打开第三方应用
-
构建一个可以滑动以在定义的最小值和最大值之间设置值的滑块
-
学习如何在 iOS 中使用 Action sheet 和 Share sheet 来分享应用中的详细信息
-
使用 Geolocation polyfill 获取用户的位置数据
此外,本章末尾还包括一个教程,介绍我们如何在第四章中构建Expenses,即使用 Android 的高级功能。
设置样板项目
使用 React Native 命令行工具,我创建了一个名为AdditionalComponents的项目。在其中,结构比我们之前的项目简化得多。
在项目的根目录下的每个特定平台的索引文件中,它从我们的app文件夹导入一个App组件并将其注册到AppRegistry:
// AdditionalComponents/index.ios.js
import App from './app';
import { AppRegistry } from 'react-native';
AppRegistry.registerComponent('AdditionalComponents', () => App);
我们将使用位于app文件夹中的index.js文件来存放我们将要构建的示例代码。
在这一步骤结束时,我们有一个index.js文件:
// AdditionalComponents/app/index.js
import React, { Component } from 'react';
import {
Text,
View
} from 'react-native';
import styles from './styles';
export default class App extends Component {
render () {
return (
<View style={ styles.container }>
<Text style={ styles.text }>
Hello from React Native!
</Text>
</View>
)
}
}
此外,我们还有一个包含我们样式的文件:
// AdditionalComponents/app/styles.js
import { StyleSheet } from 'react-native';
export const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fafafa'
},
text: {
alignSelf: 'center',
color: '#365899',
marginTop: 50
}
});
export default styles;
让 Fetch 发生
我们将要做的第一件事是在componentDidMount生命周期中对第三方 API 发起请求。我们的目的是从该 API 获取一组 JSON 数据,并将其用于填充我们将在下一节中创建的Picker组件。
我将要使用的第三方 API 是一个很酷的 API,它产生 JSON 占位符数据--jsonplaceholder.typicode.com。
为了从这个第三方 API 获取数据,我们将使用fetch API。fetch是一个 JavaScript API,不需要特别导入到我们的文件中。它返回一个包含响应的 promise。如果我们想使用 promises,我们可以像这样调用fetch:
fetch(endpoint, object)
.then((response) => {
return response.json();
})
.then((result) => {
return result;
})
我们也可以使用async/await关键字调用fetch:
async fetchAndReturnData (endpoint, object) {
const response = await fetch(endpoint, object);
const data = await response.json();
return data;
}
fetch接受的第一个参数是 API 的endpoint。第二个是一个可选的object。默认情况下,fetch假设你正在发起一个GET请求。为了发起一个POST请求,你必须传递一个包含POST属性作为字符串的object到名为method的键。此object还可以接受你希望包含的任何头信息以及所有其他要发送的请求参数。
例如,一个对象可以看起来像这样:
const obj = {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Origin': '',
'Host': 'api.test.com'
},
body: JSON.stringify({
'client_id': apiKey,
'client_secret': apiSecret,
'grant_type': grantType
})
}
在你的 App 组件中,让我们创建一个 componentDidMount 生命周期事件,使用以下 endpoint 从我们的 JSONPlaceholder API 获取数据:
https://jsonplaceholder.typicode.com/json
当数据被获取时,将其保存到组件的 state 中,并在 console 中记录以显示它已被填充。你编写的代码可能看起来像以下这样:
// AdditionalComponents/app/index.js
...
export default class App extends Component {
constructor (props) {
super (props);
this.state = {
data: []
}
}
async componentDidMount () {
const endpoint = 'https://jsonplaceholder.typicode.com/users';
const response = await fetch(endpoint);
const data = await response.json();
this.setState({
data
});
console.log(this.state.data);
}
...
}
这就是你需要做的所有事情,在 React Native 应用程序中使用 fetch 从 endpoint 发起 GET 请求!接下来,我们将使用 Vibration API 在我们的 fetch 函数成功解析时向用户的设备发送振动。
振动
Vibration API 允许我们访问用户移动设备的振动电机并向其发送振动。
这个演示将需要使用实际硬件进行测试,但如果你有可用的硬件进行实验,那么设置过程是值得的。在不支持振动的设备上,包括模拟器,将不会有任何效果。
使用 Vibration API 有一些需要注意的注意事项。
从用户体验的角度来看,在你的应用程序中使用振动应该作为一种反馈机制,让用户知道发生了某种交互。
此外,在用户的手机上长时间使用振动电机会导致设备电池性能承受更大的压力。
很容易滥用这个 API 并在你的应用程序的所有方面都包含振动,但我强烈建议将此功能保留用于确认用户可能与你的应用程序进行的键交互。
它还提供了两种方法:
-
第一种方法是
Vibration.vibrate(),它接受两个参数:-
第一种是一个数字或者数字数组。这个数字(或数字)被认为是振动发生的模式。
-
如果传递一个数字,Android 设备将根据这个数字的毫秒数振动。在 iOS 上,它将始终导致 1 次振动。
-
如果传递一个数字数组给这个函数,振动电机将遵循不同的模式。Android 设备将等待等于第一个索引的毫秒数,然后振动等于第二个索引的毫秒数。这种振动电机在等待和振动之间切换的模式将延续到数组的长度。
-
例如,一个
[100, 200, 300, 400]的数组意味着 Android 设备将等待100毫秒,然后振动200毫秒,然后等待300毫秒,最后振动400毫秒。 -
iOS 上振动电机的行为不同。而不是根据每个索引交替等待和振动,iOS 功能将始终在固定的 1 秒间隔内振动,并在振动之间根据数组中的下一个数字等待。此外,如果数组中的第一个索引设置为 0,iOS 将忽略它。
-
例如,一个
[0, 100, 200, 300]的数组意味着 iOS 设备将跳过第一个索引,振动 1 秒,然后等待100毫秒再次振动 1 秒。之后,它等待200毫秒再次振动 1 秒。最后,在最后一次振动后等待300毫秒,然后再次振动 1 秒。 -
Vibration.vibrate()接受的第二个参数是一个布尔值,它告诉电机在完成振动模式后是否重新启动振动(无限期地)或停止一次迭代后停止。
-
-
第二种方法是
Vibration.cancel(),它用于取消当前正在进行的振动。如果你在vibrate方法中将重复布尔值设置为true,这一点很重要。
此外,对于 Android 设备,你需要在你的 AndroidManifest.xml 文件中添加以下行:
<uses-permission android:name="android.permission.VIBRATE"/>
在本节中,让我们创建一个回调,当 Picker 可访问数据时触发,并使设备振动几次。这是我想到的代码;你的可能与此类似:
// AdditionalComponents/app/index.js
...
import {
...
Vibration
} from 'react-native';
...
export default class App extends Component {
...
async componentDidMount () {
const endpoint = 'https://jsonplaceholder.typicode.com/users';
const response = await fetch(endpoint);
const data = await response.json();
this.setState({
data
});
this._onDataAvailable();
}
...
_onDataAvailable () {
Vibration.vibrate([1000, 2000, 1000, 2000], false);
}
}
这很简单!在下一节中,我们将探讨如何打开设备上另一个已安装应用的链接。
使用按钮链接应用
深链接允许我们与其他应用之间的传入和传出链接进行交互。通过为你的应用创建一个深链接,你可以使其他应用能够直接与之通信,并传递参数给它,如果需要的话。
你还可以访问其他应用的深链接,使用自定义参数打开它们。在本章中,我们将学习如何使用深链接访问其他应用。
我们将创建一个 Button 组件,当按下时,会检查用户设备上是否安装了 Facebook,如果安装了,则启动应用并告诉它打开通知页面。如果没有安装 Facebook,它将在设备的默认浏览器中打开 React Native 文档。
重要的是要注意,你可以将 Linking 作为任何你希望的回调的一部分来调用。它根本不需要与 Button 组件绑定!
这里是我们将与 Linking 一起使用的两种方法:
-
canOpenURL:这是一个接受 URL 作为参数的函数。它返回一个包含布尔值的承诺,表示提供的 URL 是否可以打开。这个 URL 可以是另一个应用的深链接,或者是一个用于打开网页的基于 Web 的 URL。 -
openURL:这是一个也接受 URL 作为参数的方法,并尝试使用已安装的应用打开它。如果用户的设备不知道如何打开传递的 URL,则此方法会失败,这就是为什么最好先使用canOpenURL检查是否可以打开它的原因。
在 iOS 设备上,我们需要稍微调整 Xcode 项目文件,以便允许Linking打开自定义 URL 方案。首先,我们需要将 React Native 的Linking二进制文件链接到我们的项目中。在 Xcode 中打开您的项目,然后在左侧导航器中展开Libraries文件夹,然后是其中的RCTLinking.xcodeproj,接着是那个Products文件夹:

返回AdditionalComponents的根项目文件,进入构建阶段,并将libRCTLinking.a文件拖到Link Binary With Libraries下:

之后,转到构建设置选项卡,并将一个条目添加到头文件搜索路径数组中,指向Linking库。对于这个特定的项目,路径是$(SRCROOT)/../node_modules/react-native/Linking。
您可以保持搜索非递归:

还有最后一步要执行。从 iOS 9 开始,我们必须在应用内注册访问 Facebook URL 方案的意图。
在侧边栏中打开Info.plist文件,并创建一个名为LSApplicationQueriesSchemes的数组条目。向此数组添加一个字符串,其值为您希望访问的 URL 方案。Facebook 的 URL 方案很简单,就是fb:

完成此操作后,您的应用应该已经链接到 iOS!
在 Android 上,不需要采取任何进一步的操作;您应该默认已经可以访问其他 URL 方案。
使用这些知识,让我们创建一个Button组件,该组件可以启动已安装的 Facebook 应用,如果没有安装,则链接到用户设备上的 React Native 文档。以下是我如何编写的:
// AdditionalComponents/app/index.js
...
import {
Button,
Linking,
...
} from 'react-native';
...
export default class App extends Component {
...
render () {
return (
<View style={ styles.container }>
...
<Button
color={ '#365899' }
onPress={ () => this._onButtonPress() }
title={ 'Open Link' }
/>
</View>
)
}
async _onButtonPress () {
const facebookURL = 'fb://notifications';
const canOpenLink = await Linking.canOpenURL(facebookURL);
if (canOpenLink) {
Linking.openURL(facebookURL);
return;
}
Linking.openURL('https://facebook.github.io/react-native');
}
...
}
在我们的render方法中的Button组件在按下时会触发_onButtonPress回调。它检查Linking API 以确定设备上是否可以打开 Facebook 应用,如果可以,则打开它。如果不能,它将在设备的默认浏览器中打开一个链接到 React Native 文档网站。
滑块
滑块是 Web 上常见的 UI 组件。在本节中,您将看到在 React Native 应用程序中创建一个滑块是多么容易。
虽然Slider至少有九个不同的属性,但我们只会使用以下属性:
-
maximumValue:这是一个设置Slider起始最大值的数字。它默认为1。 -
minimumValue:这是一个设置Slider起始最小值的数字。它默认为0。 -
onSlidingComplete:这是一个在用户完成与Slider交互时触发的回调。 -
onValueChange:这是一个在用户拖动Slider时连续触发的回调。 -
step:这是一个设置滑块步进值的数字。每次Slider的点击都会移动这个数字的步数。它默认为0,范围在0和最大值与最小值之间的差值之间。
花点时间创建一个Slider组件。给它任何你喜欢的最小和最大值,任何步数,并创建一些文本,显示当滑动条移动或交互停止时滑动条的当前值。这是我实现它的方法:
// AdditionalComponents/app/index.js
...
import {
...
Slider,
} from 'react-native';
...
export default class App extends Component {
...
constructor (props) {
...
this.state = {
...
sliderValue: undefined
}
}
...
render () {
return (
<View style={ styles.container }>
...
<Text style={ styles.sliderSelectionText } >
Your Slider Value is: { this.state.sliderValue }
</Text>
<Slider
maximumValue={ 100 }
minimumValue={ 0 }
onSlidingComplete={ (value) =>
this._onSliderValueChange(value) }
onValueChange={ (value) => this._onSliderValueChange(value) }
step={ 3 }
/>
</View>
)
}
这在页面上渲染了一个新的Slider组件,其值范围从0到100。当用户正在拖动滑动条或完成拖动操作时,它会改变滑动条的价值。每次滑动条的移动都会增加3的值。
这个回调处理在状态中设置sliderValue属性。sliderValue属性的代码如下:
...
_onSliderValueChange (sliderValue) {
this.setState({
sliderValue
});
}
}
// AdditionalComponents/app/styles.js
...
const styles = StyleSheet.create({
sliderSelectionText: {
alignSelf: 'center',
color: '#365899',
marginTop: 20
},
...
});
...
使用 ActionSheetIOS
ActionSheetIOS API 让我们能够显示一个动作表单或分享表单供用户交互。
动作表单是用户可以在应用中交互的选项覆盖层。
分享表单允许用户使用内置的分享系统几乎在任何地方分享任何内容。这可能意味着以短信、电子邮件或第三方应用的形式发送内容。
创建一个动作表单
ActionSheetIOS提供的两个方法是showActionSheetWithOptions和showShareActionSheetWithOptions。
第一个方法showActionSheetWithOptions接受两个参数:一个options对象和一个callback函数。
options对象必须包含以下属性之一。在这个例子中,我们使用了所有五个属性:
-
options:这是一个字符串数组,映射到覆盖层中出现的多个选项。 -
cancelButtonIndex:这是一个指向options数组中取消按钮(如果存在)位置的索引的数字。这会将取消按钮发送到覆盖层的底部。 -
destructiveButtonIndex:这是一个指向options数组中破坏性按钮(如果存在)位置的索引的数字。这会将破坏性选项的文本颜色变为红色。 -
title:这是一个显示在动作表单上方的字符串。 -
message:这是一个显示在标题下方的字符串。
showActionSheetWithOptions方法接受的回调将传递一个参数给它,这个参数是用户在交互动作表单时选择的选项索引。如果用户点击动作表单外部以隐藏它,这具有与选择取消按钮索引完全相同的效果:

尝试创建一个自己的动作表单。有一个某种交互来切换它,然后是当索引被选中时你可以想到的任何类型的交互。确保给你的动作表单一个title、message、一些options、一个cancel索引和一个destructive索引。
这是我想出来的代码。调用一个函数来渲染ActionSheetIOS和ShareSheetIOS组件。我在函数名中添加了对ShareSheetIOS的引用,以期待下一节:
// AdditionalComponents/app/index.js
...
import {
ActionSheetIOS,
...
} from 'react-native';
...
export default class App extends Component {
...
render () {
return (
<View style={ styles.container }>
...
{ this._renderActionAndShareSheets() }
</View>
)
}
将 ActionSheetIOS 组件选择的索引通知用户:
_onActionSheetOptionSelected (index) {
alert('The index you selected is: ' + index)
}
打开 ActionSheetIOS 并添加以下代码:
...
_openActionSheet () {
const options = ['One', 'Two', 'Three', 'Cancel', 'Destroy'];
ActionSheetIOS.showActionSheetWithOptions({
options: options,
cancelButtonIndex: 3,
destructiveButtonIndex: 4,
title: 'Action Sheet Options',
message: 'Please select from the following options'
}, (index) => this._onActionSheetOptionSelected(index))
}
如果用户在 Android 设备上,不要渲染任何内容,因为这些组件是 iOS 独有的:
_renderActionAndShareSheets () {
if (Platform.OS === 'android') {
return;
}
返回一个在按下时调用 _openActionSheet 的 Button:
return (
<View>
<Button
color={ '#365899' }
onPress={ () => this._openActionSheet() }
title={ 'Open Action Sheet' }
/>
</View>
)
}
}
使用 ShareSheetIOS 分享内容
另一方面,分享表单是一种不同类型的交互。正如我之前提到的,分享表单允许我们的应用与它共享内容。要打开它,我们调用 showShareActionSheetWithOptions 方法。它接受三个参数:一个 options 对象、一个 failureCallback 函数和一个 successCallback 函数。
分享表单的 options 对象与动作表单接受的 options 对象不同。它可以包含以下属性:
-
url:这是一个要分享的字符串化 URL。如果message属性不可用,则url属性是必需的。URL 可以指向本地文件或 base-64 编码的url;可以通过这种方式分享图片、视频、PDF 等其他类型的文件。 -
message:这是一个包含用户要分享的消息的字符串。如果url属性不可用,则message属性是必需的。 -
subject:这是一个包含消息主题的字符串。
当分享表单操作失败或被用户取消时,failureCallback 被触发,而当用户成功执行分享表单操作时,successCallback 被触发。

让我们修改索引文件以执行以下操作:
-
修改
_renderActionAndShareSheets以返回一个位于第一个按钮正下方的第二个按钮,当点击时将打开 ShareSheet -
创建一个名为
_openShareSheet的函数来处理打开该分享表单的操作
这就是我的版本是如何产生的。调用 ActionSheetIOS 的 showShareActionSheetWithOptions 方法,给它一个打开的链接和一个要分享的消息,以及一个 subject 和 error/success 回调:
// AdditionalComponents/app/index.js
...
export default class App extends Component {
...
_openShareSheet () {
ActionSheetIOS.showShareActionSheetWithOptions({
url: 'https://facebook.github.io/react-native',
message: 'Check out the React Native documentation here,
it's really helpful!',
subject: 'Link to React Native docs'
}, (error) => alert(error),
(success) => {
alert(success);
})
}
看看以下代码:
_renderActionAndShareSheets () {
...
return (
<View>
...
<Button
color={ '#365899' }
onPress={ () => this._openShareSheet() }
title={ 'Open Share Sheet' }
/>
</View>
)
}
}
获取用户地理位置数据
React Native 的 Geolocation API 是 Web Geolocation API 的扩展。它可以通过调用 navigator.geolocation 来使用,不需要导入。
位置数据是移动体验的重要组成部分,并且作为最佳实践,在用户提供这些信息有明确好处之前,不应从用户那里请求这些数据。
由于位置数据在用户同意与您分享之前是私密的,因此将此信息的共享视为用户与您的应用程序之间的信任纽带。
总是假设当用户被要求分享他们的位置时,他们会选择“否”,并制定一个策略来处理这种不可避免的拒绝。
在 iOS 上,如果您使用 React Native 创建了项目,地理位置将默认启用。如果没有,您需要前往您的 Info.plist 文件,并向其中添加 NSLocationWhenInUsageDescription 键。
在安卓设备上,您需要在AndroidManifest.xml文件中添加以下行:
<uses-permission android:name=
"android.permission.ACCESS_FINE_LOCATION" />
Geolocation API 有四个不同的方法。以下是我们将在示例中介绍的三种方法:
-
getCurrentPosition: 这是一个获取设备当前位置的函数。它最多接受三个参数:-
第一个,一个
success回调,是必须的,并带有当前位置信息调用。 -
第二个,一个
error回调,是可选的。 -
第三个是一个可选的选项数组,可以包含以下受支持选项:
timeout(以毫秒为单位),maximumAge(以毫秒为单位),以及enableHighAccuracy(一个布尔值)。
-
-
watchPosition: 这是一个监视设备位置并返回一个监视 ID 号的函数。它最多接受三个参数:-
第一个是必须的
success回调,每当位置发生变化时都会触发。 -
第二个是可选的回调,用于处理错误。
-
第三个是另一个可选的选项对象,可以包含与
getCurrentPosition选项对象相同类型的timeout、maximumAge和enableHighAccuracy属性,以及一个接受以米为单位的数字的distanceFilter属性。
-
-
clearWatch: 这是一个接受一个手表 ID 号并停止监视该位置的函数。
这里是Geolocation API 返回的当前位置对象的示例:
{
coords: {
accuracy: 5,
altitude: 0,
altitudeAccuracy: -1,
heading: -1,
latitude: 37.785834,
longitude: -122.406417,
speed: -1
},
timestamp: 1483251248689.033
}
使用Geolocation API,让我们修改索引文件,以便我们可以做以下事情:
-
在
componentDidMount生命周期中获取用户的位置 -
在屏幕上显示它们的纬度和经度
-
此外,创建一个按钮来监视设备的地理位置,并在按下时更新它:
-
然后,创建一个按钮来清除它
这里是我为这一节编写的代码。在componentDidMount生命周期中调用Geolocation API 的getCurrentPosition方法,并将位置保存到状态中:
// AdditionalComponents/app/index.js
...
export default class App extends Component {
...
async componentDidMount () {
...
navigator.geolocation.getCurrentPosition((location) => {
this.setState({
location
});
});
}
在Text组件中渲染用户的纬度和经度:
render () {
return (
<View style={ styles.container }>
...
<Text style={ styles.latLongText }>
Your Latitude is: { this.state.location ?
this.state.location.coords.latitude : 'undefiend' }
</Text>
<Text style={ styles.latLongText }>
Your Longitude is: { this.state.location ?
this.state.location.coords.longitude : 'undefined' }
</Text>
渲染一个按钮以启动监视过程,另一个按钮以停止监视过程:
<Button
color={ '#80B546' }
onPress={ () => this._onBeginWatchPositionButtonPress() }
title={ 'Start Watching Position' }
/>
<Button
color={ '#80B546' }
disabled={ this.state.watchID !== undefined ? false : true }
onPress={ () => this._onCancelWatchPositionButtonPress() }
title={ 'Cancel Watching Position' }
/>
</View>
)
}
当用户开始监视他们的位置时设置用户的位置:
...
_onBeginWatchPositionButtonPress () {
const watchID = navigator.geolocation.watchPosition((watchSuccess)
=> {
this.setState({
location: watchSuccess
});
});
将watchPosition调用的 ID 保存到状态中:
this.setState({
watchID
});
}
当用户按下取消监视位置按钮时调用clearWatch,并在状态中擦除watchID:
_onCancelWatchPositionButtonPress () {
navigator.geolocation.clearWatch(this.state.watchID);
this.setState({
watchID: undefined
});
}
...
}
干得好!你已经完成了本章的游乐场部分。在下一节中,我们将转换方向,重新审视第四章中的Expenses应用,使用 Expenses 应用的扩展功能。
安卓设备费用
在第四章,使用 Expenses 应用的扩展功能中,我们完成了 Expenses 应用(我们的预算跟踪应用)的 iOS 版本的建设。由于章节的页数,我认为将安卓部分放在本章的末尾会更好。
这一节立即从第四章,“使用 Expenses 应用的高级功能”的结尾继续。
在第四章,“使用 Expenses 应用的高级功能”的代码库中,仍可以找到 Expenses 的 Android 版本示例代码。
Android 修改
为了支持 Android,我们想要对我们的代码库做以下事情:
-
通过 Gradle 导入
react-native-vector-icons库 -
用基于 Android 的解决方案替换
TabBarIOS组件,使用DrawerLayoutAndroid和ToolbarAndroid组件创建一个包含可以滑动以在当前月份和上个月份的支出之间切换的抽屉的导航栏 -
用
Navigator替换任何NavigatorIOS实例 -
移除
ProgressViewIOS -
在
AddExpensesModal中移除ExpandableCell组件,并用DatePickerAndroid替换DatePickerIOS -
将任何需要 Android 特定文件的组件添加到其中
安装矢量图标库
对于 Android,您不需要做任何额外的事情来安装react-native-vector-icons库,因为 React Native 链接应该已经为您处理了整个过程。
然而,如果您决定手动为 iOS 链接库,react-native-vector-icons的 readme 文件有导入 Android 设备库的最新、最新的手动导入说明。由于这些说明可能会随着库的新版本而更改,我强烈建议您直接从README文件中遵循它们。
如果您已经在项目的node_modules文件夹中安装了该包,您可以直接从那里阅读说明,无需进一步的网络访问。
使用 Gradle 导入的说明非常直接。您还希望遵循用于集成getImageSource和ToolbarAndroid支持的库的说明。
ToolbarAndroid
在 Android 上,导航 UI 的首选方式是通过其顶部放置的工具栏。这与 iOS 体验不同,因为不是所有可用的标签都位于屏幕底部,而是导航隐藏在一个用户点击以展开的抽屉中。
ToolbarAndroid是一个 React Native,特定于 Android 的组件,它围绕 Android SDK 的本地工具栏小部件包装。像TabBarIOS一样,我们可以访问一个通过调用<Icon.ToolbarAndroid />渲染的react-native-vector-icons特定版本的组件。当我们在应用中使用Icon.ToolbarAndroid时,我们将使用以下属性:
-
title:这是一个显示在工具栏顶部的字符串,显示应用名称 -
titleColor:这设置了title字符串的颜色 -
navIconName:这是一个设置工具栏中导航菜单图标的字符串 -
height:这是一个设置工具栏高度的数字 -
backgroundColor:这设置了工具栏的背景颜色 -
onIconClicked: 这是一个当用户点击导航图标时执行的回调。
然而,你可能已经注意到,没有足够的空间来放置这个传统上由导航图标打开的实际 Navigation 抽屉。这是因为我们将使用 Icon.ToolbarAndroid 与 DrawerLayoutAndroid 结合使用,该组件负责处理实际的导航抽屉。
DrawerLayoutAndroid
这个组件通常用于导航。想象一下,这就是 TabBarIOS 中的标签页将可用的地方。
DrawerLayoutAndroid 组件可以访问负责其可见性的 openDrawer 和 closeDrawer 方法。要使用它,传递组件的引用并使用它来调用任一方法。
虽然这个组件有很多属性可用,但我们将只使用以下属性:
-
drawerLockMode: 这是三个字符串之一,用于确定抽屉是否响应触摸手势,例如滑动打开/关闭抽屉。这不会禁用工具栏的导航图标打开和关闭抽屉:-
unlocked: 抽屉响应触摸手势 -
locked-closed: 抽屉保持关闭状态,不响应触摸手势 -
locked-open: 抽屉保持打开状态,不响应触摸手势
-
-
ref: 这是一个引用字符串,用于传递给抽屉。这样我们就可以在其子组件中引用抽屉,这对于打开和关闭它是必要的。 -
renderNavigationView: 这是一个负责渲染你抽屉的函数。
连接 ToolbarAndroid 和 DrawerLayoutAndroid
我们连接这两个组件的方式是首先编写一个 Icon.ToolbarAndroid 组件:
<Icon.ToolbarAndroid
title={ 'Expense' }
titleColor={ '#7D878D' }
navIconName={ 'menu' }
height={ 56 }
backgroundColor={ '#4E92B5' }
onIconClicked={ () => this._openDrawer(); }
/>
然后,创建一个 DrawerLayoutAndroid 组件,并将其包裹在两个子组件中:你刚刚创建的 Icon.ToolbarAndroid 组件以及随后的 Navigator:
<DrawerLayoutAndroid
drawerLockMode={ 'unlocked' }
ref={ 'drawer' }
renderNavigationView={ () => this._renderDrawerLayout() }
>
// Insert Toolbar here
<Navigator
initialRoute={{ index: 0 }}
ref={ 'navigator' }
renderScene={ (routes, navigator) => this._renderScene(routes, navigator) }
/>
</DrawerLayoutAndroid>
当集成这三个组件时,我们想要确保以下始终为真:
-
DrawerLayoutAndroid总是位于其他所有内容之上,这样抽屉就不会被塞在Icon.ToolbarAndroid下面。 -
当使用
DrawerLayoutAndroid的选项在视图之间导航时,应该存在相同的Icon.ToolbarAndroid实例,这样我们就不需要在每次都渲染一个新的Icon.ToolbarAndroid组件,也不会包含一个工具栏从屏幕上 离开 并被 另一个 替换的动画。
我们在这里要做的是在 DrawerLayoutAndroid 内嵌套 Icon.ToolbarAndroid 和 Navigator,并为 Navigator 设置一个 ref,这样我们就可以使用该 Navigator 在根文件中根据需要推送新的场景。
一旦实现了 Icon.ToolbarAndroid 和 DrawerLayoutAndroid 组件,你将能够拥有如下所示的内置导航:

下面是这个大图的样子:
// Expenses/index.android.js
import React, { Component } from 'react';
import {
AppRegistry,
DrawerLayoutAndroid,
Navigator,
StyleSheet,
View
} from 'react-native';
import App from './app/App';
import CurrentMonthExpenses from './app/components/CurrentMonthExpenses';
import Drawer from './app/components/Drawer';
import EnterBudget from './app/components/EnterBudget';
import PreviousMonthsList from './app/components/PreviousMonthsList';
这些是这个索引文件将与之工作的四个组件。App、EnterBudget和PreviousMonthsList组件将是我们导航路由的一部分。Drawer组件用于渲染DrawerLayoutAndroid的导航视图。
由于 Android 软件的设计语言与 iOS 应用不同,我选择了导入MaterialIcons包而不是FontAwesome,因为它是根据谷歌的 Material Design 指南构建的:
import Icon from 'react-native-vector-icons/MaterialIcons';
设置了一个名为expenses的属性为未定义。这是为了传递给PreviousMonthsList,因为它期望作为属性传递的支出列表。
可见路由传递给Drawer组件。我故意省略了处理输入月度预算的路由,因为这个路由用户不应该能够手动导航到。
在WillMount组件生命周期中,我调用_updateExpenses方法来设置状态中的expenses键为支出对象。查看以下代码以了解这里给出的解释:
class Expense extends Component {
constructor (props) {
super (props);
this.state = {
expenses: undefined,
visibleRoutes: [
{ title: 'This Month', index: 0 },
{ title: 'Past Months', index: 2 }
]
}
}
componentWillMount () {
this._updateExpenses();
}
render() {
const routes = [
{ title: 'Expense', index: 0 },
{ title: 'Enter Your Budget', index: 1 },
{ title: 'Previous Month List', index: 2 },
{ title: 'Past Expenses', index: 3}
];
return (
<View style={ styles.container }>
<DrawerLayoutAndroid
drawerLockMode={ 'unlocked' }
ref={ 'drawer' }
renderNavigationView={ () => this._renderDrawerLayout()
}
>
在DrawerLayoutAndroid组件内部,我嵌套了Icon.ToolbarAndroid和Navigator。正如我之前提到的,这是为了将抽屉物理地放置在应用程序其他部分的上方。查看以下代码:
<Icon.ToolbarAndroid
titleColor="white"
navIconName="menu"
height={ 56 }
backgroundColor="blue"
onIconClicked={ () => this._openDrawer() }
/>
<Navigator
initialRoute={{ index: 0 }}
ref={ 'navigator' }
renderScene={ (routes, navigator) =>
this._renderScene(routes, navigator) }
/>
</DrawerLayoutAndroid>
</View>
);
}
_openDrawer () {
this.refs['drawer'].openDrawer();
}
由于之前给DrawerLayoutAndroid分配了一个drawer引用,我可以在用户点击导航图标时使用它来打开抽屉。我还给Navigator分配了一个navigator引用,这样我就可以在根index.android.js级别上推送它。
DrawerLayoutAndroid组件的render方法返回我从我编写的自定义组件导入的这个Drawer组件。我通过navigateTo属性名传递一个回调函数,该函数推送navigator的索引并关闭DrawerLayoutAndroid:
_navigateTo (index) {
this.refs['navigator'].push({
index: index
});
this.refs['drawer'].closeDrawer();
}
_renderDrawerLayout () {
return (
<Drawer
navigateTo={ (index) => this._navigateTo(index) }
routes={ this.state.visibleRoutes }
/>
)
}
使用_renderScene配置的PreviousMonthsList的渲染将传入expenses对象和updateExpenses函数。
_renderScene (route, navigator) {
if (route.index === 0) {
return (
<App
title={ route.title }
navigator={ navigator }
/>
)
}
if (route.index === 1) {
return (
<EnterBudget
title={ route.title }
navigator={ navigator }
{ ...route.passProps }
/>
)
}
if (route.index === 2) {
return (
<PreviousMonthsList
title={ route.title }
navigator={ navigator }
expenses={ this.state.expenses }
updateExpenses={ () => this._updateExpenses() }
/>
)
}
if (route.index === 3) {
return (
<CurrentMonthExpenses
title={ route.title }
navigator={ navigator }
{ ...route.passProps }
/>
)
}
}
传递给PreviousMonthsList作为名为updateExpenses的属性的_updateExpenses函数是App组件中找到的_updateExpenses函数的修改版本。我们只关心这个组件中的expenses对象,所以我们不会设置任何其他数据:
async _updateExpenses () {
let response = await storageMethods.getAsyncStorage();
if (response) {
this.setState({
expenses: response
});
}
}
}
const styles = StyleSheet.create({
container: {
flex: 1
}
})
AppRegistry.registerComponent('Expense', () => Expense);
然后,我构建了Drawer组件:
// Expenses/app/components/Drawer/index.android.js
import React, { Component, PropTypes } from 'react';
import {
ListView,
Text,
TouchableHighlight,
View
} from 'react-native';
import DrawerRow from '../DrawerRow';
import styles from './styles';
Drawer导入的DrawerRow组件负责为Drawer的ListView组件渲染单个数据行。
export default class Drawer extends Component {
static propTypes = {
navigateTo: PropTypes.func.isRequired,
routes: PropTypes.array.isRequired
}
constructor (props) {
super (props);
this.state = {
ds: new ListView.DataSource({
rowHasChanged: (r1, r2) => r1 !== r2
})
}
}
render () {
const dataSource = this.state.ds.cloneWithRows(this.props.routes);
return (
<View style={ styles.container }>
<ListView
automaticallyAdjustContentInsets={ false }
dataSource={ dataSource }
enableEmptySections={ true }
renderRow={ (rowData, sectionID, rowID) =>
this._renderDrawerRow(rowData, sectionID, rowID) }
renderSeparator={ (sectionID, rowID) =>
<View
key={ rowID }
style={ styles.separator }
/>
}
/>
</View>
)
}
Drawer的render方法返回一个简单的ListView,它使用传递给它的作为属性的 routes 数组来生成每一行数据。
_renderDrawerRow (rowData, sectionID, rowID) {
return (
<View>
<TouchableHighlight
style={ styles.row }
onPress={ () => this.props.navigateTo(rowData.index) }
>
<DrawerRow routeName={ rowData.title } />
</TouchableHighlight>
</View>
)
}
}
然后,我为这个组件创建了一个基本的StyleSheet:
// Expenses/App/components/Drawer/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1
},
separator: {
height: StyleSheet.hairlineWidth,
marginLeft: 10,
marginRight: 10,
backgroundColor: '#E5F2FD'
}
});
export default styles;
之后,我编写了DrawerRow组件:
// Expenses/app/components/DrawerRow/index.android.js
import React, { Component } from 'react';
import {
Text,
View
} from 'react-native';
import styles from './styles';
export default class DrawerRow extends Component {
setNativeProps (props) {
this._root.setNativeProps(props)
}
由于DrawerRow是一个自定义组件,并且包裹在Drawer组件的_renderDrawerRow方法中的TouchableHighlight组件没有自动调用setNativeProps为用户创建的组件,所以我手动调用了它:
render () {
return (
<View
style={ styles.container }
ref={ component => this._root = component }
{ ...this.props }
>
<Text style={ styles.rowTitle }>
{ this.props.routeName }
</Text>
</View>
)
}
}
DrawerRow组件也有自己的StyleSheet:
// Expenses/app/components/DrawerRow/styles.js
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
container: {
flex: 1,
height: 40,
padding: 10
},
rowTitle: {
fontSize: 20,
textAlign: 'left'
}
});
export default styles;
Android 特定的应用组件
接下来,我创建了一个针对 Android 的 App.js 的特定版本,将原始的 App.ios.js 重命名。App.android.js 中只导入了两个组件,因为任何要导航到的组件都在根 index.android.js 文件中处理:
// Expenses/app/App.android.js
import React, { Component } from 'react';
import {
Text,
View
} from 'react-native';
import styles from './styles';
import * as dateMethods from './utils/dateMethods';
import * as storageMethods from './utils/storageMethods';
import AddExpenses from './components/AddExpenses';
import CurrentMonthExpenses from './components/CurrentMonthExpenses';
App 组件的状态不需要 selectedTab 属性,因为我们没有在 Android 上使用标签导航:
export default class App extends Component {
constructor (props) {
super();
this.state = {
budget: '',
expenses: {},
}
}
与其 iOS 对应版本相比,componentWillMount 没有发生变化:
componentWillMount () {
...
}
由于移除了标签导航,该组件的 render 方法已简化为仅渲染 CurrentMonthExpenses 和 AddExpenses 组件:
render () {
return (
<View style={ styles.androidContainer }>
{ this._renderCurrentMonthExpenses() }
</View>
)
}
与 componentWillMount 一样,_renderCurrentMonthExpenses 保留了 App 组件 iOS 版本的完全相同的逻辑:
_renderCurrentMonthExpenses () {
return (
<View style={ styles.androidContainer }>
<CurrentMonthExpenses
...
/>
<AddExpenses
...
/>
</View>
)
}
_renderEditBudgetComponent 方法已经更改,以考虑 Navigator 如何与 push 方法不同,正如以下代码中提到的 NavigatorIOS 的 push 方法:
_renderEnterBudgetComponent () {
this.props.navigator.push({
index: 1,
passProps: {
monthString: dateMethods.getMonthString(this.state.month),
saveAndUpdateBudget: (budget) => this._saveAndUpdateBudget(budget),
updateExpenses: () => this._updateExpenses()
}
});
}
虽然 iOS 应用组件的 _renderPreviousMonthsList 方法在 Android 上已被删除,但 _saveAndUpdateBudget、_updateBudget 和 _updateExpenses 的逻辑保持不变,正如以下代码中提到的:
async _saveAndUpdateBudget (budget) {
...
}
async _updateBudget () {
...
}
async _updateExpenses () {
...
}
}
最后,我为 App 组件添加了一个简单的 flex 容器样式:
// Expenses/app/styles.js
...
const styles = StyleSheet.create({
androidContainer: {
flex: 1
},
...
});
export default styles;
EnterBudget 样式更改
由于 Android 文本字段通常不包含边框,我使用了一些条件逻辑与 Platform API 来移除它:
// Expenses/app/components/EnterBudget/index.js
...
import {
Platform,
...
} from 'react-native';
...
export default class EnterBudget extends Component {
...
render () {
...
return (
<View style={ styles.enterBudgetContainer }>
...
<TextInput
style={ Platform.OS === 'ios' ? styles.textInput :
styles.androidTextInput }
...
/>
...
</View>
)
}
...
}
在 EnterBudget 的 render 方法中,TextInput 组件的样式现在检查用户的操作系统是否为 iOS 或 Android。如果是 iOS,则保留之前的原始 textInput 样式;如果是 Android,则将其设置为新的 androidTextInput 样式。
// Expenses/app/components/EnterBudget/styles.js
...
const styles = StyleSheet.create({
androidTextInput: {
color: '#3D4A53',
margin: 10,
padding: 10,
textAlign: 'center'
},
...
});
export default styles;
接下来,我们将对 CurrentMonthExpenses 进行一些更改。
Android 的 CurrentMonthExpenses
在 CurrentMonthExpenses 组件中,我渲染了一个 ProgressViewIOS 组件,该组件通过一个彩色水平条以视觉方式跟踪用户花费的金额。
Android 的 React Native SDK 有一个类似的组件 ProgressBarAndroid,我们将用 ProgressViewIOS 交换它。
此外,我们还想为这个组件添加一个返回按钮事件监听器,该监听器仅在用户通过 PreviousMonthsList 使用 BackAndroid 导航到 CurrentMonthExpenses 时触发。
ProgressBarAndroid 组件与 ProgressViewIOS 类似。我在我的组件中使用了以下属性:
-
color:这决定了ProgressBarAndroid的颜色。 -
indeterminate:这是一个布尔值,当设置为 true 时,将无限期地保持进度条动画。 -
progress:就像ProgressViewIOS一样,这决定了进度条应该移动多远。 -
styleAttr:这是一个字符串,用于告诉ProgressBarAndroid应该如何渲染。对于我来说,我使用了horizontal。
CurrentMonthExpenses 的 render 方法现在调用 _renderProgressIndicator 方法来确定要渲染哪个进度指示器:对于 iOS 设备,将渲染 ProgressViewIOS,而对于 Android 设备,则渲染 ProgressBarAndroid。
现在 ListView 被包裹在一个 View 中,以便与进度指示器保持一定的距离:
// Expenses/app/components/CurrentMonthExpenses/index.js
...
import {
...
BackAndroid,
Platform,
...
} from 'react-native';
...
export default class CurrentMonthExpenses extends Component {
...
componentWillMount () {
BackAndroid.addEventListener('hardwareButtonPress', () =>
this._backButtonPress());
}
componentWillUnmount () {
BackAndroid.removeEventListener('hardwareButtonPress', () =>
this._backButtonPress())
}
render () {
...
return (
<View style={ ... }>
<View style={ styles.currentMonthExpensesHeader }>
...
{ this._renderProgressIndicator() }
</View>
<View style={ styles.listViewContainer }>
<ListView
...
/>
</View>
</View>
)
}
只有当用户查看的是前一个月的数据时,我才会调用 navigator 上的 pop。否则,在尝试调用 pop 而没有访问其他路由时,他们将会遇到错误:
_backButtonPress () {
if (this.props.isPreviousMonth) {
this.props.navigator.pop();
return true;
}
}
iOS 进度指示器逻辑没有发生变化:
...
_renderProgressIndicator () {
if (Platform.OS === 'ios') {
return (
<ProgressViewIOS
progress={ this._getProgressViewAmount() }
progressTintColor={ '#A3E75A' }
style={ styles.progressView }
/>
)
}
Android 进度指示器在函数末尾返回。我在这里重用了 _getProgressViewAmount 方法:
return (
<View style={ styles.progressView }>
<ProgressBarAndroid
color={ '#A3E75A' }
indeterminate={ false }
progress={ this._getProgressViewAmount() }
styleAttr={ 'Horizontal' }
/>
</View>
)
}
...
};
此组件没有其他代码更改,并且由于其更改量最小,因此不需要 Android 特定版本。
CurrentMonthExpenses 的样式已更改,以添加 listViewContainer 属性:
// Expenses/app/components/CurrentMonthExpenses/styles.js
...
const styles = StyleSheet.create({
...
listViewContainer: {
flex: 1,
marginTop: 20
},
...
});
export default styles;
从 AddExpensesModal 中移除 ExpandableCell:
由于 DatePickerAndroid 和 Picker 组件在 Android 设备上渲染为模态,我对 AddExpensesModal 组件进行了修改,以移除 ExpandableCell 的实例:
这导致了由于大量更改而产生了一个新的 Android 特定文件。我在状态中移除了 categoryPickerExpanded 和 datePickerExpanded 属性,以及 DatePickerIOS 和 ExpandableCell 的导入:
// .../app/components/AddExpensesModal/index.android.js
...
import {
DatePickerAndroid,
...
} from 'react-native';
...
export default class AddExpensesModal extends Component {
...
constructor (props) {
super (props);
this.state = {
amount: '',
category: undefined,
date: new Date(),
description: '',
}
}
这两个常量之前以 expandableCell 为前缀,但由于 ExpandableCell 组件在此组件的 Android 版本中不再使用,因此已移除该前缀:
render () {
const datePickerButtonTitle = ...
const categoryPickerButtonTitle = ...
onRequestClose 回调是 Android 上 Modal 组件的必需属性。当用户在模态打开时按下 Android 设备上的返回按钮时,将执行此回调。在这种情况下,我执行的操作与用户按下取消按钮时的操作相同:
return (
<Modal
...
onRequestClose={ () => this._clearFieldsAndCloseModal() }
>
为适应 Android 和 iOS 应用之间的样式差异,为两个 TextInput 组件创建了 Android 特定的样式:
<ScrollView style={ styles.modalContainer }>
<View style={ styles.amountRow }>
...
<TextInput
...
style={ styles.androidAmountInput }
/>
</View>
<Text style={ styles.descriptionText }>
...
</Text>
之前被样式设置为 expandableCellContainer 的 View 已更改为 androidPickerContainers。前面的 Button 组件在按下时会调用 _renderDatePicker,该函数处理 DatePickerAndroid 的渲染:
<TextInput
...
style={ styles.androidDescriptionInput }
/>
<View style={ styles.androidPickerContainers }>
<Button
color={ '#86B2CA' }
onPress={ () => this._renderDatePicker() }
title={ datePickerButtonTitle }
/>
</View>
由于常规的 Picker 组件不是通过异步函数如 DatePickerAndroid 打开的,我保留了其逻辑不变:
<View style={ styles.androidPickerContainers }>
<View style={ styles.categoryIcon }>
{ this.state.category &&
iconMethods.getIconComponent(this.state.category) }
</View>
<Picker
...
prompt={ categoryPickerButtonTitle }
>
{ this._renderCategoryPicker() }
</Picker>
</View>
...
</ScrollView>
</Modal>
)
}
_clearFieldsAndCloseModal 方法被修改,以移除在状态中设置现在已删除的 ExpandableCell 特定属性:
...
_clearFieldsAndCloseModal () {
this.setState({
amount: '',
category: undefined,
date: new Date(),
description: ''
});
this.props.toggleModal()
}
...
最后,创建了 _renderDatePicker 来处理 DatePickerAndroid 组件的异步特性。
在 AddExpensesModal 组件的 Android 版本中,没有移除 iOS 版本的任何方法:
async _renderDatePicker () {
const options = {
date: this.state.date
};
const { action, year, month, day } = await
DatePickerAndroid.open(options);
if (action === DatePickerAndroid.dismissedAction) {
return;
}
this.setState({
day,
month,
year
});
this._onDateChange();
}
...
}
修改 PreviousMonthsList 的导航:
下一步是修改 Android 的 navigator 的 push 方法:
// Expenses/app/components/PreviousMonthsList/index.js
...
import {
Platform,
...
} from 'react-native';
...
export default class PreviousMonthsList extends Component {
...
render () {
<View style={ Platform.OS === 'ios' ? styles.previousMonthsListContainer : {} }>
...
</View>
}
...
我对围绕 PreviousMonthsList 的 View 容器进行了样式调整,以保持 ListView 在 Android 设备上紧挨着导航栏。
如果在 iOS 上,原始渲染方法没有发生变化:
_renderSelectedMonth (rowData, sectionID, rowID) {
if (Platform.OS === 'ios') {
...
}
Android 方法将内容推送到index而不是组件,传递给它的属性完全相同:
if (Platform.OS === 'android') {
this.props.navigator.push({
index: 3,
passProps: {
...
}
});
}
}
}
在本节之后,我们已经成功地将Expenses转换为了一个看起来、感觉和表现都像 Android 应用程序的 Android 应用程序!
摘要
恭喜!你已经完成了《React Native by Example》的最后一章。在本章中,你通过在组件和 API 上工作,学习了更多 React Native 库的使用,这些组件和 API 我们没有在书中的前几个应用程序中介绍。
具体来说,你构建了一个游乐场应用程序,在那里你学习了如何使用fetch API 对外部资源进行数据请求,并掌握了控制用户设备振动马达的Vibration API。
之后,你使用了Linking API 在 iOS 和 Android 上打开第三方应用程序,这使得你的应用程序能够与其他应用程序通信。然后,你构建了一个Slider组件,允许用户在两个预定的值之间选择一个值。
完成游乐场应用程序后,你创建了Buttons来打开一个ActionSheetIOS覆盖层,向用户提供交互选项,并允许用户通过共享表单从你的应用程序中分享内容。作为最后的润色,你使用了Geolocation API 来获取用户的位置数据。
在最后一节中,我们将应用程序转换为 Android 版本。我们首先确保通过 Gradle 导入矢量图标库,用 Android 的抽屉和工具栏组合替换了 iOS 特定的标签导航,用Navigator替换了NavigatorIOS特定的逻辑,用 Android 特定的进度指示器替换了进度指示器,然后调整了其余组件以具有 Android 应用程序的外观和感觉。


浙公网安备 33010602011771号