📍

React Native でなぞって検索を実装する

2023/05/17に公開

はじめに

こんにちは、株式会社カナリーの中野です。
私たちは現在「Canary」というお部屋探しのアプリを作っています。

先日「なぞって検索」機能をリリースしました。
探したい地域をマップ上から指でなぞってお部屋を検索できる機能です。

https://youtu.be/zQ-z6OMhM_U?feature=shared

Canary は React Native で開発しています。
今回はこの「なぞって検索」機能を React Native でどのように実装したのかのお話をしたいと思います。

話すこと

  • react-native-maps を駆使してなぞって検索を表現したこと
  • react-native-clusterer というクラスタリング用のライブラリが素晴らしいという話
  • クラスタリングに頑張ってアニメーションをつけた話

話さないこと

  • バックエンドについてはこの記事では深く話さないです

環境

  • react-native: 0.69.6
  • react-native-maps: 0.31.0
  • react-native-clusterer: 1.2.2

なぞって検索の仕様

実装の説明をする前に仕様についておさらいです。以下がざっくり求められた仕様です

  • マップ上を指でなぞると線をひける
  • 指を離すと始点と終点が繋がり図形になる
  • 図形の範囲内に物件数のマーカーが表示される
  • 拡大、縮小するとマーカーが分裂、結合する(クラスタリング)
  • 分裂、結合するときはアニメーションをつける
  • マーカーをタップすると物件情報が表示される


仕様策定時のデザイン

実装

正直なところ最初この仕様を見たときに React Native でどう実装するのかまったくイメージがつかなかったので、プロトタイプ制作期間を設けてもらい、どうやったら実現できそうか手を動かしながら調査を進めました。

マップの表示

react-native-maps で表示する方針で調査を進めました。
理由は Canary 内で既存のマップ表示に react-native-maps を使用していたからです。
なので react-native-maps でなぞる線や図形をデザイン通りに表現できるかどうかの調査を進めました。

Polyline でなぞる線を表現する

なぞる線は Polyline で表現できそうでした。
coordinates に緯度経度の配列(Array<LatLng>)を渡すことでマップ上に緯度経度の点を繋いだ線を表示できます。

しかし今回は事前に緯度経度がわかっているわけではなく、指でなぞった軌跡をそのまま線で表示してほしいので、Polyline で表示するには

  • なぞりながら指で触れた箇所の緯度経度を取得する
  • 取得した緯度経度を Poyline に渡す

という流れになりそうです。
これを実現するために以下のように実装しました。

const [coordinatesByDraw, setCoordinatesByDraw] = useState<LatLng[]>([]);

const handleDraw = useCallback((event: MapEvent) => {
  const { coordinate } = event.nativeEvent;
  setCoordinatesByDraw((prev) => [...prev, coordinate]);
}, []);

return (
  <>
    <MapView onPanDrag={handleDraw}>
      {coordinatesByDraw.length ? (
        <Polyline coordinates={coordinatesByDraw} />
      ) : null}
    </MapView>
  </>
);

緯度経度を取得する handleDraw という関数を用意し、MapViewonPanDrag で発火するようにしました。
これでなぞった軌跡を線として表示する動きは表現できました。

しかしまだ問題がありました。
onPanDrag のイベントが多過ぎるために古い端末ではレンダリングが追いつかず、カクついたり線が表示されなかったりフリーズなどの症状が発生しました。

これを解決するため以下のように修正しました。

const [coordinatesByDraw, setCoordinatesByDraw] = useState<LatLng[]>([]);
+ const mapEventIndex = useRef(0); // なぞった線を表示するパフォーマンス調整用のindex

const handleDraw = useCallback((event: MapEvent) => {
+  // パフォーマンス安定のため一定間隔でスキップしている
+  mapEventIndex.current++;
+  if (mapEventIndex.current % 7 === 0) return;

  const { coordinate } = event.nativeEvent;
  setCoordinatesByDraw((prev) => [...prev, coordinate]);
}, []);

return (
  <>
    <MapView onPanDrag={handleDraw}>
      {coordinatesByDraw.length ? (
        <Polyline coordinates={coordinatesByDraw} />
      ) : null}
    </MapView>
  </>
);

ドキュメントを見ても throttle っぽいオプションは見当たらなかったので、やや原始的ですが一定間隔で処理をスキップするように書いて解決しました。
(この "一定間隔" は古い端末を触りながらいい塩梅な数字を地道に探しました。イベントを間引きすぎると線のなめらかさが失われるので地味に難しい作業でした、、)

Polygon でなぞり終わった後に表示する図形を表現する

なぞり終わると始点と終点を結ぶ図形を表示するのですが、この図形は Polygon で表現できそうです。
こちらも Polyline と同様 coordinates に緯度経度の配列を渡すとマップ上に図形を表示できます。

ここまではどう実現すればいいのかを簡単にイメージできたのですが、問題は "なぞり終わった" をどう判別するかでした。
MapView のドキュメントを上から下まで調べましたが、onPanDrag はあっても onPanDragEnd なるイベントが存在しなかったからです。

これは解決方法に悩みましたが、結論としては自前の debounce 関数を用意して、一定時間 onPanDrag イベントが発生しなければ "なぞり終わった" と判別することにしました。

const [coordinatesByDraw, setCoordinatesByDraw] = useState<LatLng[]>([]);
+ const [requestCoordinates, setRequestCoordinates] = useState<LatLng[]>([]); // なぞり終わった緯度経度を保持する

+ const onPanDragEnd = useCallback(
+   debounce((polygonCoordinates: LatLng[]) => {
+     // 始点と終点を結ぶ
+     const polygonsCoordinatesConnectingStartAndEndPoints = [
+       ...polygonCoordinates,
+       polygonCoordinates[0],
+     ];
+
+     setRequestCoordinates(polygonsCoordinatesConnectingStartAndEndPoints);
+     setCoordinatesByDraw([]);
+   }, 500), // delay を 500ms に指定
+   []
+ );

const handleDraw = useCallback(
  (event: MapEvent) => {
    // パフォーマンス安定のため一定間隔でスキップしている
    mapEventIndex.current++;
    if (mapEventIndex.current % 7 === 0) return;

    const { coordinate } = event.nativeEvent;
    setCoordinatesByDraw((prev) => [...prev, coordinate]);

+     onPanDragEnd([...coordinatesByDraw, coordinate]); // handleDraw 内で onPanDragEnd を実行する
  },
  [coordinatesByDraw]
);

return (
  <>
    <MapView onPanDrag={handleDraw}>
      {coordinatesByDraw.length ? (
        <Polyline coordinates={coordinatesByDraw} />
      ) : null}

+       {requestCoordinates.length ? (
+         <Polygon coordinates={requestCoordinates} />
+       ) : null}
    </MapView>
  </>
);

debounce 関数を用意して onPanDragEnd 関数の中に仕込み、onPanDragEndhandleDraw 内で常に呼ばれるようにしました。
こうすることでなぞっている間は常に onPanDragEnd が呼ばれますが実行されず、なぞり終わって 500ms 経過すると実行されるようになりました。

debounce 関数はこちらの記事を参考に作りました。
https://qiita.com/suin/items/6d440e65e85c67976414

これでなぞり終わった後の図形も表示できるようになりました。

...が、
もう一点問題がありました。

ぐちゃぐちゃになぞられた線を整形する

ユーザーが綺麗になぞってくれるとは限りません。
くるくる回して激しく自己交差が発生することもありそうです。
自己交差が起こると図形内に "穴あき" が発生する可能性があり、その場合検索対象から漏れるのはもちろんのこと、やはり見た目がよろしくないです。

この問題は monotone-convex-hull-2d で解決しました。
これは緯度経度の配列を凸包にして返してくれるライブラリです。

注意点としてはライブラリ内で緯度経度を[number, number]という型で扱っているので、react-native-maps で取得した緯度経度の型(LatLng)は[number, number]に変換してから渡す必要があることです。

それを考慮して以下の関数を作成しました。
JavaScript 製のかなり古いライブラリなので monotone-convex-hull-2d.d.ts という型定義ファイルも用意します。

// monotone-convex-hull-2d.d.ts
declare module "monotone-convex-hull-2d" {
  export default function convexhull<T extends [number, number]>(
    points: T[]
  ): number[];
}
import convexhull from "monotone-convex-hull-2d";

type Coordinates = [number, number];

// LatLng[] を [number, number][] に変換する
const toCoordinatesFromLatLngs = (coordinates: LatLng[]): Coordinates[] => {
  return coordinates.map((coordinate) => [
    coordinate.longitude,
    coordinate.latitude,
  ]);
};

// [number, number][] を LatLng[] に変換する
const toLatLngsFromCoordinates = (coordinates: Coordinates[]): LatLng[] => {
  return coordinates.map((coordinate) => ({
    latitude: coordinate[1],
    longitude: coordinate[0],
  }));
};

// LatLng[] を受け取り、[number, number][]に変換し、convexhullを実行し、結果を LatLng[] に変換して返す
export const toConvexHullLatLngs = (polygonCoordinates: LatLng[]): LatLng[] => {
  const coordinates = toCoordinatesFromLatLngs(polygonCoordinates);
  const figuresIndexes = convexhull(coordinates);
  const figures = [...figuresIndexes.map((index) => coordinates[index])].filter(
    (vertex) => vertex
  );
  return toLatLngsFromCoordinates(figures);
};

そして作成した toConvexHullLatLngsonPanDragEnd 内で使います。

const onPanDragEnd = useCallback(
  debounce((polygonCoordinates: LatLng[]) => {
    // 始点と終点を結ぶ
    const polygonsCoordinatesConnectingStartAndEndPoints = [
      ...polygonCoordinates,
      polygonCoordinates[0],
    ];

+    const convexHullLatLngs = toConvexHullLatLngs(
+      polygonsCoordinatesConnectingStartAndEndPoints
+    );

+    setRequestCoordinates(convexHullLatLngs);
-    setRequestCoordinates(polygonsCoordinatesConnectingStartAndEndPoints);
    setCoordinatesByDraw([]);
  }, 500), // delay を 500ms に指定
  []
);

これでぐちゃぐちゃになぞられたとしても綺麗に整形してから図形を表示できるようになりました。

図形の範囲内に物件数のマーカーを表示する

少しだけバックエンドの話も入ります。
ちなみに今まで自分はフロントエンドを中心に生きてきましたが、今回はじめてバックエンドにも挑戦しました。
この取り組みについては以下の記事にて紹介していますので、もしよければこちらも見ていただけると嬉しいです。
https://zenn.dev/bluage_techblog/articles/995302d2597065

Canary では物件情報の検索に Elasticsearch を使っています。
なぞることで緯度経度の配列を取得できることはわかったので、できればこのまま緯度経度の配列から検索してほしいなと願っていたところ、それを実現できる Geo-polygon query というものが存在したのでこれをそのまま使うことにしました。

(ここの実装についても長い戦いがあったのですが、本記事の内容からかなり脱線するので割愛します)

レスポンスについては物件 ID と物件の緯度経度をセットにしたオブジェクトの配列で返すことにしました。
この情報だけあればマップでマーカーの表示ができそうだったからです。
逆に返す情報が多過ぎるとレイテンシが心配になるので、必要最低限の情報だけ返し、あとはマーカーをタップしたときに追加で物件情報を取得することにしました。

// レスポンスのイメージ
{
  id: string;
  geo: {
    lat: number,
    lng: number
  };
}
[];

マーカーは Marker で表示します。
Marker はデフォルトではピンが立ちますが style を指定できるので、今回のデザイン(円形で中に数字)も Marker で表現できます。
あとはレスポンスの配列の数だけ Marker を表示させれば図形内にマーカーを表示できます。

クラスタリング

とはいえ配列の数だけそのまま Marker で表示してはかなりカオスな状態になります。
求められている仕様は「マップを縮小したときは近い物件同士が結合してその数がマーカー内の数字になり、マップを拡大するとマーカーが分裂する」というものです。
要するにクラスタリングが求められていました。

ここのアルゴリズムを自前で用意するのは工数的にしんどいので何かよさそうなライブラリはないか調べたところいくつか見つかりました。

※ 調査内容はライブラリ設定時(2023 年 2 月)のものです

この中で自分は react-native-clusterer を使うことにしました。
選定理由はこちらです。

  • 現在も開発が続いている

  • useClusterer という hooks が用意されていて扱いやすそう

  • この紹介記事を読む感じ一番パフォーマンスに優れていそう

    ※ DeepL 訳
    
    2018年にFacebookがReact Nativeの再アーキテクチャを発表し、2021年にようやくReact Nativeの公式リリースでその新しいエンジンのサポートを見ることができるようになりました。
    新しいアーキテクチャの一部として、JSI(Javascript Interface)という、通信を助けるレイヤーがあります。
    JSIを使うメリットの1つは、JavaScriptがC++ Host Objectsへの参照を保持し、そのメソッドを呼び出せるようになることです。
    この変化に触発されて、私は新しいクラスタリングライブラリを作りました。
    目標は、Superclusterの可能な限り近い代替品を作ることでしたが、JavaScriptの代わりにC++を使うことで、より性能の高いバージョンを作りました。
    
    react-native-clusterer はクラスタの初期化を最大 10 倍高速化し、useClusterer フックのような新しいプロジェクトのセットアップを簡略化する追加機能を提供します。
    さらに、react-native-maps に依存しないため、いつでもいずれかのライブラリを更新したり、全く別のライブラリを使用したりすることが容易になります。
    

では実際に触っていきます。
useClusterer という hooks には以下の 3 つの引数を渡すことでクラスタリングしたデータを返してくれます。

  • data
    • クラスタリングしてほしいデータを渡す
    • GeoJSON 形式の配列でデータを渡す必要がある
  • mapDimensions
    • MapViewlayout 値を渡す必要がある
    • 値は MapViewonLayout で取得できる
    • { width: number, height: number } という型で渡す
  • region
    • MapView で現在表示している位置の region を渡す
    • region はマップをドラッグしたり拡大縮小するたびに変わる値
    • MapViewonRegionChangeComplete イベントで region の更新を取得し、useClusterer に渡す形となる

つまりこの hooks は region を監視して、変更があれば(= 移動や拡大、縮小がされれば)よしなにデータをクラスタリングをして、指定された layout に収まるように返してくれるものです。
オプショナルの第 4 引数に { extent: number } を指定することでクラスタリングの粒度も調整できます。

data は GeoJson 形式で渡す必要があるので、あらかじめ GeoJson という型を用意しておくと便利です。

import type { GeoJsonProperties } from "geojson";
import type { supercluster } from "react-native-clusterer";

export type GeoJSON =
  | supercluster.PointFeature<GeoJsonProperties>
  | supercluster.ClusterFeatureClusterer<GeoJsonProperties>;

GeoJSON への変換用の関数も用意します。

export const toGeoJSON = (response: Response): GeoJSON => ({
  type: "Feature",
  geometry: {
    type: "Point",
    coordinates: [response.geo.lng, response.geo.lat],
  },
  properties: {
    id: response.id,
  },
});

GeoJSON の配列に変換した responseonLayout で取得した layoutonRegionChangeComplete で取得した region と共に useClusterer に渡すとクラスタリングしたデータを返してくれます。

const geoJSONs = response.map(toGeoJSON);

// この markers がクラスタリングされたデータ
const [markers, supercluster] = useClusterer(geoJSONs, layout, region);

markers にクラスタリングされたデータが入ります。

このあたりも型で表現できると扱いやすいので ClusterOrPoint という type を用意しました。

import type { supercluster } from "react-native-clusterer";

type Cluster = supercluster.ClusterFeatureClusterer<supercluster.AnyProps>;
type Point = supercluster.PointFeature<{ [name: string]: any } | null>;

export type ClusterOrPoint = Cluster | Point;

よって markersClusterOrPoint[] となります。

クラスタリングのデータはできたので次にこれを Marker でマップ上に表示します。
Markercoordinate に緯度経度を渡すことでマップ上に表示できますが、Marker が求める型は LatLng なので変換用の関数が必要です。

export const getCoordinates = (marker: ClusterOrPoint): LatLng => {
  return {
    latitude: marker.geometry.coordinates[1],
    longitude: marker.geometry.coordinates[0],
  };
};

そしてマーカー内にはクラスタリングの数字を表示する必要があります。
Cluster の場合 marker 内に point_count という値を持っているので、それを抽出する関数も用意します。

export const getPointCountString = (marker: ClusterOrPoint) => {
  if (marker.properties?.point_count) {
    return String(marker.properties?.point_count);
  }
  return "1";
};

そして以下のようにして Marker を表示させます。
style の記述は省略しています。

<MapView
  onPanDrag={handleDraw}
  onRegionChangeComplete={onRegionChangeComplete}
  onLayout={onLayout}
>
  {coordinatesByDraw.length ? (
    <Polyline coordinates={coordinatesByDraw} />
  ) : null}

  {requestCoordinates.length ? (
    <Polygon coordinates={requestCoordinates} />
  ) : null}

  {markers.map((marker) => (
    <Marker key={marker.id} coordinate={getCoordinates(marker)}>
      <View>
        <Text text={getPointCountString(marker)} />
      </View>
    </Marker>
  ))}
</MapView>

これでクラスタリングはできました。

クラスタリングにアニメーションをつける

クラスタリング自体はできましたが、現状だとマーカーが結合や分裂をするときにアニメーションがないので、各マーカーがそれぞれどの場所から移動したのかがわかりません。
現在の位置から新しい位置へ移動する際のアニメーションが求められました。

しかし react-native-clusterer ではアニメーションまではサポートされていないので、ここは気合いで頑張る必要があります。

ちなみに react-native-maps では Marker をアニメーションさせるメソッドは用意されています。
https://github.com/react-native-maps/react-native-maps#animated-marker-position

iOS と Android でメソッドは異なりますが、共通していることは移動前の緯度経度と移動先の緯度経度を指定することです。
ですので Marker の移動前と移動先の coordinates を指定すればアニメーション自体は完成なのですが、そう簡単ではありませんでした。
react-native-clusterer が吐き出すクラスタリングのデータは移動先の coordinates はありますが、移動前の coordinates までは含まれていなかったからです。

なんとか解決方法を導き出せたのですが、これを順を追ってコードで説明するととてつもなく長くなってしまうので、どう解決したかの手法だけ説明します。

全マーカーの移動前 coordinates を保持する state を用意する

欲しいデータが含まれていないのであれば state を用意して管理するしかありません。
マーカーがCluster の場合は中身の Point の情報をすべて抜き出して保存します。

移動前、移動先の緯度経度セットを用意する

このような型でアニメーション用のデータセットを作成します。

export type AnimationMarkerInfo = {
  prevCoodinates: number[]; // state で保存している移動前の緯度経度
  nextCoodinates: number[]; // 移動先の緯度経度
  pointCount: number; // マーカー内の数字
}[];

アニメーション用の Marker と静的な Marker を交互に表示して自然なアニメーションに見せる

useClusterer から吐き出される marker に更新(=クラスタリングの更新)があるとアニメーションを挟む余地なくその瞬間にマーカーは新しい位置に飛んでしまうのでもう少し工夫が必要でした。
流れとしてはこのような形です。

  1. useClusterer から吐き出される marker が更新される(=クラスタリングの更新)
  2. アニメーション用の Marker コンポーネントが表示され、AnimationMarkerInfo を元にアニメーションをする
  3. state の値を移動先の緯度経度に更新する
  4. 静的な Marker コンポーネントを表示する(移動先の緯度経度)

1 つの Marker コンポーネントで頑張るのではなく、アニメーション用の Marker と静的な Marker を交互に表示することで自然なアニメーションを表現しました。

マーカーをタップしたら物件情報を表示する

ここはとてもシンプルで Marker コンポーネントには onPress イベントが用意されているので、マーカーが押されたときに何をしてほしいのかの処理を渡せば OK です。
実際は物件を表示するだけでなく、Point なのか Cluster なのか、Cluster の場合は何件
Point を持っているのか、などによって処理が分岐するので、以下のような関数を別で作って対応しました。

// クラスターかどうか
export const isCluster = (marker: ClusterOrPoint): boolean =>
  marker.properties?.cluster_id !== undefined;

// クラスター内のPointを取得
export const getChildrenPoint = (
  marker: Cluster,
  supercluster: Supercluster
): Point[] => {
  const clusterId = Number(marker.properties?.cluster_id);
  if (isNaN(clusterId)) return [];
  return supercluster.getLeaves(clusterId, Infinity);
};

// クラスターが何件Pointを持っているかを取得
export const getChildrenCount = (marker: Cluster): number | undefined => {
  const childrenCount = Number(marker.properties?.point_count);
  if (isNaN(childrenCount)) {
    return undefined;
  }
  return childrenCount;
};

振り返り

どう実装すべきかまったくイメージがつかない中でプロトタイプを作りながら調査する期間はかなり刺激的でした。
react-native-maps でどこまで表現できるか不安な状態でスタートしましたが、思っていた以上に Props や Event が豊富で感動しました。
個人的にはバックエンドにも挑戦できて成長を感じた開発期間でした。

勢いで書きましたけど正直マップをここまで使い倒すことはそう多くないと思いますし、クラスタリングが必要になることもかなりレアケースだと思いますが、もし必要に迫られた場合は参考になれば幸いです。
そしてクラスタリングは react-native-clusterer がパフォーマンス良いですし使いやすいのでオススメです。

Canary はこちらからダウンロードできますので、実際に触っていただけると嬉しいです。
ありがとうございました。

https://apps.apple.com/jp/app/賃貸物件検索-カナリー-canary-物件探しアプリ/id1436484080

https://play.google.com/store/apps/details?id=com.bluage.canary&hl=ja&gl=US

Canary Tech Blog

Discussion