初めてのExpoで東京のクリスマスイベントマップを表示してみる 🎄


:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

普段はVue3（Nuxt3）をメインでWeb開発をしています。
今回はReact NativeをベースとしたExpoというフレームワークを試してみます。
（Web / Androidでの動作を確認します）


## :lollipop: プロジェクトの作成

今回はクリスマスイベントスポット（東京）の表示を試してみます（申し訳程度のクリスマス要素）
イベント情報はただ力技で調べました 🙈

```
npx create-expo-app@latest example-expo-chiristmas-app
npx expo start
```

これだけでAndroid + Webの開発が可能な状態になりました :clap: 

確認できたところでひとまず`npm run reset-project`をしておきます

### 📱 Android 実機で確認する

Expo Goというアプリを利用してQRコードを読み込むだけで開発中の環境にアクセスできます
https://expo.dev/go
Expo Goは、Expo専用のサンドボックス環境で、よく使用されるライブラリは予めプリインストールされているので、開発を素早く始めることができます :dash: 

このページでExpo Goの ✅ が入っているものがプリインストールされているようです。
https://reactnative.directory/

暫くの開発はこれで十分そうです！
ここまで確認出来たら`npm run reset-project`を実行します。
（最初の`app`ディレクトリを`app-example`に移動することができます）



## :christmas_tree:  マップを表示する

webとネイティブアプリで別の方法を用いて実装します。
Expo Routerの機能でwebとネイティブアプリ用に実装を分けることができます。

```
components
├── map.native.tsx
└── map.web.tsx
```

`.native.tsx`や`.web.tsx`のような拡張子をつけることでプラットフォームを元に出し分けが可能です。
appディレクトリでは、この拡張子はされてないため、別途componentsディレクトリを作成します。
実際切り分けて表示するにはレイアウト側でのエクスポートが必要です。
```tsx:app/(tabs)/map.tsx
export { default } from './components/map';
```

### :globe_with_meridians: Webの表示

お手軽に[Googleマイマップ](https://www.google.co.jp/intl/ja/maps/about/mymaps/)を利用して、`iframe`で埋め込みます。

マイマップでイベントスポットのピンを挿したら、「自分のサイトに埋め込む」からiframeタグを取得します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279880/d0ee8ef2-8dd2-b873-1dce-69e77414fa3c.png" width="30%">

そのまま`iframe`を設置したのみでは、初期位置が好ましくありませんでした。
初期位置の指定として、`navigator.geolocation`を利用して、現在地を中心にします。
（`navigator.geolocation`を呼び出すとブラウザから位置情報の取得権限がリクエストされるはずです）
位置情報の取得が許可されなかった場合は、皇居を中央にするとします。
中心の緯度/経度を`ll`パラメータで指定できるので、セットします。
また、縮尺については`z`パラメータで指定できるようなので、併せて微調整します。

```tsx:component/map.web.tsx
import { useState, useEffect } from "react";

// 皇居の緯度、経度
const INITIAL_LATITUADE = 35.68546261882921
const INITIAL_LONGITUDE = 139.75286760095705

export default function Map() {

  const [location, setLocation] = useState<{latituade: number, longitude: number} | null>(null);
  const [iframeUrl, setIframeUrl] = useState(`https://www.google.com/maps/d/u/0/embed?mid=1BxutSC-ayz-IgADpmBl7WJfnqHsJiTU&ehbc=2E312F&noprof=1&z=14&ll=${INITIAL_LATITUADE},${INITIAL_LONGITUDE}`);

  useEffect(() => {
    if ("geolocation" in navigator) {
      navigator.geolocation.getCurrentPosition((position) => {
      // 現在地をセットする
      setLocation({ latituade: position.coords.latitude, longitude: position.coords.longitude })
      })
    }
  }, [])

  useEffect(() => {
    const url = new URL(iframeUrl);
    const searchParams = url.searchParams;
    if (location) {
      // 緯度、経度をパラメータに再設定する
      searchParams.set('ll', `${location.latituade},${location.longitude}`);
    }
    setIframeUrl(url.toString())
  }, [location])

  return (
    <div style={styles.container}>
      <iframe style={styles.map} src={iframeUrl}></iframe>
    </div>
  );
}

const styles = {
  container: {
    width: '100%',
    height: '100%',
    backgroundColor: '#fff',
  },
  map: {
    width: '100%',
    height: 'calc(100% + 46px)',
    marginTop: -46, // 見栄えのためにiframeにヘッダーを隠す
  },
};
```

### 📱 ネイティブアプリの表示
マップの表示には、`react-native-mapview`を利用しました。
基本的には`<MapView>`コンポーネント内で地図を表示、`<Marker>`コンポーネントに緯度/経度をセットすることで、イベントスポットの表示ができました。
（ちなみにウェブで作成したマイマップをCSVでexportして緯度/経度を取ってきました）
初期位置の指定も同様に行いたいので、`expo-location`を用いることにします。
位置情報の取得権限をリクエストして、`<MapView>`コンポーネントの`region`で利用する形にしています。

```tsx:component/map.native.tsx
import { useState, useEffect } from 'react';
import { View, StyleSheet } from "react-native";
import MapView, { Marker } from 'react-native-maps';

import * as Location from 'expo-location';

const INITIAL_LATITUADE = 35.68546261882921;
const INITIAL_LONGITUDE = 139.75286760095705;
const LATITUADE_DELTA = 0.04;
const LONGITUDE_DELTA = 0.04;

const POSITIONS = [
  {longitude: 139.7292319, latitude: 35.6607397, name: '六本木ヒルズ'},
  {longitude: 139.7308747, latitude: 35.6659803, name: '東京ミッドタウン'},
  {longitude: 139.7182668, latitude: 35.67543990000001, name: '明治神宮外苑'},
  {longitude: 139.7638027, latitude: 35.6810403, name: '丸の内ビルディング'},
  {longitude: 139.8115747, latitude: 35.7102333, name: '東京ソラマチ'},
  {longitude: 139.7734312, latitude: 35.7147557, name: '上野恩賜公園'},
  // ※省略します
]

export default function Map() {

  const [location, setLocation] = useState<Location.LocationObject | null>(null);

  useEffect(() => {
    async function getCurrentLocation() {
      
      let { status } = await Location.requestForegroundPermissionsAsync();
      if (status !== 'granted') {
        return;
      }

      let location = await Location.getCurrentPositionAsync({});
      setLocation(location);
    }

    getCurrentLocation();

  }, []);

  return (
    <View style={styles.container}>

      <MapView
        style={styles.map}
        provider={PROVIDER_GOOGLE}
        initialRegion={{
          latitude: INITIAL_LATITUADE,
          longitude: INITIAL_LONGITUDE,
          latitudeDelta: LATITUADE_DELTA,
          longitudeDelta: LONGITUDE_DELTA,
        }}
        region={{
          latitude: location?.coords.latitude ?? INITIAL_LATITUADE,
          longitude: location?.coords.longitude ?? INITIAL_LONGITUDE,
          latitudeDelta: LATITUADE_DELTA,
          longitudeDelta: LONGITUDE_DELTA,
        }}
      >
        {POSITIONS.map((pos, index) => 
          <Marker
            key={index}
            coordinate={{
              latitude: pos.latitude,
              longitude: pos.longitude,
            }}
            title={pos.name}
          />
        )}
      </MapView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    width: '100%',
    height: '100%',
    backgroundColor: '#fff',
  },
  map: {
    width: '100%',
    height: '100%',
  },
});
```


|web|アプリ(Android)|
|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279880/75607bec-7d5e-1551-9674-b9ea84db9be9.png" width="100%">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/279880/5f11789d-0c9a-3e76-d8df-87dc41e17f96.png" width="50%">|


## 💭 感想
- なんとなくネイティブアプリは実装も検証も大変そう...な印象でしたが、Expo Goでの開発体験は革命的でした :smiley: 
- Androidアプリの開発ビルドも試しましたが、EAS Buildでお手軽でした（ただし無料枠だと月30回までのようです）


## 参考
https://docs.expo.dev/router/advanced/platform-specific-modules/
https://docs.expo.dev/versions/latest/sdk/map-view/
https://docs.expo.dev/versions/latest/sdk/location/

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。