From 7a280190edba714ee0d633196bc0366dd8b1e687 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:15:01 -0700 Subject: [PATCH] Keep an unset camera property from the current camera, and honour an explicit 0 A camera built as MapCameraPosition(position = ...) reset zoom, bearing and tilt to 0 instead of leaving them where they were. The default is 0, so the value alone cannot say whether the caller asked for 0 (zoom 0 is the whole world) or said nothing. The camera now records which of zoom / bearing / tilt the caller gave (isZoomSpecified, ...), and MapViewState.moveCameraTo fills only the unset ones from the current camera via resolvedOnto(). Each property is resolved on its own, and an explicit 0 is used as given. The properties stay Double and read 0 when unset, so the providers, which only read them, are unchanged at the source level. The constructor now takes Double?, which the api baseline records. copy() keeps an unset property unset. Same change as ios-sdk-core and js-sdk-core. --- api/android-sdk-core.api | 14 +-- .../core/map/MapCameraPositionBase.kt | 61 +++++++++++-- .../com/mapconductor/core/map/MapViewState.kt | 11 ++- .../core/map/CameraPositionResolveTest.kt | 89 +++++++++++++++++++ 4 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 src/test/java/com/mapconductor/core/map/CameraPositionResolveTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 59298c9..7a654f0 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -852,14 +852,18 @@ public final class com.mapconductor.core.map.LocalMapOverlayRegistryKt { } public final class com.mapconductor.core.map.MapCameraPosition implements com.mapconductor.core.map.MapCameraPositionInterface { public static final com.mapconductor.core.map.MapCameraPosition$Companion Companion; - public com.mapconductor.core.map.MapCameraPosition(com.mapconductor.core.features.GeoPointInterface, double, double, double, com.mapconductor.core.map.MapPaddingsInterface, com.mapconductor.core.map.VisibleRegion); - public com.mapconductor.core.map.MapCameraPosition(com.mapconductor.core.features.GeoPointInterface, double, double, double, com.mapconductor.core.map.MapPaddingsInterface, com.mapconductor.core.map.VisibleRegion, int, kotlin.jvm.internal.DefaultConstructorMarker); - public double getZoom(); - public double getBearing(); - public double getTilt(); + public com.mapconductor.core.map.MapCameraPosition(com.mapconductor.core.features.GeoPointInterface, java.lang.Double, java.lang.Double, java.lang.Double, com.mapconductor.core.map.MapPaddingsInterface, com.mapconductor.core.map.VisibleRegion); + public com.mapconductor.core.map.MapCameraPosition(com.mapconductor.core.features.GeoPointInterface, java.lang.Double, java.lang.Double, java.lang.Double, com.mapconductor.core.map.MapPaddingsInterface, com.mapconductor.core.map.VisibleRegion, int, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.core.map.MapPaddingsInterface getPaddings(); public com.mapconductor.core.map.VisibleRegion getVisibleRegion(); public com.mapconductor.core.features.GeoPoint getPosition(); + public double getZoom(); + public double getBearing(); + public double getTilt(); + public final boolean isZoomSpecified(); + public final boolean isBearingSpecified(); + public final boolean isTiltSpecified(); + public final com.mapconductor.core.map.MapCameraPosition resolvedOnto(com.mapconductor.core.map.MapCameraPositionInterface); public final boolean equals(com.mapconductor.core.map.MapCameraPositionInterface); public final com.mapconductor.core.map.MapCameraPosition copy(com.mapconductor.core.features.GeoPointInterface, java.lang.Double, java.lang.Double, java.lang.Double, com.mapconductor.core.map.MapPaddingsInterface, com.mapconductor.core.map.VisibleRegion); public int hashCode(); diff --git a/src/main/java/com/mapconductor/core/map/MapCameraPositionBase.kt b/src/main/java/com/mapconductor/core/map/MapCameraPositionBase.kt index aad0a2f..b683600 100644 --- a/src/main/java/com/mapconductor/core/map/MapCameraPositionBase.kt +++ b/src/main/java/com/mapconductor/core/map/MapCameraPositionBase.kt @@ -32,32 +32,75 @@ interface MapCameraPositionInterface { class MapCameraPosition( position: GeoPointInterface, - override val zoom: Double = 0.0, - override val bearing: Double = 0.0, - override val tilt: Double = 0.0, + zoom: Double? = null, + bearing: Double? = null, + tilt: Double? = null, override val paddings: MapPaddingsInterface? = MapPaddings.Companion.Zeros, override val visibleRegion: VisibleRegion? = null, ) : MapCameraPositionInterface { override val position: GeoPoint = GeoPoint.from(position) + // 指定された値。null は未指定。 + private val givenZoom: Double? = zoom + private val givenBearing: Double? = bearing + private val givenTilt: Double? = tilt + + /** 指定されなかったときは 0。指定されたかどうかは [isZoomSpecified] で区別する。 */ + override val zoom: Double = zoom ?: 0.0 + + /** 指定されなかったときは 0。指定されたかどうかは [isBearingSpecified] で区別する。 */ + override val bearing: Double = bearing ?: 0.0 + + /** 指定されなかったときは 0。指定されたかどうかは [isTiltSpecified] で区別する。 */ + override val tilt: Double = tilt ?: 0.0 + + /** + * `zoom` / `bearing` / `tilt` が明示されたか。 + * + * 明示された 0(zoom 0 = 世界全体、真北、真上から)と未指定を区別するために持つ。 + * [MapViewState.moveCameraTo] は未指定のプロパティだけを現在のカメラから + * 引き継ぐ([resolvedOnto])。 + */ + val isZoomSpecified: Boolean get() = givenZoom != null + val isBearingSpecified: Boolean get() = givenBearing != null + val isTiltSpecified: Boolean get() = givenTilt != null + + /** + * 未指定の `zoom` / `bearing` / `tilt` を [current] の値で埋めたカメラを返す。 + * + * 明示されたプロパティは、0 であってもそのまま使う。 + */ + fun resolvedOnto(current: MapCameraPositionInterface): MapCameraPosition { + if (isZoomSpecified && isBearingSpecified && isTiltSpecified) return this + return MapCameraPosition( + position = position, + zoom = givenZoom ?: current.zoom, + bearing = givenBearing ?: current.bearing, + tilt = givenTilt ?: current.tilt, + paddings = paddings, + visibleRegion = visibleRegion, + ) + } + fun equals(other: MapCameraPositionInterface): Boolean = this.position.equals(other = other.position) && this.zoomEquals(other) && this.bearingEquals(other) && this.tiltEquals(other) + // 渡されなかったプロパティは、未指定だったならコピー後も未指定のまま。 fun copy( position: GeoPointInterface? = this.position, - zoom: Double? = this.zoom, - bearing: Double? = this.bearing, - tilt: Double? = this.tilt, + zoom: Double? = this.givenZoom, + bearing: Double? = this.givenBearing, + tilt: Double? = this.givenTilt, paddings: MapPaddingsInterface? = this.paddings, visibleRegion: VisibleRegion? = this.visibleRegion, ) = MapCameraPosition( position = position ?: this.position, - zoom = zoom ?: this.zoom, - bearing = bearing ?: this.bearing, - tilt = tilt ?: this.tilt, + zoom = zoom ?: this.givenZoom, + bearing = bearing ?: this.givenBearing, + tilt = tilt ?: this.givenTilt, paddings = paddings ?: this.paddings, visibleRegion = visibleRegion ?: this.visibleRegion, ) diff --git a/src/main/java/com/mapconductor/core/map/MapViewState.kt b/src/main/java/com/mapconductor/core/map/MapViewState.kt index 4ef57a7..bd6e7e7 100644 --- a/src/main/java/com/mapconductor/core/map/MapViewState.kt +++ b/src/main/java/com/mapconductor/core/map/MapViewState.kt @@ -169,22 +169,25 @@ abstract class MapViewState( cameraPosition: MapCameraPosition, durationMillis: Long?, ) { + // 明示されたプロパティは 0 でもそのまま使い、未指定のものだけ現在のカメラから + // 引き継ぐ。`MapCameraPosition(position = ...)` なら位置だけが動く。 + val resolved = cameraPosition.resolvedOnto(_cameraPosition) val ctrl = attachedMapController if (ctrl == null) { // まだ地図が無い。接続時に attachController がこの位置へ移動する。 - _cameraPosition = cameraPosition + _cameraPosition = resolved return } if (optimisticCameraUpdate) { - _cameraPosition = cameraPosition + _cameraPosition = resolved } // 引数は既に MapCameraPosition なので、プロバイダごとの // `MapCameraPosition.Companion.from` は恒等変換になる(どの実装も // `is MapCameraPosition -> position` で素通しする)。ここでは呼ばない。 if (durationMillis == null || durationMillis == 0L) { - ctrl.moveCamera(cameraPosition) + ctrl.moveCamera(resolved) } else { - ctrl.animateCamera(cameraPosition, durationMillis) + ctrl.animateCamera(resolved, durationMillis) } } diff --git a/src/test/java/com/mapconductor/core/map/CameraPositionResolveTest.kt b/src/test/java/com/mapconductor/core/map/CameraPositionResolveTest.kt new file mode 100644 index 0000000..a3060e2 --- /dev/null +++ b/src/test/java/com/mapconductor/core/map/CameraPositionResolveTest.kt @@ -0,0 +1,89 @@ +package com.mapconductor.core.map + +import com.mapconductor.core.features.GeoPoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 未指定の zoom / bearing / tilt だけが現在のカメラから引き継がれ、 + * 明示された 0 はそのまま使われることを確認する。 + * + * ios-sdk の `CameraPositionResolveTests`、js-sdk の `CameraPositionResolve.test.mjs` と同じ観点。 + */ +class CameraPositionResolveTest { + private val tokyo = GeoPoint(latitude = 35.6812, longitude = 139.7671) + private val osaka = GeoPoint(latitude = 34.7025, longitude = 135.4959) + private val current = MapCameraPosition(position = tokyo, zoom = 12.0, bearing = 30.0, tilt = 45.0) + + private fun MapCameraPosition.zbt() = listOf(zoom, bearing, tilt) + + @Test + fun positionOnlyKeepsEverythingElse() { + val resolved = MapCameraPosition(position = osaka).resolvedOnto(current) + assertEquals(osaka.latitude, resolved.position.latitude, 0.0) + assertEquals(listOf(12.0, 30.0, 45.0), resolved.zbt()) + } + + @Test + fun explicitZeroIsHonoured() { + val resolved = + MapCameraPosition(position = osaka, zoom = 0.0, bearing = 0.0, tilt = 0.0) + .resolvedOnto(current) + assertEquals(listOf(0.0, 0.0, 0.0), resolved.zbt()) + } + + @Test + fun eachPropertyIsInheritedOnItsOwn() { + assertEquals( + listOf(0.0, 30.0, 45.0), + MapCameraPosition(position = osaka, zoom = 0.0).resolvedOnto(current).zbt(), + ) + assertEquals( + listOf(12.0, 0.0, 45.0), + MapCameraPosition(position = osaka, bearing = 0.0).resolvedOnto(current).zbt(), + ) + assertEquals( + listOf(12.0, 30.0, 0.0), + MapCameraPosition(position = osaka, tilt = 0.0).resolvedOnto(current).zbt(), + ) + } + + @Test + fun unsetReadsAsZeroBeforeResolving() { + val camera = MapCameraPosition(position = osaka) + assertEquals(0.0, camera.zoom, 0.0) + assertFalse(camera.isZoomSpecified) + assertFalse(camera.isBearingSpecified) + assertFalse(camera.isTiltSpecified) + assertTrue(MapCameraPosition.Default.isZoomSpecified) + } + + @Test + fun copyKeepsUnspecifiedUnlessGiven() { + assertFalse(MapCameraPosition(position = osaka).copy(position = tokyo).isZoomSpecified) + + val withZoom = MapCameraPosition(position = osaka).copy(zoom = 0.0) + assertTrue(withZoom.isZoomSpecified) + assertFalse(withZoom.isBearingSpecified) + } + + private class TestState( + initial: MapCameraPosition, + ) : MapViewState(initial) { + override val id: String = "test" + override var mapDesignType: String = "design" + } + + @Test + fun mapViewStateMergesUnsetPropertiesFromCurrentCamera() { + val state = TestState(current) + + state.moveCameraTo(MapCameraPosition(position = osaka, bearing = 0.0), null) + assertEquals(listOf(12.0, 0.0, 45.0), state.cameraPosition.zbt()) + + state.moveCameraTo(MapCameraPosition(position = osaka, zoom = 0.0), null) + assertEquals(0.0, state.cameraPosition.zoom, 0.0) + } +}