Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Sources/MapConductorCore/marker/MarkerRenderingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()
}
}
29 changes: 28 additions & 1 deletion Sources/MapConductorCore/marker/StrategyMarkerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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) }
Expand Down
118 changes: 118 additions & 0 deletions Tests/MapConductorCoreTests/MarkerRenderingClearTests.swift
Original file line number Diff line number Diff line change
@@ -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<String>
var drawn: [String] = []
var animateStartListener: OnMarkerEventHandler?
var animateEndListener: OnMarkerEventHandler?
init(_ manager: MarkerManager<String>) { self.manager = manager }
func onAdd(data: [MarkerOverlayAddParams]) async -> [String?] { data.map { $0.state.id } }
func onChange(data: [MarkerOverlayChangeParams<String>]) async -> [String?] { data.map { $0.current.state.id } }
func onRemove(data _: [MarkerEntity<String>]) async {}
func onAnimate(entity _: MarkerEntity<String>) async {}
func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() }
}

private final class DrawAllStrategy: MarkerRenderingStrategyProtocol {
typealias ActualMarker = String
let markerManager = MarkerManager<String>()
func clear() { markerManager.clear() }
func onAdd<Renderer: MarkerOverlayRendererProtocol>(
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<Renderer: MarkerOverlayRendererProtocol>(
state _: MarkerState, viewport _: GeoRectBounds, renderer _: Renderer
) async -> Bool where Renderer.ActualMarker == String { true }
func onCameraChanged<Renderer: MarkerOverlayRendererProtocol>(
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<String, GlRenderer>(
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()
}
}
5 changes: 5 additions & 0 deletions api/MapConductorCore.api.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1934,12 +1934,16 @@ open class AbstractMarkerRenderingStrategy<ActualMarker> : 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
Expand Down Expand Up @@ -2179,6 +2183,7 @@ final public class StrategyMarkerController<ActualMarker, Strategy, Renderer> :
@_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
Expand Down
Loading