Apple TV React Native tvOS application using GraphQL
Code walkthrough for a demo application for Apple TV using React Native for tvOS, React Navigation, Apollo Client for GraphQL, React Native Video, Styled Components themes, React Intl for i18n.
June 18, 2020
What is covered
Code repository
Primers
- React Native tvOS for Apple TV. Thank you, Douglas Lowder! (dlowder-salesforce github account)
- If you want to run the application on an Apple TV HD or 4K device take a look here: Running React Native on Device
- Rapid GraphQL server setup for testing, with json-graphql-server and Faker
- i18n with FormatJS for your React Native application
- Apollo Docs: Queries - learn how to fetch data with the useQuery hook
Setup of top-level providers
/src/App.js takes care of the application top-level setup: internationalization (i18n), store for authentication, Apollo provider for GraphQL client. NavigatorContainer
from @react-navigation/native
wraps around a StackedNavigator
. Please take notice of the enableScreens
function call at line 21 because it is important when navigating between native screens.
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { enableScreens } from 'react-native-screens';
import { Text } from 'react-native';
import { IntlProvider } from 'react-intl';
import localeData from './i18n/translation.json';
import get from 'lodash.get';
import deviceLocale from './constants/deviceLocale';
import StoreProvider from './store/reducer/StoreProvider';
import ApolloProvider from './store/apollo/Provider';
import Navigator from './navigator/StackedNavigator';
import { ignored } from './constants/yellowBox';
const language = get(deviceLocale, '[0].languageCode', 'en');
const messages = localeData[language] || localeData.en;
console.ignoredYellowBox = ignored;
console.disableYellowBox = true;
enableScreens();
const App = () => {
return (
<IntlProvider locale={language} messages={messages} textComponent={Text}>
<StoreProvider>
<ApolloProvider>
<NavigationContainer>
<Navigator />
</NavigationContainer>
</ApolloProvider>
</StoreProvider>
</IntlProvider>
);
};
export default App;
Navigation
/src/navigator/StackedNavigator.js is responsible for navigation between the screens with the tabs bar on top and those without it (the “full screen” ones). It hides the header for those screens included in the navigation stack by providing Stack.Navigator
with the screenOptions
prop.
/src/navigator/TabbedNavigator.js is the one for the screens with the tabs bar on top. It is responsible for detecting if the device UUID
is activated or not and renders the Activation
screen accordingly (lines 20 - 22). It needs useContext
hook for StoreContext
to look for isAuthenticated
variable. Notice the props provided to the Tab.Navigator
(lines 25-29). It has as children the Tab.Screens
(lines 30-43) setup using the formatMessage
provided by react-intl
using the injectIntl
higher-order component (line 48).
import React, { useContext } from 'react';
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import { injectIntl } from 'react-intl';
import { StoreContext } from '../store/reducer/StoreProvider';
import Activation from '../screens/Activation';
import Home from '../screens/Home';
import Shows from '../screens/Shows';
import Movies from '../screens/Movies';
import Cartoons from '../screens/Cartoons';
import Settings from '../screens/Settings';
import messages from './messages';
import theme from '../theme';
const Tab = createMaterialTopTabNavigator();
const TabbedNavigator = ({ intl: { formatMessage } }) => {
const { state } = useContext(StoreContext);
if (state.isAuthenticated === false) {
return <Activation />;
}
return (
<Tab.Navigator
swipeEnabled={false}
style={theme.tabBar.style}
tabBarOptions={theme.tabBar.options}
initialRouteName={formatMessage(messages.homeTitle)}>
<Tab.Screen name={formatMessage(messages.homeTitle)} component={Home} />
<Tab.Screen name={formatMessage(messages.showsTitle)} component={Shows} />
<Tab.Screen
name={formatMessage(messages.moviesTitle)}
component={Movies}
/>
<Tab.Screen
name={formatMessage(messages.cartoonsTitle)}
component={Cartoons}
/>
<Tab.Screen
name={formatMessage(messages.settingsTitle)}
component={Settings}
/>
</Tab.Navigator>
);
};
export default injectIntl(TabbedNavigator);
Datastore for authentication
/src/store/reducer/StoreProvider.js is creating the StoreContext
to keep track of isAuthenticated
variable by employing the authReducer
and useReducer
hook (line 12). It also memoizes the state
and the dispatch
function (lines14-16) before storing them in that array as prop value
of StoreContext.Provider
on line 19.
import React, { useReducer, useMemo } from 'react';
import PropTypes from 'prop-types';
import { authReducer } from './reducers';
export const StoreContext = React.createContext();
const initialState = {
isAuthenticated: false,
};
const StoreProvider = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
const contextValue = useMemo(() => {
return { state, dispatch };
}, [state, dispatch]);
return (
<StoreContext.Provider value={contextValue}>
{children}
</StoreContext.Provider>
);
};
StoreProvider.propTypes = {
children: PropTypes.element.isRequired,
};
export default StoreProvider;
Apollo client setup
/src/store/apollo/Provider.js is making use of the createClient
function to prepare the Apollo client by passing to it the dispatch
function decomposed from StoreContext
at line 9.
/src/store/apollo/createClient.js is using API_ENPOINT_DEVELOPMENT
and API_ENDPOINT_PRODUCTION
constants from the .env
file located at the root level, containing the URLs of the according paths to GraphQL servers.
You need to create the
.env
file inside of the root folder, with the constants from above. As an exampleAPI_ENPOINT_DEVELOPMENT=http://192.168.0.2:3100
, where192.168.0.2
might be the IP address allocated to your computer. Verify your network settings, to see what your real IP address is. Please remember that Apple TV has to be connected to the same LAN as your computer, to be able to issue API requests.
If you didn’t notice already, the development server is started by using npm run server
or npm run dev
at the same moment as npm start
.
ApolloClient is the class that is going to be used to instantiate the client with application’s settings uri
, request
, onError
. At every request sent to the GraphQL server, this client would add to the request headers the uuid
property which is required for authentication. apollo-boost
is another piece of artwork from Apollo, and in their own words it “includes sensible defaults, such as our recommended InMemoryCache
and HttpLink
, which come configured for you with our recommended settings”.
import ApolloClient from 'apollo-boost';
import {
API_ENDPOINT_DEVELOPMENT,
API_ENDPOINT_PRODUCTION,
} from 'react-native-dotenv';
import deviceInfo from '../../constants/deviceInfo';
import { logOut } from '../reducer/actions';
const createClient = dispatch =>
new ApolloClient({
uri:
process.env.NODE_ENV === 'development'
? `${API_ENDPOINT_DEVELOPMENT}/graphql`
: `${API_ENDPOINT_PRODUCTION}/graphql`,
request: operation => {
operation.setContext({
headers: {
uuid: deviceInfo.uuid,
},
});
},
onError: ({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
console.log('graphQLErrors', graphQLErrors);
}
if (networkError) {
console.log('networkError', networkError);
logOut(dispatch);
}
},
});
export default createClient;
Components
You can browse the components under the /src/components folder. The most important ones are: Featured
(the cards used at top of Home
screen), Poster
(movie posters at the middle of the screen, featuring progressive loading new items when focusing to the right), Category
(touchable highlights with movie categories). All of these are loaded using the List
component which is a React Native FlatList
in essence. I’ll discuss Video
under a subsequent section.
<FlatList
data={items}
renderItem={({ item, index }) => this.renderItem(item, index)}
keyExtractor={item => `${childType}-${uniqueKey}-${item.id}`}
horizontal={horizontal}
onEndReached={loadMoreItems}
onEndReachedThreshold={onEndReachedThreshold}
removeClippedSubviews={false}
{...noColumns}
/>
renderItem(item, index) {
const {
childType,
navigator,
items,
horizontal,
navigateToRoute,
} = this.props;
const it = { item };
const nav = { navigator };
const first =
index === 0 && horizontal && childType === 'featured'
? { first: true }
: {};
const last =
index === items.length - 1 && horizontal && childType === 'featured'
? { last: true }
: {};
const props = { ...it, ...nav, ...first, ...last, ...{ navigateToRoute } };
if (childType === 'category') {
return <Category {...props} />;
} else if (childType === 'featured') {
return <Featured {...props} />;
}
return <Poster {...props} />;
}
Screens
All screens are listed under /src/screens folder. Activation
is rendered selectively based on isAuthenticated
variable's value under StoreContext
. It shows the user the random alphanumeric string used to authenticate the device based on its UUID
.
Home
is the initial screen rendered under the StackedNavigator
and TabbedNavigator
. It contains the 3 main components mentioned above. The other components under TabbedNavigator
are simple placeholders: Shows
, Movies
, Cartoons
, Settings
.
There are 3 more full-sized screens under the StackedNavigator
: Details
(shows movie details), Category
(renders all the movie posters under a particular category), Video
(plays the media sources).
Theming
The current theme is imported from /src/theme/index.js. I’ve defined 3 themes: clean
, warm
, luxurious
found under /src/theme/themes/ folder. Each of those consists of an object with a fixed structure, containing values for different components and their corresponding CSS properties.
I used the color
npm package to play with predefined colors based on helper functions like lighten
or darken
. These theme
objects are used all over the styling of the components, as string interpolated values.
Video player using Siri remote
The src/components/Video component uses react-native-video
as a starting point and on top of that it adds customized controls for the Siri remote. The key to that is the enableTvHandler
method (lines 52 - 70) that registers a listener for swipeRight
, swipeLeft
, swipeDown
, swipeUp
events. Up and down are used to show and hide the progress bar. Left and right are used to seek the video forward and backward while the playback is paused. playPause
and select
events are used to toggle the paused
state key.