要約
ReasonMLやReScriptは参考資料が少なく、実装で困った時の対応が難しい。
今回は、ReScript x Reason React Nativeの環境下で、reason-react-navigationのライブラリの仕様を追いつつ、TabNavigationを動かしてみた。
reason-react-navigationを利用した既存実装
reason-react-navigationのexampleではStackNavigator
を用いてmodal画面を出す実装が書かれている。
▼reason-react-naigation-exampleではDrawerNavigator
とTabを組み合わせている。これを基準にすればそれなりのアプリはできそうである。
今回はTabNavigator
を利用して、アプリの骨組みを作る。
エディタ設定
ReasonML/ReScript環境向けのプラグインがあるので、入れておく。
ReScript Docs / Language Manual / EditorPlugins
初期設定
Reason React Nativeの公式ドキュメント "Create a new project with Reason React Native"に従い、コマンドを実行する。
npx @react-native-community/cli init --template @reason-react-native/template <your-project-name>
するとプロジェクトに必要なファイル一式が作成される。
プロジェクトのディレクトリに移り、npmのパッケージ群をインストールする。
cd <your-project-name>
yarn insyall
.re/.resファイルをbuild、あるいはwatchする。そして、通常のreact-native同様、run-ios/run-androidを実行する。
コマンドはpackage.json
のscripts
の項で確認できる。
"scripts": {
"start": "react-native start",
"ios": "react-native run-ios",
"android": "react-native run-android",
"re:clean": "bsb -clean-world",
"re:watch": "bsb -clean-world -make-world -w",
"re:build": "bsb -clean-world -make-world",
"test": "jest"
}
yarn re:watch
でbuildを行い、.bs.js形式ファイルを出力し、ファイルの変更があった場合に自動で再出力する。
別窓でreact-native
を走らせる。iOSの場合はpod install
が必要。
yarn ios
.re -> .res
この手順は行わなくても良い。ただ、App.reは削除する必要がある。
デフォルトのテンプレートはReasonMLで書かれているが、今回はReScriptで記述するので、変換してみる。
ReasonMLのファイル(.re)からReScriptのファイル(.res)はコマンド一つで変換する事ができる。
node_modules/.bin/bsc -format src/App.re > App.res
元のファイルが残るため、このまま起動するとバグるので注意。古いファイルは消す。
動かすとReason React Native
のReasonMLの際と同じ画面が見られる。
目標構造
Javascript実装のサンプルの2例を組み合わせると以下のようになっている。
これを元にReScriptを書いていく。
import * as React from 'react';
import { Text, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
// You can import Ionicons from @expo/vector-icons/Ionicons if you use Expo or
// react-native-vector-icons/Ionicons otherwise.
import Ionicons from 'react-native-vector-icons/Ionicons';
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();
export default function App() {
return (
<NavigationContainer>
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName;
if (route.name === 'Home') {
iconName = focused
? 'ios-information-circle'
: 'ios-information-circle-outline';
} else if (route.name === 'Settings') {
iconName = focused ? 'ios-list-box' : 'ios-list';
}
// You can return any component that you like here!
return <Ionicons name={iconName} size={size} color={color} />;
},
})}
tabBarOptions={{
activeTintColor: 'tomato',
inactiveTintColor: 'gray',
}}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Settings" component={SettingsScreen} />
</Tab.Navigator>
</NavigationContainer>
);
}
Tab実装
まず、単純なTab Navigationを実装する。
package
reason-react-navigation
とそれに該当するライブラリをインストールする
yarn add @react-navigation/bottom-tabs @react-navigation/native react-native-screens react-native-safe-area-context reason-react-navigation
bsconfig.json
にreason-react-navigation
を追加する
"bs-dependencies": [
"reason-react",
"reason-react-native",
"reason-react-navigation"
]
ディレクトリ
src
├── App.res
├── RootNavigator.res
└── screens
├── Home.res
├── Second.res
└── Third.res
open ReactNative
@react.component
let make = (~navigation, ~route) => <SafeAreaView><Text> {React.string("home")} </Text></SafeAreaView>
Home.res
, Second.res
, Third.res
は同様に作成、ファイル名と文字列だけ変更したものを用意する。
open ReactNavigation
module RootNavigator = {
include BottomTabs.Make({
type params = unit;
})
@react.component
let make = () => <>
<Native.NavigationContainer>
<Navigator>
<Screen name="main" component=Home.make />
<Screen name="second" component=Second.make />
<Screen name="third" component=Third.make />
</Navigator>
</Native.NavigationContainer>
</>
}
open RootNavigator
@react.component
let app = () => <RootNavigator />
カスタマイズ
tabBarOptions
Tab.Navigation
のtabBarOptions
を記述する。型で指定されているので、オブジェクトを入れても通らない。
Tab.Navigator
のPropsは
type navigatorProps = {
initialRouteName: option(string),
screenOptions: option(optionsCallback),
backBehavior: option(string),
_lazy: option(bool),
tabBar: option(Js.t(bottomTabBarProps) => React.element),
tabBarOptions: option(bottomTabBarOptions),
};
と定義されていて、tabBarOptions
が該当する。
続いて、型として指定されているbottomTabBarOptions
を調べる。
[@bs.obj]
external bottomTabBarOptions:
(
~keyboardHidesTabBar: bool=?,
~activeTintColor: string=?,
~inactiveTintColor: string=?,
~activeBackgroundColor: string=?,
~inactiveBackgroundColor: string=?,
~allowFontScaling: bool=?,
~showLabel: bool=?,
~showIcon: bool=?,
~labelStyle: ReactNative.Style.t=?,
~tabStyle: ReactNative.Style.t=?,
~labelPosition: labelPositionArgs => string=?,
~adaptive: bool=?,
~style: ReactNative.Style.t=?,
unit
) =>
bottomTabBarOptions;
~(チルダ)はラベル付き引数を表すらしい。
unit
とは何ぞや?という気持ちになるが、
ReasonMLを使う理由…ではなく入門によれば、
OCamlにおける経験則として、引数にunit()を配置することをとすることで、
その位置までの引数を無視する、つまりNoneを指定して関数を実行することを明示できます。
ということらしい。
let tabBarOptions = bottomTabBarOptions(
~activeTintColor="tomato",
~inactiveTintColor="gray",
(),
)
とし、
@react.component
let make = () => <>
<Native.NavigationContainer>
<Navigator
tabBarOptions
>
<Screen name="main" component=Home.make />
<Screen name="second" component=Second.make />
<Screen name="third" component=Third.make />
</Navigator>
</Native.NavigationContainer>
</>
とすれば、
<Tab.Navigator
tabBarOptions={{
activeTintColor: 'tomato',
inactiveTintColor: 'gray',
}}
>
</Tab.Navigator>
と同等になる。なお、JavaScriptではタグ内のプロパティ単独表記はプロパティにtrue
が代入されていたが、ReScriptではプロパティと同名の変数の値が入る。JavaScriptのオブジェクト作成時と同じ感覚。
ReScript Docs / Language Manual / Overview #JSX
screenOptions-Ionic
次にscreenOptions
にとりかかる。
screenOptionsの型
Tab.Navigator
のProps
type navigatorProps = {
initialRouteName: option(string),
screenOptions: option(optionsCallback),
backBehavior: option(string),
_lazy: option(bool),
tabBar: option(Js.t(bottomTabBarProps) => React.element),
tabBarOptions: option(bottomTabBarOptions),
};
より、screenOptions
の型はoption(optionsCallback)
。
optionsCallback
の型は
type optionsCallback = optionsProps => options;
で、 optionsProps
, options
の型は
type optionsProps = {
navigation,
route,
};
と
[@bs.obj]
external options:
(
~title: string=?,
//TODO: dynamic, missing static option: React.ReactNode
~tabBarLabel: tabBarLabelArgs => React.element=?,
~tabBarIcon: tabBarIconArgs => React.element=?,
~tabBarAccessibilityLabel: string=?,
~tabBarTestID: string=?,
~tabBarVisible: bool=?,
~tabBarButton: React.element=?,
~unmountOnBlur: bool=?,
unit
) =>
options;
となる。今回用いるtabBarIcon
の型tabBarIconArgs => React.element=?
の引数tabBarIconArgs
の型は
type tabBarIconArgs = {
focused: bool,
color: string,
size: float,
};
となっている。
これらに基づいて組み立てていく。大枠は以下。
let screenOptions = ({navigation, route}: optionsProps) => {
@react.component
let selecter = ({focused, color, size}: tabBarIconArgs): React.element => {
// 何らかの処理
<>
// アイコンのコンポーネント
</>
}
options(
~tabBarIcon=selecter,
(),
)
}
フォント
今回はreact-native-vector-iconsのIonicを使う。
まず、yarnでインストールし、
yarn add react-native-vector-icons bs-react-native-vector-icons
bsconfig.json
にbs-react-native-vector-icons
を加える。
"bs-dependencies": [
"reason-react",
"reason-react-native",
"reason-react-navigation",
"bs-react-native-vector-icons"
]
また、Installationで言及されている通り、各OS毎にフォントを利用できるようにするための手順があるので、それに従いプロジェクトファイルに変更を加える。
module Ionicons = {
[@bs.deriving jsConverter]
type name = [
| [@bs.as "ios-add"] `_iosAdd
| [@bs.as "ios-add-circle"] `_iosAddCircle
// (中略)
| [@bs.as "md-woman"] `_mdWoman
];
let nameToJs = nameToJs;
[@bs.module "react-native-vector-icons/Ionicons"] [@react.component]
external make:
(~name: string, ~color: string=?, ~size: float=?) => React.element =
"default";
let makeProps = (~name) => makeProps(~name=nameToJs(name));
};
ReasonML/ReScriptに詳しくなく、ライブラリに使い方が書いてなくて困った。
Ionicons.make({"name":"ios-home", "color":Some(color), "size":Some(size)})
で型を合わせたつもりが、Cannot call a class as a function
エラーで動かず。
<Ionicons name color size />
のname
に文字列を入れるも動かず。
何時間もわからんといいながらたどり着いたのが、類似のライブラリreason-expo-vector-iconsのページ。
<Ionicons name=`md-checkmark-circle size=32 color="green" />
なるほど。`hogeがENUMみたいなやつなのね、と。(多相variantというらしい
この例のように、RNIcons.reに存在する`_iosHomeをnameに入れてみる
<Ionicons name=`_iosHome color size />
残念ながら動かない。だってこのコードはReasonMLじゃなくてReScriptだから。
ReScript Docs / Language Manual / MigrateFromBucklescriptReason
Polymorphic variants: from `red to #red.
多相variantの指定方法は`redから#redに変更。なるほど。
<Ionicons name=#_iosHome color size />
でFA。
あとはroute.nameでパターンマッチを行い、タブによるアイコンの出し分けを行う。
let screenOptions = ({route}: optionsProps) => {
@react.component
let selecter = ({color, size}: tabBarIconArgs): React.element => {
let name = switch route.name {
| "main" => #_iosHome
| "second" => #_iosList
| "third" => #_iosHeart
| _ => #_iosInformationCircle
}
<Ionicons name color size />
}
options(
~tabBarIcon=selecter,
(),
)
}
NavigatorにもscreenOptionsを加えてやる。
@react.component
let make = () => <>
<Native.NavigationContainer>
<Navigator
screenOptions
tabBarOptions
>
<Screen name="main" component=Home.make />
<Screen name="second" component=Second.make />
<Screen name="third" component=Third.make />
</Navigator>
</Native.NavigationContainer>
</>
無事Tabにアイコンが表示された。
めでたし。めでたし。
実装結果
GitHubに上げてみた。iOS Simulatorだけで試してたので、Androidで動かす場合はフォントの設定が必要。
rescript-react-navigation-tab-sample - GitHub
感想
仕事でReactNativeを使ったりしていて、これまで書いていた最新のECMAScript準拠のJavaScriptに代わる言語として、TypeScriptよりはReScriptを使いたい気持ちがあるが、記事やサンプルコードがほとんど転がっていないので、ハマったときの時間のロスが大きいなぁ、という気持ちになった。なかなかサポートがない状態ではこの言語で業務開発は難しそう。チーム開発では自分のトラブルシュートに加え、仲間のサポートもしなくてはいけなくなるし。美しく書けるというのは良いのだが、納期も予算もあるんだよ…。
あと、最初にReScriptの説明書こうと思ったけど何も思いつかなくてやめた。BuckleScript+ReasonML=ReScript? ReasonMLの時も文法変わってた気がするし、BuckleScriptを書いたことがないからわからない…。