Adding Android support to RNNYT
With all the necessary tools installed, we can actually begin to experiment with some code in an Android emulator. Open up our RNNYT project and make sure you have your Android emulator running.
From your project's root directory, launch RNNYT in the Android emulator by running the command:
react-native run-android
With any luck, you'll see the Welcome to React Native screen.
Tip
Running adb
devices will display a list of all attached Android devices. This list will include emulators and any physical hardware connected, with developer options enabled.
Before we dive into any refactoring, let's briefly orient ourselves to the Android emulator. The keyboard shortcut Command + R won't work for us anymore. If you want to refresh the screen, you'll need to either double tap the R key on your keyboard or launch the menu. There're a few ways to do this. The most obvious way is to click the Menu button visible under the section labeled Hardware Buttons. If keyboard shortcuts are more your thing, you can do the same by pressing Command + M on your keyboard. Refer to the following screenshot:
From there, you can trigger the remote debugger, hot reloading, manual reloading, and other debugging tools.
Branching platform logic
As you've most likely noticed by now, React Native allows you to optionally add a .ios.js
or .android.js
extension to any file name. If you omit the platform-specific extension, React Native assumes that the file is universal and will be used by either platform. This is probably best explained with some examples. Imagine our project includes the following files:
Home.js
MyComponent.js
MyComponent.android.js
Among other things, Home.js
includes the following code:
import MyComponent from './MyComponent';
If the code is run on Android, it will use MyComponent.android.js
. However, when the code runs on iOS, the packager is unable to find MyComponent.ios.js
and will fall back to MyComponent.js
.
Now look at our next example:
Home.js
MyComponent.js
MyComponent.ios.js
MyComponent.android.js
Here, iOS would ignore MyComponent.js
and utilize MyComponent.ios.js
. And, once again, Android would use MyComponent.android.js
. Admittedly, this is a contrived example since MyComponent.js
would effectively go unused. However, it illustrates the point that platform-specific files will always be favored over non-platform-specific files. If your code is meant to be universal, just use the traditional .js
extension. There is one exception to all of this. The root project files index.ios.js
and index.android.js
must maintain their platform extensions. Even if the contents of each of these files are identical, you cannot create a universal index.js
file.
This capability illustrates one of the ways you can tailor your code to a specific platform. But what if you don't want to branch an entire file? What if you only need to branch a small piece of logic within a file? Thankfully, React Native supports that too. In this case, both platforms can use the same JavaScript file, but utilize the Platform
API to branch the code:
import { Platform } from 'react-native'; if (Platform.OS === 'android') { // Do something specific for Android } else if (Platform.OS === 'ios') { // Handle iOS }
The Platform
API also includes a few other useful methods and properties. Platform.Version
exposes the underlying Android version. Sadly, this only works for Android. Platform.Version
on iOS will simply return undefined. Finally, Platform.select
is a method you can use to toggle platform-specific code. Here's an example:
const backgroundStyle = Platform.select({ ios: { backgroundColor: 'green' }, android: { backgroundColor: 'red' } }); const styles = StyleSheet.create({ container: { flex: 1, padding: 20, ...backgroundStyle } });
Here, Platform.select
expects an object with keys that match the platform. Those keys can map to any value. In the previous code sample, the keys map to an object that defines platform-specific background colors. It then uses the spread operator to merge the background color with the rest of the styles inside the container.
Refactoring RNNYT for Android
Thankfully, we've already moved as much code as possible outside of index.ios.js
. Therefore, we can duplicate the contents of index.ios.js
into index.android.js
. Take a look at the following code snippet:
import { AppRegistry } from 'react-native'; import App from './src/App'; AppRegistry.registerComponent('RNNYT', () => App);
If you happen to refresh your app in the Android emulator, you'll probably see the error Requiring unknown module "../components/HomeScreen"
. Given our discussion of platform-specific file extensions, this shouldn't come as any surprise. The HomeScreen
component is housed inside HomeScreen.ios.js
. Let's create a different version of HomeScreen
that's customized for Android inside HomeScreen.android.js
. Just to recap, HomeScreen
is responsible for toggling between our three primary components-- NewsFeedContainer
, SearchContainer
, and BookmarksContainer
. We accomplished this with the iOS specific component TabBarIOS
. For Android, we'll need to do something else. But, before we get into that, let's just get a basic component running that simply renders the NewsFeedContainer
. Even though this component doesn't need to be a class-based component yet, we'll set it up as such since we'll be adding methods to it shortly. Take a look at the following code snippet:
// HomeScreen.android.js import React, { Component, PropTypes } from 'react'; import NewsFeedContainer from '../containers/NewsFeedContainer'; export default class HomeScreen extends Component { render() { return <NewsFeedContainer />; } } HomeScreen.propTypes = { selectedTab: PropTypes.string, tab: PropTypes.func.isRequired };
Aside from some styling issues within the NewsFeed
, this simplified version of HomeScreen
gets us back in the game. The list of news items will render and allow us to press on them to see the detailed article. However, we added an onLongPress
event handler inside of NewsItem
, which uses the ActionSheetIOS
API. For Android, we'll use the ToastAndroid
and Vibration
APIs to inform users they've bookmarked a news article.
Import ToastAndroid
, Platform
, and Vibration
at the top of NewsItem.js
:
import {
View,
TouchableOpacity,
StyleSheet,
ActionSheetIOS,
ToastAndroid,
Platform,
Vibration
} from 'react-native';
Then, inside the onLongPress
method, we'll utilize Platform.select
to branch the logic between Android and iOS solutions:
onLongPress() { const platformMsgFn = Platform.select({ android: () => { ToastAndroid.show( `"${this.props.title}" has been bookmarked!`, ToastAndroid.LONG ); Vibration.vibrate(); this.props.onBookmark(); }, ios: () => ( ActionSheetIOS.showActionSheetWithOptions({ options: ['Bookmark', 'Cancel'], cancelButtonIndex: 1, title: this.props.title }, (buttonIndex) => { if (buttonIndex === 0) { this.props.onBookmark(); } }) ) }); platformMsgFn(); }
I've refactored this code using Platform.select
to return an anonymous function based on the platform. The resulting function is stored as platformMsgFn
and is then executed. In the case of Android, the anonymous function executes ToastAndroid.show
, passing it a message and a constant specifying how long the toast should remain visible (this can be either ToastAndroid.SHORT
or ToastAndroid.LONG
). Next, we call Vibration.vibrate
and save our bookmark using the onBookmark
action creator.
Fixing Android vibration
If you run the app inside the Android emulator and attempt to long press one of the news articles, you'll be greeted with a very unfriendly error Requires VIBRATE permissions
. What's that about? you ask. Well, Android requires that we give it explicit permissions to utilize vibration notifications. Thankfully, this is pretty simple. Open up android/app/src/main/AndroidManifest.xml
. Within the manifest tag, add the following:
<uses-permission android:name="android.permission.VIBRATE"/>
We'll need to rebuild the app in order for the permission to take effect. From your Terminal, simply rerun:
react-native run-android
Now, if you try to bookmark an article, you'll see everything works as expected (the vibration itself will only work with actual hardware). Refer to the following screenshot:
With that behind us, let's get back to our Android HomeScreen
component. React Native offers two components for Android that solve our navigation dilemma. One option is ToolbarAndroid
. This component locks a fixed header at the top of your app that can contain a logo, a navigation icon (for example, a hamburger menu), a title, and a subtitle. This would work; however, I've found this component doesn't offer many options for styling. Instead, we're going to use the fairly vanilla DrawerLayoutAndroid
. This component adds an off-screen drawer that can be toggled by swiping your finger (or, in the case of the emulator, your mouse cursor) to the right from the left edge of the device. The drawer typically contains navigation items, but could also display a logo or any other information.
Using DrawerLayoutAndroid
What I like about DrawerLayoutAndroid
is that there isn't a lot to it. As stated earlier, it's just a component that can slide in from off-screen to display our navigation options. Beyond that, we control all the logic to display the selected view. Thankfully, this won't be too challenging. To begin, add these imports to HomeScreen.android.js
:
import { DrawerLayoutAndroid, View, StyleSheet } from 'react-native'; import SearchContainer from '../containers/SearchContainer'; import BookmarksContainer from '../containers/BookmarksContainer'; import AppText from './AppText'; import * as globalStyles from '../styles/global';
Let's also configure some styles that we'll need shortly:
const styles = StyleSheet.create({ container: { backgroundColor: globalStyles.BG_COLOR, flex: 1 }, drawer: { backgroundColor: globalStyles.BG_COLOR, flex: 1, padding: 10 }, drawerItem: { fontSize: 20, marginBottom: 5 }, menuButton: { marginHorizontal: 10, marginTop: 10, color: globalStyles.LINK_COLOR } });
Next, add this navigation configuration before the HomeScreen
component:
const navConfig = { order: ['newsFeed', 'search', 'bookmarks'], newsFeed: { title: 'News', view: <NewsFeedContainer />, tab: 'newsFeed' }, search: { title: 'Search', view: <SearchContainer />, tab: 'search' }, bookmarks: { title: 'Bookmarks', view: <BookmarksContainer />, tab: 'bookmarks' } };
This configuration will be used to dynamically set the active view.
Within the render method, we're going to wrap the active container element with DrawerLayoutAndroid
. DrawerLayoutAndroid
accepts several props. We're just going to focus on a few key ones. renderNavigationView
is a prop that expects a function that returns the drawer's view contents. The drawerWidth
prop sets the width of the drawer, and drawerPosition
configures the drawer to slide in from either the left or right side. Finally, drawerBackgroundColor
acts as an overlay sitting between the open drawer and the remainder of the application. We'll start by filling in the completed render
method and then we'll add in the missing pieces afterward:
render() { return ( <DrawerLayoutAndroid drawerWidth={310} drawerPosition={DrawerLayoutAndroid.positions.Left} drawerBackgroundColor="rgba(0,0,0,0.5)" renderNavigationView={this.renderDrawer} > <View style={styles.container}> <AppText style={styles.menuButton} onPress={this.showNav} >Menu</AppText> {navConfig[this.props.selectedTab].view} </View> </DrawerLayoutAndroid> ); }
Inside DrawerLayoutAndroid
, we've added an AppText
element. At the moment, it has an onPress
handler pointing to a yet-to-be-defined method. Once it's implemented, it will provide an alternative means of opening the drawer for users unfamiliar with Android's drawer swipe gesture. The renderNavigationView
prop is calling out to a method we'll define next:
constructor(props) { super(props); this.renderDrawer = this.renderDrawer.bind(this); } renderDrawer() { return ( <View style={styles.drawer}> {navConfig.order.map(key => ( <AppText key={key} style={styles.drawerItem} onPress={() => this.props.tab(navConfig[key].tab)} > {navConfig[key].title} </AppText> ))} </View> ); }
renderDrawer
does exactly that it returns a View
that contains our three navigation options, News
, Search
, and Bookmarks
. Pressing on any of them will call the tab action creator. Once the active tab is updated in the Redux state, the correct container will render inside the DrawerLayoutAndroid
:
{navConfig[this.props.selectedTab].view}
This gets us a lot closer to where we'd like to be. However, we still have a few problems. For one, the Menu button doesn't do anything. Also, you need to manually swipe away the drawer after selecting News, Search, or Bookmarks. Thankfully, DrawerLayoutAndroid
has openDrawer
and closeDrawer
methods. We just need some way of hooking them into our DrawerLayoutAndroid
element so we can execute them. This is one of those cases where we'll want to use a React ref
(reference). If you've used ref
in React before, you may have seen something like <Component ref="myRefName" />
. Here, the ref
is set to a string and can be referenced in other parts of the component with this.refs.myRefName
. However, the string value approach is considered legacy. So, we'll use the ref
callback value. Add the following ref
callback:
<DrawerLayoutAndroid ref={(c) => { this.drawer = c; }} //...
Once the DrawerLayoutAndroid
element has mounted, the ref
callback will execute, saving the component c
to this.drawer
. Next, update the onPress
handler inside renderDrawer
to close the drawer after the user selects an option:
onPress={() => { this.props.tab(navConfig[key].tab); this.drawer.closeDrawer(); }}
Now we can write that showNav
method we've been neglecting, allowing us to open the drawer by clicking the Menu button:
showNav() { this.drawer.openDrawer(); }
Since showNav
references this, we'll need to bind it in the constructor:
this.showNav = this.showNav.bind(this);
Customizing Android styling
Functionally, our app is working well on Android. Visually, however, it could use some tuning. When we defined many of our global styles back in earlier chapters, we were only taking iOS into account. We'll need to tweak a few things to make the design work nicely on both platforms.
Let's start by fixing our global styles. Through my own experimentation, I've found that, when it comes to styling view container elements, Android tends to work better with margin and iOS with padding. With that said, we're going to create two new style files in the src/styles
directory platform.android.js
and platform.ios.js
.
We'll start with platform.ios.js
. Inside this file, we'll place all the styles that are specific to iOS:
export default { PAGE_CONTAINER_STYLE: { paddingTop: 20, paddingHorizontal: 10, marginBottom: 48 }, TEXT_STYLE: { fontFamily: 'Helvetica Neue' } };
Now we'll do the same for Android inside platform.android.js
:
export default { PAGE_CONTAINER_STYLE: { marginTop: 10, marginHorizontal: 10 }, TEXT_STYLE: { fontFamily: 'sans-serif' } };
Next, open up global.js
and import the platform module at the top, then destructure the values into PAGE_CONTAINER_STYLE
and TEXT_STYLE
:
import platformStyles from './platform'; const { PAGE_CONTAINER_STYLE, TEXT_STYLE } = platformStyles;
Thanks to our use of platform file extensions, our code will automatically use the correct styles. Now we just need to apply them. To do this, update COMMON_STYLES
:
export const COMMON_STYLES = StyleSheet.create({ pageContainer: { backgroundColor: BG_COLOR, flex: 1, ...PAGE_CONTAINER_STYLE }, text: { color: TEXT_COLOR, ...TEXT_STYLE } });
Take a look at the following screenshot:
With everything looking nice on our primary news feed, let's switch over to the Search
component. I feel there's a bit too much margin above the search box on Android. Plus, there's this unsightly dark line that Android adds by default to TextInputs
. Let's touch up the Search
component by importing Platform
at the top of Search.js
, and update the search style's marginTop
to the following:
marginTop: Platform.OS === 'ios' ? 10 : 0,
While we're at it, let's update the input style so Android adds some paddingBottom
:
paddingBottom: Platform.OS === 'android' ? 8 : 0
TextInput
has several props that are platform specific allowing us to tune the styling and user experience. In order to remove the underline visible on Android, set the underlineColorAndroid
prop to transparent
. We can also change the Android keyboard's return key text by setting the returnKeyLabel
prop. Thankfully, iOS will just ignore these so we don't need to add any platform branching logic. On the iOS side, we can change the keyboard's default theme to dark, using the keyboardAppearance
prop, to match our app's aesthetic:
<TextInput
style={styles.input}
onChangeText={this.searchNews}
value={this.state.searchText}
placeholder="Search"
placeholderTextColor={globalStyles.MUTED_COLOR}
underlineColorAndroid="transparent"
returnKeyLabel="Search"
keyboardAppearance="dark"
/>
In the process of fixing up our global styles, we actually introduced a bug into our IntroScreen
. If you visit the IntroScreen
now on Android, you'll find an unsightly white border around the top, left, and right sides. This was introduced from the margin values we applied globally. There's also one other minor issue. We call the iOS-only setBarStyle
method of StatusBar
. We can fix the latter issue by once again importing Platform
from React Native and then wrapping our call to setBarStyle
with a simple if
statement:
if (Platform.OS === 'ios') { StatusBar.setBarStyle('light-content'); }
And, finally, since neither platform actually needs a margin applied to the IntroScreen
, we'll override the global pageContainer
styles to reset the affected margin properties:
container: { marginTop: 0, marginBottom: 0, marginHorizontal: 0, justifyContent: 'center', alignItems: 'center' }
Enabling LayoutAnimation
There's one final edit we need to make to our Onboarding
component. If you click through the onboarding experience, you'll notice everything animates as expected until you reach the final transition. Pressing Done is intended to trigger an animation using LayoutAnimation
(as opposed to using the Animated
API). As is, LayoutAnimation
doesn't throw any errors, but it also doesn't animate whatsoever. It just appears.
In order to use LayoutAnimation
on Android, we need to explicitly opt into experimental layout animation by using the UIManager
. UIManager
is yet another API we'll import from the React Native library.
Inside of Onboarding.js
, import the UIManager
:
import {
StyleSheet,
View,
LayoutAnimation,
Animated,
PanResponder,
UIManager
} from 'react-native';
You'll need to exercise caution since the setLayoutAnimationEnabledExperimental
method isn't available on iOS. That said, we need to first verify if the method is present. If so, we'll call it inside the Onboarding
constructor:
constructor(props) {
super(props);
this.moveNext = this.moveNext.bind(this);
this.movePrevious = this.movePrevious.bind(this);
this.transitionToNextPanel = this.transitionToNextPanel.bind(this);
this.moveFinal = this.moveFinal.bind(this);
this.state = {
currentIndex: 0,
isDone: false,
pan: new Animated.Value(0)
};
if (UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
}
With this change in place, the Onboarding
component will behave exactly as it does on iOS.