From d82931c86a9a5f10bf8d6e6230dd14ad199dba97 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:09:12 -0700 Subject: [PATCH] Keep an unset camera property from the current camera, and honour an explicit 0 #11 dropped the all-zero test because it took zoom 0 (the whole world) for "not given". Removing it fixed that and lost the other half: a camera built as MapCameraPosition(position:) reset zoom, bearing and tilt to 0 instead of leaving them where they were. The two cannot be told apart from the value, because zoom defaults to 0. So the camera now records which of zoom / bearing / tilt the caller gave (isZoomSpecified, ...), and moveCameraTo(cameraPosition:) fills only the unset ones from the current camera via resolved(onto:). 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. copy() keeps an unset property unset. --- .../map/MapCameraPositionBase.swift | 51 +++++++++--- .../MapConductorCore/map/MapViewState.swift | 6 +- .../CameraPositionResolveTests.swift | 77 +++++++++++++++++++ api/MapConductorCore.api.swift | 6 +- 4 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 Tests/MapConductorCoreTests/CameraPositionResolveTests.swift diff --git a/Sources/MapConductorCore/map/MapCameraPositionBase.swift b/Sources/MapConductorCore/map/MapCameraPositionBase.swift index f3808ce..23ee4c4 100644 --- a/Sources/MapConductorCore/map/MapCameraPositionBase.swift +++ b/Sources/MapConductorCore/map/MapCameraPositionBase.swift @@ -54,29 +54,61 @@ public protocol MapCameraPositionProtocol { public final class MapCameraPosition: MapCameraPositionProtocol { public var position: GeoPointProtocol { positionValue } + /// 指定されなかったときは 0。指定されたかどうかは ``isZoomSpecified`` で区別する。 public let zoom: Double + /// 指定されなかったときは 0。指定されたかどうかは ``isBearingSpecified`` で区別する。 public let bearing: Double + /// 指定されなかったときは 0。指定されたかどうかは ``isTiltSpecified`` で区別する。 public let tilt: Double public let paddings: MapPaddingsProtocol? public let visibleRegion: VisibleRegion? private let positionValue: GeoPoint + /// `zoom` / `bearing` / `tilt` が明示されたか。 + /// + /// 明示された 0(zoom 0 = 世界全体、真北、真上から)と未指定を区別するために持つ。 + /// ``MapViewState/moveCameraTo(cameraPosition:durationMillis:)`` は未指定の + /// プロパティだけを現在のカメラから引き継ぐ(``resolved(onto:)``)。 + public let isZoomSpecified: Bool + public let isBearingSpecified: Bool + public let isTiltSpecified: Bool + public init( position: GeoPointProtocol, - zoom: Double = 0.0, - bearing: Double = 0.0, - tilt: Double = 0.0, + zoom: Double? = nil, + bearing: Double? = nil, + tilt: Double? = nil, paddings: MapPaddingsProtocol? = MapPaddings.Zeros, visibleRegion: VisibleRegion? = nil ) { self.positionValue = GeoPoint.from(position: position) - self.zoom = zoom - self.bearing = bearing - self.tilt = tilt + self.zoom = zoom ?? 0.0 + self.bearing = bearing ?? 0.0 + self.tilt = tilt ?? 0.0 + self.isZoomSpecified = zoom != nil + self.isBearingSpecified = bearing != nil + self.isTiltSpecified = tilt != nil self.paddings = paddings self.visibleRegion = visibleRegion } + /// 未指定の `zoom` / `bearing` / `tilt` を `current` の値で埋めたカメラを返す。 + /// + /// 明示されたプロパティは、0 であってもそのまま使う。 + public func resolved(onto current: MapCameraPositionProtocol) -> MapCameraPosition { + if isZoomSpecified && isBearingSpecified && isTiltSpecified { + return self + } + return MapCameraPosition( + position: positionValue, + zoom: isZoomSpecified ? zoom : current.zoom, + bearing: isBearingSpecified ? bearing : current.bearing, + tilt: isTiltSpecified ? tilt : current.tilt, + paddings: paddings, + visibleRegion: visibleRegion + ) + } + public func equals(other: MapCameraPositionProtocol) -> Bool { positionValue == GeoPoint.from(position: other.position) && zoomEquals(other) && @@ -92,11 +124,12 @@ public final class MapCameraPosition: MapCameraPositionProtocol { paddings: MapPaddingsProtocol? = nil, visibleRegion: VisibleRegion? = nil ) -> MapCameraPosition { + // 渡されなかったプロパティは、未指定だったならコピー後も未指定のまま。 MapCameraPosition( position: position ?? self.positionValue, - zoom: zoom ?? self.zoom, - bearing: bearing ?? self.bearing, - tilt: tilt ?? self.tilt, + zoom: zoom ?? (isZoomSpecified ? self.zoom : nil), + bearing: bearing ?? (isBearingSpecified ? self.bearing : nil), + tilt: tilt ?? (isTiltSpecified ? self.tilt : nil), paddings: paddings ?? self.paddings, visibleRegion: visibleRegion ?? self.visibleRegion ) diff --git a/Sources/MapConductorCore/map/MapViewState.swift b/Sources/MapConductorCore/map/MapViewState.swift index ac62264..9a35706 100644 --- a/Sources/MapConductorCore/map/MapViewState.swift +++ b/Sources/MapConductorCore/map/MapViewState.swift @@ -159,9 +159,9 @@ open class MapViewState: ObservableObject, MapViewStateProt } open func moveCameraTo(cameraPosition: MapCameraPosition, durationMillis: Long? = 0) { - // カメラを渡されたらそのまま使う。「位置だけ動かして縮尺は保つ」は - // `moveCameraTo(position:)` のオーバーロードが担う。 - let resolved = cameraPosition + // 明示されたプロパティは 0 でもそのまま使い、未指定のものだけ現在のカメラから + // 引き継ぐ。`MapCameraPosition(position:)` なら位置だけが動く。 + let resolved = cameraPosition.resolved(onto: storedCameraPosition) guard let controller = attachedMapController else { // まだ地図が無い。接続時に attachController がこの位置へ移動する。 storedCameraPosition = resolved diff --git a/Tests/MapConductorCoreTests/CameraPositionResolveTests.swift b/Tests/MapConductorCoreTests/CameraPositionResolveTests.swift new file mode 100644 index 0000000..9bbd940 --- /dev/null +++ b/Tests/MapConductorCoreTests/CameraPositionResolveTests.swift @@ -0,0 +1,77 @@ +import XCTest + +@testable import MapConductorCore + +/// 未指定の zoom / bearing / tilt だけが現在のカメラから引き継がれ、 +/// 明示された 0 はそのまま使われることを確認する。 +final class CameraPositionResolveTests: XCTestCase { + private let tokyo = GeoPoint(latitude: 35.6812, longitude: 139.7671) + private let osaka = GeoPoint(latitude: 34.7025, longitude: 135.4959) + + private var current: MapCameraPosition { + MapCameraPosition(position: tokyo, zoom: 12, bearing: 30, tilt: 45) + } + + func testPositionOnlyKeepsEverythingElse() { + let resolved = MapCameraPosition(position: osaka).resolved(onto: current) + XCTAssertEqual(GeoPoint.from(position: resolved.position), osaka) + XCTAssertEqual(resolved.zoom, 12) + XCTAssertEqual(resolved.bearing, 30) + XCTAssertEqual(resolved.tilt, 45) + } + + func testExplicitZeroIsHonoured() { + let resolved = MapCameraPosition(position: osaka, zoom: 0, bearing: 0, tilt: 0) + .resolved(onto: current) + XCTAssertEqual(resolved.zoom, 0) + XCTAssertEqual(resolved.bearing, 0) + XCTAssertEqual(resolved.tilt, 0) + } + + func testEachPropertyIsInheritedOnItsOwn() { + let zoomOnly = MapCameraPosition(position: osaka, zoom: 0).resolved(onto: current) + XCTAssertEqual(zoomOnly.zoom, 0) + XCTAssertEqual(zoomOnly.bearing, 30) + XCTAssertEqual(zoomOnly.tilt, 45) + + let bearingOnly = MapCameraPosition(position: osaka, bearing: 0).resolved(onto: current) + XCTAssertEqual(bearingOnly.zoom, 12) + XCTAssertEqual(bearingOnly.bearing, 0) + XCTAssertEqual(bearingOnly.tilt, 45) + + let tiltOnly = MapCameraPosition(position: osaka, tilt: 0).resolved(onto: current) + XCTAssertEqual(tiltOnly.zoom, 12) + XCTAssertEqual(tiltOnly.bearing, 30) + XCTAssertEqual(tiltOnly.tilt, 0) + } + + func testUnspecifiedReadsAsZeroBeforeResolving() { + let camera = MapCameraPosition(position: osaka) + XCTAssertEqual(camera.zoom, 0) + XCTAssertFalse(camera.isZoomSpecified) + XCTAssertFalse(camera.isBearingSpecified) + XCTAssertFalse(camera.isTiltSpecified) + XCTAssertTrue(MapCameraPosition.Default.isZoomSpecified) + } + + func testCopyKeepsUnspecifiedUnlessGiven() { + let copied = MapCameraPosition(position: osaka).copy(position: tokyo) + XCTAssertFalse(copied.isZoomSpecified) + + let withZoom = MapCameraPosition(position: osaka).copy(zoom: 0) + XCTAssertTrue(withZoom.isZoomSpecified) + XCTAssertFalse(withZoom.isBearingSpecified) + } + + func testMapViewStateMergesUnsetPropertiesFromCurrentCamera() { + let state = MapViewState(initialCameraPosition: current) + + state.moveCameraTo(cameraPosition: MapCameraPosition(position: osaka, bearing: 0)) + XCTAssertEqual(state.cameraPosition.zoom, 12) + XCTAssertEqual(state.cameraPosition.bearing, 0) + XCTAssertEqual(state.cameraPosition.tilt, 45) + + state.moveCameraTo(cameraPosition: MapCameraPosition(position: osaka, zoom: 0)) + XCTAssertEqual(state.cameraPosition.zoom, 0) + } +} diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index a9425df..7c9895f 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -1204,7 +1204,11 @@ final public class MapCameraPosition : MapConductorCore::MapCameraPositionProtoc final public let tilt: Swift::Double final public let paddings: (any MapConductorCore::MapPaddingsProtocol)? final public let visibleRegion: MapConductorCore::VisibleRegion? - public init(position: any MapConductorCore::GeoPointProtocol, zoom: Swift::Double = 0.0, bearing: Swift::Double = 0.0, tilt: Swift::Double = 0.0, paddings: (any MapConductorCore::MapPaddingsProtocol)? = MapPaddings.Zeros, visibleRegion: MapConductorCore::VisibleRegion? = nil) + final public let isZoomSpecified: Swift::Bool + final public let isBearingSpecified: Swift::Bool + final public let isTiltSpecified: Swift::Bool + public init(position: any MapConductorCore::GeoPointProtocol, zoom: Swift::Double? = nil, bearing: Swift::Double? = nil, tilt: Swift::Double? = nil, paddings: (any MapConductorCore::MapPaddingsProtocol)? = MapPaddings.Zeros, visibleRegion: MapConductorCore::VisibleRegion? = nil) + final public func resolved(onto current: any MapConductorCore::MapCameraPositionProtocol) -> MapConductorCore::MapCameraPosition final public func equals(other: any MapConductorCore::MapCameraPositionProtocol) -> Swift::Bool final public func copy(position: (any MapConductorCore::GeoPointProtocol)? = nil, zoom: Swift::Double? = nil, bearing: Swift::Double? = nil, tilt: Swift::Double? = nil, paddings: (any MapConductorCore::MapPaddingsProtocol)? = nil, visibleRegion: MapConductorCore::VisibleRegion? = nil) -> MapConductorCore::MapCameraPosition final public func hashCode() -> Swift::Int