三、订车应用

在前面的章节中,我们将重点放在功能开发上,而不是通过将应用的样式委托给 UI 库(如native-base)来构建用户界面。在本章中,我们将做相反的事情,花更多的时间构建自定义 UI 组件和屏幕。

我们将构建的应用是一个订车应用,用户可以在其中选择他/她想在哪里取车以及她想预订的汽车类型。因为我们想把重点放在用户界面上,所以我们的应用只有两个屏幕,需要一点状态管理。相反,我们将深入研究动画、组件布局、使用自定义字体或显示外部图像等方面。

该应用将可用于 iOS 和 Android 设备,由于所有用户界面都将定制,因此 100%的代码将在两个平台之间重用。我们将只使用两个外部库:

  • React-native-geocoder:这将坐标转换为人类可读的位置
  • React-native-maps:这将很容易显示地图和标记,显示可预订车辆的位置

由于其性质,大多数订车应用将其复杂性放在后端代码中,以有效地连接驾驶员和乘客。我们将跳过这一复杂性,模拟应用本身的所有功能,专注于构建美观、可用的界面。

概述

在构建移动应用时,我们需要确保将界面复杂性降至最低,因为一旦应用打开,向用户提供侵入性手册或工具提示往往会受到惩罚。让我们的应用不言自明是一个很好的做法,因此用户只需浏览应用屏幕就可以了解使用情况。这就是为什么使用抽屉菜单或标准列表等标准组件始终是一个好主意,但由于我们希望向用户呈现的数据类型,这并不总是可能的(就像我们当前的应用中发生的那样)。

在我们的例子中,我们将所有功能放在主屏幕中,并将其放在一个模式框中。让我们看看应用在 iOS 设备上的样子:

主屏幕上的背景是地图组件本身,我们将在地图中显示所有可用的汽车作为标记。在地图上,我们将显示三个组件:

  • 显示所选拾取位置的拾取位置框
  • 定位销,可在地图周围拖动以选择新位置
  • 用户想要预订的汽车类型的选择器。我们将展示三个选项:经济型、特殊型和高级型

由于大多数组件都是定制的,因此此屏幕在任何 Android 设备中看起来都非常相似:

iOS 和 Android 版本的主要区别在于地图组件。iOS 默认使用苹果地图,而 Android 则使用谷歌地图。我们将保留此设置,因为每个平台都有自己的优化地图组件,但我们可以通过配置组件将 iOS 版本切换为使用 Google 地图。

一旦用户选择了取货地点,我们将显示一个模式框以确认预订并联系最近的驾驶员取货:

正如主屏幕一样,该屏幕使用自定义组件:我们甚至决定创建自己的动画活动指示器。因此,Android 版本看起来非常相似:

由于我们的应用不会连接到任何外部 API,因此应该将其视为 React Native 可视功能的简单展示,尽管可以通过添加状态管理库和匹配的 API 轻松扩展。

本章将介绍以下主题:

  • 在我们的应用中使用地图
  • React Native 中的样式表
  • React Native 中的 Flexbox
  • 在本机应用中使用外部图像
  • 添加自定义字体
  • React Native 中的动画
  • 使用情态动词
  • 使用阴影和不透明度

设置文件夹结构

让我们使用 React-Native 的 CLI 初始化 React-Native 项目。该项目将命名为carBooking,并可用于 iOS 和 Android 设备:

react-native init --version="0.49.3" carBooking

在这个应用中,只有一个屏幕,因此代码的文件夹结构应该非常简单。由于我们将使用外部图像和字体,我们将把这些资源组织在两个单独的文件夹中:imgfonts,都在根文件夹下:

用于构建此应用的图像和字体可以从一些图像和字体库网站免费下载。我们将使用的字体名称为Blair ITC

我们还在img文件夹中存储了以下图像:

  • car.png:一个简单的汽车图,表示地图上可预订的汽车。
  • class.png:显示在类别选择按钮内的汽车轮廓。
  • classBar.png:滑动类别选择按钮以更改类别的栏。
  • loading.png:我们的定制纺纱机。它将存储为静态图像,并通过代码设置动画。

最后,让我们来看看我们的 To.t0 文件:

{
    "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:这很容易显示地图和标记,显示可预订车辆的位置

为了允许应用使用自定义字体,我们需要确保它们可以从本机端访问。为此,我们需要为名为rnpmpackage.json添加一个新密钥。此键将存储一个assets数组,我们将在其中定义我们的fonts文件夹。在构建期间,React Native 会将字体复制到本地可用的位置,从而在代码中可用。这只是字体和一些特殊资源所需,而不是图像所需

由 React Native 的 CLI 创建的文件和文件夹

让我们借此机会在这个应用中使用一个简单的文件夹结构,以显示在通过react-native init <projectName>初始化项目时,React Native 的 CLI 创建了哪些其他文件和文件夹

测验 /

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 键
  • 添加或修改要从本机代码中使用的本机库

节点单元/

大多数使用 npm 的 JavaScript 开发人员都应该熟悉这个文件夹,因为 npm 在这里存储项目中标记为依赖项的所有模块。通常不需要修改此文件夹中的任何内容,因为所有内容都应该通过 npm 的 CLI 和我们的package.json文件进行处理。

根文件夹中的文件

React Native 的 CLI 在项目的根目录中创建许多文件;让我们来看看最重要的:

  • .babelrc:Babel 是 React Native 中的默认库,用于编译包含 JSX 和 ES6 的 JavaScript 文件(例如,语法转换为大多数 JavaScript 引擎都能理解的普通 JavaScript)。在这里,我们可以修改这个编译器的配置,例如,我们可以像在 React 的第一个版本中那样使用 decorators 的@语法。
  • .buckconfig:Buck 是 Facebook 使用的构建系统。此文件用于配置使用 Buck 时的构建过程。
  • .watchmanconfig:Watchman 是一项服务,它可以监视我们项目中的文件,以便在文件发生更改时触发重建。在该文件中,我们可以添加一些配置选项,例如目录,这些选项应该被忽略。
  • app.json:此文件由react-native eject命令用于配置本机应用。它存储在每个平台上标识应用的名称,以及安装应用时将显示在设备主屏幕上的名称。
  • yarn.lockpackage.json文件描述了原作者想要的预期版本,而yarn.lock描述了给定应用最后已知的良好配置。

反应本机链接

某些应用依赖于具有本机功能的库,在响应本机 CLI 之前,这些库要求开发人员将本机库文件复制到本机项目中。在react-native link获救之前,这是一个繁琐且重复的项目。在本章中,我们将使用它从react-native-maps复制库文件,并将/fonts文件夹中的自定义字体链接到已编译的应用

通过在项目的根文件夹中运行react-native link,我们将触发链接步骤,从而使这些本机功能和资源可以从本机代码访问。

在模拟器中运行应用

package.json文件和所有初始文件中有了依赖项后,我们可以运行以下命令(在我们项目的根文件夹中)来完成安装:

npm install

然后,所有依赖项都应该安装在我们的项目中。一旦 npm 完成所有依赖项的安装,我们就可以在 iOS 模拟器中启动我们的应用:

react-native run-ios

或在 Android emulator 中使用以下命令:

react-native run-android

当 React Native 检测到应用正在模拟器中运行时,它会通过隐藏菜单启用可用的开发人员工具集,可以通过 iOS 上的快捷方式命令+D或 Android 上的命令+M(在 Windows Ctrl上)访问该快捷方式应使用而不是命令。这是 iOS 中开发者菜单的外观:

这就是 Android 模拟器中的情况:

开发者菜单

在 React Native 中构建应用的过程中,开发人员将需要调试。React-Native 通过能够在 Chrome 开发者的工具或外部应用(如 React-Native Debugger)中远程调试我们的应用来满足这些需求。错误、日志,甚至 React 组件都可以像在普通 web 环境中一样轻松调试。

除此之外,React Native 还提供了一种在每次更改完成时自动重新加载我们的应用的方法,从而为开发者节省了手动重新加载应用的任务(这可以通过按命令+RCtrl+R来实现)。当我们将应用设置为自动重新加载时,有两个选项:

  • Live reload 检测我们在应用代码中所做的任何更改,并在重新加载后将应用重置为其初始状态。
  • 热重新加载还会检测更改并重新加载应用,但会保持应用的当前状态。当我们实现用户流时,这非常有用,以节省开发人员重复流中的每个步骤(例如,登录或注册测试用户)

最后,我们可以启动性能监视器来检测执行复杂操作(如动画或数学计算)时可能出现的性能问题。

创建我们应用的入口点

让我们通过为我们的应用创建入口点来启动我们的应用代码: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(一种 React 原生 API,用作类似 CSS 样式表的抽象)来创建自己的样式,而不是使用库进行样式设置。通过StyleSheet,我们可以从一个对象(通过create方法)创建一个样式表,通过 ID 引用每个样式,该样式表可以在我们的组件中使用。

这样,我们可以重用样式代码并使代码更具可读性,因为我们将使用有意义的名称来引用每个样式(例如,<Text style={styles.title}>Title 1</Text>)。

此时,我们将只创建一个由fullScreenMap键引用的样式,并通过将topbottomleftright坐标加为零覆盖全屏尺寸,使其成为绝对位置。除此之外,我们需要在容器视图中添加一些样式,以确保它填充整个屏幕:{flex: 1}。将flex设置为1,我们希望视图填充其父视图占用的所有空间。由于这是主视图,{flex: 1}将占据整个屏幕。

对于我们的地图组件,我们将使用react-native-maps,这是一个由 Airbnb 使用谷歌和苹果地图的本地地图功能创建的开放模块。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/>放置在提供的坐标中。在每个标记内,我们将添加汽车旋转特定度数的图像,使其与街道方向匹配。旋转图像必须使用AnimatedAPI 完成,本章后面将对此进行更好的解释。

让我们在状态中添加一个新属性,以存储地图居中位置的人类可读位置:

/** * 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 服务,我们可能需要添加一个 API 密钥,以便使用该服务验证我们的应用。可在其存储库 URL中找到完整安装此模块的所有说明 https://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 以与网站类似的方式处理图像:图像应放在项目文件夹结构内的文件夹中,然后可以通过source属性从<Image />(或<Animated.Image />中引用它们。让我们看看我们应用中的一个示例:

  • 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(要显示的位置的名称)。如果未设置,它将显示一个微调器以显示活动。

因为在这个组件中有许多不同的样式需要应用,所以使用StyleSheetAPI 将样式组织在一个键/值对象中并从render方法中引用是有益的。逻辑和样式之间的这种分离有助于提高代码的可读性,还支持代码重用,因为样式可以级联到子组件。

对齐元素

React Native 使用 Flexbox 设置应用中元素的布局。这通常很简单,但在对齐元素时,有时会产生混淆,因为有四个属性可用于此目的:

  • justifyContent:定义子元素通过主轴的对齐方式
  • alignItems:定义子元素通过横轴的对齐方式
  • alignContent:当横轴上有额外空间时,它会将 flex 容器的线对齐
  • alignSelf:允许对单个弹性项目覆盖默认对齐方式(或alignItems指定的对齐方式)

前三个属性应指定给容器元素,而第四个属性将应用于子元素,以防我们要覆盖默认对齐方式。

在我们的例子中,我们只希望一个元素(标题)居中对齐,这样我们就可以使用alignSelf: 'center'。在本章后面,我们将看到不同align属性的其他用途。

定位销

在本节中,我们将着重于构建地图中心的精确定位,以直观地确认拾取位置。该 pin 还包含一个按钮,可用于触发拾取请求:

/** * 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',
  },
});

这个组件在功能上也很轻,但是有很多自定义样式。让我们深入了解一些风格细节。

弯曲方向

默认情况下,垂直反应本机和 Flexbox 堆栈元素:

对于 pin 中的横幅,我们希望将每个元素水平堆叠,如下所示:

这可以通过向包含元素flexDirection: 'row'添加以下样式来实现。flexDirection的其他有效选项有:

  • 行反转
  • column(默认)
  • column-reverse

尺寸

此组件中的第一行代码将设备的高度和宽度提取为两个变量:

const {height, width} = Dimensions.get('window');

获取设备的高度和宽度使我们的开发者能够绝对定位某些元素,确信它们会正确对齐。例如,我们希望 pin 的横幅与屏幕中心对齐,使其指向地图的中心。我们可以通过在样式表中的banner样式中添加{top: (height/2), left: (width/2)}来实现这一点。当然,这会使左上角对齐,所以我们需要将横幅的一半减去每个属性,以确保它在元素的中间居中。无论何时,只要我们需要对齐一个与组件树中的任何其他元素都不相关的元素,就可以使用此技巧,尽管建议尽可能使用相对定位。

阴影

让我们关注我们的旗帜风格,特别是shadows属性:

banner: {
  ...
 shadowColor: '#000000',
 shadowOffset: {
 width: 0,
 height: 3
 },
 shadowRadius: 5,
 shadowOpacity: 1.0 }

为了向组件添加阴影,我们需要添加四个属性:

  • shadowColor:这将为组件添加所需颜色的十六进制或 RGBA 值
  • shadowOffset:这表明我们希望自己的影子投射多远
  • shadowRadius:显示阴影角落的半径值
  • shadowOpacity:这表明我们希望自己的影子有多暗

这就是我们的LocationPin组件。

选课

在这个组件中,我们将探索 React Native 中的AnimatedAPI 来开始动画。此外,我们将使用自定义字体来改善用户体验,并在我们的应用中增加自定义的感觉:

/*** 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,它决定了当帧与原始图像尺寸不匹配时如何调整图像大小。从五个可能的值(covercontainstretchrepeatcenter中,我们选择了contain,因为我们希望均匀缩放图像(保持图像的纵横比),这样图像的两个维度都将等于或小于视图的相应维度。我们在classBarclassButtonImage中都使用了这些属性,这是我们需要在此视图中调整大小的两个图像。

添加自定义字体

React Native 包含一长串默认情况下可用的跨平台字体。字体列表可在上查看 https://github.com/react-native-training/react-native-fonts

然而,在开发应用时,添加自定义字体是一种常见的需求,特别是当涉及设计师时,因此我们将使用我们的订车应用作为一个游乐场来测试此功能。

将自定义字体添加到我们的应用需要三个步骤:

  1. 将字体文件(.ttf)添加到项目内的文件夹中。我们在这个应用中使用了fonts/
  2. 在我们的package.json中添加以下行:
      “rnpm”: {
          “assets”: [“./fonts”]
      }
  1. 在终端中运行以下命令:
 react-native link

就是这样,React Native 的 CLI 将立即在 iOS 和 Android 项目中处理fonts文件夹及其文件的插入。我们的字体将通过其字体名称(可能与文件名不同)提供。在我们的例子中,我们的样式表中有fontFamily: 'Blair ITC'

我们现在可以修改ClassSelection组件中的classLabel样式,以包含新字体:

...

classLabel: {
    fontFamily: 'Blair ITC',
    paddingTop: 5,
    fontSize: 12,
},

...

动画

React Native 的AnimatedAPI 设计用于以非常高效的方式简洁地表达各种有趣的动画和交互模式。动画侧重于输入和输出之间的声明性关系,其间有可配置的转换,以及简单的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.Viewleft坐标。

由于对于简单的移动动画来说,这可能会觉得有点过于复杂,因此它允许进行广泛的自定义,如链接动画或修改缓和功能。我们将在本章后面看到旋转动画。

确认模式

我们的最后一个组件是一个模式视图,一旦用户按下定位销上的设置拾取位置按钮,就会打开该视图。我们将显示模式和自定义活动指示器,它将使用复杂的动画设置在其位置连续旋转:

/** * 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
              style={[
                styles.spinner,
                { transform: [{ rotate: interpolatedRotateAnimation }] },
              ]}
              source={require('../../img/spinner.png')}
            />

...

总结

当我们需要构建应用时,使用诸如native-basereact-native-elements之类的 UI 库可以节省大量时间和维护麻烦,但结果最终会有一种标准的风格,这在用户体验方面并不总是可取的。这就是为什么学习如何操纵我们应用的风格始终是一个好主意,尤其是在由用户体验专家或应用设计师提供设计的团队中。

在本章中,我们深入研究了在初始化项目时由 React-Native 的 CLI 创建的文件夹和文件。此外,我们还熟悉了 developer 菜单及其调试功能。 在构建我们的应用时,我们将重点放在布局和组件样式上,同时也放在如何添加和操作动画以使我们的界面更吸引用户。我们看了 Flexbox 布局系统以及如何在组件中堆叠和居中元素。API(如尺寸)用于检索设备宽度和高度,以便在某些组件上执行定位技巧。 您学习了如何在我们的应用中添加字体和图像,以及如何显示它们以改善用户体验。

现在我们知道了如何构建更多的自定义界面,让我们在下一章中构建一个图像共享应用,其中设计起着关键作用。