ReactNative-跨平台应用开发-全-
ReactNative 跨平台应用开发(全)
原文:
zh.annas-archive.org/md5/6A2675D80E0FE70F7D8BA886F2160D60
译者:飞龙
前言
React Native 帮助 Web 和移动开发人员构建性能与任何其他原生开发应用程序相同的跨平台应用程序。使用这个库可以构建的应用程序范围很广。从电子商务到游戏,由于其灵活性和可扩展性,React Native 非常适合任何移动项目。毫无疑问,React Native 不仅是原生开发的一个很好的替代方案,而且也是将 Web 开发人员引入移动项目的一个很好的方式。
对我有什么好处?
地图对于您的旅程至关重要,特别是当您在另一个大陆度假时。在学习时,路线图可以帮助您确定前进目标的明确路径。因此,在开始旅程之前,我们为您提供了一份路线图。
本书经过精心设计和开发,旨在为您提供有关 React Native 的所有正确和相关信息。我们为您创建了这个学习路径,其中包括四堂课。
第 1 课,项目 1 - 汽车预订应用程序,解释了如何使用 React Native 开发一些最受欢迎的共享汽车应用程序。
第 2 课,项目 2 - 图像分享应用,教你如何使用 React Native 创建基于图像分享的社交网络的基础知识。
第 3 课,项目 3 - 消息应用程序,向您展示如何构建一个包括推送通知和基于云的存储的功能齐全的消息应用程序。
第 4 课,项目 4 - 游戏,向您展示了如何使用 React Native 开发 2D 游戏的基础知识。
我将从这本书中得到什么?
-
构建 React Native 项目以便于维护和扩展
-
优化项目以加快开发速度
-
使用外部模块加快项目的开发和维护
-
探索用于 iOS 和 Android 的不同 UI 和代码模式
-
了解在 React Native 中构建应用程序时的最佳实践
先决条件
本书适用于希望使用 React Native 构建令人惊叹的跨平台应用程序的开发人员。在开始阅读本书之前,需要具备以下一些先决条件:
-
需要基本的 HTML、CSS 和 JavaScript 知识
-
假设有 React 的先前工作知识
第一章:项目 1 - 汽车预订应用程序
考虑到 React 框架的成功,Facebook 最近推出了一个名为 React Native 的新移动开发框架。通过 React Native 对混合移动开发的颠覆性方法,您可以使用 JavaScript 构建更强大、更交互式、更快速的本机移动应用程序。
在本课程中,我们将把重点放在功能开发上,而不是构建用户界面,通过将我们应用程序的样式委托给原生库,如 native-base,并花更多时间构建自定义 UI 组件和屏幕。
我们要构建的应用是一个汽车预订应用程序,用户可以选择想要被接送的位置以及想要预订的车辆类型。由于我们想要专注于用户界面,我们的应用程序只会有两个屏幕,并且需要一些状态管理。相反,我们将更深入地研究诸如动画、组件布局、使用自定义字体或显示外部图像等方面。
该应用程序将适用于 iOS 和 Android 设备,由于所有用户界面都将是定制的,因此在两个平台之间将重复使用 100%的代码。我们将只使用两个外部库:
-
React-native-geocoder
:这将把坐标转换成人类可读的位置 -
React-native-maps
:这将轻松显示地图和显示可预订汽车位置的标记
由于其性质,大多数汽车预订应用程序将其复杂性放在后端代码中,以有效地连接司机和乘客。我们将跳过这种复杂性,并在应用程序中模拟所有这些功能,以便专注于构建美观和可用的界面。
概览
在构建移动应用程序时,我们需要确保将界面复杂性降至最低,因为在打开应用程序后强制向用户呈现侵入式手册或工具提示通常是不利的。让我们的应用程序自解释是一个好习惯,这样用户就可以通过浏览应用程序屏幕来理解使用方法。这就是为什么使用标准组件,如抽屉菜单或标准列表,总是一个好主意,但并非总是可能的(就像我们当前的应用程序一样),因为我们想要向用户呈现的数据类型。
在我们的情况下,我们将所有功能放在主屏幕和一个模态框中。让我们看看 iOS 设备上的应用程序将会是什么样子:
我们主屏幕的背景是地图组件本身,在地图中我们将显示所有可用的汽车作为标记。在地图上,我们将显示三个组件:
-
显示所选取的取货地点的取货地点框
-
位置图钉,可以在地图上拖动以选择新位置
-
用户想要预订的汽车类型选择器。我们将显示三个选项:
经济
,特别
和高级
由于大多数组件都是自定义构建的,这个屏幕在任何 Android 设备上看起来都会非常相似:
iOS 版本和 Android 版本之间的主要区别将是地图组件。虽然 iOS 将默认使用 Apple 地图,但 Android 使用 Google 地图。我们将保留这个设置,因为每个平台都有自己优化的地图组件,但知道我们可以通过配置我们的组件来切换 iOS 版本以使用 Google 地图是很好的。
一旦用户选择了取货地点,我们将显示一个模态框来确认预订并联系最近的司机取货:
与主屏幕一样,这个屏幕也使用了自定义组件:我们甚至决定创建自己的动画活动指示器。因此,Android 版本看起来会非常相似:
由于我们的应用程序不会连接到任何外部 API,它应该被视为 React Native 的视觉能力的纯粹展示,尽管通过添加状态管理库和匹配的 API 可以很容易地进行扩展。
让我们来看看本课程的主题:
-
在我们的应用程序中使用地图
-
React Native 中的样式表
-
React Native 中的 Flexbox
-
在 React Native 应用程序中使用外部图像
-
添加自定义字体
-
React Native 中的动画
-
使用模态框
-
使用阴影和不透明度
设置文件夹结构
让我们使用 React Native 的 CLI 初始化一个 React Native 项目。该项目将被命名为 carBooking
,并将可用于 iOS 和 Android 设备:
react-native init --version="0.49.3" carBooking
在这个应用程序中,只有一个屏幕,因此代码的文件夹结构应该非常简单。由于我们将使用外部图像和字体,我们将这些资源组织在两个单独的文件夹中:img
和 fonts
,都在根文件夹下。
用于构建此应用程序的图像和字体可以从一些图像和字体库网站免费下载。我们将使用的字体名称是 Blair ITC。
我们还将以下图像存储在 img
文件夹中:
-
car.png
:一辆汽车的简单图画,代表地图上可预订的汽车。 -
class.png
:汽车的轮廓,显示在类别选择按钮内部 -
classBar.png
:类别选择按钮将滑动以更改类别的条形。 -
loading.png
:我们自定义的旋转器。它将被存储为静态图像,并通过代码进行动画处理。
最后,让我们来看看我们的package.json
文件:
{
"name": "carBooking",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "node node_modules/react-native/local-cli/cli.js start",
"test": "jest"
},
"dependencies": {
"react": "16.0.0-beta.5",
"react-native": "0.49.3",
"react-native-geocoder": " 0.4.8",
"react-native-maps": " 0.15.2"
},
"devDependencies": {
"babel-jest": "20.0.3",
"babel-preset-react-native": "1.9.2",
"jest": "20.0.4",
"react-test-renderer": "16.0.0-alpha.6"
},
"jest": {
"preset": "react-native"
},
"rnpm": {
"assets": ["./fonts"]
}
}
我们只使用两个 npm 模块:
-
react-native-geocoder
:这将坐标转换为人类可读的位置 -
react-native-maps
:这很容易显示地图和显示可预订汽车位置的标记
为了允许应用程序使用自定义字体,我们需要确保它们可以从本地端访问。为此,我们需要向package.json
添加一个名为rnpm
的新键。这个键将存储一个assets
数组,在其中我们将定义我们的fonts
文件夹。在构建时,React Native 将把字体复制到一个位置,从那里它们将在本地可用,因此可以在我们的代码中使用。这仅适用于字体和一些特殊资源,而不适用于图像。
由 React Native 的 CLI 创建的文件和文件夹
让我们利用这个应用程序中简单的文件夹结构的机会,展示通过react-native init <projectName>
初始化项目时,React Native 的 CLI 创建了哪些其他文件和文件夹。
tests/
React Native 的 CLI 包括 Jest 作为开发人员依赖项,并且为了开始测试,它包括一个名为__tests__
的文件夹,其中可以存储所有测试。默认情况下,React Native 的 CLI 添加一个测试文件:index.js
,代表初始一组测试。开发人员可以为应用程序中的任何组件添加后续测试。React Native 还在我们的package.json
中添加了一个test
脚本,因此我们可以从一开始就运行npm run test
。
Jest 已准备好与通过 CLI 初始化的每个项目一起使用,当涉及到测试 React 组件时,它绝对是最简单的选择,尽管也可以使用其他库,如 Jasmine 或 Mocha。
android/ 和 ios/
这两个文件夹分别为两个平台本地构建的应用程序。这意味着我们可以在这里找到我们的.xcodeproj
和java
文件。每当我们需要对应用程序的本地代码进行更改时,我们都需要修改这两个目录中的一些文件。
在这些文件夹中查找和修改文件的最常见原因是:
-
通过更改
Info.plist
(iOS)或AndroidManifest.xml
(Android)修改权限(推送通知,访问位置服务,访问指南针等)。 -
更改任何平台的构建设置
-
为原生库添加 API 密钥
-
添加或修改要从我们的 React Native 代码中使用的原生库
node_modules/
这个文件夹对大多数使用npm
的 JavaScript 开发人员来说应该很熟悉,因为它是npm
存储在我们项目中标记为依赖项的所有模块的地方。在这个文件夹内修改任何内容的必要性并不常见,因为一切都应该通过 npm 的 CLI 和我们的package.json
文件来处理。
根文件夹中的文件
React Native 的 CLI 在项目的根目录中创建了许多文件;让我们来看看最重要的文件:
-
.babelrc
:Babel 是 React Native 中用于编译包含 JSX 和 ES6(例如,语法转换为大多数 JavaScript 引擎能够理解的纯 JavaScript)的默认库。在这里,我们可以修改这个编译器的配置,例如,可以使用@
语法作为装饰器,就像在 React 的早期版本中所做的那样。 -
.buckconfig
:Buck 是 Facebook 使用的构建系统。这个文件用于配置使用 Buck 时的构建过程。 -
.watchmanconfig
:Watchman 是一个监视项目中文件的服务,以便在文件发生更改时触发重新构建。在这个文件中,我们可以添加一些配置选项,比如应该被忽略的目录。 -
app.json
:这个文件被react-native eject
命令用来配置原生应用程序。它存储了在每个平台上标识应用程序的名称,以及在设备的主屏幕上安装应用程序时将显示的名称。 -
yarn.lock
:package.json
文件描述了原始作者期望的版本,而yarn.lock
描述了给定应用程序的最后已知良好配置。
react-native link
一些应用程序依赖具有原生功能的库,在 React Native CLI 之前,开发人员需要将原生库文件复制到原生项目中。这是一个繁琐和重复的项目,直到react-native link
出现才得以解决。在这节课中,我们将使用它来从react-native-maps
复制库文件,并将自定义字体从我们的/fonts
文件夹链接到编译后的应用程序。
通过在项目的根文件夹中运行react-native link
,我们将触发链接步骤,这将使那些原生能力和资源可以从我们的 React Native 代码中访问。
在模拟器中运行应用程序
在package.json
文件中具有依赖项和所有初始文件就位后,我们可以运行以下命令(在项目的根文件夹中)完成安装:
**npm install**
然后,所有依赖项都应该安装在我们的项目中。一旦 npm 完成安装所有依赖项,我们就可以在 iOS 模拟器中启动我们的应用程序:
**react-native run-ios**
或者在 Android 模拟器中使用以下命令:
**react-native run-android**
当 React Native 检测到应用程序在模拟器中运行时,它会启用一个隐藏菜单中可用的开发人员工具集,可以通过快捷键command + D(在 iOS 上)或command + M(在 Android 上)来访问(在 Windows 上应使用Ctrl而不是command)。这是 iOS 中开发人员菜单的外观:
在模拟器中运行应用程序
这是在 Android 模拟器中的外观:
开发人员菜单
在构建 React Native 应用程序的过程中,开发人员将有调试需求。React Native 通过在 Chrome 开发人员工具或外部应用程序(如 React Native Debugger)中远程调试我们的应用程序来满足这些需求。错误、日志甚至 React 组件都可以像在普通 Web 环境中一样轻松调试。
除此之外,React Native 还提供了一种在每次更改时自动重新加载我们的应用程序的方式,从而节省了开发人员手动重新加载应用程序的任务(可以通过按command + R或Ctrl + R来实现)。当我们设置我们的应用程序进行自动重新加载时,有两个选项。
-
实时重新加载检测到我们在应用程序代码中所做的任何更改,并在重新加载后将应用程序重置为其初始状态。
-
热重新加载还会检测更改并重新加载应用程序,但会保持应用程序的当前状态。当我们正在实现用户流程以节省开发人员重复每个步骤(例如登录或注册测试用户)时,这非常有用。
-
最后,我们可以启动性能监视器来检测在执行复杂操作(例如动画或数学计算)时可能出现的性能问题。
创建我们应用程序的入口点
让我们通过创建我们应用的入口点 index.js
来开始我们的应用代码。我们在这个文件中导入 src/main.js
,以便为我们的代码库使用一个共同的根组件。此外,我们将使用名称 carBooking
注册应用程序:
/*** index.js ***/
import { AppRegistry } from 'react-native';
import App from './src/main';
AppRegistry.registerComponent('carBooking', () => App);
让我们通过添加地图组件来开始构建我们的 src/main.js
:
/*** src/main.js ** */
import React from 'react';
import { View, StyleSheet } from 'react-native';
import MapView from 'react-native-maps';
export default class Main extends React.Component {
constructor(props) {
super(props);
this.initialRegion = {
latitude: 37.78825,
longitude: -122.4324,
latitudeDelta: 0.00922,
longitudeDelta: 0.00421,
};
}
render() {
return (
<View style={{ flex: 1 }}>
<MapView
style={styles.fullScreenMap}
initialRegion={this.initialRegion}
/>
</View>
);
}
}
const styles = StyleSheet.create({
fullScreenMap: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
},
});
我们将使用 StyleSheet
来创建自己的样式,而不是使用样式库,StyleSheet
是一个 React Native API,类似于 CSS 样式表的抽象。使用 StyleSheet
,我们可以通过对象(通过 create
方法)创建样式表,然后在组件中通过引用每个样式的 ID 来使用它们。
这样,我们可以重用样式代码,并通过使用有意义的名称来引用每个样式(例如,<Text style={styles.title}>Title 1</Text>
)使代码更易读。
在这一点上,我们只会创建一个由键 fullScreenMap
引用的样式,并通过将 top
、bottom
、left
和 right
坐标添加到零来使其成为绝对位置,覆盖全屏大小。除此之外,我们需要为容器视图添加一些样式,以确保它填满整个屏幕:{flex: 1}
。将 flex
设置为 1
,我们希望我们的视图填满其父级占用的所有空间。由于这是主视图,{flex: 1}
将占据整个屏幕。
对于我们的地图组件,我们将使用 react-native-maps
,这是由 Airbnb 创建的一个开放模块,使用原生地图功能支持 Google 和 Apple 地图。react-native-maps
是一个非常灵活的模块,维护得非常好,并且功能齐全,因此它已经成为 React Native 的事实地图模块。正如我们将在本课程的后面看到的那样,react-native-maps
需要开发人员运行 react-native link
才能正常工作。
除了样式之外,<MapView/>
组件将以 initialRegion
作为属性,将地图居中在一组特定的坐标上,这些坐标应该是用户的当前位置。出于一致性的原因,我们将把地图的中心定位在旧金山,在那里我们还将放置一些可预订的汽车。
/** * src/main.js ** */
import React from 'react';
import { View, Animated, Image, StyleSheet } from 'react-native';
import MapView from 'react-native-maps';
export default class Main extends React.Component {
constructor(props) {
super(props);
this.state = {
carLocations: [
{
rotation: 78,
latitude: 37.78725,
longitude: -122.4318,
},
{
rotation: -10,
latitude: 37.79015,
longitude: -122.4318,
},
{
rotation: 262,
latitude: 37.78525,
longitude: -122.4348,
},
],
};
this.initialRegion = {
latitude: 37.78825,
longitude: -122.4324,
latitudeDelta: 0.00922,
longitudeDelta: 0.00421,
};
}
render() {
return (
<View style={{ flex: 1 }}>
<MapView
style={styles.fullScreenMap}
initialRegion={this.initialRegion}
>
{this.state.carLocations.map((carLocation, i) => (
<MapView.Marker key={i} coordinate={carLocation}>
<Animated.Image
style={{
transform: [{ rotate: `${carLocation.rotation}deg` }],
}}
source={require('../img/car.png')}
/>
</MapView.Marker>
))}
</MapView>
</View>
);
}
}
...
我们已经添加了一个carLocations
数组,以在地图上显示为标记。在我们的render
函数中,我们将遍历这个数组,并将相应的<MapView.Marker/>
放置在提供的坐标中。在每个标记内,我们将添加车辆的图像,并通过特定角度旋转它,以匹配街道方向。旋转图像必须使用Animated
API 完成,这将在本课程的后面更好地解释。
让我们在我们的状态中添加一个新属性,用于存储地图中心位置的可读位置:
/** * src/main.js ** */
import GeoCoder from 'react-native-geocoder';
export default class Main extends React.Component {
constructor(props) {
super(props);
this.state = {
position: null,
...
};
...
}
_onRegionChange(region) {
this.setState({ position: null });
const self = this;
if (this.timeoutId) clearTimeout(this.timeoutId);
this.timeoutId = setTimeout(async () => {
try {
const res = await GeoCoder.geocodePosition({
lat: region.latitude,
lng: region.longitude,
});
self.setState({ position: res[0] });
} catch (err) {
console.log(err);
}
}, 2000);
}
componentDidMount() {
this._onRegionChange.call(this, this.initialRegion);
}
render() {
<View style={{ flex: 1 }}>
<MapView
style={styles.fullScreenMap}
initialRegion={this.initialRegion}
onRegionChange={this._onRegionChange.bind(this)}
>
...
</MapView>
</View>;
}
}
...
为了填充这个状态变量,我们还创建了一个_onRegionChange
函数,它使用react-native-geocoder
模块。该模块使用 Google Maps 的逆地理编码服务将一些坐标转换为可读位置。因为这是一个 Google 服务,我们可能需要添加一个 API 密钥来验证我们的应用程序与服务的身份。有关完全安装此模块的所有说明可以在其存储库 URL github.com/airbnb/react-native maps/blob/master/docs/installation.md
中找到。
我们希望这个状态变量在主组件挂载时就可用,因此我们将在componentDidMount
中调用_onRegionChange
,以便初始位置的名称也存储在状态中。此外,我们将在我们的<MapView/>
上添加onRegionChange
属性,以确保每次地图移动以显示不同区域时,位置的名称都会重新计算,这样我们的position
状态变量中始终有地图中心位置的名称。
作为此屏幕的最后一步,我们将添加所有子视图和另一个确认预订请求的函数:
/** * src/main.js ** */
...
import LocationPin from './components/LocationPin';
import LocationSearch from './components/LocationSearch';
import ClassSelection from './components/ClassSelection';
import ConfirmationModal from './components/ConfirmationModal';
export default class Main extends React.Component {
...
_onBookingRequest() {
this.setState({
confirmationModalVisible: true,
});
}
render() {
return (
<View style={{ flex: 1 }}>
...
<LocationSearch
value={
this.state.position &&
(this.state.position.feature ||
this.state.position.formattedAddress)
}
/>
<LocationPin onPress={this._onBookingRequest.bind(this)} />
<ClassSelection />
<ConfirmationModal
visible={this.state.confirmationModalVisible}
onClose={() => {
this.setState({ confirmationModalVisible: false });
}}
/>
</View>
);
}
}
...
我们添加了四个子视图:
-
LocationSearch
: 我们将在此组件中向用户显示地图中心的位置,以便她知道她准确请求接送的位置名称。 -
LocationPin
: 地图中心的标记,用户可以在地图上看到她将请求接送的位置。它还会显示一个确认接送的按钮。 -
ClassSelection
: 用户可以在其中选择接送车辆类型(经济型、特殊型或高级型)的条形图。 -
ConfirmationModal
: 显示请求确认的模态框。
_onBookingRequest
方法将负责在请求预订时弹出确认模态框。
向我们的应用程序添加图像
React Native 处理图像的方式与网站类似:图像应放置在项目文件夹结构内的一个文件夹中,然后可以通过<Image />
(或<Animated.Image />
)的source
属性引用它们。让我们看一个来自我们应用程序的例子:
-
car.png
:这个文件放在我们项目根目录的img/
文件夹内 -
然后通过使用
source
属性创建一个<Image/>
组件来显示图像:
<Image source={require('../img/car.png')} />
注意source
属性不接受字符串,而是require('../img/car.png')
。这是 React Native 中的一个特殊情况,可能会在将来的版本中改变。
位置搜索
这应该是一个简单的文本框,显示地图中心的位置的可读名称。让我们看一下代码:
/*** src/components/LocationSearch.js ** */
import React from 'react';
import {
View,
Text,
TextInput,
ActivityIndicator,
StyleSheet,
} from 'react-native';
export default class LocationSearch extends React.Component {
render() {
return (
<View style={styles.container}>
<Text style={styles.title}>PICKUP LOCATION</Text>
{this.props.value && (
<TextInput style={styles.location} value={this.props.value} />
)}
{!this.props.value && <ActivityIndicator style={styles.spinner} />}
</View>
);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'white',
margin: 20,
marginTop: 40,
height: 60,
padding: 10,
borderColor: '#ccc',
borderWidth: 1,
},
title: {
alignSelf: 'center',
fontSize: 12,
color: 'green',
fontWeight: 'bold',
},
location: {
height: 40,
textAlign: 'center',
fontSize: 13,
},
spinner: {
margin: 10,
},
});
它只接收一个属性:value
(要显示的位置的名称)。如果没有设置,它将显示一个旋转器来显示活动。
因为在这个组件中有许多不同的样式要应用,所以使用StyleSheet
API 将样式组织在一个键/值对象中,并从我们的render
方法中引用它是有益的。逻辑和样式之间的分离有助于代码的可读性,也使代码重用成为可能,因为样式可以被级联到子组件中。
对齐元素
React Native 使用 Flexbox 来设置应用程序中元素的布局。这通常很简单,但有时在对齐元素时可能会令人困惑,因为有四个属性可以用于此目的:
-
justifyContent:
它通过主轴定义子元素的对齐方式 -
alignItems
:它通过交叉轴定义子元素的对齐方式 -
alignContent
:当交叉轴上有额外空间时,它会对齐 flex 容器的行 -
alignSelf
:它允许为单个 flex 项覆盖默认对齐(或由alignItems
指定的对齐方式)
前三个属性应该分配给容器元素,而第四个属性将应用于子元素,以防我们想要覆盖默认对齐方式。
在我们的情况下,我们只希望一个元素(标题)居中对齐,所以我们可以使用alignSelf: 'center'
。在本课程的后面,我们将看到不同align
属性的其他用途。
位置图钉
在这一部分,我们将专注于构建指向地图中心的图钉,以直观确认取货位置。这个图钉还包含一个按钮,可以用来触发取货请求:
/** * src/components/LocationPin.js ** */
import React from 'react';
import {
View,
Text,
Dimensions,
TouchableOpacity,
StyleSheet,
} from 'react-native';
const { height, width } = Dimensions.get('window');
export default class LocationPin extends React.Component {
render() {
return (
<View style={styles.container}>
<View style={styles.banner}>
<Text style={styles.bannerText}>SET PICKUP LOCATION</Text>
<TouchableOpacity
style={styles.bannerButton}
onPress={this.props.onPress}
>
<Text style={styles.bannerButtonText}>{'>'}</Text>
</TouchableOpacity>
</View>
<View style={styles.bannerPole} />
</View>
);
}
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: height / 2 - 60,
left: width / 2 - 120,
},
banner: {
flexDirection: 'row',
alignSelf: 'center',
justifyContent: 'center',
borderRadius: 20,
backgroundColor: '#333',
padding: 10,
paddingBottom: 10,
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 3,
},
shadowRadius: 5,
shadowOpacity: 1.0,
},
bannerText: {
alignSelf: 'center',
color: 'white',
marginRight: 10,
marginLeft: 10,
fontSize: 18,
},
bannerButton: {
borderWidth: 1,
borderColor: '#ccc',
width: 26,
height: 26,
borderRadius: 13,
},
bannerButtonText: {
color: 'white',
textAlign: 'center',
backgroundColor: 'transparent',
fontSize: 18,
},
bannerPole: {
backgroundColor: '#333',
width: 3,
height: 30,
alignSelf: 'center',
},
});
这个组件在功能上又非常轻量,但具有很多自定义样式。让我们深入一些样式细节。
flexDirection
默认情况下,React Native 和 Flexbox 会垂直堆叠元素:
对于我们图钉上的横幅,我们希望将每个元素水平堆叠在一起:
这可以通过将以下样式添加到包含元素flexDirection: 'row'
来实现。flexDirection
的其他有效选项是:
-
row-reverse
-
column
(默认) -
column-reverse
Dimensions
在这个组件中的第一行代码中,从设备中提取了高度和宽度到两个变量中:
const {height, width} = Dimensions.get('window');
获取设备的高度和宽度使我们开发人员能够绝对定位一些元素,确信它们将正确对齐。例如,我们希望我们图钉的横幅对齐在屏幕中心,所以它指向地图的中心。我们可以通过在我们的样式表中的banner
样式中添加{top: (height/2), left: (width/2)}
来实现这一点。当然,这会将其对齐到左上角,所以我们需要从每个属性中减去横幅大小的一半,以确保它在元素中间居中。每当我们需要对齐一个与组件树中的任何其他元素无关的元素时,都可以使用这个技巧,尽管在可能的情况下建议使用相对定位。
Shadows
让我们专注于我们横幅的样式,特别是shadows
属性:
banner: {
...
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 3
},
shadowRadius: 5,
shadowOpacity: 1.0
}
为了给组件添加阴影,我们需要添加四个属性:
-
shadowColor
:这添加了我们想要为组件设置的十六进制或 RGBA 颜色值 -
shadowOffset
:这显示了我们希望阴影投射多远 -
shadowRadius
:这显示了阴影角落的半径值 -
shadowOpacity
:这显示了我们希望阴影有多深
这就是我们的LocationPin
组件。
ClassSelection
在这个组件中,我们将探索在 React Native 中使用Animated
API 来开始动画。此外,我们将使用自定义字体来改善用户体验,并增加我们应用程序中的定制感。
/*** src/components/ClassSelection.js ** */
import React from 'react';
import {
View,
Image,
Dimensions,
Text,
TouchableOpacity,
Animated,
StyleSheet,
} from 'react-native';
const { height, width } = Dimensions.get('window');
export default class ClassSelection extends React.Component {
constructor(props) {
super(props);
this.state = {
classButtonPosition: new Animated.Value(15 + width * 0.1),
};
}
_onClassChange(className) {
if (className === 'superior') {
Animated.timing(this.state.classButtonPosition, {
toValue: width * 0.77,
duration: 500,
}).start();
}
if (className === 'special') {
Animated.timing(this.state.classButtonPosition, {
toValue: width * 0.5 - 20,
duration: 500,
}).start();
}
if (className === 'economy') {
Animated.timing(this.state.classButtonPosition, {
toValue: 15 + width * 0.1,
duration: 500,
}).start();
}
}
render() {
return (
<View style={styles.container}>
<Image
style={styles.classBar}
source={require('../../img/classBar.png')}
/>
<Animated.View
style={[styles.classButton, { left: this.state.classButtonPosition }]}
>
<Image
style={styles.classButtonImage}
source={require('../../img/class.png')}
/>
</Animated.View>
<TouchableOpacity
style={[
styles.classButtonContainer,
{
width: width / 3 - 10,
left: width * 0.11,
},
]}
onPress={this._onClassChange.bind(this, 'economy')}
>
<Text style={styles.classLabel}>economy</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.classButtonContainer,
{ width: width / 3, left: width / 3 },
]}
onPress={this._onClassChange.bind(this, 'special')}
>
<Text style={[styles.classLabel, { textAlign: 'center' }]}>
Special
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.classButtonContainer,
{ width: width / 3, right: width * 0.11 },
]}
onPress={this._onClassChange.bind(this, 'superior')}
>
<Text style={[styles.classLabel, { textAlign: 'right' }]}>
Superior
</Text>
</TouchableOpacity>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
height: 80,
backgroundColor: 'white',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingBottom: 10,
},
classBar: {
width: width * 0.7,
left: width * 0.15,
resizeMode: 'contain',
height: 30,
top: 35,
},
classButton: {
top: 30,
justifyContent: 'center',
borderRadius: 20,
borderColor: '#ccc',
borderWidth: 1,
position: 'absolute',
backgroundColor: 'white',
height: 40,
width: 40,
},
classButtonImage: {
alignSelf: 'center',
resizeMode: 'contain',
width: 30,
},
classButtonContainer: {
backgroundColor: 'transparent',
position: 'absolute',
height: 70,
top: 10,
},
classLabel: {
paddingTop: 5,
fontSize: 12,
},
});
这个简单的组件由五个子组件组成:
-
classBar
:这是显示条和每个类别停止点的图像 -
classButton
:这是圆形按钮,用户按下特定类别后将移动到所选类别 -
classButtonContainer
:这是检测用户想要选择的类的可触摸组件 -
classLabel
:这些是要显示在条的顶部的每个类别的标题
让我们先看一下样式,因为我们可以找到图像组件的一个新属性:resizeMode
,它确定当框架与原始图像尺寸不匹配时如何调整图像大小。从五种可能的值(cover
、contain
、stretch
、repeat
和center
)中,我们选择了contain
,因为我们希望均匀缩放图像(保持图像的纵横比),以便图像的两个尺寸都等于或小于视图的相应尺寸。我们在classBar
和classButtonImage
中都使用了这些属性,这是我们需要在此视图中调整大小的两个图像。
添加自定义字体
React Native 包含默认可用的跨平台字体的长列表。字体列表可以在github.com/react-native-training/react-native-fonts
上查看。
尽管如此,添加自定义字体是开发应用程序时的常见需求,特别是当设计师参与其中时,因此我们将使用我们的汽车预订应用程序作为测试此功能的游乐场。
将自定义字体添加到我们的应用程序是一个三步任务:
-
将字体文件(
.ttf
)添加到项目内的一个文件夹中。我们在这个应用程序中使用fonts/
。 -
将以下行添加到我们的
package.json
中:
"rnpm": {
"assets": ["./fonts"]
}
- 在终端中运行以下命令:
**react-native link**
就是这样,React Native 的 CLI 将一次性处理fonts
文件夹及其文件在 iOS 和 Android 项目中的插入。我们的字体将通过它们的字体名称(可能与文件名不同)可用。在我们的样式表中,我们有fontFamily: 'Blair ITC'
。
现在我们可以修改ClassSelection
组件中的classLabel
样式,以包含新的字体:
...
classLabel: {
fontFamily: 'Blair ITC',
paddingTop: 5,
fontSize: 12,
},
...
动画
React Native 的Animated
API 旨在以非常高效的方式简洁地表达各种有趣的动画和交互模式。动画侧重于输入和输出之间的声明关系,中间有可配置的转换,并且有简单的start
/stop
方法来控制基于时间的动画执行。
我们在应用程序中要做的是,当用户按下她想要预订的班级时,将classButton
移动到特定位置。让我们更仔细地看看我们在应用程序中如何使用这个 API:
/** * src/components/ClassSelection ***/
...
export default class ClassSelection extends React.Component {
constructor(props) {
super(props);
this.state = {
classButtonPosition: new Animated.Value(15 + width * 0.1),
};
}
_onClassChange(className) {
if (className === 'superior') {
Animated.timing(this.state.classButtonPosition, {
toValue: width * 0.77,
duration: 500,
}).start();
}
...
}
render() {
return (
...
<Animated.View style={{ left: this.state.classButtonPosition }}>
<Image
style={styles.classButtonImage}
source={require('../../img/class.png')}
/>
</Animated.View>
...
<TouchableOpacity
onPress={this._onClassChange.bind(this, 'superior')}
>
<Text>Superior</Text>
</TouchableOpacity>
...
);
}
}
...
为了使这种移动正确发生,我们需要将classButtonImage
包装在Animated.View
中,并为其提供一个初始的Animated.Value
作为左坐标。为此,我们将使用this.state.classButtonPosition
,这样当用户选择特定班级时我们可以改变它。
我们准备开始我们的动画。它将由_onClassChange
方法触发,因为当用户按下classButtonContainer
(<TouchableOpacity/>
)时,它被调用。这个方法调用Animated.timing
函数传递两个参数:
-
驱动动画的动画值(
this.state.classButtonPosition
) -
包含动画的结束值和持续时间的对象
调用Animated.timing
将导致一个包含start()
方法的对象,我们立即调用该方法来启动动画。然后 React Native 将知道Animated.View
的left
坐标需要根据提供的参数慢慢改变。
由于这可能对于简单的移动动画来说有点复杂,但它允许广泛的定制,如链接动画或修改缓动函数。我们将在本课程的后面看到旋转动画。
ConfirmationModal
我们的最后一个组件是一个模态视图,当用户按下“设置取货位置”按钮时将打开该视图。我们将显示模态视图和自定义活动指示器,它将使用复杂的动画设置来持续旋转在其位置:
/** * src/components/ConfirmationModal.js ***/
import React from 'react';
import {
Modal,
View,
Text,
Animated,
Easing,
TouchableOpacity,
StyleSheet,
} from 'react-native';
export default class ConfirmationModal extends React.Component {
componentWillMount() {
this._animatedValue = new Animated.Value(0);
}
cycleAnimation() {
Animated.sequence([
Animated.timing(this._animatedValue, {
toValue: 100,
duration: 1000,
easing: Easing.linear,
}),
Animated.timing(this._animatedValue, {
toValue: 0,
duration: 0,
}),
]).start(() => {
this.cycleAnimation();
});
}
componentDidMount() {
this.cycleAnimation();
}
render() {
const interpolatedRotateAnimation = this._animatedValue.interpolate({
inputRange: [0, 100],
outputRange: ['0deg', '360deg'],
});
return (
<Modal
animationType={'fade'}
visible={this.props.visible}
transparent={true}
>
<View style={styles.overlay}>
<View style={styles.container}>
<Text style={styles.title}>Contacting nearest car...</Text>
<Animated.Image
style={[
styles.spinner,
{ transform: [{ rotate: interpolatedRotateAnimation }] },
]}
source={require('../../img/loading.png')}
/>
<TouchableOpacity
style={styles.closeButton}
onPress={this.props.onClose}
>
<Text style={styles.closeButtonText}>X</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: '#0006',
justifyContent: 'center',
},
container: {
backgroundColor: 'white',
alignSelf: 'center',
padding: 20,
borderColor: '#ccc',
borderWidth: 1,
},
title: {
textAlign: 'right',
fontFamily: 'Blair ITC',
paddingTop: 5,
fontSize: 12,
},
spinner: {
resizeMode: 'contain',
height: 50,
width: 50,
margin: 50,
alignSelf: 'center',
},
closeButton: {
backgroundColor: '#333',
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignSelf: 'center',
},
closeButtonText: {
color: 'white',
alignSelf: 'center',
fontSize: 20,
},
});
对于这个组件,我们使用了 React Native 中可用的<Modal />
组件,以利用其淡入淡出动画和可见性功能。属性this.props.visible
将驱动此组件的可见性,因为父级知道用户的取货请求。
让我们再次关注动画,因为我们想为显示活动的旋转器设置一个更复杂的设置。我们想要显示一个无休止旋转的动画,所以我们需要系统地调用我们的start()
动画方法。为了实现这一点,我们创建了一个cycleAnimation()
方法,它在组件挂载时被调用(以启动动画),并且从返回的Animated.timing
对象中调用,因为它被作为回调传递以在每次动画结束时被调用。
我们还使用Animated.sequence
来连接两个动画:
-
从 0 度移动到 360 度(在一秒钟内使用线性缓动)
-
从 360 度移动到 0 度(在 0 秒内)
这是为了在每个周期结束时重复第一个动画。
最后,我们定义了一个名为interpolatedRotateAnimation
的变量,用于存储从 0 度到 360 度的插值,因此可以将其传递给transform
/rotate
样式,定义在动画我们的Animated.Image
时可用的旋转值。
作为一个实验,我们可以尝试用另一张图片替换 loading.png,看看它是如何被动画化的。这可以通过替换我们的<Animated.Image />
组件中的source
属性来轻松实现:
...
<Animated.Image
style={[
styles.spinner,
{ transform: [{ rotate: interpolatedRotateAnimation }] },
]}
source={require('../../img/spinner.png')}
/>
...
总结
使用诸如native-base
或react-native-elements
之类的 UI 库在构建应用程序时节省了大量时间和维护麻烦,但结果最终呈现出标准风格,这在用户体验方面并不总是理想的。这就是为什么学习如何操纵我们应用程序的样式总是一个好主意,特别是在设计由 UX 专家或应用程序设计师提供的团队中。
在这节课中,我们深入研究了 React Native 的 CLI 在初始化项目时创建的文件夹和文件。此外,我们熟悉了开发者菜单及其调试功能。在构建我们的应用程序时,我们专注于布局和组件样式,还学习了如何添加和操纵动画,使我们的界面更具吸引力。我们研究了 Flexbox 布局系统以及如何在组件中堆叠和居中元素。API,如 dimensions,用于检索设备的宽度和高度,以便对一些组件进行定位。您学会了如何将字体和图像添加到我们的应用程序,并如何显示它们以改善用户体验。
现在我们知道如何构建更多定制的界面,让我们在下一课中构建一个图像分享应用程序,其中设计起着关键作用。
评估
-
为什么
react-native-geocoder
模块使用 Google 地图的逆地理编码服务? -
存储地图中心位置的人类可读位置
-
将一些坐标转换为人类可读的位置
-
添加 API 密钥以便用服务对我们的应用进行身份验证
-
确保每次地图移动以显示不同区域时重新计算位置的名称。
-
以下哪个属性用于对齐元素?
-
justifyContent
-
alignLeft
-
alignRight
-
alignJustify
-
默认情况下,React Native 和 Flexbox 堆叠元素。
-
对角线
-
反向
-
垂直地
-
水平地
-
以下哪行代码从设备中提取高度和宽度到两个变量中?
-
const {height, width} = Dimensions.get('height, width');
-
constant {height, width} = Dimensions.get('window');
-
const {height, width} = Dimensions.get('window');
-
const {height, width} = Dimensions.get('window');
-
按顺序添加阴影到组件的四个属性是什么?
第二章:项目 2 - 图像分享应用程序
到目前为止,我们知道如何创建一个具有自定义界面的功能齐全的应用程序。您甚至学会了如何添加一个状态管理库来控制我们应用程序中的共享数据,以便代码库保持可维护和可扩展。
在本课程中,我们将专注于使用不同的状态管理库(Redux)构建应用程序,利用相机功能,编写特定于平台的代码,并深入构建自定义用户界面,既吸引人又可用。图像分享应用程序将作为这些功能的一个很好的示例,并且还将为理解如何在 React Native 上构建大型应用程序奠定基础。
我们将重用我们的大部分代码,这个应用程序将可用于两个平台:iOS 和 Android。尽管我们的大部分用户界面将是自定义的,但我们将使用native-base
来简化 UI 元素,如图标。对于导航,我们将再次使用react-navigation
,因为它为每个平台提供了最常用的导航:iOS 的选项卡导航和 Android 的抽屉菜单导航。最后,我们将使用react-native-camera
来处理与设备相机的交互。这不仅会减少实现复杂性,还会为我们提供一大堆免费的功能,我们可以在将来扩展我们的应用程序时使用。
对于这个应用程序,我们将模拟一些 API 调用,这样我们就不需要构建后端。当构建连接的应用程序时,这些调用应该很容易被真实的 API 替换。
概览
构建图像分享应用程序的主要要求之一是具有吸引人的设计。我们将遵循一些最流行的图像分享应用程序的设计模式,为每个平台调整这些模式,同时尽量重用尽可能多的代码,利用 React Native 的跨平台能力。
让我们首先来看一下 iOS 中的用户界面:
主屏幕显示一个简单的标题和图像列表,包括用户图片、姓名和一个更多图标以分享图像。在底部,选项卡导航显示三个图标,代表三个主要屏幕:所有图像、我的图像和相机。
注意
此示例应用程序中使用的所有图像都可以以任何形式使用。
当用户按下特定图像的更多图标时,将显示分享菜单:
这是一个标准的 iOS 组件。在模拟器上使用它并没有太多意义,最好在实际设备上进行测试。
让我们来看看第二个屏幕,我的图片:
这是当前用户上传的所有图片的网格表示,可以通过下一个屏幕相机进行更新:
iOS 模拟器不包括对任何相机的支持,因此这个功能最好在实际设备上进行测试,尽管react-native-camera
是完全可用的,并且在访问时将返回虚假数据。我们将使用静态图像进行测试。
这就是 iOS 的全部内容;现在让我们转向 Android 版本:
由于 Android 鼓励使用抽屉式导航而不是选项卡,我们将在页眉中包括一个抽屉菜单图标,并且还将通过不同的图标使相机可用。
与 iOS 的分享菜单一样,Android 有自己的控制器,因此我们将利用这一功能,并在用户点击特定图像上的更多图标时包含它:
当用户点击抽屉菜单图标时,菜单将显示出来,显示三个可用屏幕。从这里,用户可以导航到我的图片屏幕:
最后,相机屏幕也可以通过抽屉菜单访问:
Android 模拟器包括一个由彩色移动方块组成的相机模拟,可以用于测试。但出于一致性原因,我们将继续使用 iOS 版本中使用的固定图像。
本课程将涵盖以下主题:
-
React Native 中的 Redux
-
使用相机
-
特定平台的代码
-
抽屉和选项卡导航
-
与其他应用程序共享数据
设置文件夹结构
让我们使用 React Native 的 CLI 初始化一个 React Native 项目。该项目将命名为imageShare
,并将适用于 iOS 和 Android 设备:
**react-native init --version="0.44.0" imageShare**
为了在此应用程序中使用一些包,我们将使用特定版本的 React Native(0.44.0
)。
我们将为我们的应用程序使用 Redux,因此我们将创建一个文件夹结构,其中可以容纳我们的reducers
、actions
、components
、screens
和api
调用:
此外,我们已经在img
文件夹中添加了logo.png
。至于其余部分,我们有一个非常标准的 React Native 项目。入口点将是index.ios.js
用于 iOS 和index.android.js
用于 Android:
/*** index.ios.js and index.android.js ***/
import { AppRegistry } from 'react-native';
import App from './src/main';
AppRegistry.registerComponent('imageShare', () => App);
我们对两个文件的实现是相同的,因为我们希望使用src/main.js
作为两个平台的通用入口点。
让我们跳转到我们的package.json
文件,了解我们的应用程序将有哪些依赖项:
/*** package.json ***/
{
"name": "imageShare",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "node node_modules/react-native/
local-cli/cli.js start",
"test": "jest"
},
"dependencies": {
"native-base": "².1.5",
"react": "16.0.0-alpha.6",
"react-native": "0.44.0",
"react-native-camera": "⁰.8.0",
"react-navigation": "¹.0.0-beta.9",
"react-redux": "⁵.0.5",
"redux": "³.6.0",
"redux-thunk": "².2.0"
},
"devDependencies": {
"babel-jest": "20.0.3",
"babel-preset-react-native": "1.9.2",
"jest": "20.0.3",
"react-test-renderer": "16.0.0-alpha.6"
},
"jest": {
"preset": "react-native"
}
}
一些依赖项,如react-navigation
或native-base
,是以前课程中的老朋友。其他一些,如react-native-camera
,将在本课程中首次介绍。其中一些与我们将在此应用程序中使用的状态管理库 Redux 密切相关:
-
redux
:这就是状态管理库本身 -
react-redux
:这些是 Redux 的 React 处理程序 -
redux-thunk
:这是处理异步操作执行的 Redux 中间件
为了完成安装,我们需要链接react-native-camera
,因为它需要对我们应用程序的本地部分进行一些更改:
react-native link react-native-camera
在 iOS 10 及更高版本中,我们还需要修改我们的ios/imageShare/Info.plist
以添加一个相机使用说明,这应该显示以请求权限在应用程序内启用相机。我们需要在最后的</dict></plist>
之前添加这些行:
<key>NSCameraUsageDescription</key>
<string>imageShare requires access to the camera on this device to perform this action</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>imageShare requires access to the image library on this device to perform this action</string>
Redux
Redux 是一个基于简单原则的 JavaScript 应用程序的可预测状态容器:
-
您应用程序的整个状态存储在一个store内的对象树中
-
改变状态树的唯一方法是发出一个action,一个描述发生了什么的对象
-
为了指定操作如何转换状态树,您需要编写纯reducers
它的流行程度来自于在任何类型的代码库(前端或后端)中使用它所能产生的一致性、可测试性和开发人员体验。由于其严格的单向数据流,它也很容易理解和掌握:
用户触发和由Reducers处理的Actions,这些Reducers只是应用基于该Action的状态变化的纯函数。生成的状态保存在一个Store中,该Store由我们应用程序中的View使用,以显示应用程序的当前状态。
Redux 是一个复杂的主题,超出了本书的范围,但它将在本书的一些课程中广泛使用,因此可能有益于查看它们的官方文档(redux.js.org/
),以熟悉这个状态管理库的基本概念。
Redux 的一些基本概念将在我们的src/main.js
文件中使用:
/*** src/main.js ***/
import React from 'react';
import { DrawerNavigator,TabNavigator } from 'react-navigation';
import { Platform } from 'react-native';
import { Provider } from 'react-redux';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import imagesReducer from './reducers/images';
import ImagesList from './screens/ImagesList.js';
import MyImages from './screens/MyImages.js';
import Camera from './screens/Camera.js';
let Navigator;
if(Platform.OS === 'ios'){
Navigator = TabNavigator({
ImagesList: { screen: ImagesList },
MyImages: { screen: MyImages },
Camera: { screen: Camera }
}, {
tabBarOptions: {
inactiveTintColor: '#aaa',
activeTintColor: '#000',
showLabel: false
}
});
} else {
Navigator = DrawerNavigator({
ImagesList: { screen: ImagesList },
MyImages: { screen: MyImages },
Camera: { screen: Camera }
});
}let store = createStore(combineReducers({ imagesReducer }), applyMiddleware(thunk));
export default class App extends React.Component {
render() {
return (
<Provider store={store}>
<Navigator/>
</Provider>
)
}
}
让我们首先关注 Redux 的仪式。let store = createStore(combineReducers({ imagesReducer }), applyMiddleware(thunk));
通过组合导入的 reducer(我们这个应用程序只有一个 reducer,所以这只是信息性的)并应用Thunk中间件来设置存储,这将使我们的应用程序能够使用异步操作。我们将模拟几个 API 调用,这些调用将返回异步承诺,因此需要这个中间件来正确处理这些承诺的解析。
然后,我们有我们的render
方法:
<Provider store={store}>
<Navigator/>
</Provider>
这在大多数使用 React 的 Redux 应用程序中都是标准的。我们用一个<Provider />
组件包装根组件(<Navigator />
在我们的例子中)来确保我们将从我们应用程序的根部分获得store
。Redux 的connect
方法将在我们继续本课程时在我们的容器或屏幕中可用。
我们将使用一个<Navigator />
组件作为我们应用程序的根,但它将根据运行的平台的不同而具有不同的性质:
let Navigator;
if(Platform.OS === 'ios'){
Navigator = TabNavigator({
...
});
} else {
Navigator = DrawerNavigator({
...
});
}
Platform
是一个 React Native API,主要用于识别我们的应用程序正在运行的平台。我们可以通过用if(Platform.OS === 'ios'){ ... }
括起来的代码来编写特定于 iOS 的代码,对于 Android 也是一样:if(Platform.OS === 'android'){ ... }
。
在这种情况下,我们将在 iOS 上使用它来构建一个选项卡导航器,在 Android 上使用它来构建一个抽屉导航器,这些都是这些平台上的事实导航模式。在这两个导航器上,我们将把ImagesList
、MyImages
和Camera
设置为我们应用程序中的三个主要屏幕。
ImagesList
我们应用程序中的主屏幕是从后端检索的图像列表。我们将显示这些图像以及它们对应的上传者个人资料图片和名称。对于每个图像,我们将显示更多
,可以用于与用户设备上的其他应用程序共享图像,例如消息应用程序或社交网络。这个屏幕的大部分 UI 将从<Gallery />
组件派生,因此我们将专注于将屏幕与 Redux 存储连接起来,添加自定义标题,并添加一个滚动视图使画廊可滚动,并添加一个活动指示器来警告用户有网络活动:
/*** src/components/ImagesList ***/
import React from 'react';
import { View, ScrollView } from 'react-native';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as Actions from '../actions';
import { Icon } from 'native-base';
import Header from '../components/Header';
import Gallery from '../components/Gallery';
import ActivityIndicator from '../components/ActivityIndicator';
class ImagesList extends React.Component {
static navigationOptions = {
tabBarIcon: ({ tintColor }) => (
<Icon name='list' style={{fontSize: 40, color: tintColor}}/>
),
drawerLabel: 'All Images'
};
componentWillMount() {
this.props.fetchImages();
}
componentWillReceiveProps(nextProps) {
if(!this.props.addingImage && nextProps.addingImage) {
this.scrollable.scrollTo({y: 0});
}
}
render() {
return (
<View style={{flex: 1}}>
<Header onMenuButtonPress={() =>
this.props.navigation.navigate('DrawerOpen')}
onCameraButtonPress={() =>
this.props.navigation.navigate('Camera')}/>
<ScrollView ref={(scrollable) => {
this.scrollable = scrollable;
}}>
{ this.props.addingImage && <ActivityIndicator
message='Adding image' /> }
<Gallery imageList={this.props.images} loading=
{this.props.fetchingImages}/>
</ScrollView>
</View>
);
}
}
function mapStateToProps(state) { return { images: state.imagesReducer.images, addingImage: state.imagesReducer.addingImage, fetchingImages: state.imagesReducer.fetchingImages } }
function mapStateActionsToProps(dispatch) { return bindActionCreators(Actions, dispatch) }
export default connect(mapStateToProps, mapStateActionsToProps)(ImagesList);
由于大多数 React 应用程序使用 Redux,我们需要将我们的组件与状态和操作连接起来。我们将创建两个函数(mapStateToProps
和mapStateActionsToProps
)来装饰我们的<ImageList />
组件,以映射组件感兴趣的操作和状态的部分:
-
images
:这是我们将在<Gallery />
中用于呈现的图像列表 -
addingImage
:这是一个标志,当上传图像时我们将设置为true
-
fetchingImages
:当应用程序请求从后端获取图像列表以更新存储时,此标志将设置为true
在这个屏幕上,我们唯一需要的操作是fetchImages
,它可以通过propscomponent
访问,因为我们将Actions
中的操作列表连接到我们的<ImagesList />
组件。同样,我们通过props
还可以访问三个状态变量(images
,addingImage
和fetchingImages
),这要归功于相同的connect
调用:
function mapStateToProps(state) {
return {
images: state.imagesReducer.images,
addingImage: state.imagesReducer.addingImage,
fetchingImages: state.imagesReducer.fetchingImages
};
}
function mapStateActionsToProps(dispatch) {
return bindActionCreators(Actions, dispatch);
}
export default connect(mapStateToProps, mapStateActionsToProps)(ImagesList);
这就是我们从 Redux 需要的全部。我们将在其他屏幕中看到这种模式,因为它是连接 React 组件与存储部分和操作列表的常见解决方案。
fetchImages
动作在componentWillMount
上调用,作为要呈现的图像列表的初始检索:
componentWillMount() {
this.props.fetchImages();
}
我们还添加了一种方法来检测addingImage
标志何时设置为true
以显示活动指示器:
componentWillReceiveProps(nextProps) {
if(!this.props.addingImage && nextProps.addingImage) {
this.scrollable.scrollTo({y: 0});
}
}
这个方法将在<Scrollview />
中调用scrollTo
,以确保它显示顶部部分,因此<ActivityIndicator />
对用户可见。这次我们使用了自定义的<ActivityIndicator />
(从src/components/ActivityIndicator
导入),因为我们不仅想显示旋转器,还想显示消息。
最后,我们将添加两个组件:
-
<Header />
:显示标志和(在 Android 版本中)两个图标,用于导航到抽屉菜单和相机屏幕 -
<Gallery />
:这显示了格式化的图片列表和上传者
在转移到另一个屏幕之前,让我们看一下我们在这个屏幕中包含的三个自定义组件:<ActivityIndicator />
、<Header />
和 <Gallery />
。
Gallery
Gallery 包含了所有图片列表的渲染逻辑。它依赖于 native-base
,更具体地说,依赖于它的两个组件,<List />
和 <ListItem />
:
/*** src/components/Gallery ***/
import React from 'react';
import { List, ListItem, Text, Icon, Button, Container, Content }
from 'native-base';
import { Image, Dimensions, View, Share, ActivityIndicator, StyleSheet } from 'react-native';
var {height, width} = Dimensions.get('window');
export default class Gallery extends React.Component {
_share(image) {
Share.share({message: image.src, title: 'Image from: ' +
image.user.name})
}
render() {
return (
<View>
<List style={{margin: -15}}>
{
this.props.imageList && this.props.imageList.map((image) =>
{
return (
<ListItem
key={image.id}
style={{borderBottomWidth: 0,
flexDirection: 'column', marginBottom: -20}}>
<View style={styles.user}>
<Image source={{uri: image.user.pic}}
style={styles.userPic}/>
<Text style={{fontWeight: 'bold'}}>
{image.user.name}</Text>
</View>
<Image source={{uri: image.src}}
style={styles.image}/>
<Button style={{position: 'absolute', right: 15,
top: 25}} transparent
onPress={this._share.bind(this, image)}>
<Icon name='ios-more' style={{fontSize: 20,
color: 'black'}}/>
</Button>
</ListItem>
);
})
}
</List>
{
this.props.loading &&
<View style={styles.spinnerContainer}>
<ActivityIndicator/>
</View>
}
</View>
);
}
}
const styles = StyleSheet.create({
user: {
flexDirection: 'row',
alignSelf: 'flex-start',
padding: 10
},
userPic: {
width: 50,
height: 50,
resizeMode: 'cover',
marginRight: 10,
borderRadius: 25
},
image: {
width: width,
height: 300,
resizeMode: 'cover'
},
spinnerContainer: {
justifyContent: 'center',
height: (height - 50)
}
});
这个组件从它的父组件中获取两个 props:loading
和 imageList
。
loading
用于显示标准的 <ActivityIndicator />
,显示用户的网络活动。这次我们使用标准的指示器,而不是自定义指示器,因为应该很清楚网络活动表示的是什么。
imageList
是存储图片列表的数组,它将在我们的 <Gallery />
中一次渲染一个 <ListItem />
。每个 <ListItem />
都包含一个带有 onPress={this._share.bind(this, image)
的 <Button />
,用于与其他应用程序共享图片。让我们看一下 _share
函数:
_share(image) {
Share.share({message: image.src, title: 'Image from: '
+ image.user.name})
}
Share
是一个用于分享文本内容的 React Native API。在我们的情况下,我们将分享图片的 URL(img.src
)以及一个简单的标题。分享文本是在应用程序之间共享内容的最简单方式,因为许多应用程序都会接受文本作为共享格式。
值得注意的是我们对图片应用的样式,使其占据整个宽度并具有固定高度(300
),这样即使显示的图片大小不同,我们也能获得稳定的布局。为了实现这一设置,我们使用了 resizeMode: 'cover'
,这样图片在任何维度上都不会被拉伸。这意味着我们可能会裁剪图片,但这样可以保持统一性。另一个选项是使用 resizeMode: contain
,如果我们不想裁剪任何内容,而是想要将图片适应这些边界并可能缩小它们。
Header
我们想要在多个屏幕之间重用一个自定义的标题。这就是为什么最好为它创建一个单独的组件,并在这些屏幕中导入它的原因:
/*** src/components/Header ***/
import React from 'react';
import { View, Image, StyleSheet } from 'react-native';
import { Icon, Button } from 'native-base';
import { Platform } from 'react-native';
export default class Header extends React.Component {
render() {
return (
<View style={styles.container}>
{
Platform.OS === 'android' &&
<Button transparent onPress={this.props.onMenuButtonPress}>
<Icon android='md-menu' style={styles.menuIcon}/>
</Button>
}
<Image source={require('../../img/logo.png')}
style={styles.logo} />
{
Platform.OS === 'android' &&
<Button onPress={this.props.onCameraButtonPress} transparent>
<Icon name='camera' style={styles.cameraIcon}/>
</Button>
}
</View>
);
}
}
const styles = StyleSheet.create({
container: {
paddingTop: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
borderBottomWidth: 1,
borderBottomColor: '#ccc'
},
menuIcon: {
fontSize: 30,
color: 'black'
},
logo: {
height: 25,
resizeMode: 'contain',
margin: 10
},
cameraIcon: {
fontSize: 30,
color: 'black'
}
});
我们再次使用 Platform
API 来检测安卓设备,并且只在该平台上显示抽屉菜单按钮和相机按钮。我们决定这样做是为了使这些功能更加突出,从而减少需要按下的按钮数量,以便安卓用户更容易地使用这些功能。按下按钮时要执行的操作是通过父组件通过两个 props 传递的:
-
onMenuButtonPress
-
onCameraButtonPress
这两个属性调用两个单独的函数,调用导航器的navigate
方法:
-
this.props.navigation.navigate('DrawerOpen')
-
this.props.navigation.navigate('Camera')
最后要注意的是我们如何设置这个组件中容器的布局。我们使用justifyContent: 'space-around'
,这是告诉 Flexbox 均匀分布项目在行中,并在它们周围有相等的空间。请注意,从视觉上看,这些空间并不相等,因为所有项目在两侧都有相等的空间。第一个项目将在容器边缘有一个单位的空间,但下一个项目之间将有两个单位的空间,因为下一个项目有自己的间距。
ActivityIndicator
我们的自定义ActivityIndicator
是一个非常简单的组件:
/*** src/components/ActivityIndicator ***/
import React from 'react';
import { ActivityIndicator, View, Text, StyleSheet }
from 'react-native';
export default class CustomActivityIndicator extends React.Component {
render() {
return (
<View style={styles.container}>
<ActivityIndicator style={{marginRight: 10}}/>
<Text>{this.props.message}</Text>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'center',
padding: 10,
backgroundColor: '#f0f0f0'
}
});
它接收一个消息作为属性,并将其显示在标准旋转器旁边。我们还添加了自定义背景颜色(#f0f0f0
)以使其在白色背景上更加可见。
现在让我们转移到相机屏幕,将我们的图像添加到列表中。
相机
在使用react-native-camera
时,大部分拍照逻辑可以被抽象出来,因此我们将专注于在我们的组件中使用这个模块,并确保通过 Redux 操作将其连接到我们应用程序的状态:
/*** src/screens/Camera ***/
import React, { Component } from 'react';
import {
Dimensions,
StyleSheet,
Text,
TouchableHighlight,
View
} from 'react-native';
import { Button, Icon } from 'native-base';
import Camera from 'react-native-camera';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as Actions from '../actions';
class CameraScreen extends Component {
static navigationOptions = {
tabBarIcon: ({ tintColor }) => (
<Icon name='camera' style={{fontSize: 40, color: tintColor}}/>
),
};
render() {
return (
<View style={styles.container}>
<Camera
ref={(cam) => {
this.camera = cam;
}}
style={styles.preview}
aspect={Camera.constants.Aspect.fill}>
<Button onPress={this.takePicture.bind(this)}
style={styles.cameraButton} transparent>
<Icon name='camera' style={{fontSize: 70,
color: 'white'}}/>
</Button>
</Camera>
<Button onPress={() =>
this.props.navigation.navigate('ImagesList')}
style={styles.backButton} transparent>
<Icon ios='ios-arrow-dropleft' android='md-arrow-dropleft'
style={{fontSize: 30, color: 'white'}}/>
</Button>
</View>
);
}
takePicture() {
const options = {};
this.camera.capture({metadata: options})
.then((data) => {
this.props.addImage(data);
this.props.navigation.navigate('ImagesList');
})
.catch(err => console.error(err));
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
},
preview: {
flex: 1,
justifyContent: 'flex-end',
padding: 20
},
capture: {
flex: 0,
backgroundColor: '#fff',
borderRadius: 5,
color: '#000',
padding: 10,
margin: 40
},
cameraButton: {
flex: 0,
alignSelf: 'center'
},
backButton: {
position: 'absolute',
top:20
}
});
function mapStateToProps(state) { return {} }
function mapStateActionsToProps(dispatch) { return bindActionCreators(Actions, dispatch) }
export default connect(mapStateToProps, mapStateActionsToProps)(CameraScreen);
react-native-camera
的工作方式是通过提供一个我们可以包含在屏幕中的组件,并通过引用,我们可以调用它的capture
方法,该方法返回一个我们可以使用的承诺,以调用addImage
将我们的图像上传到应用程序的后端。
让我们更仔细地看一下<Camera />
组件:
<Camera
ref={(cam) => {
this.camera = cam;
}}
style={styles.preview}
aspect={Camera.constants.Aspect.fill}>
...
</Camera>
<Camera />
组件有三个属性:
-
ref
:这在父组件中为<Camera />
组件设置一个引用,以便调用捕获方法。 -
样式
:这允许开发人员指定应用程序中组件的外观。 -
aspect
:这允许您定义视图渲染器在显示相机视图时的行为。有三个选项:fill
,fit
和stretch
。
当用户按下相机按钮时,takePicture
函数将被调用:
takePicture() {
const options = {};
this.camera.capture({metadata: options})
.then((data) => {
this.props.addImage(data);
this.props.navigation.navigate('ImagesList');
})
.catch(err => console.error(err));
}
我们将使用保存的相机引用来调用其capture
方法,我们可以传递一些元数据(例如,拍摄照片的位置)。这个方法返回一个 promise,将使用这个数据调用addImage
动作将这个数据发送到后端,这样图片就可以添加到imagesList
中。在将图像发送到后端后,我们将使应用程序导航回ImagesList
屏幕。addImage
方法将设置addingImages
标志,因此ImageList
屏幕可以显示相应消息的活动指示器。
让我们继续看看我们应用程序中的最后一个屏幕:MyImages
。
MyImages
这个屏幕显示了已登录用户上传的所有图像。我们在这个屏幕上使用虚假图像来预先填充这个屏幕,但更多的图像可以通过相机屏幕添加。
大部分渲染逻辑将被移动到一个名为<ImagesGrid />
的单独组件中:
/*** src/screens/MyImages ***/
import React from 'react';
import {
Image,
TouchableOpacity,
Text,
View,
ActivityIndicator,
Dimensions
} from 'react-native';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as Actions from '../actions';
import { Icon } from 'native-base';
import Header from '../components/Header';
import ImagesGrid from '../components/ImagesGrid';
var {height, width} = Dimensions.get('window');
class MyImages extends React.Component {
static navigationOptions = {
drawerLabel: 'My Images',
tabBarIcon: ({ tintColor }) => (
<Icon name='person' style={{fontSize: 40, color: tintColor}}/>
)
};
componentWillMount() {
this.props.fetchImages(this.props.user.name);
}
render() {
return (
<View>
<Header onMenuButtonPress={() =>
this.props.navigation.navigate('DrawerOpen')}
onCameraButtonPress={() =>
this.props.navigation.navigate('Camera')}/>
{
this.props.fetchingImages &&
<View style={{justifyContent: 'center',
height: (height - 50)}}>
<ActivityIndicator/>
</View>
}
<ImagesGrid images={this.props.images}/>
</View>
);
}
}
function mapStateToProps(state) { return { images: state.imagesReducer.userImages, user: state.imagesReducer.user, fetchingImages: state.imagesReducer.fetchingUserImages } }
function mapStateActionsToProps(dispatch) { return bindActionCreators(Actions, dispatch) }
export default connect(mapStateToProps, mapStateActionsToProps)(MyImages);
这个组件的第一件事是调用fetchImages
动作,但与<ImagesList />
组件不同的是,它只传递用户名来仅检索已登录用户的图片。当我们创建这个动作时,我们需要考虑到这一点,并接收一个可选的userName
参数来过滤我们将检索的图像列表。
除此之外,这个组件将大部分行为委托给<ImageGrid />
,这样我们可以重用渲染能力给其他用户。让我们继续看看<ImageGrid />
。
ImageGrid
一个简单的滚动视图和图像列表。这个组件就是这么简单,但它配置成可以让图像以网格的方式轻松流动:
/*** src/components/ImageGrid ***/
import React from 'react';
import {
Image,
TouchableOpacity,
ScrollView,
Dimensions,
View,
StyleSheet
} from 'react-native';
var {height, width} = Dimensions.get('window');
export default class ImagesGrid extends React.Component {
render() {
return (
<ScrollView>
<View style={styles.imageContainer}>
{
this.props.images &&
this.props.images.map(img => {
return (<Image style={styles.image}
key={img.id} source={{uri: img.src}}/>);
})
}
</View>
</ScrollView>
);
}
}
const styles = StyleSheet.create({
imageContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
flexWrap: 'wrap'
},
image: {
width: (width/3 - 2),
margin: 1,
height: (width/3 - 2),
resizeMode: 'cover'
}
});
在设置容器样式时,我们使用flexWrap
:'wrap'来确保图像不仅在row
方向上流动,而且当设备宽度覆盖一行图像时也会扩展到新行。通过为每个图像设置width
和height
为width/3 - 2
,我们确保容器可以每行容纳三个图像,包括两个像素的小间距。
还有一些通过npm
可用的网格模块,但我们决定为此构建我们自己的组件,因为我们不需要网格中的额外功能,并且以这种方式可以获得灵活性。
这些就是我们在图像分享应用中需要的所有屏幕和视觉组件。现在让我们来看看让它们一起工作的粘合剂,即动作和减速器。
动作
正如我们在屏幕上看到的,这个应用只需要两个操作,fetchImages
(对所有用户或特定用户)和addImage
:
/*** src/actions/index ***/
import api from '../api';
export function fetchImages(userId = null) {
let actionName, actionNameSuccess, actionNameError;
if(userId) {
actionName = 'FETCH_USER_IMAGES';
actionNameSuccess = 'FETCH_USER_IMAGES_SUCCESS';
actionNameError = 'FETCH_USER_IMAGES_ERROR';
} else {
actionName = 'FETCH_IMAGES';
actionNameSuccess = 'FETCH_IMAGES_SUCCESS';
actionNameError = 'ADD_IMAGE_ERROR';
}
return dispatch => {
dispatch({ type: actionName });
api
.fetchImages(userId)
.then(images => {
dispatch({
type: actionNameSuccess,
images
})
})
.catch(error => {
dispatch({
type: actionNameError,
error
});
});
};
}
export function addImage(data = null) {
return dispatch => {
dispatch({ type: 'ADD_IMAGE' });
api
.addImage()
.then(imageSrc => {
dispatch({
type: 'ADD_IMAGE_SUCCESS',
imageSrc
});
})
.catch(error => {
dispatch({
type: 'ADD_IMAGE_ERROR',
error
});
});
};
}
Redux 操作只是描述事件的简单对象,包括其有效负载。由于我们使用了redux-thunk
,我们的action creators将返回一个函数,在这个函数中将调用 Redux 的dispatch
函数,传递操作。让我们更仔细地看看我们的addImage
操作:
export function addImage(data = null) {
return dispatch => {
dispatch({ type: 'ADD_IMAGE' });
api
.addImage()
.then(imageSrc => {
dispatch({
type: 'ADD_IMAGE_SUCCESS',
imageSrc
});
})
.catch(error => {
dispatch({
type: 'ADD_IMAGE_ERROR',
error
});
});
};
}
我们返回的函数首先通过不带有效负载的操作ADD_IMAGE
来调度一个操作,因为我们只是想让 Redux 知道我们准备好发起网络请求将图像上传到我们的后端。然后,我们使用我们的api
进行该请求(稍后我们将模拟这个调用)。这个请求将返回一个 promise,所以我们可以附加.then
和.catch
回调来处理响应。如果响应是积极的(图像被正确上传),我们将调度一个ADD_IMAGE_SUCCESS
操作,传递上传图像的 URL。如果出现错误,我们将调度一个ADD_IMAGE_ERROR
操作,涵盖所有可能的状态。
大多数 action creators 在Redux和Thunk中进行网络请求时都是以类似的方式工作。事实上,我们的 action fetchImages
与addImage
非常相似,只有一个例外:它需要检查是否传递了userId
,并发出一组不同的操作,以便 reducers 可以相应地修改状态。让我们来看一下将处理所有这些操作的 reducers。
Reducers
在 Redux 中,reducers 是负责在发生新操作时更新状态的函数。它们接收当前的state
和操作(包括任何有效负载),并返回一个新的状态对象。我们不会深入研究 reducers 的工作原理,我们只需要了解它们的基本结构:
/*** src/reducers/index ***/
const initialState = {
images: null,
userImages: null,
error: null,
user: {
id: 78261,
name: 'Sharer1',
pic: 'https://cdn.pixabay.com/photo/2015/07/20/12/53/
man-852762_960_720.jpg'
}
}
export default function (state = initialState, action) {
switch(action.type){
case 'FETCH_IMAGES':
return Object.assign({}, state, {
images: [],
fetchingImages: true,
error: null
});
case 'FETCH_IMAGES_SUCCESS':
return Object.assign({}, state, {
fetchingImages: false,
images: action.images,
error: null
});
case 'FETCH_IMAGES_ERROR':
return Object.assign({}, state, {
fetchingImages: false,
images: null,
error: action.error
});
case 'FETCH_USER_IMAGES':
return Object.assign({}, state, {
userImages: [],
fetchingUserImages: true,
error: null
});
case 'FETCH_USER_IMAGES_SUCCESS':
return Object.assign({}, state, {
fetchingUserImages: false,
userImages: action.images,
error: null
});
case 'FETCH_USER_IMAGES_ERROR':
return Object.assign({}, state, {
fetchingUserImages: false,
userImages: null,
error: action.error
});
case 'ADD_IMAGE':
return Object.assign({}, state, {
addingImage: true,
error: null
});
case 'ADD_IMAGE_SUCCESS':
let image = {
id: Math.floor(Math.random() * 99999999),
src: action.imageSrc,
user: state.user
}
return Object.assign({}, state, {
addingImage: false,
images: [image].concat(state.images),
userImages: [image].concat(state.images),
error: null
});
case 'ADD_IMAGE_ERROR':
return Object.assign({}, state, {
addingImage: false,
error: action.error
});
default:
return state;
}
}
让我们来分解一下:
const initialState = {
images: null,
userImages: null,
error: null,
user: {
id: 78261,
name: 'Sharer1',
pic: 'https://cdn.pixabay.com/photo/2015/07/20/12/53/
man-852762_960_720.jpg'
}
}
我们从一个初始状态开始,其中所有属性都将设置为null
,除了user
,它将包含模拟用户数据。这个初始状态默认在启动时由 reducer 注入:
export default function (state = initialState, action) {
...
}
在后续的调用中,Redux 将在应用任何操作后注入实际状态。在这个函数内部,我们有一个switch
来评估每个触发的操作的类型,以根据该操作及其有效负载修改状态。例如,让我们来看一下FETCH_IMAGES_SUCCESS
操作:
case 'FETCH_IMAGES_SUCCESS':
return Object.assign({}, state, {
fetchingImages: false,
images: action.images,
error: null
});
Redux 中的一个规则是,减速器不应该改变状态,而是在触发动作后返回一个新对象。使用Object.assign
,我们返回一个包含当前状态加上基于刚刚发生的动作的所需更改的新对象。在这种情况下,我们将fetchingImages
标志设置为false
,以便让我们的组件知道它们可以隐藏与获取图像动作相关的任何活动指示器。我们还将收到的图像列表(来自actions.images
)设置在我们状态的images
键中,以便它们可以被注入到需要它们的组件中。最后,我们将error
标志设置为null
,以隐藏由于先前状态而显示的任何错误。
正如我们之前提到的,每个异步操作都应该分成三个单独的动作来表示三种不同的状态:异步请求挂起,成功和出错。这样,我们将为我们的应用程序有三组动作:
-
FETCH_IMAGES
,FETCH_IMAGES_SUCCESS
和FETCH_IMAGES_ERROR
-
FETCH_USER_IMAGES
,FETCH_USER_IMAGES_SUCCESS
和FETCH_USER_IMAGES_ERROR
-
ADD_IMAGE
,ADD_IMAGE_SUCCESS
和ADD_IMAGE_ERROR
重要的是要注意,我们为FETCH_IMAGES
和FETCH_USER_IMAGES
有单独的情况,因为我们希望同时保留两个不同的图像列表:
-
包含用户正在关注的所有人的图像的通用列表
-
用户上传的图片列表
最后缺失的部分是从动作创建者调用的 API 调用。
API
在现实世界的应用程序中,我们会将所有对后端的调用放在一个单独的api
文件夹中。出于教育目的,我们只是模拟了对我们应用程序核心的两个 API 调用,addImage
和fetchImages
:
/*** src/api/index ***/
export default {
addImage: function(image) {
return new Promise((resolve, reject) => {
setTimeout(()=>{
resolve( '<imgUrl>' );
}, 3000)
})
},
fetchImages: function(user = null){
const images = [
{id: 1, src: '<imgUrl>', user: {pic: '<imgUrl>', name: 'Naia'}},
{id: 2, src: '<imgUrl>', user: {pic: '<imgUrl>',
name: 'Mike_1982'}},
{id: 5, src: '<imgUrl>', user: {pic: '<imgUrl>',
name: 'Sharer1'}},
{id: 3, src: '<imgUrl>', user: {pic: '<imgUrl>', name: 'Naia'}},
{id: 6, src: '<imgUrl>', user: {pic: '<imgUrl>',
name: 'Sharer1'}},
{id: 4, src: '<imgUrl>', user: {pic: '<imgUrl>',
name: 'Sharer1'}},
{id: 7, src: '<imgUrl>', user: {pic: '<imgUrl>',
name: 'Sharer1'}}
]
return new Promise((resolve, reject) => {
setTimeout(()=>{
resolve( images.filter(img => !user || user === img.user.name)
);
}, 1500);
})
}
}
为了模拟网络延迟,我们添加了一些setTimeouts
,这将有助于测试我们设置的用于显示用户网络活动的活动指示器。我们还使用了 promise 而不是普通的回调来使我们的代码更易于阅读。我们还在这些示例中跳过了图像 URL,以使其更简洁。
摘要
我们在这个应用程序中使用了 Redux,并且这塑造了我们使用的文件夹结构。虽然使用 Redux 需要一些样板代码,但它有助于以合理的方式拆分我们的代码库,并消除容器或屏幕之间的直接依赖关系。当我们需要在屏幕之间保持共享状态时,Redux 绝对是一个很好的补充,因此我们将在本书的其余部分继续使用它。在更复杂的应用程序中,我们需要构建更多的减速器,并可能按领域将它们分开并使用 Redux combineReducers
。此外,我们需要添加更多的操作,并为每组操作创建单独的文件。例如,我们需要登录、注销和注册的操作,我们可以将它们放在名为src/actions/user.js
的文件夹中。然后,我们应该将我们与图像相关的操作(目前在index.js
中)移动到src/actions/images.js
,这样我们就可以修改src/actions/index.js
以将其用作用户和图像操作的组合器,以便在需要一次性导入所有操作时使用。
Redux 还有助于测试,因为它将应用程序的业务逻辑隔离到减速器中,因此我们可以专注于对其进行彻底测试。
模拟 API 调用使我们能够为我们的应用程序构建一个快速原型。当后端可用时,我们可以重用这些模型进行测试,并用真正的 HTTP 调用替换src/api/index.js
。无论如何,最好为我们所有的 API 调用建立一个单独的文件夹,这样如果后端发生任何更改,我们就可以轻松地替换它们。
您还学会了如何构建特定平台的代码(在我们的案例中是特定于 Android),这对大多数应用程序来说是非常有用的功能。一些公司更喜欢为每个平台编写单独的应用程序,并且只重用它们的业务逻辑代码,在任何基于 Redux 的应用程序中都应该非常容易,因为它驻留在减速器中。
在 React Native 中没有特定的 API 来控制设备的相机,但我们可以使用react-native-camera
模块来实现。这是一个访问 iOS 和 Android 本地 API 并在 React Native JavaScript 世界中公开它们的库的示例。
在我们的下一课中,我们将通过构建一个消息应用程序来探索和跨越 React Native 应用程序中本地和 JavaScript 世界之间的桥梁。
评估
-
由 ______ 处理的操作只是纯函数,根据该操作对状态进行更改。
-
查看器
-
减速器
-
导航器
-
中间件
-
Gallery 包含了所有图像列表的渲染逻辑。它依赖于 _____,更具体地说,依赖于它的两个组件,
- 和
。 -
native-base
-
base-native
-
resizeMode
-
header
-
判断以下陈述是真还是假:每当在 Firebase 中存储新消息时,
this.selectedChatMessages
将被同步以反映它。 -
以下哪个是
<TextInput/>
的属性,当用户按下键盘上的 Return 或 Next 按钮时将被调用? -
this.refs.loginPassword.focus()
-
React.Component
-
onSubmitEditing
-
onChangeText
-
在将登录屏幕分成两个表单:
和 时,需要传递哪三个属性组件?
第三章:项目 3 - 消息应用
一对一通信是手机的主要用途,尽管短信已经很快被直接消息应用所取代。在本课程中,我们将使用 React Native 构建一个消息应用,并得到 Firebase 的支持,这是一个移动后端服务,将使我们摆脱为我们的应用构建整个后端的负担。相反,我们将专注于完全从前端处理我们应用的状态。当然,这可能会有安全方面的影响,需要最终解决,但为了保持本书对 React Native 能力的关注,我们将坚持将所有逻辑保留在我们的应用内部的方法。
Firebase 是一个建立在自我同步数据集合上的实时数据库,它与 MobX 配合非常好,所以我们将再次使用它来控制我们应用的状态。但在本课程中,我们将更深入地挖掘,因为我们将构建更大的数据存储,这些数据将通过 mobx-react 连接器注入到我们的组件树中。
我们将构建该应用,使其可以在 iOS 和 Android 上使用,为导航编写一些特定于平台的代码(我们将在 iOS 上使用选项卡导航,在 Android 上使用抽屉导航)。
为了减少代码的大小,在本课程中,我们将把重点放在功能上,而不是设计上。大部分用户界面将是简单明了的,但我们会尽量考虑可用性。此外,我们将在我们的聊天屏幕上使用 react-native-gifted
聊天--一个预先构建的 React Native 组件,用于根据消息列表渲染聊天室。
概览
消息应用需要比我们在之前课程中审查过的应用更多的工作,因为它需要一个用户管理系统,包括登录、注册和退出登录。我们将使用 Firebase 作为后端来减少构建这个系统的复杂性。除了用户管理系统,我们还将使用他们的推送通知系统,在新消息发送给用户时通知他们。Firebase 还提供了分析平台、lambda 函数服务和免费的存储系统,但我们将从中获得最大利润的功能是他们的实时数据库。我们将把用户的个人资料、消息和聊天数据存储在那里。
让我们来看一下我们的应用将会是什么样子,以便对我们将要构建的屏幕有一个心理形象:
第一个屏幕将是登录/注册屏幕,因为我们需要用户提供姓名和一些凭据,以将他们的设备连接到特定帐户,这样他们就可以接收每条消息的推送通知。这两种身份验证方法都是使用 Firebase 的 API 进行验证的,如果成功,将会显示聊天屏幕:
当在联系人列表中按下一个联系人时,应用程序将在聊天屏幕中显示与所选联系人的对话:
聊天屏幕将显示所有为登录用户启动的对话。最初,这个屏幕将是空的,因为用户还没有开始任何对话。要开始对话,用户应该去搜索屏幕以找到一些联系人:
这是一个简单的屏幕,用户可以在数据库中输入联系人姓名进行搜索。如果联系人姓名匹配成功,用户将能够点击它开始对话。从那时起,对话将显示在聊天屏幕中。
最后一个屏幕是个人资料屏幕:
这个屏幕只是用来注销当前用户的。在扩展应用程序时,我们可以添加更多功能,比如更改头像或用户名。
虽然在 Android 上应用程序看起来非常相似,但导航将被抽屉替换,从中可以访问所有屏幕。让我们看看 Android 版本:
登录/注册屏幕在 Android 上具有标准的文本输入和按钮组件:
用户登录后,可以通过滑动手指打开抽屉来浏览所有屏幕。默认登录后打开的屏幕是聊天屏幕,我们将列出用户拥有的所有对话列表:
从这个屏幕,用户可以按下特定对话以列出其中的消息:
接下来的屏幕是搜索屏幕,用于搜索其他用户并与他们开始对话:
最后一个屏幕是个人资料屏幕,可以找到注销按钮:
该应用程序将在横屏和竖屏模式下都能正常工作:
正如我们可以想象的那样,这个应用程序将需要一个强大的后端环境来存储我们的用户、消息和状态。此外,当用户收到任何消息时,我们将需要一个推送通知平台来通知用户。由于我们在本书中专注于 React Native,我们将把所有这些后端工作委托给移动世界中最流行的移动后端作为服务(MBaaS)之一:Firebase。
在开始编码之前,我们将花一些时间设置我们的 Firebase 推送通知服务和实时数据库,以更好地了解我们的应用程序将要处理的数据类型。
总之,在本课程中,我们将讨论以下主题:
-
React Native 中的复杂 Redux
-
Firebase 实时数据库
-
Firebase 推送通知
-
Firebase 用户管理
-
表单
让我们首先回顾一下我们将使用的数据模型以及我们的应用程序将如何与 Firebase 连接以同步其数据。
Firebase
Firebase 是一种移动后端作为服务(MBaaS),这意味着它为移动开发人员提供了所有后端必需品,如用户管理、无 SQL 数据库和推送通知服务器。它通过官方的 node 包轻松集成到 React Native 中,这为数据库连接提供了免费的服务。不幸的是,Firebase 并没有为他们的推送通知服务提供 JavaScript SDK,但有几个 React Native 库通过将 Firebase 的 iOS 和 Java SDK 与 JavaScript 接口进行桥接来填补这一空白。我们将使用react-native-fcm
,因为它在这个领域是最成熟的。
在 Firebase MBaaS 上构建应用程序之前,您需要为其创建一个项目。这是一个免费的过程,可以在 Firebase 的网站firebase.google.com/
上找到解释。虽然这个过程与 React Native 没有直接关系,但这是一个很好的起点,可以了解如何为我们的应用程序设置和使用 MBaaS。通过遵循 Firebase 文档网站上提供的教程,大部分配置可以在几分钟内完成。设置这个 MBaaS 的好处使得这几分钟的时间和最初的麻烦都是值得的。
要设置 Firebase 并将我们的应用程序连接到正确的项目,我们需要使用在我们的 Firebase 项目仪表板内的设置屏幕中找到的web 配置
片段。我们将这个初始化片段添加到src/firebase.js
中:
import firebase from 'firebase';
var firebaseConfig = {
apiKey: “<Your Firebase API key>",
authDomain: “<Your Firebase Auth domain>",
databaseURL: “<Your Firebase database URL>",
projectId: “<Your Firebase projectId>",
storageBucket: “<Your Firebase storageBucket>",
messagingSenderId: “<Your messaging SenderId>"
};
export const firebaseApp = firebase.initializeApp(firebaseConfig);
一旦项目设置完成,我们可以开始看一下我们的数据库将如何被构建。
实时数据库
Firebase 允许移动开发人员使用云托管的 NoSQL 数据库在用户和设备之间实时存储和同步数据。更新后的数据在毫秒内同步到连接的设备上,如果应用离线,数据仍然可用,无论网络连接如何,都能提供出色的用户体验。
在考虑一对一通信应用程序应该处理的基本数据时,有三个数据模型涉及其中:
-
users
:这里将存储头像、姓名和推送通知令牌。这里不需要存储认证数据,因为它是通过不同的 Firebase API(认证 API)处理的。 -
messages
:我们将在每个聊天室单独保存每条消息,以便使用聊天室 ID 作为键轻松检索。 -
聊天:所有关于已打开聊天的信息都将存储在这里。
为了理解我们将如何请求和使用应用中的数据,让我们看一下我们实际可以用于测试的示例数据的要点:
{
“chats" : {
“--userId1--" : {
“--userId2----userId1--" : {
“contactId" : “--userId2--",
“image" : “https://images.com/person2.jpg",
“name" : “Jason"
}
},
“--userId2--" : {
“--userId2----userId1--" : {
“contactId" : “--userId1--",
“image" : “https://images.com/person1.jpg",
“name" : “John"
}
}
},
“messages" : {
“--userId2----userId1--" : {
“-KpEwU8sr01vHSy3qvRY" : {
“_id" : “2367ad00-301d-46b5-a7b5-97cb88781489",
“createdAt" : 1500284842672,
“text" : “Hey man!",
“user" : {
“_id" : “--userId2--",
“name" : “Jason"
}
}
}
},
“users" : {
“--userId1--" : {
“name" : “John",
“notificationsToken" : “"
},
“--userId2--" : {
“name" : “Jason",
“notificationsToken" : “--notificationsId1--"
}
}
}
我们以一种易于消息应用检索和同步的方式组织了我们的数据。我们引入了一些数据重复,以增加数据检索速度,并将前端代码简化到最大程度,而不是对数据结构进行规范化。
users
集合使用用户 ID 作为键(--user1--
和--user2--
)来保存用户数据。这些用户 ID 在注册/登录期间由 Firebase 自动检索。每个用户都有一个通知令牌,这是用户使用推送通知服务登录的设备的标识符。当用户注销时,通知令牌将被移除,因此发送给该用户的消息将被存储,但不会通知到任何设备。
chats
集合通过用户 ID 存储每个用户的聊天列表。每个聊天都有自己的 ID(由两个用户 ID 连接而成),并且会被复制,因为每个参与聊天的用户都应该有一份聊天数据的副本。在每个副本中,都有足够的信息供另一个用户构建他们的聊天界面。
messages
集合存储在一个单独的集合中,可以通过该 ID 引用。每个聊天 ID 指向一条消息列表(在本例中只有一条),其中存储了聊天界面所需的所有数据。在这个集合中也有一些重复,因为一些用户数据与每条消息一起存储,以减少构建聊天界面时所需的请求次数。
在他们的网站上可以找到有关如何在 Firebase 的实时数据库中读写数据的完整教程(firebase.google.com/docs/database/
),但是我们将快速浏览一下我们在本课程中将使用的方法。
从 Firebase 的数据库中读取数据
从 Firebase 的数据库中检索数据有两种方法。第一种方法设置一个监听器,每当数据更改时都会调用它,因此我们只需要为我们的应用程序的整个生命周期设置一次即可:
firebaseApp.database().ref('/users/' + userId).on('value', (snapshot) => {
const userObj = snapshot.val();
this.name = userObj.name;
this.avatar = userObj.avatar;
});
正如我们所看到的,为了检索数据的快照,我们需要在我们的firebaseApp
对象中调用database()
方法(我们在src/firebase.js
文件中创建的对象)。然后,我们将有一个database
对象,我们可以在其上调用ref('<uri>')
,传递数据存储的 URI。这将返回一个由该 URI 指向的数据片段的引用。我们可以使用on('value', callback)
方法,它将附加一个回调,传递数据的快照。Firebase 总是将对象作为快照返回,因此我们需要自己将它们转换为普通数据。在这个例子中,我们想要检索一个具有两个键(name
和avatar
)的对象,所以我们只需要在快照上调用val()
方法来检索包含数据的普通对象。
如果我们不需要在更新时自动同步检索到的数据,我们可以使用once()
方法而不是on()
:
import firebase from 'firebase';
import { firebaseApp } from '../firebase';
firebaseApp.database().ref('/users/' + userId).once('value')
.then((snapshot) => {
const userObj = snapshot.val();
this.name = userObj.name;
this.avatar = userObj.avatar;
});
接收快照的回调只会被调用一次。
在 Firebase 的数据库中更新数据
在 Firebase 数据库中写入数据也可以通过两种不同的方式完成:
firebaseApp.database().ref('/users/' + userId).update({
name: userName
});
update()
方法根据传递的键和值更改由提供的 URI 引用的对象。对象的其余部分保持不变。
另一方面,set()
将用我们提供的对象替换数据库中的对象:
firebaseApp.database().ref('/users/' + userId).set({
name: userName,
avatar: avatarURL
});
最后,如果我们想要添加一个新的数据快照,但是我们希望 Firebase 为其生成一个 ID,我们可以使用push
方法:
firebaseApp.database().ref('/messages/' + chatId).push().set(message);
认证
我们将使用 Firebase 认证服务,因此我们不需要担心在我们这边存储登录凭据、处理忘记的密码或验证电子邮件。这些和其他相关任务都可以通过 Firebase 认证服务免费完成。
为了通过电子邮件和密码激活登录和注册,我们需要在 Firebase 仪表板中将此方法作为会话登录方法启用。有关如何执行此操作的更多信息,请访问 Firebase 网站firebase.google.com/docs/auth/web/password-auth
。
在我们的应用程序中,我们只需要使用提供的 Firebase SDK 进行登录:
firebase.auth().signInWithEmailAndPassword(username, password)
.then(() => {
//user is logged in
})
.catch(() => {
//error logging in
})
})
对于注册,我们可以使用以下代码:
firebase.auth().createUserWithEmailAndPassword(email, password)
.then((user) => {
//user is registered
})
.catch((error) => {
//error registering
})
所有令牌处理将由 Firebase 处理,我们只需要添加一个监听器来确保我们的应用程序在身份验证状态更改时得到更新:
firebase.auth().onAuthStateChanged((user) => {
//user has logged in or out
}
设置文件夹结构
让我们使用 React Native 的 CLI 初始化一个 React Native 项目。该项目将命名为messagingApp
,并将可用于 iOS 和 Android 设备:
react-native init --version="0.45.1" messagingApp
我们将使用 MobX 来管理应用程序中的状态,因此我们需要一个存储文件夹。其余的文件夹结构对大多数 React 应用程序都是标准的。
我们需要五个屏幕(Chats
、Chat
、Login
、Profile
和Search
)、一个组件(ListItem
)和两个存储(chats
和users
),这些将通过stores/index.js
文件可用。我们还将使用两个辅助程序来支持我们的应用程序:
-
notifications.js
:所有与推送通知相关的逻辑将存储在此文件中 -
firebase.js
:这包括 Firebase SDK 的配置和初始化
由于我们将使用 MobX 和其他几个依赖项,让我们查看一下我们的package.json
文件,以了解我们将使用哪些包:
/*** package.json ***/
{
“name": “messagingApp",
“version": “0.0.1",
“private": true,
“scripts": {
“start": “node node_modules/react-native/local-cli
/cli.js start",
“test": “jest"
},
“dependencies": {
“firebase": “⁴.1.3",
“mobx": “³.2.0",
“mobx-react": “⁴.2.2",
“react": “16.0.0-alpha.12",
“react-native": “0.45.1",
“react-native-fcm": “⁷.1.0",
“react-native-gifted-chat": “⁰.2.0",
“react-native-keyboard-aware-scroll-view": “⁰.2.9",
“react-native-vector-icons": “⁴.2.0",
“react-navigation": “¹.0.0-beta.11"
},
“devDependencies": {
“babel-jest": “20.0.3",
“babel-plugin-transform-decorators-legacy": “¹.3.4",
“babel-preset-react-native": “2.1.0",
“jest": “20.0.4",
“react-test-renderer": “16.0.0-alpha.12"
},
“jest": {
“preset": “react-native"
}
}
我们将使用一些npm
包:
-
firebase
:Firebase 的身份验证和数据库连接的 SDK -
mobx
:MobX 将处理我们的应用程序状态 -
react-native-fcm
:Firebase 的推送消息 SDK -
react-native-gifted-chat
:用于渲染聊天室的库,包括日期分隔、头像和许多其他功能 -
react-native-keyboard-aware-scroll-view
:一个库,确保在处理表单时屏幕键盘不会隐藏任何焦点文本输入 -
react-native-vector-icons
:我们将在此应用程序中使用 Font Awesome 图标 -
react-navigation
:我们将使用抽屉式、选项卡式和堆栈式导航器来处理我们应用程序中的屏幕 -
babel-plugin-transform-decorators-legacy
:此库允许我们使用装饰器(使用传统的@
语法),在使用 MobX 时非常有用
在运行npm install
之后,我们的应用程序将准备好开始编码。与以前的应用程序一样,我们的消息应用程序的入口点将是index.ios.js
(iOS)和index.android.js
(Android)中相同的代码:
/*** index.ios.js and index.android.js ***/
import React from 'react'
import { AppRegistry } from 'react-native';
import App from './src/main';
import { Provider } from 'mobx-react/native';
import { chats, users } from './src/stores';
class MessagingApp extends React.Component {
render() {
return (
<Provider users={users} chats={chats}>
<App/>
</Provider>
)
}
}
AppRegistry.registerComponent('messagingApp', () => MessagingApp);
这是使用 MobX 启动 React Native 应用程序的标准方式--<Provider />
作为根元素提供,以将两个商店(users
和chats
)注入到我们应用程序中的屏幕中。所有初始化和导航逻辑都已推迟到src/main.js
文件中:
/*** src/main.js ***/
import React from 'react'
import { DrawerNavigator,TabNavigator } from 'react-navigation'
import { Platform, View } from 'react-native'
import { observer, inject } from 'mobx-react/native'
import Login from './screens/Login'
import Chats from './screens/Chats'
import Profile from './screens/Profile'
import Search from './screens/Search'
import { users, chats } from './stores'
let Navigator;
if(Platform.OS === 'ios'){
Navigator = TabNavigator({
Chats: { screen: Chats },
Search: { screen: Search },
Profile: { screen: Profile }
}, {
tabBarOptions: {
inactiveTintColor: '#aaa',
activeTintColor: '#000',
showLabel: true
}
});
} else {
Navigator = DrawerNavigator({
Chats: { screen: Chats },
Search: { screen: Search },
Profile: { screen: Profile }
});
}
@inject('users') @observer
export default class App extends React.Component {
constructor() {
super();
}
render() {
if(this.props.users.isLoggedIn){
return <Navigator/>
} else {
return <Login/>
}
}
}
我们在src/main.js
文件中看到的第一件事是,我们将使用不同的导航器,这取决于我们运行应用程序的平台:iOS 将打开一个选项卡导航器,而 Android 将打开一个基于抽屉的导航器。
然后,我们会在我们应用程序的许多组件中重复看到一行:
@inject('users') @observer
这是告诉 MobX 这个组件需要接收users
商店的方式。然后 MobX 将其作为属性传递给这个组件,因此我们可以使用它所持有的所有方法和属性。在这种情况下,我们对isLoggedIn
属性感兴趣,以便在用户尚未登录时向用户呈现<Login />
屏幕。由于 MobX 将这个属性注入为我们组件的属性,访问它的正确方式将是this.props.users.isLoggedIn
。
在继续构建组件之前,让我们看一下我们将在本课程中使用的商店,以更好地了解可用的数据和操作。
用户商店
该商店负责保存所有与用户相关的数据和逻辑,但也在用户登录时帮助聊天商店初始化:
/*** src/stores/users.js ***/
import {observable, computed, map, toJS, action} from 'mobx';
import chats from './chats'
import firebase from 'firebase';
import { firebaseApp } from '../firebase';
import notifications from '../notifications'
class Users {
@observable id = null;
@observable isLoggedIn = false;
@observable name = null;
@observable avatar = null;
@observable notificationsToken = null;
@observable loggingIn = false;
@observable registering = false;
@observable loggingError = null;
@observable registeringError = null;
@action login = function(username, password) {
//login with Firebase email/password method
}
@action logout = function() {
//logout from Firebase authentication service
}
@action register = function(email, password, name) {
//register through firebase authentication service
}
@action setNotificationsToken(token) {
//store the notifications token for this device
}
searchUsers(name) {
//helper for searching users by name in the database
}
constructor() {
this.bindToFirebase();
}
bindToFirebase() {
//Initialise connection to Firebase user
//authentication status and data
}
}
const users = new Users();
export default users;
这些是我们为此商店需要的所有属性和方法。有几个标志(包含动词-ing 形式的属性)来注意网络活动。现在让我们实现每个方法:
@action login = function(username, password) {
this.loggingIn = true;
this.loggingError = null;
firebase.auth().signInWithEmailAndPassword(username, password)
.then(() => {
this.loggingIn = false;
notifications.init((notificationsToken) => {
this.setNotificationsToken(notificationsToken);
});
})
.catch((error) => {
this.loggingIn = false;
this.loggingError = error.message;
});
}
使用 Firebase 登录就像在他们的身份验证 SDK 上调用signInWithEmailAndPassword
一样简单。如果登录成功,我们将初始化通知模块以启用设备接收推送通知。我们将在注销时遵循相反的路径:
@action logout = function() {
notifications.unbind();
this.setNotificationsToken('');
firebase.auth().signOut();
}
在注册操作中,除了设置适当的网络活动标志之外,我们还需要验证用户输入了名称,初始化通知,并将名称存储在数据库中:
@action register = function(email, password, name) {
if(!name || name == '') {
this.registering = false;
this.registeringError = 'Name was not entered';
return;
}
this.registering = true;
this.registeringError = null;
firebase.auth().createUserWithEmailAndPassword(email, password)
.then((user) => {
this.registering = false;
notifications.init((notificationsToken) => {
this.setNotificationsToken(notificationsToken);
});
firebaseApp.database().ref('/users/' + user.uid).set({
name: name
});
})
.catch((error) => {
this.registering = false;
this.registeringError = error.message;
})
}
设置通知令牌只是在数据库中进行简单的更新:
@action setNotificationsToken(token) {
if(!this.id) return;
this.notificationsToken = token;
firebaseApp.database().ref('/users/' + this.id).update({
notificationsToken: token
});
}
searchUsers()
方法没有标记为@action
,因为它不会修改我们应用程序的状态,而只是在数据库中搜索并返回具有提供的名称的用户列表:
searchUsers(name) {
return new Promise(function(resolve) {
firebaseApp.database().ref('/users/').once('value')
.then(function(snapshot) {
let foundUsers = [];
const users = snapshot.val();
for(var id in users) {
if(users[id].name === name) {
foundUsers.push({
name: users[id].name,
avatar:
users[id].avatar,
notificationsToken:
users[id].
notificationsToken,
id
});
}
}
resolve(foundUsers);
});
});
}
由于我们正在进行的请求是异步的,我们将以 promise 的形式返回结果。
最后,bindToFirebase()
将把这个存储中的属性附加到 Firebase 数据库中的数据快照。这个方法由构造函数调用,因此它作为用户数据的初始化。重要的是要注意,当认证状态改变时,这些数据将被更新,以始终反映用户的最新数据:
bindToFirebase() {
return firebase.auth().onAuthStateChanged((user) => {
if(this.chatsBind && typeof this.chatsBind.off === 'function')
this.chatsBind.off();
if(this.userBind && typeof this.userBind.off === 'function')
this.userBind.off();
if (user) {
this.id = user.uid;
this.isLoggedIn = true;
this.chatsBind = chats.bindToFirebase(user.uid);
this.userBind = firebaseApp.database().ref('/users/' + this.id).
on('value', (snapshot) =>
{
const userObj = snapshot.val();
if(!userObj) return;
this.name = userObj.name;
this.avatar = userObj.avatar;
});
} else {
this.id = null;
this.isLoggedIn = false;
this.userBind = null;
this.name = null;
this.avatar = null;
}
});
}
我们将存储聊天数据的监听器(作为this.chatsBind
)和用户数据的监听器(作为this.userBind
),这样我们可以在每次auth
状态改变时移除它们(通过调用off()
方法),然后附加新的监听器。
聊天存储
这个存储负责保存所有与聊天和消息相关的数据和逻辑,但它还有助于在用户登录时初始化chats
存储:
/*** src/stores/chats.js ***/
import { observable, computed, map, toJS, action } from 'mobx';
import { AsyncStorage } from 'react-native'
import { firebaseApp } from '../firebase'
import notifications from '../notifications'
class Chats {
@observable list;
@observable selectedChatMessages;
@observable downloadingChats = false;
@observable downloadingChat = false;
@action addMessages = function(chatId, contactId, messages) {
//add a list of messages to a chat
}
@action selectChat = function(id) {
//set a chat as selected and retrieve all the messages for it
}
@action add(user1, user2) {
//add a new chat to the list of chats for the users in it
}
bindToFirebase(userId) {
//listen for the list of chats in Firebase to update the
@observable list
}
}
const chats = new Chats()
export default chats;
我们将在@observable list
中存储用户拥有的开放聊天列表。当用户选择一个聊天时,我们将下载并同步该聊天上的消息列表到@observable selectedChatMessages
。然后,我们将设置一些标志,让用户知道我们正在从 Firebase 数据库下载数据。
让我们逐个查看每个方法。我们将从addMessages
开始:
@action addMessages = function(chatId, contactId, messages) {
if(!messages || messages.length < 1) return;
messages.forEach((message) => {
let formattedMessage = {
_id: message._id,
user: {
_id: message.user._id,
}
};
if(message.text) formattedMessage.text = message.text;
if(message.createdAt) formattedMessage.createdAt =
message.createdAt/1;
if(message.user.name) formattedMessage.user.name =
message.user.name;
if(message.user.avatar) formattedMessage.user.avatar =
message.user.avatar;
if(message.image) formattedMessage.image = message.image;
//add the message to the chat
firebaseApp.database().ref('/messages/' +
chatId).push().set(formattedMessage);
//notify person on the chat room
firebaseApp.database().ref('/users/' + contactId).once('value')
.then(function(snapshot) {
var notificationsToken = snapshot.val().notificationsToken;
notifications.sendNotification(notificationsToken, {
sender: message.user.name,
text: message.text,
image: message.user.image,
chatId
});
});
});
}
这个方法接收三个参数:
-
chatId
:消息将被添加到的聊天的 ID。 -
contactId
:我们要发送消息的用户的 ID。这将用于向用户的联系人发送通知。 -
messages
:这是我们想要添加到聊天中的所有消息的数组。
我们将循环遍历消息列表,格式化消息的方式,然后调用数据库引用上的set()
方法,将新消息保存在 Firebase 数据库中。最后,我们需要向我们的联系人发送通知,因此我们通过查询users
集合来检索他们的通知令牌,以获取他们的通知令牌。
通常,发送通知是由后端处理的,但由于我们正在在应用程序本身上设置所有逻辑,我们需要构建一个发送通知的函数。我们在我们的通知module: notifications.sendNotification(notificationsToken, data);
中完成了这个。
让我们看看当我们选择一个聊天来显示其消息时会发生什么:
@action selectChat = function(id) {
this.downloadingChat = true;
if(this.chatBind && typeof this.chatBind.off === 'function')
this.chatBind.off();
this.chatBind = firebaseApp.database().ref('/messages/' + id)
.on('value', (snapshot) => {
this.selectedChatMessages = [];
this.downloadingChat = false;
const messagesObj = snapshot.val();
for(var id in messagesObj) {
this.selectedChatMessages.push({
_id: id,
text: messagesObj[id].text,
createdAt: messagesObj[id].createdAt,
user: {
_id: messagesObj[id].user._id,
name: messagesObj[id].user.name,
avatar: messagesObj[id].user.avatar
},
image: messagesObj[id].image
});
}
});
}
这里的主要功能是将监听器附加到消息/聊天 ID 集合,这将使this.selectedChatMessages
可观察对象与数据库中所选聊天的消息列表同步。这意味着每当在 Firebase 中存储新消息时,this.selectedChatMessages
将同步以反映它。这就是 Firebase SDK 中on()
方法的工作原理:我们传递一个回调,我们可以使用它来将实时数据库与我们应用的状态同步。
添加新聊天将使用add()
方法完成:
@action add(user1, user2) {
return new Promise(function(resolve, reject) {
firebaseApp.database().ref('/chats/' + user1.id + '/' + user1.id +
user2.id).set({
name: user2.name,
image: user2.avatar,
contactId: user2.id
}).then(() => {
firebaseApp.database().ref('/chats/' + user2.id + '/'
+ user1.id +
user2.id).set({
name: user1.name,
image: user1.avatar,
contactId: user1.id
}).then(() => {
resolve();
})
})
});
}
在这里,我们正在构建并返回一个承诺,当两个聊天(每个用户参与的一个)更新时将解决。这两个数据库更新可以被视为数据的复制,但它也将减少数据结构的复杂性,从而提高我们代码库的可读性。
这个存储中的最后一个方法是bindToFirebase()
:
bindToFirebase(userId) {
this.downloadingChats = true;
return firebaseApp.database().ref('/chats/' + userId).
on('value', (snapshot) => {
this.downloadingChats = false;
const chatsObj = snapshot.val();
this.list = [];
for(var id in chatsObj) {
this.list.push({
id,
name: chatsObj[id].name,
image: chatsObj[id].image,
contactId: chatsObj[id].contactId
});
}
});
}
正如我们在users
存储中看到的,当用户登录时将调用此方法,并将监听器附加到chats/<userId>
数据快照,以使所有聊天数据与数据库上的this.list
属性同步。
为了方便起见,我们将两个存储都放在src/stores/index.js
中,这样我们就可以在一行代码中导入它们:
/*** src/stores/index.js ***/
import users from './users';
import chats from './chats';
export {
users,
chats
};
这就是我们将要使用的存储。正如我们所看到的,大部分业务逻辑都在这里处理,因此可以进行彻底的测试。现在让我们转移到我们将用于通知的辅助程序。
使用 Firebase 进行推送通知
Firebase 集成了 iOS 和 Android 的推送通知服务,但不幸的是,它没有提供任何 JavaScript 来使用它的 SDK。因此,一个开源库被创建,将 Objective-C 和 Java SDK 桥接到 React Native 模块中:react-native-fcm
。
我们不会在本书中涵盖此模块的安装,因为这是一个不断变化的过程,最好在其存储库中进行跟踪github.com/evollu/react-native-fcm
。
我们决定将这个模块的逻辑抽象到我们的src/notifications.js
文件中,以便在保持可维护性的同时为每个组件提供该逻辑。让我们来看看这个文件:
/*** src/notifications.js ***/
import {Platform} from 'react-native';
import FCM, {FCMEvent, RemoteNotificationResult, WillPresentNotificationResult, NotificationType} from 'react-native-fcm';
let notificationListener = null;
let refreshTokenListener = null;
const API_URL = 'https://fcm.googleapis.com/fcm/send';
const FirebaseServerKey = '<Your Firebase Server Key>';
const init = (cb) => {
FCM.requestPermissions();
FCM.getFCMToken().then(token => {
cb(token)
});
refreshTokenListener = FCM.on(FCMEvent.RefreshToken, (token) => {
cb(token);
});
}
const onNotification = (cb) => {
notificationListener = FCM.on(FCMEvent.Notification, (notif) => {
cb(notif);
if(Platform.OS ==='ios'){
switch(notif._notificationType){
case NotificationType.Remote:
notif.finish(RemoteNotificationResult.NewData)
break;
case NotificationType.NotificationResponse:
notif.finish();
break;
case NotificationType.WillPresent:
notif.finish(WillPresentNotificationResult.All)
break;
}
}
})
}
const unbind = () => {
if(notificationListener) notificationListener.remove();
if(refreshTokenListener) refreshTokenListener.remove();
}
const sendNotification = (token, data) => {
let body = JSON.stringify({
“to": token,
“notification": {
“title": data.sender || '',
“body": data. text || '',
“sound": “default"
},
“data": {
“name": data.sender,
“chatId": data.chatId,
“image": data.image
},
“priority": 10
});
let headers = new Headers({
“Content-Type": “application/json",
“Content-Length": parseInt(body.length),
“Authorization": “key=" + FirebaseServerKey
});
fetch(API_URL, { method: “POST", headers, body })
.then(response => console.log(“Send response", response))
.catch(error => console.log(“Error sending “, error));
}
export default { init, onNotification, sendNotification, unbind }
这个模块中暴露了四个函数:
-
init
:这请求权限接收推送通知(如果尚未授予),并请求设备令牌或在更改时刷新它。 -
onNotification
:当收到通知时,调用提供的回调函数。在 iOS 中,它还调用通知上的适当方法来关闭循环。 -
unbind
:这将停止监听推送通知。 -
sendNotification
:这会格式化并发送推送通知到特定设备,使用提供的通知令牌。
在 Firebase 中发送通知可以使用它们的 HTTP API,因此我们将使用fetch
来发送带有适当标头和主体数据的POST
请求。
现在,我们已经拥有了构建屏幕和组件所需的所有逻辑。
登录
<Login />
组件在逻辑上严重依赖于users
存储,因为它主要专注于呈现登录和注册两个表单。所有表单的验证都由 Firebase 完成,因此我们只需要专注于呈现 UI 元素和调用适当的存储方法。
在这个屏幕中,我们将使用react-native-keyboard-aware-scroll
视图,这是一个提供自动滚动的<Scrollview />
模块,它会对任何聚焦的<TextInput />
做出反应,以便它们在键盘弹出时不会被隐藏。
让我们来看一下代码:
/*** src/screens/Login.js ***/
import React, { PropTypes } from 'react'
import {
ScrollView,
TextInput,
Button,
Text,
View,
Image,
ActivityIndicator
} from 'react-native';
import { observer, inject } from 'mobx-react/native'
import Icon from 'react-native-vector-icons/FontAwesome'
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'
import LoginForm from '../components/LoginForm'
import RegistrationForm from '../components/RegistrationForm'
@inject('users') @observer
class Login extends React.Component {
onLogin(email, password) {
this.props.users.login(email, password);
}
onPressRegister(email, password, name) {
this.props.users.register(email, password, name);
}
render() {
return (
<KeyboardAwareScrollView style={{padding: 20, marginTop: 20,
backgroundColor: '#eee'}}>
<Icon name="comments" size={60} color='#ccc'
style={{alignSelf: 'center', paddingBottom: 20}}/>
<View style={{alignItems: 'center', marginBottom: 20}}>
<Text>- please, login to continue -</Text>
</View>
<LoginForm
onPress={this.onLogin.bind(this)}
busy={this.props.users.loggingIn}
loggingError={this.props.users.loggingError}
/>
<View style={{alignItems: 'center', marginTop: 20,
marginBottom: 20}}>
<Text>- or register -</Text>
</View>
<RegistrationForm
onPress={this.onPressRegister.bind(this)}
busy={this.props.users.registering}
registeringError={this.props.users.registeringError}
/>
</KeyboardAwareScrollView>
)
}
}
export default Login;
我们将登录屏幕分为两个表单:<LoginForm />
和<RegistrationForm />
。这两个组件都需要传递三个 props:
-
onPress
:当按下发送按钮时,组件需要执行的操作。 -
busy
:我们是否在等待远程数据? -
loginError/registrationError
:登录/注册时发生的错误的描述(如果发生了)。
我们将整个屏幕包裹在<KeyboardAwareScrollView />
中,以确保当焦点集中时,没有任何<TextInput />
会被键盘遮挡。现在让我们来看一下LoginForm
:
/*** src/components/LoginForm.js ***/
import React, { PropTypes } from 'react'
import {
TextInput,
Button,
Text,
View,
Image,
ActivityIndicator
} from 'react-native';
class LoginForm extends React.Component {
state= {
loginEmail: '',
loginPassword: ''
}
onPressLogin() {
this.props.onPress(this.state.loginEmail,
this.state.loginPassword);
}
render() {
return (
<View style={{backgroundColor: 'white', padding: 15,
borderRadius: 10}}>
{
this.props.loggingError &&
<View style={{backgroundColor: '#fcc', borderRadius: 5,
alignItems: 'center', marginBottom: 10}}>
<Text>{this.props.loggingError}</Text>
</View>
}
<TextInput
autoCapitalize='none'
autoCorrect={false}
keyboardType='email-address'returnKeyType='next'
style={{height: 40}}
onChangeText={(loginEmail) => this.setState({loginEmail})}
value={this.state.loginEmail}
placeholder='email'
onSubmitEditing={(event) => {
this.refs.loginPassword.focus();
}}
/>
<TextInput
ref='loginPassword'
style={{height: 40}}
onChangeText={(loginPassword) =>
this.setState({loginPassword})}
value={this.state.loginPassword}
secureTextEntry={true}
placeholder='password'
/>
{
this.props.busy ?
<ActivityIndicator/>
:
<Button
onPress={this.onPressLogin.bind(this)}
title='Login'
/>
}
</View>
)
}
}
export default LoginForm;
对于包含电子邮件的<TextInput />
元素,我们将属性keyboardType='email-address'
设置为使@
符号在软键盘上易于访问。还有其他选项,比如数字键盘,但我们只会在这个应用程序中使用'email-address'
。
<TextInput />
上的另一个有用的 prop 是returnKeyType
。我们将returnKeyType='next'
设置为那些不是最后一个的表单输入,以在键盘中显示下一个
按钮,以便用户知道他们可以通过点击该按钮转到下一个输入。这个 prop 与以下 prop 一起使用:
onSubmitEditing={(event) => {
this.refs.loginPassword.focus();
}}
onSubmitEditing
是一个 <TextInput />
属性,当用户按下键盘上的 Return
或 Next
按钮时将被调用。我们使用它来聚焦到下一个 <TextInput />
,在处理表单时这是非常用户友好的。为了获取下一个 <TextInput />
的引用,我们使用 ref
,这并不是最安全的方式,但对于简单的表单来说已经足够了。为了使其工作,我们需要将相应的 ref
分配给下一个 <TextInput />
:ref='loginPassword'
。
RegistrationForm
是一个非常相似的表单:
/*** src/components/RegistrationForm ***/
import React, { PropTypes } from 'react'
import {
ScrollView,
TextInput,
Button,
Text,
View,
Image,
ActivityIndicator
} from 'react-native';
class RegisterForm extends React.Component {
state= {
registerEmail: '',
registerPassword: '',
registerName: ''
}
onPressRegister() {
this.props.onPress(this.state.registerEmail,
this.state.registerPassword, this.state.registerName);
}
render() {
return (
<View style={{backgroundColor: 'white', padding: 15,
borderRadius: 10}}>
{
this.props.registeringError &&
<View style={{backgroundColor: '#fcc', borderRadius: 5,
alignItems: 'center', marginBottom: 10}}>
<Text>{this.props.registeringError}</Text>
</View>
}
<TextInput
autoCapitalize='none'
autoCorrect={false}
keyboardType='email-address'
returnKeyType='next'
style={{height: 40}}
onChangeText={(registerEmail) =>
this.setState({registerEmail})}
value={this.state.registerEmail}
placeholder='email'
onSubmitEditing={(event) => {
this.refs.registerName.focus();
}}
/>
<TextInput
ref='registerName'
style={{height: 40}}
onChangeText={(registerName) =>
this.setState({registerName})}
returnKeyType='next'
value={this.state.registerName}
placeholder='name'
onSubmitEditing={(event) => {
this.refs.registerPassword.focus();
}}
/>
<TextInput
ref='registerPassword'
style={{height: 40}}
onChangeText={(registerPassword) =>
this.setState({registerPassword})}
value={this.state.registerPassword}
secureTextEntry={true}
placeholder='password'
/>
{
this.props.busy ?
<ActivityIndicator/>
:
<Button
onPress={this.onPressRegister.bind(this)}
title='Register'
/>
}
</View>
)
}
}
export default RegisterForm;
聊天
这是显示打开聊天列表的屏幕。这里需要注意的特殊之处是,我们使用了第二个导航器来在聊天列表的顶部显示选定的聊天。这意味着我们的 Chats
组件中需要一个 StackNavigator
,其中包含两个屏幕:ChatList
和 Chat
。当用户从 ChatList
中点击一个聊天时,StackNavigator
将在 ChatList
顶部显示选定的聊天,通过标题栏中的标准 < back
按钮使聊天列表可用。
为了列出聊天,我们将使用 <FlatList />
,这是一个用于渲染简单、平面列表的高性能界面,支持大部分 <ListView />
的功能:
/*** src/screens/Chats.js ***/
import React, { PropTypes } from 'react'
import { View, Text, FlatList, ActivityIndicator } from 'react-native'
import { observer, inject } from 'mobx-react/native'
import { StackNavigator } from 'react-navigation'
import Icon from 'react-native-vector-icons/FontAwesome'
import notifications from '../notifications'
import ListItem from '../components/ListItem'
import Chat from './Chat'
@inject('chats') @observer
class ChatList extends React.Component {
imgPlaceholder =
'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
2160923_960_720.png'
componentWillMount() {
notifications.onNotification((notif)=>{
this.props.navigation.goBack();
this.props.navigation.navigate('Chat', {
id: notif.chatId,
name: notif.name || '',
image: notif.image || this.imgPlaceholder
})
});
}
render () {
return (
<View>
{
this.props.chats.list &&
<FlatList
data={this.props.chats.list.toJS()}
keyExtractor={(item, index) => item.id}
renderItem={({item}) => {
return (
<ListItem
text={item.name}
image={item.image || this.imgPlaceholder}
onPress={() => this.props.navigation.navigate('Chat',
{
id: item.id,
name: item.name,
image: item.image || this.imgPlaceholder,
contactId: item.contactId
})}
/>
)
}}
/>
}
{
this.props.chats.downloadingChats &&
<ActivityIndicator style={{marginTop: 20}}/>
}
</View>
)
}
}
const Navigator = StackNavigator({
Chats: {
screen: ChatList,
navigationOptions: ({navigation}) => ({
title: 'Chats',
}),
},
Chat: {
screen: Chat
}
});
export default class Chats extends React.Component {
static navigationOptions = {
tabBarLabel: 'Chats',
tabBarIcon: ({ tintColor }) => (
<Icon name="comment-o" size={30} color={tintColor}/>
)
};
render() {
return <Navigator />
}
}
我们注意到的第一件事是我们注入了 chats
存储,其中保存了聊天列表:@inject('chats') @observer
。我们需要这样做来构建我们的 <FlatList />
,基于 this.props.chats.list
,但是由于聊天列表是一个可观察的 MobX 对象,我们需要使用它的 toJS()
方法来转换它,使其成为一个 JavaScript 数组。
在 componentWillMount()
函数中,我们将调用通知模块上的 onNotification
来在用户每次按下设备上的推送通知时打开相应的聊天。因此,我们将在导航器上使用 navigate()
方法来打开正确的聊天界面,包括联系人的姓名和头像。
列表项
聊天列表依赖于 <ListItem />
来渲染列表中的每个特定聊天。这个组件是我们创建的一个自定义 UI 类,用来减少 ChatList
组件的复杂性:
/*** src/components/ListItem.js ***/
import React, { PropTypes } from 'react'
import { View, Image, Text, TouchableOpacity } from 'react-native'
import Icon from 'react-native-vector-icons/FontAwesome'
const ListItem = (props) => {
return (
<TouchableOpacity onPress={props.onPress}>
<View style={{height: 60, borderColor: '#ccc',
borderBottomWidth: 1,
marginLeft: 10, flexDirection: 'row'}}>
<View style={{padding: 15, paddingTop: 10}}>
<Image source={{uri: props.image}} style={{width: 40,
height: 40,
borderRadius: 20, resizeMode: 'cover'}}/>
</View>
<View style={{padding: 15, paddingTop: 20}}>
<Text style={{fontSize: 15}}>{ props.text }</Text>
</View>
<Icon name="angle-right" size={20} color="#aaa"
style={{position: 'absolute', right: 20, top: 20}}/>
</View>
</TouchableOpacity>
)
}
export default ListItem
这个组件上有很少的逻辑,因为它只接收一个名为 onPress()
的 prop,当 <ListItem />
被按下时将被调用,正如我们在这个组件的父组件中看到的那样,它将打开聊天界面以显示特定聊天中的消息列表。让我们来看看渲染特定聊天的 chat
屏幕。
聊天
为了保持我们的代码简洁和可维护,我们将使用GiftedChat
来渲染聊天中的所有消息,但是我们仍然需要做一些工作来正确渲染这个屏幕:
/*** src/screens/Chat.js ***/
import React, { PropTypes } from 'react'
import { View, Image, ActivityIndicator } from 'react-native';
import { observer, inject } from 'mobx-react/native'
import { GiftedChat } from 'react-native-gifted-chat'
@inject('chats', 'users') @observer
class Chat extends React.Component {
static navigationOptions = ({ navigation, screenProps }) => ({
title: navigation.state.params.name,
headerRight: <Image source={{uri: navigation.state.params.image}}
style={{
width: 30,
height: 30,
borderRadius: 15,
marginRight: 10,
resizeMode: 'cover'
}}/>
})
onSend(messages) {
this.props.chats.addMessages(this.chatId, this.contactId,
messages);
}
componentWillMount() {
this.contactId = this.props.navigation.state.params.contactId;
this.chatId = this.props.navigation.state.params.id;this.props.chats.selectChat(this.chatId);
}
render () {
var messages = this.props.chats.selectedChatMessages;
if(this.props.chats.downloadingChat) {
return <View><ActivityIndicator style={{marginTop: 20}}/></View>
}
return (
<GiftedChat
onSend={(messages) => this.onSend(messages)}
messages={messages ? messages.toJS().reverse() : []}
user={{
_id: this.props.users.id,
name: this.props.users.name,
avatar: this.props.users.avatar
}}
/>
)
}
}
export default Chat;
我们还需要为我们的<Chat />
组件注入一些存储。这次,我们需要users
和chats
存储,它们将作为组件内的 props 可用。该组件还期望从导航器接收两个参数:chatId
(聊天的 ID)和contactId
(用户正在聊天的人的 ID)。
当组件准备好被挂载(onComponentWillMount()
)时,我们在组件内部保存chatId
和contactId
到更方便的变量中,并在chats
存储上调用selectChat()
方法。这将触发一个请求到 Firebase 数据库,以获取所选聊天的消息,这些消息将通过chats
存储进行同步,并通过this.props.chats.selectedChatMessages
在组件中访问。MobX 还将更新一个downloadingChat
属性,以确保我们让用户知道数据正在从 Firebase 中检索。
最后,我们需要为GiftedChat
添加一个onSend()
函数,每次按下Send
按钮时,它将在chats
存储上调用addMessages()
方法,以将消息发布到 Firebase。
GiftedChat
在很大程度上帮助我们减少了渲染聊天消息列表所需的工作量。另一方面,我们需要按照GiftedChat
的要求格式化消息,并提供一个onSend()
函数,以便在需要将消息发布到我们的后端时执行。
搜索
搜索屏幕分为两部分:一个<TextInput />
用于用户搜索姓名,一个<FlatList />
用于显示输入姓名找到的联系人列表:
import React, { PropTypes } from 'react'
import { View, TextInput, Button, FlatList } from 'react-native'
import Icon from 'react-native-vector-icons/FontAwesome'
import { observer, inject } from 'mobx-react/native'
import ListItem from '../components/ListItem'
@inject('users', 'chats') @observer
class Search extends React.Component {
imgPlaceholder = 'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
2160923_960_720.png'
state = {
name: '',
foundUsers: null
}
static navigationOptions = {
tabBarLabel: 'Search',
tabBarIcon: ({ tintColor }) => (
<Icon name="search" size={30} color={tintColor}/>
)
};
onPressSearch() {
this.props.users.searchUsers(this.state.name)
.then((foundUsers) => {
this.setState({ foundUsers });
});
}
onPressUser(user) {
//open a chat with the selected user
}
render () {
return (
<View>
<View style={{padding: 20, marginTop: 20,
backgroundColor: '#eee'}}>
<View style={{backgroundColor: 'white', padding: 15,
borderRadius: 10}}>
<TextInput
style={{borderColor: 'gray', borderBottomWidth: 1,
height: 40}}
onChangeText={(name) => this.setState({name})}
value={this.state.name}
placeholder='Name of user'
/>
<Button
onPress={this.onPressSearch.bind(this)}
title='Search'
/>
</View>
</View>
{
this.state.foundUsers &&
<FlatList
data={this.state.foundUsers}
keyExtractor={(item, index) => index}
renderItem={({item}) => {
return (
<ListItem
text={item.name}
image={item.avatar || this.imgPlaceholder}
onPress={this.onPressUser.bind(this, item)}
/>
)
}}
/>
}
</View>
)
}
}
export default Search;
该组件需要注入两个存储(users
和chats
)。当用户点击搜索
按钮时,users
存储用于调用searchUsers()
方法。这个方法不会修改状态,因此我们需要提供一个回调来接收找到的用户列表,最终将该列表设置在组件的状态上。
第二个存储chats
将用于通过从onPressUser()
函数调用add()
在 Firebase 中存储打开的聊天:
onPressUser(user) {
this.props.chats.add({
id: this.props.users.id,
name: this.props.users.name,
avatar: this.props.users.avatar || this.imgPlaceholder,
notificationsToken: this.props.users.notificationsToken || ''
}, {
id: user.id,
name: user.name,
avatar: user.avatar || this.imgPlaceholder,
notificationsToken: user.notificationsToken || ''
});
this.props.navigation.navigate('Chats', {});
}
chats
存储中的add()
方法需要传递两个参数:每个用户在新打开的聊天中一个参数。这些数据将被正确存储在 Firebase 中,因此两个用户将在应用程序的聊天列表中看到聊天。添加新聊天后,我们将导航到聊天屏幕,以便用户可以查看添加是否成功。
个人资料
个人资料屏幕显示用户的头像,姓名和“注销”按钮以退出登录:
import React, { PropTypes } from 'react'
import { View, Image, Button, Text } from 'react-native'
import { observer, inject } from 'mobx-react/native'
import Icon from 'react-native-vector-icons/FontAwesome'
import notifications from '../notifications'
@inject('users') @observer
class Profile extends React.Component {
static navigationOptions = {
tabBarLabel: 'Profile',
tabBarIcon: ({ tintColor }) => (
<Icon name="user" size={30} color={tintColor}/>
),
};
imgPlaceholder =
'https://cdn.pixabay.com/photo/2017/03/21/02/00/user-
2160923_960_720.png'
onPressLogout() {
this.props.users.logout();
}
render () {
return (
<View style={{ padding: 20 }}>
{
this.props.users.name &&
<View style={{ flexDirection: 'row', alignItems: 'center'
}}>
<Image
source={{uri: this.props.users.avatar ||
this.imgPlaceholder}}
style={{width: 100, height: 100, borderRadius: 50,
margin: 20, resizeMode: 'cover'}}
/>
<Text style={{fontSize: 25}}>{this.props.users.name}
</Text>
</View>
}
<Button
onPress={this.onPressLogout.bind(this)}
title="Logout"
/>
</View>
)
}
}
export default Profile;
注销过程是通过在users
存储上调用logout()
方法来触发的。由于我们在src/main.js
文件中控制了身份验证状态,因此在注销成功时,应用程序将自动返回到登录或注册屏幕。
总结
我们涵盖了大多数现代企业应用程序的几个重要主题:用户管理,数据同步,复杂的应用程序状态和处理表单。这是一个完整的应用程序,我们设法用一个小的代码库和 MobX 和 Firebase 的帮助来修复它。
Firebase 非常有能力在生产中处理具有大量用户的应用程序,但构建我们自己的后端系统不应该是一个复杂的任务,特别是如果我们有使用 socket.io 和实时数据库的经验。
在本课程中还有一些方面缺失,比如处理安全性(完全可以在 Firebase 内完成)或为两个以上用户创建聊天室。无论如何,这些方面都超出了 React Native 的环境,因此故意被排除在外。
完成本课程后,我们应该能够在 Firebase 和 MobX 上构建任何应用程序,因为我们涵盖了这两种技术上最常用的用例。当然,还有一些更复杂的情况被排除在外,但通过对本课程中解释的基础知识有很好的理解,它们可以很容易地学会。
在下一课中,我们将构建一种非常不同类型的应用程序:用 React Native 编写的游戏。
评估
-
这个 ____ 存储负责保存所有与聊天和消息相关的数据和逻辑,并在用户登录时帮助聊天存储初始化。
-
推送通知
-
列表
-
聊天
-
搜索
-
以下哪个是用于计算存储在精灵中的每个精灵的新位置的主要功能。
-
getRockProps()
-
reducer()
-
action()
-
移动精灵()
-
判断以下陈述是真还是假:Firebase 允许移动开发人员使用云托管的 NoSQL 数据库实时存储和同步用户和设备之间的数据。
-
背景图像不包含在任何自定义组件中,而是在 ______ 中。这是因为作为静态元素,它不需要任何特殊的逻辑。
-
<GamseContainer />
-
<Image />
-
<TouchableWithoutFeedback />
-
<TouchableOpacity />
-
可用的 Redux 动作有哪些?
第四章:项目 4 - 游戏
应用商店上大多数成功的应用都是游戏。它们被证明非常受欢迎,因为移动用户倾向于在通勤、候诊室、旅行或者在家休息时玩各种类型的游戏。事实上,移动用户更倾向于为游戏付费,而不是市场上的其他类型的应用,因为大多数时候它们的感知价值更高。
现代游戏通常是使用强大的游戏引擎构建的,比如 Unity 或 Unreal,因为它们提供了一系列工具和框架来处理精灵、动画或物理效果。但事实是,由于其原生能力,React Native 也可以构建出优秀的游戏。此外,React Native 已经将许多网页和移动应用程序员引入游戏开发,因为它为他们提供了熟悉和直观的界面。当构建游戏时,非游戏开发人员可能需要理解一些游戏开发的概念,以充分利用库的优势。像精灵、滴答声或碰撞这样的概念是非游戏开发人员在构建游戏之前可能需要克服的小障碍。
游戏将为 iOS 和 Android 构建,并将使用有限数量的外部库。选择了 Redux 作为状态管理库,以帮助计算每一帧上每个精灵的位置。
我们将使用一些自定义精灵,并添加声音效果以提醒每次得分增加。构建游戏时的主要挑战之一是确保精灵能够响应式地渲染,以便不同设备以相同的比例显示游戏,从而在不同的屏幕尺寸上提供相同的游戏体验。
这款游戏将设计为仅支持竖屏模式。
概述
我们在这节课中要构建的游戏具有简单的机制:
-
目标是帮助一只鹦鹉在洞穴中飞过岩石
-
点击屏幕会使鹦鹉飞得更高
-
重力会把鹦鹉拉向地面
-
鹦鹉与岩石或地面之间的任何碰撞都将导致游戏结束
-
每次鹦鹉飞过一组岩石时,得分将增加
这种类型的游戏非常适合使用 React Native 构建,因为它实际上不需要复杂的动画或物理能力。我们只需要确保在正确的时间移动屏幕上的每个精灵(图形组件),以创建连续动画的感觉。
让我们来看一下我们游戏的初始屏幕:
这个屏幕展示了游戏的标志和关于如何启动游戏的说明。在这种情况下,简单的轻触将启动游戏机制,导致鹦鹉在每次轻触时向前飞行。
玩家必须帮助我们的鹦鹉飞过岩石。每次通过一组岩石,玩家将获得一分。
为了增加难度,岩石的高度将会变化,迫使鹦鹉飞得更高或更低以通过岩石。如果鹦鹉与岩石或地面发生碰撞,游戏将停止,并向用户呈现最终得分:
在这一点上,用户可以通过再次在屏幕上轻触来重新开始游戏。
为了使游戏更加美观和易玩,可以在屏幕的任何位置进行轻触,这将导致不同的效果,具体取决于用户所在的屏幕:
-
在初始屏幕上轻触将启动游戏
-
游戏中的轻触将导致鹦鹉飞得更高
-
在游戏结束屏幕上轻触将重新开始游戏并重置得分
正如可以观察到的那样,这将是一个非常简单的游戏,但正因为如此,它很容易扩展并且很有趣。在构建这种类型的应用程序时,一个重要的方面是拥有一套精美的图形。为此,我们将从多个游戏资产市场之一下载我们的资产,这些市场可以在线找到(大多数游戏资产需要支付一小笔费用,尽管偶尔也可以找到免费资产)。
这个游戏的技术挑战更多地在于精灵如何随时间移动,而不是在于复杂的状态维护。尽管如此,我们将使用 Redux 来保持和更新应用程序的状态,因为它是一个高性能且广为人知的解决方案。除了重新审视 Redux,我们还将在本课程中复习以下主题:
-
处理动画精灵
-
播放音效
-
检测碰撞的精灵
-
不同屏幕分辨率下的绝对定位
精灵
精灵是游戏中使用的图形,通常分组成一个或多个图像。许多游戏引擎包括工具来方便地拆分和管理这些图形,但在 React Native 中并非如此。由于它是设计用来处理不同类型的应用程序的,有几个库支持 React Native 处理精灵,但我们的游戏将足够简单,不需要使用这些库,所以我们将把一个图形存储在每个图像中,并将它们分别加载到应用程序中。
在开始构建游戏之前,让我们熟悉一下我们将加载的图形,因为它们将是整个应用程序的构建模块。
数字
我们将使用精灵来显示游戏中的得分,而不是使用<Text/>
组件,以获得更吸引人的外观。这些是我们将用来表示用户得分的图像:
如前所述,所有这些图形将存储在单独的图像中(命名为0.png
到9.png
),因为 React Native 缺乏精灵拆分功能。
背景
我们需要一个大背景来确保它适合所有屏幕尺寸。在本课程中,我们将使用这个精灵作为静态图形,尽管它可以很容易地进行动画处理,以创建一个漂亮的视差效果:
从这个背景中,我们将取一块地面来进行动画。
地面
地面将循环动画,以创建恒定的速度感。这个图像的大小需要大于我们想要支持的最大屏幕分辨率,因为它应该从屏幕的一侧移动到另一侧。在任何时候,将显示两个地面图像,一个接一个地确保在动画期间至少显示一个图像在屏幕上。
岩石
移动的岩石是我们的鹦鹉需要通过的障碍物。顶部和底部各有一个,并且两者将以与地面相同的速度进行动画处理。它们的高度将因每对岩石而异,但始终保持它们之间的间隙大小相同:
在我们的images
文件夹中,我们将有rock-up.png
和rock-down.png
代表每个精灵。
鹦鹉
我们将使用两张不同的图像来表示我们的主角,这样我们就可以创建一个动画,显示用户何时点击了屏幕:
第一张图将在鹦鹉向下移动时显示:
第二张图片将在用户按下屏幕使鹦鹉上升时显示。这些图片将被命名为parrot1.png
和parrot2.png
。
主屏幕
对于主屏幕,我们将显示两张图片:一个标志和一些关于如何开始游戏的说明。让我们来看看它们:
开始游戏的说明只是指出轻触将开始游戏:
游戏结束画面
当鹦鹉撞到岩石或地面时,游戏将结束。然后,是时候显示游戏结束标志和重置按钮,以重新开始游戏:
虽然整个屏幕都可以触摸以重新开始游戏,但我们将包括一个按钮,让用户知道轻触将导致游戏重新开始:
这张图片将被存储为reset.png
。
这是我们游戏中将拥有的全部图片列表:
现在,我们知道了我们游戏中将使用的图片列表。让我们来看看整个文件夹结构。
设置文件夹结构
让我们使用 React Native 的 CLI 初始化一个 React Native 项目。该项目将命名为birdGame
,并可用于 iOS 和 Android 设备:
react-native init --version="0.46.4" birdGame
由于这是一个简单的游戏,我们只需要一个屏幕,我们将在其中定位所有我们的精灵,根据游戏状态移动、显示或隐藏它们,这将由 Redux 管理。因此,我们的文件夹结构将符合标准的 Redux 应用程序:
actions
文件夹将只包含一个文件,因为在这个游戏中只会发生三个动作(start
,tick
和bounce
)。还有一个sounds
文件夹,用于存储每次鹦鹉通过一对岩石时播放的音效:
对于每个精灵,我们将创建一个组件,以便可以轻松地移动、显示或隐藏它:
再次,只需要一个 reducer 来处理我们所有的动作。我们还将创建两个辅助文件:
-
constants.js
:这是我们将存储用于分割播放游戏设备屏幕高度和宽度的辅助变量的地方。 -
sprites.js
:这里存储了所有将计算精灵在每一帧中应该定位的函数,以创建所需的动画。
main.js
将作为 iOS 和 Android 的入口点,并负责初始化 Redux:
其余文件由 React Native 的 CLI 生成。
现在让我们来审查一下package.json
文件,我们需要在项目中设置依赖项:
/*** package.json ***/
{
“name": “birdGame",
“version": “0.0.1",
“private": true,
“scripts": {
“start": “node node_modules/react-native/local-cli/cli.js start",
“test": “jest"
},
“dependencies": {
“react": “16.0.0-alpha.12",
“react-native": “0.46.4",
“react-native-sound": “⁰.10.3",
“react-redux": “⁴.4.5",
“redux": “³.5.2"
},
“devDependencies": {
“babel-jest": “20.0.3",
“babel-preset-react-native": “2.1.0",
“jest": “20.0.4",
“react-test-renderer": “16.0.0-alpha.12"
},
“jest": {
“preset": “react-native"
}
}
除了 Redux 库,我们还将导入react-native-sound
,它将负责在我们的游戏中播放任何声音。
运行npm install
后,我们的应用程序将准备好开始编码。与以前的应用程序一样,我们的消息应用的入口点将在index.ios.js
和index.android.js
中是相同的代码,但两者都将将初始化逻辑委托给src/main.js
:
/*** index.ios.js and index.android.js ***/
import { AppRegistry } from 'react-native';
import App from './src/main';
AppRegistry.registerComponent('birdGame', () => App);
src/main.js
负责初始化 Redux,并将GameContainer
设置为我们应用程序的根组件:
/*** src/main.js ***/
import React from “react";
import { createStore, combineReducers } from “redux";
import { Provider } from “react-redux";
import gameReducer from “./reducers/game";
import GameContainer from “./components/GameContainer";
let store = createStore(combineReducers({ gameReducer }));
export default class App extends React.Component {
render() {
return (
<Provider store={store}>
<GameContainer />
</Provider>
);
}
}
我们将GameContainer
用作应用程序中组件树的根。作为常规的 Redux 应用程序,<Provider />
组件负责向所有需要读取或修改应用程序状态的组件提供存储。
GameContainer
GameContainer
负责在用户点击屏幕后启动游戏。它将使用requestAnimationFrame()
来实现这一点——这是 React Native 中实现的自定义定时器之一。
requestAnimationFrame()
类似于setTimeout()
,但前者会在所有帧刷新后触发,而后者会尽快触发(在 iPhone 5S 上每秒超过 1000 次);因此,requestAnimationFrame()
更适合处理动画游戏,因为它只处理帧。
与大多数动画游戏一样,我们需要创建一个循环来通过计算每个元素在每一帧上的下一个位置来为屏幕中的精灵创建动画。这个循环将由GameContainer
内部的名为nextFrame()
的函数创建:
nextFrame() {
if (this.props.gameOver) return;
var elapsedTime = new Date() - this.time;
this.time = new Date();
this.props.tick(elapsedTime);
this.animationFrameId =
requestAnimationFrame(this.nextFrame.bind(this));
}
如果属性gameOver
设置为true
,则此函数将被中止。否则,它将触发tick()
动作(根据经过的时间计算精灵在下一帧上应该如何移动),最后通过requestAnimationFrame()
调用自身。这将保持游戏中的循环以动画移动精灵。
当然,这个nextFrame()
应该在第一次开始时被调用,所以我们还将在GameContainer
内创建一个start()
函数来启动游戏:
start() {
cancelAnimationFrame(this.animationFrameId);
this.props.start();
this.props.bounce();
this.time = new Date();
this.setState({ gameOver: false });
this.animationFrameId =
requestAnimationFrame(this.nextFrame.bind(this));
}
start
函数通过调用cancelAnimationFrame()
确保没有启动任何动画。这将防止用户重置游戏时执行任何双重动画。
然后,这些函数触发start()
动作,它只是在存储中设置一个标志,以通知游戏已经开始。
我们希望通过将鹦鹉向上移动来开始游戏,这样用户就有时间做出反应。为此,我们还调用bounce()
动作。
最后,我们通过将已知的nextFrame()
函数作为requestAnimationFrame()
的回调来启动动画循环。
让我们也来审查一下我们将用于这个容器的render()
方法:
render() {
const {
rockUp,
rockDown,
ground,
ground2,
parrot,
isStarted,
gameOver,
bounce,
score
} = this.props;
return (
<TouchableOpacity
onPress={
!isStarted || gameOver ? this.start.bind(this) :
bounce.bind(this)
}
style={styles.screen}
activeOpacity={1}
>
<Image
source={require(“../../images/bg.png")}
style={[styles.screen, styles.image]}
/>
<RockUp
x={rockUp.position.x * W} //W is a responsiveness factor
//explained in the 'constants' section
y={rockUp.position.y}
height={rockUp.size.height}
width={rockUp.size.width}
/>
<Ground
x={ground.position.x * W}
y={ground.position.y}
height={ground.size.height}
width={ground.size.width}
/>
<Ground
x={ground2.position.x * W}
y={ground2.position.y}
height={ground2.size.height}
width={ground2.size.width}
/>
<RockDown
x={rockDown.position.x * W}
y={rockDown.position.y * H} //H is a responsiveness factor
//explained in the 'constants'
//section
height={rockDown.size.height}
width={rockDown.size.width}
/>
<Parrot
x={parrot.position.x * W}
y={parrot.position.y * H}
height={parrot.size.height}
width={parrot.size.width}
/>
<Score score={score} />
{!isStarted && <Start />}
{gameOver && <GameOver />}
{gameOver && isStarted && <StartAgain />}
</TouchableOpacity>
);
}
可能会很长,但实际上,它只是简单地将屏幕上所有可见元素进行定位,同时将它们包裹在<TouchableOpacity />
组件中,以便捕捉用户在屏幕的任何部分点击。这个<TouchableOpacity />
组件实际上在用户点击屏幕时不会向用户发送任何反馈(我们通过传递activeOpacity={1}
作为属性来禁用它),因为这个反馈已经由鹦鹉在每次点击时反弹来提供。
注意
我们本可以使用 React Native 的<TouchableWithoutFeedback />
来处理这个问题,但它有一些限制,这可能会影响我们的性能。
提供的onPress
属性只是定义了用户在屏幕上点击时应用程序应该执行的操作:
-
如果游戏处于活动状态,它将使鹦鹉精灵反弹
-
如果用户在游戏结束画面上,它将通过调用
start()
动作重新启动游戏
render()
方法中的所有其他子元素都是我们游戏中的图形元素,为每个元素指定它们的位置和大小。还有几点需要注意:
-
有两个
<Ground />
组件,因为我们需要在x轴上连续地对其进行动画处理。它们将水平排列在一起,以便一起进行动画处理,因此当第一个<Ground />
组件的末端显示在屏幕上时,第二个的开头将跟随其后,从而创建连续感。 -
背景不包含在任何自定义组件中,而是包含在
<Image />
中。这是因为作为静态元素,它不需要任何特殊的逻辑。 -
一些位置被因子变量(
W
和H
)相乘。我们将在常量部分更深入地研究这些变量。在这一点上,我们只需要知道它们是帮助绝对定位元素的变量,考虑到所有屏幕尺寸。 -
现在让我们将所有这些函数放在一起来构建我们的
<GameContainer />
:
/*** src/components/GameContainer.js ***/
import React, { Component } from “react";
import { connect } from “react-redux";
import { bindActionCreators } from “redux";
import { TouchableOpacity, Image, StyleSheet } from “react-native";
import * as Actions from “../actions";
import { W, H } from “../constants";
import Parrot from “./Parrot";
import Ground from “./Ground";
import RockUp from “./RockUp";
import RockDown from “./RockDown";
import Score from “./Score";
import Start from “./Start";
import StartAgain from “./StartAgain";
import GameOver from “./GameOver";
class Game extends Component {
constructor() {
super();
this.animationFrameId = null;
this.time = new Date();
}
nextFrame() {
...
}
start() {
...
}
componentWillUpdate(nextProps, nextState) {
if (nextProps.gameOver) {
this.setState({ gameOver: true });
cancelAnimationFrame(this.animationFrameId);
}
}
shouldComponentUpdate(nextProps, nextState) {
return !nextState.gameOver;
}
render() {
...
}
}
const styles = StyleSheet.create({
screen: {
flex: 1,
alignSelf: “stretch",
width: null
},
image: {
resizeMode: “cover"
}
});
function mapStateToProps(state) {
const sprites = state.gameReducer.sprites;
return {
parrot: sprites[0],
rockUp: sprites[1],
rockDown: sprites[2],
gap: sprites[3],
ground: sprites[4],
ground2: sprites[5],
score: state.gameReducer.score,
gameOver: state.gameReducer.gameOver,
isStarted: state.gameReducer.isStarted
};
}
function mapStateActionsToProps(dispatch) {
return bindActionCreators(Actions, dispatch);
}
export default connect(mapStateToProps, mapStateActionsToProps)(Game);
我们在这个组件中添加了另外三个 ES6 和 React 生命周期方法:
-
super()
: 构造函数将保存一个名为animationFrameId
的属性,以捕获nextFrame
函数将运行的动画帧的 ID,还将保存另一个名为time
的属性,该属性将存储游戏初始化的确切时间。time
属性将被tick()
函数用于计算精灵应该移动多少。 -
componentWillUpdate()
: 每当传递新的 props(游戏中精灵的位置和大小)时,将调用此函数。它将检测游戏是否因碰撞而必须停止,因此游戏结束屏幕将被显示。 -
shouldComponentUpdate()
: 这执行另一个检查,以避免在游戏结束时重新渲染游戏容器。
其余的函数与 Redux 相关。它们负责通过注入操作和属性将组件连接到存储中:
-
mapStateToProps()
: 这会获取存储中所有精灵的数据,并将它们注入组件作为 props。精灵将被存储在一个数组中,因此它们将通过索引访问。除此之外,Score
,一个标志表示当前游戏是否结束,以及一个标志表示游戏是否正在进行也将从状态中检索并注入到组件中。 -
mapStateActionsToProps()
: 这将把三个可用操作(tick
,bounce
和start
)注入到组件中,以便它们可以被使用。
注意
通过索引访问精灵数据并不是一种推荐的做法,因为如果精灵的数量增加,索引可能会发生变化,但出于简单起见,我们将在此应用中使用它。
操作
正如我们之前提到的,只有三个 Redux 操作将可用:
-
tick()
: 计算屏幕上精灵的下一个位置 -
bounce()
: 让鹦鹉向上飞 -
start()
: 初始化游戏变量
这意味着我们的src/actions/index.js
文件应该非常简单:
/*** src/actions/index.js ***/
export function start() {
return { type: “START" };
}
export function tick(elapsedTime) {
return { type: “TICK", elapsedTime };
}
export function bounce() {
return { type: “BOUNCE" };
}
只有tick()
操作需要传递一个有效负载:自上一帧以来经过的时间。
Reducer
由于我们有非常有限的行动,我们的减速器也会相当简单,并且会将大部分功能委托给src/sprites.js
文件中的精灵助手函数:
/*** src/reducers/index.js ***/
import {
sprites,
moveSprites,
checkForCollision,
getUpdatedScore,
bounceParrot
} from “../sprites";
const initialState = {
score: 0,
gameOver: false,
isStarted: false,
sprites
};
export default (state = initialState, action) => {
switch (action.type) {
case “TICK":
return {
...state,
sprites: moveSprites(state.sprites, action.elapsedTime),
gameOver: checkForCollision(state.sprites[0],
state.sprites.slice(1)),
score: getUpdatedScore(state.sprites, state.score)
};
case “BOUNCE":
return {
...state,
sprites: bounceParrot(state.sprites)
};
case “START":
return {
...initialState,
isStarted: true
};
default:
return state;
}
};
start()
函数只需要将isStarted
标志设置为true
,因为初始状态默认情况下将其设置为false
。我们将在每次游戏结束时重用此初始状态。
bounce()
将使用精灵模块中的bounceParrot()
函数来为主角设置新的方向。
最重要的变化将发生在触发tick()
函数时,因为它需要计算所有移动元素的位置(通过moveSprites()
函数),检测鹦鹉是否与任何静态元素发生碰撞(通过checkForCollision()
函数),并在存储中更新得分(通过getUpdatedScore()
函数)。
正如我们所看到的,大部分游戏功能都委托给了精灵模块内的辅助函数,因此让我们更深入地看一下src/sprites.js
文件。
精灵模块
精灵模块的结构由精灵数组和几个导出函数组成:
/*** src/sprites.js ***/
import sound from “react-native-sound";
const coinSound = new sound(“coin.wav", sound.MAIN_BUNDLE);
let heightOfRockUp = 25;
let heightOfRockDown = 25;
let heightOfGap = 30;
let heightOfGround = 20;
export const sprites = [
...
];
function prepareNewRockSizes() {
...
}
function getRockProps(type) {
...
}
export function moveSprites(sprites, elapsedTime = 1000 / 60) {
...
}
export function bounceParrot(sprites) {
...
}
function hasCollided(mainSprite, sprite) {
...
}
export function checkForCollision(mainSprite, sprites) {
...
}
export function getUpdatedScore(sprites, score) {
...
}
该模块首先通过加载我们将在鹦鹉通过一组岩石时播放的音效来开始,以向用户反馈其得分增加。
然后,我们为几个精灵定义了一些高度:
-
heightOfRockUp
:这是将出现在屏幕上部的岩石的高度。 -
heightOfRockDown
:这是岩石的高度,将显示在屏幕的下部。 -
heightOfGap
:我们将在上部和下部岩石之间创建一个不可见的视图,以侦测鹦鹉何时通过每组岩石,以便更新得分。这是间隙的高度。 -
heightOfGround
:这是地面高度的静态值。
此模块中的每个其他项目都在移动或定位屏幕上的精灵。
精灵数组
这是负责在给定时间存储所有精灵位置和大小的数组。为什么我们使用数组来存储我们的精灵,而不是哈希映射(对象)?主要是为了可扩展性;虽然哈希映射会使我们的代码更易读,但如果我们想要添加新的现有类型的精灵(就像在这个应用程序中的ground
精灵一样),我们需要为它们每个使用人工键,尽管它们是相同类型的。使用精灵数组是游戏开发中的一种常见模式,它允许将实现与精灵列表解耦。
每当我们想要移动一个精灵,我们将更新它在这个数组中的位置:
export const sprites = [
{
type: “parrot",
position: { x: 50, y: 55 },
velocity: { x: 0, y: 0 },
size: { width: 10, height: 8 }
},
{
type: “rockUp",
position: { x: 110, y: 0 },
velocity: { x: -1, y: 0 },
size: { width: 15, height: heightOfRockUp }
},
{
type: “rockDown",
position: { x: 110, y: heightOfRockUp + 30 },
velocity: { x: -1, y: 0 },
size: { width: 15, height: heightOfRockDown }
},
{
type: “gap",
position: { x: 110, y: heightOfRockUp },
velocity: { x: -1, y: 0 },
size: { width: 15, height: 30 }
},
{
type: “ground",
position: { x: 0, y: 80 },
velocity: { x: -1, y: 0 },
size: { width: 100, height: heightOfGround }
},
{
type: “ground",
position: { x: 100, y: 80 },
velocity: { x: -1, y: 0 },
size: { width: 100, height: heightOfGround }
}
];
数组将存储游戏中所有移动精灵的定位和大小的初始值。
prepareNewRockSizes()
这个函数随机计算下一个上部和下部岩石的大小以及它们之间间隙的高度:
function prepareNewRockSizes() {
heightOfRockUp = 10 + Math.floor(Math.random() * 40);
heightOfRockDown = 50 - heightOfRockUp;
heightOfGap = 30;
}
重要的是要注意,这个函数只计算新一组岩石的高度,但不创建它们。这只是一个准备步骤。
getRockProps()
格式化岩石(或gap
)的position
和size
属性的辅助函数:
function getRockProps(type) {
switch (type) {
case “rockUp":
return { y: 0, height: heightOfRockUp };
case “rockDown":
return { y: heightOfRockUp + heightOfGap,
height: heightOfRockDown };
case “gap":
return { y: heightOfRockUp, height: heightOfGap };
}
}
移动精灵()
这是主要函数,因为它计算了存储在精灵数组中的每个精灵的新位置。游戏开发依赖于物理学来计算每帧中每个精灵的位置。
例如,如果我们想要将一个对象移动到屏幕的右侧,我们将需要更新它的x
位置一定数量的像素。我们为对象的x
属性添加的像素越多,下一帧它就移动得越快(sprite.x = sprite.x + 5;
比sprite.x = sprite.x + 1;
移动得快五倍)。
正如我们在下面的例子中所看到的,我们计算每个精灵的新位置的方式基于三个因素:精灵的当前位置,自上一帧以来经过的时间(elapsedTime
),以及精灵的重力/速度(即 sprite.velocity.y + elapsedTime * gravity
)。
此外,我们将使用辅助函数getRockProps
来获取岩石的新大小和位置。让我们看看moveSprites
函数是什么样子的:
export function moveSprites(sprites, elapsedTime = 1000 / 60) {
const gravity = 0.0001;
let newSprites = [];
sprites.forEach(sprite => {
if (sprite.type === “parrot") {
var newParrot = {
...sprite,
position: {
x: sprite.position.x,
y:
sprite.position.y +
sprite.velocity.y * elapsedTime +
0.5 * gravity * elapsedTime * elapsedTime
},
velocity: {
x: sprite.velocity.x,
y: sprite.velocity.y + elapsedTime * gravity
}
};
newSprites.push(newParrot);
} else if (
sprite.type === “rockUp" ||
sprite.type === “rockDown" ||
sprite.type === “gap"
) {
let rockPosition,
rockSize = sprite.size;
if (sprite.position.x > 0 - sprite.size.width) {
rockPosition = {
x: sprite.position.x + sprite.velocity.x,
y: sprite.position.y
};
} else {
rockPosition = { x: 100, y: getRockProps(sprite.type).y };
rockSize = { width: 15,
height: getRockProps(sprite.type).height };
}
var newRock = {
...sprite,
position: rockPosition,
size: rockSize
};
newSprites.push(newRock);
} else if (sprite.type === “ground") {
let groundPosition;
if (sprite.position.x > -97) {
groundPosition = { x: sprite.position.x + sprite.velocity.x,
y: 80 };
} else {
groundPosition = { x: 100, y: 80 };
}
var newGround = { ...sprite, position: groundPosition };
newSprites.push(newGround);
}
});
return newSprites;
}
计算精灵的下一个位置,大多数情况下是基本的加法(或减法)。例如,让我们看看鹦鹉应该如何移动:
var newParrot = {
...sprite,
position: {
x: sprite.position.x,
y:
sprite.position.y +
sprite.velocity.y * elapsedTime +
0.5 * gravity * elapsedTime * elapsedTime
},
velocity: {
x: sprite.velocity.x,
y: sprite.velocity.y + elapsedTime * gravity
}
}
鹦鹉只会在垂直方向移动,其速度基于重力,因此x
属性对它来说始终保持不变,而y
属性将根据函数sprite.position.y +
sprite.velocity.y * elapsedTime +
0.5 * gravity * elapsedTime * elapsedTime
进行改变,这个函数总结起来就是将经过的时间和重力分别加到不同的因素中。
岩石应该如何移动的计算要复杂一些,因为我们需要考虑每次岩石从屏幕上消失的情况(if (sprite.position.x > 0 - sprite.size.width)
)。当它们被超过时,我们需要用不同的高度重新创建它们(rockPosition = { x: 100, y: getRockProps(sprite.type).y }
)。
我们对地面也有相同的行为,需要在它完全离开屏幕时重新创建它(if (sprite.position.x > -97)
)。
弹跳鹦鹉()
这个函数的唯一任务是改变主角的速度,这样它就会向上飞,逆转重力的影响。这个函数将在用户在游戏开始时点击屏幕时调用:
export function bounceParrot(sprites) {
var newSprites = [];
var sprite = sprites[0];
var newParrot = { ...sprite, velocity: { x: sprite.velocity.x,
y: -0.05 } };
newSprites.push(newParrot);
return newSprites.concat(sprites.slice(1));
}
这是一个简单的操作,我们从sprites
数组中获取鹦鹉的精灵数据;我们将其在y轴上的速度更改为负值,以使鹦鹉向上移动。
checkForCollision()
checkForCollision()
负责识别任何刚性精灵是否与鹦鹉精灵发生了碰撞,以便游戏可以停止。它将使用hasCollided()
作为一个支持函数,对每个特定的精灵执行所需的计算。
function hasCollided(mainSprite, sprite) {
/***
*** we will check if 'mainSprite' has entered in the
*** space occupied by 'sprite' by comparing their
*** position, width and height
***/
var mainX = mainSprite.position.x;
var mainY = mainSprite.position.y;
var mainWidth = mainSprite.size.width;
var mainHeight = mainSprite.size.height;
var spriteX = sprite.position.x;
var spriteY = sprite.position.y;
var spriteWidth = sprite.size.width;
var spriteHeight = sprite.size.height;
/***
*** this if statement checks if any border of mainSprite
*** sits within the area covered by sprite
***/
if (
mainX < spriteX + spriteWidth &&
mainX + mainWidth > spriteX &&
mainY < spriteY + spriteHeight &&
mainHeight + mainY > spriteY
) {
return true;
}
}
export function checkForCollision(mainSprite, sprites) {
/***
*** loop through all sprites in the sprites array
*** checking, for each of them, if there is a
*** collision with the mainSprite (parrot)
***/
return sprites.filter(sprite => sprite.type !== “gap").find(sprite => {
return hasCollided(mainSprite, sprite);
});
}
为了简单起见,我们假设所有精灵都是矩形的形状(尽管岩石朝末端变得更薄),因为如果考虑不同的形状,计算会更加复杂。
总之,checkForCollision()
只是循环遍历sprites
数组,以找到任何发生碰撞的精灵,hasCollided()
根据精灵的大小和位置检查碰撞。在一个if
语句中,我们比较了精灵和鹦鹉精灵的边界,以查看它们是否占据了屏幕相同的区域。
getUpdatedScore()
精灵模块中的最后一个函数将检查分数是否需要根据鹦鹉位置相对于间隙位置(上下岩石之间的间隙也被视为一个精灵)进行更新:
export function getUpdatedScore(sprites, score) {
var parrot = sprites[0];
var gap = sprites[3];
var parrotXPostion = parrot.position.x;
var gapXPosition = gap.position.x;
var gapWidth = gap.size.width;
if (parrotXPostion === gapXPosition + gapWidth) {
coinSound.play();
score++;
prepareNewRockSizes();
}
return score;
}
一个if
语句检查了鹦鹉在x轴上的位置是否超过了间隙(gapXPosition + gapWidth
)。当这种情况发生时,我们通过调用其play()
方法来播放我们在模块头部创建的声音(const coinSound = new sound(“coin.wav", sound.MAIN_BUNDLE);
)。此外,我们将增加score
变量,并准备一个新的岩石组在当前的岩石离开屏幕时渲染。
常量
我们已经看到了变量W
和H
。它们代表了屏幕的一部分,如果我们把它分成 100 部分。让我们看一下constants.js
文件,以更好地理解这一点:
/*** src/constants.js ***/
import { Dimensions } from “react-native";
var { width, height } = Dimensions.get(“window");
export const W = width / 100;
export const H = height / 100;
W
可以通过将设备屏幕的总宽度除以100
单位来计算(因为百分比在定位我们的精灵时更容易推理)。H
也是如此;它可以通过将总高度除以100
来计算。使用这两个常量,我们可以相对于屏幕的大小来定位和调整我们的精灵的大小,因此所有屏幕尺寸将显示相同的位置和大小比例。
这些常量将用于所有需要响应能力的视觉组件,因此它们将根据屏幕大小的不同显示和移动。这种技术将确保即使在小屏幕上,游戏也是可玩的,因为精灵将相应地调整大小。
现在让我们继续移动到将显示在<GameContainer />
内的组件。
鹦鹉
主角将由这个组件表示,它将由<GameContainer />
传递的Y
位置属性驱动的两个不同的图像组成(翅膀上扬和下垂的相同鹦鹉):
/*** src/components/parrot.js ***/
import React from “react";
import { Image } from “react-native";
import { W, H } from “../constants";
export default class Parrot extends React.Component {
constructor() {
super();
this.state = { wings: “down" };
}
componentWillUpdate(nextProps, nextState) {
if (this.props.y < nextProps.y) {
this.setState({ wings: “up" });
} else if (this.props.y > nextProps.y) {
this.setState({ wings: “down" });
}
}
render() {
let parrotImage;
if (this.state.wings === “up") {
parrotImage = require(“../../images/parrot1.png");
} else {
parrotImage = require(“../../images/parrot2.png");
}
return (
<Image
source={parrotImage}
style={{
position: “absolute",
resizeMode: “contain",
left: this.props.x,
top: this.props.y,
width: 12 * W,
height: 12 * W
}}
/>
);
}
}
我们使用一个名为wings
的状态变量来选择鹦鹉将会是哪个形象--当它向上飞时,会显示翅膀下垂的形象,而向下飞时会显示翅膀上扬的形象。这将根据鸟在y轴上的位置来计算,该位置作为属性从容器传递过来:
-
如果
Y
位置低于先前的Y
位置,意味着鸟正在下降,因此翅膀应该上扬 -
如果
Y
位置高于先前的Y
位置,意味着鸟正在上升,因此翅膀应该下垂
鹦鹉的大小固定为12 * W
,对于height
和width
都是如此,因为精灵是一个正方形,我们希望它相对于每个屏幕设备的宽度进行调整。
RockUp 和 RockDown
岩石的精灵上没有逻辑,基本上是由父组件定位和调整大小的 <Image />
组件。这是 <RockUp />
的代码:
/*** src/components/RockUp.js ***/
import React, { Component } from “react";
import { Image } from “react-native";
import { W, H } from “../constants";
export default class RockUp extends Component {
render() {
return (
<Image
resizeMode="stretch"
source={require(“../../images/rock-down.png")}
style={{
position: “absolute",
left: this.props.x,
top: this.props.y,
width: this.props.width * W,
height: this.props.height * H
}}
/>
);
}
}
高度和宽度将通过以下公式计算:this.props.width * W
和 this.props.height * H
。这将使岩石相对于设备屏幕和提供的高度和宽度进行调整。
<RockDown />
的代码非常相似:
/*** src/components/RockDown.js ***/
import React, { Component } from “react";
import { Image } from “react-native";
import { W, H } from “../constants";
export default class RockDown extends Component {
render() {
return (
<Image
resizeMode="stretch"
source={require(“../../images/rock-up.png")}
style={{
position: “absolute",
left: this.props.x,
top: this.props.y,
width: this.props.width * W,
height: this.props.height * H
}}
/>
);
}
}
地面
构建地面组件与岩石精灵类似。在正确的位置和大小渲染图像将足以满足此组件的需求:
/*** src/components/Ground.js ***/
import React, { Component } from “react";
import { Image } from “react-native";
import { W, H } from “../constants";
export default class Ground extends Component {
render() {
return (
<Image
resizeMode="stretch"
source={require(“../../images/ground.png")}
style={{
position: “absolute",
left: this.props.x,
top: this.props.y * H,
width: this.props.width * W,
height: this.props.height * H
}}
/>
);
}
}
在这种情况下,我们将使用 H
来相对定位地面图像。
得分
我们决定使用数字图像来渲染分数,因此我们需要加载它们并根据用户的分数选择适当的数字:
/*** src/components/Score.js ***/
import React, { Component } from “react";
import { View, Image } from “react-native";
import { W, H } from “../constants";
export default class Score extends Component {
getSource(num) {
switch (num) {
case “0":
return require(“../../images/0.png");
case “1":
return require(“../../images/1.png");
case “2":
return require(“../../images/2.png");
case “3":
return require(“../../images/3.png");
case “4":
return require(“../../images/4.png");
case “5":
return require(“../../images/5.png");
case “6":
return require(“../../images/6.png");
case “7":
return require(“../../images/7.png");
case “8":
return require(“../../images/8.png");
case “9":
return require(“../../images/9.png");
default:
return require(“../../images/0.png");
}
}
render() {
var scoreString = this.props.score.toString();
var scoreArray = [];
for (var index = 0; index < scoreString.length; index++) {
scoreArray.push(scoreString[index]);
}
return (
<View
style={{
position: “absolute",
left: 47 * W,
top: 10 * H,
flexDirection: “row"
}}
>
{scoreArray.map(
function(item, i) {
return (
<Image
style={{ width: 10 * W }}
key={i}
resizeMode="contain"
source={this.getSource(item)}
/>
);
}.bind(this)
)}
</View>
);
}
}
我们在 render
方法中进行以下操作:
-
将分数转换为字符串
-
将字符串转换为数字列表
-
使用支持的
getSource()
函数将数字列表转换为图像列表
React Native <Image />
的一个限制是其源不能作为变量被引入。因此,我们使用 getSource()
方法来检索源的小技巧,该方法实际上获取所有可能的图像,并通过 switch
/case
子句返回正确的图像。
开始
开始屏幕包括两个图像:
-
一个标志
-
一个解释如何启动游戏的开始按钮(在屏幕上的任何位置轻触)
/*** src/components/Start.js ***/
import React, { Component } from “react";
import { Text, View, StyleSheet, Image } from “react-native";
import { W, H } from “../constants";
export default class Start extends Component {
render() {
return (
<View style={{ position: “absolute", left: 20 * W, top: 3 * H }}>
<Image
resizeMode="contain"
source={require(“../../images/logo.png")}
style={{ width: 60 * W }}
/>
<Image
resizeMode="contain"
style={{ marginTop: 15, width: 60 * W }}
source={require(“../../images/tap.png")}
/>
</View>
);
}
}
我们再次使用我们的 H
和 W
常量,以确保元素在每个设备屏幕上都定位正确。
游戏结束
当鹦鹉与岩石或地面发生碰撞时,我们应该显示游戏结束画面。这个画面只包含两个图像:
-
游戏结束标志
-
重新开始游戏的按钮
让我们首先看一下游戏结束标志:
/*** src/components/GameOver.js ***/
import React, { Component } from “react";
import { Image } from “react-native";
import { W, H } from “../constants";
export default class GameOver extends Component {
render() {
return (
<Image
style={{
position: “absolute",
left: 15 * W,
top: 30 * H
}}
resizeMode="stretch"
source={require(“../../images/game-over.png")}
/>
);
}
}
现在,让我们继续重置游戏按钮。
重新开始
实际上,重置按钮只是一个标志,因为用户不仅可以在按钮上轻触,还可以在屏幕的任何位置开始游戏。无论如何,我们将使用 H 和 W 常量在每个屏幕上正确定位此按钮:
/*** src/components/StartAgain.js ***/
import React, { Component } from “react";
import { Text, View, StyleSheet, TouchableOpacity, Image }
from “react-native";
import { W, H } from “../constants";
export default class StartAgain extends Component {
render() {
return (
<Image
style={{ position: “absolute", left: 35 * W, top: 40 * H }}
resizeMode="contain"
source={require(“../../images/reset.png")}
/>
);
}
}
总结
游戏是一种非常特殊的应用程序。它们基于根据时间和用户交互在屏幕上显示和移动精灵。这就是为什么我们在大部分课程中解释了如何以最高效的方式轻松显示所有图像以及如何定位和调整它们的大小。
我们还审查了一种常见的技巧,相对于设备屏幕的高度和宽度来定位和调整精灵的大小。
尽管 Redux 并非专门为游戏设计,但我们在应用程序中使用它来存储和分发精灵的数据。
总的来说,我们证明了 React Native 可以用于构建高性能的游戏,尽管它缺乏游戏特定的工具,但我们可以生成非常可读的代码,这意味着它应该很容易扩展和维护。事实上,在这个阶段可以创建一些非常简单的扩展来使游戏更有趣和可玩性:在通过特定数量的障碍物后增加速度,减少或增加间隙大小,在屏幕上显示多组岩石等等。
通过这个,我们已经完成了这次学习之旅。我希望你有一个顺利的旅程,并在 React 上获得了很多知识。
祝愿你在未来的项目中一切顺利。继续学习和探索!
评估
-
命名游戏中使用的图形,通常分组为一个或多个图像。
-
数字
-
背景
-
地面
-
精灵
-
请说明以下陈述是真还是假:精灵是游戏中使用的图形,通常分组为一个或多个图像。许多游戏引擎包括工具来方便地拆分和管理这些图形,但在 React Native 中并非如此。
-
请说明以下陈述是真还是假:精灵数组是负责在特定时间存储所有精灵位置和大小的数组。
-
哪些功能负责通过注入操作和属性将组件连接到存储中?
-
________ 负责在用户点击屏幕后启动游戏。它将使用
requestAnimationFrame()
来实现这一点——这是 React Native 中实现的自定义定时器之一。 -
nextFrame()
-
cancelAnimationFrame()
-
GameContainer
-
mapStateToProps(state)
附录:评估答案
Lesson 1: Project 1 – 车辆预订应用
问题编号 | 答案 |
---|---|
1 | 2 |
2 | 1 |
3 | 3 |
4 | 4 |
5 |
-
shadowColor
: 这添加了我们希望为组件使用的颜色的十六进制或 RGBA 值 -
shadowOffset
: 这显示了我们希望阴影投射多远 -
shadowRadius
: 这显示了阴影角的半径值 -
shadowOpacity
: 这显示了我们希望阴影有多深
|
Lesson 2: Project 2 – 图片分享应用
问题编号 | 答案 |
---|---|
1 | 2 |
2 | 1 |
3 | True |
4 | 3 |
5 |
-
onPress
: 当按下发送按钮时组件需要执行的操作 -
busy
: 这意味着“我们正在等待远程数据吗?” -
loginError/registrationError
: 登录/注册时发生的错误的描述(如果发生)
|
Lesson 3: Project 3 – 消息应用
问题编号 | 答案 |
---|---|
1 | 3 |
2 | 4 |
3 | True |
4 | 2 |
5 |
-
tick()
: 计算屏幕上精灵的下一个位置 -
bounce()
: 让鹦鹉飞起来 -
start()
: 初始化游戏变量
|
Lesson 4: Project 4 – 游戏
问题编号 | 答案 |
---|---|
1 | 4 |
2 | True |
3 | True |
4 |
-
mapStateToProps()
: 这会从存储中获取所有精灵的数据,并将它们注入到组件中作为 props。精灵将被存储在一个数组中,因此它们将通过索引访问。除此之外,还将从状态中检索得分、标志,指示当前游戏是否结束,以及指示游戏是否正在进行中的标志,并将它们注入到组件中。 -
mapStateActionsToProps()
: 这将把三个可用的操作(tick
,bounce
和start
)注入到组件中,以便组件可以使用它们。
|
5 | 3 |
---|