React 和 ReactNative 第五版(四)
原文:
zh.annas-archive.org/md5/47e218557a614bce0d999181bbb2b76b译者:飞龙
第二十章:渲染项目列表
在本章中,你将学习如何处理项目列表。列表是常见的 Web 应用程序组件。虽然使用<ul>和<li>元素构建列表相对简单,但在本地移动平台上做类似的事情要复杂得多。
幸运的是,React Native 提供了一个项目列表接口,隐藏了所有的复杂性。首先,你将通过浏览一个示例来了解项目列表的工作方式。然后,你将学习如何构建更改列表中显示的数据的控件。最后,你将看到几个从网络获取项目的示例。
本章我们将涵盖以下主题:
-
渲染数据集合
-
排序和过滤列表
-
获取列表数据
-
懒加载列表
-
实现下拉刷新
技术要求
你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter20。
渲染数据集合
列表是显示大量信息最常见的方式:例如,你可以显示你的朋友列表、消息和新闻。许多应用程序包含具有数据集合的列表,React Native 提供了创建这些组件的工具。
让我们从示例开始。你将使用 React Native 组件FlatList来渲染列表,它在 iOS 和 Android 上工作方式相同。列表视图接受一个data属性,它是一个对象数组。这些对象可以具有你喜欢的任何属性,但它们确实需要一个key属性。如果你没有key属性,你可以将keyExtractor属性传递给Flatlist组件,并指示使用什么代替key。key属性类似于在<ul>元素内部渲染<li>元素的要求。这有助于列表在列表数据更改时高效渲染。
现在我们来实现一个基本的列表。以下是渲染一个包含 100 项的基本列表的代码:
const data = new Array(100)
.fill(null)
.map((v, i) => ({ key: i.toString(), value: `Item ${i}` }));
export default function App() {
return (
<View style={styles.container}>
<FlatList
data={data}
renderItem={({ item }) => <Text style={styles.item}>{item.value}</Text>}
/>
</View>
);
}
让我们逐步了解这里发生的事情,从data常量开始。它包含一个 100 项的数组。这是通过填充一个包含 100 个null值的新数组,然后将其映射到一个新数组,该数组包含要传递给<FlatList>的对象来创建的。每个对象都有一个key属性,因为这是一个要求;其他任何内容都是可选的。在这种情况下,你决定添加一个value属性,该属性将在列表渲染时使用。
接下来,你将渲染<FlatList>组件。它位于<View>容器中,因为列表视图需要高度才能正确工作。data和renderItem属性被传递给<FlatList>,这最终决定了渲染的内容。
初看之下,FlatList组件似乎并没有做太多。您是否需要弄清楚项的外观?嗯,是的,FlatList组件应该是通用的。它应该擅长处理更新,并将滚动功能嵌入到列表中。以下是用于渲染列表的样式:
import { StyleSheet } from "react-native";
export default StyleSheet.create({
container: {
flex: 1,
flexDirection: "column",
paddingTop: 40,
},
item: {
margin: 5,
padding: 5,
color: "slategrey",
backgroundColor: "ghostwhite",
textAlign: "center",
},
});
在这里,您正在为列表中的每个项设置样式。否则,每个项都将是纯文本,这将很难区分其他列表项。container样式通过将flex设置为1来设置列表的高度。
让我们看看现在列表看起来像什么:
图 20.1:渲染数据集合
如果您在模拟器中运行此示例,您可以在屏幕上的任何地方单击并按住鼠标按钮,就像用手指一样,然后上下滚动通过项。
在下一节中,您将学习如何添加排序和过滤列表的控件。
排序和过滤列表
现在您已经学习了FlatList组件的基础知识,包括如何传递数据,让我们向在渲染数据集合部分实现的列表添加一些控件。FlatList组件可以与其他组件一起渲染:例如,列表控件。它帮助您操作数据源,这最终决定了屏幕上渲染的内容。
在实现列表控制组件之前,回顾这些组件的高级结构可能会有所帮助,这样代码就有更多的上下文。以下是您将要实现的组件结构的示意图:
图 20.2:组件结构
每个这些组件负责的内容如下:
-
ListContainer:列表的整体容器;它遵循熟悉的 React 容器模式 -
List:一个无状态组件,将相关的状态片段传递给ListControls和 React Native 的ListView组件 -
ListControls:一个组件,它包含各种控件,这些控件可以更改列表的状态 -
ListFilter:用于过滤项目列表的控件 -
ListSort:用于更改列表排序顺序的控件 -
FlatList:实际渲染项的 React Native 组件
在某些情况下,将列表的实现拆分开来可能会增加开销。然而,我认为如果您的列表需要控件,那么您可能正在实现一些将从良好的组件架构中受益的东西。
现在,让我们深入探讨这个列表的实现,从ListContainer组件开始:
function mapItems(items: string[]) {
return items.map((value, i) => ({ key: i.toString(), value }));
}
const array = new Array(100).fill(null).map((v, i) => `Item ${i}`);
function filterAndSort(text: string, asc: boolean): string[] {
return array
.filter((i) => text.length === 0 || i.includes(text))
.sort(
asc
? (a, b) => (a > b ? 1 : a < b ? -1 : 0)
: (a, b) => (b > a ? 1 : b < a ? -1 : 0)
);
}
在这里,我们定义了一些实用函数和我们将使用的初始数组。
然后,我们将定义asc和filter来管理排序和过滤列表,分别使用useMemo钩子实现的data变量:
export default function ListContainer() {
const [asc, setAsc] = useState(true);
const [filter, setFilter] = useState("");
const data = useMemo(() => {
return filterAndSort(filter, asc);
}, [filter, asc]);
它给我们一个避免手动更新的机会,因为当filter和asc依赖项更新时,它将自动重新计算。它还有助于我们在filter和asc未更改时避免不必要的重新计算。
这就是我们将此逻辑应用于List组件的方式:
return (
<List
data={mapItems(data)}
asc={asc}
onFilter={(text) => {
setFilter(text);
}}
onSort={() => {
setAsc(!asc);
}}
/>
);
如果这看起来有点多,那是因为确实如此。此容器组件有很多状态要处理。它还有一些需要对其子组件提供的不平凡的行为。如果您从封装状态的角度来看,它将更容易接近。它的任务是使用状态数据填充列表,并提供在此状态下操作的功能。
在理想的世界里,此容器的子组件应该是简单而优雅的,因为它们不需要直接与状态交互。让我们看看下一个List组件:
export default function List({ data, ...props }: Props) {
return (
<FlatList
data={data}
ListHeaderComponent={<ListControls {...props}/>}
renderItem={({ item }) => <Text style={styles.item}>{item.value}</Text>}
/>
);
}
此组件将来自ListContainer组件的状态作为属性,并渲染一个FlatList组件。与上一个示例相比,这里的主要区别是ListHeaderComponent属性。它渲染了List组件的控件。这个属性特别有用,因为它在可滚动列表内容之外渲染控件,确保控件始终可见。让我们看看下一个ListControls组件:
type Props = {
onFilter: (text: string) => void;
onSort: () => void;
asc: boolean;
};
export default function ListControls({ onFilter, onSort, asc }: Props) {
return (
<View style={styles.controls}>
<ListFilter onFilter={onFilter} />
<ListSort onSort={onSort} asc={asc} />
</View>
);
}
此组件将ListFilter和ListSort控件结合在一起。因此,如果您要添加另一个列表控件,您应该在这里添加。
现在让我们看看ListFilter的实现:
type Props = {
onFilter: (text: string) => void;
};
export default function ListFilter({ onFilter }: Props) {
return (
<View>
<TextInput
autoFocus
placeholder="Search"
style={styles.filter}
onChangeText={onFilter}
/>
</View>
);
}
筛选控件是一个简单的文本输入,通过用户类型筛选项目列表。处理此操作的onFilter函数来自ListContainer组件。
让我们看看下一个ListSort组件:
const arrows = new Map([
[true, "▼"],
[false, "▲"],
]);
type Props = {
onSort: () => void;
asc: boolean;
};
export default function ListSort({ onSort, asc }: Props) {
return <Text onPress={onSort}>{arrows.get(asc)}</Text>;
}
下面是结果的列表:
图 20.3:排序和筛选列表
默认情况下,整个列表按升序渲染。当用户尚未提供任何内容时,您可以看到占位符搜索文本。让我们看看当您输入筛选器和更改排序顺序时,它看起来会是什么样子:
图 20.4:排序顺序和搜索值已更改的列表
此搜索包括包含1的项,并按降序排序结果。请注意,您可以先更改顺序,然后输入筛选器。筛选器和排序顺序都是ListContainer状态的一部分。
在下一节中,您将学习如何从 API 端点获取列表数据。
获取列表数据
通常,您将从某个 API 端点获取列表数据。在本节中,您将了解如何在 React Native 组件中发起 API 请求。好消息是,React Native 已经填充了fetch() API,因此您在移动应用程序中的网络代码应该看起来和感觉就像在您的 Web 应用程序中一样。
首先,让我们为我们的列表项构建一个mock API,使用返回 promise 的函数,就像fetch()一样:
const items = new Array(100).fill(null).map((v, i) => `Item ${i}`);
function filterAndSort(data: string[], text: string, asc: boolean) {
return data
.filter((i) => text.length === 0 || i.includes(text))
.sort(
asc
? (a, b) => (b > a ? -1 : a === b ? 0 : 1)
: (a, b) => (a > b ? -1 : a === b ? 0 : 1)
);
}
export function fetchItems(
filter: string,
asc: boolean
): Promise<{ json: () => Promise<{ items: string[] }> }> {
return new Promise((resolve) => {
resolve({
json: () =>
Promise.resolve({
items: filterAndSort(items, filter, asc),
}),
});
});
}
在 mock API 函数就位后,让我们对ListContainer组件做一些修改。现在,你不再使用本地数据源,而是可以使用fetchItems()函数从 mock API 加载数据。让我们看看并定义ListContainer组件:
export default function ListContainer() {
const [asc, setAsc] = useState(true);
const [filter, setFilter] = useState("");
const [data, setData] = useState<MappedList>([]);
useEffect(() => {
fetchItems(filter, asc)
.then((resp) => resp.json())
.then(({ items }) => {
setData(mapItems(items));
});
}, []);
我们使用useState和useEffect钩子定义了状态变量来获取初始列表数据。
现在,让我们来看看我们在List组件中新的处理器的用法:
return (
<List
data={data}
asc={asc}
onFilter={(text) => {
fetchItems(text, asc)
.then((resp) => resp.json())
.then(({ items }) => {
setFilter(text);
setData(mapItems(items));
});
}}
onSort={() => {
fetchItems(filter, !asc)
.then((resp) => resp.json())
.then(({ items }) => {
setAsc(!asc);
setData(mapItems(items));
});
}}
/>
);
}
任何修改列表状态的行动都需要在 promise 解析后调用fetchItems()并设置适当的状态。
在接下来的部分,你将学习如何懒加载列表数据。
懒加载列表
在本节中,你将实现一种不同类型的列表:一个可以无限滚动的列表。有时,用户实际上并不知道他们在寻找什么,所以过滤或排序并不能帮助。想想当你登录账户时看到的 Facebook 新闻源;它是应用程序的主要功能,你很少会寻找特定的事物。你需要通过滚动列表来查看发生了什么。
要使用FlatList组件实现这一点,你需要能够在用户滚动到列表末尾时获取更多的 API 数据。为了理解这是如何工作的,你需要大量的 API 数据来操作,生成器在这方面非常出色。所以,让我们修改你在获取列表数据部分的示例中创建的 mock,让它持续响应新数据:
function* genItems() {
let cnt = 0;
while (true) {
yield `Item ${cnt++}`;
}
}
let items = genItems();
export function fetchItems({ refresh }: { refresh?: boolean }) {
if (refresh) {
items = genItems();
}
return Promise.resolve({
json: () =>
Promise.resolve({
items: new Array(30).fill(null).map(() => items.next().value as string),
}),
});
}
使用fetchItems,你现在可以在每次到达列表末尾时发起一个新的 API 请求以获取新数据。最终,当内存耗尽时,这将会失败,但我只是想从一般的角度展示你可以在 React Native 中实现无限滚动的方案。现在,让我们看看带有fetchItems的ListContainer组件的样子:
import React, { useState, useEffect } from "react";
import * as api from "./api";
import List from "./List";
export default function ListContainer() {
const [data, setData] = useState([]);
function fetchItems() {
return api
.fetchItems({})
.then((resp) => resp.json())
.then(({ items }) => {
setData([
...data,
...items.map((value) => ({
key: value,
value,
})),
]);
});
}
useEffect(() => {
fetchItems();
}, []);
return <List data={data} fetchItems={fetchItems} />;
}
每次调用fetchItems()时,响应都会与data数组连接。这成为新的列表数据源,而不是像早期示例中那样替换它。
现在,让我们来看看List组件,看看如何响应到达列表的末尾:
type Props = {
data: { key: string; value: string }[];
fetchItems: () => Promise<void>;
refreshItems: () => Promise<void>;
isRefreshing: boolean;
};
export default function List({
data,
fetchItems
}: Props) {
return (
<FlatList
data={data}
renderItem={({ item }) => <Text style={styles.item}>{item.value}</Text>}
onEndReached={fetchItems}
/>
);
}
FlatList接受onEndReached处理程序属性,它将在你滚动到列表末尾时被调用。
如果你运行这个示例,你会看到,当你滚动到屏幕底部时,列表会不断增长。
实现下拉刷新
下拉刷新手势是移动设备上的一种常见操作。它允许用户在不离开屏幕或手动重新打开应用的情况下,只需下拉即可刷新视图内容,从而触发页面刷新。Tweetie(后来成为 iPhone 上的 Twitter)和 Letterpress 的创造者 Loren Brichter 在 2009 年引入了这一手势。这一手势变得如此流行,以至于苹果将其集成到其 SDK 中,作为UIRefreshControl。
要在FlatList应用中使用下拉刷新,我们只需传递一些属性和处理器。让我们看看我们的List组件:
type Props = {
data: { key: string; value: string }[];
fetchItems: () => Promise<void>;
refreshItems: () => Promise<void>;
isRefreshing: boolean;
};
export default function List({
data,
fetchItems,
refreshItems,
isRefreshing,
}: Props) {
return (
<FlatList
data={data}
renderItem={({ item }) => <Text style={styles.item}>{item.value}</Text>}
onEndReached={fetchItems}
onRefresh={refreshItems}
refreshing={isRefreshing}
/>
);
}
由于我们提供了onRefresh和refreshing属性,我们的FlatList组件自动启用了下拉刷新手势。当你下拉列表时,将调用onRefresh处理器,而refreshing属性将启用加载指示器以反映加载状态。
要在List组件中应用定义的属性,让我们在ListContainer组件中实现带有isRefreshing状态的refreshItems函数:
const [isRefreshing, setIsRefreshing] = useState(false);
function fetchItems() {
return api
.fetchItems({})
.then((resp) => resp.json())
.then(({ items }) => {
setData([
...data,
...items.map((value) => ({
key: value,
value,
})),
]);
});
}
在refreshItems以及fetchItems方法中,我们获取列表项,但将它们保存为一个新的列表。此外,请注意,在调用 API 之前,我们更新isRefreshing状态将其设置为true值,并在最后的代码块中将其设置为false,以向FlatList提供信息,表明加载已完成。
摘要
在本章中,你学习了 React Native 中的FlatList组件。这个组件是通用的,因为它不对渲染的项目外观施加任何特定的要求。相反,列表的外观由你决定,让FlatList组件帮助高效地渲染数据源。FlatList组件还为其渲染的项目提供了一个可滚动的区域。
你实现了一个利用列表视图中的部分标题的示例。这是一个渲染静态内容(如列表控件)的好地方。然后你学习了如何在 React Native 中进行网络调用;它就像在其他任何 Web 应用中使用fetch()一样。
最后,你实现了通过仅在滚动到已渲染内容的底部后加载新项目来实现无限滚动的懒列表。此外,我们还添加了一个通过下拉手势刷新该列表的功能。
在下一章中,你将学习如何显示网络调用的进度,以及其他内容。
第二十一章:地理位置和地图
在本章中,您将了解 React Native 的地理位置和地图功能。您将从学习如何使用地理位置 API开始,然后继续使用MapView组件来绘制兴趣点和区域。为此,我们将使用react-native-maps包来实现地图。
本章的目标是概述 React Native 中可用的地理位置功能以及react-native-maps中的地图功能。
在本章中,我们将介绍我们将要涵盖的主题列表:
-
使用地理位置 API
-
渲染地图
-
标注兴趣点
技术要求
您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter21.
使用地理位置 API
网络应用用来确定用户位置的地理位置 API 也可以由 React Native 应用使用,因为相同的 API 已经被 polyfilled。除了地图之外,此 API 对于从移动设备的 GPS 获取精确坐标非常有用。然后,您可以使用这些信息向用户显示有意义的地理位置数据。
不幸的是,地理位置 API 返回的数据本身用处不大。您的代码必须进行一些工作,将其转换为有用的东西。例如,纬度和经度对用户来说没有意义,但您可以使用这些数据查找对用户有用的信息。这可能只是显示用户当前的位置那么简单。
让我们实现一个示例,使用 React Native 的地理位置 API查找坐标,然后使用这些坐标从 Google Maps API 中查找可读的地理位置信息。
在我们开始编码之前,让我们使用npx create-expo-app创建一个项目,然后添加位置模块:
npx expo install expo-location
接下来,我们需要在应用中配置位置权限。在移动应用中访问用户的位置需要用户明确授权。在本例的后续部分,我们将通过调用Location.requestForegroundPermissionsAsync()方法来实现这一点。这将向用户显示一个权限对话框,询问他们是否允许或拒绝位置访问。在继续使用位置方法之前,检查返回的状态以查看是否已授予权限非常重要。如果权限被拒绝,您应该在代码中优雅地处理它,并在必要时提示用户在应用设置中授权。
在真实的应用中,在我们请求权限之前,我们应该首先在应用配置中设置这些权限。我们可以通过向app.json文件添加插件来完成此操作:
{
"expo": {
"plugins": [
[
"expo-location",
{
"locationAlwaysAndWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location."
}
]
]
}
}
你应该尽早请求位置权限,例如当你的应用首次启动或当用户首次导航到需要位置信息的屏幕时。通过提前请求权限并妥善处理用户的选项,你可以确保你的应用按预期工作,同时尊重用户的隐私偏好。
当你有一个准备好的项目时,让我们看看 App 组件,你可以在这里找到它:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter22/where-am-i/App.tsx。这个组件的目标是在屏幕上渲染地理位置 API 返回的属性,以及查找用户的特定位置并显示它。
要从应用中获取位置,我们需要授予权限。在 App.tsx 中,我们已经调用了 Location.requestForegroundPermissionsAsync() 来实现这一点。
setPosition() 函数在几个地方用作回调,其任务是设置组件的状态。首先,setPosition() 设置经纬度坐标。通常,你不会直接显示这些数据,但这是一个示例,展示了作为地理位置 API 一部分可用的数据。其次,它使用 latitude 和 longitude 值来查找用户当前所在地的名称,使用的是 Google Maps API。
在示例中,API_KEY 值为空,你可以在以下链接获取:developers.google.com/maps/documentation/geocoding/start。
setPosition() 回调与 getCurrentPosition() 一起使用,它仅在组件挂载时调用一次。你还在 watchPosition() 中使用 setPosition(),它会在用户的位置发生变化时调用回调。
iOS 模拟器和 Android Studio 允许你通过菜单选项更改位置。你不必每次想要测试更改位置时都在物理设备上安装你的应用。
让我们看看当位置数据加载后这个屏幕看起来像什么:
图 21.1:位置数据
获取的地址信息可能比经纬度数据更有用,对于需要查找周围建筑或公司的应用来说效果很好。甚至比物理地址文本更好的是,在地图上可视化用户的物理位置;你将在下一节中学习如何做到这一点。
渲染地图
来自 react-native-maps 的 MapView 组件是你在 React Native 应用中渲染地图的主要工具。它提供了广泛的工具来渲染地图、标记、多边形、热图等。
你可以在网站上找到有关 react-native-maps 的更多信息:github.com/react-native-maps/react-native-maps。
现在我们来实现一个基本的MapView组件,看看你能够得到什么:
import { View, StatusBar } from "react-native";
import MapView from "react-native-maps";
import styles from "./styles";
StatusBar.setBarStyle("dark-content");
export default () => (
<View style={styles.container}>
<MapView style={styles.mapView} showsUserLocation followsUserLocation />
</View>
);
你传递给MapView的两个布尔属性为你做了很多工作。showsUserLocation属性会激活地图上的标记,表示运行此应用程序的设备的物理位置。followsUserLocation属性告诉地图在设备移动时更新位置标记。
这里是生成的地图:
图 21.2:当前位置
设备的当前位置在地图上被清楚地标记出来。默认情况下,兴趣点也会在地图上渲染。这些是用户附近的事物,以便他们可以看到周围的情况。
通常,在设置showsUserLocation时使用followsUserLocation是一个好主意。这使得地图缩放到用户所在的位置。
在下一节中,你将学习如何在你的地图上注释兴趣点。
注释兴趣点
注释正如其名:在基本地图地理之上渲染的附加信息。当你渲染MapView组件时,默认情况下你会得到注释。MapView组件可以渲染用户的当前位置以及用户周围的兴趣点。这里的挑战可能是你想要显示与你的应用程序相关的兴趣点,而不是默认渲染的那些。
在本节中,你将学习如何在地图上绘制特定位置的标记,以及如何在地图上绘制区域。
绘制点
让我们绘制一些当地的酿酒厂!这是你如何将注释传递给MapView组件的方法:
<MapView
style={styles.mapView}
showsPointsOfInterest={false}
showsUserLocation
followsUserLocation
>
<Marker
title="Duff Brewery"
description="Duff beer for me, Duff beer for you"
coordinate={{
latitude: 43.8418728,
longitude: -79.086082,
}}
/>
{...}
</MapView>
在这个例子中,我们通过将showsPointsOfInterest属性设置为false来放弃了这一功能。让我们看看这些酿酒厂的位置在哪里:
图 21.3:绘制点
当你按下显示地图上酿酒厂位置的标记时,会显示呼出窗口。你给<Marker>提供的title和description属性值用于渲染此文本。
绘制叠加层
在本章的最后部分,你将学习如何渲染区域叠加层。将区域想象为连接几个点的连线画,而一个点是一个单独的纬度/经度坐标。
区域可以服务于许多目的。在我们的例子中,我们将创建一个区域,显示我们更有可能找到 IPA 饮用者还是黑啤饮用者的地方。你可以点击此链接查看完整代码的样子:github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter22/plotting-overlays/App.tsx。以下是代码的 JSX 部分的样子:
<View style={styles.container}>
<View>
<Text style={ipaStyles} onPress={onClickIpa}>
IPA Fans
</Text>
<Text style={stoutStyles} onPress={onClickStout}>
Stout Fans
</Text>
</View>
<MapView
style={styles.mapView}
showsPointsOfInterest={false}
initialRegion={{
latitude: 43.8486744,
longitude: -79.0695283,
latitudeDelta: 0.002,
longitudeDelta: 0.04,
}}
>
{overlays.map((v, i) => (
<Polygon
key={i}
coordinates={v.coordinates}
strokeColor={v.strokeColor}
strokeWidth={v.strokeWidth}
/>
))}
</MapView>
</View>
区域数据由几个定义区域形状和位置的 纬度/经度 坐标组成。区域被放置在 overlays 状态变量中,我们将它们映射到 Polygon 组件。其余的代码主要关于处理当两个文本链接被按下时的状态。
默认情况下,IPA 区域的渲染方式如下:
图 21.4:IPA 粉丝
当按下 Stout 粉丝 按钮,IPA 覆盖层将从地图中移除,并添加 stout 区域:
图 21.5:Stout 粉丝
当你需要突出显示一个区域而不是一个 纬度/经度 点或地址时,覆盖层非常有用。例如,它可能是一个用于在所选区域或社区中寻找出租公寓的应用程序。
摘要
在本章中,你学习了 React Native 中的地理位置和地图绘制。地理位置 API 与其网络版本的工作方式相同。在 React Native 应用程序中使用地图的唯一可靠方法是安装第三方 react-native-maps 包。
你看到了基本的 MapView 组件配置以及它们如何跟踪用户的位置并显示相关的兴趣点。然后,你看到了如何绘制你自己的兴趣点和感兴趣的区域。
在下一章中,你将学习如何使用类似于 HTML 表单控件的 React Native 组件来收集用户输入。
第二十二章:收集用户输入
在 Web 应用中,你可以从所有浏览器上看起来和表现相似的 HTML 表单元素中收集用户输入。在使用原生 UI 平台时,收集用户输入更为复杂。
在本章中,你将学习如何使用各种 React Native 组件来收集用户输入。这些包括文本输入、从选项列表中选择、复选框和日期/时间选择器。所有这些都在注册或登录流程以及购买表单的每个应用中使用。创建此类表单的经验非常有价值,本章将帮助你了解如何在未来的应用中创建任何表单。你将了解 iOS 和 Android 之间的差异以及如何为你的应用实现适当的抽象。
本章将涵盖以下主题:
-
收集文本输入
-
从选项列表中选择
-
在开和关之间切换
-
收集日期/时间输入
技术要求
你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter22。
收集文本输入
实现文本输入时有很多需要考虑的因素。例如,是否应该有占位文本?这是否是敏感数据,不应该在屏幕上显示?是否应该在用户移动到另一个字段时处理文本?
在 Web 应用中,有一个特殊的<input>HTML 元素,允许你收集用户输入。在 React Native 中,我们使用TextInput组件来达到这个目的。让我们构建一个示例,渲染几个<TextInput>组件的实例:
function Input(props: InputProps) {
return (
<View style={styles.textInputContainer}>
<Text style={styles.textInputLabel}>{props.label}</Text>
<TextInput style={styles.textInput} {...props} />
</View>
);
}
我们已经实现了Input组件,我们将多次重用它。让我们看看几个文本输入的使用案例:
export default function CollectingTextInput() {
const [changedText, setChangedText] = useState("");
const [submittedText, setSubmittedText] = useState("");
return (
<View style={styles.container}>
<Input label="Basic Text Input:" />
<Input label="Password Input:" secureTextEntry />
<Input label="Return Key:" returnKeyType="search" />
<Input label="Placeholder Text:" placeholder="Search" />
<Input
label="Input Events:"
onChangeText={(e) => {
setChangedText(e);
}}
onSubmitEditing={(e) => {
setSubmittedText(e.nativeEvent.text);
}}
onFocus={() => {
setChangedText("");
setSubmittedText("");
}}
/>
<Text>Changed: {changedText}</Text>
<Text>Submitted: {submittedText}</Text>
</View>
);
}
我不会深入探讨每个<TextInput>组件的功能;Input组件中有标签解释了这一点。让我们看看这些组件在屏幕上的样子:
图 22.1:文本输入的变体
纯文本输入显示已输入的文本。密码输入字段不显示任何字符。占位文本在输入为空时显示。已更改的文本状态也显示出来。你无法看到已提交的文本状态,因为我没有在虚拟键盘上按下已提交按钮之前截图。
让我们看看通过returnKeyType属性更改返回键文本的输入元素的虚拟键盘:
图 22.2:按键文本已更改的键盘
当键盘的返回键反映了用户按下它时将要发生的事情,用户会感到与应用程序更加协调。
另一个常见的用例是更改键盘类型。通过将keyboardType属性提供给TextInput组件,你将看到不同类型的键盘。当你需要输入 PIN 码或电子邮件地址时,这很方便。以下是一个numeric键盘的示例:
图 22.3:数字键盘类型
现在你已经熟悉了收集文本输入,是时候学习如何从选项列表中选择一个值了。
从选项列表中选择
在 Web 应用程序中,你通常使用<select>元素让用户从选项列表中进行选择。React Native 自带一个Picker组件,它在 iOS 和 Android 上都可用,但为了减少 React Native 应用程序的大小,Meta 团队决定在未来版本中删除它,并将Picker提取到自己的包中。要使用该包,首先,在一个干净的项目中运行以下命令:
npx expo install @react-native-picker/picker
根据用户所在的平台,对这个组件进行样式设置涉及一些技巧,所以让我们将这些内容全部隐藏在一个通用的Select组件中。以下是Select.ios.js模块:
export default function Select(props: SelectProps) {
return (
<View style={styles.pickerHeight}>
<View style={styles.pickerContainer}>
<Text style={styles.pickerLabel}>{props.label}</Text>
<Picker style={styles.picker} {...props}>
{props.items.map((i) => (
<Picker.Item key={i.label} {...i} />
))}
</Picker>
</View>
</View>
);
}
对于一个简单的Select组件来说,这确实是一个很大的开销。实际上,要样式化 React Native 的Picker组件相当困难,因为它在 iOS 和 Android 上的外观完全不同。尽管如此,我们仍然希望使其更加跨平台。
这里是Select.android.js模块:
export default function Select(props: SelectProps) {
return (
<View>
<Text style={styles.pickerLabel}>{props.label}</Text>
<Picker {...props}>
{props.items.map((i) => (
<Picker.Item key={i.label} {...i} />
))}
</Picker>
</View>
);
}
这是样式的样子:
container: {
flex: 1,
flexDirection: "column",
backgroundColor: "ghostwhite",
justifyContent: "center",
},
pickersBlock: {
flex: 2,
flexDirection: "row",
justifyContent: "space-around",
alignItems: "center",
},
pickerHeight: {
height: 250,
},
如同通常的container和pickersBlock样式,我们定义了屏幕的基本布局。接下来,让我们看看Select组件的样式:
pickerContainer: {
flex: 1,
flexDirection: "column",
alignItems: "center",
backgroundColor: "white",
padding: 6,
height: 240,
},
pickerLabel: {
fontSize: 14,
fontWeight: "bold",
},
picker: {
width: 150,
backgroundColor: "white",
},
selection: {
flex: 1,
textAlign: "center",
},
现在,你可以渲染你的Select组件。以下是App.js文件的样子:
const sizes = [
{ label: "", value: null },
{ label: "S", value: "S" },
{ label: "M", value: "M" },
{ label: "L", value: "L" },
{ label: "XL", value: "XL" },
];
const garments = [
{ label: "", value: null, sizes: ["S", "M", "L", "XL"] },
{ label: "Socks", value: 1, sizes: ["S", "L"] },
{ label: "Shirt", value: 2, sizes: ["M", "XL"] },
{ label: "Pants", value: 3, sizes: ["S", "L"] },
{ label: "Hat", value: 4, sizes: ["M", "XL"] },
];
在这里,我们为我们的Select组件定义了默认值。让我们看看最终的SelectingOptions组件:
export default function SelectingOptions() {
const [availableGarments, setAvailableGarments] = useState<typeof garments>(
[]
);
const [selectedSize, setSelectedSize] = useState<string | null>(null);
const [selectedGarment, setSelectedGarment] = useState<number | null>(null);
使用这些钩子,我们已经实现了选择器的状态。接下来,我们将使用并将它们传递到组件中:
<View style={styles.container}>
<View style={styles.pickersBlock}>
<Select
label="Size"
items={sizes}
selectedValue={selectedSize}
onValueChange={(size: string) => {
setSelectedSize(size);
setSelectedGarment(null);
setAvailableGarments(
garments.filter((i) => i.sizes.includes(size))
);
}}
/>
<Select
label="Garment"
items={availableGarments}
selectedValue={selectedGarment}
onValueChange={(garment: number) => {
setSelectedGarment(garment);
}}
/>
</View>
<Text style={styles.selection}>{selectedSize && selectedGarment && `${selectedSize} ${garments.find((i) => i.value === selectedGarment)?.label}`}</Text>
</View>
这个示例的基本思想是第一个选择器中选中的选项会改变第二个选择器中可用的选项。当第二个选择器改变时,标签会显示selectedSize和selectedGarment作为字符串。以下是屏幕的显示方式:
图 22.4:从选项列表中选择
Size选择器显示在屏幕的左侧。当Size值改变时,屏幕右侧Garment选择器中可用的值会改变,以反映尺寸的可用性。两个选择器之后会以字符串的形式显示当前的选择。
这是我们app在 Android 设备上的样子:
图 22.5:在 Android 上从选项列表中选择
当Picker组件的 iOS 版本渲染一个可滚动的选项列表时,Android 版本只提供打开对话框模态以选择选项的按钮。
在接下来的部分,你将了解在开和关状态之间切换的按钮。
在开和关之间切换
在网页表单中,您还会看到另一个常见元素,即复选框。例如,想想在您的设备上切换 Wi-Fi 或蓝牙。React Native 有一个Switch组件,在 iOS 和 Android 上都能工作。幸运的是,这个组件比Picker组件更容易样式化。让我们看看您可以实现的简单抽象,为您的开关提供标签:
type CustomSwitchProps = SwitchProps & {
label: string;
};
export default function CustomSwitch(props: CustomSwitchProps) {
return (
<View style={styles.customSwitch}>
<Text>{props.label}</Text>
<Switch {...props} />
</View>
);
}
现在,让我们学习如何使用几个开关来控制应用程序状态:
export default function TogglingOnAndOff() {
const [first, setFirst] = useState(false);
const [second, setSecond] = useState(false);
return (
<View style={styles.container}>
<Switch
label="Disable Next Switch"
value={first}
disabled={second}
onValueChange={setFirst}
/>
<Switch
label="Disable Previous Switch"
value={second}
disabled={first}
onValueChange={setSecond}
/>
</View>
);
}
这两个开关相互切换对方的disabled属性。当第一个开关被切换时,会调用setFirst()函数,这将更新第一个状态值。根据first的当前值,它将被设置为true或false。第二个开关的工作方式相同,但它使用setSecond()和第二个状态值。
打开一个开关将禁用另一个开关,因为我们已经将每个开关的disabled属性值设置为另一个开关的状态。例如,第二个开关有disabled={first},这意味着当第一个开关打开时,它将被禁用。以下是 iOS 上的屏幕截图:
图 22.6:iOS 上的开关切换
这是 Android 上的相同屏幕截图:
图 22.7:Android 上的开关切换
如您所见,我们的CustomSwitch组件在 Android 和 iOS 上实现了相同的功能,同时使用了一个组件来处理这两个平台。在下一节中,您将了解如何收集日期/时间输入。
收集日期/时间输入
在本章的最后部分,您将学习如何实现日期/时间选择器。React Native 文档建议使用@react-native-community/datetimepicker独立日期/时间选择器组件,这意味着处理组件之间的跨平台差异取决于您。
要安装datetimepicker,请在项目中运行以下命令:
npx expo install @react-native-community/datetimepicker
因此,让我们从 iOS 的DatePicker组件开始:
export default function DatePicker(props: DatePickerProps) {
return (
<View style={styles.datePickerContainer}>
<Text style={styles.datePickerLabel}>{props.label}</Text>
<DateTimePicker
mode="date"
display="spinner"
value={props.value}
onChange={(event, date) => {
if (date) {
props.onChange(date);
}
}}
/>
</View>
);
}
这个组件没有太多内容;它只是给DateTimePicker组件添加了一个标签。Android 版本的工作方式略有不同;更好的方法是使用命令式 API。让我们看看实现方式:
export default function DatePicker({label, value, onChange }: DatePickerProps) {
return (
<View style={styles.datePickerContainer}>
<Text style={styles.datePickerLabel}>{label}</Text>
<Text
onPress={() => {
DateTimePickerAndroid.open({
value: value,
mode: "date",
onChange: (event, date) => {
if (event.type === "set" && date) {
onChange(date);
}
},
});
}}
>
{value.toLocaleDateString()}
</Text>
</View>
);
}
两个日期选择器的关键区别在于,Android 版本不使用 iOS 中类似的 React Native 组件DateTimePicker。相反,我们必须使用命令式DateTimePickerAndroid.open() API。当用户点击我们组件渲染的日期文本并打开日期选择器对话框时,将触发此 API。好消息是,我们组件的这个部分隐藏了这个 API 在声明式组件后面。
我还实现了一个遵循此精确模式的时间选择器组件。因此,而不是在这里列出代码,我建议您从 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter2 下载本书的代码,这样您可以看到细微的差异并运行示例。
现在,让我们学习如何使用我们的日期和时间选择器组件:
export default function CollectingDateTimeInput() {
const [date, setDate] = useState(new Date());
const [time, setTime] = useState(new Date());
return (
<View style={styles.container}>
<DatePicker
label="Pick a date, any date:"
value={date}
onChange={setDate}
/>
<TimePicker
label="Pick a time, any time:"
value={time}
onChange={setTime}
/>
</View>
);
}
太棒了!现在,我们有了 DatePicker 和 TimePicker 组件,可以帮助我们在应用中选择日期和时间。此外,它们在 iOS 和 Android 上都适用。让我们看看选择器在 iOS 上的样子:
图 22.8:iOS 日期和时间选择器
如您所见,iOS 日期和时间选择器使用了您在本章早期学习过的 Picker 组件。Android 的选择器看起来大不相同;现在让我们来看看:
图 22.9:Android 日期选择器
Android 版本与 iOS 日期/时间选择器的做法完全不同,但我们可以在两个平台上使用相同的 DatePicker 组件。这就结束了本章的内容。
摘要
在本章中,我们学习了各种类似于我们习惯的网页表单元素的 React Native 组件。我们首先学习了文本输入以及每个文本输入都有自己的虚拟键盘需要考虑。接下来,我们学习了 Picker 组件,它允许用户从选项列表中选择一个项目。然后,我们学习了 Switch 组件,它有点像复选框。有了这些组件,您将能够构建任何复杂性的表单。
在最后一节中,我们学习了如何实现适用于 iOS 和 Android 的通用日期/时间选择器。在下一章中,我们将学习 React Native 中的模态对话框。
第二十三章:响应用户手势
你在这本书中迄今为止实现的全部示例都依赖于用户手势。在传统的网络应用中,你主要处理鼠标事件。然而,触摸屏依赖于用户用手指操纵元素,这与鼠标操作有根本的不同。
在本章中,首先,你将学习滚动。这可能是除了触摸之外最常见的手势。然后,你将学习在用户与你的组件交互时提供适当的反馈级别。最后,你将实现可滑动的组件。
本章的目标是向你展示 React Native 内部的 手势响应系统 如何工作,以及该系统通过组件暴露的一些方式。
在本章中,我们将涵盖以下主题:
-
用手指滚动
-
提供触觉反馈
-
使用可滑动和可取消的组件
技术要求
你可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter2。
用手指滚动
在网络应用中,滚动是通过使用鼠标指针来回拖动滚动条或上下滚动,或者通过旋转鼠标滚轮来完成的。这在移动设备上不起作用,因为没有鼠标。所有操作都由屏幕上的手势控制。
例如,如果你想向下滚动,你用你的拇指或食指通过在屏幕上移动手指来物理地向上拉内容。
这样的滚动很难实现,但它变得更复杂。当你在一个移动屏幕上滚动时,会考虑拖动动作的速度。你快速拖动屏幕,然后放手,屏幕会根据你移动手指的速度继续滚动。你也可以在滚动过程中触摸屏幕来停止滚动。
幸运的是,你不必处理大部分这些事情。ScrollView 组件为你处理了大部分滚动复杂性。实际上,你已经在 第二十章,渲染项目列表 中使用了 ScrollView 组件。ListView 组件已经内置了 ScrollView。
你可以通过实现手势生命周期方法来调整用户交互的低级部分。你可能永远不需要这样做,但如果你对此感兴趣,你可以在 reactnative.dev/docs/gesture-responder-system 上阅读有关内容。
你可以在 ListView 之外使用 ScrollView。例如,如果你只是渲染任意内容,如文本和其他小部件:不是列表,换句话说:你只需将其包裹在 <ScrollView> 中。以下是一个例子:
export default function App() {
return (
<View style={styles.container}>
<ScrollView style={styles.scroll}>
{new Array(20).fill(null).map((v, i) => (
<View key={i}>
<Text style={[styles.scrollItem, styles.text]}>Some text</Text>
<ActivityIndicator style={styles.scrollItem} size="large" />
<Switch style={styles.scrollItem} />
</View>
))}
</ScrollView>
</View>
);
}
ScrollView 组件本身并不很有用:它存在是为了包裹其他组件。它需要高度才能正确地工作。以下是滚动样式的样子:
scroll: {
height: 1,
alignSelf: "stretch",
},
height属性设置为1,但alignSelf的拉伸值允许项目正确显示。以下是最终结果的样子:
图 23.1:ScrollView
当你向下拖动内容时,屏幕右侧有一个垂直滚动条。如果你运行这个示例,你可以尝试各种手势,比如让内容自动滚动然后停止。
当用户在屏幕上滚动内容时,他们会收到视觉反馈。用户在触摸屏幕上的某些元素时也应该收到视觉反馈。
提供触摸反馈
在这本书中,你迄今为止使用的 React Native 示例已经使用了纯文本作为按钮或链接。在 Web 应用程序中,为了让文本看起来可以点击,你只需用适当的链接将其包裹起来。React Native 中没有链接组件,所以你可以将文本样式设置为按钮样式。
尝试在移动设备上将文本样式设置为链接的问题在于它们太难按了。按钮提供了更大的目标供手指操作,并且更容易应用触摸反馈。
让我们给一些文本设置按钮样式。这是一个很好的第一步,因为它使文本看起来可以触摸。但你也想当用户开始与按钮交互时给用户提供视觉反馈。React Native 提供了几个组件来帮助实现这一点:
-
TouchableOpacity -
TouchableHighlight -
可按压 API
但在深入代码之前,让我们看看当用户与这些组件交互时,它们在视觉上看起来是什么样子,从TouchableOpacity开始:
图 23.2:TouchableOpacity
这里渲染了三个按钮。顶部的一个,标有不透明度,当前正被用户按下。按钮在被按下时变暗,这为用户提供重要的视觉反馈。
让我们看看当按下高亮按钮时它看起来是什么样子:
图 23.3:可触摸高亮
与在按下时改变不透明度不同,TouchableHighlight组件在按钮上添加了一个高亮层。在这种情况下,它使用的是比字体和边框颜色中使用的板岩灰色更透明的版本。
最后一个按钮的例子是由Pressable组件提供的。Pressable API 被引入作为核心组件包装器,允许对其定义的任何子组件的不同按下交互阶段。使用这样的组件,我们可以处理onPressIn、onPressOut(我们将在下一章中探讨)和onLongPress回调,并实现我们想要的任何触摸反馈。让我们看看点击PressableButton时它看起来是什么样子:
图 23.4:可按压按钮
如果我们继续按住这个按钮,我们将得到一个onLongPress事件,按钮将更新:
图 23.5:长按按钮
实际上使用哪种方法并不重要。重要的是,你为用户提供了适当的触摸反馈,让他们在与按钮交互时感到舒适。实际上,你可能会在同一个应用中使用所有这些方法,但用于不同的事情。
让我们创建一个OpacityButton和HighlightButton组件,这使得使用前两种方法变得容易:
type ButtonProps = {
label: string;
onPress: () => void;
};
export const OpacityButton = ({ label, onPress }: ButtonProps) => {
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.5}
>
<Text style={styles.buttonText}>{label}</Text>
</TouchableOpacity>
);
};
export const HighlightButton = ({ label, onPress }: ButtonProps) => {
return (
<TouchableHighlight
style={styles.button}
underlayColor="rgba(112,128,144,0.3)"
onPress={onPress}
>
<Text style={styles.buttonText}>{label}</Text>
</TouchableHighlight>
);
};
这里是创建此按钮所使用的样式:
button: {
padding: 10,
margin: 5,
backgroundColor: "azure",
borderWidth: 1,
borderRadius: 4,
borderColor: "slategrey",
},
buttonText: {
color: "slategrey",
},
现在让我们看看基于 Pressable API 的按钮:
const PressableButton = () => {
const [text, setText] = useState("Not Pressed");
return (
<Pressable
onPressIn={() => setText("Pressed")}
onPressOut={() => setText("Press")}
onLongPress={() => {
setText("Long Pressed");
}}
delayLongPress={500}
style={({ pressed }) => [
{
opacity: pressed ? 0.5 : 1,
},
styles.button,
]}
>
<Text>{text}</Text>
</Pressable>
);
};
这里是如何将这些按钮放入主应用模块中的:
export default function App() {
return (
<View style={styles.container}>
<OpacityButton onPress={() => {}} label="Opacity" />
<HighlightButton onPress={() => {}} label="Highlight" />
<PressableButton />
</View>
);
}
注意,onPress 回调实际上并没有做任何事情:我们传递它们是因为它们是一个必需的属性。
在下一节中,你将了解当用户在屏幕上滑动元素时提供反馈。
使用可滑动和可取消组件
与移动网页应用相比,原生移动应用更容易使用的一部分原因是它们感觉更直观。使用手势,你可以快速了解事物的工作方式。例如,用手指在屏幕上滑动一个元素是一个常见的动作,但这个动作必须是可发现的。
假设你正在使用一个应用,并且你并不完全确定屏幕上的某个功能是什么。所以,你用手指按下并尝试拖动元素。它开始移动。不确定会发生什么,你抬起手指,元素就回到了原位。你刚刚发现了这个应用程序的一部分是如何工作的。
你将使用Scrollable组件来实现这种可滑动和可取消的行为。你可以创建一个相对通用的组件,允许用户将文本从屏幕上滑动掉,当这种情况发生时,调用回调函数。在我们查看通用组件本身之前,让我们看看将渲染滑动组件的代码:
export default function SwipableAndCancellable() {
const [items, setItems] = useState(
new Array(10).fill(null).map((v, id) => ({ id, name: "Swipe Me" }))
);
function onSwipe(id: number) {
return () => {
setItems(items.filter((item) => item.id !== id));
};
}
return (
<View style={styles.container}>
{items.map((item) => (
<Swipeable
key={item.id}
onSwipe={onSwipe(item.id)}
name={item.name}
width={200}
/>
))}
</View>
);
}
这将在屏幕上渲染 10 个<Swipeable>组件。让我们看看这会是什么样子:
图 23.6:带有可滑动组件的屏幕
现在,如果你开始向左滑动这些项目之一,它就会移动。下面是这个动作的样子:
图 23.7:已滑动的组件
如果你没有滑动足够远,手势将被取消,项目将回到原位,正如预期的那样。如果你完全滑动,项目将从列表中完全移除,屏幕上的项目将填充空出的空间。
现在,让我们看看Swipeable组件本身:
type SwipeableProps = {
name: string;
width: number;
onSwipe: () => void;
};
export default function Swipeable({ name, width, onSwipe }: SwipeableProps) {
function onScroll(e: NativeSyntheticEvent<NativeScrollEvent>) {
console.log(e.nativeEvent.contentOffset.x);
e.nativeEvent.contentOffset.x >= width && onSwipe();
}
return (
<View style={styles.swipeContainer}>
<ScrollView
horizontal
snapToInterval={width}
showsHorizontalScrollIndicator={false}
scrollEventThrottle={10}
onScroll={onScroll}
>
<View style={[styles.swipeItem, { width }]}>
<Text style={styles.swipeItemText}>{name}</Text>
</View>
<View style={[styles.swipeBlank, { width }]} />
</ScrollView>
</View>
);
}
组件接受width属性来指定宽度本身,snapToInterval来创建具有滑动取消的翻页行为,以及处理我们可以在其中调用onSwipe回调以从列表中删除项目的距离。
要启用向左滑动,我们需要在包含文本的组件旁边添加一个空白组件。以下是用于此组件的样式:
swipeContainer: {
flex: 1,
flexDirection: "row",
width: 200,
height: 30,
marginTop: 50,
},
swipeItem: {
height: 30,
backgroundColor: "azure",
justifyContent: "center",
borderWidth: 1,
borderRadius: 4,
borderColor: "slategrey",
},
swipeItemText: {
textAlign: "center",
color: "slategrey",
},
swipeItemBlank: {
height: 30,
},
swipeItemBlank样式与swipeItem具有相同的高度,但除此之外没有其他内容。它是不可见的。
我们现在已经涵盖了本章的所有主题。
摘要
在本章中,我们介绍了在原生平台上的手势与移动网页平台相比有显著差异的观点。我们首先查看ScrollView组件,以及它如何通过为包装组件提供原生滚动行为来简化生活。
接下来,我们花了一些时间来实现带有触觉反馈的按钮。这是在移动网页上正确实现的一个棘手领域。我们学习了如何使用TouchableOpacity、TouchableHighlight和Pressable API 组件来完成这项工作。
最后,我们实现了一个通用的Swipeable组件。滑动是一个常见的移动模式,它允许用户发现事物的工作方式,而不会感到害怕。
在下一章中,我们将学习如何使用 React Native 控制动画。
第二十四章:显示进度
本章全部关于向用户传达进度。React Native 有不同类型的组件,用于处理你想要传达的不同类型的进度。首先,你将学习为什么需要在应用中传达进度。然后,你将学习如何实现进度指示器和进度条。最后,你将看到具体的示例,展示如何在数据加载时使用进度指示器进行导航,以及如何使用进度条来传达一系列步骤中的当前位置。
本章涵盖了以下内容:
-
理解进度和可用性
-
指示进度
-
测量进度
-
探索导航指示器
-
步骤进度
技术要求
你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter2。
理解进度和可用性
想象一下,你有一个没有窗户且不发出声音的微波炉。与它交互的唯一方式是按下标有“烹饪”的按钮。这个设备听起来可能很荒谬,但这就是许多软件用户面临的情况:没有进度指示。微波炉在烹饪吗?如果是,我们怎么知道它什么时候会完成?
改善微波炉状况的一种方法是在其中添加蜂鸣声。这样,用户在按下烹饪按钮后就能得到反馈。你已经克服了一个障碍,但用户仍然会问,“我的食物什么时候能准备好?”在你破产之前,你最好添加某种进度测量显示,比如计时器。
并非 UI 程序员不理解这种可用性关注的基本原则;只是他们有事情要做,这类事情在优先级上只是被忽略了。在 React Native 中,有组件可以给用户提供不确定的进度反馈和精确的进度测量。如果你想要良好的用户体验,始终将这些事情作为首要任务是明智的。
现在你已经理解了进度在可用性中的作用,是时候学习如何在 React Native UI 中指示进度了。
指示进度
在本节中,你将学习如何使用ActivityIndicator组件。正如其名称所暗示的,当你需要向用户指示正在发生某事时,你将渲染此组件。实际的进度可能是不确定的,但至少你有一个标准化的方式来显示正在发生某事,尽管目前还没有结果可以显示。
让我们创建一个示例,以便你可以看到这个组件的外观。以下是App组件:
import React from "react";
import { View, ActivityIndicator } from "react-native";
import styles from "./styles";
export default function App() {
return (
<View style={styles.container}>
<ActivityIndicator size="large" />
</View>
);
}
<ActivityIndicator />组件是平台无关的。以下是它在 iOS 上的外观:
图 24.1:iOS 上的活动指示器
它在屏幕中间渲染一个动画旋转器。这是在size属性中指定的较大旋转器。ActivityIndicator旋转器也可以是小的,如果您在另一个较小的元素内部渲染它,这更有意义。
现在,让我们看看这在一个 Android 设备上的样子:
图 24.2:Android 上的活动指示器
旋转器的样子不同,正如它应该的那样,但您的应用在两个平台上传达了相同的信息:您正在等待某事。
此示例会无限旋转。但不用担心:接下来会有一个更现实的进度指示器示例,向您展示如何处理导航和加载 API 数据。
探索导航指示器
在本章的早期部分,您已经了解了ActivityIndicator组件。在本节中,您将学习如何在加载数据的程序导航中使用它。例如,用户从页面或屏幕一导航到页面二。然而,页面二需要从 API 获取数据,以便向用户显示。因此,当这个网络调用正在进行时,显示一个进度指示器比显示一个没有有用信息的屏幕更有意义。
做这件事实际上有点棘手,因为您必须确保每次用户导航到该屏幕时,屏幕所需的数据都是从 API 中获取的。您的目标应该是以下这些:
-
让
Navigator组件自动获取即将渲染的场景的 API 数据。 -
使用 API 调用返回的 promise 作为显示旋转器和在 promise 解析后隐藏它的手段。
由于您的组件可能不关心是否显示旋转器,让我们将其实现为一个通用的Wrapper组件:
export function LoadingWrapper({ children }: Props) {
const [loading, setLoading] = useState(true);
useEffect(() => {
setTimeout(() => {
setLoading(false);
}, 1000);
}, []);
if (loading) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" />
</View>
);
} else {
return children;
}
}
此LoadingWrapper组件接受一个children组件,并在loading条件下返回它(即渲染)。它有一个带有超时的useEffect()钩子,当它解析时,它会将loading状态更改为false。如您所见,loading状态决定了是渲染旋转器还是children组件。
在LoadingWrapper组件已经就位的情况下,让我们看看您将使用react-navigation的第一个屏幕组件:
const First = ({ navigation }: Props) => (
<LoadingWrapper>
<View style={styles.container}>
<Button title="Second" onPress={() => navigation.navigate("Second")} />
<Button title="Third" onPress={() => navigation.navigate("Third")} />
</View>
</LoadingWrapper>
);
此组件渲染了一个由我们之前创建的LoadingWrapper组件包裹的布局。它包裹了整个屏幕,以便在setTimeout方法挂起时显示一个旋转器。这是一种有用的方法,可以在一个地方隐藏额外的逻辑,并在每个页面上重用它。在实际应用中,您可以通过传递额外的属性到LoadingWrapper来完全控制该屏幕的loading状态。
测量进度
仅指示进度正在进行的缺点是用户看不到结束的迹象。这会导致不安的感觉,就像你在没有计时器的微波炉里等待食物烹饪时一样。当你知道已经完成了多少进度以及还剩下多少进度时,你会感觉更好。这就是为什么在可能的情况下,始终使用确定性进度条会更好。
与ActivityIndicator组件不同,React Native 中没有跨平台的进度条组件。因此,我们将使用react-native-progress库来渲染进度条。
在过去,React-Native 有专门用于显示 iOS 和 Android 进度条的组件,但由于 React-Native 的大小优化,Meta 团队正在努力将这些组件移动到单独的包中。因此,ProgressViewIOS和ProgressBarAndroid已经被移动到 React-Native 库之外。
现在,让我们构建应用程序将使用的ProgressBar组件:
import * as Progress from "react-native-progress";
type ProgressBarProps = {
progress: number;
};
export default function ProgressBar({ progress }: ProgressBarProps) {
return (
<View style={styles.progress}>
<Text style={styles.progressText}>{Math.round(progress * 100)}%</Text>
<Progress.Bar width={200} useNativeDriver progress={progress} />
</View>
);
}
ProgressBar组件接受progress属性并渲染标签和进度条。《Progress.Bar />组件接受一组属性,但我们只需要width、progress和useNativeDriver(用于更好的动画)。现在,让我们将这个组件用于App`组件:
export default function MeasuringProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
let timeoutRef: NodeJS.Timeout | null = null;
function updateProgress() {
setProgress((currentProgress) => {
if (currentProgress < 1) {
return currentProgress + 0.01;
} else {
return 0;
}
});
timeoutRef = setTimeout(updateProgress, 100);
}
updateProgress();
return () => {
timeoutRef && clearTimeout(timeoutRef);
};
}, []);
return (
<View style={styles.container}>
<ProgressBar progress={progress} />
</View>
);
}
初始时,<ProgressBar>组件渲染为 0%。在useEffect()钩子中,updateProgress()函数使用计时器来模拟你想要显示进度的真实过程。
在现实世界中,你可能永远不会使用计时器的模拟。然而,在某些特定场景下,这种方法可能非常有价值,例如在显示统计数据或监控文件上传到服务器的进度时。在这些情况下,即使你并不依赖于直接的计时器,你仍然可以访问到当前进度值,并可以使用它。
下面是这个屏幕的样子:
图 24.3:进度条
显示进度的定量度量很重要,这样用户就可以判断某件事需要多长时间。在下一节中,你将学习如何使用步骤进度条来显示用户在导航屏幕中的位置。
步骤进度
在这个最后的例子中,你将构建一个应用程序,显示用户通过预定义步骤的进度。例如,将表单分成几个逻辑部分并按这种方式组织它们,当用户完成一个部分时,他们就可以移动到下一个步骤。进度条对用户来说是一个有用的反馈。
你将在导航栏中插入进度条,位于标题下方,这样用户就可以知道他们已经走了多远以及还剩下多远。你还将重用本章前面使用的ProgressBar组件。
让我们先看看结果。在这个应用程序中,用户可以导航的屏幕有四个。这是第一页(场景)的样子:
图 24.4:第一个屏幕
标题下的进度条反映了用户已经完成了导航的 25%。让我们看看第三个屏幕的样子:
图 24.5:第三个屏幕
进度更新以反映用户在路由堆栈中的位置。让我们看看这里的App组件:github.com/PacktPublishing/React-and-React-Native-5E/blob/main/Chapter21/step-progress-new/App.tsx。
此应用有四个屏幕。渲染每个屏幕的组件存储在routes常量中,然后使用createNativeStackNavigator()配置堆栈导航器。创建routes数组的原因是它可以由initialParams传递给每个路由的progress参数使用。为了计算进度,我们取当前路由索引作为路由长度的值。
例如,Second位于数字 2 的位置(索引为 1 + 1),数组的长度为 4。这将使进度条达到 50%。
此外,下一页和上一页按钮调用navigation.navigate()时必须传递routeName,因此我们在screenOptions处理程序中添加了nextRouteName和prevRouteName变量。
摘要
在本章中,你学习了如何向用户展示幕后正在发生的事情。首先,我们讨论了为什么显示进度对于应用程序的可用性很重要。然后,我们实现了一个基本的屏幕,指示正在进行的进度。之后,我们实现了一个ProgressBar组件,用于测量特定的进度量。
指示器适用于不确定的进度。我们实现了在网络调用挂起时显示进度指示器的导航。在最后一节中,我们实现了一个进度条,显示了用户在预定义的步骤中的位置。
在下一章中,我们将探讨 React Native 地图和地理位置数据在实际中的应用。
第二十五章:显示模态屏幕
本章的目标是向您展示如何以不干扰当前页面的方式向用户展示信息。页面使用View组件并将其直接渲染到屏幕上。然而,有时会有一些重要的信息用户需要看到,但你又不想让他们离开当前页面。
你将从学习如何显示重要信息开始。通过了解哪些信息是重要的以及何时使用它,你将学习如何获取用户的确认:既适用于错误场景也适用于成功场景。然后,你将实现被动通知,向用户显示发生了某些事情。最后,你将实现模态视图,显示后台正在发生的事情。
本章将涵盖以下主题:
-
术语定义
-
获取用户确认
-
被动通知
-
活动模态
技术要求
你可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter2。
术语定义
在你开始实现警告、通知和确认之前,让我们花几分钟时间思考一下每一项的含义。我认为这很重要,因为如果你只是被动地通知用户关于错误的信息,它很容易被忽略。以下是我对您希望显示的信息类型的定义:
-
警告:刚刚发生了一些重要的事情,你需要确保用户能看到正在发生的情况。可能的话,用户需要确认这个警告。
-
确认:这是警告的一部分。例如,如果用户刚刚执行了一个操作,然后想要在继续之前确保操作成功,他们必须确认他们已经看到了信息,以便关闭模态框。确认也可以存在于警告中,提醒用户即将执行的操作。
-
通知:发生了一些事情,但并不足以完全阻止用户正在进行的活动。这些通常会在自己消失。
技巧在于尝试在信息值得了解但不是关键的情况下使用通知。只有在功能的工作流程无法在没有用户确认正在发生的事情的情况下继续时,才使用确认。在接下来的章节中,你将看到用于不同目的的警告和通知的示例。
获取用户确认
在本节中,你将学习如何显示模态视图以获取用户的确认。首先,你将学习如何实现一个成功的场景,其中操作产生了一个成功的成果,你希望用户意识到这一点。然后,你将学习如何实现一个错误场景,其中出了问题,你不想让用户在没有确认问题的情况下继续前进。
显示成功确认
让我们从实现一个作为用户成功执行操作的结果显示的模态视图开始。这是Modal组件,用于向用户展示确认模态:
type Props = ModalProps & {
onPressConfirm: () => void;
onPressCancel: () => void;
};
export default function ConfirmationModal({
onPressConfirm,
onPressCancel,
...modalProps
}: Props) {
return (
<Modal transparent onRequestClose={() => {}} {...modalProps}>
<View style={styles.modalContainer}>
<View style={styles.modalInner}>
<Text style={styles.modalText}>Dude, srsly?</Text>
<Text style={styles.modalButton} onPress={onPressConfirm}>
Yep
</Text>
<Text style={styles.modalButton} onPress={onPressCancel}>
Nope
</Text>
</View>
</View>
</Modal>
);
}
传递给ConfirmationModal的属性被转发到 React Native 的Modal组件。你很快就会明白原因。首先,让我们看看这个确认模态的外观:
图 25.1:确认模态
用户完成操作后显示的模态使用我们自己的样式和确认消息。它还有两个操作,但根据这个确认是预操作还是后操作,可能只需要一个。以下是用于此模态的样式:
modalContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
modalInner: {
backgroundColor: "azure",
padding: 20,
borderWidth: 1,
borderColor: "lightsteelblue",
borderRadius: 2,
alignItems: "center",
},
modalText: {
fontSize: 16,
margin: 5,
color: "slategrey",
},
modalButton: {
fontWeight: "bold",
margin: 5,
color: "slategrey",
},
使用 React Native 的Modal组件,你几乎可以随心所欲地设计你的确认模态视图的外观。把它们想象成常规视图,唯一的区别是它们是在其他视图之上渲染的。
大多数时候,你可能不会关心自己模态视图的样式。例如,在网页浏览器中,你可以简单地调用alert()函数,该函数在浏览器设置的窗口中显示文本。React Native 有类似的东西:Alert.alert()。这就是我们如何打开原生警告框的方式:
function toggleAlert() {
Alert.alert("", "Failed to do the thing...", [
{
text: "Dismiss",
},
]);
}
这是 iOS 上警告的显示效果:
图 25.2:iOS 上的确认警告
在功能方面,这里并没有什么真正的区别。这里有一个标题和其下的文本,但如果你想要的话,这些可以很容易地添加到模态视图中。真正的区别在于,这个模态看起来像 iOS 模态,而不是由应用设置的样式。让我们看看这个警告在 Android 上的显示效果:
图 25.3:Android 上的确认警告
这个模态看起来像 Android 模态,你不需要为其设置样式。我认为大多数情况下,使用警告框而不是模态框是更好的选择。让某些东西看起来像是 iOS 或 Android 的一部分是有意义的。然而,有时你需要更多控制模态的外观,例如在显示错误确认时。
渲染模态的方法与渲染警告的方法不同。然而,它们仍然是基于属性值变化的声明式组件。
错误确认
在显示成功确认部分学到的所有原则,在你需要用户确认错误时都是适用的。如果你需要更多控制显示方式,请使用模态框。例如,你可能想让模态框看起来是红色的,令人害怕的,就像这样:
图 25.4:错误确认模态
这是创建此外观所使用的样式。也许你想要更微妙一些,但重点是你可以按照自己的意愿来制作这个外观:
modalInner: {
backgroundColor: "azure",
padding: 20,
borderWidth: 1,
borderColor: "lightsteelblue",
borderRadius: 2,
alignItems: "center",
},
在modalInner样式属性中,我们定义了屏幕样式。接下来,我们将定义模态样式:
modalInnerError: {
backgroundColor: "lightcoral",
borderColor: "darkred",
},
modalText: {
fontSize: 16,
margin: 5,
color: "slategrey",
},
modalTextError: {
fontSize: 18,
color: "darkred",
},
modalButton: {
fontWeight: "bold",
margin: 5,
color: "slategrey",
},
modalButtonError: {
color: "black",
},
你用于成功确认的相同模态样式仍然在这里。这是因为错误确认模态需要许多相同的样式属性。
下面是如何将两者应用到Modal组件上:
const innerViewStyle = [styles.modalInner, styles.modalInnerError];
const textStyle = [styles.modalText, styles.modalTextError];
const buttonStyle = [styles.modalButton, styles.modalButtonError];
type Props = ModalProps & {
onPressConfirm: () => void;
onPressCancel: () => void;
};
export default function ErrorModal({
onPressConfirm,
onPressCancel,
...modalProps
}: Props) {
return (
<Modal transparent onRequestClose={() => {}} {...modalProps}>
<View style={styles.modalContainer}>
<View style={innerViewStyle}>
<Text style={textStyle}>Epic fail!</Text>
<Text style={buttonStyle} onPress={onPressConfirm}>
Fix it
</Text>
<Text style={buttonStyle} onPress={onPressCancel}>
Ignore it
</Text>
</View>
</View>
</Modal>
);
}
样式在传递给style组件属性之前被组合成数组。错误样式总是放在最后,因为如backgroundColor这样的冲突样式属性将被数组中后面的样式覆盖。
除了错误确认中的样式外,你还可以包括你想要的任何高级控件。这完全取决于你的应用程序如何让用户处理错误:例如,可能有一些可以采取的行动方案。
然而,更常见的情况是出了些问题,除了确保用户意识到这种情况外,你无能为力。在这些情况下,你可能只需显示一个警告:
图 25.5:错误警告
现在你已经能够显示需要用户参与的错误通知了,是时候了解不那么激进的、不会打断用户当前操作的通知了。
被动通知
在本章中你检查到的所有通知都需要用户的输入。这是出于设计考虑,因为这是重要的信息,你正在强迫用户查看。然而,你不想做得太过分。对于重要但忽略后不会改变生活的重要性的通知,你可以使用被动通知。这些通知以一种不那么引人注目的方式显示,并且不需要任何用户操作来关闭它们。
在本节中,你将创建一个使用react-native-root-toast库提供的Toast API的应用程序。之所以称为 Toast API,是因为显示的信息看起来像一块弹出的吐司。Toast是 Android 中显示一些不需要用户响应的基本信息的常用组件。由于 iOS 没有 Toast API,我们将使用一个在两个平台上都运行良好的类似 API 的库。
下面是App组件的样式:
export default function PassiveNotifications() {
return (
<RootSiblingParent>
<View style={styles.container}>
<Text
onPress={() => {
Toast.show("Something happened!", {
duration: Toast.durations.LONG,
});
}}
>
Show Notification
</Text>
</View>
</RootSiblingParent>
);
}
首先,我们应该将我们的应用程序包裹在RootSiblingParent组件中,然后我们就可以开始使用 Toast API 了。要打开一个 Toast,我们调用Toast.show方法。
下面是 Toast 通知的样式:
图 25.6:Android 的 Toast
在屏幕底部显示一条通知,内容为发生了某些事情!,并在短时间内消失。关键是通知不会太引人注目。
让我们看看相同的 Toast 在 iOS 设备上的样子:
图 25.7:iOS 的通知
在下一节中,你将了解活动模态,它向用户显示正在发生的事情。
活动模态
在本章的最后部分,您将实现一个显示进度指示器的模态框。想法是显示模态框,然后在 promise 解析时隐藏它。以下是通用Activity组件的代码,它显示带有ActivityIndicator的模态框:
type ActivityProps = {
visible: boolean;
size?: "small" | "large";
};
export default function Activity({ visible, size = "large" }: ActivityProps) {
return (
<Modal visible={visible} transparent>
<View style={styles.modalContainer}>
<ActivityIndicator size={size} />
</View>
</Modal>
);
}
您可能会想将 promise 传递给组件,以便它在 promise 解析时自动隐藏。我认为这不是一个好主意,因为那样您就必须将状态引入此组件。此外,它将依赖于 promise 才能运行。按照您实现此组件的方式,您可以根据visible属性单独显示或隐藏模态框。
这是 iOS 上活动模态的显示效果:
图 25.8:活动模态
在覆盖主视图的模态窗口上有一个半透明的背景,其中包含Fetch Stuff...链接。点击此链接,我们将看到活动加载器。以下是styles.js中创建此效果的方法:
modalContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.2)",
},
而不是将实际的Modal组件设置为透明,您可以在backgroundColor中设置透明度,这样看起来就像是一个覆盖层。现在,让我们看看控制此组件的代码:
export default function App() {
const [fetching, setFetching] = useState(false);
const [promise, setPromise] = useState(Promise.resolve());
function onPress() {
setPromise(
new Promise((resolve) => setTimeout(resolve, 3000)).then(() => {
setFetching(false);
})
);
setFetching(true);
}
return (
<View style={styles.container}>
<Activity visible={fetching} />
<Text onPress={onPress}>Fetch Stuff...</Text>
</View>
);
}
当按下获取链接时,会创建一个新的 promise 来模拟异步网络活动。然后,当 promise 解析时,您可以更改fetching状态回false,以便隐藏活动对话框。
摘要
在本章中,我们学习了向移动用户显示重要信息的需求。这有时需要用户的明确反馈,即使只是承认消息。在其他情况下,被动通知效果更好,因为它们比确认模态不那么侵扰。
我们可以使用两种工具向用户显示消息:模态框和警告框。模态框更灵活,因为它们就像常规视图一样。警告框适合显示纯文本,并且会为我们处理样式问题。在 Android 上,我们还有ToastAndroid接口。我们看到了在 iOS 上也可以这样做,但这需要更多的工作。
在下一章中,我们将更深入地探讨 React Native 内部的手势响应系统,这比浏览器能提供的移动体验更好。
第二十六章:使用动画
动画可以用来提升移动应用程序的用户体验。它们通常帮助用户快速识别出变化,或者帮助他们关注重要的事情。它们提升了用户体验和用户满意度。此外,动画看起来也很有趣。例如,在 Instagram 应用中点赞帖子时的心跳反应,或者在 Snapchat 刷新页面时的幽灵动画。
在 React Native 中处理和控制动画有几种不同的方法。首先,我们将看看我们可以使用的动画工具,发现它们的优缺点,并进行比较。然后,我们将实现几个示例,以更好地了解 API。
在这个章节中,我们将涵盖以下主题:
-
使用 React Native Reanimated
-
动画化布局组件
-
动画化组件样式
技术要求
你可以在 GitHub 上找到这个章节的代码文件,链接为 github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter26。
使用 React Native Reanimated
在 React Native 世界中,我们有大量的库和方案来动画化我们的组件,包括内置的 Animated API。但在这个章节中,我想要选择一个名为 React Native Reanimated 的库,并将其与 Animated API 进行比较,以了解为什么它是最佳选择。
Animated API
Animated API 是在 React Native 中用于动画化组件最常用的工具。它提供了一系列方法,帮助你创建动画对象,控制其状态,并处理它。主要好处是它可以与任何组件一起使用,而不仅仅是像 View 或 Text 这样的动画组件。
但是,同时,这个 API 已经在 React Native 的旧架构中实现了。JavaScript 和 UI Native 线程之间的异步通信使用 Animated API,导致更新至少延迟一帧,持续大约 16 毫秒。有时,如果 JavaScript 线程正在运行 React 的 diff 算法,同时比较或处理网络请求,延迟可能会更长。React Native Reanimated 库可以解决这个问题,它基于新的架构,并在 UI 线程中从 JavaScript 线程处理所有业务逻辑。
React Native Reanimated
React Native Reanimated 可以用来提供对 Animated API 的更全面的抽象,以便与 React Native 一起使用。它提供了一个具有多阶段动画和自定义过渡的命令式 API,同时提供了一个声明式 API,可以用来以类似 CSS 过渡的方式描述简单的动画和过渡。它是建立在 React Native Animated 之上的,并在原生线程上重新实现了它。这允许你在使用最高性能和最简单的 API 的同时,利用熟悉的 JavaScript 语言。
此外,React Native Reanimated 定义了 worklets,这些是可以在 UI 线程中同步执行的 JavaScript 函数。这允许在不等待新帧的情况下进行即时动画。让我们看看一个简单的 worklet 是什么样子:
function simpleWorklet() {
"worklet";
console.log("Hello from UI thread");
}
要在 UI 线程内调用 simpleWorklet 函数,只需在 function 块的顶部添加 worklet 指令即可。
React Native Reanimated 提供了各种钩子和方法,帮助我们处理动画:
-
useSharedValue:这个钩子返回一个SharedValue实例,这是在 UI 线程上下文中存在的主要有状态数据对象,其概念与核心 Animated API 中的Animated.Value类似。当SharedValue发生变化时,会触发 Reanimated 动画。主要好处是共享值的更新可以在 React Native 和 UI 线程之间同步,而不会触发重新渲染。这使复杂的动画能够在 60 FPS 下平稳运行,而不会阻塞 JS 线程。 -
useDerivedValue:这个钩子创建了一个新的共享值,当其计算中使用的共享值发生变化时,它会自动更新。它允许你创建依赖于其他共享值的共享值,同时保持它们的所有反应性。useDerivedValue用于在 UI 线程上运行的 worklet 中创建 派生 状态,该状态基于源共享值的更新。然后,这个派生状态可以驱动动画或其他副作用,而不会在 JS 线程上触发重新渲染。 -
useAnimatedStyle:这个钩子允许你创建一个可以基于共享值动画其属性的风格对象。它将共享值更新映射到相应的视图属性。useAnimatedStyle是将共享值连接到视图并启用在 UI 线程上运行的平滑动画的主要方式。 -
withTiming、withSpring、withDecay:这些是动画实用方法,它们使用各种曲线和物理方式以平滑、动画的方式更新共享值。它们允许你通过指定目标值和动画配置来声明式地定义动画。
我们已经了解了 React Native Reanimated 是什么以及它与 Animated API 的不同之处。接下来,让我们尝试安装它并将其应用到我们的应用中。
安装 React Native Reanimated 库
要安装 React Native Reanimated 库,请在您的 Expo 项目内部运行此命令:
expo install react-native-reanimated
安装完成后,我们需要将 Babel 插件添加到 babel.config.js 文件中:
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};
该插件的主要目的是将我们的 JavaScript worklet 函数转换为将在 UI 线程中工作的函数。
添加 Babel 插件后,重新启动您的开发服务器并清除 bundler 缓存:
expo start --clear
本节向我们介绍了 React Native Reanimated 库。我们了解到为什么它比内置的 Animated API 更好。在下一节中,我们将使用实际示例来展示它。
布局组件的动画
一个常见的用例是动画化组件的进入和退出布局。这意味着当您的组件首次渲染时以及当您卸载组件时,它将以动画形式出现。React Native Reanimated 是一个 API,允许您动画化布局并添加如 FadeIn、BounceIn 和 ZoomIn 等动画。
React Native Reanimated 还提供了一个特殊的 Animated 组件,它与 Animated API 中的 Animated 组件相同,但具有额外的属性:
-
entering:在组件挂载和渲染时接受预定义的动画 -
exiting:接受相同的动画对象,但将在组件卸载时被调用
让我们创建一个简单的待办事项列表,其中包含创建任务的按钮和允许我们在点击时删除任务的功能。
截图中无法看到动画,所以我建议你打开代码并尝试实现动画以查看结果。
首先,让我们看看我们的待办事项应用的主屏幕以及当前项目是如何渲染的:
图 26.1:待办事项列表
这是一个简单的示例,包含任务项列表和一个用于添加新任务的按钮。当我们快速多次按下 添加 按钮时,列表项会以动画形式从屏幕左侧出现:
图 26.2:具有动画渲染的待办事项列表
魔法是在 TodoItem 组件中实现的。让我们看看它:
export const TodoItem = ({ id, title, onPress }) => {
return (
<Animated.View entering={SlideInLeft}
exiting={SlideOutRight}>
<TouchableOpacity onPress={() => onPress(id)}
style={styles.todoItem}>
<Text>{title}</Text>
</TouchableOpacity>
</Animated.View>
);
};
如您所见,没有复杂的逻辑,代码也不是很多。我们只是将 Animated 组件作为动画的根组件,并将 React Native Reanimated 库中预定义的动画传递给 entering 和 exiting 属性。
要看到项目如何从屏幕上消失,我们需要按下待办事项项目,这样退出动画就会运行。我已经按下了几个项目,并尝试在下面的屏幕截图中捕捉结果:
图 26.3:从屏幕上删除待办事项
让我们检查 App 组件以了解整个情况:
export default function App() {
const [todoList, setTodoList] = useState([]);
const addTask = () => {
setTodoList([
...todoList,
{ id: String(new Date().getTime()), title: "New task"
},
]);
};
const deleteTask = (id) => {
setTodoList(todoList.filter((todo) => todo.id !== id));
};
我们使用 useState 钩子和添加和删除任务的处理器函数创建了一个 todoList 状态。接下来,让我们看看动画将如何应用于布局:
return (
<View style={styles.container}>
<View style={{ flex: 1 }}>
{todoList.map(({ id, title }) => (
<TodoItem key={id} id={id} title={title}
onPress={deleteTask} />
))}
</View>
<Button onPress={addTask} title="Add" />
</View>
);
}
在这个示例中,我们学习了一种简单的方法来应用动画,使我们的应用看起来更好。然而,React Native Reanimated 库比我们想象的要强大得多。下一个示例将说明我们如何通过直接将动画应用于组件的样式来动画化和创建自己的动画。
组件样式动画
在一个更复杂的示例中,我建议创建一个具有美丽可触摸反馈的按钮。这个按钮将使用我们在第二十三章“响应用户手势”中学到的Pressable组件来构建。该组件接受onPressIn、onLongPress和onPressOut事件。由于这些事件,我们将能够看到我们的触摸如何在按钮上反映。
让我们先定义SharedValue和AnimatedStyle:
const radius = useSharedValue(30);
const opacity = useSharedValue(1);
const scale = useSharedValue(1);
const color = useSharedValue(0);
const backgroundColor = useDerivedValue(() => {
return interpolateColor(color.value, [0, 1], ["orange", "red"]);
});
const animatedStyles = useAnimatedStyle(() => {
return {
opacity: opacity.value,
borderRadius: radius.value,
transform: [{ scale: scale.value }],
backgroundColor: backgroundColor.value,
};
}, []);
为了动画化样式属性,我们使用useSharedValue钩子创建了一个SharedValue对象。它接受默认值作为参数。接下来,我们使用useAnimatedStyle钩子创建了样式对象。该钩子接受一个回调,该回调应返回一个样式对象。useAnimatedStyle钩子与useMemo钩子类似,但所有计算都在 UI 线程中执行,并且所有SharedValue的变化都将触发钩子重新计算样式对象。按钮的背景色是通过useDerivedValue创建的,通过在橙色和红色之间插值来提供平滑的过渡。
接下来,让我们创建处理函数,这些函数将根据按钮的按下状态更新样式属性:
const onPressIn = () => {
radius.value = withSpring(20);
opacity.value = withSpring(0.7);
scale.value = withSpring(0.9);
};
const onLongPress = () => {
scale.value = withSpring(0.8);
color.value = withSpring(1);
};
const onPressOut = () => {
radius.value = withSpring(30);
opacity.value = withSpring(1);
scale.value = withSpring(1, { damping: 50 });
color.value = withSpring(0);
};
第一个处理函数onPressIn将borderRadius、opacity和scale从它们的默认值更新。我们同样使用withSpring来更新这些值,这使得更新样式更加平滑。像第一个处理函数一样,其他处理函数也会以不同的方式更新按钮的样式。onLongPress将按钮变为红色并使其变小。onPressOut将所有值重置为它们的默认值。
我们已经实现了所有必要的逻辑,现在可以将其应用于布局:
<View style={styles.container}>
<Animated.View style={[styles.buttonContainer,
animatedStyles]}>
<Pressable
onPressIn={onPressIn}
onPressOut={onPressOut}
onLongPress={onLongPress}
style={styles.button}
>
<Text style={styles.buttonText}>Press me</Text>
</Pressable>
</Animated.View>
</View>
最后,让我们看看结果:
图 26.4:具有默认、按下和长按样式的按钮
在图 26.4中,你可以看到按钮的三个状态:默认、按下和长按。
摘要
在本章中,我们学习了如何使用 React Native Reanimated 库向布局和组件添加动画。我们了解了库的基本原理,并发现了它在底层的工作方式以及它是如何在不使用 Bridge 连接应用 JavaScript 和原生层的情况下在 UI 线程中执行代码的。
我们还通过两个使用 React Native Reanimated 库的示例进行了说明。在第一个示例中,我们学习了如何使用预定义的声明式动画来应用布局动画,使我们的组件以美丽的方式出现和消失。在第二个示例中,我们使用useSharedValue和useAnimatedStyle钩子来动画化按钮的样式。
动画组件和布局的技巧将帮助您使您的应用更加美观和响应。在下一章中,我们将学习如何在我们的应用中控制图像。
第二十七章:控制图像显示
到目前为止,本书中的示例还没有在移动屏幕上渲染任何图像。这并不反映移动应用程序的现实。Web 应用程序显示了很多图像。如果有什么不同的话,原生移动应用程序比 Web 应用程序更依赖于图像,因为当您有限的空间时,图像是一种强大的工具。
在本章中,您将学习如何使用 React Native 的Image组件,从加载不同来源的图像开始。然后,您将学习如何使用Image组件来调整图像大小,以及如何为懒加载的图像设置占位符。最后,您将学习如何使用@expo/vector-icons包实现图标。这些部分涵盖了在应用程序中使用图像和图标的最常见用例。
我们将在本章中介绍以下主题:
-
加载图像
-
调整图像大小
-
懒加载图像
-
渲染图标
技术要求
您可以在 GitHub 上找到本章的代码和图像文件,网址为github.com/PacktPublishing/React-and-React-Native-5E/tree/main/Chapter27。
加载图像
让我们首先弄清楚如何加载图像。您可以像任何其他 React 组件一样渲染<Image>组件并传递其属性。但这个特定的组件需要图像 blob 数据才能发挥作用。BLOB(代表Binary Large Object,即二进制大对象)是一种用于存储大型、非结构化二进制数据的数据类型。BLOB 通常用于存储多媒体文件,如图像、音频和视频。
让我们看看一些代码:
const reactLogo = "https://reactnative.dev/docs/assets/favicon.png";
const relayLogo = require("./assets/relay.png");
export default function App() {
return (
<View style={styles.container}>
<Image style={styles.image} source={{ uri: reactLogo }} />
<Image style={styles.image} source={relayLogo} />
</View>
);
}
有两种方法可以将 blob 数据加载到<Image>组件中。第一种方法是从网络中加载图像数据。这是通过将具有URI属性的对象传递给source代码来完成的。本例中的第二个<Image>组件正在使用本地图像文件。它是通过调用require()并将结果传递给source代码来实现的。
现在,让我们看看渲染结果是什么样的:
图 27.1:图像加载
这是这些图像使用的样式:
image: {
width: 100,
height: 100,
margin: 20,
},
注意,如果没有设置width和height样式属性,图像将不会渲染。在下一节中,您将了解当设置width和height值时图像缩放是如何工作的。
调整图像大小
Image组件的width和height样式属性决定了屏幕上渲染的大小。例如,您可能需要在某个时候处理分辨率比您希望在 React Native 应用程序中显示的更大的图像。只需在Image上设置width和height样式属性就足以正确缩放图像。
让我们看看一些代码,这些代码允许您使用控件动态调整图像的尺寸:
export default function App() {
const source = require("./assets/flux.png");
const [width, setWidth] = useState(100);
const [height, setHeight] = useState(100);
return (
<View style={styles.container}>
<Image source={source} style={{ width, height }} />
<Text>Width: {width}</Text>
<Text>Height: {height}</Text>
<Slider
style={styles.slider}
minimumValue={50}
maximumValue={150}
value={width}
onValueChange={(value) => {
setWidth(value);
setHeight(value);
}}
/>
</View>
);
}
如果您使用默认的 100 x 100 维度,图像看起来是这样的:
图 27.2:100 x 100 图像
这里是缩小后的图像版本:
图 27.3:50 x 50 图像
最后,这里是一个放大后的图像版本:
图 27.4:150 x 150 图像
有一个resizeMode属性可以传递给Image组件。这决定了缩放后的图像如何适应实际组件的尺寸。您将在本章的渲染图标部分看到这个属性的用法。
如您所见,图像的尺寸由width和height样式属性控制。在应用运行时,可以通过更改这些值来调整图像大小。在下一节中,您将学习如何懒加载图像。
懒加载图像
有时,您可能不希望在图像渲染的精确时刻加载图像;例如,您可能正在渲染屏幕上尚未可见的内容。大多数时候,在图像实际可见之前从网络上获取图像源是完全可以接受的。但如果您正在微调应用程序并发现通过网络加载大量图像会导致性能问题,您可以使用懒加载策略。
我认为在移动环境中更常见的用例是处理您已经渲染了一个或多个图像,但网络响应缓慢的场景。在这种情况下,您可能希望渲染一个占位图,以便用户立即看到一些内容,而不是空白空间。那么,让我们开始吧。
首先,您可以实现一个抽象,它封装了加载后要显示的实际图像。以下是该代码:
const placeholder = require("./assets/placeholder.png");
type PlaceholderProps = {
loaded: boolean;
style: StyleProp<ImageStyle>;
};
function Placeholder({ loaded, style }: PlaceholderProps) {
if (loaded) {
return null;
} else {
return <Image style={style} source={placeholder} />;
}
}
现在,在这里,您可以看到占位图仅在原始图像未加载时才会被渲染:
type Props = {
style: StyleProp<ImageStyle>;
resizeMode: ImageProps["resizeMode"];
source: ImageSourcePropType | null;
};
export default function LazyImage({ style, resizeMode, source }: Props) {
const [loaded, setLoaded] = useState(false);
return (
<View style={style}>
{!!source ? (
<Image
source={source}
resizeMode={resizeMode}
style={style}
onLoad={() => {
setLoaded(true);
}}
/>
) : (
<Placeholder loaded={loaded} style={style} />
)}
</View>
);
}
此组件渲染一个包含两个Image组件的View组件。它还有一个加载状态,初始值为false。当loaded为false时,将渲染占位图。当调用onLoad()处理程序时,将loaded状态设置为true。这意味着占位图将被移除,主图像将显示出来。
现在,让我们使用我们刚刚实现的LazyImage组件。您将渲染没有source的图像,并且应该显示占位图。让我们添加一个按钮,为懒加载图像提供一个source。当它加载时,占位图应该被替换。以下是主app模块的外观:
const remote = "https://reactnative.dev/docs/assets/favicon.png";
export default function LazyLoading() {
const [source, setSource] = useState<ImageSourcePropType | null>(null);
return (
<View style={styles.container}>
<LazyImage
style={{ width: 200, height: 150 }}
resizeMode="contain"
source={source}
/>
<Button
label="Load Remote"
onPress={() => {
setSource({ uri: remote });
}}
/>
</View>
);
}
这就是屏幕的初始状态:
图 27.5:图像的初始状态
然后,点击加载远程按钮,最终看到我们真正想要的图像:
图 27.6:已加载图像
您可能会注意到,根据您的网络速度,占位符图像在您点击 加载远程 按钮后仍然可见。这是设计上的考虑,因为您不希望在确定实际图像准备好显示之前移除占位符图像。现在,让我们在我们的 React Native 应用程序中渲染一些图标。
渲染图标
在本章的最后部分,您将学习如何在 React Native 组件中渲染图标。使用图标来表示意义可以使网络应用程序更易于使用。那么,为什么原生移动应用程序会有所不同呢?
我们将使用 @expo/vector-icons 包将各种矢量字体包拉入您的 React Native 应用程序。这个包已经是我们在应用程序基础上使用的 Expo 项目的组成部分,现在,您可以导入 Icon 组件并将它们渲染出来。让我们实现一个示例,根据选择的图标类别渲染几个 FontAwesome 图标:
export default function RenderingIcons() {
const [selected, setSelected] = useState<IconsType>("web_app_icons");
const [listSource, setListSource] = useState<IconName[]>([]);
const categories = Object.keys(iconNames);
function updateListSource(selected: IconsType) {
const listSource = iconNames[selected] as any;
setListSource(listSource);
setSelected(selected);
}
useEffect(() => {
updateListSource(selected);
}, []);
在这里,我们已经定义了所有必要的逻辑来存储和更新图标数据。接下来,我们将将其应用于布局:
return (
<View style={styles.container}>
<View style={styles.picker}>
<Picker selectedValue={selected} onValueChange={updateListSource}>
{categories.map((category) => (
<Picker.Item key={category} label={category} value={category} />
))}
</Picker>
</View>
<FlatList
style={styles.icons}
data={listSource.map((value, key) => ({ key: key.toString(), value }))}
renderItem={({ item }) => (
<View style={styles.item}>
<Icon name={item.value} style={styles.itemIcon} />
<Text style={styles.itemText}>{item.value}</Text>
</View>
)}
/>
</View>
);
}
当您运行此示例时,您应该看到以下内容:
图 27.7:渲染图标
摘要
在本章中,我们学习了如何在 React Native 应用程序中处理图像。在原生移动环境中,图像与在 Web 环境中一样重要:它们改善了用户体验。
我们学习了加载图像的不同方法,以及如何调整图像大小。我们还学习了如何实现懒加载图像,在实际图像加载时显示占位符图像。最后,我们学习了如何在 React Native 应用程序中使用图标。这些技能将帮助您管理图像,并使您的应用程序更具信息性。
在下一章,我们将学习 React Native 中的本地存储,当我们的应用程序离线时,这非常有用。