Recently, I wrote about my first React Native applicationโ a mobile calculator and told you to expect more tutorials from me.
Well, guess what? Here is another one:
A mobile news application that enables users to read and search for news easily within the application.
At the end of this tutorial, you'll learn how to use Expo Router, query data with Tanstack Query, open webpages within your application, and style your application using Tailwind CSS.
App Demo
To preview the application, download Expo Go app, and paste the links below into the app URL field.
Android: exp://u.expo.dev/update/3be31284-3908-42e5-ad3e-23bf4823278d
iOS: exp://u.expo.dev/update/c98748eb-96a2-4f36-b32d-394b935b3106
Project Setup and Installation with Expo
Expo saves us from the complex configurations required to create a native application with the React Native CLI, making it the easiest and fastest way to build and publish React Native apps.
Create an Expo project that uses Expo Router by running the code snippet below within your terminal.
npx create-expo-app@latest --template tabs@49
Expo Router is an open-source file-based routing system that enables users to navigate between screens easily. It is similar to Next.js, where each file name represents its route name.
Start the development server to ensure the app is working as expected.
npx expo start
Next, delete the components
and constants
folders, and clear out everything inside the app
folder except for the _layout.tsx
file.
Install the Expo Linear Gradient, React Native Snap Carousel, and React Native Webview packages.
Expo Linear Gradient enables us to add colour gradients to React Native elements, React Native Snap Carousel for creating interactive carousels, and React Native WebView for embedding web content within the mobile application.
npx expo install expo-linear-gradient react-native-webview react-native-snap-carousel @types/react-native-snap-carousel
Fix any compatibility issues within the packages by running the command below.
npx expo install --fix
PS: You may encounter this warning while using React Native Snap Carousel:
ViewPropTypes will be removed from React Native, along with all other PropTypes. We recommend that you migrate away from PropTypes and switch to a type system like TypeScript. If you need to continue using ViewPropTypes, migrate to the 'deprecated-react-native-prop-types' package.
To fix it, navigate into the node_modules/react-native-snap-carousel
folder and change the ViewPropTypes
import to be from the deprecated package below.
npx expo install deprecated-react-native-prop-types
Navigating between screens with Expo Router
Within the app
folder, there is a _layout.tsx
file that describes the layout (either Screen / Tab) of the files within the folder and allows you to add some custom settings to each screen.
Consider you have a folder containing a _layout.tsx
file, and you need all the screens to use the React Native Stack layout. You can achieve this using the code snippet below:
import { Stack } from "expo-router";
function RootLayoutNav() {
return (
<Stack screenOptions={{ headerShown: false }}>
{/**-- add screens for specific settings --*/}
</Stack>
);
}
To navigate between screens, you can use the Link component or useRouter
hook provided by Expo Router.
import { Link, useRouter } from "expo-router";
export default function Page() {
const router = useRouter();
const handleClick = () => {
console.log("Pressed");
router.push("/screen");
};
return (
<View>
{/** -- using useRouter hook ---*/}
<Pressable onPress={handleClick}>
<View>
<Text>Hello World</Text>
</View>
</Pressable>
{/** -- using Link component ---*/}
<Link
href={{
pathname: "/news",
params: {id: "1"},
}}
asChild
>
<Pressable>
<View>
<Text>Hello World</Text>
</View>
</Pressable>
</Link>
</View>
);
}
Styling Expo applications with Tailwind CSS
Tailwind CSS is a CSS framework that enables us to create modern and stunning applications easily.
However, to style Expo applications using Tailwind CSS, you need to install NativeWind - a library that uses Tailwind CSS as its scripting language.
Run the code snippet below to install NativeWind and its dependencies.
yarn add nativewind@^4.0.1 react-native-reanimated
yarn add -D tailwindcss
Run npx tailwindcss init
to create a tailwind.config.js
file. Update the file with the code snippet below.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
};
Create a globals.css
file within the root of your project and add the Tailwind directives below.
@tailwind base;
@tailwind components;
@tailwind utilities;
Update the babel.config.js
file as done below.
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: [
// Required for expo-router
"expo-router/babel",
"react-native-reanimated/plugin",
],
};
};
Create a metro.config.js
file within the root of your project and paste the code snippet below into the file.
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname, {
// [Web-only]: Enables CSS support in Metro.
isCSSEnabled: true,
});
module.exports = withNativeWind(config, { input: "./globals.css" });
Finally, import the ./globals.css
file into the app/_layout.tsx
file to enable you to style your application with Tailwind CSS.
//๐๐ป Within ./app/_layout.tsx
import "../globals.css";
Congratulations, you can start styling your application using Tailwind CSS. If you encounter any issues, you can visit the documentation for a complete how-to guide.
Setting up TanStack Query in React Native
TanStack Query is a data fetching and state management library that handles API requests effectively within your applications. It provides various features such as caching, auto-refetching, paginated queries, and many others.
Run the code snippet to install TanStack Query to your Expo application.
yarn add @tanstack/react-query
Wrap the entire screens of the application with the QueryClientProvider
component via the app/_layout.tsx
file.
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
function RootLayoutNav() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name='index' options={{ title: "Home" }} />
</Stack>
</QueryClientProvider>
);
}
Now, you can query data using TanStack Query.
Building the application interface
In this section, I'll walk you through building the application screens and fetching news from the News API.
Create an account on the website and copy your API token into a .env.local
file.
EXPO_PUBLIC_NEWS_API_KEY=<your_API_key>
Next, create a fetchNews.ts
file within the assets folder and copy the code snippet below into the file.
//๐๐ป base URL
const apiBaseUrl = "https://newsapi.org/v2";
//๐๐ป breaking news endpoint
const breakingNewsUrl = `${apiBaseUrl}/top-headlines?country=ng&apiKey=${process
.env.EXPO_PUBLIC_NEWS_API_KEY!}`;
//๐๐ป recommended news endpoint
const recommendedNewsUrl = `${apiBaseUrl}/top-headlines?country=ng&category=business&apiKey=${process
.env.EXPO_PUBLIC_NEWS_API_KEY!}`;
//๐๐ป fetch by category endpoint
const discoverNewsUrl = (discover: string) =>
`${apiBaseUrl}/top-headlines?country=ng&category=${discover}&apiKey=${process
.env.EXPO_PUBLIC_NEWS_API_KEY!}`;
//๐๐ป search news endpoint
const searchNewsUrl = (query: string) =>
`${apiBaseUrl}/everything?q=${query}&apiKey=${process.env
.EXPO_PUBLIC_NEWS_API_KEY!}`;
//๐๐ป API function call
const newsApiCall = async (endpoints: string) => {
try {
const response = await fetch(endpoints);
const data = await response.json();
return data;
} catch (err) {
console.error(err);
}
};
//๐๐ป returns breaking news
export const fetchBreakingNews = async () => {
return await newsApiCall(breakingNewsUrl);
};
//๐๐ป returns recommended news
export const fetchRecommendedNews = async () => {
return await newsApiCall(recommendedNewsUrl);
};
//๐๐ป returns news based on a category
export const fetchDiscoverNews = async (discover: string) => {
return await newsApiCall(discoverNewsUrl(discover));
};
//๐๐ป returns search query news
export const fetchSearchNews = async (query: string) => {
const endpoint = searchNewsUrl(query);
return await newsApiCall(endpoint);
};
The code snippet above fetches the breaking news, recommended news, and discover news from the API endpoint. The search news endpoint enables us to retrieve news based on a given input (query string).
Finally, create a util.ts
file within the assets folder and copy the code snippet below into the file. It contains variables and functions used within the application.
//๐๐ป converts the date data from the API to a readable format
export function convertToReadableDate(
utcDateString: string | undefined
): string {
if (utcDateString === undefined) return "";
const utcDate = new Date(utcDateString);
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
};
const readableDate: string = utcDate.toLocaleDateString("en-US", options);
return readableDate;
}
//๐๐ป list of news categories
export const categories: Categories[] = [
{
id: "business",
name: "Business",
description: "Business news",
image_url:
"https://images.unsplash.com/photo-1590283603385-17ffb3a7f29f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTMyMzYyNg&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
id: "entertainment",
name: "Entertainment",
description: "Entertainment news",
image_url:
"https://images.unsplash.com/photo-1598743400863-0201c7e1445b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4NjIyMDI3Nw&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
id: "general",
name: "General",
description: "General news",
image_url:
"https://images.unsplash.com/photo-1557992260-ec58e38d363c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTc1MTkwNg&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
id: "health",
name: "Health",
description: "Health news",
image_url:
"https://images.unsplash.com/photo-1495638488670-437e54b3bab4?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTc2MDI3Mw&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
id: "science",
name: "Science",
description: "Science news",
image_url:
"https://images.unsplash.com/photo-1614935151651-0bea6508db6b?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTM0MzA0OA&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
id: "sports",
name: "Sports",
description: "Sports news",
image_url:
"https://images.unsplash.com/photo-1476480862126-209bfaa8edc8?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTQ1MTE5NQ&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
];
//๐๐ป images array
const images: Image[] = [
{
url: "https://images.unsplash.com/photo-1579532536935-619928decd08?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTM3OTI3Ng&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
url: "https://images.unsplash.com/photo-1482160549825-59d1b23cb208?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTQxNzk3Mg&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
url: "https://plus.unsplash.com/premium_photo-1664297878197-0f50d094db72?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY5OTk3MjQ2Ng&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
url: "https://images.unsplash.com/photo-1572375992501-4b0892d50c69?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY5OTk3MjUxOQ&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
url: "https://images.unsplash.com/photo-1503694978374-8a2fa686963a?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4MTMyMTY5MA&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
url: "https://plus.unsplash.com/premium_photo-1682098211431-6fbbaac9be2c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY5OTk3MjYwMQ&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
{
url: "https://images.uns.lengthplash.com/photo-1529243856184-fd5465488984?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY4NjA3NzExNA&ixlib=rb-4.0.3&q=80&utm_campaign=api-credit&utm_medium=referral&utm_source=unsplash_source&w=300",
},
];
//๐๐ป returns a random image for news without an image header
export const generateRandomImage = () => {
const index = Math.floor(Math.random() * images.length);
return images[index].url;
};
//๐๐ป Required TypeScript interface
export interface Categories {
id: string;
name: string;
description: string;
image_url: string;
}
export interface Image {
url: string;
}
App Overview
The application is divided into six screens, including the entry point to the application. Therefore, create a (tabs)
folder containing a home.tsx
, discover.tsx
, and search.tsx
files.
cd apps
mkdir (tabs)
touch _layout.tsx home.tsx discover.tsx search.tsx
The Home screen displays the breaking and recommended news, The Discover screen allows users to read news based on a particular category, and the Search screen enables users to search for news.
Next, create a (stack)
folder containing a _layout.tsx
,[title].tsx
, and news.tsx
files.
cd apps
mkdir (stack)
touch _layout.tsx [title].tsx news.tsx
The news.tsx
file displays all the news based on a particular category, and the [title.tsx]
file displays the content of a particular news.
PS: The brackets around the tabs and stack folder names enable us to navigate between screens using the route name instead of the relative path.
For instance when navigating to the news page, instead of/(tabs)/news
, you can use/news
from any point within the application.
Finally, add the stack and tab routes to the app/_layout.tsx
file.
function RootLayoutNav() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name='index' options={{ title: "Home" }} />
<Stack.Screen name='(tabs)' />
<Stack.Screen name='(stack)' />
</Stack>
</QueryClientProvider>
);
}
The Welcome Screen
The welcome screen displays the content of the index.tsx
file, showing a brief overview of the application and a button that redirects users to the home screen.
Update the app/index.tsx
file with the code snippet below:
import { ImageBackground, Pressable, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { LinearGradient } from "expo-linear-gradient";
export default function TabOneScreen() {
const router = useRouter();
return (
<ImageBackground
source={require("../assets/images/background.jpg")}
className='flex-1 items-center justify-center pb-10 bg-gradient-to-bl from-gray-200 to-gray-900'
>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.9)"]}
style={{
position: "absolute",
bottom: 0,
width: "100%",
height: "100%",
}}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View className='absolute bottom-14 flex flex-col items-center justify-center w-full bg-gradient-to-t from-gray-900 px-4'>
<Text
className='text-4xl text-white font text-center mb-4'
style={{ fontFamily: "Bold" }}
>
Breaking Boundaries, Breaking News
</Text>
<Text
className='text-gray-300 text-center text-xl mb-6'
style={{ fontFamily: "Medium" }}
>
Explore the world through our lens. Your passport to a connected and
informed world, right at your fingertips.
</Text>
<Pressable
onPress={() => router.push("/home")}
className='bg-stone-700 rounded-full p-4 w-full items-center justify-center shadow-lg'
>
<Text
className='text-white text-2xl'
style={{ fontFamily: "Medium" }}
>
Get Started
</Text>
</Pressable>
</View>
<StatusBar style='light' />
</ImageBackground>
);
}
The Tab Screens
It contains the Home, Discover, and Search screens. Update the (tabs)/_layout.tsx
file to display the tab icons and default settings for each screen within the Tab layout.
import { Tabs } from "expo-router";
import { FontAwesome5, MaterialIcons, FontAwesome } from "@expo/vector-icons";
export default function Page() {
return (
<Tabs
screenOptions={{
tabBarShowLabel: false,
tabBarActiveBackgroundColor: "#fff",
tabBarActiveTintColor: "#a16207",
headerShown: false,
}}
>
<Tabs.Screen
name='home'
options={{
title: "Home",
tabBarIcon: ({ color }) => (
<FontAwesome5 name='home' size={24} color={color} />
),
}}
/>
<Tabs.Screen
name='discover'
options={{
title: "Discover",
tabBarIcon: ({ color }) => (
<MaterialIcons name='explore' size={24} color={color} />
),
}}
/>
<Tabs.Screen
name='search'
options={{
title: "Search",
tabBarIcon: ({ color }) => (
<FontAwesome name='search' size={24} color={color} />
),
}}
/>
</Tabs>
);
}
Import the functions from the fetchNews.ts
file declared earlier, execute the functions using TanStack Query, and display the results within the Home screen.
import { FlatList, StatusBar } from "react-native";
import Carousel from "react-native-snap-carousel";
import { useQuery } from "@tanstack/react-query";
import {
fetchBreakingNews,
fetchRecommendedNews,
} from "../../assets/fetchNews";
export default function Page() {
//๐๐ป fetch the breaking news
const breakingNewsQuery = useQuery({
queryKey: ["breakingNews"],
queryFn: fetchBreakingNews,
});
//๐๐ป fetch the recommended news
const recommendedNewsQuery = useQuery({
queryKey: ["recommendedNews"],
queryFn: fetchRecommendedNews,
});
return (
<SafeAreaView className='flex-1'>
<View>
{breakingNewsQuery.data && (
<Carousel
data={breakingNewsQuery.data.articles}
renderItem={renderBreakingNewsItem}
firstItem={1}
inactiveSlideScale={0.86}
sliderWidth={width}
itemWidth={width * 0.8}
slideStyle={{ display: "flex", alignItems: "center" }}
/>
)}
</View>
<View>
{recommendedNewsQuery.data && (
<FlatList
data={recommendedNewsQuery.data.articles}
renderItem={renderRecommendedNewsItem}
showsVerticalScrollIndicator={false}
keyExtractor={(item, index) => item.url}
/>
)}
</View>
<StatusBar style='dark' />
</SafeAreaView>
);
}
Create the functions that render each result within the Carousel and the Flatlist.
//๐๐ป Renders the Breaking News UI (horizontal row)
const renderBreakingNewsItem = ({ item }: any) => {
return (
<Link
href={{
pathname: "/[title]",
params: {
data: JSON.stringify([item.url, item.title]),
},
}}
asChild
>
<Pressable>
<View className='relative'>
<Image
source={{ uri: item.urlToImage || generateRandomImage() }}
style={{
width: width * 0.8,
height: height * 0.22,
borderRadius: 10,
}}
resizeMode='cover'
className='rounded-3xl'
/>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.9)"]}
start={{ x: 0.5, y: 0 }}
end={{ x: 0, y: 1 }}
style={{
position: "absolute",
bottom: 0,
width: "100%",
height: "100%",
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
}}
/>
<View className='absolute bottom-0 left-4 right-0 justify-end h-[80%] px-4 pb-4'>
<Text
className='text-xl text-white mb-2'
style={{ fontFamily: "Bold" }}
>
{item.title.length > 48
? item.title.slice(0, 47) + "..."
: item.title}
</Text>
<Text className=' text-stone-200' style={{ fontFamily: "Medium" }}>
{item.author}
</Text>
</View>
</View>
</Pressable>
</Link>
);
};
//๐๐ป Renders the Recommended News UI (vertical row)
const renderRecommendedNewsItem = ({ item }: any) => {
return (
<Link
href={{
pathname: "/[title]",
params: {
data: JSON.stringify([item.url, item.title]),
},
}}
asChild
>
<Pressable className='px-4 w-full'>
<View className='flex flex-row items-center justify-between w-full mb-4 bg-white shadow-xl rounded-xl'>
<Image
source={{ uri: item.urlToImage || generateRandomImage() }}
style={{
width: width * 0.4,
height: width * 0.3,
borderRadius: 5,
}}
resizeMode='cover'
className='rounded-3xl mr-[1px]'
/>
<View className='px-3 flex-1'>
<Text
style={{ fontFamily: "Medium" }}
className='text-stone-500 text-sm'
>
{item.author}
</Text>
<Text className='text-lg mb-[1px]' style={{ fontFamily: "Bold" }}>
{item.title.length > 48
? item.title.slice(0, 47) + "..."
: item.title}
</Text>
<Text
style={{ fontFamily: "Medium" }}
className='text-stone-500 text-sm'
>
{convertToReadableDate(item.publishedAt)}
</Text>
</View>
</View>
</Pressable>
</Link>
);
};
The discover.tsx
file displays the news categories within the application and redirects users to the (stack)/news
screens containing all the news under that category. Copy the code snippet below into the discover.tsx
file to render the news categories:
import { Categories, categories } from "../../assets/util";
export default function Page() {
return (
<View className='rounded-2xl shadow-xl'>
<FlatList
data={categories}
renderItem={renderItem}
keyExtractor={(item, index) => item.id}
numColumns={2}
contentContainerStyle={{
justifyContent: "space-between",
alignItems: "center",
padding: 10,
width: "100%",
}}
/>
</View>
);
}
The renderItem
function below represents the layout of each item rendered within the FlatList.
const renderItem = ({ item }: { item: Categories }) => {
return (
<Link
href={{
pathname: "/news",
params: {
category: item.id,
},
}}
asChild
>
<Pressable>
<View className='relative m-[7px]'>
<Image
source={{ uri: item.image_url }}
style={{
width: width * 0.47,
height: width * 0.45,
borderRadius: 10,
}}
resizeMode='cover'
className='rounded-xl'
/>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.9)"]}
start={{ x: 0.5, y: 0 }}
end={{ x: 0, y: 1 }}
style={{
position: "absolute",
bottom: 0,
width: "100%",
height: "100%",
borderBottomLeftRadius: 20,
borderBottomRightRadius: 20,
}}
/>
<View className='absolute bottom-0 left-4 right-0 justify-end h-[80%] px-4 pb-4'>
<Text
className='text-xl text-white mb-2'
style={{ fontFamily: "Bold" }}
>
{item.name}
</Text>
</View>
</View>
</Pressable>
</Link>
);
};
Update the search.tsx
file to enable users enter an input to the search field and displays the results within a FlatList.
import {
View,
Text,
Pressable,
Image,
Dimensions,
FlatList,
} from "react-native";
import { Link } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { TextInput } from "react-native-gesture-handler";
import { FontAwesome, MaterialIcons } from "@expo/vector-icons";
import { useState } from "react";
import { fetchSearchNews } from "../../assets/fetchNews";
import {
News,
convertToReadableDate,
generateRandomImage,
} from "../../assets/util";
const { width } = Dimensions.get("window");
export default function Page() {
const [query, onChangeQuery] = useState<string>("");
const [results, setResults] = useState<any[]>([]);
const [resultsCount, setResultsCount] = useState<number>(0);
const handleTextChange = (text: string) => {
onChangeQuery(text);
if (text.length > 2) {
fetchSearchNews(text).then((res) => {
setResults(res.articles);
setResultsCount(res.totalResults);
});
}
};
return (
<SafeAreaView>
<View className='px-4 '>
<Text
className='text-3xl text-stone-500 mb-3'
style={{ fontFamily: "Bold" }}
>
Search
</Text>
<View className='flex flex-row items-center justify-between w-full rounded-2xl bg-gray-100 border-[1px] px-3 border-stone-300'>
<FontAwesome name='search' size={24} color='gray' className='mr-2' />
<TextInput
className='flex-1
rounded-xl px-4 py-4'
placeholder='Search for news'
style={{ fontFamily: "Medium" }}
value={query}
onChangeText={(text) => handleTextChange(text)}
/>
</View>
<Text className='text-lg mt-4 mb-4' style={{ fontFamily: "Semibold" }}>
Total Results: {resultsCount}
</Text>
<View>
{results && (
<FlatList
data={results}
renderItem={newsItem}
showsVerticalScrollIndicator={false}
keyExtractor={(item) => item.url}
/>
)}
</View>
</View>
</SafeAreaView>
);
}
The results are rendered via a newItem
component created as shown below.
export interface News {
title: string;
url: string;
image?: string;
publishedAt?: string;
author?: string;
urlToImage?: string;
}
const newsItem = ({ item }: { item: News }) => {
return (
<Link
href={{
pathname: "/[title]",
params: {
url: item.url,
title: item.title,
},
}}
asChild
>
<Pressable className='px-4 w-full'>
<View className='flex flex-row items-center justify-between w-full mb-4 bg-white shadow-xl rounded-xl p-3'>
<Image
source={{ uri: item.urlToImage || generateRandomImage() }}
style={{ width: width * 0.2, height: width * 0.2, borderRadius: 5 }}
resizeMode='cover'
className='rounded-3xl mr-[1px]'
/>
<View className='px-3 flex-1'>
<Text
style={{ fontFamily: "Medium" }}
className='text-stone-500 text-sm'
>
{item.author}
</Text>
<Text className='text-lg mb-[1px]' style={{ fontFamily: "Bold" }}>
{item.title.length > 48
? item.title.slice(0, 47) + "..."
: item.title}
</Text>
<Text
style={{ fontFamily: "Medium" }}
className='text-stone-500 text-sm'
>
{convertToReadableDate(item.publishedAt)}
</Text>
</View>
<MaterialIcons name='keyboard-arrow-right' size={26} color='brown' />
</View>
</Pressable>
</Link>
);
};
The Stack Screens
Update the (stack)/_layout.tsx
file to display its files using the Stack layout.
import { Stack } from "expo-router";
export default function Page() {
return <Stack></Stack>;
}
Modify the news.tsx
file to display the list of news based on a chosen category. When a user selects a news category, the user is redirected to the news route where the API results (category news) are displayed.
export default function Page() {
const { category }: {category: string} = useLocalSearchParams();
if (category === "breaking") {
const breakingNewsQuery = useQuery({
queryKey: ["breakingNews"],
queryFn: fetchBreakingNews,
});
return <DisplayNews news={breakingNewsQuery} title='Breaking News' />;
} else if (category === "recommended") {
const recommendedNewsQuery = useQuery({
queryKey: ["recommendedNews"],
queryFn: fetchRecommendedNews,
});
return <DisplayNews news={recommendedNewsQuery} title='Recommended News' />;
} else {
const discoverNewsQuery = useQuery({
queryKey: ["discoverNews", category],
queryFn: () => fetchDiscoverNews(category),
});
return (
<DisplayNews
news={discoverNewsQuery}
title={`${category[0].toUpperCase() + category.slice(1)} News`}
/>
);
}
}
The DisplayNews
component above displays the results in a FlatList.
How to open web resources with React Native WebView
React Native WebView enables us to embed web content, such as HTML, CSS, and JavaScript, within a mobile app. It provides a way to display web pages in a React Native application.
Copy the code snippet below into the [title].tsx
file:
import {
View,
Pressable,
ActivityIndicator,
Dimensions,
Text,
} from "react-native";
import React, { useState } from "react";
import { AntDesign } from "@expo/vector-icons";
import { WebView } from "react-native-webview";
import { Stack, router, useLocalSearchParams } from "expo-router";
const { width, height } = Dimensions.get("window");
export default function Page() {
const [visible, setVisible] = useState<boolean>(false);
const params = useLocalSearchParams();
const data: [string, string] = JSON.parse(params.data)
const pageTitle = data[1];
const pageURL = data[0]
return (
<>
<Stack.Screen options={{ headerTitle: `${pageTitle}` }} />
<View className='pt-4 p-6 flex flex-row justify-between items-center bg-stone-200 fixed top-0'>
<Pressable
className='bg-stone-100 rounded-xl p-3 shadow-2xl'
onPress={() => router.back()}
>
<AntDesign name='back' size={28} color='brown' />
</Pressable>
<Text style={{ fontFamily: "Medium" }}>Sponsored by Global Pulse </Text>
</View>
<WebView
style={{ flex: 1 }}
source={{ uri: pageURL }}
onLoadStart={() => setVisible(true)}
onLoadEnd={() => setVisible(false)}
/>
{visible && (
<ActivityIndicator
size={"large"}
color={"green"}
style={{
position: "absolute",
top: height / 2,
left: width / 2,
}}
/>
)}
</>
);
}
- From the code snippet above,
- the
[title].tsx
route accepts the news title and source URL from the Link component. - the WebView component from React Native WebView accepts the news source URL and displays the news content within the app.
- the
Stack.Screen
component changes its header title to the news title.
- the
Congratulation!๐ You've completed the project for this tutorial.
Conclusion
So far, you've learnt how to do the following:
- Create a mobile application with Expo,
- Install and navigate between screens with Expo Router,
- Style React Native applications with TailwindCSS, and
- Open web pages within a mobile application.
If you prefer a video format, check out this tutorial on YouTube:
The source code for this tutorial is available here:
https://github.com/dha-stix/global-pulse-app
Thank you for reading!๐
Open to work๐
Did you enjoy this article or need an experienced Technical Writer / React Developer for a remote, full-time, or contract-based role? Feel free to contact me.
GitHub || LinkedIn || Twitter
Top comments (7)
Why I'm getting this error for importing queryClient and queryClientProvider from @tanstack/react-query
Unable to resolve module '@tanstack/query-core.js' Evaluating @tanstack/query-core.js Evaluating @tanstack/react-query.js Evaluating App.js Loading App.js
You need to install '@tanstack/query-core.js'
But you haven't installed that package right ?
When i try to install that package it's showing not found. And you haven't written anything about installation of that package you just installed @tanstack/react-query
Start a new project and follow the step in the article.
๐
Great blog David! Are you building anything new with Expo these days?
Expo Router V3 is coming out soon. Curious to see how to build with it.
Thanks.
Yes, I am.
It should be ready before the month ends.
Some comments have been hidden by the post's author - find out more