🖌️

GoogleMap上にフリーハンドで線を描画する

2023/09/28に公開

はじめに

スペースマーケットで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
	)
    }
}

動作

Image from Gyazo

線が描けるようになりました!!
ただ、この場合だと丸や円以外も描けるため、囲んで検索の要件には満たない状態です。
次回の記事で、グチャグチャの線を引いても、丸に変わるような方法を紹介したいと思います。

最後に

スペースマーケットでは一緒に働く仲間を募集しています!
カジュアルに話を聞きたいだけという方でも大歓迎ですので、ちょっとでも興味があれば以下からご応募お待ちしております!

▼SRE/インフラエンジニア
https://herp.careers/v1/spmhr/qZ-3RxrPtDgM

▼アプリエンジニア
https://herp.careers/v1/spmhr/m2LlDFRg7Ck0

▼フロントエンドエンジニア
https://herp.careers/v1/spmhr/oZ-mn0UYmzXj

▼エンジニア採用ページ(迷ったらこちらからどうぞ!)

スペースマーケット Engineer Blog

Discussion