🖌️
GoogleMap上にフリーハンドで線を描画する
はじめに
スペースマーケットでAndroidアプリの開発を担当しておりますseoと申します。
今回は、スペースマーケットのアプリv5.3.0以降に搭載された、地図上で丸を描いて検索する「囲んで検索」機能の実現方法について、簡単に紹介したいと思います!
「囲んで検索」機能は大きく分けて、GoogleMap上にフリーハンドで線を描画する機能と描画されたpathを緯度経度情報に変換して検索する機能で実現しています。
こちらの記事では、GoogleMap上にフリーハンドで線を描画する機能にフォーカスしてみたいと思います。
採用した技術
- UI作成にJetpackComposeを採用しています。
- 線の描画にCanvasを使用しています
- 線の描画開始・完了の検知にこちらのライブラリを使っています。
実装
1. ライブラリ追加
最新のVerのライブラリを追加します。
build.gradle(app)
implementation 'com.github.SmartToolFactory:Compose-Extended-Gestures:3.0.0'
implementation "com.google.maps.android:maps-compose:$mapsComposeVer"
implementation "com.google.android.gms:play-services-maps:$mapsVer"
2. GoogleMapの表示
画面上にGoogleMapを表示します。APIキーの設定などは、公式ドキュメントを参照ください。
Box(
modifier = modifier
.fillMaxSize()
.padding(it),
) {
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
uiSettings = MapUiSettings(
compassEnabled = true,
mapToolbarEnabled = false
)
)
}
3. Canvasをセットする
- タップ処理の反応を区別できるよう
MotionEvent
をenum classで定義します。
enum class MotionEvent {
Idle, // お絵描き開始前の状態
Down, // お絵描き開始の状態
Move, // お絵描き中の状態
Up, // お絵描き完了の状態
Done // お絵描き完了・検索完了の状態
}
- 各状態を保存するプロパティを定義します。
// タップの状態
var motionEvent by remember { mutableStateOf(MotionEvent.Idle) }
// なぞったOffsetを格納する
var positions by remember { mutableStateOf<List<Offset>>(listOf()) }
// canvas上に表示する線を格納する
var path by remember { mutableStateOf(Path()) }
// GoogleMap上に表示するPolygon
var polygon by remember { mutableStateOf<List<LatLng>>(listOf()) }
- Canvas上にタップを検知し、なぞった部分が線で表示されるように
modifier
を設定します。
val drawModifier = Modifier
.fillMaxSize()
.clipToBounds()
.background(Color.Transparent)
// Compose-Extended-Gesturesの機能
.pointerMotionEvents(
onDown = { pointerInputChange: PointerInputChange ->
positions = positions + listOf(pointerInputChange.position)
motionEvent = MotionEvent.Down
pointerInputChange.consume()
},
onMove = { pointerInputChange: PointerInputChange ->
// なぞった軌跡をpositionsに追加していく
positions += listOf(pointerInputChange.position)
motionEvent = MotionEvent.Move
pointerInputChange.consume()
},
onUp = { pointerInputChange: PointerInputChange ->
motionEvent = MotionEvent.Up
pointerInputChange.consume()
},
delayAfterDownInMillis = 25L
)
4. Canvas上に線を描く
先ほど定義したMotionEventに応じてそれぞれActionを設定していきます。
Canvas(modifier = drawModifier) {
when (motionEvent) {
MotionEvent.Down -> {
val currentPosition = positions.lastOrNull() ?: Offset.Unspecified
path.moveTo(currentPosition.x, currentPosition.y)
}
MotionEvent.Move -> {
val previousPosition = positions.takeLast(2).firstOrNull() ?: Offset.Unspecified
val currentPosition = positions.lastOrNull() ?: Offset.Unspecified
// positionsの軌跡をベジェ曲線として描画していきます
path.quadraticBezierTo(
previousPosition.x,
previousPosition.y,
(previousPosition.x + currentPosition.x) / 2,
(previousPosition.y + currentPosition.y) / 2
)
}
MotionEvent.Up -> {
// positionsに格納されているOffsetをLatLng型に変換します
val latLngList = positions.mapNotNull {
val xRounded = it.x.roundToInt()
val yRounded = it.y.roundToInt()
val xyPoints = Point(xRounded, yRounded)
cameraPositionState.projection?.fromScreenLocation(xyPoints)
}
// LatLngに変換した配列をGoogleMapのPolygonに渡すことで、図形はGoogleMap上に表示し、Canvas上のpathは初期化します。
polygon = latLngList
path = Path()
motionEvent = MotionEvent.Done
}
else -> Unit
}
drawPath(
color = Colors.black,
path = path
)
}
5. 最終的なコード
enum class MotionEvent {
Idle,
Down,
Move,
Up,
Done
}
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(cameraPosition, Const.GoogleMap.DEFAULT_MAP_ZOOM)
}
var motionEvent by remember { mutableStateOf(MotionEvent.Idle) }
var positions by remember { mutableStateOf<List<Offset>>(listOf()) }
var path by remember { mutableStateOf(Path()) }
var polygon by remember { mutableStateOf<List<LatLng>>(listOf()) }
Box(
modifier = modifier
.fillMaxSize()
.padding(it),
) {
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
uiSettings = MapUiSettings(
compassEnabled = true,
mapToolbarEnabled = false
)
)
Canvas(modifier = drawModifier) {
when (motionEvent) {
MotionEvent.Down -> {
val currentPosition = positions.lastOrNull() ?: Offset.Unspecified
path.moveTo(currentPosition.x, currentPosition.y)
}
MotionEvent.Move -> {
val previousPosition = positions.takeLast(2).firstOrNull() ?: Offset.Unspecified
val currentPosition = positions.lastOrNull() ?: Offset.Unspecified
path.quadraticBezierTo(
previousPosition.x,
previousPosition.y,
(previousPosition.x + currentPosition.x) / 2,
(previousPosition.y + currentPosition.y) / 2
)
}
MotionEvent.Up -> {
val latLngList = positions.mapNotNull {
val xRounded = it.x.roundToInt()
val yRounded = it.y.roundToInt()
val xyPoints = Point(xRounded, yRounded)
cameraPositionState.projection?.fromScreenLocation(xyPoints)
}
polygon = latLngList
path = Path()
motionEvent = MotionEvent.Done
}
else -> Unit
}
drawPath(
color = Colors.black,
path = path
)
}
}
動作
線が描けるようになりました!!
ただ、この場合だと丸や円以外も描けるため、囲んで検索の要件には満たない状態です。
次回の記事で、グチャグチャの線を引いても、丸に変わるような方法を紹介したいと思います。
最後に
スペースマーケットでは一緒に働く仲間を募集しています!
カジュアルに話を聞きたいだけという方でも大歓迎ですので、ちょっとでも興味があれば以下からご応募お待ちしております!
▼SRE/インフラエンジニア
▼アプリエンジニア
▼フロントエンドエンジニア
▼エンジニア採用ページ(迷ったらこちらからどうぞ!)
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion