diff --git a/Sources/MapConductorCore/marker/MarkerRenderingSupport.swift b/Sources/MapConductorCore/marker/MarkerRenderingSupport.swift index b4377a8..2e16ac0 100644 --- a/Sources/MapConductorCore/marker/MarkerRenderingSupport.swift +++ b/Sources/MapConductorCore/marker/MarkerRenderingSupport.swift @@ -30,6 +30,20 @@ public protocol MarkerRenderingSupport: AnyObject { /// Releases the renderer and controller built for the connected strategy. func disconnect() + /// Removes every marker the connected strategy has drawn, keeping the strategy connected. + /// + /// Called from each provider's `clearOverlays()` (through + /// ``MapServiceRegistry/clearMarkerRendering()``) so markers drawn by a strategy, such as + /// clustering, are cleared together with the provider's own overlays. Implementations call + /// the strategy controller's `clear()` (onRemove → strategy.clear() → onPostProcess) and + /// forget what they last sent, so the next ``syncMarkers(_:)`` sends the markers again, + /// the same way the overlay collectors re-add content after a clear. + /// + /// Required, with no default: a default no-op would compile and silently leave the + /// strategy's markers on the map for a provider that keeps its own strategy controller + /// (HERE, ArcGIS 3D). + func clearMarkers() async + /// Called by the provider immediately before it evaluates its content closure. func beginContentPass() @@ -48,3 +62,13 @@ public protocol MarkerRenderingSupport: AnyObject { public enum MarkerRenderingSupportKey: MapServiceKey { public typealias Value = any MarkerRenderingSupport } + +public extension MapServiceRegistry { + /// Clears the markers drawn through ``MarkerRenderingSupport`` (strategy-driven markers such + /// as clusters). Every provider's `clearOverlays()` calls this once, after clearing its own + /// overlay controllers. Does nothing when no marker rendering support is registered. + @MainActor + func clearMarkerRendering() async { + await get(MarkerRenderingSupportKey.self)?.clearMarkers() + } +} diff --git a/Sources/MapConductorCore/marker/StrategyMarkerManager.swift b/Sources/MapConductorCore/marker/StrategyMarkerManager.swift index e880143..ef02d1d 100644 --- a/Sources/MapConductorCore/marker/StrategyMarkerManager.swift +++ b/Sources/MapConductorCore/marker/StrategyMarkerManager.swift @@ -73,6 +73,7 @@ where Renderer.ActualMarker == ActualMarker { subscriptions.values.forEach { $0.cancel() } subscriptions.removeAll() statesById = [:] + clearedUntilListChanges = false if let initialCamera = currentCamera() { Task { [weak self] in await self?.controller?.onCameraChanged(mapCameraPosition: initialCamera) @@ -107,11 +108,32 @@ where Renderer.ActualMarker == ActualMarker { /// style ロード後など、shouldAddMarkers が true になったタイミングで未送信マーカーを再送する。 public func flush() { - guard !latestMarkers.isEmpty, let controller, shouldAddMarkers() else { return } + guard !clearedUntilListChanges, !latestMarkers.isEmpty, let controller, shouldAddMarkers() else { return } let markers = latestMarkers Task { await controller.add(data: markers) } } + /// ``MarkerRenderingSupport`` の実装。`clearOverlays()` から呼ばれる。 + /// + /// strategy は接続したまま、描いたマーカーだけを消す(``StrategyMarkerController/clear()``: + /// onRemove → strategy.clear() → onPostProcess)。 + /// + /// 消した後は、他のオーバーレイのコレクタと同じく「同じインスタンスの一覧が送られ続ける間は + /// 描かない。顔ぶれが変わるか作り直された state が来たら全部を描き直す」。SwiftUI は + /// clear の直後にも body を評価し直して同じ一覧を送ってくるので、そこで描き直すと + /// 消したはずのマーカーがすぐ戻る。 + public func clearMarkers() async { + clearedUntilListChanges = true + if !latestMarkers.isEmpty { + latestMarkers = [] + onMarkersChanged?([]) + } + await controller?.clear() + } + + /// ``clearMarkers()`` の後、一覧が変わるまで描かない印。 + private var clearedUntilListChanges = false + /// strategy を解除してリソースを解放する。 public func clear() { subscriptions.values.forEach { $0.cancel() } @@ -144,6 +166,11 @@ where Renderer.ActualMarker == ActualMarker { newStatesById[state.id] = state } statesById = newStatesById + if clearedUntilListChanges { + // clearMarkers() の後、同じ一覧が送られている間は描かない(コレクタと同じ)。 + guard shouldSyncList else { return } + clearedUntilListChanges = false + } let markersChanged = latestMarkers.count != markers.count || shouldSyncList latestMarkers = markers if markersChanged { onMarkersChanged?(markers) } diff --git a/Tests/MapConductorCoreTests/MarkerRenderingClearTests.swift b/Tests/MapConductorCoreTests/MarkerRenderingClearTests.swift new file mode 100644 index 0000000..8121dbc --- /dev/null +++ b/Tests/MapConductorCoreTests/MarkerRenderingClearTests.swift @@ -0,0 +1,118 @@ +import XCTest + +@testable import MapConductorCore + +/// `clearOverlays()` が strategy(クラスタリングなど)のマーカーも消すこと。 +/// +/// 各プロバイダの `clearOverlays()` は自分のオーバーレイのコントローラを clear した後に +/// ``MapServiceRegistry/clearMarkerRendering()`` を 1 回呼ぶ。これがレジストリに登録された +/// ``MarkerRenderingSupport``(``StrategyMarkerManager``)の ``StrategyMarkerManager/clearMarkers()`` に届き、 +/// GL 型の描画担当(onPostProcess で manager の全部を描く)でも何も描かれていない状態で終わること。 +/// 送り直したら(次の syncMarkers)もう一度描かれること。 +@MainActor +final class MarkerRenderingClearTests: XCTestCase { + private let camera = MapCameraPosition( + position: GeoPoint(latitude: 35.0, longitude: 139.0), + zoom: 10, + visibleRegion: VisibleRegion(bounds: GeoRectBounds( + southWest: GeoPoint(latitude: 34.0, longitude: 138.0), + northEast: GeoPoint(latitude: 36.0, longitude: 140.0) + )) + ) + + private let markers = [ + MarkerState(position: GeoPoint(latitude: 35.0, longitude: 139.0), id: "s1"), + MarkerState(position: GeoPoint(latitude: 35.1, longitude: 139.1), id: "s2"), + ] + + @MainActor + private final class GlRenderer: MarkerOverlayRendererProtocol { + typealias ActualMarker = String + let manager: MarkerManager + var drawn: [String] = [] + var animateStartListener: OnMarkerEventHandler? + var animateEndListener: OnMarkerEventHandler? + init(_ manager: MarkerManager) { self.manager = manager } + func onAdd(data: [MarkerOverlayAddParams]) async -> [String?] { data.map { $0.state.id } } + func onChange(data: [MarkerOverlayChangeParams]) async -> [String?] { data.map { $0.current.state.id } } + func onRemove(data _: [MarkerEntity]) async {} + func onAnimate(entity _: MarkerEntity) async {} + func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() } + } + + private final class DrawAllStrategy: MarkerRenderingStrategyProtocol { + typealias ActualMarker = String + let markerManager = MarkerManager() + func clear() { markerManager.clear() } + func onAdd( + data: [MarkerState], viewport _: GeoRectBounds, renderer: Renderer + ) async -> Bool where Renderer.ActualMarker == String { + markerManager.clear() + for state in data { + markerManager.registerEntity(MarkerEntity(marker: state.id, state: state, isRendered: true)) + } + await renderer.onPostProcess() + return true + } + func onUpdate( + state _: MarkerState, viewport _: GeoRectBounds, renderer _: Renderer + ) async -> Bool where Renderer.ActualMarker == String { true } + func onCameraChanged( + mapCameraPosition _: MapCameraPosition, renderer _: Renderer + ) async where Renderer.ActualMarker == String {} + } + + private func waitUntil(_ condition: @escaping () -> Bool) async { + for _ in 0..<200 where !condition() { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + func testClearOverlaysPathClearsStrategyMarkersAndResendDrawsThemAgain() async { + let strategy = DrawAllStrategy() + var renderer: GlRenderer? + let manager = StrategyMarkerManager( + makeRenderer: { s in + let r = GlRenderer(s.markerManager) + renderer = r + return r + }, + currentCamera: { [camera] in camera } + ) + let registry = MutableMapServiceRegistry() + registry.put(MarkerRenderingSupportKey.self, manager) + + manager.beginContentPass() + XCTAssertTrue(manager.connect(strategy: AnyMarkerRenderingStrategy(strategy), markers: markers)) + manager.endContentPass() + await waitUntil { renderer?.drawn == ["s1", "s2"] } + XCTAssertEqual(renderer?.drawn, ["s1", "s2"]) + + await registry.clearMarkerRendering() + + XCTAssertEqual(renderer?.drawn, []) + XCTAssertTrue(strategy.markerManager.allEntities().isEmpty) + XCTAssertTrue(manager.latestMarkers.isEmpty) + XCTAssertNotNil(manager.controller, "strategy stays connected") + + // 同じインスタンスの一覧が送られ続ける間は描かない(clear 直後の body の再評価で戻らない)。 + manager.beginContentPass() + manager.connect(strategy: AnyMarkerRenderingStrategy(strategy), markers: markers) + manager.endContentPass() + manager.flush() + try? await Task.sleep(nanoseconds: 200_000_000) + XCTAssertEqual(renderer?.drawn, []) + + // 作り直した state(同じ id)を送り直すと描き直される(コレクタの clear 後の再追加と同じ)。 + let resent = markers.map { MarkerState(position: $0.position, id: $0.id) } + manager.beginContentPass() + manager.connect(strategy: AnyMarkerRenderingStrategy(strategy), markers: resent) + manager.endContentPass() + await waitUntil { renderer?.drawn == ["s1", "s2"] } + XCTAssertEqual(renderer?.drawn, ["s1", "s2"]) + } + + func testClearMarkerRenderingWithoutSupportDoesNothing() async { + await MutableMapServiceRegistry().clearMarkerRendering() + } +} diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index ece94cd..f551c60 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -1934,12 +1934,16 @@ open class AbstractMarkerRenderingStrategy : MapConductorCore::Mar @_Concurrency::MainActor func connect(strategy: Any, markers: [MapConductorCore::MarkerState]) -> Swift::Bool @_Concurrency::MainActor func syncMarkers(_ markers: [MapConductorCore::MarkerState]) @_Concurrency::MainActor func disconnect() + @_Concurrency::MainActor func clearMarkers() async @_Concurrency::MainActor func beginContentPass() @_Concurrency::MainActor func endContentPass() } public enum MarkerRenderingSupportKey : MapConductorCore::MapServiceKey { public typealias Value = any MapConductorCore::MarkerRenderingSupport } +extension MapConductorCore::MapServiceRegistry { + @_Concurrency::MainActor public func clearMarkerRendering() async +} public enum MarkerAnimation { case Drop case Bounce @@ -2179,6 +2183,7 @@ final public class StrategyMarkerController : @_Concurrency::MainActor final public func endContentPass() @_Concurrency::MainActor final public func onCameraChanged(_ position: MapConductorCore::MapCameraPosition) async @_Concurrency::MainActor final public func flush() + @_Concurrency::MainActor final public func clearMarkers() async @_Concurrency::MainActor final public func clear() @_Concurrency::MainActor final public func syncMarkers(_ markers: [MapConductorCore::MarkerState]) @objc deinit