ReactNative从入门到性能优化(一)

引言

大家好啊,我是前端拿破轮。

最近在学习React Native(简称为RN),所以打算记录一下,希望能够对各位读者有所帮助。

什么是React Native

简单来说,React Native是一个跨平台的移动端开发框架,能够让咱们开发人员使用JavaScriptReact构建原生IOSAndroid应用程序。

在设计理念上,React Native遵循分层架构,将JavaScript应用程序代码,跨平台的C++渲染基础设施和基于特定平台的本地实现之间的关注点分开。真正实现在React中编写一次,就可以在iOSAndroid上生成真正的原生用户界面。

如何使用React Native

这里我们直接从一个demo程序开始,在做的过程中再解释。

配置环境

这里大家可以直接去expo文档官网来查看如何配置环境。

不同的开发设备,配置的方式也有所差异,官方文档都提供了详细的配置教程。

我这里以macOS,使用iOS的模拟器为例,进行开发。

创建应用

shell 复制代码
# 利用expo框架快速创建一个RN应用
pnpm dlx create-expo-app@latest

expo是一个开发RN应用的框架,集成了一系列的工具和服务,可以让我们快速的开发RN应用。

根据命令行提示进入该目录并启动应用。

shell 复制代码
# 进入demo目录
cd demo

# 启动应用
pnpm ios

这里如果在手机上下载了expo客户端,可以直接在手机上扫描二维码,打开应用(注意手机和电脑在同一个局域网下)。

我们这里的话使用模拟器打开应用。

热更新

打开app/(tabs)/index.tsx文件,可以看到首页的代码。我们尝试修改Welcome!Hello World!

然后我们可以看到应用自己触发了热更新,不用我们手动刷新。

目录介绍

我们来观察一下expo脚手架的目录结构。

  • .expo:这是我们在使用pnpm ios的时候自动生成的一个目录,里面存放了一些expo的配置文件。
  • app:这是我们的应用程序代码,包括UI业务逻辑,类似于我们在Web开发中常用的src目录。
  • assets:存放应用程序的静态资源,比如图片、字体等。
  • components:存放组件的文件夹,不解释。
  • constants:常量,不解释。
  • hooks:存放reacthooks
  • scripts:存放我们自定义的脚本文件,项目创建时自带一个reset-project的脚本,可以将初始的项目代码重置为空白。
  • app.json:这是RN应用的配置文件,配置在不同平台上应用的基础信息。
  • 其他:常见的项目配置文件,不解释。

重置项目

我们可以运行以下命令来重置demo代码,开始一个新项目。

shell 复制代码
pnpm reset-project

开发demo

基本概念

这个脚本将app目录中的所有文件移动到app-example,然后创建一个带有index.tsx的新index.tsx文件的新app目录。

我们可以看到在app/index.tsx文件中,,有一个简单的React函数式组件。

这里使用了两个从react-native导入的组件,TextView

Text组件用于显示文本,View组件用于显示容器。

和在Web开发中不同,RN中的文字不能直接写,必须包裹在Text组件中。

View组件就类似于div,用于显示容器。

tsx 复制代码
import { Text, View } from 'react-native';

export default function Index() {
  return (
    <View
      style={{
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center',
      }}
    >
      <Text>Edit app/index.tsx to edit this screen.</Text>
    </View>
  );
}

关于样式,我们可以看到style属性是一个对象,里面包含了flexjustifyContentalignItems等属性。这些和Web开发中的CSS属性类似,用于设置容器的布局和对齐方式。

注意:RN中没有CSS文件,所有的样式都写在JavaScript中。

如果我们想要书写样式,要使用StyleSheet来创建一个styles

tsx 复制代码
import { StyleSheet, Text, View } from 'react-native';

export default function Index() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Edit app/index.tsx to edit this screen.</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
    justifyContent: 'center',
  },
  text: {
    color: '#fff',
  },
});

添加导航

我们这里使用Expo Router,这是一个基于文件 的路由框架,适用于React NativeWeb应用。使用的时候,我们需要了解以下约定:

  • app目录:这是一个特殊的目录,仅包含路由及其布局。添加到这个目录中的任何文件都会成为我们原生应用中的一个屏幕。
  • app/_layout.tsx文件:这是固定命名文件,不能修改 ,定义了共享的UI元素,比如标题栏和标签栏,以便它们在不同路由之间保持一致。
  • 路由文件默认导出React组件,可以用.js,.jsx,.ts,.tsx来命名。

app目录中新建一个about.tsx的新文件,当用户访问/about路径时,会显示这个页面。

tsx 复制代码
import { StyleSheet, Text, View } from 'react-native';
import React from 'react';

export default function about() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>about</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
    justifyContent: 'center',
  },
  text: {
    color: '#fff',
  },
});

_layout.tsx中,我们进行相关配置。

tsx 复制代码
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Home' }} />
      <Stack.Screen name="about" options={{ title: 'About' }} />
    </Stack>
  );
}

这里的StackExpo Router的组件,用于定义路由的堆栈。

切换屏幕

app/index.tsx中,我们添加一个按钮,当用户点击按钮时,会跳转到/about路径。

tsx 复制代码
import { Link } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';

export default function Index() {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Edit app/index.tsx to edit this screen.</Text>
      <Link href="/about" style={styles.button}>
        Go to About Screen
      </Link>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
    justifyContent: 'center',
  },
  text: {
    color: '#fff',
  },
  button: {
    fontSize: 20,
    textDecorationLine: 'underline',
    color: '#fff',
  },
});

错误路由页面

当路由不存在时,我们可以用+not-found路由来显示备用页面。这样可以避免应用崩溃或者显示对用户不友好的404错误。在Expo Router中,我们可以使用一个特殊的文件+not-found.tsx来实现。

注意,这里的文件名+not-found.tsx是固定名称,不能随意修改。

  1. app目录中新建一个+not-found.tsx的新文件,用来添加NotFoundScreen组件。
  2. Stack.Screen添加options属性,为这个路由自定义屏幕标题。
  3. 添加一个Link组件,用于导航到首页。

这里我们在原生应用不是很好测试错误路由页面,所以我们可以在浏览器中访问一个不存在的路由,比如http://localhost:8081/not-found,可以看到Expo Router会自动跳转到+not-found.tsx页面。

tsx 复制代码
import { StyleSheet, Text, View } from 'react-native';
import React from 'react';
import { Stack } from 'expo-router';

export default function NotFoundScreen() {
  return (
    <>
      <Stack.Screen options={{ title: 'Oops! Not Found' }} />
      <View style={styles.container}>
        <Text style={styles.button}>Go back to Home Screen</Text>
      </View>
    </>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    justifyContent: 'center',
    alignItems: 'center',
  },

  button: {
    fontSize: 20,
    textDecorationLine: 'underline',
    color: '#fff',
  },
});

添加导航栏

  1. app目录下,添加一个(tabs)的子目录,这个目录用于将路由分组并显示在底部的标签栏中。
  2. 创建(tabs)/_layout.tsx文件,该文件将用于自定义选项卡布局,该布局和根布局是独立的。
  3. 将现有的index.tsxabout.tsx移动到(tabs)目录下,应用程序的目录结构如下所示:
txt 复制代码
📦app
 ┣ 📂(tabs)
 ┃ ┣ 📜_layout.tsx
 ┃ ┣ 📜about.tsx
 ┃ ┗ 📜index.tsx
 ┣ 📜+not-found.tsx
 ┗ 📜_layout.tsx

更新根布局文件,添加(tabs)路由:

tsx 复制代码
// apps/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
    </Stack>
  );
}

(tabs)/_layout.tsx中,添加一个Tabs组件来定义底部标签布局。

tsx 复制代码
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs>
      <Tabs.Screen name="index" options={{ title: 'Home' }} />
      <Tabs.Screen name="about" options={{ title: 'About' }} />
    </Tabs>
  );
}

我们可以看到应用已经添加了底部导航栏。但是图标样式是默认的三角形,我们可以自定义修改。

修改导航栏样式

修改(tabs)/_layout.tsx文件来添加标签栏图标。

  1. @expo/vector-icons中导入Ionicons图标库。
  2. tabBarIcon添加到indexabout路由,这是一个函数,接受focusedcolor作为参数,并渲染图标组件。
  3. Tabs组件中添加screenOptions.tabBarActiveTintColor属性,并将其设置为#ffd33d,这用来设置激活时的标签颜色。

我们还可以利用screenOptions属性来更改标签栏和标题栏的背景颜色

tsx 复制代码
// app/(tabs)/_layout.tsx
<Tabs
  screenOptions={{
    tabBarActiveTintColor: '#ffd33d',
    headerStyle: {
      backgroundColor: '#25292e',
    },
    headerShadowVisible: false,
    headerTintColor: '#fff',
    tabBarStyle: {
      backgroundColor: '#25292e',
    },
  }}
>

这下导航栏和整个页面的风格也更加的一致。

编辑首页

最后,我们要实现一个类似上面的效果。

我们观察发现,页面主要由三个部分组成,分别是

  • 占据屏幕大部分的图片
  • 选择图片按钮
  • 使用默认图片按钮
显示图片

我们使用expo-image来展示图片:

shell 复制代码
pnpm dlx expo install expo-image

访问Expo官网来下载静态资源,并替换app/assets/images

tsx 复制代码
// app/(tabs)/index.tsx
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';

const PlaceholderImage = require('@/assets/images/background-image.png');

export default function Index() {
  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <Image style={styles.image} source={PlaceholderImage} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
  },

  imageContainer: {
    flex: 1,
  },

  image: {
    width: 320,
    height: 440,
    borderRadius: 18,
  },
});
拆分组件

RN开发中,同样遵循组件化的原则。

  1. 在项目根目录创建components目录,并在其中创建ImageViewer.tsx文件。
  2. 将用于显示图像的代码以及image样式移到此文件中。
tsx 复制代码
import { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';

const PlaceholderImage = require('@/assets/images/background-image.png');

export default function Index() {
  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <Image style={styles.image} source={PlaceholderImage} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
  },

  imageContainer: {
    flex: 1,
  },

  image: {
    width: 320,
    height: 440,
    borderRadius: 18,
  },
});

app/(tabs)/index.tsx中使用它:

tsx 复制代码
import ImageViewer from '@/components/ImageViewer';
import { StyleSheet, View } from 'react-native';

const PlaceholderImage = require('@/assets/images/background-image.png');

export default function Index() {
  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <ImageViewer imgSource={PlaceholderImage} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
  },

  imageContainer: {
    flex: 1,
  },
});
添加按钮

在我们的设计中,需要两个按钮,但是每个按钮的样式和标签都不同。首先,我们需要为这些按钮创建一个可重用的组件。

components目录下创建一个名为Button的文件,并添加以下代码:

tsx 复制代码
import { Pressable, StyleSheet, Text, View } from 'react-native';

type Props = {
  label: string;
};

export default function Button({ label }: Props) {
  return (
    <View style={styles.buttonContainer}>
      <Pressable
        style={styles.button}
        onPress={() => alert('You pressed a button')}
      >
        <Text style={styles.buttonLabel}>{label}</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  buttonContainer: {
    width: 320,
    height: 68,
    marginHorizontal: 20,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 3,
  },

  button: {
    borderRadius: 10,
    width: '100%',
    height: '100%',
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'row',
  },

  buttonLabel: {
    color: '#fff',
    fontSize: 16,
  },
});

应为两个按钮的样式不同,所以需要我们对Button.tsx组件进行调整。

tsx 复制代码
import { Pressable, StyleSheet, Text, View } from 'react-native';
import FontAwesome from '@expo/vector-icons/FontAwesome';

type Props = {
  label: string;
  theme?: 'primary';
};

export default function Button({ label, theme }: Props) {
  if (theme === 'primary') {
    return (
      <View
        style={[
          styles.buttonContainer,
          { borderWidth: 4, borderColor: '#ffd33d', borderRadius: 18 },
        ]}
      >
        <Pressable
          style={[styles.button, { backgroundColor: '#fff' }]}
          onPress={() => alert('You press a button.')}
        >
          <FontAwesome
            name="picture-o"
            size={18}
            color="#25292e"
            style={styles.buttonIcon}
          />
          <Text style={[styles.buttonLabel, { color: '#25292d' }]}>
            {label}
          </Text>
        </Pressable>
      </View>
    );
  }
  return (
    <View style={styles.buttonContainer}>
      <Pressable
        style={styles.button}
        onPress={() => alert('You pressed a button')}
      >
        <Text style={styles.buttonLabel}>{label}</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  buttonContainer: {
    width: 320,
    height: 68,
    marginHorizontal: 20,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 3,
  },

  button: {
    borderRadius: 10,
    width: '100%',
    height: '100%',
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'row',
  },

  buttonIcon: {
    paddingRight: 8,
  },

  buttonLabel: {
    color: '#fff',
    fontSize: 16,
  },
});

修改app/(tabs)/index.tsx文件,在第一个按钮上使用theme="primary"属性

tsx 复制代码
import { View, StyleSheet } from 'react-native';

import Button from '@/components/Button';
import ImageViewer from '@/components/ImageViewer';

const PlaceholderImage = require('@/assets/images/background-image.png');

export default function Index() {
  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <ImageViewer imgSource={PlaceholderImage} />
      </View>
      <View style={styles.footerContainer}>
        <Button theme="primary" label="Choose a photo" />
        <Button label="Use this photo" />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
  },
  imageContainer: {
    flex: 1,
  },
  footerContainer: {
    flex: 1 / 3,
    alignItems: 'center',
  },
});
添加图片选择器

安装expo-image-picker

shell 复制代码
pnpm dlx expo install expo-image-picker

更新app/(tabs)/index.tsx文件

tsx 复制代码
// ...rest of the import statements remain unchanged
import * as ImagePicker from 'expo-image-picker';

export default function Index() {
  const pickImageAsync = async () => {
    let result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ['images'],
      allowsEditing: true,
      quality: 1,
    });

    if (!result.canceled) {
      console.log(result);
    } else {
      alert('You did not select any image.');
    }
  };

  // ...rest of the code remains same
}
更新按钮组件
tsx 复制代码
import { StyleSheet, View, Pressable, Text } from 'react-native';
import FontAwesome from '@expo/vector-icons/FontAwesome';

type Props = {
  label: string;
  theme?: 'primary';
  onPress?: () => void;
};

export default function Button({ label, theme, onPress }: Props) {
  if (theme === 'primary') {
    return (
      <View
        style={[
          styles.buttonContainer,
          { borderWidth: 4, borderColor: '#ffd33d', borderRadius: 18 },
        ]}
      >
        <Pressable
          style={[styles.button, { backgroundColor: '#fff' }]}
          onPress={onPress}
        >
          <FontAwesome
            name="picture-o"
            size={18}
            color="#25292e"
            style={styles.buttonIcon}
          />
          <Text style={[styles.buttonLabel, { color: '#25292e' }]}>
            {label}
          </Text>
        </Pressable>
      </View>
    );
  }

  return (
    <View style={styles.buttonContainer}>
      <Pressable
        style={styles.button}
        onPress={() => alert('You pressed a button.')}
      >
        <Text style={styles.buttonLabel}>{label}</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  buttonContainer: {
    width: 320,
    height: 68,
    marginHorizontal: 20,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 3,
  },
  button: {
    borderRadius: 10,
    width: '100%',
    height: '100%',
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'row',
  },
  buttonIcon: {
    paddingRight: 8,
  },
  buttonLabel: {
    color: '#fff',
    fontSize: 16,
  },
});

app/(tabs)/index.tsx文件中,将pickImageAsync函数添加到第一个<Button>onPress属性中。

tsx 复制代码
import { View, StyleSheet } from 'react-native';
import * as ImagePicker from 'expo-image-picker';

import Button from '@/components/Button';
import ImageViewer from '@/components/ImageViewer';

const PlaceholderImage = require('@/assets/images/background-image.png');

export default function Index() {
  const pickImageAsync = async () => {
    let result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ['images'],
      allowsEditing: true,
      quality: 1,
    });

    if (!result.canceled) {
      console.log(result);
    } else {
      alert('You did not select any image.');
    }
  };

  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <ImageViewer imgSource={PlaceholderImage} />
      </View>
      <View style={styles.footerContainer}>
        <Button
          theme="primary"
          label="Choose a photo"
          onPress={pickImageAsync}
        />
        <Button label="Use this photo" />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
  },
  imageContainer: {
    flex: 1,
  },
  footerContainer: {
    flex: 1 / 3,
    alignItems: 'center',
  },
});

我们可以选择一个图片进行测试,在iOS的模拟器上,result类似下面这样:

json 复制代码
{
  "assets": [
    {
      "assetId": "99D53A1F-FEEF-40E1-8BB3-7DD55A43C8B7/L0/001",
      "base64": null,
      "duration": null,
      "exif": null,
      "fileName": "IMG_0004.JPG",
      "fileSize": 2548364,
      "height": 1669,
      "mimeType": "image/jpeg",
      "type": "image",
      "uri": "file:///data/user/0/host.exp.exponent/cache/ExperienceData/%2540anonymous%252FStickerSmash-13f21121-fc9d-4ec6-bf89-bf7d6165eb69/ImagePicker/ea574eaa-f332-44a7-85b7-99704c22b402.jpeg",
      "width": 1668
    }
  ],
  "canceled": false
}
展示选择的图像

result对象提供了一个assets数组,其中包含所选图片的uri

我们可以修改app/(tabs)/index.tsx文件来展示选择的图像。

  1. 使用ReactuseState钩子来声明一个名为selectedImage的状态变量,用来保存此状态变量所保存图片的URI
  2. 更新pickImageAsync()函数,将图像的URI保存到selectedImage状态变量中。
  3. selectedImage状态变量传递给ImageViewer组件。
  4. 更新ImageViewer组件,新增selectedImage属性,用于接收selectedImage状态变量。
tsx 复制代码
import Button from '@/components/Button';
import ImageViewer from '@/components/ImageViewer';
import { StyleSheet, View } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { useState } from 'react';

const PlaceholderImage = require('@/assets/images/background-image.png');

export default function Index() {
  const [selectedImage, setSelectedImage] = useState<string | undefined>(
    undefined,
  );
  const pickImageAsync = async () => {
    let result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ['images'],
      allowsEditing: true,
      quality: 1,
    });

    if (!result.canceled) {
      console.log(result);
      setSelectedImage(result.assets[0].uri);
    } else {
      alert('You did not select any image');
    }
  };
  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <ImageViewer
          imgSource={PlaceholderImage}
          selectedImage={selectedImage}
        />
      </View>
      <View style={styles.footerContainer}>
        <Button
          label="Choose a photo"
          theme="primary"
          onPress={pickImageAsync}
        />
        <Button label="Use this photo" />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
  },

  imageContainer: {
    flex: 1,
    paddingTop: 28,
  },

  footerContainer: {
    flex: 1 / 3,
    alignItems: 'center',
  },
});
tsx 复制代码
// ImageViewer.tsx

import { Image } from 'expo-image';
import { ImageSourcePropType, StyleSheet } from 'react-native';

type Props = {
  imgSource: ImageSourcePropType;
  selectedImage: string | undefined;
};

export default function ImageViewer({ imgSource, selectedImage }: Props) {
  const imageSource = selectedImage ? { uri: selectedImage } : imgSource;
  return <Image source={imageSource} style={styles.image} />;
}

const styles = StyleSheet.create({
  image: {
    width: 320,
    height: 440,
    borderRadius: 18,
  },
});
创建模态框

app/(tabs)/index.tsx中:

  1. 声明一个布尔状态变量showAppOptions,用于控制模态框的显示和隐藏。默认状态为false
  2. 更新pickImageAsync()函数,在用户选择图像后,将showAppOptions状态变量设置为true
  3. 将第二个按钮的点击事件修改为() => setShowAppOptions(true)
tsx 复制代码
import Button from '@/components/Button';
import ImageViewer from '@/components/ImageViewer';
import { StyleSheet, View } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { useState } from 'react';

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) {
      console.log(result);
      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
            label="Choose a photo"
            theme="primary"
            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,
    paddingTop: 28,
  },

  footerContainer: {
    flex: 1 / 3,
    alignItems: 'center',
  },
});
创建模态框

选择图片后,展示的模态框如下所示

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

tsx 复制代码
import { Pressable, StyleSheet, View } 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',
  },
});

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

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中,进行更新

tsx 复制代码
import Button from '@/components/Button';
import ImageViewer from '@/components/ImageViewer';
import { StyleSheet, View } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { useState } from 'react';
import IconButton from '@/components/IconButton';
import CircleButton from '@/components/CircleButton';

const PlaceholderImage = require('@/assets/images/background-image.png');

export default function Index() {
  // 选中图片的uri
  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) {
      console.log(result);
      setSelectedImage(result.assets[0].uri);
      setShowAppOptions(true);
    } else {
      alert('You did not select any image');
    }
  };

  const onReset = () => {
    setShowAppOptions(false);
  };

  const onAddSticker = () => {
    // TODO: 添加贴纸
  };

  const onSaveImageAsync = async () => {
    // TODO: 异步保存图片
  };

  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <ImageViewer
          imgSource={PlaceholderImage}
          selectedImage={selectedImage}
        />
      </View>
      {showAppOptions ? (
        <View style={styles.optionContainer}>
          <View style={styles.optionRow}>
            <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
            label="Choose a photo"
            theme="primary"
            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,
    paddingTop: 28,
  },

  footerContainer: {
    flex: 1 / 3,
    alignItems: 'center',
  },

  optionContainer: {
    position: 'absolute',
    bottom: 80,
  },

  optionRow: {
    alignItems: 'center',
    flexDirection: 'row',
  },
});

我们可以看到,当我们选择图片后,底部的按钮就会消失,并且会出现一个模态框,模态框中包含了三个按钮,分别是ResetAdd StickerSave

创建emoji选择器

components目录下,新建一个EmojiPicker.tsx文件。该组件接受三个props

  • isVisible:一个布尔值,用于控制模态框的显示和隐藏。
  • onClose:关闭模态框的函数
  • children:插槽,u用来显示表情符号列表
tsx 复制代码
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { PropsWithChildren } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';

type Props = PropsWithChildren<{
  isVisible: boolean;
  onClose: () => void;
}>;

export default function EmojiPicker({ isVisible, onClose, children }: 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,
  },
});

修改app/(tabs)/index.tsx文件,在onAddSticker函数中,控制模态框的显示和隐藏。

tsx 复制代码
/*
 * @Author: majialu.3 majialu.3@jd.com
 * @Date: 2025-10-31 11:25:47
 * @LastEditors: majialu.3 majialu.3@jd.com
 * @LastEditTime: 2025-11-02 15:16:50
 * @FilePath: /demo/app/(tabs)/index.tsx
 * @Description:
 *
 * Copyright (c) 2025 by majialu.3@jd.com All Rights Reserved.
 */
import Button from '@/components/Button';
import ImageViewer from '@/components/ImageViewer';
import { StyleSheet, View } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { useState } from 'react';
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() {
  // 选中图片的uri
  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) {
      console.log(result);
      setSelectedImage(result.assets[0].uri);
      setShowAppOptions(true);
    } else {
      alert('You did not select any image');
    }
  };

  const onReset = () => {
    setShowAppOptions(false);
  };

  const onAddSticker = () => {
    // TODO: 添加贴纸
    setIsModalVisible(true);
  };

  const onModalClose = () => {
    setIsModalVisible(false);
  };

  const onSaveImageAsync = async () => {
    // TODO: 异步保存图片
  };

  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <ImageViewer
          imgSource={PlaceholderImage}
          selectedImage={selectedImage}
        />
      </View>
      {showAppOptions ? (
        <View style={styles.optionContainer}>
          <View style={styles.optionRow}>
            <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
            label="Choose a photo"
            theme="primary"
            onPress={pickImageAsync}
          />
          <Button
            label="Use this photo"
            onPress={() => setShowAppOptions(true)}
          />
        </View>
      )}
      <EmojiPicker
        isVisible={isModalVisible}
        onClose={onModalClose}
      ></EmojiPicker>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#25292e',
    alignItems: 'center',
  },

  imageContainer: {
    flex: 1,
    paddingTop: 28,
  },

  footerContainer: {
    flex: 1 / 3,
    alignItems: 'center',
  },

  optionContainer: {
    position: 'absolute',
    bottom: 80,
  },

  optionRow: {
    alignItems: 'center',
    flexDirection: 'row',
  },
});

当我们点击加号来添加贴纸的时候,我们可以发现弹窗已经有了,只不过里面还没有内容而已。

创建表情符号列表

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

tsx 复制代码
import { Image } from "expo-image";
import { useState } from "react";
import { FlatList, ImageSourcePropType, Platform, Pressable, StyleSheet } from "react-native";

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

更新apps/(tabs)/index.tsx文件

tsx 复制代码
import Button from "@/components/Button";
import ImageViewer from "@/components/ImageViewer";
import { ImageSourcePropType, StyleSheet, View } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { useState } from "react";
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() {
  // 选中图片的uri
  const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined);

  // 是否显示模态框
  const [showAppOptions, setShowAppOptions] = useState<boolean>(false);

  // 是否显示贴纸框
  const [isModalVisible, setIsModalVisible] = useState<boolean>(false);

  // 选中的Emoji
  const [pickedEmoji, setPickedEmoji] = useState<ImageSourcePropType | undefined>(undefined);

  /**
   * 选择图片处理函数
   */
  const pickImageAsync = async () => {
    let result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ["images"],
      allowsEditing: true,
      quality: 1,
    });

    if (!result.canceled) {
      console.log(result);
      setSelectedImage(result.assets[0].uri);
      setShowAppOptions(true);
    } else {
      alert("You did not select any image");
    }
  };

  const onReset = () => {
    setShowAppOptions(false);
  };

  const onAddSticker = () => {
    // TODO: 添加贴纸
    setIsModalVisible(true);
  };

  const onModalClose = () => {
    setIsModalVisible(false);
  };

  const onSaveImageAsync = async () => {
    // TODO: 异步保存图片
  };

  return (
    <View style={styles.container}>
      <View style={styles.imageContainer}>
        <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} />
      </View>
      {showAppOptions ? (
        <View style={styles.optionContainer}>
          <View style={styles.optionRow}>
            <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 label="Choose a photo" theme="primary" 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,
    paddingTop: 28,
  },

  footerContainer: {
    flex: 1 / 3,
    alignItems: "center",
  },

  optionContainer: {
    position: "absolute",
    bottom: 80,
  },

  optionRow: {
    alignItems: "center",
    flexDirection: "row",
  },
});
显示选定的表情符号

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

tsx 复制代码
import { Image } from "expo-image";
import { ImageSourcePropType, View } from "react-native";

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>
  );
}

apps/(tabs)/index.tsx文件中,进行相应调整:

tsx 复制代码
import Button from "@/components/Button";
import ImageViewer from "@/components/ImageViewer";
import { ImageSourcePropType, StyleSheet, View } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { useState } from "react";
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() {
  // 选中图片的uri
  const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined);

  // 是否显示模态框
  const [showAppOptions, setShowAppOptions] = useState<boolean>(false);

  // 是否显示贴纸框
  const [isModalVisible, setIsModalVisible] = useState<boolean>(false);

  // 选中的Emoji
  const [pickedEmoji, setPickedEmoji] = useState<ImageSourcePropType | undefined>(undefined);

  /**
   * 选择图片处理函数
   */
  const pickImageAsync = async () => {
    let result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ["images"],
      allowsEditing: true,
      quality: 1,
    });

    if (!result.canceled) {
      console.log(result);
      setSelectedImage(result.assets[0].uri);
      setShowAppOptions(true);
    } else {
      alert("You did not select any image");
    }
  };

  const onReset = () => {
    setShowAppOptions(false);
  };

  const onAddSticker = () => {
    // TODO: 添加贴纸
    setIsModalVisible(true);
  };

  const onModalClose = () => {
    setIsModalVisible(false);
  };

  const onSaveImageAsync = async () => {
    // TODO: 异步保存图片
  };

  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.optionContainer}>
          <View style={styles.optionRow}>
            <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 label="Choose a photo" theme="primary" 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,
    paddingTop: 28,
  },

  footerContainer: {
    flex: 1 / 3,
    alignItems: "center",
  },

  optionContainer: {
    position: "absolute",
    bottom: 80,
  },

  optionRow: {
    alignItems: "center",
    flexDirection: "row",
  },
});

我们可以看到,当我们选择一个表情符号后,它将被添加到图片上。

添加手势

我们将实现两种手势:

  • 双击可放大表情贴纸,再次双击缩小
  • 按住贴纸平移可以在屏幕上移动表情贴纸
tsx 复制代码
// app/(tabs)/index.tsx
// ... rest of the import statements remain same
import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function Index() {
  return (
    <GestureHandlerRootView style={styles.container}>
      {/* ...rest of the code remains */}
    </GestureHandlerRootView>
  )
}

修改components/EmojiSticker.tsx文件:

tsx 复制代码
import { ImageSourcePropType, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";

type Props = {
  imageSize: number;
  stickerSource: ImageSourcePropType;
};

export default function EmojiSticker({ imageSize, stickerSource }: Props) {
  // 照片缩放规模
  const scaleImage = useSharedValue(imageSize);

  // 平移位置
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);

  /**
   * 双击动画
   */
  const doubleTap = Gesture.Tap()
    .numberOfTaps(2)
    .onStart(() => {
      if (scaleImage.value !== imageSize * 2) {
        scaleImage.value = scaleImage.value * 2;
      } else {
        scaleImage.value = Math.round(scaleImage.value / 2);
      }
    });

  const drag = Gesture.Pan().onChange((e) => {
    translateX.value += e.changeX;
    translateY.value += e.changeY;
  });

  const containerStyle = useAnimatedStyle(() => {
    return {
      transform: [
        {
          translateX: translateX.value,
        },
        {
          translateY: translateY.value,
        },
      ],
    };
  });

  const imageStyle = useAnimatedStyle(() => {
    return {
      width: withSpring(scaleImage.value),
      height: withSpring(scaleImage.value),
    };
  });
  return (
    <GestureDetector gesture={drag}>
      <Animated.View style={[containerStyle, { top: -350 }]}>
        <GestureDetector gesture={doubleTap}>
          <Animated.Image
            source={stickerSource}
            resizeMode="contain"
            style={[imageStyle, { width: imageSize, height: imageSize }]}
          />
        </GestureDetector>
      </Animated.View>
    </GestureDetector>
  );
}

我们可以发现,当我们双击表情符号时,它会放大或缩小,当我们拖动时,它会平移。

保存图片

我们将使用react-native-view-shotexpo-media-library来保存图片

shell 复制代码
pnpm dlx expo install react-native-view-shot 
pnpm dlx expo install expo-media-library

修改app/(tabs)/index.tsx文件:

tsx 复制代码
import Button from "@/components/Button";
import ImageViewer from "@/components/ImageViewer";
import { ImageSourcePropType, StyleSheet, View } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { useEffect, useRef, useState } from "react";
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";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { captureRef } from "react-native-view-shot";
import * as MediaLibrary from "expo-media-library";

const PlaceholderImage = require("@/assets/images/background-image.png");

export default function Index() {
  // 图片Ref
  const imageRef = useRef<View>(null);

  // 请求权限
  const [permissionResponse, requestPermission] = MediaLibrary.usePermissions();

  // 选中图片的uri
  const [selectedImage, setSelectedImage] = useState<string | undefined>(undefined);

  // 是否显示模态框
  const [showAppOptions, setShowAppOptions] = useState<boolean>(false);

  // 是否显示贴纸框
  const [isModalVisible, setIsModalVisible] = useState<boolean>(false);

  // 选中的Emoji
  const [pickedEmoji, setPickedEmoji] = useState<ImageSourcePropType | undefined>(undefined);

  /**
   * 选择图片处理函数
   */
  const pickImageAsync = async () => {
    let result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ["images"],
      allowsEditing: true,
      quality: 1,
    });

    if (!result.canceled) {
      console.log(result);
      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 () => {
    try {
      const localUrl = await captureRef(imageRef, {
        height: 440,
        quality: 1,
      });

      await MediaLibrary.saveToLibraryAsync(localUrl);
      if (localUrl) {
        alert("saved");
      }
    } catch (e) {
      console.log(e);
    }
  };

  useEffect(() => {
    if (!permissionResponse?.granted) {
      requestPermission();
    }
  });
  return (
    <GestureHandlerRootView style={styles.container}>
      <View style={styles.imageContainer}>
        <View ref={imageRef} collapsable={false}>
          <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} />
          {pickedEmoji && <EmojiSticker imageSize={40} stickerSource={pickedEmoji} />}
        </View>
      </View>
      {showAppOptions ? (
        <View style={styles.optionContainer}>
          <View style={styles.optionRow}>
            <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 label="Choose a photo" theme="primary" onPress={pickImageAsync} />
          <Button label="Use this photo" onPress={() => setShowAppOptions(true)} />
        </View>
      )}
      <EmojiPicker isVisible={isModalVisible} onClose={onModalClose}>
        <EmojiList onSelect={setPickedEmoji} onCloseModal={onModalClose} />
      </EmojiPicker>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#25292e",
    alignItems: "center",
  },

  imageContainer: {
    flex: 1,
    paddingTop: 28,
  },

  footerContainer: {
    flex: 1 / 3,
    alignItems: "center",
  },

  optionContainer: {
    position: "absolute",
    bottom: 80,
  },

  optionRow: {
    alignItems: "center",
    flexDirection: "row",
  },
});

处理平台差异

刚才我们使用react-native-view-shot来保存图片,但是web平台是无法使用这个的,所以我们要针对web平台进行额外的处理。

这里我们使用dom-to-image来在web中保存图片。

shell 复制代码
pnpm add dom-to-image 

# 安装类型声明文件
pnpm add -D @types/dom-to-image

修改app/(tabs)/index.tsx文件:

tsx 复制代码
  /**
   * 保存函数到图库中
   */
  const onSaveImageAsync = async () => {
    // web平台使用domtoimage
    if (Platform.OS === 'web') {
      try {
        if (!imageRef.current) {
          throw new Error('Image ref is not available');
        }
        const dataUrl = await domtoimage.toJpeg(imageRef.current as unknown as Node, {
          quality: 0.95,
          width: 320,
          height: 440,
        });

        let link = document.createElement('a');
        link.download = 'sticker-smash.jpeg';
        link.href = dataUrl;
        link.click();
      } catch (e) {
        console.error('Failed to save image:', e);
        alert('Failed to save image');
      }
    } else {
      // 其他平台使用react-native-view-shot
      try {
        const localUrl = await captureRef(imageRef, {
          height: 440,
          quality: 1,
        });

        await MediaLibrary.saveToLibraryAsync(localUrl);
        if (localUrl) {
          alert('saved');
        }
      } catch (e) {
        console.log(e);
      }
    }
  }; 

配置状态栏,启动画面和应用程序图标

配置状态栏

修改app/_layout.tsx文件。

tsx 复制代码
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';

export default function RootLayout() {
  return (
    <>
      <Stack>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      </Stack>
      <StatusBar style="light" />
    </>
  );
}

启动动画和应用程序图标在app.json文件中已经配置好,我们无需调整。

json 复制代码
{
  "expo": {
    "name": "demo",
    "slug": "demo",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/images/icon.png",
    "scheme": "demo",
    "userInterfaceStyle": "automatic",
    "newArchEnabled": true,
    "ios": {
      "supportsTablet": true
    },
    "android": {
      "adaptiveIcon": {
        "backgroundColor": "#E6F4FE",
        "foregroundImage": "./assets/images/android-icon-foreground.png",
        "backgroundImage": "./assets/images/android-icon-background.png",
        "monochromeImage": "./assets/images/android-icon-monochrome.png"
      },
      "edgeToEdgeEnabled": true,
      "predictiveBackGestureEnabled": false
    },
    "web": {
      "output": "static",
      "favicon": "./assets/images/favicon.png"
    },
    "plugins": [
      "expo-router",
      [
        "expo-splash-screen",
        {
          "image": "./assets/images/splash-icon.png",
          "imageWidth": 200,
          "resizeMode": "contain",
          "backgroundColor": "#ffffff",
          "dark": {
            "backgroundColor": "#000000"
          }
        }
      ]
    ],
    "experiments": {
      "typedRoutes": true,
      "reactCompiler": true
    }
  }
}

打包构建

这里我们使用expo官方推荐的EAS(Expo Application Services)构建,从而实现更快的分发。

安装最新版的eas-cli

shell 复制代码
# 全局安装
pnpm add -g eas-cli@latest

# 登录
eas login

没有账户的可以去expo官网注册一个。

shell 复制代码
# 测试是否登录成功
eas whoami

# 配置项目
eas build:configure

配置完成后,我们发现在项目的根目录有了一个eas.json文件,这个文件是EAS构建的配置文件。

为了便于开发,我们在这里创建一个开发版本 的应用程序,开发版本包含expo-dev-client方便我们在客户端进行调试。

shell 复制代码
# 安装开发调试工具
pnpm dlx expo install expo-dev-client

这里我们出于简单起见,创建一个Android自由分发的版本,从而避免了应用商店的复杂配置过程。

shell 复制代码
# 创建Android自由分发的版本
eas build --platform android --profile development

云构建完成后,终端会输出一个二维码,用Android手机扫码即可下载应用。

这里最好使用科学上网,否则下载有点慢。如果我们不想要开发版本,想要预览版本,直接构建预览版即可。

shell 复制代码
# 创建预览版
eas build --platform android --profile preview

总结

本文从RN的介绍出发,用一个简单的应用demo来演示如何使用expo进行RN开发,以及打包构建。

本专栏的后续文章会继续深入讲解RN的开发知识以及性能优化手段,欢迎订阅关注👏🏻👏🏻👏🏻

好了,这篇文章就到这里啦,如果对您有所帮助,欢迎点赞,收藏,分享👍👍👍。您的认可是我更新的最大动力。由于笔者水平有限,难免有疏漏不足之处,欢迎各位大佬评论区指正。

往期推荐✨✨✨

我是前端拿破轮,关注我,和您分享前端知识,我们下期见!

相关推荐
鹏多多4 分钟前
HTML的Video从基础使用到高级实战+兼容的完全指南
前端·javascript·vue.js
晓得迷路了6 分钟前
栗子前端技术周刊第 107 期 - Angular v21、pnpm 10.22、React 2025 现状调查...
前端·javascript·angular.js
韩曙亮12 分钟前
【Web APIs】JavaScript 事件高级 ③ ( DOM 事件流 | 捕获阶段 | 目标阶段 | 冒泡阶段 )
前端·javascript·web apis·捕获阶段·目标阶段·冒泡阶段·dom 事件流
p***h64316 分钟前
React数据分析应用
前端·react.js·前端框架
4***997417 分钟前
TypeScript类型体操
前端·javascript·typescript
u***096418 分钟前
TypeScript装饰器
前端·javascript·typescript
h***839330 分钟前
React虚拟现实开发
前端·react.js·vr
7***n7534 分钟前
React虚拟现实案例
前端·react.js·vr
不羁的木木40 分钟前
【开源鸿蒙跨平台开发学习笔记】Day02:React Native 开发 HarmonyOS-环境搭建篇(填坑记录)
笔记·学习·react native·harmonyos·har
IT_陈寒1 小时前
JavaScript 闭包通关指南:从作用域链到内存管理的8个核心知识点
前端·人工智能·后端