diff --git a/docs/recipes/ExpoRouter.md b/docs/recipes/ExpoRouter.md new file mode 100644 index 00000000..22903e24 --- /dev/null +++ b/docs/recipes/ExpoRouter.md @@ -0,0 +1,1744 @@ +--- +title: Expo Router +description: How to convert Ignite v9 demo app to utilize `expo-router` +tags: + - Expo + - expo-router + - react-navigation +last_update: + author: Frank Calise +publish_date: 2023-11-03 +--- + +# Expo Router + +## Overview + +Expo Router brings file-based routing to React Native and web applications allowing you to easily create universal apps. Whenever a file is added to your `app` directory, a new path is automatically added to your navigation. + +For the full documentation, head on over to the [Introduction to Expo Router](https://docs.expo.dev/routing/introduction/). + +Ignite v9 is fully equipped to utilize `expo-router` after dependency installation and some directory structure updates! In this recipe, we'll convert the demo app's auth and tab navigators from `react-navigation` to use `expo-router`. + +## Installation and Project Configuration + +Bootstrap a new Ignite project: + +```terminal +npx ignite-cli@next new pizza-router --yes +cd pizza-router +``` + +Add the missing dependencies `expo-router` needs: + +```terminal +npx expo install expo-router expo-constants +``` + +Change the entry point that `expo-router` expects in `package.json`: + +```json +// error-line +"main": "node_modules/expo/AppEntry.js", +// success-line +"main": "expo-router/entry", +``` + +Update the `babel.config.js` with the `expo-router/babel` plugin: + +```js +const plugins = [ + // success-line + "expo-router/babel", + /** react-native-reanimated web support @see https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation/#web */ + "@babel/plugin-proposal-export-namespace-from", + /** NOTE: This must be last in the plugins @see https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation/#babel-plugin */ + "react-native-reanimated/plugin", +]; +``` + +Version 2 of `expo-router` has much improved [TypeScript support](https://docs.expo.dev/router/reference/typed-routes/), so let's enable that in `app.json` under `experiments`. + +```json +{ + "expo": { + // ... + // success-line-start + "experiments": { + "typedRoutes": true, + "tsconfigPaths": true + } + // success-line-end + } +} +``` + +And finally, let's update the TS alias and include paths over in `tsconfig.json`. More on this in the next section. + +```json +{ + "compilerOptions": { + // ... + "paths": { + // error-line + "app/*": ["./app/*"], + // success-line + "src/*": ["./src/*"], + // ... + }, + } + "include": [ + "index.js", + "app", + "types", + "plugins", + "app.config.ts", + "src", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ], + // ... +} +``` + +## Reworking the Directory Structure + +Due to some naming conventions forced by `expo-router`, we'll have to rename the `app` directory to `src` from Ignite's base boilerplate. The `app` directory will contain all the file-base routing files from here on out, so models, components and other shared files will be located in the `src` directory now. We'll also remove `App.tsx` as this is no longer the entry point of the application + +```terminal +rm App.tsx +mv app src +mkdir app +``` + +## Creating File-based Routes + +### app/\_layout.tsx + +We're now ready to start setting up the navigation for the app! If you're familiar with Ignite, `app.tsx` is where our root navigator lives, however, with `expo-router`, we'll use `app/_layout.tsx` for that. We'll add the providers here that any route would need within the app. + +```tsx +// app/_layout.tsx +import React from "react"; +import { Slot, SplashScreen } from "expo-router"; +import { useInitialRootStore } from "src/models"; + +SplashScreen.preventAutoHideAsync(); + +if (__DEV__) { + // Load Reactotron configuration in development. We don't want to + // include this in our production bundle, so we are using `if (__DEV__)` + // to only execute this in development. + require("src/devtools/ReactotronConfig.ts"); +} + +export { ErrorBoundary } from "src/screens/ErrorScreen/ErrorBoundary"; + +export default function Root() { + // Wait for stores to load and render our layout inside of it so we have access + // to auth info etc + const { rehydrated } = useInitialRootStore(); + if (!rehydrated) { + return null; + } + + // TODO: render gesture handler wrapper here? + return ; +} +``` + +For starters, this sets up our error boundary for the app and handles waiting on our stores to rehydrate. `` comes from `expo-router`, you can think of it like the `children` prop in `React`. This component can be wrapped with others to help create a layout. + +Next, we'll convert the conditional part of authentication from `react-navigation` to `expo-router`, deciding on whether or not to display the login form or get to the welcome screen experience. + +### app/(app)/\_layout.tsx + +Create another `_layout.tsx` but this time inside of a new directory, `app/(app)`. This route wrapped in parentheses is called a [Group](https://docs.expo.dev/routing/layouts/#groups). Groups can be used to add layouts and/or help organize sections of the app without adding additional segments to the URL. Remember, each directory is a route in this new mental model of file-based routing - but sometimes we don't want that, that's when you'll call upon groups. + +In this layout is where we'll determine if the user is authenticated by checking our MST store. We'll also wait here while assets are loaded and then hide the splash screen when finished. + +```tsx +import React from "react"; +import { Redirect, SplashScreen, Stack } from "expo-router"; +import { observer } from "mobx-react-lite"; +import { useStores } from "src/models"; +import { useFonts } from "expo-font"; +import { customFontsToLoad } from "src/theme"; + +export default observer(function Layout() { + const { + authenticationStore: { isAuthenticated }, + } = useStores(); + + const [fontsLoaded, fontError] = useFonts(customFontsToLoad); + + React.useEffect(() => { + if (fontsLoaded || fontError) { + // Hide the splash screen after the fonts have loaded and the UI is ready. + SplashScreen.hideAsync(); + } + }, [fontsLoaded, fontError]); + + if (!fontsLoaded && !fontError) { + return null; + } + + if (!isAuthenticated) { + return ; + } + + return ; +}); +``` + +As you can see, if the user is not authenticated we redirect them to the `/log-in` route, otherwise we'll render a stack navigator. TypeScript is probably telling us that route doesn't exist yet, so let's fix that. + +### app/log-in.tsx + +To redirect the user to the login form, create `app/log-in.tsx`. We'll copy over the contents from the original boilerplate `src/screens/LoginScreen.tsx` to help the UI layout of this page. + +
+ app/log-in.tsx + +```tsx +import { observer } from "mobx-react-lite"; +import React, { + ComponentType, + FC, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { TextInput, TextStyle, ViewStyle } from "react-native"; +import { + Button, + Icon, + Screen, + Text, + TextField, + TextFieldAccessoryProps, +} from "src/components"; +import { useStores } from "src/models"; +import { AppStackScreenProps } from "src/navigators"; +import { colors, spacing } from "src/theme"; + +export default observer(function Login(_props) { + const authPasswordInput = useRef(null); + + const [authPassword, setAuthPassword] = useState(""); + const [isAuthPasswordHidden, setIsAuthPasswordHidden] = useState(true); + const [isSubmitted, setIsSubmitted] = useState(false); + const [attemptsCount, setAttemptsCount] = useState(0); + const { + authenticationStore: { + authEmail, + setAuthEmail, + setAuthToken, + validationError, + }, + } = useStores(); + + useEffect(() => { + // Here is where you could fetch credentials from keychain or storage + // and pre-fill the form fields. + setAuthEmail("ignite@infinite.red"); + setAuthPassword("ign1teIsAwes0m3"); + + // Return a "cleanup" function that React will run when the component unmounts + return () => { + setAuthPassword(""); + setAuthEmail(""); + }; + }, []); + + const error = isSubmitted ? validationError : ""; + + function login() { + setIsSubmitted(true); + setAttemptsCount(attemptsCount + 1); + + if (validationError) return; + + // Make a request to your server to get an authentication token. + // If successful, reset the fields and set the token. + setIsSubmitted(false); + setAuthPassword(""); + setAuthEmail(""); + + // We'll mock this with a fake token. + setAuthToken(String(Date.now())); + } + + const PasswordRightAccessory: ComponentType = + useMemo( + () => + function PasswordRightAccessory(props: TextFieldAccessoryProps) { + return ( + setIsAuthPasswordHidden(!isAuthPasswordHidden)} + /> + ); + }, + [isAuthPasswordHidden] + ); + + return ( + + + + {attemptsCount > 2 && ( + + )} + + authPasswordInput.current?.focus()} + /> + + + +
+ +If you're familiar with the Ignite boilerplate, this is the same authentication screen you are used to - the only difference here is some of the imports now from from `src/*` rather than the relative paths. So keep that in mind if you're upgrading an existing application. + +### app/(app)/index.tsx + +If the user is successfully authenticated, we'll show them the welcome screen. Can you guess what the route will be by looking at the directory structure? + +Just the root route! Think about it in terms of web URLs, if arriving at `http://localhost:8081/` (in this case of local development), we'd expect to see the welcome screen. However, if we're not authenticated, we'll be redirected to `/log-in` to ask the user to log in. + +This JSX will be the same exact contents from `WelcomeScreen.tsx` in the original Ignite boilerplate with the exception of some import paths (using the TS aliases) and a simple update to `goNext`. + +Since we'll no longer use the `navigation` prop, we utilize `expo-router`'s [Imperative navigation](https://docs.expo.dev/routing/navigating-pages/#imperative-navigation) to navigate to the component demo Showroom next. We're using `.replace` since we don't need to get back to this route. You can read more about [Navigating between pages](https://docs.expo.dev/routing/navigating-pages/) at Expo's documentation. + +
+ app/(app)/index.tsx + +```tsx +import { router } from "expo-router"; +import { observer } from "mobx-react-lite"; +import React from "react"; +import { Image, ImageStyle, TextStyle, View, ViewStyle } from "react-native"; +import { Button, Text } from "src/components"; +import { isRTL } from "src/i18n"; +import { useStores } from "src/models"; +import { colors, spacing } from "src/theme"; +import { useHeader } from "src/utils/useHeader"; +import { useSafeAreaInsetsStyle } from "src/utils/useSafeAreaInsetsStyle"; + +const welcomeLogo = require("assets/images/logo.png"); +const welcomeFace = require("assets/images/welcome-face.png"); + +export default observer(function WelcomeScreen() { + const { + authenticationStore: { logout }, + } = useStores(); + + function goNext() { + router.replace("/showroom"); + } + + useHeader( + { + rightTx: "common.logOut", + onRightPress: logout, + }, + [logout] + ); + + const $bottomContainerInsets = useSafeAreaInsetsStyle(["bottom"]); + + return ( + + + + + + + + + + +
+ +## Adding Tab Navigation + +Now that we can see the welcome screen, it's time to add the tab navigator. First, we'll create another route group to help contain where these routes live and set the layout for the tabs. + +Create `app/(app)/(tabs)/_layout.tsx` and we'll convert Ignite's previous `app/navigators/DemoNavigator.tsx` to live here. + +
+ app/(app)/(tabs)/_layout.tsx + +```tsx +import React from "react"; +import { Tabs } from "expo-router/tabs"; +import { observer } from "mobx-react-lite"; +import { Icon } from "src/components"; +import { translate } from "src/i18n"; +import { colors, spacing, typography } from "src/theme"; +import { TextStyle, ViewStyle } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export default observer(function Layout() { + const { bottom } = useSafeAreaInsets(); + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +}); + +const $tabBar: ViewStyle = { + backgroundColor: colors.background, + borderTopColor: colors.transparent, +}; + +const $tabBarItem: ViewStyle = { + paddingTop: spacing.md, +}; + +const $tabBarLabel: TextStyle = { + fontSize: 12, + fontFamily: typography.primary.medium, + lineHeight: 16, + flex: 1, +}; +``` + +
+ +### Creating Tab Screens + +Now to create screens for each tabs, you simply just add `[screen].tsx` under the `(tabs)` group. Let's bring over the 3 simpler screens first - Community, Podcasts and Debug. Those will mostly be copy 🍝 aside from changing the exports to default and import from our TS paths. + +
+ app/(app)/(tabs)/community.tsx + +```tsx +import React from "react"; +import { Image, ImageStyle, TextStyle, View, ViewStyle } from "react-native"; +import { ListItem, Screen, Text } from "src/components"; +import { spacing } from "src/theme"; +import { openLinkInBrowser } from "src/utils/openLinkInBrowser"; +import { isRTL } from "src/i18n"; + +const chainReactLogo = require("assets/images/demo/cr-logo.png"); +const reactNativeLiveLogo = require("assets/images/demo/rnl-logo.png"); +const reactNativeRadioLogo = require("assets/images/demo/rnr-logo.png"); +const reactNativeNewsletterLogo = require("assets/images/demo/rnn-logo.png"); + +export default function DemoCommunityScreen() { + return ( + + + + + + + openLinkInBrowser("https://community.infinite.red/")} + /> + + + + openLinkInBrowser("https://github.com/infinitered/ignite") + } + /> + + + + + + + } + onPress={() => openLinkInBrowser("https://reactnativeradio.com/")} + /> + + + + } + onPress={() => openLinkInBrowser("https://reactnativenewsletter.com/")} + /> + + + + } + onPress={() => openLinkInBrowser("https://rn.live/")} + /> + + + + } + onPress={() => openLinkInBrowser("https://cr.infinite.red/")} + /> + + + openLinkInBrowser("https://infinite.red/contact")} + /> + + ); +} + +const $container: ViewStyle = { + paddingTop: spacing.lg + spacing.xl, + paddingHorizontal: spacing.lg, +}; + +const $title: TextStyle = { + marginBottom: spacing.sm, +}; + +const $tagline: TextStyle = { + marginBottom: spacing.xxl, +}; + +const $description: TextStyle = { + marginBottom: spacing.lg, +}; + +const $sectionTitle: TextStyle = { + marginTop: spacing.xxl, +}; + +const $logoContainer: ViewStyle = { + marginEnd: spacing.md, + flexDirection: "row", + flexWrap: "wrap", + alignContent: "center", +}; + +const $logo: ImageStyle = { + height: 38, + width: 38, +}; +``` + +
+ +
+ app/(app)/(tabs)/podcasts.tsx + +```tsx +import { observer } from "mobx-react-lite"; +import React, { ComponentType, useEffect, useMemo } from "react"; +import { + AccessibilityProps, + ActivityIndicator, + Image, + ImageSourcePropType, + ImageStyle, + Platform, + StyleSheet, + TextStyle, + View, + ViewStyle, +} from "react-native"; +import { type ContentStyle } from "@shopify/flash-list"; +import Animated, { + Extrapolate, + interpolate, + useAnimatedStyle, + useSharedValue, + withSpring, +} from "react-native-reanimated"; +import { + Button, + ButtonAccessoryProps, + Card, + EmptyState, + Icon, + ListView, + Screen, + Text, + Toggle, +} from "src/components"; +import { isRTL, translate } from "src/i18n"; +import { useStores } from "src/models"; +import { Episode } from "src/models/Episode"; +import { colors, spacing } from "src/theme"; +import { delay } from "src/utils/delay"; +import { openLinkInBrowser } from "src/utils/openLinkInBrowser"; + +const ICON_SIZE = 14; + +const rnrImage1 = require("assets/images/demo/rnr-image-1.png"); +const rnrImage2 = require("assets/images/demo/rnr-image-2.png"); +const rnrImage3 = require("assets/images/demo/rnr-image-3.png"); +const rnrImages = [rnrImage1, rnrImage2, rnrImage3]; + +export default observer(function DemoPodcastListScreen(_props) { + const { episodeStore } = useStores(); + + const [refreshing, setRefreshing] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + // initially, kick off a background refresh without the refreshing UI + useEffect(() => { + (async function load() { + setIsLoading(true); + await episodeStore.fetchEpisodes(); + setIsLoading(false); + })(); + }, [episodeStore]); + + // simulate a longer refresh, if the refresh is too fast for UX + async function manualRefresh() { + setRefreshing(true); + await Promise.all([episodeStore.fetchEpisodes(), delay(750)]); + setRefreshing(false); + } + + return ( + + + contentContainerStyle={$listContentContainer} + data={episodeStore.episodesForList.slice()} + extraData={episodeStore.favorites.length + episodeStore.episodes.length} + refreshing={refreshing} + estimatedItemSize={177} + onRefresh={manualRefresh} + ListEmptyComponent={ + isLoading ? ( + + ) : ( + + ) + } + ListHeaderComponent={ + + + {(episodeStore.favoritesOnly || + episodeStore.episodesForList.length > 0) && ( + + + episodeStore.setProp( + "favoritesOnly", + !episodeStore.favoritesOnly + ) + } + variant="switch" + labelTx="demoPodcastListScreen.onlyFavorites" + labelPosition="left" + labelStyle={$labelStyle} + accessibilityLabel={translate( + "demoPodcastListScreen.accessibility.switch" + )} + /> + + )} + + } + renderItem={({ item }) => ( + episodeStore.toggleFavorite(item)} + /> + )} + /> + + ); +}); + +const EpisodeCard = observer(function EpisodeCard({ + episode, + isFavorite, + onPressFavorite, +}: { + episode: Episode; + onPressFavorite: () => void; + isFavorite: boolean; +}) { + const liked = useSharedValue(isFavorite ? 1 : 0); + + const imageUri = useMemo(() => { + return rnrImages[Math.floor(Math.random() * rnrImages.length)]; + }, []); + + // Grey heart + const animatedLikeButtonStyles = useAnimatedStyle(() => { + return { + transform: [ + { + scale: interpolate(liked.value, [0, 1], [1, 0], Extrapolate.EXTEND), + }, + ], + opacity: interpolate(liked.value, [0, 1], [1, 0], Extrapolate.CLAMP), + }; + }); + + // Pink heart + const animatedUnlikeButtonStyles = useAnimatedStyle(() => { + return { + transform: [ + { + scale: liked.value, + }, + ], + opacity: liked.value, + }; + }); + + /** + * Android has a "longpress" accessibility action. iOS does not, so we just have to use a hint. + * @see https://reactnative.dev/docs/accessibility#accessibilityactions + */ + const accessibilityHintProps = useMemo( + () => + Platform.select({ + ios: { + accessibilityLabel: episode.title, + accessibilityHint: translate( + "demoPodcastListScreen.accessibility.cardHint", + { + action: isFavorite ? "unfavorite" : "favorite", + } + ), + }, + android: { + accessibilityLabel: episode.title, + accessibilityActions: [ + { + name: "longpress", + label: translate( + "demoPodcastListScreen.accessibility.favoriteAction" + ), + }, + ], + onAccessibilityAction: ({ nativeEvent }) => { + if (nativeEvent.actionName === "longpress") { + handlePressFavorite(); + } + }, + }, + }), + [episode, isFavorite] + ); + + const handlePressFavorite = () => { + onPressFavorite(); + liked.value = withSpring(liked.value ? 0 : 1); + }; + + const handlePressCard = () => { + openLinkInBrowser(episode.enclosure.link); + }; + + const ButtonLeftAccessory: ComponentType = useMemo( + () => + function ButtonLeftAccessory() { + return ( + + + + + + + + + ); + }, + [] + ); + + return ( + + + {episode.datePublished.textLabel} + + + {episode.duration.textLabel} + + + } + content={`${episode.parsedTitleAndSubtitle.title} - ${episode.parsedTitleAndSubtitle.subtitle}`} + {...accessibilityHintProps} + RightComponent={} + FooterComponent={ + + } + /> + ); +}); + +const $screenContentContainer: ViewStyle = { + flex: 1, +}; + +const $listContentContainer: ContentStyle = { + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg + spacing.xl, + paddingBottom: spacing.lg, +}; + +const $heading: ViewStyle = { + marginBottom: spacing.md, +}; + +const $item: ViewStyle = { + padding: spacing.md, + marginTop: spacing.md, + minHeight: 120, +}; + +const $itemThumbnail: ImageStyle = { + marginTop: spacing.sm, + borderRadius: 50, + alignSelf: "flex-start", +}; + +const $toggle: ViewStyle = { + marginTop: spacing.md, +}; + +const $labelStyle: TextStyle = { + textAlign: "left", +}; + +const $iconContainer: ViewStyle = { + height: ICON_SIZE, + width: ICON_SIZE, + flexDirection: "row", + marginEnd: spacing.sm, +}; + +const $metadata: TextStyle = { + color: colors.textDim, + marginTop: spacing.xs, + flexDirection: "row", +}; + +const $metadataText: TextStyle = { + color: colors.textDim, + marginEnd: spacing.md, + marginBottom: spacing.xs, +}; + +const $favoriteButton: ViewStyle = { + borderRadius: 17, + marginTop: spacing.md, + justifyContent: "flex-start", + backgroundColor: colors.palette.neutral300, + borderColor: colors.palette.neutral300, + paddingHorizontal: spacing.md, + paddingTop: spacing.xxxs, + paddingBottom: 0, + minHeight: 32, + alignSelf: "flex-start", +}; + +const $unFavoriteButton: ViewStyle = { + borderColor: colors.palette.primary100, + backgroundColor: colors.palette.primary100, +}; + +const $emptyState: ViewStyle = { + marginTop: spacing.xxl, +}; + +const $emptyStateImage: ImageStyle = { + transform: [{ scaleX: isRTL ? -1 : 1 }], +}; +``` + +
+ +
+ app/(app)/(tabs)/debug.tsx + +```tsx +import React from "react"; +import * as Application from "expo-application"; +import { Linking, Platform, TextStyle, View, ViewStyle } from "react-native"; +import { Button, ListItem, Screen, Text } from "src/components"; +import { colors, spacing } from "src/theme"; +import { isRTL } from "src/i18n"; +import { useStores } from "src/models"; + +function openLinkInBrowser(url: string) { + Linking.canOpenURL(url).then((canOpen) => canOpen && Linking.openURL(url)); +} + +export default function DemoDebugScreen() { + const { + authenticationStore: { logout }, + } = useStores(); + + const usingHermes = + typeof HermesInternal === "object" && HermesInternal !== null; + // @ts-expect-error + const usingFabric = global.nativeFabricUIManager != null; + + const demoReactotron = React.useMemo( + () => async () => { + if (__DEV__) { + console.tron.display({ + name: "DISPLAY", + value: { + appId: Application.applicationId, + appName: Application.applicationName, + appVersion: Application.nativeApplicationVersion, + appBuildVersion: Application.nativeBuildVersion, + hermesEnabled: usingHermes, + }, + important: true, + }); + } + }, + [] + ); + + return ( + + + openLinkInBrowser("https://github.com/infinitered/ignite/issues") + } + /> + + + + App Id + {Application.applicationId} + + } + /> + + App Name + {Application.applicationName} + + } + /> + + App Version + {Application.nativeApplicationVersion} + + } + /> + + App Build Version + {Application.nativeBuildVersion} + + } + /> + + Hermes Enabled + {String(usingHermes)} + + } + /> + + Fabric Enabled + {String(usingFabric)} + + } + /> + + +
+ +These will all be navigable by routing to `/community`, `/podcasts` or `/debug`. Next we'll cover the Showroom which is a bit more involved, since we have to add some supporting components that only apply to that route. + +### Showroom Screen + +The Showroom screen has some supporting components it needs that only applies to that route. Ignite used to colocate these next to the screen file itself, in the `app/screens/DemoShowroomScreen` directory. However, `expo-router` wants to keep the `app` directory strictly for app routes. + +To adhere to this, we'll move the supporting components to `src/components/Showroom` and import them from their in our `app/(app)/(tabs)/showroom.tsx`. + +```terminal +mv src/screens/DemoShowroomScreen src/components/Showroom +rm src/components/Showroom/DemoShowroomScreen.tsx +``` + +We've deleted the screen file because we'll make a few `expo-router` specific changes to it over in the `app` directory. One improvement we can make to the Showroom screen is that we can reduce the platform specific code with regards to the `renderItem` of `SectionList`. + +Before, we had an implementation for both web and mobile to help with some specific web routing for deep links: + +```tsx +const WebListItem: FC = ({ item, sectionIndex }) => { + const sectionSlug = item.name.toLowerCase(); + + return ( + + + {item.name} + + {item.useCases.map((u) => { + const itemSlug = slugify(u); + + return ( + + {u} + + ); + })} + + ); +}; + +const NativeListItem: FC = ({ + item, + sectionIndex, + handleScroll, +}) => ( + + handleScroll?.(sectionIndex)} + preset="bold" + style={$menuContainer} + > + {item.name} + + {item.useCases.map((u, index) => ( + handleScroll?.(sectionIndex, index + 1)} + text={u} + rightIcon={isRTL ? "caretLeft" : "caretRight"} + /> + ))} + +); + +const ShowroomListItem = Platform.select({ + web: WebListItem, + default: NativeListItem, +}); +``` + +However, we don't have to worry about this anymore. We can implement this as follows: + +```tsx +const ShowroomListItem: FC = ({ item, sectionIndex }) => { + const sectionSlug = item.name.toLowerCase(); + + return ( + + + {item.name} + + {item.useCases.map((u) => { + const itemSlug = slugify(u); + return ( + + + + ); + })} + + ); +}; +``` + +Note the `Link` wrapper provided by `expo-router`. We link to the `/showroom` route and provide the extra search params for a section or specific component we want to navigate to. We can then extract (and type) these params using `useLocalSearchParams` + +The snippet below contains the entire file for reference: + +
+ app/(app)/(tabs)/showroom.tsx + +```tsx +import React, { FC, useEffect, useRef, useState } from "react"; +import { + Image, + ImageStyle, + SectionList, + TextStyle, + View, + ViewStyle, +} from "react-native"; +import { Drawer } from "react-native-drawer-layout"; +import { type ContentStyle } from "@shopify/flash-list"; +import { ListItem, ListView, ListViewRef, Screen, Text } from "src/components"; +import { isRTL } from "src/i18n"; +import { colors, spacing } from "src/theme"; +import { useSafeAreaInsetsStyle } from "src/utils/useSafeAreaInsetsStyle"; +import * as Demos from "src/components/Showroom/demos"; +import { DrawerIconButton } from "src/components/Showroom/DrawerIconButton"; +import { Link, useLocalSearchParams } from "expo-router"; + +const logo = require("assets/images/logo.png"); + +interface DemoListItem { + item: { name: string; useCases: string[] }; + sectionIndex: number; + handleScroll?: (sectionIndex: number, itemIndex?: number) => void; +} + +const slugify = (str: string) => + str + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + +const ShowroomListItem: FC = ({ item, sectionIndex }) => { + const sectionSlug = item.name.toLowerCase(); + + return ( + + + {item.name} + + {item.useCases.map((u) => { + const itemSlug = slugify(u); + return ( + + + + ); + })} + + ); +}; + +export default function DemoShowroomScreen() { + const [open, setOpen] = useState(false); + const timeout = useRef>(); + const listRef = useRef(null); + const menuRef = useRef>(null); + + const params = useLocalSearchParams<{ + sectionSlug?: string; + itemSlug?: string; + }>(); + + // handle scroll when section/item params change + React.useEffect(() => { + if (Object.keys(params).length > 0) { + const demoValues = Object.values(Demos); + const findSectionIndex = demoValues.findIndex( + (x) => x.name.toLowerCase() === params.sectionSlug + ); + let findItemIndex = 0; + if (params.itemSlug) { + try { + findItemIndex = + demoValues[findSectionIndex].data.findIndex( + (u) => slugify(u.props.name) === params.itemSlug + ) + 1; + } catch (err) { + console.error(err); + } + } + handleScroll(findSectionIndex, findItemIndex); + } + }, [params]); + + const toggleDrawer = () => { + if (!open) { + setOpen(true); + } else { + setOpen(false); + } + }; + + const handleScroll = (sectionIndex: number, itemIndex = 0) => { + listRef.current?.scrollToLocation({ + animated: true, + itemIndex, + sectionIndex, + }); + toggleDrawer(); + }; + + const scrollToIndexFailed = (info: { + index: number; + highestMeasuredFrameIndex: number; + averageItemLength: number; + }) => { + listRef.current?.getScrollResponder()?.scrollToEnd(); + timeout.current = setTimeout( + () => + listRef.current?.scrollToLocation({ + animated: true, + itemIndex: info.index, + sectionIndex: 0, + }), + 50 + ); + }; + + useEffect(() => { + return () => timeout.current && clearTimeout(timeout.current); + }, []); + + const $drawerInsets = useSafeAreaInsetsStyle(["top"]); + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + drawerType={"slide"} + drawerPosition={isRTL ? "right" : "left"} + renderDrawerContent={() => ( + + + + + + + ref={menuRef} + contentContainerStyle={$listContentContainer} + estimatedItemSize={250} + data={Object.values(Demos).map((d) => ({ + name: d.name, + useCases: d.data.map((u) => u.props.name as string), + }))} + keyExtractor={(item) => item.name} + renderItem={({ item, index: sectionIndex }) => ( + + )} + /> + + )} + > + + + + item} + renderSectionFooter={() => } + ListHeaderComponent={ + + + + } + onScrollToIndexFailed={scrollToIndexFailed} + renderSectionHeader={({ section }) => { + return ( + + + {section.name} + + {section.description} + + ); + }} + /> + + + ); +} + +const $screenContainer: ViewStyle = { + flex: 1, +}; + +const $drawer: ViewStyle = { + backgroundColor: colors.background, + flex: 1, +}; + +const $listContentContainer: ContentStyle = { + paddingHorizontal: spacing.lg, +}; + +const $sectionListContentContainer: ViewStyle = { + paddingHorizontal: spacing.lg, +}; + +const $heading: ViewStyle = { + marginBottom: spacing.xxxl, +}; + +const $logoImage: ImageStyle = { + height: 42, + width: 77, +}; + +const $logoContainer: ViewStyle = { + alignSelf: "flex-start", + justifyContent: "center", + height: 56, + paddingHorizontal: spacing.lg, +}; + +const $demoItemName: TextStyle = { + fontSize: 24, + marginBottom: spacing.md, +}; + +const $demoItemDescription: TextStyle = { + marginBottom: spacing.xxl, +}; + +const $demoUseCasesSpacer: ViewStyle = { + paddingBottom: spacing.xxl, +}; +``` + +
+ +If you head on over to the web app at `http://localhost:8081/showroom?itemSlug=variants§ionSlug=toggle`, you'll see the Showroom screen will open and scroll down to the appropriate section. + +We can emulate this same deep link on an iOS simulator with the following command: + +```terminal +xcrun simctl openurl booted 'pizza-router://showroom?sectionSlug=toggle&itemSlug=variants' +``` + +Observe the simulator opens the mobile app and navigates to the Showroom screen, followed by scrolling to the Variants section of the Toggle component. + +We get that universal linking for free with `expo-router`! + +## Code Removal + +Now that we have the boilerplate up and running again, let's clean up some of the screen and navigation files that are no longer needed. + +```terminal +rm src/app.tsx +rm -rf src/screens +rm -rf src/navigators +``` + +## Summary + +There you have it, a culinary masterpiece of Ignite and Expo Router, shipped in one pizza box. What we achieved here: + +- Simplified navigation code +- Typed routing +- Examples of many aspects of `expo-router`, such as authentication, tab navigation, search params +- Deep linking that Just WorksTM on both web and mobile +- Reduced Platform specific code + +## Additional Resources + +To go more in-depth on `expo-router`, check out the official documentation at [Expo.dev](https://docs.expo.dev/routing/introduction/). + +You can also follow Evan Bacon, the author of Expo Router, on [GitHub](https://github.com/EvanBacon/expo-router-twitter/blob/main/app/_layout.tsx) and check out his applications or demos using the navigation library. + +- [Pillar Valley](https://github.com/EvanBacon/pillar-valley/) - a game built in Expo using expo-router +- [Twitter routing demo](https://github.com/EvanBacon/expo-router-twitter/) - a demo of how an expo-router application would look if rebuilding Twitter's routes + +Additionally, here is an Ignite repo with `expo-router` added in for reference on my [GitHub](https://github.com/frankcalise/ignite-expo-router)