Skip to content

Latest commit

 

History

History
1053 lines (797 loc) · 46.9 KB

File metadata and controls

1053 lines (797 loc) · 46.9 KB

九、使用 React Native 和 Expo 制作动画游戏

您在本书中创建的大多数项目都集中在显示数据和使页面之间导航成为可能。在上一章中,您探讨了创建 web 和移动应用之间的一些差异。构建移动应用时的另一个区别是,用户期望动画和手势,因为它们使应用的使用变得简单和熟悉。这是您将在本章中重点介绍的内容。

在本章中,您将使用 React-Native(一个名为 Lottie 的包)和 Expo 的GestureHandler中的动画 API 向 React-Native 应用添加动画和手势。它们共同使我们能够创建能够充分利用手机交互方式的应用,这非常适合于Tic Tac Toe等游戏。此外,应用将在游戏界面旁边显示此游戏的高分排行榜。

要创建此游戏,将介绍以下主题:

  • 使用 React 本机动画 API
  • 洛蒂高级动画
  • 使用 Expo 处理本地手势

项目概述

在本章中,我们将使用 React Native 和 Expo 创建一个动画的Tic Tac Toe游戏构建,它使用动画 API 添加基本动画,使用 Lottie 添加高级动画,使用 Expo 的手势处理程序处理本机手势。我们的出发点是使用 Expo CLI 创建一个应用,该应用中实现了基本路由,因此我们的用户可以在游戏界面和游戏高分概览之间切换。

构建时间为 1.5 小时。

开始

我们将在本章中创建的项目建立在您可以在 GitHub 上找到的初始版本之上:https://github.com/PacktPublishing/React-Projects/tree/ch9-initial 。完整的源代码也可以在 GitHub 上找到:https://github.com/PacktPublishing/React-Projects/tree/ch9.

您需要在移动 iOS 或 Android 设备上安装 application Expo 客户端,才能在物理设备上运行项目。或者,您可以在计算机上安装 Xcode 或 Android Studio,以便在虚拟设备上运行应用:

export ANDROID_SDK=ANDROID_SDK_LOCATION export PATH=ANDROID_SDK_LOCATION/platform-tools:$PATH
export PATH=ANDROID_SDK_LOCATION/tools:$PATH

ANDROID_SDK_LOCATION的值是本地机器上 Android SDK 的路径,可以通过打开 Android Studio 并进入首选项****外观&行为系统设置【T10 Android SDK】找到。该路径列在说明 Android SDK 位置*的框中,如下所示:/Users/myuser/Library/Android/sdk*

**This application was created using Expo SDK version 33.0.0, and so, you need to ensure that the version of Expo you're using on your local machine is similar. As React Native and Expo are frequently updated, make sure that you're working with this version so that the patterns described in this chapter behave as expected. In case your application doesn’t start or if you encounter errors, refer to the Expo documentation to learn more about updating the Expo SDK.

检查初始项目

您将在本章中使用的应用已经为您构建,但我们需要通过添加动画和过渡等功能来完成它。下载或克隆项目后,需要移动到项目的根目录,在那里可以运行以下命令来安装依赖项并启动应用:

npm install && npm start

这将启动 Expo,使您能够从终端或浏览器启动项目。在终端中,您可以使用二维码在移动设备上打开应用,也可以选择在模拟器中打开应用

无论您是在虚拟设备还是物理设备上打开应用,此时应用的外观应如下所示:

该应用由三个屏幕组成:StartGameLeaderBoard。第一屏为Start,点击绿色按钮即可启动游戏。这将导致Game屏幕,该屏幕设置为模式。Start屏幕使用选项卡导航,您也可以从中访问LeaderBoard屏幕,该屏幕将显示球员的得分

此 React 本机应用的项目结构如下所示。此结构类似于我们在本书中创建的项目:

tic-tac-toe
|-- .expo
|-- assets
    |-- icon.png
    |-- splash.png
    |-- winner.json
|-- Components
    |-- // ...
|-- context
    |-- AppContext.js
|-- node_modules
|-- Screens
    |-- Game.js
    |-- LeaderBoard.js
    |-- Start.js
|-- utils
    |-- arrayContainsArray.js
    |-- checkSlots.js
.gitignore
App.js
AppContainer.js
app.json
babel.config.js
package.json

assets目录中,您将看到两幅图像:一幅图像将在移动设备上安装此应用后用作主屏幕上的应用图标,另一幅图像将用作启动应用时显示的启动屏幕。这里还放置了一个乐蒂动画文件,您将在本章后面使用它。您的应用的配置(例如应用商店)位于app.json中,而babel.config.js包含特定的巴别塔配置。

App.js文件是应用的实际入口点,在context/AppContext.js文件中创建的上下文提供程序中导入并返回AppContainer.js文件。在AppContainer中,定义了此应用的所有路由,AppContext将包含在整个应用中应该可用的信息。在utils目录中,您可以找到游戏的逻辑,即填充Tic Tac Toe板插槽并确定哪个玩家赢得游戏的功能。

此游戏的所有组件都位于ScreensComponents目录中,前者保存由StartGameLeaderBoard路径呈现的组件。这些屏幕的子组件可以在Components目录中找到,该目录具有以下结构:

|-- Components
    |-- Actions
        |-- Actions.js
    |-- Board
        |-- Board.js
    |-- Button
        |-- Button.js
    |-- Player
        |-- Player.js
    |-- Slot
        |-- Slot.js
        |-- Filled.js

前面结构中最重要的组件是BoardSlotFilled,因为它们构成了游戏的大部分。BoardGame屏幕呈现,并保存游戏的一些逻辑,而SlotFilled是在该棋盘上呈现的组件。Actions组件返回两个Button组件,以便我们可以离开Game屏幕或重新启动游戏。Player显示轮到的玩家或赢得游戏的玩家的姓名。

使用 React Native 和 Expo 创建动画 Tic Tac Toe 游戏应用

手机游戏通常有华而不实的动画,让用户想继续玩,让游戏更具互动性。Tic Tac Toe游戏已经开始运行,目前还没有使用动画,只是有一些内置导航的过渡。在本节中,您将向应用添加动画和手势,这将改进游戏界面,让用户在玩游戏时感觉更舒适。

使用 React 本机动画 API

在 React Native 中使用动画有多种方法,其中之一是使用动画 API,可以在 React Native 的核心中找到。通过动画 API,默认情况下可以从react-nativeViewTextImageScrollView组件创建动画。或者,您可以使用createAnimatedComponent方法创建自己的

创建基本动画

可以添加的最简单的动画之一是通过更改元素的不透明度值来淡入或淡出元素。在您之前创建的Tic Tac Toe游戏中,插槽中填充了绿色或蓝色,具体取决于填充该插槽的玩家。这些颜色已经显示了一个小的过渡,因为您正在使用TouchableOpacity元素创建插槽。但是,可以通过使用动画 API 为此添加自定义转换。要添加动画,必须更改以下代码块:

  1. 首先在src/Components/Slot目录中创建一个新文件并调用它Filled.js。此文件将包含以下代码,用于构造Filled组件。在此文件中,添加以下代码:
import React from 'react';
import { View } from 'react-native';

const Filled = ({ filled }) => {
  return (
    <View
        style={{
            position: 'absolute',
            display: filled ? 'block' : 'none',
            width: '100%',
            height: '100%',
            backgroundColor: filled === 1 ? 'blue' : 'green',
        }}
    />
  );
}

export default Filled;

此组件显示一个View元素,并使用使用 JSS 语法的样式化对象进行样式化,这是 React Native 的默认语法。此元素可用于填充另一个元素,因为其位置是绝对的,具有 100%宽度和 100%高度。它还需要filled道具,以便我们可以设置backgroundColor并确定组件是否显示。

  1. 您可以将此组件导入到Slot组件中,并在任何玩家填满插槽后显示它。不必设置SlotWrapper组件的背景色,您可以将属于一个或两个播放器的颜色传递给Filled组件:
import React from 'react';
import { TouchableOpacity, Dimensions } from 'react-native';
import styled from 'styled-components/native';
+ import Filled from './Filled';

const SlotWrapper = styled(TouchableOpacity)`
    width: ${Dimensions.get('window').width * 0.3};
    height: ${Dimensions.get('window').width * 0.3};
-   background-color: ${({ filled }) => filled ? (filled === 1 ? 'blue' : 'green') : 'grey'};
+ background-color: grey;
    border: 1px solid #fff;
`;

const Slot = ({ index, filled, handleOnPress }) => (
- <SlotWrapper filled={filled} onPress={() => !filled && handleOnPress(index)} />
+ <SlotWrapper onPress={() => !filled && handleOnPress(index)}>
+   <Filled filled={filled} /> 
+ </SlotWrapper>
);

export default Slot;
  1. 现在,无论何时单击插槽,都不会改变任何可见内容,因为您需要先将可单击元素从TouchableOpacity元素更改为TouchableWithoutFeedback元素。这样,带有不透明度的默认过渡将消失,因此您可以用自己的替换。TouchableWithoutFeedback元素可以从react-native导入,并应放置在View元素周围,该元素将保存插槽的默认样式:
import React from 'react';
- import { TouchableOpacity, Dimensions } from 'react-native';
+ import { TouchableWithoutFeedback, View, Dimensions } from 'react-native'; import styled from 'styled-components/native';
import Filled from './Filled';

- const SlotWrapper = styled(TouchableOpacity)`
+ const SlotWrapper = styled(View)`
    width: ${Dimensions.get('window').width * 0.3};
    height: ${Dimensions.get('window').width * 0.3};    background-color: grey;
    border: 1px solid #fff;
`;

const Slot = ({ index, filled, handleOnPress }) => (
- <SlotWrapper onPress={() => !filled && handleOnPress(index)}>
+ <TouchableWithoutFeedback onPress={() => !filled && handleOnPress(index)}>
+   <SlotWrapper>
      <Filled filled={filled} />
    </SlotWrapper>
+ <TouchableWithoutFeedback>
);

export default Slot;

现在,您刚刚按下的插槽将立即填充您在Filled组件的backgroundColor字段中指定的颜色,没有任何过渡

  1. 要重新创建此过渡,您可以使用动画 API,您将使用该 API 更改Filled组件从被插槽渲染的那一刻起的不透明度。因此,您需要从src/Components/Slot/Filled.js中的react-native导入Animated
import React from 'react';
- import { View } from 'react-native';
+ import { Animated, View } from 'react-native';

const Filled = ({ filled }) => {
  return (
    ...
  1. 动画 API 的新实例首先指定一个值,该值应在我们使用动画 API 创建的动画期间更改。整个组件中的动画 API 应该可以更改此值,因此可以将此值添加到组件的顶部。此值应使用useState钩子创建,因为您希望此值稍后可以更改:
import React from 'react';
import { Animated, View } from 'react-native';

const Filled = ({ filled }) => {
+ const [opacityValue] = React.useState(new Animated.Value(0));

  return (
    ...
  1. 现在,动画 API 可以使用内置的三种动画类型中的任何一种来更改此值。它们是decayspringtiming,您将使用动画 API 中的timing方法在指定的时间范围内更改动画值。动画 API 可以从链接到onPress事件或生命周期方法的任何函数触发。由于Filled组件只应在插槽填充时显示,因此您可以使用在filled道具组件更改时触发的生命周期方法,即以filled道具作为依赖项的useEffect挂钩。当filled道具为false时,组件的opacity0,因此可以删除显示器的样式规则:
import React from 'react';
import { Animated, View } from 'react-native';

const Filled = ({ filled }) => {
  const [opacityValue] = React.useState(new Animated.Value(0));

+ **R**eact.useEffect(() => {
+    filled && Animated.timing(
+        opacityValue, 
+        {
+            toValue: 1,
+            duration: 500,
+        }
+    ).start();
+ }, [filled]);

return (
    <View
        style={{
            position: 'absolute',
 -          display: filled ? 'block' : 'none',
            width: '100%',
            height: '100%',
            backgroundColor: filled === 1 ? 'blue' : 'green',
        }}
    />
 );
}

export default Filled;

timing方法采用您在组件顶部指定的opacityValue和一个具有动画 API 配置的对象。其中一个字段是toValue,当动画结束时,它将成为opacityValue的值。另一个字段用于字段的持续时间,它指定动画应持续多长时间。

The other built-in animation types next to timing are decay and spring. Where the timing method changes gradually over time, the decay type has animations that change fast in the beginning and gradually slow down until the end of the animation. With spring, you can create animations that move a little outside of its edges at the end of the animations.

  1. 最后,您只需将View元素更改为Animated.View元素,并将opacity字段和opacityValue值添加到style对象:
import React from 'react';
- import { Animated, View } from 'react-native';
+ import { Animated } from 'react-native';

const Filled = ({ filled }) => {

...

return (    
- <View
+   <Animated.View
        style={{
            position: 'absolute',
            width: '100%',
            height: '100%',
            backgroundColor: filled === 1 ? 'blue : 'green',
+           opacity: opacityValue,
        }}
    />
  );
}

export default Filled;

现在,当您按下任何插槽时,Filled组件将淡入,因为不透明度值将持续 500 毫秒。当您在 iOS 模拟器或在 iOS 上运行的设备中运行应用时,这将使两个播放器的填充插槽如下所示。在 Android 上,应用看起来应该类似,因为没有添加特定于平台的样式:

要使动画看起来更平滑,还可以向Animated对象添加easing字段。此字段的值来自Easing模块,可从react-native导入。Easing模块有三个标准功能:linearquadcubic在此,linear功能可用于更平滑的定时动画:

import React from 'react';
- import { Animated } from 'react-native';
+ import { Animated, Easing } from 'react-native';

const Filled = ({ filled }) => {
  const [opacityValue] = React.useState(new Animated.Value(0));

  React.useEffect(() => {
    filled && Animated.timing(
        opacityValue, 
        {
            toValue: 1,
            duration: 1000,
+           easing: Easing.linear(),
        }
    ).start();
  }, [filled]);

  return (
    ...

通过最后一次更改,动画完成,游戏界面已经感觉更平滑,因为插槽正在使用您自己的自定义动画填充。在本节的下一部分中,我们将结合这些动画中的一些,使此游戏的用户体验更加高级。

将动画与动画 API 相结合

通过改变Filled组件的不透明度进行转换已经是对游戏界面的改进。但是我们可以制作更多的动画,使游戏的互动更加吸引人。

我们可以做的一件事是将淡入动画添加到Filled组件的大小。为了使这个动画能够很好地处理我们刚刚创建的动画中的淡入淡出,我们可以使用动画 API 中的parallel方法。此方法将启动在同一时刻内指定的动画。要创建此效果,我们需要进行以下更改:

  1. 对于第二个动画,您希望Filled组件不仅具有淡入的颜色,而且具有淡入的大小。要设置不透明度的初始值,必须为此组件的大小设置初始值:
import React from 'react';
import { Animated, Easing } from 'react-native';

const Filled = ({ filled }) => {
  const [opacityValue] = React.useState(new Animated.Value(0));
+ const [scaleValue] = React.useState(new Animated.Value(0));

  React.useEffect(() => {
    ...
  1. 您在useEffect钩子中创建的Animated.timing方法需要包装在Animated.parallel函数中。这样,您可以添加另一个动画,稍后更改Filled组件的大小。Animated.parallel函数以Animated方法的数组作为参数,必须这样添加:
import React from 'react';
import { Animated, Easing } from 'react-native';

const Filled = ({ filled }) => {
  const [opacityValue] = React.useState(new Animated.Value(0));
  const [scaleValue] = React.useState(new Animated.Value(0));

 React.useEffect(() => {
+ filled && Animated.parallel([
- filled && Animated.timing(
+ Animated.timing(
      opacityValue,
      {
        toValue: 1,
        duration: 1000,
        easing: Easing.linear(),
      }
-   ).start();
+  ),
+  ]).start();
 }, [filled]);

 return (
 ...

Next to the parallel function, three other functions help you with animation composition. These functions are delay, sequence, and stagger, and can also be used in combination with each other. The delay function starts any animation after a predefined delay, the sequence function starts animations in the order you've specified and waits until an animation is resolved before starting another one, and the stagger function can start animations both in order and parallel with specified delays in-between.

  1. parallel函数中,您需要添加动画 API 的spring方法,该方法可以为Filled组件的大小设置动画。这一次,您将不会使用timing方法,而是使用spring方法,它会在动画结尾添加一点反弹效果。还添加了一个Easing功能,使动画看起来更平滑:
...
const Filled = ({ filled }) => {
    const [opacityValue] = React.useState(new Animated.Value(0));
    const [scaleValue] = React.useState(new Animated.Value(0));

    React.useEffect(() => {
      filled && Animated.parallel([
        Animated.timing(
          opacityValue,
          {
            toValue: 1,
            duration: 1000,
            easing: Easing.linear(),
          }        ),
+       Animated.spring(
+         scaleValue,
+         {
+           toValue: 1,
+           easing: Easing.cubic(),
+         },
+       ),
      ]).start();
    }, [filled]);

    return (
        ...
  1. spring动画将scaleValue的值从0更改为1,并在动画结束时产生一点反弹效果。scaleValue还必须添加到style对象中,才能使Animated.View组件生效。scaleValue将添加到transform中的scale字段中字段,该字段将更改Filled组件的大小:
...

return (    
   <Animated.View
        style={{
            position: 'absolute',
            width: '100%',
            height: '100%',
            backgroundColor: filled === 1 ? 'blue' : 'green',            opacity: opacityValue,
+           transform: [
+             {
+               scale: scaleValue,
+             }
+           ],
        }}
    />
 );
}

export default Filled

当你点击任何一个插槽时,Filled组件不仅会通过改变不透明度而淡入,还会通过改变其大小而淡入。动画末尾的反弹效果为淡入淡出效果添加了一种美妙的触感。

  1. 但是,当您单击描述游戏赢家的插槽时,动画没有足够的时间结束,而赢家状态由组件渲染。因此,您还需要为设置游戏赢家的函数添加超时。此函数可在src/Screens/Game.js中找到,您可以在其中添加一个常量,用于设置动画应持续的毫秒数:
import React from 'react';
import { View } from 'react-native';
import styled from 'styled-components/native';
import Board from '../Components/Board/Board';
import Actions from '../Components/Actions/Actions';
import Player from '../Components/Player/Player';
import checkSlots from '../utils/checkSlots';
import { AppContext } from '../context/AppContext';

+ export const ANIMATION_DURATION = 1000;

...

这也将把设置胜利者的函数包装在一个setTimeout函数中,该函数会将这些函数的执行延迟与动画持续的时间相同:

...
const checkWinner = (player) => {
  const slots = state[`player${player}`];

  if (slots.length >= 3) {
    if (checkSlots(slots)) { +     setTimeout(() => {        setWinner(player);
        setPlayerWins(player); +     }, ANIMATION_DURATION);
    }
  }

  return false;
}

...
  1. 由于导出了ANIMATION_DURATION常量,您可以在src/Components/Slot/Filled.js文件中导入该常量,并将该常量用于实际动画。这样,如果在某个点更改动画的持续时间,则不必对其他组件进行任何更改,这些更改才会可见:
import React from 'react';
import { Animated, Easing } from 'react-native';
+ import { ANIMATION_DURATION } from '../../Screens/Game';

const Filled = ({ filled }) => {
    const [opacityValue] = React.useState(new Animated.Value(0));
    const [scaleValue] = React.useState(new Animated.Value(0));

    React.useEffect(() => {
      filled && Animated.parallel([
        Animated.timing(
          opacityValue,
          {
            toValue: 1,
- duration: 1000,
+           duration: ANIMATION_DURATION,
            easing: Easing.linear(),
          }

除了现在使用动画Filled组件填充的插槽,该组件执行两个并行动画外,当您单击其中任何一个时,设置游戏赢家的功能将等待插槽填充后再开火。

下一节将展示如何处理更高级的动画,例如当两个玩家中的任何一个获胜时显示动画图形。为此,我们将使用 Lottie 包,因为它支持比内置动画 API 更多的功能。

洛蒂高级动画

React 原生动画 API 非常适合构建简单动画,但构建更高级的动画可能更难。幸运的是,Lottie 提供了一个在 React Native 中创建高级动画的解决方案,使我们能够在 iOS、Android 和 React Native 中实时渲染后效动画。Lottie 可以使用npm作为单独的软件包安装,但也可以从 Expo 获得。由于 Lottie 仍然是 Expo 实验特性的一部分,因此您可以通过从DangerZone名称空间检索它来使用它。因此,目前最好从npm安装 Lottie,并将其导入到您想要使用它的文件中。

When using Lottie, you don't have to create these After Effects animations yourself; there's a whole library full of resources that you can customize and use in your project. This library is called LottieFiles and is available at https://lottiefiles.com/.

由于您已经将动画添加到棋盘游戏的插槽中,因此添加更高级动画的好地方是当任一玩家赢得游戏时显示的屏幕。在这个屏幕上,比赛结束后,可以显示奖杯而不是棋盘。现在让我们这样做:

  1. 要开始使用 Lottie,请运行以下命令,该命令将安装 Lottie 及其依赖项,并将其添加到您的package.json文件中:
npm install lottie-react-native
  1. 安装过程完成后,您可以继续创建一个组件,该组件将用于渲染作为乐蒂文件下载的 After Effects 动画。可以在新的src/Components/Winner/Winner.js文件中创建此组件。在这个文件中,您需要从lottie-react-native导入 React,当然还有 Lottie,您刚刚安装了:
import React from 'react';
import Lottie from 'lottie-react-native';

const Winner = () => ();

export default Winner;
  1. 导入的Lottie组件可以呈现您自己创建或从LottieFiles库下载的任何 Lottie 文件。在assets目录中,您将发现一个名为winner.json的 Lottie 文件,可用于此项目。将该文件添加到源时,Lottie组件可以渲染该文件,并且可以通过传递样式对象来设置动画的宽度和高度。此外,您还应添加autoPlay道具,以便在组件渲染后启动动画:
import React from 'react';
import Lottie from 'lottie-react-native';

const Winner = () => (
+    <Lottie
+        autoPlay
+        style={{
+            width: '100%',
+            height: '100%',
+        }}
+        source={require('../../img/winner.json')}
+    />
);

export default Winner;
  1. 该组件现在将开始在包含该组件的任何屏幕中渲染奖杯动画。因为当任何一个玩家赢得游戏时,应该显示该动画而不是棋盘,所以Board组件将是添加该组件的好地方,因为您可以使用棋盘的包装样式。Board组件可以在src/Components/Board/Board.js文件中找到,您可以在这里导入Winner组件:
import React from 'react';
import { View, Dimensions } from 'react-native';
import styled from 'styled-components/native';
import Slot from '../Slot/Slot';
+ import Winner from '../Winner/Winner';

...

const Board = ({ slots, winner, setSlot }) => (
    ...

在该组件的return功能中,您可以检查winner道具是true还是false,并根据结果显示Winner组件或迭代slots

const Board = ({ slots, winner, setSlot }) => (
 <BoardWrapper>
    <SlotsWrapper>
-    {slots.map((slot, index) =>
+    {
+      winner
+      ? <Winner />
+      : slots.map((slot, index) =>
            <Slot
              key={index}
              index={index}
              handleOnPress={!winner ? setSlot : () => { }}
              filled={slot.filled}
            />
        )
    }
    </SlotsWrapper>
  </BoardWrapper>
);

Board组件接收到具有true值的winner道具时,用户将看到正在渲染的奖杯动画,而不是棋盘。当您使用 iOS 模拟器运行应用或在 iOS 设备上运行应用时,可以在此处看到这样的示例:

如果您发现此动画的速度太快,可以通过将动画 API 与 Lottie 相结合来更改此速度。Lottie组件可以使用progress道具来确定动画的速度。传递由动画 API 创建的值时,可以根据自己的需要调整动画的速度。将其添加到乐蒂动画可以按如下方式完成:

  1. 首先,您需要导入AnimatedEasing(稍后使用),并使用组件顶部的AnimateduseState挂钩创建新值:
import React from 'react';
+ import { Animated, Easing } from 'react-native'; import Lottie from 'lottie-react-native';

- const Winner = () => (
+ const Winner = () => {
+   const [progressValue] = React.useState(new Animated.Value(0));
+   return (
      <Lottie
        autoPlay
        style={{
          width: '100%',
          height: '100%' ,
        }}
        source={ require('../../img/winner.json') }
        progress={progressValue}
      />
  );
+ };

export default Winner;
  1. useEffect钩子中,您可以创建Animated.timing方法,该方法将在您使用duration字段指示的时间范围内设置progressValue。动画应在组件渲染后立即开始,因此挂钩的依赖项数组应为空。您还可以将Easing.linear函数添加到easing字段,以使动画运行更平滑:
...
const Winner = () => {
   const [progressValue] = React.useState(new Animated.Value(0));

+ React.useEffect(() => {
+    Animated.timing(progressValue, {
+      toValue: 1,
+      duration: 4000,
+      easing: Easing.linear,
+    }).start(); + }, []);

return (
  ...
  1. 现在,progressValue值可以传递给Lottie组件,这将导致动画的不同行为:
...

const Winner = () => {
  const [progressValue] = React.useState(new Animated.Value(0));

  ...

  return (
    <Lottie
      autoPlay
      style={{
        width: '100%',
        height: '100%' ,
      }}
      source={ require('../../img/winner.json') }
+ progress={progressValue}
      />
  );
};

export default Winner;

现在,动画正在减速。动画从头到尾播放需要 4000 毫秒,而不是默认的 3000 毫秒。在下一节中,您将通过处理移动设备上可用的手势,为该应用的用户体验增加更多的复杂性。

与世博会打交道

手势是移动应用的一个重要特征,因为它们将区分普通和优秀的移动应用。在您创建的Tic Tac Toe游戏中,可以添加一些手势,使游戏更具吸引力。

之前,您使用了TouchableOpacity元素,在用户通过更改该元素按下该元素后,该元素会给出反馈。另一个可以用于此的元素是TouchableHighlight元素。就像TouchableOpacity一样,用户可以按下它,但它不会改变不透明度,而是突出显示元素。这些反馈或突出显示手势让用户对他们在应用中做出决策时会发生什么有一个印象,从而改善用户体验。这些手势可以定制,也可以添加到其他元素中,从而可以定制可触摸的元素。

为此,您可以使用一个名为react-native-gesture-handler的软件包,它可以帮助您在每个平台上访问本机手势。所有这些手势都将在本机线程中运行,这意味着您可以添加复杂的手势逻辑,而无需处理 React-native 手势响应系统的性能限制。它支持的一些手势包括轻触、旋转、拖动和平移手势。使用 Expo CLI 创建的任何项目都可以从react-native-gesture-handler使用GestureHandler,而无需手动安装软件包

You can also use gestures directly from React Native, without having to use an additional package. However, the gesture responder system that React Native currently uses doesn't run in the native thread. Not only does this limit the possibilities of creating and customizing gestures, but you can also run into cross-platform or performance problems. Therefore, it's advised that you use the react-native-gesture-handler package, but this isn't necessary for using gestures in React Native.

处理敲击手势

我们将实现的第一个手势是点击手势,它将被添加到Slot组件中,以向用户提供更多关于其动作的反馈。当用户点击时,用户不会填充插槽,而是会在点击事件开始时收到一些反馈,并在事件完成时收到反馈。在这里,我们将使用在本机线程中运行的react-native-gesture-handler中的TouchableWithoutFeedback元素,而不是使用手势应答器系统的react-native中的TouchableWithoutFeedback元素。将react-native部件更换为react-native-gesture-handler部件可通过以下步骤完成:

  1. TouchableWithoutFeedback可以从src/components/Slot.js文件顶部的react-native-gesture-handler导入:
import React from 'react';
- import { TouchableWithoutFeedback, View, Dimensions } from 'react-native';
+ import { View, Dimensions } from 'react-native';
+ import **{ Tou**chableWithoutFeedback } from 'react-native-gesture-handler'; import styled from 'styled-components/native';
import Filled from './Filled';

...

const Slot = ({ index, filled, handleOnPress }) => (
 ...

您不必更改返回函数中的任何内容,因为TouchableWithoutFeedback使用的道具与react-native中的道具相同。当你点击插槽时,什么都不会改变。这是因为该槽将由Filled组件填充,一旦出现动画,该组件将显示动画。

  1. 当您轻触任何插槽并用手指按住它时,handleOnPress功能将不会被调用。只有当您通过移除手指完成轻触手势时,手势才会结束,并且会调用handleOnPress功能。要在触摸插槽启动点击手势时启动动画,可以使用来自TouchableWithoutFeedbackonPressIn回调。点击事件启动后,需要向Filled组件传递一个值,指示其应启动动画。这个值可以通过useState钩子创建,所以您已经有了一个可以调用的函数来更改这个值。当点击事件结束时,将手指从元素上移开,应调用handleOnPress函数。您可以使用onPressOut回调进行此操作:
import React from 'react';
import { View, Dimensions } from 'react-native';
import { TapGestureHandler, State } from 'react-native-gesture-handler';
import styled from 'styled-components/native';
import Filled from './Filled';

...

- const Slot = ({ index, filled, handleOnPress }) => (
+ const Slot = ({ index, filled, handleOnPress }) => {
+  const [start, setStart] = React.useState(false);

+  return ( -    <TouchableWithoutFeedback onPress={() => !filled && handleOnPress(index)}> +    <TouchableWithoutFeedback onPressIn={() => setStart()} onPressOut={() => !filled && handleOnPress(index)}>
       <SlotWrapper>
-        <Filled filled={filled} />
+        <Filled filled={filled} start={start} />
       </SlotWrapper>
     </TouchableWithoutFeedback>
   );
};

export default Slot;
  1. src/Components/Slot/Filled.js文件的Filled组件中,您需要检查start道具,并在该道具的值为true后启动动画。由于您不希望在start的值为true时启动整个动画,因此只有更改opacityValue的动画才会启动:
import React from 'react';
import { Animated, Easing } from 'react-native';
import { ANIMATION_DURATION } from '../../utils/constants';

- const Filled = ({ filled }) => {
+ const Filled = ({ filled, start }) => {
    const [opacityValue] = React.useState(new Animated.Value(0));
- const [scaleValue] = React.useState(new Animated.Value(0));
+   const [scaleValue] = React.useState(new Animated.Value(.8));

+ React.useEffect(() => {
+   start && Animated.timing(
+     opacityValue,
+     {
+         toValue: 1,
+         duration: ANIMATION_DURATION,
+         easing: Easing.linear(),
+      }
+   ).start();
+ }, [start]);

  React.useEffect(() => {
    ...
  1. 此外,可以从检查filled道具的useEffect挂钩中删除更改不透明度的动画。这个useEffect钩子只处理改变比例的动画。初始的scaleValue应该更改,否则组件的大小将等于0
+ const Filled = ({ filled, start }) => {
    const [opacityValue] = React.useState(new Animated.Value(0));
- const [scaleValue] = React.useState(new Animated.Value(0));
+   const [scaleValue] = React.useState(new Animated.Value(.8));

React.useEffect(() => {

... React.useEffect(() => {
- filled && Animated.parallel([
-   Animated.timing(
-     opacityValue,
-     {
-       toValue: 1,
-       duration: ANIMATION_DURATION,
-       easing: Easing.linear(),
-     }
-   ),
-   Animated.spring(
+   filled && Animated.spring(
      scaleValue,
      {
        toValue: 1,
        easing: Easing.cubic(),
      }
-    )
-  ]).start()
+  ).start();
 }, [filled]);

...

进行这些更改后,当您点击任何插槽时,timing动画将启动,插槽中会出现一个正方形,表示正在点击插槽。一旦您将手指从该插槽中释放,方块的大小将发生变化,并在spring动画开始时填充插槽的其余部分,当onPress功能更改filled的值时会发生这种情况。

自定义轻触手势

现在,插槽有不同的动画,这取决于点击事件的状态,如果用户对选择哪个插槽有第二个想法,这将非常有用。用户可以将手指从所选插槽中取出,在这种情况下,点击事件将遵循不同的状态流。你甚至可以决定用户是否应该长时间点击插槽以确定选择,或者像在某些社交媒体应用上喜欢图片一样双击插槽

要创建更复杂的点击手势(如这些),您需要知道点击事件经历不同的状态。TouchableWithoutFeedback在发动机罩下使用TapGestureHandler,可经历以下状态:UNDETERMINEDFAILEDBEGANCANCELLEDACTIVEEND。这些状态的命名非常简单,处理程序通常会有以下流程:UNDETERMINED > BEGAN > ACTIVE > END > UNDETERMINED。在TouchableWithoutFeedback元素的onPressIn回调中添加函数时,当 tap 事件处于BEGAN状态时,调用此函数。状态为END时调用onPressOut回调,默认onPress回调响应ACTIVE状态。

要创建这些复杂的手势,您可以通过自己处理事件状态来使用react-native-gesture-handler包,而不是使用可触摸元素的声明方式:

  1. TapGestureHandler可以从react-native-gesture-handler导入,并允许您创建定制的可触摸元素,这些元素具有您可以定义自己的手势。您需要从react-native-gesture-handler导入State对象,该对象包含您需要用于处理 tap 事件状态检查的常量:
import React from 'react';
- import { TouchableWithoutFeedback } from 'react-native-gesture-handler';
+ import { TapGestureHandler, State } from 'react-native-gesture-handler';import styled from 'styled-components/native';
import Filled from './Filled';

...

const Slot = ({ index, filled, handleOnPress }) => (
   ...
  1. TouchableWithoutFeedback元素有一个名为onHandlerStateChange的回调,而不是像onPress这样的事件处理程序。每当TapGestureHandler的状态发生变化时(例如,当点击该元素时),就会调用该函数。通过使用TapGestureHandler创建可触摸元素,您不再需要TouchableWithoutFeedback元素。此元素的功能可以移动到您将创建的新元素:
...

const Slot = ({ index, filled, handleOnPress }) => {
...

return (
- <TouchableWithoutFeedback onPressIn={() => setStart()} onPressOut={() => !filled && handleOnPress(index)}>
+ <TapGestureHandler onHandlerStateChange={onTap}>
    <SlotWrapper>
      <Filled filled={filled} start={start} />
    </SlotWrapper>
- </TouchableWithoutFeedback>
+ </TapGestureHandler>
  );
};

...
  1. onHandlerStateChange获取onTap函数,您仍然需要创建该函数,并检查 tap 事件的当前状态。当点击事件处于类似于onPressIn处理程序的BEGAN状态时,Filled中的动画应该开始。点击事件的完成具有END状态,类似于onPressOut处理程序,在该处理程序中,您将调用handleOnPress函数,该函数将更改点击该插槽的玩家的道具值。将调用setStart函数重置启动动画的状态:
import React from 'react';
import { View, Dimensions } from 'react-native';
import { TapGestureHandler, State } from 'react-native-gesture-handler';
import styled from 'styled-components/native';
import Filled from './Filled';

...

const Slot = ({ index, filled, handleOnPress }) => {
    const [start, setStart] = React.useState(false);

+   const onTap = event => { +    if (event.nativeEvent.state === State.BEGAN) {
+       setStart(true);
+    }  +    if (event.nativeEvent.state === State.END) {
+       !filled && handleOnPress(index);
+       setStart(false);
+    }
+ }

  return (
    ...

当您轻触任何插槽并将手指放在其上时,handleOnPress功能将不会被调用。只有当您通过移除手指完成轻触手势时,手势才会结束并调用handleOnPress功能。

这些手势可以定制得更多,因为您可以使用合成来拥有多个相互响应的点击事件。通过创建所谓的交叉处理程序交互,您可以创建支持双击手势和长按手势的可触摸元素。通过设置并传递使用 ReactuseRef钩子创建的 ref,您可以让react-native-gesture-handler中的手势处理程序监听其他处理程序的状态生命周期。这样,您就可以对事件进行排序,并像双击事件一样响应手势:

  1. 要创建 ref,您需要将useRef挂钩放置在组件顶部,并将此 ref 传递给TapGestureHandler
import React from 'react';
import { View, Dimensions } from 'react-native';
import { TapGestureHandler, State } from 'react-native-gesture-handler';
import styled from 'styled-components/native';
import Filled from './Filled';

...

const Slot = ({ index, filled, handleOnPress }) => {
   const [start, setStart] = React.useState(false);
+  const doubleTapRef = React.useRef(null);

   ...

   return (
-    <TapGestureHandler onHandlerStateChange={onTap}>
+    <TapGestureHandler
+       ref={doubleTapRef}
+       onHandlerStateChange={onTap} +    >
       <SlotWrapper>
          <Filled filled={filled} start={start} />
       </SlotWrapper>
     </TapGestureHandler>
  );
};

export default Slot;
  1. 现在,您需要设置开始和完成轻触手势所需的轻触次数。您不必对onTap函数进行任何更改,因为第一次点击该元素时,点击事件的状态将为BEGAN。只有在点击元素两次后,点击事件状态才会变为END
... return (
  <TapGestureHandler
    ref={doubleTapRef}
    onHandlerStateChange={onTap}
+   numberOfTaps={2}
  >
    <SlotWrapper>
      <Filled filled={filled} start={start} />
    </SlotWrapper>
  </TapGestureHandler>
);

...
  1. 要填充插槽,用户必须点击TapGestureHandler两次才能完成点击事件。但是,当点击一次TapGestureHandler时,您也可以通过添加另一个TapGestureHandler来调用一个函数,该函数将现有的TapGestureHandler作为其子函数。这个新的TapGestureHandler应该等待另一个处理程序有双击手势,它可以使用doubleTapRef进行检查。onTap函数应重命名为onDoubleTap,这样您就有了一个新的onTap函数来处理单次点击:
...

const Slot = ({ index, filled, handleOnPress }) => {
   const [start, setStart] = React.useState(false);
   const doubleTapRef = React.useRef(null);

+  const onTap = event => {};

-  const onTap = event => {
+  const onDoubleTap = event => {
     ...
   }

   return (
+   <TapGestureHandler
+      onHandlerStateChange={onTap}
+      waitFor={doubleTapRef}
+ >
      <TapGestureHandler
        ref={doubleTapRef}
-       onHandlerStateChange={onTap}
+       onHandlerStateChange={onDoubleTap}
        numberOfTaps={2}
      > 
        <SlotWrapper>
           <Filled filled={filled} start={start} />
        </SlotWrapper>
      </TapGestureHandler>
+ </TapGestureHandler> );
}

...
  1. 当您仅点击一次插槽时,动画将启动,因为TapGestureHandler将处于BEGAN状态。双击手势上的动画应仅在状态为ACTIVE而不是BEGAN时启动,因此动画不会仅在一次点击时启动。此外,通过在点击手势结束时调用的函数中添加一个setTimeout,动画将看起来更平滑,因为这两个动画会在彼此之后过快出现:
...

const Slot = ({ index, filled, handleOnPress }) => {
   const [start, setStart] = React.useState(false);
   const doubleTapRef = React.useRef(null);

   const onTap = event => {};
   const onDoubleTap = event => {
-    if (event.nativeEvent.state === State.BEGAN) {
+    if (event.nativeEvent.state === State.ACTIVE) {
       setStart(true);
     }

     if (event.nativeEvent.state === State.END) {
+      setTimeout(() => {
         !filled && handleOnPress(index);
         setStart(false);
+      }, 100);
     }
   }

...

除了用双击手势来填充插槽外,长按手势还可以改善用户的交互。您可以通过以下步骤添加长按手势:

  1. react-native-gesture-handler导入LongPressGestureHandler
import React from 'react';
import { View, Dimensions } from 'react-native';
- import { TapGestureHandler, State } from 'react-native-gesture-handler';
+ import { LongPressGestureHandler, TapGestureHandler, State } from 'react-native-gesture-handler';
import styled from 'styled-components/native';
import Filled from './Filled';

...
  1. 在此处理程序上,您可以设置长按手势的最短持续时间,并设置在此时间段结束后应调用的函数。LongPressGestureHandler处理程序有一个状态生命周期,可以与onDoubleTap函数一起使用:
...

const Slot = ({ index, filled, handleOnPress }) => {
 ...

 return (
+  <LongPressGestureHandler 
+    onHandlerStateChange={onDoubleTap} 
+    minDurationMs={500}
+  >
      <TapGestureHandler
        onHandlerStateChange={onTap}
        waitFor={doubleTapRef}
      >
         ...
     </TapGestureHandler>
+   </LongPressGestureHandler>
 )
};

export default Slot;

If you only want to create a long-press gesture, you can use the onLongPress event handler, which is available on the touchable elements from react-native and react-native-gesture-handler. It's advised that you use the touchable elements from react-native-gesture-handler as they will run in the native thread, instead of using the React Native gesture responder system.

  1. 也许不是所有的用户都会明白,他们需要用长按的手势来填补空缺。因此,您可以使用onTap功能来提醒用户该功能,该功能只需点击一次即可调用。为此,您可以使用AlertAPI,它适用于 iOS 和 Android,并使用来自这两种平台的本机警报消息。在此警报中,您可以为用户添加一条小消息:
import React from 'react';
- import { View, Dimensions } from 'react-native';
+ import { Alert, View, Dimensions } from 'react-native';
import { LongPressGestureHandler, TapGestureHandler, State } from 'react-native-gesture-handler';
import styled from 'styled-components/native';
import Filled from './Filled';

...

const Slot = ({ index, filled, handleOnPress }) => {
  const [start, setStart] = React.useState(false);
  const doubleTapRef = React.useRef(null);

  const onTap = event => {
+   if (event.nativeEvent.state === State.ACTIVE) {
+     Alert.alert(
+      'Hint',
+      'You either need to press the slot longer to make your move',
+     ); +   }
  }

  ...

当用户不使用长按在板上移动时,这将显示警报,从而使他们更容易理解。随着这些最终的添加,游戏界面得到了更大的改进。用户不仅可以看到基于他们动作的动画,还可以被告知可以使用哪些手势。

总结

在本章中,我们将动画和手势添加到使用 React Native 和 Expo 构建的简单的Tic Tac Toe游戏中。动画是使用 React Native Animated API 和 Lottie 创建的,该 API 和 Lottie 可从 Expo CLI 获得,并作为单独的软件包提供。我们还为游戏添加了基本的和更复杂的手势,由于react-native-gesture-handler软件包,游戏在本机线程中运行。

动画和手势为移动应用的用户界面提供了明显的改进,我们还可以做更多的事情。不过,我们的应用还需要向用户请求和显示数据。

之前,我们在 React 旁边使用了 GraphQL。在下一章中,我们将以此为基础。您将在下一章中创建的项目将探索如何使用 WebSockets 和使用 Apollo 的 GraphQL 在 React 本机应用中处理实时数据。

进一步阅读