【Map Compose】 デバイスの現在地と方位を取得してGoogle Map公式アプリみたいな現在地マーカーを表示する

デフォルトのGoogle Map Composeでも[isMyLocationEnabled](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-map-properties/is-my-location-enabled.html)をtrueにすれば現在地マーカーは表示されるのですが方位矢印の動きがカクカクなのが嫌だったので自分で実装することにしました。
![](https://storage.googleapis.com/zenn-user-upload/8aabb4009c4c-20241005.gif)


# 完成系

今回作ったやつとGoogleMap公式アプリで同じ場所を歩いて比較してみました(個人情報笑
多分問題なさそう。

| 作ったもの | GoogleMap公式アプリ |
| ---- | ---- |
| ![](https://storage.googleapis.com/zenn-user-upload/85e0a6142d56-20241004.gif)| ![](https://storage.googleapis.com/zenn-user-upload/a9808510d995-20241004.gif)|

# 位置情報の取得
## 権限の取得
[Accompanist](https://google.github.io/accompanist/permissions/)を使います

```kotlin
@OptIn(ExperimentalPermissionsApi::class)
  @Composable
  private fun LocationPermissionEffect(permissionState: MultiplePermissionsState) {
    LaunchedEffect(permissionState) {
      snapshotFlow {
        permissionState.allPermissionsGranted
      }
        .distinctUntilChanged()
        .collect {
        if (!permissionState.allPermissionsGranted) {
          permissionState.launchMultiplePermissionRequest()
        }
        locationManager.updateLocationPermissionStatus(it)
      }
    }
  }
```

## 現在地の取得
[Fused Location Provider API](https://developers.google.com/location-context/fused-location-provider)を使います。
位置情報の更新は[requestLocationUpdates](https://developer.android.com/reference/android/location/LocationManager#requestLocationUpdates%28java.lang.String,%20long,%20float,%20android.location.LocationListener,%20android.os.Looper%29)にcallbackを渡すことで受け取れるようになります。
flowで受け取れるようにしたいので [callbackFlow](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/callback-flow.html)でflowに変換します。
`requestLocationUpdates`の第3引数の`Looper`はなにも指定しないとデフォルトは実行元のスレッド（ここだとメイン）のルーパーになるので、別スレッドの`Looper`を渡してバックグラウンドで更新されるようにします。
※アプリがバックグラウンドの時も位置情報を取得したい場合は[Service](https://developer.android.com/develop/background-work/background-tasks?hl=ja#foreground-services)を利用する必要があります
今回は、`lastLocation` を使ってますが、`locations` を利用すると前回取得した地点からすべての位置情報が返ってくるのでもっとスムーズにマーカーを移動させたい場合はこちらを使うのがいいと思います。

```kotlin
@ApplicationScope
@Inject
class LocationManager(context: Context, private val externalScope: CoroutineScope) {
  private val locationClient: FusedLocationProviderClient =
    LocationServices.getFusedLocationProviderClient(context)
  private val locationRequest = LocationRequest.Builder(1000)
    .setPriority(Priority.PRIORITY_HIGH_ACCURACY)
    .build()

  private val _hasLocationPermission = MutableStateFlow(false)

  @OptIn(ExperimentalCoroutinesApi::class)
  @SuppressLint("MissingPermission")
  val location = _hasLocationPermission.flatMapLatest { hasPermission ->
    if (!hasPermission) {
      flowOf(null)
    } else callbackFlow {
      val locationCallback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) {
          result.lastLocation?.let {
            trySend(it)
          }
        }
      }
      val handlerThread = HandlerThread("location").apply { start() }
      locationClient.requestLocationUpdates(
        locationRequest,
        locationCallback,
        handlerThread.looper
      ).addOnFailureListener {
        cancel("Error requesting location updates", it)
      }
      awaitClose {
        handlerThread.quit()
        locationClient.removeLocationUpdates(locationCallback)
      }
    }.distinctUntilChanged()
      .shareIn(
        externalScope,
        replay = 1,
        started = SharingStarted.WhileSubscribed()
      )
  }

  fun updateLocationPermissionStatus(hasPermission: Boolean) {
    _hasLocationPermission.value = hasPermission
  }
}
```

ちなみにワンショットで現在位置が欲しいだけなら、拡張ライブラリの[kotlinx-coroutines-play-services](https://github.com/Kotlin/kotlinx.coroutines/tree/master/integration/kotlinx-coroutines-play-services) を使えば `await` で`suspend`してくれるので、以下のようにとれます

```kotlin
suspend fun getLocation(): Location? = 
    LocationServices.getFusedLocationProviderClient(context).lastLocation.await()
```

# デバイスの方位の取得
デバイスが向いている方向を取得します。0度北が基準で時計回りに90度ごとに東、南、西です。
最初は[Sensor API](https://developer.android.com/develop/sensors-and-location/sensors/sensors_overview?hl=ja)の加速度計センサと磁場センサを使って自力で実装していたのですが、ロールとピッチの補正がうまくいかず悩んでいました。
色々調べていたところ、最近Googleが [Fused Orientation ProivderAPI](https://developers.google.com/android/reference/com/google/android/gms/location/FusedOrientationProviderClient)というデバイスのorigintationを返却してくれるAPIを公開しており、これを使うようにしました。
Google Map公式アプリでも使われているようです。
>We’ve heard from developers that orientation is challenging to get right, with frequent user complaints when orientation is incorrect.
> 開発者からは、向きを正しくするのは難しく、向きが間違っているとユーザーから頻繁に苦情が寄せられると聞いています。 

わかるなぁと思いました笑 むずかしい

https://android-developers.googleblog.com/2024/03/introducing-fused-orientation-provider-api.html

```kotlin
@ApplicationScope  
@Inject  
class DeviceOrientationProvider(context: Context, externalScope: CoroutineScope) {  
  
  private val fusedOrientationProviderClient: FusedOrientationProviderClient =  
    LocationServices.getFusedOrientationProviderClient(context)  
  private val request =  
    DeviceOrientationRequest.Builder(DeviceOrientationRequest.OUTPUT_PERIOD_DEFAULT).build()  
  
  val bearing = callbackFlow {  
  val orientationCallback = DeviceOrientationListener { orientation ->  
  trySend(orientation.headingDegrees)  
    }  
  
  val executor = Executors.newSingleThreadExecutor()  
    fusedOrientationProviderClient.requestOrientationUpdates(request, executor, orientationCallback)  
      .addOnFailureListener {
        cancel("Error requesting device orientation updates", it)
      }  
  awaitClose {  
  executor.shutdown()  
      fusedOrientationProviderClient.removeOrientationUpdates(orientationCallback)  
    }  
 }.distinctUntilChanged()  
    .shareIn(  
      externalScope,  
      replay = 1,  
      started = SharingStarted.WhileSubscribed()  
    )  
  
}
```

# カメラの方位を加味して補正

ここまでで現在位置とデバイスの方位が取得できたので、実際にマーカーが示す方位を設定します。
デフォルトのカメラ(bearing=0)だと表示されているマップの上は北を指しますが、ユーザージェスチャーなどで地図が回転してbearingが変化することもありえます。
そのため、回転した分補正した値をマーカーの回転角に指定することでユーザーが実際にどの方向を向いているかを正確に示すようにします。

地図のカメラのviewに関しては以下の図が非常に分かりやすいです。
引用：[カメラの位置](https://developers.google.com/maps/documentation/android-sdk/views?hl=ja)
<img src="https://storage.googleapis.com/zenn-user-upload/c18a74d87f47-20241003.png" width=80% />


今回はユーザーの現在地情報を格納するカスタムのState Holderをつくり、そこに [MarkerState](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-marker-state/index.html)も持たせました。

```kotlin
@Stable
class UserLocationState(
  initialLocation: Location?,
  val markerState: MarkerState,
  initialBearing: Float
) {
  var location by mutableStateOf(initialLocation)
    private set

  var heading by mutableFloatStateOf(initialBearing)
    private set

  fun update(location: Location, heading: Float) {
    this.location = location
    this.heading = heading
    markerState.position = LatLng(location.latitude, location.longitude)
  }
}
```

`cameraのBearing`/ `現在地のLocation` / `デバイスのheading`の3点をcombineして上記で作ったstateにセットします。

```kotlin
val userLocationState = rememberUserLocationState()
val cameraPositionState = rememberCameraPositionState()

LaunchedEffect(Unit) {
  val cameraBearing = snapshotFlow {
    mapState.cameraPositionState.position.bearing
  }
  combine(
    locationManager.location.filterNotNull(),
    deviceOrientationProvider.bearing,
    cameraBearing,
    ::Triple
  ).collect { (location, deviceBearing, cameraBearing) ->
    val correctedBearing = (deviceBearing - cameraBearing + 360) % 360 // 補正
    currentLocationState.update(location, correctedBearing)
  }
}
```

View側では以下のように使います。

```kotlin
GoogleMap() {
  with(state.currentLocationState) {
    if (location != null) {
      UserLocationMarker(
        markerState = markerState,
        heading = heading
      )
    }
  }
}}
```

# カスタムマーカーの作成

カスタムのマーカーを表示するにはデフォルトのスタイルが当たった [Marker](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-marker.html) / [AdvancedMarker](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-advanced-marker.html)を利用するか、ComposeでスクラッチでUIを構築できる [MarkerComposable](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-marker-composable.html)を使います。
今回はComposeで作りたいので後者を利用し、以下のようなマーカーを作ります。

<img src="https://storage.googleapis.com/zenn-user-upload/821e177a780c-20241003.png" width=30% >

[drawArc](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope#drawArc(androidx.compose.ui.graphics.Brush,kotlin.Float,kotlin.Float,kotlin.Boolean,androidx.compose.ui.geometry.Offset,androidx.compose.ui.geometry.Size,kotlin.Float,androidx.compose.ui.graphics.drawscope.DrawStyle,androidx.compose.ui.graphics.ColorFilter,androidx.compose.ui.graphics.BlendMode))と[drawCircle](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/drawscope/DrawScope#drawCircle(androidx.compose.ui.graphics.Brush,kotlin.Float,androidx.compose.ui.geometry.Offset,kotlin.Float,androidx.compose.ui.graphics.drawscope.DrawStyle,androidx.compose.ui.graphics.ColorFilter,androidx.compose.ui.graphics.BlendMode))を使って、方位を示す扇形、白線の円、内側の青い円を重ねて配置し、扇形に[radicalGradient](https://developer.android.com/develop/ui/compose/graphics/draw/brush?hl=ja#gradient-brushes)でグラデーションを適用します。
参考：[Composeのグラフィック](https://developer.android.com/develop/ui/compose/graphics/draw/overview?hl=ja) / [ブラシ: グラデーションとシェーダー](https://developer.android.com/develop/ui/compose/graphics/draw/brush?hl=ja)

```kotlin
@Composable
@Preview
private fun UserLocationMarkerInner() {
  Canvas(modifier = Modifier.size(100.dp)) {
    val center = Offset(size.width / 2, size.height / 2)
    val circleBorder = 3.dp.toPx()
    val circleRadius = 6.dp.toPx()
    val gradient = Brush.radialGradient(
      colorStops = arrayOf(
        0.0f to Blue,
        0.2f to Blue.copy(alpha = 0.3f),
        1f to Color.Transparent,
      ),
    ) // 外側に行くほど透明にする
    drawArc(
      brush = gradient,
      startAngle = 235f, //孤の描画開始角度。3時が0度
      sweepAngle = 70f,  //何度描画するか。ここでは235度から305度の弧になる。
      useCenter = true,
      size = Size(size.width, size.height + circleRadius * 2 + circleBorder * 2), // 孤の中心点が円のbottomにくるようにする
    )

    drawCircle(
      center = center,
      color = Color.White,
      radius = circleRadius + circleBorder,
    )
    drawCircle(
      center = center,
      color = Blue,
      radius = circleRadius
    )
  }
}
```

これを以下のように[MarkerComposable](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-marker-composable.html)のラムダに渡せば表示されます。
`anchor`は`MarkerState`が持っている`position`をMarkerのどの位置に置くか、と言う指定でデフォルトだとbottomCenter(0.5f, 1.0f)ですがここでは真ん中にしています。
`rotation`にheading(さきほど補正した値)を指定すれば、その角度にMarkerが回転します。

```kotlin
@GoogleMapComposable
@Composable
fun UserLocationMarker(
  heading: Float,
  markerState: MarkerState
) {
  MarkerComposable(
    state = markerState,
    anchor = Offset(0.5f, 0.5f),
    rotation = heading
  ) {
    UserLocationMarkerInner()
  }
}
```

以上で、GoogleMapと同じようなマーカーを作成することできました！


# 参考
- [Add a map to your Android app (Kotlin with Compose)](https://developers.google.com/codelabs/maps-platform/maps-platform-101-compose#0)
- [Map Compose ライブラリ](https://developers.google.com/maps/documentation/android-sdk/maps-compose?hl=ja)
- [カメラとビュー](https://developers.google.com/maps/documentation/android-sdk/views?hl=ja)
- [ブラシ: グラデーションとシェーダー](https://developer.android.com/develop/ui/compose/graphics/draw/brush?hl=ja)
- [android-maps-compose](https://github.com/googlemaps/android-maps-compose)
- [Introducing the Fused Orientation Provider API: Consistent device orientation for all](https://android-developers.googleblog.com/2024/03/introducing-fused-orientation-provider-api.html)
- [高度なマーカーを作成する](https://developers.google.com/maps/documentation/android-sdk/advanced-markers/add-marker?hl=ja)