From c67b42a324f9bf209c0065b44d48a82ee67712a0 Mon Sep 17 00:00:00 2001 From: Frank Calise Date: Tue, 7 Nov 2023 17:02:41 -0500 Subject: [PATCH 1/5] feat(recipes): expo-router first draft --- docs/recipes/ExpoRouter.md | 1744 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1744 insertions(+) create mode 100644 docs/recipes/ExpoRouter.md 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) From 64e7ee6cbfc8289d789f82576c7780ac5a068d6e Mon Sep 17 00:00:00 2001 From: Frank Calise Date: Wed, 8 Nov 2023 06:10:51 -0500 Subject: [PATCH 2/5] fix(config): added error range blocks --- docusaurus.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/docusaurus.config.js b/docusaurus.config.js index a7298e57..d4bbcb16 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -281,6 +281,7 @@ const config = { { className: "code-block-error-line", line: "error-line", + block: { start: "error-line-start", end: "error-line-end" }, }, { className: "code-block-success-line", From 199b88000026528ca7b585ed8c9987c68950388d Mon Sep 17 00:00:00 2001 From: Frank Calise Date: Wed, 8 Nov 2023 06:11:20 -0500 Subject: [PATCH 3/5] fix(expo-router): src dir, reactotron updates --- docs/recipes/ExpoRouter.md | 134 +++++++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 36 deletions(-) diff --git a/docs/recipes/ExpoRouter.md b/docs/recipes/ExpoRouter.md index 22903e24..b7e034b3 100644 --- a/docs/recipes/ExpoRouter.md +++ b/docs/recipes/ExpoRouter.md @@ -16,7 +16,7 @@ publish_date: 2023-11-03 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/). +For the full documentation by [Expo](https://expo.dev), 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`. @@ -87,16 +87,7 @@ And finally, let's update the TS alias and include paths over in `tsconfig.json` // ... }, } - "include": [ - "index.js", - "app", - "types", - "plugins", - "app.config.ts", - "src", - ".expo/types/**/*.ts", - "expo-env.d.ts" - ], + "include": ["**/*.ts", "**/*.tsx"], // ... } ``` @@ -108,14 +99,15 @@ Due to some naming conventions forced by `expo-router`, we'll have to rename the ```terminal rm App.tsx mv app src -mkdir app +mv src/screens/ErrorBoundary src/components +mkdir src/app ``` ## Creating File-based Routes -### app/\_layout.tsx +### src/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. +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 `src/app/_layout.tsx` for that. We'll add the providers here that any route would need within the app. ```tsx // app/_layout.tsx @@ -132,7 +124,7 @@ if (__DEV__) { require("src/devtools/ReactotronConfig.ts"); } -export { ErrorBoundary } from "src/screens/ErrorScreen/ErrorBoundary"; +export { ErrorBoundary } from "src/components/ErrorBoundary/ErrorBoundary"; export default function Root() { // Wait for stores to load and render our layout inside of it so we have access @@ -142,7 +134,6 @@ export default function Root() { return null; } - // TODO: render gesture handler wrapper here? return ; } ``` @@ -151,9 +142,9 @@ For starters, this sets up our error boundary for the app and handles waiting on 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 +### src/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. +Create another `_layout.tsx` but this time inside of a new directory, `src/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. @@ -193,12 +184,12 @@ export default observer(function Layout() { 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 +### src/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. +To redirect the user to the login form, create `src/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 + src/app/log-in.tsx ```tsx import { observer } from "mobx-react-lite"; @@ -380,7 +371,7 @@ const $tapButton: ViewStyle = { 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 +### src/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? @@ -391,7 +382,7 @@ This JSX will be the same exact contents from `WelcomeScreen.tsx` in the origina 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 + src/app/(app)/index.tsx ```tsx import { router } from "expo-router"; @@ -503,10 +494,10 @@ const $welcomeHeading: TextStyle = { 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. +Create `src/app/(app)/(tabs)/_layout.tsx` and we'll convert Ignite's previous `app/navigators/DemoNavigator.tsx` to live here.
- app/(app)/(tabs)/_layout.tsx + src/app/(app)/(tabs)/_layout.tsx ```tsx import React from "react"; @@ -622,7 +613,7 @@ const $tabBarLabel: TextStyle = { 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 + src/app/(app)/(tabs)/community.tsx ```tsx import React from "react"; @@ -778,7 +769,7 @@ const $logo: ImageStyle = {
- app/(app)/(tabs)/podcasts.tsx + src/app/(app)/(tabs)/podcasts.tsx ```tsx import { observer } from "mobx-react-lite"; @@ -1178,7 +1169,7 @@ const $emptyStateImage: ImageStyle = {
- app/(app)/(tabs)/debug.tsx + src/app/(app)/(tabs)/debug.tsx ```tsx import React from "react"; @@ -1350,15 +1341,29 @@ These will all be navigable by routing to `/community`, `/podcasts` or `/debug`. ### 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. +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 `src/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`. +To adhere to this, we'll move the supporting components to `src/components/Showroom` and import them from their in our `src/app/(app)/(tabs)/showroom.tsx`. ```terminal mv src/screens/DemoShowroomScreen src/components/Showroom rm src/components/Showroom/DemoShowroomScreen.tsx ``` +> **Note**: There is a type that gets removed by the above command. Add the following to the top of `src/components/Showroom/demos/index.ts` +> +> ```tsx +> import { ReactElement } from "react"; +> +> export interface Demo { +> name: string; +> description: string; +> data: ReactElement[]; +> } +> ``` +> +> You'll need to update the imports in the `src/components/Showroom/demos/Demo*.ts` files. + 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: @@ -1451,7 +1456,7 @@ Note the `Link` wrapper provided by `expo-router`. We link to the `/showroom` ro The snippet below contains the entire file for reference:
- app/(app)/(tabs)/showroom.tsx + src/app/(app)/(tabs)/showroom.tsx ```tsx import React, { FC, useEffect, useRef, useState } from "react"; @@ -1712,9 +1717,9 @@ Observe the simulator opens the mobile app and navigates to the Showroom screen, We get that universal linking for free with `expo-router`! -## Code Removal +## Code Cleanup -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. +Now that we have the boilerplate up and running again, let's clean some of the screen and navigation files that are no longer needed. ```terminal rm src/app.tsx @@ -1722,6 +1727,63 @@ rm -rf src/screens rm -rf src/navigators ``` +In doing so, we'll need to fix some `Reacetotron` code for custom commands. We'll drop the `resetNavigation` one (logging out is really the same thing) and update the `navigateTo` and `goBack`. Open up `src/devtools/ReactotronConfig.ts` to edit these. + +```ts +// error-line +import { + goBack, + resetRoot, + navigate, +} from "src/navigators/navigationUtilities"; +// success-line +import { router } from "expo-router"; +// ... +// error-line-start +reactotron.onCustomCommand({ + title: "Reset Navigation State", + description: "Resets the navigation state", + command: "resetNavigation", + handler: () => { + Reactotron.log("resetting navigation state"); + resetRoot({ index: 0, routes: [] }); + }, +}); +// error-line-end + +reactotron.onCustomCommand<[{ name: "route"; type: ArgType.String }]>({ + command: "navigateTo", + handler: (args) => { + const { route } = args ?? {}; + if (route) { + Reactotron.log(`Navigating to: ${route}`); + // error-line + navigate(route as any); // this should be tied to the navigator, but since this is for debugging, we can navigate to illegal routes + // success-line + router.push(route); + } else { + Reactotron.log("Could not navigate. No route provided."); + } + }, + title: "Navigate To Screen", + description: "Navigates to a screen by name.", + args: [{ name: "route", type: ArgType.String }], +}); + +reactotron.onCustomCommand({ + title: "Go Back", + description: "Goes back", + command: "goBack", + handler: () => { + Reactotron.log("Going back"); + // error-line + goBack(); + // success-line + router.back(); + }, +}); +``` + ## Summary There you have it, a culinary masterpiece of Ignite and Expo Router, shipped in one pizza box. What we achieved here: @@ -1738,7 +1800,7 @@ To go more in-depth on `expo-router`, check out the official documentation at [E 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 +- [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) +Additionally, here is an Ignite repo with `expo-router` added in for reference on my [GitHub](https://github.com/frankcalise/ignite-expo-router). From 537c086a2b2f02a063f7ba79952392e22545a385 Mon Sep 17 00:00:00 2001 From: Justin Poliachik Date: Thu, 25 Jan 2024 15:04:59 -0500 Subject: [PATCH 4/5] update expo router recipe for expo50 & expo-router3 --- docs/recipes/ExpoRouter.md | 542 +++++++++++++++++-------------------- 1 file changed, 249 insertions(+), 293 deletions(-) diff --git a/docs/recipes/ExpoRouter.md b/docs/recipes/ExpoRouter.md index b7e034b3..18020596 100644 --- a/docs/recipes/ExpoRouter.md +++ b/docs/recipes/ExpoRouter.md @@ -6,15 +6,15 @@ tags: - expo-router - react-navigation last_update: - author: Frank Calise -publish_date: 2023-11-03 + author: Frank Calise & Justin Poliachik +publish_date: 2024-01-25 --- # 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. +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 `src/app` directory, a new path is automatically added to your navigation. For the full documentation by [Expo](https://expo.dev), head on over to the [Introduction to Expo Router](https://docs.expo.dev/routing/introduction/). @@ -35,6 +35,17 @@ Add the missing dependencies `expo-router` needs: npx expo install expo-router expo-constants ``` +Add `expo-router` to `app.json` plugins list if necessary: + +```json +"plugins": [ + ... + "expo-font", + // success-line + "expo-router" +], +``` + Change the entry point that `expo-router` expects in `package.json`: ```json @@ -44,36 +55,31 @@ Change the entry point that `expo-router` expects in `package.json`: "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`. +`expo-router` has great [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 + "tsconfigPaths": true, + // success-line + "typedRoutes": 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. +## Reworking the Directory Structure + +Expo Router requires route files to live in either `app` or `src/app` directories. But since our Ignite project is already using `app`, we'll need to rename it to `src`. We'll create `src/app` to contain all the file-base routing files from here on out, and 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 src/app +``` + +Let's update the TS alias and include paths over in `tsconfig.json` ```json { @@ -87,27 +93,135 @@ And finally, let's update the TS alias and include paths over in `tsconfig.json` // ... }, } + // error-line-start + "include": [ + "index.js", + "App.tsx", + "app", + "types", + "plugins", + "app.config.ts", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ], + // error-line-end + // success-line "include": ["**/*.ts", "**/*.tsx"], // ... } ``` -## Reworking the Directory Structure +### Fix Imports -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 +We also need to fix up a few imports to use `src/` instead of `app/`. +Ignite's Demo App only contains a few files we need to update, but an existing app could contain more. -```terminal -rm App.tsx -mv app src -mv src/screens/ErrorBoundary src/components -mkdir src/app +**`package.json`** + +```json +// error-line-start +"format": "prettier --write \"app/**/*.{js,jsx,json,md,ts,tsx}\"", +"lint": "eslint App.tsx app test --fix --ext .js,.ts,.tsx && npm run format", +// error-line-end +// success-line-start +"format": "prettier --write \"src/**/*.{js,jsx,json,md,ts,tsx}\"", +"lint": "eslint src test --fix --ext .js,.ts,.tsx && npm run format", +// success-line-end +``` + +**`src/devtools/ReactotronConfig.ts`** + +```ts +// error-line-start +import { clear } from "app/utils/storage"; +import { goBack, resetRoot, navigate } from "app/navigators/navigationUtilities"; +// error-line-end +// success-line-start +import { clear } from "src/utils/storage"; +import { goBack, resetRoot, navigate } from "src/navigators/navigationUtilities"; +// success-line-end ``` +**`src/components/ListView.ts`** + +```ts +// error-line +import { isRTL } from "app/i18n"; +// success-line +import { isRTL } from "src/i18n"; +``` + +
+ (optional) Additional files to update + +**`test/i18n.test.ts`** + +```ts +// error-line +import en from "../app/i18n/en"; +// success-line +import en from "../src/i18n/en"; +import { exec } from "child_process"; +``` + +**`ignite/templates/component/NAME.tsx.ejs`** + +```js +--- +patch: + // error-line + path: "app/components/index.ts" + // success-line + path: "src/components/index.ts" + append: "export * from \"./<%= props.subdirectory %><%= props.pascalCaseName %>\"\n" + skip: <%= props.skipIndexFile %> +--- +import * as React from "react" +import { StyleProp, TextStyle, View, ViewStyle } from "react-native" +import { observer } from "mobx-react-lite" +// error-line-start +import { colors, typography } from "app/theme" +import { Text } from "app/components/Text" +// error-line-end +// success-line-start +import { colors, typography } from "src/theme" +import { Text } from "src/components/Text" +// success-line-end +``` + +**`ignite/templates/model/NAME.tsx.ejs`** + +```js +--- +patches: +// error-line +- path: "app/models/RootStore.ts" +// success-line +- path: "src/models/RootStore.ts" + after: "from \"mobx-state-tree\"\n" + insert: "import { <%= props.pascalCaseName %>Model } from \"./<%= props.pascalCaseName %>\"\n" + skip: <%= !props.pascalCaseName.endsWith('Store') %> +// error-line +- path: "app/models/RootStore.ts" +// success-line +- path: "src/models/RootStore.ts" + after: "types.model(\"RootStore\").props({\n" + insert: " <%= props.camelCaseName %>: types.optional(<%= props.pascalCaseName %>Model, {} as any),\n" + skip: <%= !props.pascalCaseName.endsWith('Store') %> +// error-line +- path: "app/models/index.ts" +// success-line +- path: "src/models/index.ts" + +``` + +
+ ## Creating File-based Routes ### src/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 `src/app/_layout.tsx` for that. We'll add the providers here that any route would need within the app. +We're now ready to start setting up 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 `src/app/_layout.tsx` for that. We'll add the providers here that any route would need within the app. ```tsx // app/_layout.tsx @@ -124,7 +238,7 @@ if (__DEV__) { require("src/devtools/ReactotronConfig.ts"); } -export { ErrorBoundary } from "src/components/ErrorBoundary/ErrorBoundary"; +export { ErrorBoundary } from "src/components/ErrorBoundary"; export default function Root() { // Wait for stores to load and render our layout inside of it so we have access @@ -138,6 +252,25 @@ export default function Root() { } ``` +Move `ErrorBoundary` out of `screens` and into `src/components`: + +```terminal +mv src/screens/ErrorScreen/* src/components +``` + +And update imports in `ErrorDetails.tsx` + +```ts +// error-line-start +import { Button, Icon, Screen, Text } from "../../components"; +import { colors, spacing } from "../../theme"; +// error-line-end +// success-line-start +import { Button, Icon, Screen, Text } from "."; +import { colors, spacing } from "../theme"; +// success-line-end +``` + 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. @@ -192,26 +325,12 @@ To redirect the user to the login form, create `src/app/log-in.tsx`. We'll copy src/app/log-in.tsx ```tsx +import { router } from "expo-router"; import { observer } from "mobx-react-lite"; -import React, { - ComponentType, - FC, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { ComponentType, useEffect, useMemo, useRef, useState } from "react"; import { TextInput, TextStyle, ViewStyle } from "react-native"; -import { - Button, - Icon, - Screen, - Text, - TextField, - TextFieldAccessoryProps, -} from "src/components"; +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) { @@ -222,12 +341,7 @@ export default observer(function Login(_props) { const [isSubmitted, setIsSubmitted] = useState(false); const [attemptsCount, setAttemptsCount] = useState(0); const { - authenticationStore: { - authEmail, - setAuthEmail, - setAuthToken, - validationError, - }, + authenticationStore: { authEmail, setAuthEmail, setAuthToken, validationError }, } = useStores(); useEffect(() => { @@ -259,45 +373,32 @@ export default observer(function Login(_props) { // We'll mock this with a fake token. setAuthToken(String(Date.now())); + + // navigate to the main screen + router.replace("/"); } - const PasswordRightAccessory: ComponentType = - useMemo( - () => - function PasswordRightAccessory(props: TextFieldAccessoryProps) { - return ( - setIsAuthPasswordHidden(!isAuthPasswordHidden)} - /> - ); - }, - [isAuthPasswordHidden] - ); + const PasswordRightAccessory: ComponentType = useMemo( + () => + function PasswordRightAccessory(props: TextFieldAccessoryProps) { + return ( + setIsAuthPasswordHidden(!isAuthPasswordHidden)} + /> + ); + }, + [isAuthPasswordHidden] + ); return ( - - - - {attemptsCount > 2 && ( - - )} + + + + {attemptsCount > 2 && } -
+### Checkpoint + +Build and run your app using `yarn run ios`. You should see the log-in route, be able to authenticate, and navigate to the main "welcome" screen. But we aren't done yet - we still need to add the remaining screens in a Tab Navigator. + ## 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. +First, we'll create another route group to help contain where these routes live and set the layout for the tabs. Create `src/app/(app)/(tabs)/_layout.tsx` and we'll convert Ignite's previous `app/navigators/DemoNavigator.tsx` to live here. @@ -530,13 +619,7 @@ export default observer(function Layout() { href: "/showroom", headerShown: false, tabBarLabel: translate("demoNavigator.componentsTab"), - tabBarIcon: ({ focused }) => ( - - ), + tabBarIcon: ({ focused }) => , }} /> ( - - ), + tabBarIcon: ({ focused }) => , }} /> ( - - ), + tabBarIcon: ({ focused }) => , }} /> ( - - ), + tabBarIcon: ({ focused }) => , }} /> @@ -630,11 +695,7 @@ const reactNativeNewsletterLogo = require("assets/images/demo/rnn-logo.png"); export default function DemoCommunityScreen() { return ( - + @@ -646,33 +707,17 @@ export default function DemoCommunityScreen() { rightIcon={isRTL ? "caretLeft" : "caretRight"} onPress={() => openLinkInBrowser("https://community.infinite.red/")} /> - - + + - openLinkInBrowser("https://github.com/infinitered/ignite") - } + onPress={() => openLinkInBrowser("https://github.com/infinitered/ignite")} /> - - + + openLinkInBrowser("https://cr.infinite.red/")} /> - + + contentContainerStyle={$listContentContainer} data={episodeStore.episodesForList.slice()} @@ -861,16 +888,8 @@ export default observer(function DemoPodcastListScreen(_props) { - {(episodeStore.favoritesOnly || - episodeStore.episodesForList.length > 0) && ( + {(episodeStore.favoritesOnly || episodeStore.episodesForList.length > 0) && ( - episodeStore.setProp( - "favoritesOnly", - !episodeStore.favoritesOnly - ) - } + onValueChange={() => episodeStore.setProp("favoritesOnly", !episodeStore.favoritesOnly)} variant="switch" labelTx="demoPodcastListScreen.onlyFavorites" labelPosition="left" labelStyle={$labelStyle} - accessibilityLabel={translate( - "demoPodcastListScreen.accessibility.switch" - )} + accessibilityLabel={translate("demoPodcastListScreen.accessibility.switch")} /> )} @@ -964,21 +975,16 @@ const EpisodeCard = observer(function EpisodeCard({ Platform.select({ ios: { accessibilityLabel: episode.title, - accessibilityHint: translate( - "demoPodcastListScreen.accessibility.cardHint", - { - action: isFavorite ? "unfavorite" : "favorite", - } - ), + accessibilityHint: translate("demoPodcastListScreen.accessibility.cardHint", { + action: isFavorite ? "unfavorite" : "favorite", + }), }, android: { accessibilityLabel: episode.title, accessibilityActions: [ { name: "longpress", - label: translate( - "demoPodcastListScreen.accessibility.favoriteAction" - ), + label: translate("demoPodcastListScreen.accessibility.favoriteAction"), }, ], onAccessibilityAction: ({ nativeEvent }) => { @@ -1005,13 +1011,7 @@ const EpisodeCard = observer(function EpisodeCard({ function ButtonLeftAccessory() { return ( - + - + {episode.datePublished.textLabel} - + {episode.duration.textLabel} @@ -1189,8 +1181,7 @@ export default function DemoDebugScreen() { authenticationStore: { logout }, } = useStores(); - const usingHermes = - typeof HermesInternal === "object" && HermesInternal !== null; + const usingHermes = typeof HermesInternal === "object" && HermesInternal !== null; // @ts-expect-error const usingFabric = global.nativeFabricUIManager != null; @@ -1214,17 +1205,11 @@ export default function DemoDebugScreen() { ); return ( - + - openLinkInBrowser("https://github.com/infinitered/ignite/issues") - } + onPress={() => openLinkInBrowser("https://github.com/infinitered/ignite/issues")} /> @@ -1278,15 +1263,8 @@ export default function DemoDebugScreen() { /> -