TL;DR
本稿では、SpotifyのようなアニメーションつきのタブバーをReact Navigation 5.xで実装する方法についてを紹介します。
こちらが完成形です。
途中でSpotify愛を語りながらグダグダと書きますので、生き急いでる方はgithubリポジトリをご覧ください。
自己紹介
僕は北の大地で統計学、経済学、経営学などを学んでいる**文系大学院生(闇)**です。
大学3年の秋頃に暇だったのでプログラミングを学び始めました。専門は統計学だったので、「ディープラーニングができたらいいなあ」と思って最初の言語はPythonにしました。僕はいまM1なので、プログラミング歴でいうと2年フラットとなります。
また、今年度に入ってなぜか(コロナで暇だったから)アプリ開発をしたいと思い、React Nativeをやりはじめました。なので、React Native歴で言うと3ヶ月ほどになります。
アプリ開発をするためにSwiftもKotlinもどっちも学ぶのはめんどくせえなと思ってクロスプラットフォームフレームワークを使うことにしました。その中でもReact NativeにしたのはJavaScriptで書けるってところが大きかったですね。あとユーザー数の多さ(大事)
環境
macOS Catalina
node.js 12.17.0
expo-cli 3.21.13
Windows機でも動かせたのでOS間の違いは特にないはずです。
Spotify、マジでカッケェ
僕はSpotifyがホントに好きです。
つい最近『Spotify』という本が発売されました。SpotifyのCEOのダニエル・エクが、Spotifyを世界一のストリーミングプラットフォームにするまでの経緯を描いたノンフィクションです。
読んだら「やっぱりSpotifyはカッケェなあ(小並感」ってなりました。
Spotifyは、他のストリーミングサービス(Apple Music, Amazon Musicなど)と違って、様々な角度から曲を切っていて、様々な角度から新しい曲と出会えるという点で素晴らしいサービスだともともと思っていたんです。
あとからSpotifyはSpotとidentifyからきた造語で、どちらも「見つける」という意味であるということを知って、会社のビジョンがサービスを通じてユーザーに伝わっているのもマジでカッケェなと思いました。
※書籍『Spotify』の中ではダニエル・エクの聞き間違いからSpotifyという名前が生まれたと書かれていました。
Spotify、タブバーすらカッケェ
まずAppleの「ミュージック」アプリのタブバーを見てください。タブを切り替えても色が変わるだけです。
次にSpotifyのタブバーを見てください。
キモチェエエエエエエエエエエエエ
このアイコンが「ぷにっ」とするのがマジで気持ちいい!!!!!
タブバーに「ぷにっ」としたアニメーションがあるかどうかでA/Bテストを行ったら、ユーザーのアクティブ率とかに有意な差が絶対出ると思います!!!!!
こうして、SpotifyのようなタブバーをReact Native(React Navigation 5.x)で実装したいと僕は考えたわけです。
React Navigation 5.xのセットアップ
React NavigationはReact Nativeで画面遷移を行うためのパッケージです。タブバーとかドロワーバーとかの実装が比較的簡単にできます。
React Navigationはメジャーアップデートのたびに書き方がガラリと変わるので大変です。。
依存関係にあるパッケージも多いので、最初はReact Navigationを動かすだけで一苦労でした。。
React Navigationを使うには、依存関係にあるパッケージをインストールしておく必要があります。Expo環境を想定しているので、以下のコマンドで依存パッケージは一括でインストールすることができます。
expo install @react-navigation/native react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view
また、今回はタブバーを作成したいので、上記の他に、@react-navigation/bottom-tabs
もインストールします。
expo install @react-navigation/bottom-tabs
これでReact Navigation 5.xを使う準備は完了です。
基本となるタブバーを作る
画面中央にHome!と書いてあるだけのHomeScreenというコンポーネントと、同じく画面中央にSettings!と書いてあるだけのSettingsScreenというコンポーネントを定義しています。
下記の通りに記述すれば最も簡単なタブナビゲーションが実装できます。
import React from 'react';
import { Text, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
function HomeScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home!</Text>
</View>
);
}
function SettingsScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Settings!</Text>
</View>
);
}
const Tab = createBottomTabNavigator();
function MyTabs() {
return (
<Tab.Navigator>
<Tab.Screen name='Home' component={HomeScreen} />
<Tab.Screen name='Settings' component={SettingsScreen} />
</Tab.Navigator>
);
}
export default function App() {
return (
<NavigationContainer>
<MyTabs />
</NavigationContainer>
);
}
以下のようになっていれば成功です。
アイコンがないと物足りない感がすごいので、次にアイコンを追加しましょう。
アイコンを追加する
今回はMaterialCommunityIconというアイコンセットを用います。
react-native-vector-icons/MaterialCommunityIcons
をインポートします。
expo install
でインストールする必要はありません。(Expo環境であれば)
MaterialCommunityIconを見てみると、Spotifyのアイコンもありました。
せっかくなのでこれを入れてタブを3つに増やします。
iconsはスクリーン名をプロパティとし、アイコン名を値としたオブジェクトになっています。
Icon内では、これを用いて、スクリーン名(route.name)から、アイコン名を取得しています。
import React from 'react';
import { Text, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
function HomeScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home!</Text>
</View>
);
}
function SettingsScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Settings!</Text>
</View>
);
}
function PremiumScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center'}}>
<Text>Premium!</Text>
</View>
)
}
const Tab = createBottomTabNavigator();
function MyTabs() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ color, size }) => {
const icons = {
Home: 'home',
Settings: 'settings',
Premium: 'spotify'
};
return (
<Icon
name={icons[route.name]}
color={color}
size={size}
/>
);
},
})}
>
<Tab.Screen name='Home' component={HomeScreen} />
<Tab.Screen name='Settings' component={SettingsScreen} />
<Tab.Screen name='Premium' component={PremiumScreen} />
</Tab.Navigator>
);
}
export default function App() {
return (
<NavigationContainer>
<MyTabs />
</NavigationContainer>
);
}
Spotifyのアイコンが増えているはずです。
アイコンが追加されるとよく見るタブバーになりました。
ここからこだわりが出始めます。
タブの選択でアイコンを変える
SpotifyのタブバーのHomeタブをよく見ると、選択する前と後でアイコンが異なります。Homeを選択していない状態だとアウトラインだけのアイコンで、Homeを選択するとなかの塗りつぶしのあるアイコンに切り替わっています。
これを実装するために、tabBarIconのfocused
というプロパティを用います。これは「現在そのタブが選択されているか」の真偽値です。これを三項演算子の条件式に用いると、簡単にアイコンが切り替わるようにできます。
MaterialCommunityIconにはhome-outlineとsettings-outlineというアイコンはあるのですが、spotify-outlineというアイコンはないので、(focused | (route.name == 'Premium'))
として、Premiumタブに関しては常にspotifyアイコンが表示されるようにしています。
import React from 'react';
import { Text, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
function HomeScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home!</Text>
</View>
);
}
function SettingsScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Settings!</Text>
</View>
);
}
function PremiumScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center'}}>
<Text>Premium!</Text>
</View>
)
}
const Tab = createBottomTabNavigator();
function MyTabs() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ color, size, focused }) => {
const icons = {
Home: 'home',
Settings: 'settings',
Premium: 'spotify'
};
return (
<Icon
name={(focused | (route.name == 'Premium')) ? icons[route.name] : icons[route.name] + '-outline'}
color={color}
size={size}
/>
);
},
})}
>
<Tab.Screen name='Home' component={HomeScreen} />
<Tab.Screen name='Settings' component={SettingsScreen} />
<Tab.Screen name='Premium' component={PremiumScreen} />
</Tab.Navigator>
);
}
export default function App() {
return (
<NavigationContainer>
<MyTabs />
</NavigationContainer>
);
}
settingsアイコンがアウトラインだけのアイコンになっているはずです。
Home以外のタブを選択すると、homeアイコンもアウトラインだけになります。
タブバーを自由に作る
もっと柔軟にタブバーを作成することもできます。タブバーを自分でゼロから作り、そこにタブナビゲーションの機能を追加するというイメージです。
ここでせっかくなので、背景色とタブバーの色とアイコンの色をSpotifyのようにしてみようと思います。
テキストの色を白くしないと背景の黒と同化して見えなくなってしまうので、テキストの色も変更してます。
ステータスバーのテキストの色も同様です。StatusBar
を用いて簡単に変更できます。
また、このあたりでStyleSheetを用いて、スタイルもまとめて管理しようと思います。
さらに、これまではiPhoneX以降のホームバーの分、画面のボトムのマージンをReact Navigationが自動調整してくれていましたが、自力で調整しなければいけなくなるため、react-native-iphone-x-helper
を使います。
expo install react-native-iphone-x-helper
でインストールします。
getBottomSpace()
は画面サイズからiPhoneX以上かどうかを判定して、iPhoneX以上であれば、ホームバー分の高さを返してくれる関数です。
ちなみにnavigation.emit()
は画面遷移を実行するメソッドです。
import React from 'react';
import { Text, View, StyleSheet, TouchableOpacity, StatusBar } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { getBottomSpace } from 'react-native-iphone-x-helper'
function HomeScreen() {
return (
<View style={styles.conteiner}>
<Text style={{color: '#fefdff'}}>Home!</Text>
</View>
);
}
function SettingsScreen() {
return (
<View style={styles.conteiner}>
<Text style={{color: '#fefdff'}}>Settings!</Text>
</View>
);
}
function PremiumScreen() {
return (
<View style={styles.conteiner}>
<Text style={{color: '#fefdff'}}>Premium!</Text>
</View>
)
}
const Tab = createBottomTabNavigator();
function MyTabBar({state, navigation}) {
return (
<View style={styles.tabbar}>
{state.routes.map((route, index) => {
const icons = {
Home: 'home',
Settings: 'settings',
Premium: 'spotify',
}
let isFocused = (state.index == index);
const _onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
};
};
return (
<View style={styles.tabButton} key={route.name}>
<TouchableOpacity
onPress={_onPress}
style={{ width:50, height:50, alignItems: 'center', justifyContent: 'center' }}
>
<Icon
name={(isFocused | (route.name == 'Premium')) ? icons[route.name] : icons[route.name] + '-outline'}
size={28} color={isFocused ? '#fefdff' : '#b7b4b7'}
style={styles.tabButtonIcon}
/>
<Text style={[styles.tabLabel, {color: isFocused ? '#fefdff' : '#b7b4b7'}]}>{route.name}</Text>
</TouchableOpacity>
</View>
)
})}
</View>
)
};
function MyTabs() {
return (
<Tab.Navigator tabBar={props => <MyTabBar {...props} />}>
<Tab.Screen name='Home' component={HomeScreen} />
<Tab.Screen name='Settings' component={SettingsScreen} />
<Tab.Screen name='Premium' component={PremiumScreen} />
</Tab.Navigator>
);
}
export default function App() {
StatusBar.setBarStyle('light-content', true);
return (
<NavigationContainer>
<MyTabs />
</NavigationContainer>
);
}
const styles = StyleSheet.create({
conteiner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#151116',
},
tabbar: {
height: 60 + getBottomSpace(),
width: '100%',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#2A272C',
paddingBottom: getBottomSpace(),
},
tabButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
tabButtonIcon: {
marginVertical: 4,
},
tabLabel : {
fontSize: 12,
}
})
だいぶ完成形に近づいてきました。
あとはアニメーションを追加するだけです。
- タブを押しても透明度が上がらない。(TouchbleOpacityはデフォルトで、タップされたらopacityが0.2になるようになっています。)
- アニメーションはタブを押したあと開始される。
- アニメーションに緩急がある気がする。アイコンが小さくなるとき、はじめはゆっくり小さくなり、あとから縮小するスピードが上がる気がする。逆に、アイコンが大きくなるとき、はじめは素早く大きくなり、あとから拡大するスピードが下がる気がする。
タブを押しても透明度が上がらないようにするには
TouchbleOpacityのactiveOpacity
を1.0に指定する。(デフォルトは0.2)
アニメーションがタブを押したあとに開始するようにするには
Promiseを用いて、非同期処理を行う必要があります。
タップ->アイコン&テキスト縮小->アイコン&テキスト拡大->画面遷移という順序で処理が走るようにする必要があります。
TouchbleOpacityをタップすると、まず_onPress()
を実行しますが、_onPress()
はbuttonSizeUp()
が実行されない限り実行されません。また、buttonSizeUp()
はbuttonSizeDown()
が実行されない限り実行されません。
buttonSizeDown()
とbuttonSizeUp()
は、animatedValue.timing(...).start()
と setTimeOut()
を同時に実行します。setTimeOut()
もPromiseの中に記述することによって、アニメーションの値が変化しきる前に次の処理を行うことを阻止しています。
_onPress()
内では、画面遷移が行われています。アニメーションの処理が完了する前に遷移してしまうと、アニメーションが途中で終了してしまうので、Promiseを使って、最後に実行されるようにしています。
アニメーションに緩急をつけるには
Animated.Value().interpolate()
を用いることで、入力値と出力値をセットで管理できます。
アイコンのサイズに関しては、100フレームに分割したとき、28からスタートし、60フレーム目までに26になり、100フレーム目までに24になるようにしています。
テキストのサイズに関しては、100フレームに分割したとき、12からスタートし、60フレーム目までに11に、100フレーム目までに10になるようにしています。
緩急をつけていると言っても気持ちだけという感じです。
また、拡大するときは、100フレーム目からスタートし、0フレーム目に戻ってくるというイメージです。
import React from 'react';
import { Text, View, StyleSheet, TouchableOpacity, StatusBar, Animated } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { getBottomSpace } from 'react-native-iphone-x-helper'
function HomeScreen() {
return (
<View style={styles.conteiner}>
<Text style={{color: '#fefdff'}}>Home!</Text>
</View>
);
}
function SettingsScreen() {
return (
<View style={styles.conteiner}>
<Text style={{color: '#fefdff'}}>Settings!</Text>
</View>
);
}
function PremiumScreen() {
return (
<View style={styles.conteiner}>
<Text style={{color: '#fefdff'}}>Premium!</Text>
</View>
)
}
const Tab = createBottomTabNavigator();
function MyTabBar({state, navigation}) {
return (
<View style={styles.tabbar}>
{state.routes.map((route, index) => {
const AnimatedIcon = Animated.createAnimatedComponent(Icon);
const AnimatedText = Animated.createAnimatedComponent(Text);
const animatedValue = new Animated.Value(0);
const interPolateIconSize = animatedValue.interpolate({
inputRange: [0, 60, 100],
outputRange: [28, 26, 24],
});
const interPolateTextSize = animatedValue.interpolate({
inputRange: [0, 60, 100],
outputRange: [12, 11, 10],
});
const icons = {
Home: 'home',
Settings: 'settings',
Premium: 'spotify',
}
let isFocused = (state.index == index);
const buttonSizeDown = () => {
return new Promise((resolve) => {
Animated.timing(animatedValue, {
toValue: 100,
duration: 75,
useNativeDriver: false,
}).start();
setTimeout(() => {
resolve()
}, 75)
})
}
const buttonSizeUp = () => {
return new Promise((resolve) => {
buttonSizeDown().finally(() => {
Animated.timing(animatedValue, {
toValue: 0,
duration: 75,
useNativeDriver: false,
}).start();
setTimeout(() => {
resolve()
}, 75);
})
})
}
const _onPress = () => {
buttonSizeUp().finally(() => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
});
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
};
})
}
return (
<View style={styles.tabButton} key={route.name}>
<TouchableOpacity
onPress={_onPress}
activeOpacity={1.0}
style={{ width:50, height:50, alignItems: 'center', justifyContent: 'center' }}
>
<AnimatedIcon
name={(isFocused | (route.name == 'Premium')) ? icons[route.name] : icons[route.name] + '-outline'}
color={isFocused ? '#fefdff' : '#b7b4b7'}
style={[styles.tabButtonIcon, {fontSize: interPolateIconSize}]}
/>
<AnimatedText style={{color: isFocused ? '#fefdff' : '#b7b4b7', fontSize: interPolateTextSize}}>{route.name}</AnimatedText>
</TouchableOpacity>
</View>
)
})}
</View>
)
};
function MyTabs() {
return (
<Tab.Navigator tabBar={props => <MyTabBar {...props} />}>
<Tab.Screen name='Home' component={HomeScreen} />
<Tab.Screen name='Settings' component={SettingsScreen} />
<Tab.Screen name='Premium' component={PremiumScreen} />
</Tab.Navigator>
);
}
export default function App() {
StatusBar.setBarStyle('light-content', true);
return (
<NavigationContainer>
<MyTabs />
</NavigationContainer>
);
}
const styles = StyleSheet.create({
conteiner: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#151116',
},
tabbar: {
height: 60 + getBottomSpace(),
width: '100%',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#2A272C',
paddingBottom: getBottomSpace(),
},
tabButton: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
tabButtonIcon: {
marginVertical: 4,
},
tabLabel : {
fontSize: 12,
}
})
できた!!!!!!!
これだけのために何時間も調査して実装するのはクレイジーだと思うのですが、神は細部に宿るそうなので、粘り強く開発するのが大切なのではないかと思います。知らんけど。