From 012ebbc1964e3a2f42442d6e63ab744be0808053 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 6 Sep 2026 01:48:46 +0900 Subject: [PATCH 1/6] Stop taps on an InfoBubble from reaching the map underneath The map is an AndroidView laid under a full-screen Compose overlay, and an event nobody consumed goes to it. So tapping the padding of a bubble, or its title, or anywhere its content did not itself handle, was read as a tap on the map: on the street tree page that selected whichever tree happened to sit under the bubble and swapped the contents for another one. "Hit the bubble and nothing wanted it" is not the same as "missed the bubble". The overlay Box now consumes what its content leaves behind, so the first case stops there and the second still falls through to the map. Consumption happens on the Main pass. Compose delivers Main from the child outwards, so by the time it arrives here the bubble's own buttons and clickable rows have already taken theirs; consuming on Initial would have killed them. Verified on the tablet with the Multiple Bubbles sample, whose bubbles close themselves when tapped: the tapped one still closes and its neighbour stays open. The Box is the bubble's bounding rectangle, so the corners beside the tail count as the bubble. That is a few pixels either way and the alternative is hit-testing the drawn shape, which is not worth it to make a corner pixel pan the map. Panning still works, and a tap on the map away from the bubble still dismisses it. All three InfoBubble entry points share this overlay, so all three are fixed. --- .../compose/info/InfoWindowOverlay.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/com/mapconductor/compose/info/InfoWindowOverlay.kt b/src/main/java/com/mapconductor/compose/info/InfoWindowOverlay.kt index 0df76da..6cf149b 100644 --- a/src/main/java/com/mapconductor/compose/info/InfoWindowOverlay.kt +++ b/src/main/java/com/mapconductor/compose/info/InfoWindowOverlay.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -45,6 +46,24 @@ internal fun InfoBubbleOverlay( infoWndSize = it.size }.offset { IntOffset(x.toInt(), y.toInt()) + }.pointerInput(Unit) { + // 吹き出しの上のタップを地図へ落とさない。 + // + // 地図は下に敷かれた AndroidView で、誰も消費しなかった + // イベントはそこへ届く。吹き出しの余白を叩くと地図のタップ + // として扱われ、別のマーカーが選ばれたり吹き出しが閉じたり + // する。「当たったが誰も使わなかった」と「当たらなかった」 + // は別なので、前者はここで止める。 + // + // 消費は Main パスで行う。Compose は Main を子から親へ配る + // ため、ここへ来た時点で中身のボタンやリンクは既に自分の分 + // を受け取っている。Initial パスで消費すると、それらが動か + // なくなる。 + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { it.consume() } + } + } }, ) { content() From 88f96bb64d9168f0a6fe50c56944f78b67fbb9fb Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 6 Sep 2026 01:56:13 +0900 Subject: [PATCH 2/6] Match the bubble's hit area to its drawn shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consuming modifier was on InfoBubbleOverlay, whose Box is the bounding rectangle. That made the corners either side of the tail count as bubble: an area half the bubble's width by the tail's height where the map is plainly visible and a tap did nothing. The shape is known to DrawInfoBubble, not to the overlay, so the consuming moves there. isInsideBubble is the same geometry the Canvas draws — the body down to height minus the tail, plus the triangle closing linearly to its tip. Corner rounding is ignored; at the default it is 4dp against a tail-side area of half the width by 8dp, which is what this is about. InfoBubbleCustom draws its own shape and so keeps its own hit behaviour. It has no tail this code knows about and no rectangle worth guessing at. DrawInfoBubbleHitTest pins the boundary. Consuming too much and consuming too little both look like "it sometimes ignores me" and neither logs anything, so the edges are worth stating: body inside, beside the tail outside, the tail itself inside and narrowing. --- .../compose/info/DrawInfoBubble.kt | 54 ++++++++++++++- .../compose/info/InfoWindowOverlay.kt | 19 ------ .../compose/info/DrawInfoBubbleHitTest.kt | 68 +++++++++++++++++++ 3 files changed, 121 insertions(+), 20 deletions(-) create mode 100644 src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt diff --git a/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt b/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt index 7476f34..ec915f4 100644 --- a/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt +++ b/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp @Composable @@ -27,7 +28,37 @@ internal fun DrawInfoBubble( tailSize: Dp, content: @Composable () -> Unit, ) { - Box(modifier = modifier.wrapContentSize()) { + Box( + modifier = + modifier + .wrapContentSize() + .pointerInput(tailSize) { + // 吹き出しの上のタップを地図へ落とさない。 + // + // 地図は下に敷かれた AndroidView で、誰も消費しなかった + // イベントはそこへ届く。吹き出しの余白を叩くと地図のタップ + // として扱われ、下にいたマーカーが選ばれたり吹き出しが閉じ + // たりする。「当たったが誰も使わなかった」と「当たらなかっ + // た」は別なので、前者をここで止める。 + // + // 消費は Main パスで行う。Compose は Main を子から親へ配る + // ため、ここへ来た時点で中身のボタンや clickable は既に自分 + // の分を受け取っている。Initial パスで消費すると、それらが + // 動かなくなる。 + val tailPx = tailSize.toPx() + val boxHeight = size.height.toFloat() + val boxWidth = size.width.toFloat() + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { change -> + if (isInsideBubble(change.position, boxHeight, boxWidth, tailPx)) { + change.consume() + } + } + } + } + }, + ) { Canvas(modifier = Modifier.matchParentSize()) { val width = size.width val height = size.height @@ -110,3 +141,24 @@ internal fun DrawInfoBubble( } } } + +/** + * 吹き出しの内側か。判定は描画形状(本体+しっぽの三角)に合わせる。 + * + * Box はしっぽを含む外接矩形なので、しっぽの左右の角は箱の中だが吹き出しでは + * ない。そこまで吹き出し扱いにすると、地図が見えている場所を叩いても何も起き + * ない。角の丸めは無視している。既定 4dp に対してしっぽ脇は幅の半分×8dp あり、 + * 除きたかったのはそちらで、丸めの差は 1 桁小さい。 + */ +internal fun isInsideBubble( + point: Offset, + height: Float, + width: Float, + tailPx: Float, +): Boolean { + if (point.x < 0f || point.x > width || point.y < 0f || point.y > height) return false + val bodyBottom = height - tailPx + if (point.y <= bodyBottom) return true + // しっぽは (w/2, height) を頂点に、bodyBottom で幅 tailPx まで開く三角形。 + return kotlin.math.abs(point.x - width / 2f) <= (height - point.y) / 2f +} diff --git a/src/main/java/com/mapconductor/compose/info/InfoWindowOverlay.kt b/src/main/java/com/mapconductor/compose/info/InfoWindowOverlay.kt index 6cf149b..0df76da 100644 --- a/src/main/java/com/mapconductor/compose/info/InfoWindowOverlay.kt +++ b/src/main/java/com/mapconductor/compose/info/InfoWindowOverlay.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -46,24 +45,6 @@ internal fun InfoBubbleOverlay( infoWndSize = it.size }.offset { IntOffset(x.toInt(), y.toInt()) - }.pointerInput(Unit) { - // 吹き出しの上のタップを地図へ落とさない。 - // - // 地図は下に敷かれた AndroidView で、誰も消費しなかった - // イベントはそこへ届く。吹き出しの余白を叩くと地図のタップ - // として扱われ、別のマーカーが選ばれたり吹き出しが閉じたり - // する。「当たったが誰も使わなかった」と「当たらなかった」 - // は別なので、前者はここで止める。 - // - // 消費は Main パスで行う。Compose は Main を子から親へ配る - // ため、ここへ来た時点で中身のボタンやリンクは既に自分の分 - // を受け取っている。Initial パスで消費すると、それらが動か - // なくなる。 - awaitPointerEventScope { - while (true) { - awaitPointerEvent().changes.forEach { it.consume() } - } - } }, ) { content() diff --git a/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt b/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt new file mode 100644 index 0000000..f0b1822 --- /dev/null +++ b/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt @@ -0,0 +1,68 @@ +package com.mapconductor.compose.info + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 吹き出しが自分のタップを消費する範囲。 + * + * 消費しすぎても消費しなさすぎても、落ちも警告も出ない。狭ければタップが下の + * 地図へ抜けて別のマーカーが選ばれ、広ければ地図が見えている場所を叩いても + * 何も起きない。どちらも「たまに反応がおかしい」としか見えないので、境界は + * ここで固定する。 + * + * 座標系は [DrawInfoBubble] の Box の左上原点。高さはしっぽを含む。 + */ +class DrawInfoBubbleHitTest { + private val width = 200f + private val height = 108f + private val tail = 8f + private val bodyBottom = height - tail // 100f + + private fun inside( + x: Float, + y: Float, + ) = isInsideBubble(Offset(x, y), height, width, tail) + + @Test + fun bodyIsInside() { + assertTrue("中央", inside(width / 2f, bodyBottom / 2f)) + assertTrue("左上の角", inside(0f, 0f)) + assertTrue("右上の角", inside(width, 0f)) + assertTrue("本体の下端", inside(10f, bodyBottom)) + } + + /** + * しっぽの左右は箱の中だが吹き出しではない。ここが本題で、外接矩形をその + * まま使っていたときはこの領域まで吹き出し扱いになっていた。 + */ + @Test + fun besideTheTailIsOutside() { + assertFalse("しっぽの左", inside(10f, bodyBottom + tail / 2f)) + assertFalse("しっぽの右", inside(width - 10f, bodyBottom + tail / 2f)) + assertFalse("左下の角", inside(0f, height)) + assertFalse("右下の角", inside(width, height)) + } + + /** しっぽ自体は吹き出しの一部。先端に向かって細くなる。 */ + @Test + fun theTailItselfIsInside() { + assertTrue("しっぽの中心", inside(width / 2f, bodyBottom + tail / 2f)) + assertTrue("先端", inside(width / 2f, height)) + // 付け根で幅 tail、そこから頂点へ線形に閉じる。bodyBottom から 2px 下 + // では半幅 3px なので、3 は内側で 4 は外側。 + assertTrue("しっぽの内側", inside(width / 2f - 3f, bodyBottom + 2f)) + assertFalse("同じ高さで少し外", inside(width / 2f - 4f, bodyBottom + 2f)) + assertFalse("先端の高さでは幅がない", inside(width / 2f + 2f, height)) + } + + @Test + fun outsideTheBoxIsOutside() { + assertFalse(inside(-1f, 10f)) + assertFalse(inside(width + 1f, 10f)) + assertFalse(inside(10f, -1f)) + assertFalse(inside(10f, height + 1f)) + } +} From 52af22cbe0f13470df5f8f0b35067b6a90e11acd Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 6 Sep 2026 02:08:33 +0900 Subject: [PATCH 3/6] Leave the bubble's tail out of its hit area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tail is 8dp at the default. Hit-testing the triangle costs a line of geometry and a test to hold it up, and buys the case where someone aims at the point itself — where the consequence of missing is that the map below responds, not that something they were aiming at fails to move. The rule is now the body rectangle, which is what the consuming was for. --- .../compose/info/DrawInfoBubble.kt | 21 ++++++++++--------- .../compose/info/DrawInfoBubbleHitTest.kt | 18 ++++++++-------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt b/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt index ec915f4..e5e9946 100644 --- a/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt +++ b/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt @@ -143,22 +143,23 @@ internal fun DrawInfoBubble( } /** - * 吹き出しの内側か。判定は描画形状(本体+しっぽの三角)に合わせる。 + * 吹き出しの本体の内側か。 * * Box はしっぽを含む外接矩形なので、しっぽの左右の角は箱の中だが吹き出しでは * ない。そこまで吹き出し扱いにすると、地図が見えている場所を叩いても何も起き - * ない。角の丸めは無視している。既定 4dp に対してしっぽ脇は幅の半分×8dp あり、 - * 除きたかったのはそちらで、丸めの差は 1 桁小さい。 + * ない。 + * + * しっぽ自体も外している。既定 8dp の三角形で、外して困るのは先端を狙って + * 叩いた場合だけ。そのとき起きるのは下の地図が反応することで、狙っていた + * ものが動かないのとは違う。角の丸めも同じ理由で無視している。 */ internal fun isInsideBubble( point: Offset, height: Float, width: Float, tailPx: Float, -): Boolean { - if (point.x < 0f || point.x > width || point.y < 0f || point.y > height) return false - val bodyBottom = height - tailPx - if (point.y <= bodyBottom) return true - // しっぽは (w/2, height) を頂点に、bodyBottom で幅 tailPx まで開く三角形。 - return kotlin.math.abs(point.x - width / 2f) <= (height - point.y) / 2f -} +): Boolean = + point.x >= 0f && + point.x <= width && + point.y >= 0f && + point.y <= height - tailPx diff --git a/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt b/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt index f0b1822..525391e 100644 --- a/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt +++ b/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt @@ -46,16 +46,16 @@ class DrawInfoBubbleHitTest { assertFalse("右下の角", inside(width, height)) } - /** しっぽ自体は吹き出しの一部。先端に向かって細くなる。 */ + /** + * しっぽ自体も外側。 + * + * 既定 8dp の三角形なので、外して困るのは先端を狙って叩いた場合だけで、 + * そのとき起きるのは下の地図が反応することにすぎない。 + */ @Test - fun theTailItselfIsInside() { - assertTrue("しっぽの中心", inside(width / 2f, bodyBottom + tail / 2f)) - assertTrue("先端", inside(width / 2f, height)) - // 付け根で幅 tail、そこから頂点へ線形に閉じる。bodyBottom から 2px 下 - // では半幅 3px なので、3 は内側で 4 は外側。 - assertTrue("しっぽの内側", inside(width / 2f - 3f, bodyBottom + 2f)) - assertFalse("同じ高さで少し外", inside(width / 2f - 4f, bodyBottom + 2f)) - assertFalse("先端の高さでは幅がない", inside(width / 2f + 2f, height)) + fun theTailIsOutside() { + assertFalse("しっぽの中心", inside(width / 2f, bodyBottom + tail / 2f)) + assertFalse("先端", inside(width / 2f, height)) } @Test From b4b267ca3a888972baca951f279e5432046f6c4f Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sun, 6 Sep 2026 03:01:05 +0900 Subject: [PATCH 4/6] Have states report their own changes instead of being read for them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging the street tree map was heavy on a Pixel 5a long after the tile rendering stopped being the reason. Frame stats put 270 to 380 ms of every janky frame between AnimationStart and PerformTraversalsStart, with the GPU at a 16 ms 90th percentile; simpleperf put three quarters of the main thread in androidx.collection.ScatterSet.contains, under advanceGlobalSnapshot, under a snapshotFlow's apply observer. That observer belonged to the collector. To find in-place changes it read every state's fingerprint inside one snapshotFlow, and Compose asks such an observer, on every snapshot commit anywhere in the app, whether any object it read is among the changed ones — one contains per object read. 144,183 markers of a dozen fields each is about 1.7 million probes, and a drag commits a snapshot every frame. The loop ran the wrong way round. Asking "of everything I read, did any of it change?" costs the size of the collection whether or not anything changed; being told "this one changed" costs one call per actual change, and during a drag markers do not change at all. So the states push: every mutable field goes through StateMutationSignal.notifying, which writes through to the same mutableStateOf — the field stays readable from a composition exactly as before — and reports the write. On a Pixel 5a, same 144k markers, same drag: janky frames 17.7% -> 1.4% 90th 350ms -> 21ms 99th 700ms -> 24ms slow UI thread 19 -> 2 which is where an empty map already was (0.2%, 16ms, 19ms). Quartering the markers had quartered the cost, so this was never about what was drawn. The collector keeps its own id-to-state map of who is reporting. Membership reaches `flow` through a debounce, so a state added and removed inside one window is not in that map when the removal arrives; clearing by id there found nothing and left the state reporting for the rest of the collector's life, which the removal test caught. Two things go with it. The FingerPrint type parameter, which existed only for the diff — fingerPrint() stays, several providers do their own diffing with it. And asFlow() on all six state types plus CollectAndUpdateEach, the per-state watcher this replaced twice over: nothing called either, and they are the shape that must not come back. The failure mode of the swap is silent — a setter that forgets to report leaves the renderer drawing the old value, with nothing logged and nothing thrown — so OverlayCollectorMutationTest states the contract: a write arrives, an equal write does not, repeats inside a window collapse to the latest, a removed state goes quiet, and two states changing together both arrive. --- .../mapconductor/compose/OverlayProvider.kt | 182 ++++++++---------- .../compose/map/CollectAndUpdateEach.kt | 29 --- .../mapconductor/compose/map/MapViewBase.kt | 1 + 3 files changed, 86 insertions(+), 126 deletions(-) delete mode 100644 src/main/java/com/mapconductor/compose/map/CollectAndUpdateEach.kt diff --git a/src/main/java/com/mapconductor/compose/OverlayProvider.kt b/src/main/java/com/mapconductor/compose/OverlayProvider.kt index acf22b1..29c804f 100644 --- a/src/main/java/com/mapconductor/compose/OverlayProvider.kt +++ b/src/main/java/com/mapconductor/compose/OverlayProvider.kt @@ -1,97 +1,85 @@ -package com.mapconductor.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import com.mapconductor.compose.info.InfoBubbleEntry -import com.mapconductor.core.OverlayCollector -import com.mapconductor.core.circle.CircleFingerPrint -import com.mapconductor.core.circle.CircleOverlay -import com.mapconductor.core.circle.CircleState -import com.mapconductor.core.controller.MapViewControllerInterface -import com.mapconductor.core.groundimage.GroundImageFingerPrint -import com.mapconductor.core.groundimage.GroundImageOverlay -import com.mapconductor.core.groundimage.GroundImageState -import com.mapconductor.core.map.MapOverlayInterface -import com.mapconductor.core.map.MapOverlayRegistry -import com.mapconductor.core.marker.MarkerAnimationOverlayEntry -import com.mapconductor.core.marker.MarkerFingerPrint -import com.mapconductor.core.marker.MarkerOverlay -import com.mapconductor.core.marker.MarkerState -import com.mapconductor.core.polygon.PolygonFingerPrint -import com.mapconductor.core.polygon.PolygonOverlay -import com.mapconductor.core.polygon.PolygonState -import com.mapconductor.core.polyline.PolylineFingerPrint -import com.mapconductor.core.polyline.PolylineOverlay -import com.mapconductor.core.polyline.PolylineState -import com.mapconductor.core.raster.RasterLayerFingerPrint -import com.mapconductor.core.raster.RasterLayerOverlay -import com.mapconductor.core.raster.RasterLayerState -import com.mapconductor.settings.Settings -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow - -open class MapViewScope { - val markerCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val bubbleFlow = MutableStateFlow>(mutableMapOf()) - - /** Active screen-space marker animations, keyed by marker id. */ - val markerAnimationFlow = MutableStateFlow>(emptyMap()) - val polylineCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val circleCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val polygonCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val groundImageCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val rasterLayerCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - - fun buildRegistry(): MapOverlayRegistry { - val registry = MapOverlayRegistry() - registry.register(MarkerOverlay(markerCollector.flow)) - registry.register(CircleOverlay(circleCollector.flow)) - registry.register(PolylineOverlay(polylineCollector.flow)) - registry.register(PolygonOverlay(polygonCollector.flow)) - registry.register(GroundImageOverlay(groundImageCollector.flow)) - registry.register(RasterLayerOverlay(rasterLayerCollector.flow)) - return registry - } -} - -@OptIn(FlowPreview::class) -@Composable -fun CollectAndRenderOverlays( - registry: MapOverlayRegistry, - controller: MapViewControllerInterface, -) { - registry.getAll().forEach { overlay -> - @Suppress("UNCHECKED_CAST") - val typedOverlay = overlay as MapOverlayInterface - val flowState = typedOverlay.flow.collectAsState() - - LaunchedEffect(flowState.value) { - typedOverlay.render(flowState.value, controller) - } - } -} +package com.mapconductor.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import com.mapconductor.compose.info.InfoBubbleEntry +import com.mapconductor.core.OverlayCollector +import com.mapconductor.core.circle.CircleOverlay +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.controller.MapViewControllerInterface +import com.mapconductor.core.groundimage.GroundImageOverlay +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.map.MapOverlayInterface +import com.mapconductor.core.map.MapOverlayRegistry +import com.mapconductor.core.marker.MarkerAnimationOverlayEntry +import com.mapconductor.core.marker.MarkerOverlay +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.polygon.PolygonOverlay +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.PolylineOverlay +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.raster.RasterLayerOverlay +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.settings.Settings +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow + +open class MapViewScope { + val markerCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val bubbleFlow = MutableStateFlow>(mutableMapOf()) + + /** Active screen-space marker animations, keyed by marker id. */ + val markerAnimationFlow = MutableStateFlow>(emptyMap()) + val polylineCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val circleCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val polygonCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val groundImageCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val rasterLayerCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + + fun buildRegistry(): MapOverlayRegistry { + val registry = MapOverlayRegistry() + registry.register(MarkerOverlay(markerCollector.flow)) + registry.register(CircleOverlay(circleCollector.flow)) + registry.register(PolylineOverlay(polylineCollector.flow)) + registry.register(PolygonOverlay(polygonCollector.flow)) + registry.register(GroundImageOverlay(groundImageCollector.flow)) + registry.register(RasterLayerOverlay(rasterLayerCollector.flow)) + return registry + } +} + +@OptIn(FlowPreview::class) +@Composable +fun CollectAndRenderOverlays( + registry: MapOverlayRegistry, + controller: MapViewControllerInterface, +) { + registry.getAll().forEach { overlay -> + @Suppress("UNCHECKED_CAST") + val typedOverlay = overlay as MapOverlayInterface + val flowState = typedOverlay.flow.collectAsState() + + LaunchedEffect(flowState.value) { + typedOverlay.render(flowState.value, controller) + } + } +} diff --git a/src/main/java/com/mapconductor/compose/map/CollectAndUpdateEach.kt b/src/main/java/com/mapconductor/compose/map/CollectAndUpdateEach.kt deleted file mode 100644 index 2037248..0000000 --- a/src/main/java/com/mapconductor/compose/map/CollectAndUpdateEach.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.mapconductor.compose.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import com.mapconductor.core.ComponentState -import kotlin.time.Duration -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce - -@OptIn(FlowPreview::class) -@Composable -internal fun CollectAndUpdateEach( - states: kotlinx.coroutines.flow.StateFlow>, - debounce: Duration, - asFlow: (T) -> Flow, - onUpdate: suspend (T) -> Unit, -) { - val snapshot = states.collectAsState() - snapshot.value.values.forEach { state -> - LaunchedEffect(state.id) { - asFlow(state).debounce(debounce).collectLatest { - onUpdate(state) - } - } - } -} diff --git a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt index ae4b1ea..7d1d280 100644 --- a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt +++ b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt @@ -57,6 +57,7 @@ import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapDesignTypeInterface import com.mapconductor.core.map.MapOverlayRegistry +import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.MapViewHolderInterface import com.mapconductor.core.map.MapViewStateInterface import com.mapconductor.core.map.ScreenProjectionRequirement From 7d44e6cf61ad4ad102a0fc94907f47796580bee7 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 17 Sep 2026 05:32:33 +0900 Subject: [PATCH 5/6] Opt in to the driver API the collectors are built on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OverlayCollector is @InternalMapConductorApi in android-sdk-core now: it is the overlay collection wiring, not something an app calls, and its type parameters changed with the collector redesign. This module is that wiring, so it opts in per module the way android-for-* does. The baseline follows: the collectors lose their FingerPrint type argument, and CollectAndUpdateEach is gone with the redesign — states report their own writes instead of being read for them. --- api/android-sdk-compose.api | 16 +++++++--------- build.gradle.kts | 3 +++ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index 7c56c60..964f159 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -1,13 +1,13 @@ public class com.mapconductor.compose.MapViewScope { public com.mapconductor.compose.MapViewScope(); - public final com.mapconductor.core.OverlayCollector getMarkerCollector(); + public final com.mapconductor.core.OverlayCollector getMarkerCollector(); public final kotlinx.coroutines.flow.MutableStateFlow> getBubbleFlow(); public final kotlinx.coroutines.flow.MutableStateFlow> getMarkerAnimationFlow(); - public final com.mapconductor.core.OverlayCollector getPolylineCollector(); - public final com.mapconductor.core.OverlayCollector getCircleCollector(); - public final com.mapconductor.core.OverlayCollector getPolygonCollector(); - public final com.mapconductor.core.OverlayCollector getGroundImageCollector(); - public final com.mapconductor.core.OverlayCollector getRasterLayerCollector(); + public final com.mapconductor.core.OverlayCollector getPolylineCollector(); + public final com.mapconductor.core.OverlayCollector getCircleCollector(); + public final com.mapconductor.core.OverlayCollector getPolygonCollector(); + public final com.mapconductor.core.OverlayCollector getGroundImageCollector(); + public final com.mapconductor.core.OverlayCollector getRasterLayerCollector(); public final com.mapconductor.core.map.MapOverlayRegistry buildRegistry(); } public final class com.mapconductor.compose.OverlayProviderKt { @@ -29,6 +29,7 @@ public final class com.mapconductor.compose.groundimage.LocalGroundImageCollecto } public final class com.mapconductor.compose.info.DrawInfoBubbleKt { public static final void DrawInfoBubble-5cZf5Bw(androidx.compose.ui.Modifier, long, long, float, float, float, float, kotlin.jvm.functions.Function2, androidx.compose.runtime.Composer, int); + public static final boolean isInsideBubble-M_7yMNQ(long, float, float, float); } public final class com.mapconductor.compose.info.InfoBubbleComposeKt { public static final void InfoBubble-j2PKIZY(com.mapconductor.compose.MapViewScope, com.mapconductor.core.marker.MarkerState, long, long, float, float, float, float, kotlin.jvm.functions.Function2, androidx.compose.runtime.Composer, int, int); @@ -55,9 +56,6 @@ public abstract class com.mapconductor.compose.map.BaseMapViewSaver createSaver(); protected abstract java.lang.String getStateId(T); } -public final class com.mapconductor.compose.map.CollectAndUpdateEachKt { - public static final void CollectAndUpdateEach-NcHsxvU(kotlinx.coroutines.flow.StateFlow>, long, kotlin.jvm.functions.Function1>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, androidx.compose.runtime.Composer, int); -} public final class com.mapconductor.compose.map.MapAttributionOverlayKt { public static final void MapAttributionOverlay(java.util.List, androidx.compose.runtime.Composer, int); } diff --git a/build.gradle.kts b/build.gradle.kts index 588d645..5517ed4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -62,6 +62,9 @@ kotlin { project.property("jvmTarget").toString(), ), ) + // ドライバー実装点(@InternalMapConductorApi)を使うためのオプトイン。 + // この層はオーバーレイ収集の配線そのものなので、モジュール単位で許可する。 + optIn.add("com.mapconductor.core.InternalMapConductorApi") } } From c0a31c48ff354381a1fbbea0b3dd5d6f653f321a Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 30 Sep 2026 15:29:52 -0700 Subject: [PATCH 6/6] Withdraw VectorStyleSupport with the other capabilities on dispose --- src/main/java/com/mapconductor/compose/map/MapViewBase.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt index 7d1d280..541264b 100644 --- a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt +++ b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt @@ -63,6 +63,7 @@ import com.mapconductor.core.map.MapViewStateInterface import com.mapconductor.core.map.ScreenProjectionRequirement import com.mapconductor.core.map.resolveMapAttributions import com.mapconductor.core.marker.MarkerCapableInterface +import com.mapconductor.core.map.VectorStyleSupportKey import com.mapconductor.core.marker.MarkerRenderingSupportKey import com.mapconductor.core.polygon.PolygonCapableInterface import com.mapconductor.core.polyline.PolylineCapableInterface @@ -121,7 +122,10 @@ fun < // 登録側は各プロバイダ(コントローラ生成時)。content の合成より前に登録される必要があり、 // `MarkerClusterGroup` は登録が無ければその場で return するため、ここで put はしない。 DisposableEffect(state) { - onDispose { state.serviceRegistry.remove(MarkerRenderingSupportKey) } + onDispose { + state.serviceRegistry.remove(MarkerRenderingSupportKey) + state.serviceRegistry.remove(VectorStyleSupportKey) + } } controller?.also {