Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Some of the packages used:
- The [React Native TV fork](https://github.com/react-native-tvos/react-native-tvos), which supports both phone (Android and iOS) and TV (Android TV and Apple TV) targets
- The [React Native TV config plugin](https://github.com/react-native-tvos/config-tv/tree/main/packages/config-tv), to allow Expo prebuild to modify the project's native files for TV builds
- The [NativeWind](https://www.nativewind.dev/) package which lets you use Tailwind CSS in react-native.
- The [react-native-bottom-tabs](https://github.com/okwasniewski/react-native-bottom-tabs) package that provides a fully native tab bar (top bar for Apple TV, bottom bar for Android TV).
- The [expo-router native tabs](https://docs.expo.dev/router/advanced/native-tabs/) experimental feature in Expo SDK 54.

## 🚀 How to use

Expand Down Expand Up @@ -71,7 +71,7 @@ The [themed components](./components) demonstrate how to use NativeWind class na

### Tab layout

The app provides a [native tab layout](./layouts/TabLayout.tsx) using `react-native-bottom-tabs`.
The app provides a [native tab layout](./layouts/TabLayout.tsx) using `expo-router/unstable-native-tabs`.

For web and Android TV, the [web tab layout](./layouts/TabLayout.web.tsx) uses the [custom tab layout](https://docs.expo.dev/router/advanced/custom-tabs/) feature of Expo Router.

Expand All @@ -83,11 +83,9 @@ These are shown in the [focus/hover/active demo screen](<./app/(tabs)/tvdemo.tsx
- The buttons are also styled with `hover:bg-blue-300`, to apply that style when the mouse hovers over the button in the web version of the app.
- Finally, `transition duration-500` is applied so that the focus, blur, and hover transitions happen smoothly with an animation.

### Compatibility with native components provided by Expo packages
### Compatibility with third party components

The [home screen](<./app/(tabs)/index.tsx>) shows how to wrap the `<Image />` component provided by `expo-image` for use with NativeWind, using [the `cssInterop()` API](https://www.nativewind.dev/api/css-interop).

The [video demo](./components/VideoTest.tsx) shows the same technique applied to the `<VideoView />` component from `expo-video`.
The [CSSWrappedComponents.tsx](./components/CSSWrappedComponents.tsx) file demonstrates how to wrap components provided by Expo and other packages to enable them to use NativeWind class names, using [the `cssInterop()` API](https://www.nativewind.dev/api/css-interop).

## Learn more

Expand Down
14 changes: 2 additions & 12 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,7 @@
}
],
"expo-router",
"react-native-bottom-tabs",
[
"expo-build-properties",
{
"ios": {
"useFrameworks": "static"
}
}
],
"expo-build-properties",
"expo-font",
"expo-web-browser",
"expo-video"
Expand All @@ -49,11 +41,9 @@
"typedRoutes": true
},
"android": {
"edgeToEdgeEnabled": true,
"newArchEnabled": true
"edgeToEdgeEnabled": true
},
"ios": {
"newArchEnabled": true,
"supportsTablet": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
Expand Down
32 changes: 12 additions & 20 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
import { cssInterop, useColorScheme, vars } from 'nativewind';
import { Platform, View } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { useColorScheme, vars } from 'nativewind';
import { View, ScrollView } from 'react-native';

import '@/global.css';
import { ThemedText, ThemedTextType } from '@/components/ThemedText';
import { ThemedButton, ThemedButtonBehavior } from '@/components/ThemedButton';
import { ThemedLink } from '@/components/ThemedLink';
import { useScreenDimensions } from '@/hooks/useScreenDimensions';
import { ScrollView } from 'react-native-gesture-handler';
import { SafeAreaView as RNSafeAreaContextView } from 'react-native-safe-area-context';

// Apply cssInterop to enable NativeWind for expo-image
// https://github.com/nativewind/nativewind/issues/680
const Image = cssInterop(ExpoImage, {
className: 'style',
});

const SafeAreaView = cssInterop(RNSafeAreaContextView, {
className: 'style',
});
import { SafeAreaView, Image } from '@/components/CSSWrappedComponents';
import { useTheme } from '@/hooks/useTheme';

const customTheme = vars({
'--light-theme-fg': '#ff0000',
Expand All @@ -30,15 +19,18 @@ const imageClassNames: { [key: string]: string } = {
landscape: 'w-[5vw] h-[5vw]',
};

const backgroundClassName = 'bg-[--color-background] flex-1 pt-[8vh]';

const App = () => {
const { colorScheme, setColorScheme } = useColorScheme();
const { orientation } = useScreenDimensions();
const safeAreaClassName = `w-screen h-screen ${
Platform.OS === 'ios' && Platform.isTV ? 'mt-[10vh]' : ''
}`;
const theme = useTheme();
return (
<View className="flex-1 justify-center items-center bg-[--color-background]">
<SafeAreaView className={safeAreaClassName}>
<View
style={theme}
className="flex-1 justify-center items-center bg-[--color-background]"
>
<SafeAreaView className={backgroundClassName}>
<ScrollView
showsVerticalScrollIndicator
contentContainerClassName="gap-[1vh] h-fulljustify-center items-center"
Expand Down
39 changes: 39 additions & 0 deletions app/(tabs)/legendlistdemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useWindowDimensions, View } from 'react-native';

import '@/global.css';
import { ThemedText, ThemedTextType } from '@/components/ThemedText';
import { LegendList, SafeAreaView } from '@/components/CSSWrappedComponents';
import { useTheme } from '@/hooks/useTheme';
import { ThemedButton } from '@/components/ThemedButton';

const backgroundClassName = 'bg-[--color-background] w-full h-full pt-[8vh]';

const data: number[] = [...Array(100).keys()];

const LegendListDemo: () => React.JSX.Element = () => {
const theme = useTheme();
const { height } = useWindowDimensions();
return (
<SafeAreaView style={theme} className={backgroundClassName}>
<View className="justify-center items-center">
<ThemedText type={ThemedTextType.title}>LegendList</ThemedText>
</View>
<View className="h-[70vh] w-full">
<LegendList
keyExtractor={(item: any) => `${item}`}
data={data}
estimatedItemSize={height * 0.08}
renderItem={({ item }: { item: number }) => {
return (
<View className="justify-center items-center">
<ThemedButton>{`Block ${item}`}</ThemedButton>
</View>
);
}}
/>
</View>
</SafeAreaView>
);
};

export default LegendListDemo;
44 changes: 23 additions & 21 deletions app/(tabs)/tvdemo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
SafeAreaView,
Text,
Pressable,
View,
Expand All @@ -9,25 +8,28 @@ import {

import '@/global.css';
import { useScreenDimensions } from '@/hooks/useScreenDimensions';
import { SafeAreaView } from '@/components/CSSWrappedComponents';
import { useTheme } from '@/hooks/useTheme';

const backgroundStyle = 'bg-[--color-background] flex-1 pt-[10vh]';
const backgroundClassName = 'bg-[--color-background] flex-1 pt-[8vh]';

const buttonBaseStyle = `relative m-[0.5vw] bg-blue-500 w-[80vw] text-white p-[1vw] font-bold overflow-hidden transition duration-500 hover:bg-blue-300 focus:bg-blue-300 active:bg-green-500`;
const buttonBaseClassName = `relative m-[0.5vw] bg-blue-500 w-[80vw] text-white p-[1vw] font-bold overflow-hidden transition duration-500 hover:bg-blue-300 focus:bg-blue-300 active:bg-green-500`;

const buttonTextStyle = 'text-white text-[2.5vw]';
const buttonTextClassName = 'text-white text-[2.5vw]';

const ribbonStyle = 'ribbonstyle';
const ribbonClassName = 'ribbonstyle';

const blockTextStyle = 'text-blue-800 font-bold text-[2.5vw] p-[1.5vw]';
const blockTextClassName = 'text-blue-800 font-bold text-[2.5vw] p-[1.5vw]';

const data: number[] = [...Array(10).keys()];

const TVDemo: () => React.JSX.Element = () => {
const { width, height, orientation } = useScreenDimensions();
const { orientation } = useScreenDimensions();
const theme = useTheme();

const buttonHeightStyle =
orientation === 'landscape' ? 'h-[10vw]' : 'h-[10vh]';
const buttonStyle = `${buttonBaseStyle} ${buttonHeightStyle}`;
const buttonStyle = `${buttonBaseClassName} ${buttonHeightStyle}`;

const ribbonTextStyle = `text-white ${
orientation === 'landscape' ? 'text-[1.2vw]' : 'text-[1.2vh]'
Expand All @@ -36,16 +38,16 @@ const TVDemo: () => React.JSX.Element = () => {
const renderRow = ({ item }: { item: number }) => {
return (
<View key={item}>
<Text className={blockTextStyle}>{`Block ${item}`}</Text>
<Text className={blockTextClassName}>{`Block ${item}`}</Text>
<Pressable
onPress={() => console.log('onPress')}
onLongPress={() => console.log('onLongPress')}
onPressIn={() => console.log('onPressIn')}
onPressOut={() => console.log('onPressOut')}
className={buttonStyle}
>
<Text className={buttonTextStyle}>Button 1</Text>
<View className={ribbonStyle}>
<Text className={buttonTextClassName}>Button 1</Text>
<View className={ribbonClassName}>
<Text className={ribbonTextStyle}>Press me</Text>
</View>
</Pressable>
Expand All @@ -57,8 +59,8 @@ const TVDemo: () => React.JSX.Element = () => {
tvParallaxProperties={{ enabled: false }}
className={buttonStyle}
>
<Text className={buttonTextStyle}>Button 2</Text>
<View className={ribbonStyle}>
<Text className={buttonTextClassName}>Button 2</Text>
<View className={ribbonClassName}>
<Text className={ribbonTextStyle}>Cool ribbon style</Text>
</View>
</Pressable>
Expand All @@ -72,8 +74,8 @@ const TVDemo: () => React.JSX.Element = () => {
}}
className={buttonStyle}
>
<Text className={buttonTextStyle}>Button 3</Text>
<View className={ribbonStyle}>
<Text className={buttonTextClassName}>Button 3</Text>
<View className={ribbonClassName}>
<Text className={ribbonTextStyle}>ABCDEFG</Text>
</View>
</Pressable>
Expand All @@ -85,8 +87,8 @@ const TVDemo: () => React.JSX.Element = () => {
className={buttonStyle}
>
<View>
<Text className={buttonTextStyle}>TouchableHighlight</Text>
<View className={ribbonStyle}>
<Text className={buttonTextClassName}>TouchableHighlight</Text>
<View className={ribbonClassName}>
<Text className={ribbonTextStyle}>LMNOP</Text>
</View>
</View>
Expand All @@ -95,8 +97,8 @@ const TVDemo: () => React.JSX.Element = () => {
);
};
return (
<View className={backgroundStyle}>
<SafeAreaView style={{ width, height }}>
<SafeAreaView style={theme} className={backgroundClassName}>
<View className="h-[75vh]">
<FlatList
contentContainerStyle={{
justifyContent: 'center',
Expand All @@ -106,8 +108,8 @@ const TVDemo: () => React.JSX.Element = () => {
data={data}
renderItem={renderRow}
></FlatList>
</SafeAreaView>
</View>
</View>
</SafeAreaView>
);
};

Expand Down
25 changes: 15 additions & 10 deletions app/(tabs)/video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@ import { Platform, View } from 'react-native';
import '@/global.css';
import { ThemedText, ThemedTextType } from '@/components/ThemedText';
import VideoTest from '@/components/VideoTest';
import { SafeAreaView } from '@/components/CSSWrappedComponents';

const backgroundClassName = 'bg-[--color-background] flex-1 pt-[8vh]';

const App = () => {
return (
<View className="flex-1 justify-center items-center p-[5vh] bg-[--color-background]">
<ThemedText type={ThemedTextType.title}>expo-video demo</ThemedText>
{Platform.OS === 'web' ? (
<ThemedText type={ThemedTextType.tiny}>
This demo only works on mobile and tv devices.
</ThemedText>
) : (
<VideoTest />
)}
</View>
<SafeAreaView className={backgroundClassName}>
<View className="flex-1 justify-center items-center p-[5vh] bg-[--color-background]">
<ThemedText type={ThemedTextType.title}>expo-video demo</ThemedText>
{Platform.OS === 'web' ? (
<ThemedText type={ThemedTextType.tiny}>
This demo only works on mobile and tv devices.
</ThemedText>
) : (
<VideoTest />
)}
</View>
</SafeAreaView>
);
};

Expand Down
2 changes: 1 addition & 1 deletion app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default function RootLayout() {
},
}}
/>
<Stack.Screen name="+not-found" />
<Stack.Screen name="[...missing]" />
</Stack>
</GestureHandlerRootView>
</View>
Expand Down
38 changes: 38 additions & 0 deletions components/CSSWrappedComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Image as ExpoImage } from 'expo-image';
import { VideoView as ExpoVideoView } from 'expo-video';
import { cssInterop } from 'nativewind';
import { SafeAreaView as RNSafeAreaContextView } from 'react-native-safe-area-context';
import { LegendList as OriginalLegendList } from '@legendapp/list';
import { Platform, ScrollView } from 'react-native';

// Apply cssInterop to enable NativeWind for expo-image
// https://github.com/nativewind/nativewind/issues/680
export const Image = cssInterop(ExpoImage, {
className: 'style',
});
export const SafeAreaView = cssInterop(RNSafeAreaContextView, {
className: 'style',
});
export const VideoView: any = cssInterop(ExpoVideoView, {
className: 'style',
});

const WrappedLegendList = cssInterop(OriginalLegendList, {
className: 'style',
contentContainerClassName: 'contentContainerStyle',
indicatorClassName: 'indicatorStyle',
columnWrapperClassName: 'columnWrapperStyle',
});

// LegendList's default for this property uses an Animated.ScrollView from the built in Animated of react-native itself.
// This doesn't work with react-native-reanimated, so we need to provide our own.
export function LegendList(props: any) {
return (
<WrappedLegendList
renderScrollComponent={(props: any) => {
return <ScrollView {...props} showsScrollIndex={!Platform.isTV} />;
}}
{...props}
/>
);
}
10 changes: 4 additions & 6 deletions components/VideoTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,10 @@ import { useEffect, useRef, useState } from 'react';
import { Platform, View } from 'react-native';
import { useInterval } from '@/hooks/useInterval';
import { ThemedButton } from './ThemedButton';
import { cssInterop } from 'nativewind';
import { useScreenDimensions } from '@/hooks/useScreenDimensions';

import '@/global.css';

const VideoView: any = cssInterop(ExpoVideoView, {
className: 'style',
});
import { VideoView } from './CSSWrappedComponents';

const videoSource =
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
Expand Down Expand Up @@ -80,7 +76,9 @@ export default function VideoTest() {
nativeControls
contentFit="cover"
showsTimecodes
allowsFullscreen
fullscreenOptions={{
enable: true,
}}
allowsPictureInPicture
contentPosition={{ dx: 0, dy: 0 }}
/>
Expand Down
Loading