React Native でなぞって検索を実装する
はじめに
こんにちは、株式会社カナリーの中野です。
私たちは現在「Canary」というお部屋探しのアプリを作っています。
先日「なぞって検索」機能をリリースしました。
探したい地域をマップ上から指でなぞってお部屋を検索できる機能です。
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
という関数を用意し、MapView の onPanDrag
で発火するようにしました。
これでなぞった軌跡を線として表示する動きは表現できました。
しかしまだ問題がありました。
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
関数の中に仕込み、onPanDragEnd
は handleDraw
内で常に呼ばれるようにしました。
こうすることでなぞっている間は常に onPanDragEnd
が呼ばれますが実行されず、なぞり終わって 500ms 経過すると実行されるようになりました。
debounce
関数はこちらの記事を参考に作りました。
これでなぞり終わった後の図形も表示できるようになりました。
...が、
もう一点問題がありました。
ぐちゃぐちゃになぞられた線を整形する
ユーザーが綺麗になぞってくれるとは限りません。
くるくる回して激しく自己交差が発生することもありそうです。
自己交差が起こると図形内に "穴あき" が発生する可能性があり、その場合検索対象から漏れるのはもちろんのこと、やはり見た目がよろしくないです。
この問題は 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);
};
そして作成した toConvexHullLatLngs
を onPanDragEnd
内で使います。
const onPanDragEnd = useCallback(
debounce((polygonCoordinates: LatLng[]) => {
// 始点と終点を結ぶ
const polygonsCoordinatesConnectingStartAndEndPoints = [
...polygonCoordinates,
polygonCoordinates[0],
];
+ const convexHullLatLngs = toConvexHullLatLngs(
+ polygonsCoordinatesConnectingStartAndEndPoints
+ );
+ setRequestCoordinates(convexHullLatLngs);
- setRequestCoordinates(polygonsCoordinatesConnectingStartAndEndPoints);
setCoordinatesByDraw([]);
}, 500), // delay を 500ms に指定
[]
);
これでぐちゃぐちゃになぞられたとしても綺麗に整形してから図形を表示できるようになりました。
図形の範囲内に物件数のマーカーを表示する
少しだけバックエンドの話も入ります。
ちなみに今まで自分はフロントエンドを中心に生きてきましたが、今回はじめてバックエンドにも挑戦しました。
この取り組みについては以下の記事にて紹介していますので、もしよければこちらも見ていただけると嬉しいです。
Canary では物件情報の検索に Elasticsearch
を使っています。
なぞることで緯度経度の配列を取得できることはわかったので、できればこのまま緯度経度の配列から検索してほしいなと願っていたところ、それを実現できる Geo-polygon query というものが存在したのでこれをそのまま使うことにしました。
(ここの実装についても長い戦いがあったのですが、本記事の内容からかなり脱線するので割愛します)
レスポンスについては物件 ID と物件の緯度経度をセットにしたオブジェクトの配列で返すことにしました。
この情報だけあればマップでマーカーの表示ができそうだったからです。
逆に返す情報が多過ぎるとレイテンシが心配になるので、必要最低限の情報だけ返し、あとはマーカーをタップしたときに追加で物件情報を取得することにしました。
// レスポンスのイメージ
{
id: string;
geo: {
lat: number,
lng: number
};
}
[];
マーカーは Marker で表示します。
Marker
はデフォルトではピンが立ちますが style
を指定できるので、今回のデザイン(円形で中に数字)も Marker
で表現できます。
あとはレスポンスの配列の数だけ Marker
を表示させれば図形内にマーカーを表示できます。
クラスタリング
とはいえ配列の数だけそのまま Marker
で表示してはかなりカオスな状態になります。
求められている仕様は「マップを縮小したときは近い物件同士が結合してその数がマーカー内の数字になり、マップを拡大するとマーカーが分裂する」というものです。
要するにクラスタリングが求められていました。
ここのアルゴリズムを自前で用意するのは工数的にしんどいので何かよさそうなライブラリはないか調べたところいくつか見つかりました。
※ 調査内容はライブラリ設定時(2023 年 2 月)のものです
-
react-native-map-clustering
- https://github.com/venits/react-native-map-clustering
- メンテナンスは 2021 年 1 月で止まっている
- スター数: 595
-
react-native-clusterer
- https://github.com/JiriHoffmann/react-native-clusterer
- 開発は 2023 年 2 月現在もおこなわれている
- スター数: 64
-
react-native-component-map-clustering
- https://github.com/bamlab/react-native-components-collection/tree/master/packages/react-native-component-map-clustering
- メンテナンスは 2018 年で止まっている
- スター数: 39
-
react-native-map-cluster
- https://github.com/laurensk/react-native-map-cluster
- メンテナンスは 2021 年 1 月で止まっている
- スター数: 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
-
MapView
のlayout
値を渡す必要がある - 値は
MapView
のonLayout
で取得できる -
{ width: number, height: number }
という型で渡す
-
-
region
-
MapView
で現在表示している位置のregion
を渡す -
region
はマップをドラッグしたり拡大縮小するたびに変わる値 -
MapView
のonRegionChangeComplete
イベントで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 の配列に変換した response
を onLayout
で取得した layout
、onRegionChangeComplete
で取得した 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;
よって markers
は ClusterOrPoint[]
となります。
クラスタリングのデータはできたので次にこれを Marker
でマップ上に表示します。
Marker
の coordinate
に緯度経度を渡すことでマップ上に表示できますが、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
をアニメーションさせるメソッドは用意されています。
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
に更新(=クラスタリングの更新)があるとアニメーションを挟む余地なくその瞬間にマーカーは新しい位置に飛んでしまうのでもう少し工夫が必要でした。
流れとしてはこのような形です。
-
useClusterer
から吐き出されるmarker
が更新される(=クラスタリングの更新) - アニメーション用の
Marker
コンポーネントが表示され、AnimationMarkerInfo
を元にアニメーションをする - state の値を移動先の緯度経度に更新する
- 静的な
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 はこちらからダウンロードできますので、実際に触っていただけると嬉しいです。
ありがとうございました。
Discussion