创建一个模态框

编辑页面

在本教程中,学习如何创建一个用于选择图片的 React Native 模态框。


For the complete documentation index, see llms.txt. Use this Use this file to discover all available pages.

React Native 提供了一个 <Modal> 组件,用于在应用其余内容之上展示内容。通常,模态框用于吸引用户注意力到关键信息,或引导他们采取操作。例如,在第三章中,按下按钮后,我们使用 alert() 来显示一些占位文本。这就是模态组件展示覆盖层的方式。

在本章中,我们将创建一个显示表情选择器列表的模态框。

观看:在你的通用 Expo 应用中创建一个模态框
观看:在你的通用 Expo 应用中创建一个模态框

使用 React Native 的 Modal API 构建一个模态组件,用于显示表情选择器并处理用户交互。


1

声明一个用于显示按钮的状态变量

在实现模态框之前,我们先添加三个新按钮。这些按钮会在用户从媒体库中选择图片或使用占位图片后显示。其中一个按钮将触发表情选择器模态框。

app/(tabs)/index.tsx 中:

  1. 声明一个布尔类型的状态变量 showAppOptions,用于显示或隐藏打开模态框以及其他几个选项的按钮。当应用屏幕加载时,我们将其设置为 false,这样在选择图片之前不会显示这些选项。当用户选择图片或使用占位图片时,我们会将其设置为 true
  2. 更新 pickImageAsync() 函数,在用户选择图片后将 showAppOptions 的值设为 true
  3. 为没有主题的按钮添加 onPress 属性,并使用以下值。
app/(tabs)/index.tsx
import { View, StyleSheet } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import { useState } from 'react'; import Button from '@/components/Button'; import ImageViewer from '@/components/ImageViewer'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const pickImageAsync = async () => { let result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> </View> {showAppOptions ? ( <View /> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, });

在上面的代码片段中,我们根据 showAppOptions 的值来渲染 Button 组件,并将按钮移动到三元运算块中。当 showAppOptions 的值为 true 时,渲染一个空的 <View> 组件。我们将在下一步处理这个状态。

现在,我们可以移除 Button 组件上的 alert,并在 components/Button.tsx 中渲染第二个按钮时更新 onPress 属性:

components/Button.tsx
<Pressable style={styles.button} onPress={onPress}>

2

添加按钮

让我们拆解本章要实现的选项按钮布局。设计如下:

它包含一个父级 <View>,其中有三个按钮横向排列。中间带有加号图标(+)的按钮会打开模态框,并且样式与另外两个按钮不同。

components 目录中,创建一个新的 CircleButton.tsx 文件,并添加以下代码:

components/CircleButton.tsx
import { View, Pressable, StyleSheet } from 'react-native'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; type Props = { onPress: () => void; }; export default function CircleButton({ onPress }: Props) { return ( <View style={styles.circleButtonContainer}> <Pressable style={styles.circleButton} onPress={onPress}> <MaterialIcons name="add" size={38} color="#25292e" /> </Pressable> </View> ); } const styles = StyleSheet.create({ circleButtonContainer: { width: 84, height: 84, marginHorizontal: 60, borderWidth: 4, borderColor: '#ffd33d', borderRadius: 42, padding: 3, }, circleButton: { flex: 1, justifyContent: 'center', alignItems: 'center', borderRadius: 42, backgroundColor: '#fff', }, });

为了渲染加号图标,这个按钮使用了 @expo/vector-icons 库中的 <MaterialIcons> 图标集。

另外两个按钮也使用 <MaterialIcons> 来显示垂直对齐的文本标签和图标。在 components 目录中创建一个名为 IconButton.tsx 的文件。这个组件接受三个 props:

  • icon:对应 MaterialIcons 图标库图标名称。
  • label:按钮上显示的文本标签。
  • onPress:用户按下按钮时调用的函数。
components/IconButton.tsx
import { Pressable, StyleSheet, Text } from 'react-native'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; type Props = { icon: keyof typeof MaterialIcons.glyphMap; label: string; onPress: () => void; }; export default function IconButton({ icon, label, onPress }: Props) { return ( <Pressable style={styles.iconButton} onPress={onPress}> <MaterialIcons name={icon} size={24} color="#fff" /> <Text style={styles.iconButtonLabel}>{label}</Text> </Pressable> ); } const styles = StyleSheet.create({ iconButton: { justifyContent: 'center', alignItems: 'center', }, iconButtonLabel: { color: '#fff', marginTop: 12, }, });

app/(tabs)/index.tsx 中:

  1. 导入 CircleButtonIconButton 组件以便显示它们。
  2. 为这些按钮添加三个占位函数。onReset() 函数会在用户按下重置按钮时调用,使图片选择按钮再次显示。其他两个函数的功能我们稍后再添加。
app/(tabs)/index.tsx
import { View, StyleSheet } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import { useState } from 'react'; import Button from '@/components/Button'; import ImageViewer from '@/components/ImageViewer'; import IconButton from '@/components/IconButton'; import CircleButton from '@/components/CircleButton'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const pickImageAsync = async () => { let result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; const onReset = () => { setShowAppOptions(false); }; const onAddSticker = () => { // 我们稍后会实现这个 }; const onSaveImageAsync = async () => { // 我们稍后会实现这个 }; return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> </View> {showAppOptions ? ( <View style={styles.optionsContainer}> <View style={styles.optionsRow}> <IconButton icon="refresh" label="Reset" onPress={onReset} /> <CircleButton onPress={onAddSticker} /> <IconButton icon="save-alt" label="Save" onPress={onSaveImageAsync} /> </View> </View> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, optionsContainer: { position: 'absolute', bottom: 80, }, optionsRow: { alignItems: 'center', flexDirection: 'row', }, });

让我们看看应用在 Android、iOS 和 Web 上的效果:

3

创建一个表情选择器模态框

这个模态框允许用户从可用表情列表中选择一个表情。请在 components 目录中创建一个 EmojiPicker.tsx 文件。这个组件接受三个 props:

  • isVisible:用于确定模态框可见状态的布尔值。
  • onClose:用于关闭模态框的函数。
  • children:稍后用于显示表情列表。
components/EmojiPicker.tsx
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native'; import { PropsWithChildren } from 'react'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; type Props = PropsWithChildren<{ isVisible: boolean; onClose: () => void; }>; export default function EmojiPicker({ isVisible, children, onClose }: Props) { return ( <View> <Modal animationType="slide" transparent={true} visible={isVisible}> <View style={styles.modalContent}> <View style={styles.titleContainer}> <Text style={styles.title}>Choose a sticker</Text> <Pressable onPress={onClose}> <MaterialIcons name="close" color="#fff" size={22} /> </Pressable> </View> {children} </View> </Modal> </View> ); } const styles = StyleSheet.create({ modalContent: { height: '25%', width: '100%', backgroundColor: '#25292e', borderTopRightRadius: 18, borderTopLeftRadius: 18, position: 'absolute', bottom: 0, }, titleContainer: { height: '16%', backgroundColor: '#464C55', borderTopRightRadius: 10, borderTopLeftRadius: 10, paddingHorizontal: 20, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, title: { color: '#fff', fontSize: 16, }, });

让我们了解以上代码的作用:

  • <Modal> 组件会显示一个标题和一个关闭按钮。
  • 它的 visible 属性取值为 isVisible,用于控制模态框是打开还是关闭。
  • 它的 transparent 属性是一个布尔值,用于决定模态框是否填满整个视图。
  • 它的 animationType 属性决定模态框进入和离开屏幕的方式。在这里,它从屏幕底部滑入。
  • 最后,当用户按下关闭的 <Pressable> 时,<EmojiPicker> 会调用 onClose 属性。

现在,让我们修改 app/(tabs)/index.tsx

  1. 导入 <EmojiPicker> 组件。
  2. 使用 useState 钩子创建一个 isModalVisible 状态变量。它的默认值为 false,这会在用户按下打开按钮之前隐藏模态框。
  3. 替换 onAddSticker() 函数中的注释,当用户按下按钮时将 isModalVisible 变量更新为 true。这将打开表情选择器。
  4. 创建 onModalClose() 函数来更新 isModalVisible 状态变量。
  5. <EmojiPicker> 组件放在 Index 组件底部。
app/(tabs)/index.tsx
import { View, StyleSheet } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import { useState } from 'react'; import Button from '@/components/Button'; import ImageViewer from '@/components/ImageViewer'; import IconButton from '@/components/IconButton'; import CircleButton from '@/components/CircleButton'; import EmojiPicker from '@/components/EmojiPicker'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const [isModalVisible, setIsModalVisible] = useState<boolean>(false); const pickImageAsync = async () => { let result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; const onReset = () => { setShowAppOptions(false); }; const onAddSticker = () => { setIsModalVisible(true); }; const onModalClose = () => { setIsModalVisible(false); }; const onSaveImageAsync = async () => { // 我们稍后会实现这个 }; return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> </View> {showAppOptions ? ( <View style={styles.optionsContainer}> <View style={styles.optionsRow}> <IconButton icon="refresh" label="Reset" onPress={onReset} /> <CircleButton onPress={onAddSticker} /> <IconButton icon="save-alt" label="Save" onPress={onSaveImageAsync} /> </View> </View> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} <EmojiPicker isVisible={isModalVisible} onClose={onModalClose}> {/* Emoji 列表组件将放在这里 */} </EmojiPicker> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, optionsContainer: { position: 'absolute', bottom: 80, }, optionsRow: { alignItems: 'center', flexDirection: 'row', }, });

以下是这一步之后的结果:

4

显示表情列表

现在,让我们在模态框内容中添加一个横向的表情列表。为此我们将使用 React Native 的 <FlatList> 组件。

components 目录中创建一个 EmojiList.tsx 文件,并添加以下代码:

components/EmojiList.tsx
import { useState } from 'react'; import { ImageSourcePropType, StyleSheet, FlatList, Platform, Pressable } from 'react-native'; import { Image } from 'expo-image'; type Props = { onSelect: (image: ImageSourcePropType) => void; onCloseModal: () => void; }; export default function EmojiList({ onSelect, onCloseModal }: Props) { const [emoji] = useState<ImageSourcePropType[]>([ require("../assets/images/emoji1.png"), require("../assets/images/emoji2.png"), require("../assets/images/emoji3.png"), require("../assets/images/emoji4.png"), require("../assets/images/emoji5.png"), require("../assets/images/emoji6.png"), ]); return ( <FlatList horizontal showsHorizontalScrollIndicator={Platform.OS === 'web'} data={emoji} contentContainerStyle={styles.listContainer} renderItem={({ item, index }) => ( <Pressable onPress={() => { onSelect(item); onCloseModal(); }}> <Image source={item} key={index} style={styles.image} /> </Pressable> )} /> ); } const styles = StyleSheet.create({ listContainer: { borderTopRightRadius: 10, borderTopLeftRadius: 10, paddingHorizontal: 20, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, image: { width: 100, height: 100, marginRight: 20, }, });

让我们了解以上代码的作用:

  • 上面的 <FlatList> 组件使用 Image 组件渲染所有表情图片,并由 <Pressable> 包裹。稍后我们会改进它,使用户可以在屏幕上点击某个表情,将其作为贴纸显示在图片上。
  • 它还接受由 emoji 数组变量提供的项目数组,作为 data 属性的值。renderItem 属性取出 data 中的项目并返回列表中的该项。最后,我们添加了 Image<Pressable> 组件来显示该项目。
  • horizontal 属性使列表水平而不是垂直渲染。showsHorizontalScrollIndicator 使用 React Native 的 Platform 模块来检查该值,并在 Web 上显示横向滚动条。

现在,更新 app/(tabs)/index.tsx,导入 <EmojiList> 组件,并将 <EmojiPicker> 组件内部的注释替换为以下代码片段:

app/(tabs)/index.tsx
import { ImageSourcePropType, View, StyleSheet } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import { useState } from 'react'; import Button from '@/components/Button'; import ImageViewer from '@/components/ImageViewer'; import IconButton from '@/components/IconButton'; import CircleButton from '@/components/CircleButton'; import EmojiPicker from '@/components/EmojiPicker'; import EmojiList from '@/components/EmojiList'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const [isModalVisible, setIsModalVisible] = useState<boolean>(false); const [pickedEmoji, setPickedEmoji] = useState<ImageSourcePropType | undefined>(undefined); const pickImageAsync = async () => { let result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; const onReset = () => { setShowAppOptions(false); }; const onAddSticker = () => { setIsModalVisible(true); }; const onModalClose = () => { setIsModalVisible(false); }; const onSaveImageAsync = async () => { // 我们稍后会实现这个 }; return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> </View> {showAppOptions ? ( <View style={styles.optionsContainer}> <View style={styles.optionsRow}> <IconButton icon="refresh" label="Reset" onPress={onReset} /> <CircleButton onPress={onAddSticker} /> <IconButton icon="save-alt" label="Save" onPress={onSaveImageAsync} /> </View> </View> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} <EmojiPicker isVisible={isModalVisible} onClose={onModalClose}> <EmojiList onSelect={setPickedEmoji} onCloseModal={onModalClose} /> </EmojiPicker> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, optionsContainer: { position: 'absolute', bottom: 80, }, optionsRow: { alignItems: 'center', flexDirection: 'row', }, });

EmojiList 组件中,onSelect 属性用于选择表情,选择后,onCloseModal 会关闭模态框。

让我们看看应用在 Android、iOS 和 Web 上的效果:

5

显示选中的表情

现在,我们要把表情贴纸放到图片上。请在 components 目录中新建一个文件,命名为 EmojiSticker.tsx。然后添加以下代码:

components/EmojiSticker.tsx
import { ImageSourcePropType, View } from 'react-native'; import { Image } from 'expo-image'; type Props = { imageSize: number; stickerSource: ImageSourcePropType; }; export default function EmojiSticker({ imageSize, stickerSource }: Props) { return ( <View style={{ top: -350 }}> <Image source={stickerSource} style={{ width: imageSize, height: imageSize }} /> </View> ); }

这个组件接收两个 props:

  • imageSize:在 Index 组件内部定义的值。我们将在下一章中使用这个值来在点击时缩放图片大小。
  • stickerSource:所选表情图片的来源。

app/(tabs)/index.tsx 文件中导入该组件,并更新 Index 组件以在图片上显示表情贴纸。我们会检查 pickedEmoji 状态是否不为 undefined

app/(tabs)/index.tsx
import { ImageSourcePropType, View, StyleSheet } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import { useState } from 'react'; import Button from '@/components/Button'; import ImageViewer from '@/components/ImageViewer'; import IconButton from '@/components/IconButton'; import CircleButton from '@/components/CircleButton'; import EmojiPicker from '@/components/EmojiPicker'; import EmojiList from '@/components/EmojiList'; import EmojiSticker from '@/components/EmojiSticker'; const PlaceholderImage = require('@/assets/images/background-image.png'); export default function Index() { const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined); const [showAppOptions, setShowAppOptions] = useState<boolean>(false); const [isModalVisible, setIsModalVisible] = useState<boolean>(false); const [pickedEmoji, setPickedEmoji] = useState<ImageSourcePropType | undefined>(undefined); const pickImageAsync = async () => { let result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); setShowAppOptions(true); } else { alert('You did not select any image.'); } }; const onReset = () => { setShowAppOptions(false); }; const onAddSticker = () => { setIsModalVisible(true); }; const onModalClose = () => { setIsModalVisible(false); }; const onSaveImageAsync = async () => { // 我们稍后会实现这个 }; return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> {pickedEmoji && <EmojiSticker imageSize={40} stickerSource={pickedEmoji} />} </View> {showAppOptions ? ( <View style={styles.optionsContainer}> <View style={styles.optionsRow}> <IconButton icon="refresh" label="Reset" onPress={onReset} /> <CircleButton onPress={onAddSticker} /> <IconButton icon="save-alt" label="Save" onPress={onSaveImageAsync} /> </View> </View> ) : ( <View style={styles.footerContainer}> <Button theme="primary" label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" onPress={() => setShowAppOptions(true)} /> </View> )} <EmojiPicker isVisible={isModalVisible} onClose={onModalClose}> <EmojiList onSelect={setPickedEmoji} onCloseModal={onModalClose} /> </EmojiPicker> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#25292e', alignItems: 'center', }, imageContainer: { flex: 1, }, footerContainer: { flex: 1 / 3, alignItems: 'center', }, optionsContainer: { position: 'absolute', bottom: 80, }, optionsRow: { alignItems: 'center', flexDirection: 'row', }, });

让我们看看应用在 Android、iOS 和 Web 上的效果:

概要

Chapter 5: Create a modal

我们已经成功创建了表情符号选择器弹窗,并实现了选择表情符号并将其显示在图片上的逻辑。

在下一章中,我们将通过手势添加用户交互,以拖动表情符号并通过轻点它来缩放大小。

Next: 添加手势