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) + } +}