はじめに
Webとモバイルアプリで同一のUI/UXを描きたい時、WebをNext.jsやViteなどで開発して、モバイルアプリをReact Native,Expoを使って開発することがあるかと思います。しかし、React DOMはReact Nativeでは流用することはできないので、Webとモバイルアプリを同一コンポーネントで開発するというのは難しいです。
しかし、その課題に対して強力なフレームワークがあります。それが Tamagui(タマグイ) です。tamaguiを使うことによって、WebとNativeを別々で開発していた工数が同時開発になるので大きく削減することが可能になります。
この記事では、そんなTamaguiについて余すことなく紹介していきたいと思います
Tamaguiの主な特徴
- マルチプラットフォームを単一コンポーネントで対応
-
静的コンパイルで高速化
- props をビルド時に解析し、最適化された CSS クラスへ変換します
- Web ではランタイム処理がほぼ不要になります
-
React Native と自然に共存
- モバイルではそのまま React Native コンポーネントとして動作
-
型定義の充実による書きやすさ
- デザイントークンを型安全に設定できる
導入方法
公式のtemplateを使って開発したほうが手っ取り早いです。
npm create tamagui@latest
次にPick a templateと聞かれるので、Freeを選びます。するとNext.jsとExpoを使ったスターターテンプレートが作成されます。
.
├── apps
│ ├── expo
│ └── next
├── biome.json
├── package.json
├── packages
│ ├── app
│ ├── config
│ └── ui
├── README.md
├── tsconfig.base.json
├── tsconfig.json
├── turbo.json
├── vitest.config.mts
└── yarn.lock
configは、カラーやマージンの設定などでデザイントークンを書いておきます。ここに書いておいたデザイントークンの値は、他のuiやappなどで効いてきます。
uiにはButtonやSelect,フォームパーツやアイコンなど、基本的な部品を置いておきます。
appでuiで作った部品を組み合わせて画面を作っていきます。
next,expoでは、appで作った画面コンポーネントをただそのままimportして、next.js,expoでデプロイするための設定だけにします。
これによって共通のUIを実現できます。具体的なそれぞれの使い方を細かくは今回取り扱わないので、詳しくは公式のドキュメントを見てください!
コンポーネント共通化のしやすさ
Tamagui のコンポーネントは React Native 互換 API をベースにしているため、Web とネイティブのコード分岐を最小限にできます。
import { Button } from 'tamagui'
export const PrimaryButton = () => (
<Button theme="blue" size="$4">
Continue
</Button>
)
このコンポーネントはビルドターゲットに応じて適切な要素に変換されます。
-
Web →
<button>+ 静的 CSS -
iOS/Android →
Pressable+ RNText
「ひとつの UI を複数環境で自然に共有したい」という場面で非常に有効です。
でも共通化したくない部分もある!
そうはいってもWebとNativeでなんでも共通できるわけではないです。Nativeにしかないプッシュ通知の機能やカメラなど、Webでは使えないので共通で使えません。そういう時には、コンポーネントを分けます。
便利なことに,Component.tsxとComponent.native.tsxと拡張子を分けることによって、環境を自動で判断してくれてそのコンポーネントを読み込んでくれます。
import { Text } from "tamagui"
export const Component = () => (
<Text>Web Component</Text>
)
import { Text } from "tamagui"
export const Component = () => (
<Text>Native Component</Text>
)
import { Component } from "./Component"
export const Parent = () => (
<Component/>
)
このようにだけ書けば、native環境の場合は自動でComponent.native.tsxを読み込んでくれます。このようにすれば、react nativeでしか提供されていないようなライブラリも読み込むことができます。
.web.tsx,.native.tsx,.ios.tsx,.android.tsxはそれぞれの環境で優先的に呼び出されます。それらの拡張子が環境で見つからない場合、何も書いてない.tsxが読み込まれるという仕組みになっています。これは実行時に毎回判断されるわけでなく、ビルド時にそれぞれの環境に合わせて最適化されます。
コンポーネント分けるほどでもないんだよなぁ
だからといって、ちょっとしたUI差分のためにコンポーネント分けてたら大変です。
<View height={300} />
と書いた時、Webだと300pxで、Nativeだと300dp(デバイス密度に依存しない単位)になります。実際に見たら、WebとNativeで見た目をちょっと調整したいこともあります。そんなスタイルのちょっとした差分が発生するたびにコンポーネント分けてたら結構めんどくさいです。そこでtamaguiでは、このように書きます。
<View height={300} $platform-native={{ height:200 }}/>
このように書くだけで、Web環境は300px,Native環境は200dpになります。このようにして、環境別にスタイルを当てることもできます。
ルーディングもなんなら共通化したい
WebとNativeでさらに共通化が難しい部分がページ(画面)遷移です。Next.jsではNext.jsのLinkやNavigation Hooksを使ってページ遷移をしますが、ExpoではExpo Routerというライブラリを使って画面の遷移をします。
これをうまく、Next.jsのRouterとExpoのExpo Routerで共通化してくれるSolitoというライブラリを使います。これを使うとうまく二つを抽象化してくれて、Next.jsのLinkなどを使っているような感覚で使えます。
import { Link } from 'solito/link'
export const Component = () => (
<Link href="/" />
)
ちゃんとNext.jsとExpoでファイルパスは一致させてある必要はあります。これでルーティングが共通化できました。
デザインシステムとの相性の良さ
Tamaguiは、サイズや色など独自のデザイントークンを設定できます。以下は公式より持ってきたconfigの設定
import { createTamagui, getConfig } from 'tamagui'
export const config = createTamagui({
// tokens work like CSS Variables (and compile to them on the web)
// accessible from anywhere, never changing dynamically:
tokens: {
// width="$sm"
size: { sm: 8, md: 12, lg: 20 },
// margin="$sm"
space: { sm: 4, md: 8, lg: 12 },
// radius="$none"
radius: { none: 0, sm: 3 },
color: { white: '#fff', black: '#000' },
},
// themes are like CSS Variables that you can change anywhere in the tree
// you use <Theme name="light" /> to change the theme
themes: {
light: {
bg: '#f2f2f2',
color: '#fff',
},
dark: {
bg: '#111',
color: '#000',
},
// sub-themes are a powerful feature of tamagui, explained later in the docs
// user theme like <Theme name="dark"><Theme name="blue">
// or just <Theme name="dark_blue">
dark_blue: {
bg: 'darkblue',
color: '#fff',
},
},
// media query definitions can be used as style props or with the useMedia hook
// but also are added to "group styles", which work like Container Queries from CSS
media: {
sm: { maxWidth: 860 },
gtSm: { minWidth: 860 + 1 },
short: { maxHeight: 820 },
hoverNone: { hover: 'none' },
pointerCoarse: { pointer: 'coarse' },
},
shorthands: {
// <View px={20} />
px: 'paddingHorizontal',
},
// there are more settings, explained below:
settings: {
disableSSR: true,
allowedStyleValues: 'somewhat-strict-web',
},
})
// now, make your types flow nicely back to your `tamagui` import:
type OurConfig = typeof config
declare module 'tamagui' {
interface TamaguiCustomConfig extends OurConfig {}
}
このように設定してあげて、TamaguiProviderでconfigを読み込んであげると、自分で設定したデザイントークンが反映されます。
import { TamaguiProvider, View } from 'tamagui'
import { config } from './tamagui.config.ts'
export default () => (
<TamaguiProvider config={config}>
<View margin="$sm" />
</TamaguiProvider>
)
tamaguiの型をtamagui.config.tsのdeclareで上書きしてあげるおかげで、型安全にトークンを扱うことができます。レスポンシブの境界だって設定できます。VS Codeで配布されているTamagui Intellisenseを使えば補完も効きます。設定したtoken以外を利用すると型エラーになるようになってます。なので非常に型安全です。今は簡易的に1ファイルにまとめてあげてますが、型定義の部分だけは別ファイルに分けてtsconfigで読み込んであげたほうが無難です。
まとめ
Tamaguiを利用することによってWebとNativeのUIを共通化することが簡単にできるようになりました。しかもReactとReact NativeはDOMの部分以外は共通化できるのでReactの資産をとても活かせます。なのでコードをかなり共通化できます。学習コストは最初は少しかかりますが、慣れれば型安全なのもありめちゃくちゃ強力です。tamaguiは非常におすすめなフレームワークです。
ちなみに、tamaguiを利用して、Webとネイティブを含めたアプリ全体をViteベースで管理するフレームワークOne (onestack.dev)というのもあります。まだbeta版ですが、これからどうなっていくのか楽しみです。
参考
