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

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;

/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 example API_ENPOINT_DEVELOPMENT=http://192.168.0.2:3100, where 192.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.