From c6c69e74e726b6782369da000a10d7642ca69103 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Wed, 28 Oct 2026 13:47:16 -0700 Subject: [PATCH 01/15] Add .claude/settings.json --- .claude/settings.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..671a084 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "attribution": { + "commit": "", + "pr": "" + } +} From 0f4633a0c5061a965c08211fc468c1aad4b7ab0f Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 10:23:15 -0800 Subject: [PATCH 02/15] Add a GroundImage(bounds, imageUrl) overload Same as the Drawable overload, with the image given as a URL that core loads (GroundImageState(bounds, imageUrl)). --- api/android-sdk-compose.api | 1 + .../groundimage/GroundImageComponent.kt | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index bac0a49..d5e97f2 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -23,6 +23,7 @@ public final class com.mapconductor.compose.circle.LocalCircleCollectorKt { public final class com.mapconductor.compose.groundimage.GroundImageComponentKt { public static final void GroundImage(com.mapconductor.compose.MapViewScope, com.mapconductor.core.groundimage.GroundImageState, androidx.compose.runtime.Composer, int); public static final void GroundImage(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, android.graphics.drawable.Drawable, float, int, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); + public static final void GroundImage(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, java.lang.String, float, int, java.lang.String, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); } public final class com.mapconductor.compose.groundimage.LocalGroundImageCollectorKt { public static final androidx.compose.runtime.ProvidableCompositionLocal> getLocalGroundImageCollector(); diff --git a/src/main/java/com/mapconductor/compose/groundimage/GroundImageComponent.kt b/src/main/java/com/mapconductor/compose/groundimage/GroundImageComponent.kt index e2a849b..1760091 100644 --- a/src/main/java/com/mapconductor/compose/groundimage/GroundImageComponent.kt +++ b/src/main/java/com/mapconductor/compose/groundimage/GroundImageComponent.kt @@ -47,3 +47,30 @@ fun MapViewScope.GroundImage( ) GroundImage(state) } + +/** + * [imageUrl](http / https / file / data)の画像を貼る。SDK が読み、読み終えるまでは透明。 + * http(s) にはアプリの `android.permission.INTERNET` が要る。 + */ +@Composable +fun MapViewScope.GroundImage( + bounds: GeoRectBounds, + imageUrl: String, + opacity: Float = 0.5f, + tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, + id: String? = null, + extra: Serializable? = null, + onClick: OnGroundImageEventHandler? = null, +) { + val state = + GroundImageState( + bounds = bounds, + imageUrl = imageUrl, + opacity = opacity, + tileSize = tileSize, + id = id, + extra = extra, + onClick = onClick, + ) + GroundImage(state) +} From c3ee15756471e7062e112f2938794cfb750b0a75 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 18:05:22 -0800 Subject: [PATCH 03/15] Default Circle strokeWidth to 1.dp and GroundImage opacity to 1.0f, like their states The component form and the state form of the same overlay drew differently when nothing was passed: Circle and GroundImage components used strokeWidth 2 / opacity 0.5 while their states used 1 / 1.0, and PolygonState used strokeWidth 2 while the Polygon component used 1. Defaults are now the same for both forms on React, Android and iOS: Circle / Polygon / Polyline strokeWidth = 1, GroundImage opacity = 1.0. --- .../java/com/mapconductor/compose/circle/CircleCompose.kt | 2 +- .../mapconductor/compose/groundimage/GroundImageComponent.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt index aa8872b..1afdaac 100644 --- a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt +++ b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt @@ -34,7 +34,7 @@ fun MapViewScope.Circle( geodesic: Boolean = true, clickable: Boolean = true, strokeColor: Color = Color.Red, - strokeWidth: Dp = 2.dp, + strokeWidth: Dp = 1.dp, fillColor: Color = Color.White.copy(alpha = 0.5f), zIndex: Int? = null, extra: Serializable? = null, diff --git a/src/main/java/com/mapconductor/compose/groundimage/GroundImageComponent.kt b/src/main/java/com/mapconductor/compose/groundimage/GroundImageComponent.kt index 1760091..09b6ace 100644 --- a/src/main/java/com/mapconductor/compose/groundimage/GroundImageComponent.kt +++ b/src/main/java/com/mapconductor/compose/groundimage/GroundImageComponent.kt @@ -29,7 +29,7 @@ fun MapViewScope.GroundImage(state: GroundImageState) { fun MapViewScope.GroundImage( bounds: GeoRectBounds, image: Drawable, - opacity: Float = 0.5f, + opacity: Float = 1.0f, tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, id: String? = null, extra: Serializable? = null, @@ -56,7 +56,7 @@ fun MapViewScope.GroundImage( fun MapViewScope.GroundImage( bounds: GeoRectBounds, imageUrl: String, - opacity: Float = 0.5f, + opacity: Float = 1.0f, tileSize: Int = GroundImageTileProvider.DEFAULT_TILE_SIZE, id: String? = null, extra: Serializable? = null, From 2fa68f5c8a138419fd1010720c6db6538913ffee Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 18:11:13 -0800 Subject: [PATCH 04/15] Make InfoBubble follow the marker's icon and keep its entry stable InfoBubble(marker) and InfoBubbleCustom registered the entry built on first composition inside DisposableEffect(marker) (Custom: marker, tailOffset). The entry's icon is a value, so swapping marker.icon left the bubble placed for the old icon: a pin scaled 2x was drawn over by its own bubble. All three overloads now go through RegisterInfoBubble: the entry is remembered on what decides where the bubble goes (anchor, icon, tailOffset) and is swapped only when one of those changes; its content reads the latest lambda through rememberUpdatedState, so content and colours follow without touching bubbleFlow. Disposal removes only the entry this call put there. Adds a JVM unit test that runs the Compose runtime on a no-node applier (kotlinx-coroutines-test for Main, isReturnDefaultValues for android.os.Trace). --- build.gradle.kts | 7 + gradle/libs.versions.toml | 1 + .../compose/info/InfoBubbleCompose.kt | 130 +++++---- .../info/InfoBubbleRegistrationTest.kt | 253 ++++++++++++++++++ 4 files changed, 340 insertions(+), 51 deletions(-) create mode 100644 src/test/java/com/mapconductor/compose/info/InfoBubbleRegistrationTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 47a06cf..39326d7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,6 +48,12 @@ android { compose = true } + testOptions { + // JVM 単体テストで Compose ランタイムを回すと android.os.Trace を呼ぶ。 + // スタブの既定値(何もしない)で通す。InfoBubbleRegistrationTest を参照。 + unitTests.isReturnDefaultValues = true + } + publishing { singleVariant("release") { withSourcesJar() @@ -88,6 +94,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ff38ca..fa989c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", ve # Coroutines kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } # Test junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/src/main/java/com/mapconductor/compose/info/InfoBubbleCompose.kt b/src/main/java/com/mapconductor/compose/info/InfoBubbleCompose.kt index d4179fa..e77bb47 100644 --- a/src/main/java/com/mapconductor/compose/info/InfoBubbleCompose.kt +++ b/src/main/java/com/mapconductor/compose/info/InfoBubbleCompose.kt @@ -3,6 +3,7 @@ package com.mapconductor.compose.info import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -10,7 +11,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.mapconductor.compose.MapViewScope import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.marker.DefaultMarkerIcon +import com.mapconductor.core.marker.MarkerIconInterface import com.mapconductor.core.marker.MarkerState import java.util.UUID @@ -50,27 +53,18 @@ fun MapViewScope.InfoBubble( ) } - val entry = - InfoBubbleEntry( - id = marker.id, - positionProvider = { marker.position }, - // Marker本体もicon未指定時はDefaultMarkerIconを描画するため、InfoBubbleの - // 接続位置計算にも同じアイコン寸法・anchor・infoAnchorを使用する。 - icon = marker.icon ?: DefaultMarkerIcon(), - content = wrapped, - ) - - DisposableEffect(marker) { - val newMap = bubbleFlow.value.toMutableMap() - newMap[entry.id] = entry - bubbleFlow.value = newMap - - onDispose { - val newMap = bubbleFlow.value.toMutableMap() - newMap.remove(entry.id) - bubbleFlow.value = newMap - } - } + RegisterInfoBubble( + id = marker.id, + anchorKey = marker, + positionProvider = { marker.position }, + // Marker本体もicon未指定時はDefaultMarkerIconを描画するため、InfoBubbleの + // 接続位置計算にも同じアイコン寸法・anchor・infoAnchorを使用する。 + // marker.icon はスナップショット状態なので、ここで読めば差し替えで再合成される。 + icon = marker.icon, + defaultIcon = true, + tailOffset = Offset(0.5f, 1.0f), + content = wrapped, + ) } /** @@ -110,24 +104,15 @@ fun MapViewScope.InfoBubble( } // Re-register when position changes so the bubble moves to the new coordinates. - DisposableEffect(position) { - val entry = - InfoBubbleEntry( - id = id, - positionProvider = { position }, - icon = null, - content = wrapped, - ) - val newMap = bubbleFlow.value.toMutableMap() - newMap[id] = entry - bubbleFlow.value = newMap - - onDispose { - val newMap = bubbleFlow.value.toMutableMap() - newMap.remove(id) - bubbleFlow.value = newMap - } - } + RegisterInfoBubble( + id = id, + anchorKey = position, + positionProvider = { position }, + icon = null, + defaultIcon = false, + tailOffset = Offset(0.5f, 1.0f), + content = wrapped, + ) } /** @@ -144,25 +129,68 @@ fun MapViewScope.InfoBubbleCustom( tailOffset: Offset, content: @Composable () -> Unit, ) { + RegisterInfoBubble( + id = marker.id, + anchorKey = marker, + positionProvider = { marker.position }, + icon = marker.icon, + defaultIcon = true, + tailOffset = tailOffset, + content = content, + ) +} + +/** + * 吹き出しを [MapViewScope.bubbleFlow] に登録し、中身を常に最新に保つ。 + * + * 以前は各関数が `DisposableEffect(marker)` の中で**最初の合成時の** [InfoBubbleEntry] を + * 登録していた。エントリの `content` はそのときのラムダで、色や中身の値を捕まえたまま + * 固まるので、`bubbleColor` やラムダに渡した値、マーカーのアイコンが後から変わっても + * 画面の吹き出しは古いままだった。 + * + * ここではエントリを作り直さず、`content` を「最新のラムダを読んで呼ぶ」ラムダにする + * ([rememberUpdatedState])。中身や見た目が変わっても再合成されるのは吹き出しの中だけで、 + * `bubbleFlow` は鳴らない。位置・アイコン・接続点のように**どこに出すか**が変わったときだけ + * エントリを差し替える。 + * + * @param anchorKey 位置を決めるもの(マーカーまたは座標)。変わったら登録し直す。 + * @param icon 接続位置の計算に使うアイコン。[defaultIcon] が true で null なら既定のピン。 + */ +@Composable +private fun MapViewScope.RegisterInfoBubble( + id: String, + anchorKey: Any, + positionProvider: () -> GeoPointInterface, + icon: MarkerIconInterface?, + defaultIcon: Boolean, + tailOffset: Offset, + content: @Composable () -> Unit, +) { + val currentContent = rememberUpdatedState(content) val entry = - InfoBubbleEntry( - id = marker.id, - positionProvider = { marker.position }, - icon = marker.icon ?: DefaultMarkerIcon(), - tailOffset = tailOffset, - content = content, - ) + remember(id, anchorKey, icon, tailOffset) { + InfoBubbleEntry( + id = id, + positionProvider = positionProvider, + icon = icon ?: if (defaultIcon) DefaultMarkerIcon() else null, + tailOffset = tailOffset, + content = { currentContent.value() }, + ) + } - // Re-register when marker or tail changes - DisposableEffect(marker, tailOffset) { + DisposableEffect(entry) { val newMap = bubbleFlow.value.toMutableMap() newMap[entry.id] = entry bubbleFlow.value = newMap onDispose { - val newMap = bubbleFlow.value.toMutableMap() - newMap.remove(entry.id) - bubbleFlow.value = newMap + // 差し替えでは新しいエントリが同じ id で入る。消すのは自分が入れたものだけ。 + val current = bubbleFlow.value + if (current[entry.id] === entry) { + val newMap = current.toMutableMap() + newMap.remove(entry.id) + bubbleFlow.value = newMap + } } } } diff --git a/src/test/java/com/mapconductor/compose/info/InfoBubbleRegistrationTest.kt b/src/test/java/com/mapconductor/compose/info/InfoBubbleRegistrationTest.kt new file mode 100644 index 0000000..2ed556d --- /dev/null +++ b/src/test/java/com/mapconductor/compose/info/InfoBubbleRegistrationTest.kt @@ -0,0 +1,253 @@ +package com.mapconductor.compose.info + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.mapconductor.compose.MapViewScope +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.marker.BitmapIcon +import com.mapconductor.core.marker.MarkerIconInterface +import com.mapconductor.core.marker.MarkerState +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * InfoBubble / InfoBubbleCustom が、後から変わった中身・見た目・アイコンに追従すること。 + * + * 以前は `DisposableEffect(marker)` の中で最初の合成時のエントリを登録していたので、 + * 中身のラムダが捕まえた値や `bubbleColor`、マーカーのアイコンが変わっても吹き出しは + * 古いままだった。落ちもせず吹き出しも出るので、画面で「数字が変わらない」を見るまで + * 気づけない。 + * + * 直し方の約束: + * - 中身や見た目が変わっても**エントリは同じ参照のまま**(bubbleFlow を鳴らさない) + * - それでもエントリの content を描けば**最新の中身**が出る + * - 位置・アイコン・接続点が変わったときだけエントリを差し替える + * + * UI を持たない Applier の上で Compose ランタイムだけを回す。中身の描画確認は + * UI を出さない InfoBubbleCustom で行う。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class InfoBubbleRegistrationTest { + // MapViewScope の OverlayCollector は Main で購読を始める。JVM には Main が無い。 + @Before + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @After + fun tearDown() = Dispatchers.resetMain() + + private class NoNodeApplier : AbstractApplier(Unit) { + override fun insertTopDown( + index: Int, + instance: Unit, + ) = Unit + + override fun insertBottomUp( + index: Int, + instance: Unit, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit + + override fun move( + from: Int, + to: Int, + count: Int, + ) = Unit + + override fun onClear() = Unit + } + + /** [content] を合成し、[steps] を順に実行して、各手順のあと再合成が落ち着くまで回す。 */ + private fun compose( + content: @Composable () -> Unit, + vararg steps: () -> Unit, + ) = runBlocking { + val clock = BroadcastFrameClock() + withContext(clock + Dispatchers.Unconfined) { + val recomposer = Recomposer(coroutineContext) + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + val composition = Composition(NoNodeApplier(), recomposer) + composition.setContent(content) + + suspend fun settle() { + repeat(5) { + Snapshot.sendApplyNotifications() + yield() + clock.sendFrame(0L) + yield() + } + } + settle() + for (step in steps) { + step() + settle() + } + composition.dispose() + recomposer.close() + job.join() + } + } + + /** DefaultMarkerIcon は JVM では作れない(Typeface などが要る)ので、寸法だけの代役。 */ + private class StubIcon( + override val iconSize: Dp, + ) : MarkerIconInterface { + override val scale = 1f + override val anchor = Offset(0.5f, 1f) + override val infoAnchor = Offset(0.5f, 0f) + override val debug = false + + override fun toBitmapIcon(): BitmapIcon = error("not drawn in this test") + } + + private fun marker() = + MarkerState( + id = "m1", + position = GeoPoint.fromLatLong(35.0, 139.0), + icon = StubIcon(48.dp), + ) + + @Test + fun customBubbleShowsLatestContentWithoutReRegistering() { + val scope = MapViewScope() + val marker = marker() + var label by mutableStateOf("count 0") + val rendered = mutableListOf() + val entries = mutableListOf() + + compose( + { + // 値をラムダの外で読む: ラムダは String を捕まえるので、毎回別のラムダになる。 + val text = label + scope.InfoBubbleCustom(marker = marker, tailOffset = Offset(0.5f, 1f)) { + rendered += text + } + // プロバイダ側の描画(MapViewBase と同じく bubbleFlow を読んで content を呼ぶ)。 + val bubbles by scope.bubbleFlow.collectAsState() + bubbles.values.forEach { entry -> key(entry.id) { entry.content() } } + }, + { entries += scope.bubbleFlow.value["m1"] }, + { label = "count 1" }, + { entries += scope.bubbleFlow.value["m1"] }, + { label = "count 2" }, + { entries += scope.bubbleFlow.value["m1"] }, + ) + + assertEquals("count 2", rendered.last()) + assertTrue("count 1 も描かれている: $rendered", "count 1" in rendered) + assertSame("中身の変更でエントリを作り直さない", entries[0], entries[1]) + assertSame(entries[1], entries[2]) + assertNull("合成を抜けたら登録も消える", scope.bubbleFlow.value["m1"]) + } + + @Test + fun styledBubbleKeepsEntryWhenOnlyAppearanceChanges() { + val scope = MapViewScope() + val marker = marker() + var color by mutableStateOf(Color.White) + val entries = mutableListOf() + + compose( + { + scope.InfoBubble(marker = marker, bubbleColor = color) {} + val bubbles by scope.bubbleFlow.collectAsState() + bubbles.size // 読むだけ + }, + { entries += scope.bubbleFlow.value["m1"] }, + { color = Color.Yellow }, + { color = Color.Green }, + { entries += scope.bubbleFlow.value["m1"] }, + ) + assertSame("見た目の変更でエントリを作り直さない", entries[0], entries[1]) + } + + @Test + fun iconChangeOnTheSameMarkerReplacesTheEntry() { + val scope = MapViewScope() + val marker = marker() + val oldIcon = marker.icon + val newIcon = StubIcon(64.dp) + val entries = mutableListOf() + + compose( + { scope.InfoBubble(marker = marker) {} }, + { entries += scope.bubbleFlow.value["m1"] }, + { marker.icon = newIcon }, + { entries += scope.bubbleFlow.value["m1"] }, + ) + + assertNotSame("アイコンが変わったら接続位置の計算を変えるため差し替える", entries[0], entries[1]) + assertSame(newIcon, entries[1]?.icon) + assertSame(oldIcon, entries[0]?.icon) + } + + @Test + fun positionBubbleIsReRegisteredOnlyWhenThePositionChanges() { + val scope = MapViewScope() + var position by mutableStateOf(GeoPoint.fromLatLong(35.0, 139.0)) + var label by mutableStateOf("a") + val entries = mutableListOf() + + compose( + { + val text = label + scope.InfoBubble(position = position) { text.length } + }, + { + entries += + scope.bubbleFlow.value.values + .single() + }, + { label = "b" }, + { + entries += + scope.bubbleFlow.value.values + .single() + }, + { position = GeoPoint.fromLatLong(36.0, 140.0) }, + { + entries += + scope.bubbleFlow.value.values + .single() + }, + ) + + assertSame(entries[0], entries[1]) + assertNotSame(entries[1], entries[2]) + assertEquals(36.0, entries[2]!!.positionProvider().latitude, 0.0) + } +} From 720a139d625acc9564c7c2d21b50d316a568722d Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 19:25:12 -0800 Subject: [PATCH 05/15] Save uiSettings and camera paddings across configuration changes The Compose BaseMapViewSaver, which every android-for-* saver extends, did not save uiSettings, so after a rotation every gesture flag came back true. It now saves uiSettings and camera paddings through core's MapViewStateBundle. Adds a device round-trip test. --- .../map/BaseMapViewSaverRoundTripTest.kt | 125 ++++++++++++++++++ .../compose/map/BaseMapViewSaver.kt | 44 +++--- 2 files changed, 146 insertions(+), 23 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/compose/map/BaseMapViewSaverRoundTripTest.kt diff --git a/src/androidTest/java/com/mapconductor/compose/map/BaseMapViewSaverRoundTripTest.kt b/src/androidTest/java/com/mapconductor/compose/map/BaseMapViewSaverRoundTripTest.kt new file mode 100644 index 0000000..977b2bb --- /dev/null +++ b/src/androidTest/java/com/mapconductor/compose/map/BaseMapViewSaverRoundTripTest.kt @@ -0,0 +1,125 @@ +package com.mapconductor.compose.map + +import androidx.compose.runtime.saveable.SaverScope +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.AttributionRule +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapDesignTypeInterface +import com.mapconductor.core.map.MapPaddings +import com.mapconductor.core.map.MapUISettings +import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.map.MapViewStateBundle +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import android.os.Bundle +import android.os.Parcel + +/** + * プロバイダの `*MapViewSaver` が継承する [BaseMapViewSaver] が、uiSettings とカメラ + * (paddings を含む)を落とさないこと。Bundle は実機の Parcel を一度通す。 + */ +@RunWith(AndroidJUnit4::class) +class BaseMapViewSaverRoundTripTest { + @Test + fun keepsUiSettingsCameraAndDesignRules() { + val state = + FakeState( + id = "compose-state", + design = FakeDesign("d", listOf(AttributionRule("© test", minZoom = 2))), + camera = + MapCameraPosition( + position = GeoPoint(-33.86, 151.21), + zoom = 9.0, + bearing = 90.0, + tilt = 30.0, + paddings = MapPaddings(top = 5.0, left = 6.0, bottom = 7.0, right = 8.0), + ), + ) + state.uiSettings = MapUISettings(rotateGesture = false, tiltGesture = false) + + val saver = FakeSaver().createSaver() + val saved = with(saver) { SaverScope { true }.save(state) }!! + val restored = saver.restore(throughParcel(saved))!! + + assertEquals("compose-state", restored.id) + assertEquals("d", restored.mapDesignType.id) + assertEquals( + "© test", + restored.mapDesignType.attributionRules + .single() + .attribution, + ) + assertEquals( + 2, + restored.mapDesignType.attributionRules + .single() + .minZoom, + ) + assertEquals(true, restored.uiSettings.scrollGesture) + assertEquals(true, restored.uiSettings.zoomGesture) + assertEquals(false, restored.uiSettings.rotateGesture) + assertEquals(false, restored.uiSettings.tiltGesture) + assertEquals(-33.86, restored.cameraPosition.position.latitude, 1e-9) + assertEquals(151.21, restored.cameraPosition.position.longitude, 1e-9) + assertEquals(9.0, restored.cameraPosition.zoom, 1e-9) + assertEquals(90.0, restored.cameraPosition.bearing, 1e-9) + assertEquals(30.0, restored.cameraPosition.tilt, 1e-9) + assertEquals(5.0, restored.cameraPosition.paddings!!.top, 1e-9) + assertEquals(8.0, restored.cameraPosition.paddings!!.right, 1e-9) + } + + private class FakeDesign( + override val id: String, + override val attributionRules: List, + ) : MapDesignTypeInterface { + override fun getValue(): String = id + } + + private class FakeState( + override val id: String, + design: FakeDesign, + camera: MapCameraPosition = MapCameraPosition.Default, + ) : MapViewState(camera) { + override var mapDesignType: FakeDesign = design + } + + private class FakeSaver : BaseMapViewSaver() { + override fun saveMapDesign( + state: FakeState, + bundle: Bundle, + ) { + bundle.putString("id", state.mapDesignType.id) + MapViewStateBundle.putAttributionRules(bundle, state.mapDesignType.attributionRules) + } + + override fun createState( + stateId: String, + mapDesignBundle: Bundle?, + cameraPosition: MapCameraPosition, + ): FakeState = + FakeState( + id = stateId, + design = + FakeDesign( + id = mapDesignBundle?.getString("id") ?: "", + attributionRules = MapViewStateBundle.getAttributionRules(mapDesignBundle), + ), + camera = cameraPosition, + ) + + override fun getStateId(state: FakeState): String = state.id + } + + private fun throughParcel(bundle: Bundle): Bundle { + val parcel = Parcel.obtain() + try { + parcel.writeBundle(bundle) + parcel.setDataPosition(0) + return parcel.readBundle(javaClass.classLoader)!! + } finally { + parcel.recycle() + } + } +} diff --git a/src/main/java/com/mapconductor/compose/map/BaseMapViewSaver.kt b/src/main/java/com/mapconductor/compose/map/BaseMapViewSaver.kt index e1fb919..6b7f35a 100644 --- a/src/main/java/com/mapconductor/compose/map/BaseMapViewSaver.kt +++ b/src/main/java/com/mapconductor/compose/map/BaseMapViewSaver.kt @@ -1,16 +1,25 @@ package com.mapconductor.compose.map import androidx.compose.runtime.saveable.Saver -import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.InternalMapConductorApi import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapPaddingsInterface +import com.mapconductor.core.map.MapViewStateBundle import com.mapconductor.core.map.MapViewStateInterface import android.os.Bundle /** * Base class for MapView state savers + * + * カメラ(paddings を含む)と [MapViewStateInterface.uiSettings] はここで保存・復元する。 + * デザインは型がプロバイダごとに違うので [saveMapDesign] / [createState] が受け持つ。 + * デザインの `attributionRules` を含め、アプリが設定したものは全部書くこと + * (`MapViewStateBundle.putAttributionRules` を使う)。書かなかった値は回転などの + * 構成変更のあと黙って既定値に戻る。 + * * @param T MapViewStateInterface type */ +@OptIn(InternalMapConductorApi::class) abstract class BaseMapViewSaver> { /** * Save map design type to bundle @@ -50,6 +59,7 @@ abstract class BaseMapViewSaver> { putString("stateId", getStateId(state)) putBundle("mapDesign", mapDesignBundle) putBundle("camera", cameraStateBundle) + putBundle(MapViewStateBundle.KEY_UI_SETTINGS, MapViewStateBundle.uiSettingsToBundle(state.uiSettings)) } }, restore = { storedData -> @@ -59,7 +69,14 @@ abstract class BaseMapViewSaver> { val cameraPosition = createCameraPositionFromBundle(cameraBundle) - createState(stateId, mapDesignBundle, cameraPosition) + createState(stateId, mapDesignBundle, cameraPosition).also { state -> + // uiSettings はどのプロバイダでも MapViewStateInterface の var なので、 + // createState に渡さずここで戻す(以前は保存しておらず、復元後は + // 全部 true の既定値に戻っていた)。 + MapViewStateBundle.uiSettingsFromBundle(storedData.getBundle(MapViewStateBundle.KEY_UI_SETTINGS))?.let { + state.uiSettings = it + } + } }, ) @@ -68,27 +85,8 @@ abstract class BaseMapViewSaver> { */ protected abstract fun getStateId(state: T): String - private fun createCameraBundle(state: T): Bundle = - state.cameraPosition.let { - Bundle().apply { - putDouble("zoom", it.zoom) - putDouble("tilt", it.tilt) - putDouble("bearing", it.bearing) - putDouble("latitude", it.position.latitude) - putDouble("longitude", it.position.longitude) - } - } + private fun createCameraBundle(state: T): Bundle = MapViewStateBundle.cameraToBundle(state.cameraPosition) private fun createCameraPositionFromBundle(cameraBundle: Bundle?): MapCameraPosition = - MapCameraPosition( - position = - GeoPoint.fromLatLong( - latitude = cameraBundle?.getDouble("latitude") ?: 0.0, - longitude = cameraBundle?.getDouble("longitude") ?: 0.0, - ), - zoom = cameraBundle?.getDouble("zoom") ?: 0.0, - bearing = cameraBundle?.getDouble("bearing") ?: 0.0, - tilt = cameraBundle?.getDouble("tilt") ?: 0.0, - paddings = getCameraPaddings(), - ) + MapViewStateBundle.cameraFromBundle(cameraBundle, fallbackPaddings = getCameraPaddings()) } From b8eae5b514deba5bdaca16a4e2218fd4f27413de Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 6 Nov 2026 20:49:58 -0800 Subject: [PATCH 06/15] Add Polygons(states) to register many polygons in one effect Same shape as Markers(states): diffs the list against the ids this call added last time and publishes the result in one collector update, so several Polygons()/Polygon() can share a map. --- api/android-sdk-compose.api | 1 + .../compose/polygon/PolygonComponent.kt | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index d5e97f2..f7a2295 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -79,6 +79,7 @@ public final class com.mapconductor.compose.polygon.LocalPolygonCollectorKt { } public final class com.mapconductor.compose.polygon.PolygonComponentKt { public static final void Polygon(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polygon.PolygonState, androidx.compose.runtime.Composer, int); + public static final void Polygons(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); public static final void Polygon-YoVAMyA(com.mapconductor.compose.MapViewScope, java.util.List, java.util.List>, java.lang.String, long, float, long, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); public static final void Polygon-IvR-a-0(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, java.lang.String, long, float, long, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); } diff --git a/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt b/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt index 9bc164f..da37eb4 100644 --- a/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt +++ b/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt @@ -3,6 +3,8 @@ package com.mapconductor.compose.polygon import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -14,6 +16,9 @@ import com.mapconductor.core.polygon.OnPolygonEventHandler import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polygon.unionHolesInPlace import java.io.Serializable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext @Composable fun MapViewScope.Polygon(state: PolygonState) { @@ -32,6 +37,57 @@ fun MapViewScope.Polygon(state: PolygonState) { } } +/** + * Efficiently add many polygons without creating one Composable per polygon. + * + * The same shape as [com.mapconductor.compose.marker.Markers]: one effect diffs + * the list against the ids this call added last time and publishes the result + * in a single collector update. + */ +@Composable +fun MapViewScope.Polygons(states: List) { + val collector = LocalPolygonCollector.current + val prevIdsState = remember { mutableStateOf>(emptySet()) } + + LaunchedEffect(states) { + withContext(Dispatchers.Default) { + val prevIds = prevIdsState.value + val nextIds = states.asSequence().map { it.id }.toSet() + val removedIds = prevIds - nextIds + // Union the holes once per newly added state, as Polygon(state) does. + for (state in states) { + if (state.id !in prevIds && state.holes.size > 1) { + state.unionHolesInPlace() + } + } + prevIdsState.value = nextIds + // Merge only the ids this Polygons() owns, so several + // Polygons()/Polygon() can share one map's collector. + collector.flow.update { current -> + val next = current.toMutableMap() + for (id in removedIds) next.remove(id) + for (state in states) next[state.id] = state + next + } + } + } + + DisposableEffect(Unit) { + onDispose { + // Remove only the polygons this Polygons() owns on dispose. + val ownIds = prevIdsState.value + if (ownIds.isNotEmpty()) { + collector.flow.update { current -> + val next = current.toMutableMap() + for (id in ownIds) next.remove(id) + next + } + } + prevIdsState.value = emptySet() + } + } +} + @Composable fun MapViewScope.Polygon( points: List, From 06d69924d0dad5b853e92446ee862626c56134b9 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 13:16:37 -0800 Subject: [PATCH 07/15] Release the map view when MapViewBase leaves the composition MapViewBase destroyed the controller on dispose but nobody destroyed the SDK's map view. Its render thread kept the view, and through the camera listeners the old controller with every overlay, so each provider switch left the previous map in memory until the app ran out of it. MapViewBase now takes onDestroyMapView. On dispose it destroys the controller, detaches the view and hands it to the provider once. This also runs for a configuration change: the old Activity's view is never shown again, and what survives a rotation is the saved state. A provider that does not pass it gets a warning in the log instead of a silent leak. The previous signature stays as a hidden overload so code compiled against it still links (same JVM descriptor; the .api line differs only in the erased generic signature). --- api/android-sdk-compose.api | 3 +- .../mapconductor/compose/map/MapViewBase.kt | 95 ++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index d5e97f2..e811940 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -61,7 +61,8 @@ public final class com.mapconductor.compose.map.MapAttributionOverlayKt { public static final void MapAttributionOverlay(java.util.List, androidx.compose.runtime.Composer, int); } public final class com.mapconductor.compose.map.MapViewBaseKt { - public static final , SpecificController extends com.mapconductor.core.controller.BaseMapViewController, ActualMapView extends android.view.View, ActualMap, SpecificScope extends com.mapconductor.compose.MapViewScope, SpecificHolder extends com.mapconductor.core.map.MapViewHolderInterface> void MapViewBase(SpecificState, androidx.compose.runtime.MutableState, androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2, SpecificScope, com.mapconductor.core.map.MapOverlayRegistry, kotlin.jvm.functions.Function1, ? extends java.lang.Object>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function4, ? super androidx.compose.runtime.Composer, ? super java.lang.Integer, kotlin.Unit>, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final , SpecificController extends com.mapconductor.core.controller.BaseMapViewController, ActualMapView extends android.view.View, ActualMap, SpecificScope extends com.mapconductor.compose.MapViewScope, SpecificHolder extends com.mapconductor.core.map.MapViewHolderInterface> void MapViewBase(SpecificState, androidx.compose.runtime.MutableState, androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2, SpecificScope, com.mapconductor.core.map.MapOverlayRegistry, kotlin.jvm.functions.Function1, ? extends java.lang.Object>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function4, ? super androidx.compose.runtime.Composer, ? super java.lang.Integer, kotlin.Unit>, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void MapViewBase(com.mapconductor.core.map.MapViewStateInterface, androidx.compose.runtime.MutableState, androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2, com.mapconductor.compose.MapViewScope, com.mapconductor.core.map.MapOverlayRegistry, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function4, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.compose.marker.LocalMarkerCollectorKt { public static final androidx.compose.runtime.ProvidableCompositionLocal> getLocalMarkerCollector(); diff --git a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt index 8d762d8..46f3cb3 100644 --- a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt +++ b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -109,6 +110,17 @@ fun < customDisposableEffect: (@Composable (InitState, Ref) -> Unit)? = null, style: MapViewStyle? = null, onStyleDiagnostics: ((List) -> Unit)? = null, + /** + * Releases the SDK's map view after the map has left the composition. + * + * Called once, on the main thread, after the controller has been destroyed + * and the view has been taken out of its parent. Every provider has to pass + * this: a map view nobody destroys keeps its render thread (and through its + * listeners the controller with every overlay) alive until the process runs + * out of memory. It runs for a configuration change too -- the old Activity's + * view is never shown again; what survives a rotation is the state. + */ + onDestroyMapView: ((ActualMapView) -> Unit)? = null, content: (@Composable SpecificScope.() -> Unit)? = null, ) { ResourceProvider.init(LocalContext.current) @@ -450,16 +462,95 @@ fun < LaunchedEffect(state.uiSettings, initState) { controllerRef.value?.applyUISettings(state.uiSettings) } + val currentOnDestroyMapView by rememberUpdatedState(onDestroyMapView) DisposableEffect(Unit) { - // Setup logic when the composable enters the screen - onDispose { + // Overlays first, while the map they live on is still alive. controllerRef.value?.destroy() + controllerRef.value = null + holderRef.value = null + val mapView = mapViewRef.value ?: return@onDispose + mapViewRef.value = null + destroyMapView( + mapView = mapView, + provider = state::class.java.simpleName, + onDestroyMapView = currentOnDestroyMapView, + ) } } customDisposableEffect?.invoke(initState, holderRef) } +/** + * The signature before `onDestroyMapView`, kept so code compiled against it + * still links. It cannot release the map view; see the overload above. + */ +@Deprecated("Pass onDestroyMapView so the map view is released", level = DeprecationLevel.HIDDEN) +@Composable +fun < + SpecificState : MapViewStateInterface<*>, + SpecificController : BaseMapViewController, + ActualMapView : View, + ActualMap : Any, + SpecificScope : MapViewScope, + SpecificHolder : MapViewHolderInterface, +> MapViewBase( + state: SpecificState, + cameraState: MutableState, + modifier: Modifier = Modifier, + viewProvider: @Composable () -> ActualMapView, + scope: SpecificScope, + registry: MapOverlayRegistry, + sdkInitialize: suspend () -> Boolean = { true }, + holderProvider: suspend (mapView: ActualMapView) -> SpecificHolder, + controllerProvider: suspend (holder: SpecificHolder) -> SpecificController, + onMapLoaded: OnMapLoadedHandler? = null, + customDisposableEffect: (@Composable (InitState, Ref) -> Unit)? = null, + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, + content: (@Composable SpecificScope.() -> Unit)? = null, +) = MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = viewProvider, + scope = scope, + registry = registry, + sdkInitialize = sdkInitialize, + holderProvider = holderProvider, + controllerProvider = controllerProvider, + onMapLoaded = onMapLoaded, + customDisposableEffect = customDisposableEffect, + style = style, + onStyleDiagnostics = onStyleDiagnostics, + onDestroyMapView = null, + content = content, +) + +private fun destroyMapView( + mapView: ActualMapView, + provider: String, + onDestroyMapView: ((ActualMapView) -> Unit)?, +) { + if (onDestroyMapView == null) { + // Not passing it compiles and the map still draws; only the memory goes. + Log.w( + "MapConductor", + "$provider does not release its map view (MapViewBase onDestroyMapView is null); " + + "the old map view and its controller stay in memory", + ) + return + } + // Detach before destroying, so the view's surface goes away while the SDK + // renderer is still there to handle it. + (mapView.parent as? ViewGroup)?.removeView(mapView) + try { + onDestroyMapView(mapView) + } catch (e: Exception) { + Log.w("MapConductor", "$provider: releasing the map view failed", e) + } +} + @Composable private fun BasicMessage(text: String) { Box( From cb498c11c03c15dcad0cdbf19c65f71c94fa94db Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 15:19:03 -0800 Subject: [PATCH 08/15] Add MapViewLifecycleOwner, a map view's own lifecycle Some SDKs keep what a map view holds -- lifecycleScope coroutines, observers, component callbacks -- until the lifecycle they were given (or found in the view tree) is destroyed. With the Activity's, a map the user switched away from stays until the Activity goes. MapViewLifecycleOwner follows the host lifecycle and can be ended on its own from onDestroyMapView. --- api/android-sdk-compose.api | 5 ++ .../compose/map/MapViewLifecycleOwner.kt | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/main/java/com/mapconductor/compose/map/MapViewLifecycleOwner.kt diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index e811940..55ce351 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -64,6 +64,11 @@ public final class com.mapconductor.compose.map.MapViewBaseKt { public static final , SpecificController extends com.mapconductor.core.controller.BaseMapViewController, ActualMapView extends android.view.View, ActualMap, SpecificScope extends com.mapconductor.compose.MapViewScope, SpecificHolder extends com.mapconductor.core.map.MapViewHolderInterface> void MapViewBase(SpecificState, androidx.compose.runtime.MutableState, androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2, SpecificScope, com.mapconductor.core.map.MapOverlayRegistry, kotlin.jvm.functions.Function1, ? extends java.lang.Object>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function4, ? super androidx.compose.runtime.Composer, ? super java.lang.Integer, kotlin.Unit>, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); public static final void MapViewBase(com.mapconductor.core.map.MapViewStateInterface, androidx.compose.runtime.MutableState, androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2, com.mapconductor.compose.MapViewScope, com.mapconductor.core.map.MapOverlayRegistry, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function4, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); } +public final class com.mapconductor.compose.map.MapViewLifecycleOwner implements androidx.lifecycle.LifecycleOwner { + public com.mapconductor.compose.map.MapViewLifecycleOwner(androidx.lifecycle.Lifecycle); + public androidx.lifecycle.Lifecycle getLifecycle(); + public final void destroy(); +} public final class com.mapconductor.compose.marker.LocalMarkerCollectorKt { public static final androidx.compose.runtime.ProvidableCompositionLocal> getLocalMarkerCollector(); } diff --git a/src/main/java/com/mapconductor/compose/map/MapViewLifecycleOwner.kt b/src/main/java/com/mapconductor/compose/map/MapViewLifecycleOwner.kt new file mode 100644 index 0000000..99285ee --- /dev/null +++ b/src/main/java/com/mapconductor/compose/map/MapViewLifecycleOwner.kt @@ -0,0 +1,47 @@ +package com.mapconductor.compose.map + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry + +/** + * A lifecycle for one map view that follows the host's but can end on its own. + * + * Some SDKs tie what a map view holds to the [LifecycleOwner] it is given or + * finds in the view tree -- coroutines in its `lifecycleScope`, observers, + * component callbacks -- and let go of it only when that lifecycle is + * destroyed. Given the Activity's, a map the user switched away from stays + * until the Activity goes. Give the view this instead and call [destroy] from + * `onDestroyMapView`. + * + * Until [destroy] it moves with [host] (it catches up on creation). [destroy] + * stops following and moves it down to DESTROYED, which the SDK sees as + * ON_PAUSE / ON_STOP / ON_DESTROY as far as it had got. Calling it again, or + * after the host was destroyed, does nothing. + */ +class MapViewLifecycleOwner( + private val host: Lifecycle, +) : LifecycleOwner { + private val registry = LifecycleRegistry(this) + + private val follow = + LifecycleEventObserver { _, event -> + if (registry.currentState != Lifecycle.State.DESTROYED) registry.handleLifecycleEvent(event) + } + + init { + host.addObserver(follow) + } + + override val lifecycle: Lifecycle + get() = registry + + fun destroy() { + host.removeObserver(follow) + // INITIALIZED cannot go straight to DESTROYED; nothing was started then. + if (registry.currentState.isAtLeast(Lifecycle.State.CREATED)) { + registry.currentState = Lifecycle.State.DESTROYED + } + } +} From 34b14ddb5d646829f644eee2121f193d593d1ac6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 16:20:26 -0800 Subject: [PATCH 09/15] Detach the destroyed controller from the state; drop the old signature MapViewBase now hands the controller it destroyed back to the state (MapViewState.detachController), so a state the app keeps no longer holds the old map's controller and overlays. Done here for every provider; ArcGIS did it on its own before. The hidden overload without onDestroyMapView is removed: code compiled against it has to be rebuilt (binary break, accepted). --- api/android-sdk-compose.api | 1 - .../mapconductor/compose/map/MapViewBase.kt | 55 +++---------------- 2 files changed, 8 insertions(+), 48 deletions(-) diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index 55ce351..ac630bf 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -62,7 +62,6 @@ public final class com.mapconductor.compose.map.MapAttributionOverlayKt { } public final class com.mapconductor.compose.map.MapViewBaseKt { public static final , SpecificController extends com.mapconductor.core.controller.BaseMapViewController, ActualMapView extends android.view.View, ActualMap, SpecificScope extends com.mapconductor.compose.MapViewScope, SpecificHolder extends com.mapconductor.core.map.MapViewHolderInterface> void MapViewBase(SpecificState, androidx.compose.runtime.MutableState, androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2, SpecificScope, com.mapconductor.core.map.MapOverlayRegistry, kotlin.jvm.functions.Function1, ? extends java.lang.Object>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function4, ? super androidx.compose.runtime.Composer, ? super java.lang.Integer, kotlin.Unit>, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); - public static final void MapViewBase(com.mapconductor.core.map.MapViewStateInterface, androidx.compose.runtime.MutableState, androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2, com.mapconductor.compose.MapViewScope, com.mapconductor.core.map.MapOverlayRegistry, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function4, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.compose.map.MapViewLifecycleOwner implements androidx.lifecycle.LifecycleOwner { public com.mapconductor.compose.map.MapViewLifecycleOwner(androidx.lifecycle.Lifecycle); diff --git a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt index 46f3cb3..ed268b0 100644 --- a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt +++ b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt @@ -61,6 +61,7 @@ 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.MapViewState import com.mapconductor.core.map.MapViewStateInterface import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.map.MapViewStyleEffect @@ -466,7 +467,13 @@ fun < DisposableEffect(Unit) { onDispose { // Overlays first, while the map they live on is still alive. - controllerRef.value?.destroy() + controllerRef.value?.let { controller -> + controller.destroy() + // The state outlives the map (apps keep it, e.g. one per provider + // while switching); without this it holds the destroyed controller + // and with it every overlay of the old map. + (state as? MapViewState<*>)?.detachController(controller) + } controllerRef.value = null holderRef.value = null val mapView = mapViewRef.value ?: return@onDispose @@ -481,52 +488,6 @@ fun < customDisposableEffect?.invoke(initState, holderRef) } -/** - * The signature before `onDestroyMapView`, kept so code compiled against it - * still links. It cannot release the map view; see the overload above. - */ -@Deprecated("Pass onDestroyMapView so the map view is released", level = DeprecationLevel.HIDDEN) -@Composable -fun < - SpecificState : MapViewStateInterface<*>, - SpecificController : BaseMapViewController, - ActualMapView : View, - ActualMap : Any, - SpecificScope : MapViewScope, - SpecificHolder : MapViewHolderInterface, -> MapViewBase( - state: SpecificState, - cameraState: MutableState, - modifier: Modifier = Modifier, - viewProvider: @Composable () -> ActualMapView, - scope: SpecificScope, - registry: MapOverlayRegistry, - sdkInitialize: suspend () -> Boolean = { true }, - holderProvider: suspend (mapView: ActualMapView) -> SpecificHolder, - controllerProvider: suspend (holder: SpecificHolder) -> SpecificController, - onMapLoaded: OnMapLoadedHandler? = null, - customDisposableEffect: (@Composable (InitState, Ref) -> Unit)? = null, - style: MapViewStyle? = null, - onStyleDiagnostics: ((List) -> Unit)? = null, - content: (@Composable SpecificScope.() -> Unit)? = null, -) = MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = viewProvider, - scope = scope, - registry = registry, - sdkInitialize = sdkInitialize, - holderProvider = holderProvider, - controllerProvider = controllerProvider, - onMapLoaded = onMapLoaded, - customDisposableEffect = customDisposableEffect, - style = style, - onStyleDiagnostics = onStyleDiagnostics, - onDestroyMapView = null, - content = content, -) - private fun destroyMapView( mapView: ActualMapView, provider: String, From 39a52ded9e349e59441c8c087f764399212a3f79 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 17:21:31 -0800 Subject: [PATCH 10/15] Detach from the state the controller was attached to; drop style mutations A cycle through every provider (Boston parcels) still kept two old maps: - ArcGIS 2D: the example hands the same MapViewBase a second state for a frame (ArcGIS 3D and 2D share a state type), the provider attached the controller to that one, and dispose detached it from the first. MapViewBase now remembers the state current when the controller was made and asks it, the latest and the first to let go (each only if it is still attached). - Mapbox: the VectorStyleMutationSupport store left in the state's registry holds the map's holder and controller. MapViewBase removes it with the other two capabilities it already took down. --- .../mapconductor/compose/map/MapViewBase.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt index ed268b0..d049d37 100644 --- a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt +++ b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt @@ -66,6 +66,7 @@ import com.mapconductor.core.map.MapViewStateInterface import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.map.MapViewStyleEffect import com.mapconductor.core.map.ScreenProjectionRequirement +import com.mapconductor.core.map.VectorStyleMutationSupportKey import com.mapconductor.core.map.VectorStyleSupportKey import com.mapconductor.core.map.resolveMapAttributions import com.mapconductor.core.marker.MarkerCapableInterface @@ -127,6 +128,7 @@ fun < ResourceProvider.init(LocalContext.current) val mapViewRef = remember { Ref() } val controllerRef = remember { Ref() } + val controllerStateRef = remember { Ref() } val holderRef = remember { Ref() } var initState by remember { mutableStateOf(InitState.NotStarted) } val bubbles by scope.bubbleFlow.collectAsState() @@ -145,6 +147,9 @@ fun < onDispose { state.serviceRegistry.remove(MarkerRenderingSupportKey) state.serviceRegistry.remove(VectorStyleSupportKey) + // Mapbox / MapLibre register a store whose callback holds the map's + // holder and controller; left here it keeps the whole old map. + state.serviceRegistry.remove(VectorStyleMutationSupportKey) } } @@ -454,6 +459,11 @@ fun < mapViewRef.value?.let { mapView -> val holder = holderProvider(mapView) holderRef.value = holder + // The provider attaches the controller to the state it was composed + // with now, which need not be the one this effect first saw: an app + // can hand the same MapViewBase another state (the example does, for + // a frame, between ArcGIS 3D and 2D). + controllerStateRef.value = state controllerRef.value = controllerProvider(holder) initState = InitState.MapCreating } @@ -464,6 +474,7 @@ fun < controllerRef.value?.applyUISettings(state.uiSettings) } val currentOnDestroyMapView by rememberUpdatedState(onDestroyMapView) + val currentState by rememberUpdatedState(state) DisposableEffect(Unit) { onDispose { // Overlays first, while the map they live on is still alive. @@ -471,9 +482,13 @@ fun < controller.destroy() // The state outlives the map (apps keep it, e.g. one per provider // while switching); without this it holds the destroyed controller - // and with it every overlay of the old map. - (state as? MapViewState<*>)?.detachController(controller) + // and with it every overlay of the old map. Each state lets go only + // if this controller is the one attached to it. + setOf(controllerStateRef.value, currentState, state).forEach { + (it as? MapViewState<*>)?.detachController(controller) + } } + controllerStateRef.value = null controllerRef.value = null holderRef.value = null val mapView = mapViewRef.value ?: return@onDispose From 365f7538acd4e8111c75a6859672b1c85a02103e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 19:19:16 -0800 Subject: [PATCH 11/15] CI: build against core's branch of the same name when there is one A change that adds API to core and uses it here is opened as a pair of PRs on branches of the same name. CI always built core's default branch, so the compose PR failed until core was merged (fix/mapview-teardown: detachController(controller) not found). Use core's same-named branch when it exists; otherwise the default branch, as before. --- .github/workflows/build.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5bfa6b3..54cf398 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,9 +37,21 @@ jobs: java-version: '17' cache: 'gradle' + # core と同時に出した変更(同じ名前のブランチ)は、その core と組む。 + # 既定ブランチの core では、まだ入っていない API を使う PR が必ず落ちる。 + # core に同じ名前のブランチが無ければ、これまでどおり既定ブランチを使う。 - name: Build core locally + env: + HEAD_REF: ${{ github.head_ref }} run: | - git clone --depth 1 https://github.com/MapConductor/android-sdk-core.git _core + CORE=https://github.com/MapConductor/android-sdk-core.git + if [ -n "$HEAD_REF" ] && git ls-remote --exit-code --heads "$CORE" "$HEAD_REF" > /dev/null; then + echo "core: $HEAD_REF" + git clone --depth 1 --branch "$HEAD_REF" "$CORE" _core + else + echo "core: default branch" + git clone --depth 1 "$CORE" _core + fi cd _core ./gradlew --no-daemon publishToMavenLocal cd .. From 60d3a460bfd4e2f600aa1a6cd5ea1bd275e1cf19 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 09:38:06 -0800 Subject: [PATCH 12/15] Add Polylines(states) and PolylineTiling(options) Polylines() is Polygons() for lines: it diffs the list against the ids it added last time and publishes one collector update, so several Polylines()/Polyline() share a map. PolylineTiling() sets the map's polyline tiling (MapViewScope. polylineTilingOptions), read when the polylines are handed over. --- api/android-sdk-compose.api | 3 + .../mapconductor/compose/OverlayProvider.kt | 9 +- .../compose/polyline/PolylineComponent.kt | 70 +++++++ .../polyline/PolylinesCollectionTest.kt | 172 ++++++++++++++++++ 4 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/mapconductor/compose/polyline/PolylinesCollectionTest.kt diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index 76b5a43..a5ba4ed 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -4,6 +4,7 @@ public class com.mapconductor.compose.MapViewScope { public final kotlinx.coroutines.flow.MutableStateFlow> getBubbleFlow(); public final kotlinx.coroutines.flow.MutableStateFlow> getMarkerAnimationFlow(); public final com.mapconductor.core.OverlayCollector getPolylineCollector(); + public final kotlinx.coroutines.flow.MutableStateFlow getPolylineTilingOptions(); public final com.mapconductor.core.OverlayCollector getCircleCollector(); public final com.mapconductor.core.OverlayCollector getPolygonCollector(); public final com.mapconductor.core.OverlayCollector getGroundImageCollector(); @@ -93,6 +94,8 @@ public final class com.mapconductor.compose.polyline.LocalPolylineCollectorKt { } public final class com.mapconductor.compose.polyline.PolylineComponentKt { public static final void Polyline(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineState, androidx.compose.runtime.Composer, int); + public static final void Polylines(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void PolylineTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineTilingOptions, androidx.compose.runtime.Composer, int); public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, java.util.List, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); } diff --git a/src/main/java/com/mapconductor/compose/OverlayProvider.kt b/src/main/java/com/mapconductor/compose/OverlayProvider.kt index 29c804f..ec599c0 100644 --- a/src/main/java/com/mapconductor/compose/OverlayProvider.kt +++ b/src/main/java/com/mapconductor/compose/OverlayProvider.kt @@ -19,6 +19,7 @@ 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.polyline.PolylineTilingOptions import com.mapconductor.core.raster.RasterLayerOverlay import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.settings.Settings @@ -38,6 +39,12 @@ open class MapViewScope { OverlayCollector( updateDebounce = Settings.Default.composeEventDebounce, ) + + /** + * This map's polyline tiling, set by [com.mapconductor.compose.polyline.PolylineTiling]; + * null keeps the provider's default ([PolylineTilingOptions.Default]). + */ + val polylineTilingOptions = MutableStateFlow(null) val circleCollector = OverlayCollector( updateDebounce = Settings.Default.composeEventDebounce, @@ -59,7 +66,7 @@ open class MapViewScope { val registry = MapOverlayRegistry() registry.register(MarkerOverlay(markerCollector.flow)) registry.register(CircleOverlay(circleCollector.flow)) - registry.register(PolylineOverlay(polylineCollector.flow)) + registry.register(PolylineOverlay(polylineCollector.flow, polylineTilingOptions)) registry.register(PolygonOverlay(polygonCollector.flow)) registry.register(GroundImageOverlay(groundImageCollector.flow)) registry.register(RasterLayerOverlay(rasterLayerCollector.flow)) diff --git a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt index ca6a3b1..d09c26d 100644 --- a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt +++ b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt @@ -3,6 +3,9 @@ package com.mapconductor.compose.polyline import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -12,7 +15,11 @@ import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.features.GeoRectBounds import com.mapconductor.core.polyline.OnPolylineEventHandler import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTilingOptions import java.io.Serializable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext @Composable fun MapViewScope.Polyline(state: PolylineState) { @@ -28,6 +35,69 @@ fun MapViewScope.Polyline(state: PolylineState) { } } +/** + * Efficiently add many polylines without creating one Composable per polyline. + * + * The same shape as [com.mapconductor.compose.polygon.Polygons] and + * [com.mapconductor.compose.marker.Markers]: one effect diffs the list against + * the ids this call added last time and publishes the result in a single + * collector update. Several Polylines()/Polyline() can share one map. + */ +@Composable +fun MapViewScope.Polylines(states: List) { + val collector = LocalPolylineCollector.current + val prevIdsState = remember { mutableStateOf>(emptySet()) } + + LaunchedEffect(states) { + withContext(Dispatchers.Default) { + val prevIds = prevIdsState.value + val nextIds = states.asSequence().map { it.id }.toSet() + val removedIds = prevIds - nextIds + prevIdsState.value = nextIds + // Merge only the ids this Polylines() owns, so several + // Polylines()/Polyline() can share one map's collector. + collector.flow.update { current -> + val next = current.toMutableMap() + for (id in removedIds) next.remove(id) + for (state in states) next[state.id] = state + next + } + } + } + + DisposableEffect(Unit) { + onDispose { + // Remove only the polylines this Polylines() owns on dispose. + val ownIds = prevIdsState.value + if (ownIds.isNotEmpty()) { + collector.flow.update { current -> + val next = current.toMutableMap() + for (id in ownIds) next.remove(id) + next + } + } + prevIdsState.value = emptySet() + } + } +} + +/** + * How this map draws many polylines: as raster tiles once there are enough + * of them ([PolylineTilingOptions.Default]), never ([PolylineTilingOptions.Disabled]), + * or at other thresholds. Without it the map uses [PolylineTilingOptions.Default]. + * + * Read when the polylines are handed to the map, so put it in the map's + * content before them; a change takes effect the next time they are handed + * over (recreate the map to apply it to an unchanged list). + */ +@Composable +fun MapViewScope.PolylineTiling(options: PolylineTilingOptions) { + val scope = this + SideEffect { + scope.polylineTilingOptions.value = options + } +} + @Composable fun MapViewScope.Polyline( points: List, diff --git a/src/test/java/com/mapconductor/compose/polyline/PolylinesCollectionTest.kt b/src/test/java/com/mapconductor/compose/polyline/PolylinesCollectionTest.kt new file mode 100644 index 0000000..4ee6f69 --- /dev/null +++ b/src/test/java/com/mapconductor/compose/polyline/PolylinesCollectionTest.kt @@ -0,0 +1,172 @@ +package com.mapconductor.compose.polyline + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.mapconductor.compose.MapViewScope +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTilingOptions +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * `Polylines(states)` は `Polygons(states)`・`Markers(states)` と同じく、そのインスタンスが + * 持つ id だけを地図の collector に足し引きする。複数の Polylines / Polyline が同じ地図で + * 共存でき、片方を外しても他方は残る。 + * + * js-sdk-react の `Polylines.test.tsx`、ios-sdk の `PolylinesContentTests` と同じ内容。 + * UI を持たない Applier の上で Compose ランタイムだけを回す(InfoBubbleRegistrationTest と同じ)。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PolylinesCollectionTest { + @Before + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @After + fun tearDown() = Dispatchers.resetMain() + + private class NoNodeApplier : AbstractApplier(Unit) { + override fun insertTopDown( + index: Int, + instance: Unit, + ) = Unit + + override fun insertBottomUp( + index: Int, + instance: Unit, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit + + override fun move( + from: Int, + to: Int, + count: Int, + ) = Unit + + override fun onClear() = Unit + } + + /** + * [content] を合成し、[steps] を順に実行して、各手順のあと再合成と + * Polylines の差分(Dispatchers.Default で走る)が落ち着くまで回す。 + */ + private fun compose( + content: @Composable () -> Unit, + vararg steps: () -> Unit, + ) = runBlocking { + val clock = BroadcastFrameClock() + withContext(clock + Dispatchers.Unconfined) { + val recomposer = Recomposer(coroutineContext) + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + val composition = Composition(NoNodeApplier(), recomposer) + composition.setContent(content) + + suspend fun settle() { + repeat(10) { + Snapshot.sendApplyNotifications() + yield() + clock.sendFrame(0L) + yield() + Thread.sleep(20) + } + } + settle() + for (step in steps) { + step() + settle() + } + composition.dispose() + recomposer.close() + job.join() + } + } + + // 点を id ごとに変える: PolylineState の equals は id を見ないので、同じ点だと + // 別の一覧に差し替えても mutableStateOf が変化と見なさない。 + private fun line(id: String): PolylineState { + val d = id.hashCode() % 1000 * 1e-5 + return PolylineState( + points = listOf(GeoPoint.fromLatLong(42.35 + d, -71.06), GeoPoint.fromLatLong(42.36 + d, -71.05)), + id = id, + ) + } + + @Test + fun twoPolylinesShareTheMap_andRemovingOneKeepsTheOther() { + val scope = MapViewScope() + val a = listOf(line("a1"), line("a2")) + val b = listOf(line("b1")) + var showB by mutableStateOf(true) + var listA by mutableStateOf(a) + val seen = mutableListOf>() + + compose( + { + CompositionLocalProvider(LocalPolylineCollector provides scope.polylineCollector) { + scope.Polylines(listA) + if (showB) scope.Polylines(b) + scope.Polyline(line("single")) + } + }, + { seen += scope.polylineCollector.flow.value.keys }, + // 片方の Polylines を外す: 自分の id だけ消える + { showB = false }, + { seen += scope.polylineCollector.flow.value.keys }, + // 一覧を差し替える: 消えた id だけ消え、他の Polylines / Polyline のものは残る + { listA = listOf(line("a2"), line("a3")) }, + { seen += scope.polylineCollector.flow.value.keys }, + ) + + assertEquals(setOf("a1", "a2", "b1", "single"), seen[0]) + assertEquals(setOf("a1", "a2", "single"), seen[1]) + assertEquals(setOf("a2", "a3", "single"), seen[2]) + } + + @Test + fun polylineTilingSetsTheMapsOptions() { + val scope = MapViewScope() + var options by mutableStateOf(PolylineTilingOptions.Disabled) + val seen = mutableListOf() + assertNull("指定が無ければ null(プロバイダの既定)", scope.polylineTilingOptions.value) + + compose( + { + CompositionLocalProvider(LocalPolylineCollector provides scope.polylineCollector) { + scope.PolylineTiling(options) + } + }, + { seen += scope.polylineTilingOptions.value }, + { options = PolylineTilingOptions(minPolylineCount = 10) }, + { seen += scope.polylineTilingOptions.value }, + ) + + assertEquals(listOf(PolylineTilingOptions.Disabled, PolylineTilingOptions(minPolylineCount = 10)), seen) + } +} From 1e77bbce1cf5aa355a0eff3874334771800fd662 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Tue, 10 Nov 2026 16:16:50 -0800 Subject: [PATCH 13/15] Support bulk Circles with raster tiles --- api/android-sdk-compose.api | 3 + .../mapconductor/compose/OverlayProvider.kt | 9 +- .../compose/circle/CircleCompose.kt | 71 ++++++++ .../compose/circle/CirclesCollectionTest.kt | 169 ++++++++++++++++++ 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index a5ba4ed..10b1b6d 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -6,6 +6,7 @@ public class com.mapconductor.compose.MapViewScope { public final com.mapconductor.core.OverlayCollector getPolylineCollector(); public final kotlinx.coroutines.flow.MutableStateFlow getPolylineTilingOptions(); public final com.mapconductor.core.OverlayCollector getCircleCollector(); + public final kotlinx.coroutines.flow.MutableStateFlow getCircleTilingOptions(); public final com.mapconductor.core.OverlayCollector getPolygonCollector(); public final com.mapconductor.core.OverlayCollector getGroundImageCollector(); public final com.mapconductor.core.OverlayCollector getRasterLayerCollector(); @@ -16,6 +17,8 @@ public final class com.mapconductor.compose.OverlayProviderKt { } public final class com.mapconductor.compose.circle.CircleComposeKt { public static final void Circle(com.mapconductor.compose.MapViewScope, com.mapconductor.core.circle.CircleState, androidx.compose.runtime.Composer, int); + public static final void Circles(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void CircleTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.circle.CircleTilingOptions, androidx.compose.runtime.Composer, int); public static final void Circle-6pqZqJ8(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoPointInterface, double, java.lang.String, boolean, boolean, long, float, long, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.compose.circle.LocalCircleCollectorKt { diff --git a/src/main/java/com/mapconductor/compose/OverlayProvider.kt b/src/main/java/com/mapconductor/compose/OverlayProvider.kt index ec599c0..37dcfbb 100644 --- a/src/main/java/com/mapconductor/compose/OverlayProvider.kt +++ b/src/main/java/com/mapconductor/compose/OverlayProvider.kt @@ -7,6 +7,7 @@ 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.circle.CircleTilingOptions import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.groundimage.GroundImageOverlay import com.mapconductor.core.groundimage.GroundImageState @@ -49,6 +50,12 @@ open class MapViewScope { OverlayCollector( updateDebounce = Settings.Default.composeEventDebounce, ) + + /** + * This map's circle tiling, set by [com.mapconductor.compose.circle.CircleTiling]; + * null keeps the provider's default ([CircleTilingOptions.Default]). + */ + val circleTilingOptions = MutableStateFlow(null) val polygonCollector = OverlayCollector( updateDebounce = Settings.Default.composeEventDebounce, @@ -65,7 +72,7 @@ open class MapViewScope { fun buildRegistry(): MapOverlayRegistry { val registry = MapOverlayRegistry() registry.register(MarkerOverlay(markerCollector.flow)) - registry.register(CircleOverlay(circleCollector.flow)) + registry.register(CircleOverlay(circleCollector.flow, circleTilingOptions)) registry.register(PolylineOverlay(polylineCollector.flow, polylineTilingOptions)) registry.register(PolygonOverlay(polygonCollector.flow)) registry.register(GroundImageOverlay(groundImageCollector.flow)) diff --git a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt index 1afdaac..eb6c5c5 100644 --- a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt +++ b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt @@ -3,14 +3,23 @@ package com.mapconductor.compose.circle import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.mapconductor.compose.MapViewScope import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.copyOverlayStates +import com.mapconductor.core.overlayStateIds +import com.mapconductor.core.circle.CircleTilingOptions import com.mapconductor.core.circle.OnCircleEventHandler import com.mapconductor.core.features.GeoPointInterface import java.io.Serializable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext @Composable fun MapViewScope.Circle(state: CircleState) { @@ -26,6 +35,68 @@ fun MapViewScope.Circle(state: CircleState) { } } +/** + * Efficiently add many circles without creating one Composable per circle. + * + * The same shape as [com.mapconductor.compose.polyline.Polylines] and + * [com.mapconductor.compose.polygon.Polygons]: one effect diffs the list + * against the ids this call added last time and publishes the result in a + * single collector update. Several Circles()/Circle() can share one map. + */ +@Composable +fun MapViewScope.Circles(states: List) { + val collector = LocalCircleCollector.current + val prevIdsState = remember { mutableStateOf>(emptySet()) } + + LaunchedEffect(states) { + withContext(Dispatchers.Default) { + val prevIds = prevIdsState.value + val nextIds = overlayStateIds(states) + prevIdsState.value = nextIds + // Merge only the ids this Circles() owns, so several + // Circles()/Circle() can share one map's collector. + collector.flow.update { current -> + val next = copyOverlayStates(current) + for (id in prevIds) if (id !in nextIds) next.remove(id) + for (state in states) next[state.id] = state + next + } + } + } + + DisposableEffect(Unit) { + onDispose { + // Remove only the circles this Circles() owns on dispose. + val ownIds = prevIdsState.value + if (ownIds.isNotEmpty()) { + collector.flow.update { current -> + val next = copyOverlayStates(current) + for (id in ownIds) next.remove(id) + next + } + } + prevIdsState.value = emptySet() + } + } +} + +/** + * How this map draws many circles: as raster tiles once there are enough of + * them ([CircleTilingOptions.Default]), never ([CircleTilingOptions.Disabled]), + * or at another threshold. Without it the map uses [CircleTilingOptions.Default]. + * + * Read when the circles are handed to the map, so put it in the map's content + * before them; a change takes effect the next time they are handed over + * (recreate the map to apply it to an unchanged list). + */ +@Composable +fun MapViewScope.CircleTiling(options: CircleTilingOptions) { + val scope = this + SideEffect { + scope.circleTilingOptions.value = options + } +} + @Composable fun MapViewScope.Circle( center: GeoPointInterface, diff --git a/src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt b/src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt new file mode 100644 index 0000000..7902f61 --- /dev/null +++ b/src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt @@ -0,0 +1,169 @@ +package com.mapconductor.compose.circle + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.mapconductor.compose.MapViewScope +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.circle.CircleTilingOptions +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +/** + * `Circles(states)` は `Polylines(states)`・`Polygons(states)` と同じく、そのインスタンスが + * 持つ id だけを地図の collector に足し引きする。複数の Circles / Circle が同じ地図で + * 共存でき、片方を外しても他方は残る。 + * + * js-sdk-react の `Circles.test.tsx`、ios-sdk の `CirclesContentTests` と同じ内容。 + * UI を持たない Applier の上で Compose ランタイムだけを回す(InfoBubbleRegistrationTest と同じ)。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CirclesCollectionTest { + @Before + fun setUp() = Dispatchers.setMain(Dispatchers.Unconfined) + + @After + fun tearDown() = Dispatchers.resetMain() + + private class NoNodeApplier : AbstractApplier(Unit) { + override fun insertTopDown( + index: Int, + instance: Unit, + ) = Unit + + override fun insertBottomUp( + index: Int, + instance: Unit, + ) = Unit + + override fun remove( + index: Int, + count: Int, + ) = Unit + + override fun move( + from: Int, + to: Int, + count: Int, + ) = Unit + + override fun onClear() = Unit + } + + /** + * [content] を合成し、[steps] を順に実行して、各手順のあと再合成と + * Polylines の差分(Dispatchers.Default で走る)が落ち着くまで回す。 + */ + private fun compose( + content: @Composable () -> Unit, + vararg steps: () -> Unit, + ) = runBlocking { + val clock = BroadcastFrameClock() + withContext(clock + Dispatchers.Unconfined) { + val recomposer = Recomposer(coroutineContext) + val job = + launch(start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + val composition = Composition(NoNodeApplier(), recomposer) + composition.setContent(content) + + suspend fun settle() { + repeat(10) { + Snapshot.sendApplyNotifications() + yield() + clock.sendFrame(0L) + yield() + Thread.sleep(20) + } + } + settle() + for (step in steps) { + step() + settle() + } + composition.dispose() + recomposer.close() + job.join() + } + } + + // 中心を id ごとに変える: CircleState の equals は id を見ないので、同じ円だと + // 別の一覧に差し替えても mutableStateOf が変化と見なさない。 + private fun circle(id: String): CircleState { + val d = id.hashCode() % 1000 * 1e-5 + return CircleState(center = GeoPoint.fromLatLong(42.35 + d, -71.06), radiusMeters = 10.0, id = id) + } + + @Test + fun twoCirclesShareTheMap_andRemovingOneKeepsTheOther() { + val scope = MapViewScope() + val a = listOf(circle("a1"), circle("a2")) + val b = listOf(circle("b1")) + var showB by mutableStateOf(true) + var listA by mutableStateOf(a) + val seen = mutableListOf>() + + compose( + { + CompositionLocalProvider(LocalCircleCollector provides scope.circleCollector) { + scope.Circles(listA) + if (showB) scope.Circles(b) + scope.Circle(circle("single")) + } + }, + { seen += scope.circleCollector.flow.value.keys }, + // 片方の Circles を外す: 自分の id だけ消える + { showB = false }, + { seen += scope.circleCollector.flow.value.keys }, + // 一覧を差し替える: 消えた id だけ消え、他の Circles / Circle のものは残る + { listA = listOf(circle("a2"), circle("a3")) }, + { seen += scope.circleCollector.flow.value.keys }, + ) + + assertEquals(setOf("a1", "a2", "b1", "single"), seen[0]) + assertEquals(setOf("a1", "a2", "single"), seen[1]) + assertEquals(setOf("a2", "a3", "single"), seen[2]) + } + + @Test + fun circleTilingSetsTheMapsOptions() { + val scope = MapViewScope() + var options by mutableStateOf(CircleTilingOptions.Disabled) + val seen = mutableListOf() + assertNull("指定が無ければ null(プロバイダの既定)", scope.circleTilingOptions.value) + + compose( + { + CompositionLocalProvider(LocalCircleCollector provides scope.circleCollector) { + scope.CircleTiling(options) + } + }, + { seen += scope.circleTilingOptions.value }, + { options = CircleTilingOptions(minCircleCount = 10) }, + { seen += scope.circleTilingOptions.value }, + ) + + assertEquals(listOf(CircleTilingOptions.Disabled, CircleTilingOptions(minCircleCount = 10)), seen) + } +} From a31dfec7183cf0f415b1636cb62602fd45f374c6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 12 Nov 2026 22:18:49 -0800 Subject: [PATCH 14/15] Add onAddStart/onAddProgress/onAddEnd to Markers, Circles, Polylines, Polygons A list of thousands shows nothing until it is drawn; these say the work is going on. They count the states a list brings by the same moment as each state's onAdded, report progress at most every 100 ms, and start over when a new list adds states. The same as the js-sdk-react collections. --- api/android-sdk-compose.api | 8 +- .../com/mapconductor/compose/AddProgress.kt | 173 ++++++++++++++++++ .../compose/circle/CircleCompose.kt | 31 +++- .../compose/marker/MarkerCompose.kt | 34 +++- .../compose/polygon/PolygonComponent.kt | 43 ++++- .../compose/polyline/PolylineComponent.kt | 31 +++- .../compose/AddProgressTrackerTest.kt | 117 ++++++++++++ 7 files changed, 410 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/mapconductor/compose/AddProgress.kt create mode 100644 src/test/java/com/mapconductor/compose/AddProgressTrackerTest.kt diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index 10b1b6d..7844c40 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -17,7 +17,7 @@ public final class com.mapconductor.compose.OverlayProviderKt { } public final class com.mapconductor.compose.circle.CircleComposeKt { public static final void Circle(com.mapconductor.compose.MapViewScope, com.mapconductor.core.circle.CircleState, androidx.compose.runtime.Composer, int); - public static final void Circles(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void Circles(com.mapconductor.compose.MapViewScope, java.util.List, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void CircleTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.circle.CircleTilingOptions, androidx.compose.runtime.Composer, int); public static final void Circle-6pqZqJ8(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoPointInterface, double, java.lang.String, boolean, boolean, long, float, long, java.lang.Integer, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); } @@ -80,7 +80,7 @@ public final class com.mapconductor.compose.marker.MarkerAnimationLayerKt { } public final class com.mapconductor.compose.marker.MarkerComposeKt { public static final void Marker(com.mapconductor.compose.MapViewScope, com.mapconductor.core.marker.MarkerState, androidx.compose.runtime.Composer, int); - public static final void Markers(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void Markers(com.mapconductor.compose.MapViewScope, java.util.List, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void Marker(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoPointInterface, java.lang.String, java.lang.Integer, boolean, boolean, com.mapconductor.core.marker.MarkerIconInterface, com.mapconductor.core.marker.MarkerAnimation, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.compose.polygon.LocalPolygonCollectorKt { @@ -88,7 +88,7 @@ public final class com.mapconductor.compose.polygon.LocalPolygonCollectorKt { } public final class com.mapconductor.compose.polygon.PolygonComponentKt { public static final void Polygon(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polygon.PolygonState, androidx.compose.runtime.Composer, int); - public static final void Polygons(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void Polygons(com.mapconductor.compose.MapViewScope, java.util.List, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void Polygon-YoVAMyA(com.mapconductor.compose.MapViewScope, java.util.List, java.util.List>, java.lang.String, long, float, long, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int, int); public static final void Polygon-IvR-a-0(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, java.lang.String, long, float, long, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); } @@ -97,7 +97,7 @@ public final class com.mapconductor.compose.polyline.LocalPolylineCollectorKt { } public final class com.mapconductor.compose.polyline.PolylineComponentKt { public static final void Polyline(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineState, androidx.compose.runtime.Composer, int); - public static final void Polylines(com.mapconductor.compose.MapViewScope, java.util.List, androidx.compose.runtime.Composer, int); + public static final void Polylines(com.mapconductor.compose.MapViewScope, java.util.List, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void PolylineTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineTilingOptions, androidx.compose.runtime.Composer, int); public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, java.util.List, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, com.mapconductor.core.features.GeoRectBounds, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer, int, int); diff --git a/src/main/java/com/mapconductor/compose/AddProgress.kt b/src/main/java/com/mapconductor/compose/AddProgress.kt new file mode 100644 index 0000000..78fb58f --- /dev/null +++ b/src/main/java/com/mapconductor/compose/AddProgress.kt @@ -0,0 +1,173 @@ +package com.mapconductor.compose + +import android.os.SystemClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.mapconductor.core.ComponentState +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.OverlayAddedListeners +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * How far a collection (`Markers`, `Circles`, `Polylines`, `Polygons`) is with + * putting its states on the map: for a list of thousands the screen shows + * nothing until they are drawn, and these say that work is going on. + * + * "On the map" is what each state's `onAdded` means: drawn natively, or, for a + * state drawn in tiles, its tiles handed to the map (their images may still be + * loading). + * + * - `onAddStart(total)`: a list brought states that are not on the map yet; + * `total` is how many. + * - `onAddProgress(added, total)`: how many of them are on the map so far, at + * most every 100 ms, and once more with `added == total` before the end. + * - `onAddEnd(total)`: all of them are on the map. A state taken out of the + * list before it got there no longer counts against the end. + * + * A list that brings new states while an earlier one is still being added + * starts over: a new `onAddStart` with only the new states, and no `onAddEnd` + * for the earlier list. A list that only updates or removes states calls + * nothing. All three run on the main thread. + */ +@InternalMapConductorApi +internal class AddProgressTracker( + private val scope: CoroutineScope, + private val now: () -> Long = SystemClock::uptimeMillis, +) { + var onAddStart: ((total: Int) -> Unit)? = null + var onAddProgress: ((added: Int, total: Int) -> Unit)? = null + var onAddEnd: ((total: Int) -> Unit)? = null + + /** State objects handed over and not on the map yet, by id. */ + private var pending = HashMap() + private var total = 0 + private var added = 0 + private var lastProgressAt = Long.MIN_VALUE / 2 + private var timer: Job? = null + + /** + * The states of [states] whose id is not in [prevIds], or none when no one + * listens. Call it off the main thread; it walks the whole list. + */ + fun newStates( + states: List, + prevIds: Set, + wanted: Boolean, + ): List = if (wanted) states.filter { it.id !in prevIds } else emptyList() + + /** + * [states] (ids [nextIds]) is about to be handed to the collector and + * [newStates] are the ones it adds. Call on the main thread, before the + * hand-over, so that no arrival comes ahead of it. + */ + fun track( + states: List, + nextIds: Set, + newStates: List, + ) { + if (pending.isNotEmpty()) { + val before = pending.size + pending.keys.retainAll(nextIds) + total -= before - pending.size + // A state that is still coming may be handed over as a new object + // with the same id; the map reports that object. + if (pending.isNotEmpty() && newStates.isEmpty()) { + for (state in states) if (state.id in pending) pending[state.id] = state + } + if (newStates.isEmpty()) finishIfDone() + } + if (newStates.isEmpty()) return + timer?.cancel() + timer = null + pending = HashMap(newStates.size * 2).apply { for (s in newStates) put(s.id, s) } + total = newStates.size + added = 0 + lastProgressAt = Long.MIN_VALUE / 2 + onAddStart?.invoke(total) + } + + /** States reached the map (any collection's, any map's: only ours count). */ + fun arrived(states: List) { + if (pending.isEmpty()) return + var hit = false + for (state in states) { + if (pending[state.id] === state) { + pending.remove(state.id) + added++ + hit = true + } + } + if (!hit) return + if (pending.isEmpty()) finishIfDone() else reportProgress() + } + + fun dispose() { + timer?.cancel() + timer = null + pending = HashMap() + } + + private fun finishIfDone() { + if (pending.isNotEmpty()) return + timer?.cancel() + timer = null + onAddProgress?.invoke(total, total) + onAddEnd?.invoke(total) + } + + private fun reportProgress() { + if (timer != null) return + val wait = lastProgressAt + PROGRESS_INTERVAL_MS - now() + if (wait <= 0) { + emitProgress() + } else { + timer = + scope.launch { + delay(wait) + timer = null + emitProgress() + } + } + } + + private fun emitProgress() { + if (pending.isEmpty()) return + lastProgressAt = now() + onAddProgress?.invoke(added, total) + } + + @InternalMapConductorApi + private companion object { + const val PROGRESS_INTERVAL_MS = 100L + } +} + +/** The tracker for one collection, listening only while one of the callbacks is set. */ +@InternalMapConductorApi +@Composable +internal fun rememberAddProgress( + onAddStart: ((total: Int) -> Unit)?, + onAddProgress: ((added: Int, total: Int) -> Unit)?, + onAddEnd: ((total: Int) -> Unit)?, +): AddProgressTracker { + val scope = rememberCoroutineScope() + val tracker = remember { AddProgressTracker(scope) } + SideEffect { + tracker.onAddStart = onAddStart + tracker.onAddProgress = onAddProgress + tracker.onAddEnd = onAddEnd + } + val wanted = onAddStart != null || onAddProgress != null || onAddEnd != null + DisposableEffect(tracker, wanted) { + val remove = if (wanted) OverlayAddedListeners.add(tracker::arrived) else null + onDispose { remove?.invoke() } + } + DisposableEffect(tracker) { onDispose { tracker.dispose() } } + return tracker +} diff --git a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt index eb6c5c5..881efc0 100644 --- a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt +++ b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt @@ -1,5 +1,6 @@ package com.mapconductor.compose.circle +import com.mapconductor.compose.rememberAddProgress import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -42,17 +43,39 @@ fun MapViewScope.Circle(state: CircleState) { * [com.mapconductor.compose.polygon.Polygons]: one effect diffs the list * against the ids this call added last time and publishes the result in a * single collector update. Several Circles()/Circle() can share one map. + * + * [onAddStart], [onAddProgress] and [onAddEnd] show that a long list is being + * put on the map. `onAddStart(total)` comes when the list brings states the + * map does not have yet, `onAddProgress(added, total)` at most every 100 ms, + * and `onAddEnd(total)` once all of them are on the map, at the same moment as + * their `onAdded` (for tiled states: their tiles were handed to the map). A + * state removed before it got there no longer counts. A list that adds states + * while an earlier one is still coming starts over without an `onAddEnd` for + * the earlier one; a list that only updates or removes calls nothing. They run + * on the main thread. */ @Composable -fun MapViewScope.Circles(states: List) { +fun MapViewScope.Circles( + states: List, + onAddStart: ((total: Int) -> Unit)? = null, + onAddProgress: ((added: Int, total: Int) -> Unit)? = null, + onAddEnd: ((total: Int) -> Unit)? = null, +) { val collector = LocalCircleCollector.current val prevIdsState = remember { mutableStateOf>(emptySet()) } + val progress = rememberAddProgress(onAddStart, onAddProgress, onAddEnd) + val tracking = onAddStart != null || onAddProgress != null || onAddEnd != null LaunchedEffect(states) { + val prevIds = prevIdsState.value + val (nextIds, added) = + withContext(Dispatchers.Default) { + overlayStateIds(states) to progress.newStates(states, prevIds, tracking) + } + prevIdsState.value = nextIds + // Before the hand-over, so that no arrival comes ahead of it. + progress.track(states, nextIds, added) withContext(Dispatchers.Default) { - val prevIds = prevIdsState.value - val nextIds = overlayStateIds(states) - prevIdsState.value = nextIds // Merge only the ids this Circles() owns, so several // Circles()/Circle() can share one map's collector. collector.flow.update { current -> diff --git a/src/main/java/com/mapconductor/compose/marker/MarkerCompose.kt b/src/main/java/com/mapconductor/compose/marker/MarkerCompose.kt index 29c627f..21a0fa7 100644 --- a/src/main/java/com/mapconductor/compose/marker/MarkerCompose.kt +++ b/src/main/java/com/mapconductor/compose/marker/MarkerCompose.kt @@ -1,5 +1,6 @@ package com.mapconductor.compose.marker +import com.mapconductor.compose.rememberAddProgress import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -37,22 +38,45 @@ fun MapViewScope.Marker(state: MarkerState) { * * This avoids large composition overhead (thousands of LaunchedEffects/DisposableEffects) * by performing batched add/remove in a single effect. + * + * [onAddStart], [onAddProgress] and [onAddEnd] show that a long list is being + * put on the map. `onAddStart(total)` comes when the list brings states the + * map does not have yet, `onAddProgress(added, total)` at most every 100 ms, + * and `onAddEnd(total)` once all of them are on the map, at the same moment as + * their `onAdded` (for tiled states: their tiles were handed to the map). A + * state removed before it got there no longer counts. A list that adds states + * while an earlier one is still coming starts over without an `onAddEnd` for + * the earlier one; a list that only updates or removes calls nothing. They run + * on the main thread. */ @Composable -fun MapViewScope.Markers(states: List) { +fun MapViewScope.Markers( + states: List, + onAddStart: ((total: Int) -> Unit)? = null, + onAddProgress: ((added: Int, total: Int) -> Unit)? = null, + onAddEnd: ((total: Int) -> Unit)? = null, +) { val collector = LocalMarkerCollector.current val prevIdsState = remember { mutableStateOf>(emptySet()) } + val progress = rememberAddProgress(onAddStart, onAddProgress, onAddEnd) + val tracking = onAddStart != null || onAddProgress != null || onAddEnd != null LaunchedEffect(states) { val startedAt = SystemClock.elapsedRealtime() markerTrace("collector effect start count=${states.size}") + val prevIds = prevIdsState.value + val (nextIds, added) = + withContext(Dispatchers.Default) { + states.asSequence().map { it.id }.toSet() to progress.newStates(states, prevIds, tracking) + } + markerTrace("collector ids created count=${nextIds.size}") + prevIdsState.value = nextIds + // Before the hand-over, so that no arrival comes ahead of it. + progress.track(states, nextIds, added) // For very large marker sets, avoid per-marker SharedFlow emits which can backpressure and // block rendering; instead publish the whole map in one StateFlow update. withContext(Dispatchers.Default) { - val nextIds = states.asSequence().map { it.id }.toSet() - val removedIds = prevIdsState.value - nextIds - markerTrace("collector ids created count=${nextIds.size}") - prevIdsState.value = nextIds + val removedIds = prevIds - nextIds // Merge only the ids this Markers() owns into the shared collector so // several Markers()/Marker() can share one map without clobbering each // other. Assigning the whole map (a full replace) dropped everyone diff --git a/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt b/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt index da37eb4..2b0684d 100644 --- a/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt +++ b/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt @@ -1,5 +1,6 @@ package com.mapconductor.compose.polygon +import com.mapconductor.compose.rememberAddProgress import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -43,24 +44,46 @@ fun MapViewScope.Polygon(state: PolygonState) { * The same shape as [com.mapconductor.compose.marker.Markers]: one effect diffs * the list against the ids this call added last time and publishes the result * in a single collector update. + * + * [onAddStart], [onAddProgress] and [onAddEnd] show that a long list is being + * put on the map. `onAddStart(total)` comes when the list brings states the + * map does not have yet, `onAddProgress(added, total)` at most every 100 ms, + * and `onAddEnd(total)` once all of them are on the map, at the same moment as + * their `onAdded` (for tiled states: their tiles were handed to the map). A + * state removed before it got there no longer counts. A list that adds states + * while an earlier one is still coming starts over without an `onAddEnd` for + * the earlier one; a list that only updates or removes calls nothing. They run + * on the main thread. */ @Composable -fun MapViewScope.Polygons(states: List) { +fun MapViewScope.Polygons( + states: List, + onAddStart: ((total: Int) -> Unit)? = null, + onAddProgress: ((added: Int, total: Int) -> Unit)? = null, + onAddEnd: ((total: Int) -> Unit)? = null, +) { val collector = LocalPolygonCollector.current val prevIdsState = remember { mutableStateOf>(emptySet()) } + val progress = rememberAddProgress(onAddStart, onAddProgress, onAddEnd) + val tracking = onAddStart != null || onAddProgress != null || onAddEnd != null LaunchedEffect(states) { - withContext(Dispatchers.Default) { - val prevIds = prevIdsState.value - val nextIds = states.asSequence().map { it.id }.toSet() - val removedIds = prevIds - nextIds - // Union the holes once per newly added state, as Polygon(state) does. - for (state in states) { - if (state.id !in prevIds && state.holes.size > 1) { - state.unionHolesInPlace() + val prevIds = prevIdsState.value + val (nextIds, added) = + withContext(Dispatchers.Default) { + // Union the holes once per newly added state, as Polygon(state) does. + for (state in states) { + if (state.id !in prevIds && state.holes.size > 1) { + state.unionHolesInPlace() + } } + states.asSequence().map { it.id }.toSet() to progress.newStates(states, prevIds, tracking) } - prevIdsState.value = nextIds + prevIdsState.value = nextIds + // Before the hand-over, so that no arrival comes ahead of it. + progress.track(states, nextIds, added) + withContext(Dispatchers.Default) { + val removedIds = prevIds - nextIds // Merge only the ids this Polygons() owns, so several // Polygons()/Polygon() can share one map's collector. collector.flow.update { current -> diff --git a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt index d09c26d..28c83ed 100644 --- a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt +++ b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt @@ -1,5 +1,6 @@ package com.mapconductor.compose.polyline +import com.mapconductor.compose.rememberAddProgress import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -42,18 +43,40 @@ fun MapViewScope.Polyline(state: PolylineState) { * [com.mapconductor.compose.marker.Markers]: one effect diffs the list against * the ids this call added last time and publishes the result in a single * collector update. Several Polylines()/Polyline() can share one map. + * + * [onAddStart], [onAddProgress] and [onAddEnd] show that a long list is being + * put on the map. `onAddStart(total)` comes when the list brings states the + * map does not have yet, `onAddProgress(added, total)` at most every 100 ms, + * and `onAddEnd(total)` once all of them are on the map, at the same moment as + * their `onAdded` (for tiled states: their tiles were handed to the map). A + * state removed before it got there no longer counts. A list that adds states + * while an earlier one is still coming starts over without an `onAddEnd` for + * the earlier one; a list that only updates or removes calls nothing. They run + * on the main thread. */ @Composable -fun MapViewScope.Polylines(states: List) { +fun MapViewScope.Polylines( + states: List, + onAddStart: ((total: Int) -> Unit)? = null, + onAddProgress: ((added: Int, total: Int) -> Unit)? = null, + onAddEnd: ((total: Int) -> Unit)? = null, +) { val collector = LocalPolylineCollector.current val prevIdsState = remember { mutableStateOf>(emptySet()) } + val progress = rememberAddProgress(onAddStart, onAddProgress, onAddEnd) + val tracking = onAddStart != null || onAddProgress != null || onAddEnd != null LaunchedEffect(states) { + val prevIds = prevIdsState.value + val (nextIds, added) = + withContext(Dispatchers.Default) { + states.asSequence().map { it.id }.toSet() to progress.newStates(states, prevIds, tracking) + } + prevIdsState.value = nextIds + // Before the hand-over, so that no arrival comes ahead of it. + progress.track(states, nextIds, added) withContext(Dispatchers.Default) { - val prevIds = prevIdsState.value - val nextIds = states.asSequence().map { it.id }.toSet() val removedIds = prevIds - nextIds - prevIdsState.value = nextIds // Merge only the ids this Polylines() owns, so several // Polylines()/Polyline() can share one map's collector. collector.flow.update { current -> diff --git a/src/test/java/com/mapconductor/compose/AddProgressTrackerTest.kt b/src/test/java/com/mapconductor/compose/AddProgressTrackerTest.kt new file mode 100644 index 0000000..4a43d8f --- /dev/null +++ b/src/test/java/com/mapconductor/compose/AddProgressTrackerTest.kt @@ -0,0 +1,117 @@ +package com.mapconductor.compose + +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPoint +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * `Markers` / `Circles` / `Polylines` / `Polygons` の onAddStart / onAddProgress / onAddEnd + * (AddProgressTracker)。js-sdk-react の `addProgress.test.mjs` と同じ内容。 + * + * - 地図に未だ無い state を持ち込んだリストで onAddStart(total)。 + * - 各 state の onAdded と同じ時点で 1 つ数える。途中経過は 100 ms に 1 回まで。 + * - 全部載ったら onAddProgress(total, total) と onAddEnd(total)。 + * - 載る前に外された state は数から抜ける。新しい追加は数え直し、前のリストの onAddEnd は来ない。 + * - 更新・削除だけのリストは何も呼ばない。他のコレクション・他の地図の state は数えない。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddProgressTrackerTest { + private val scope = TestScope() + private val calls = mutableListOf() + private var prevIds: Set = emptySet() + private val tracker = + AddProgressTracker(scope, now = { scope.testScheduler.currentTime + 1000 }).apply { + onAddStart = { calls += "start $it" } + onAddProgress = { added, total -> calls += "progress $added/$total" } + onAddEnd = { calls += "end $it" } + } + + private fun circle(id: String) = CircleState(id = id, center = GeoPoint.fromLatLong(42.35, -71.06), radiusMeters = 5.0) + + /** What Circles() does with a new list. */ + private fun set(states: List) { + val nextIds = states.map { it.id }.toSet() + tracker.track(states, nextIds, tracker.newStates(states, prevIds, wanted = true)) + prevIds = nextIds + } + + @Test + fun `all on the map - start, progress at the total, end`() { + val states = listOf(circle("a"), circle("b"), circle("c")) + set(states) + tracker.arrived(listOf(states[0])) + tracker.arrived(listOf(states[1], states[2])) + assertEquals(listOf("start 3", "progress 1/3", "progress 3/3", "end 3"), calls) + } + + @Test + fun `progress at most every 100 ms`() { + val states = List(5) { circle("s$it") } + set(states) + tracker.arrived(listOf(states[0])) // now + tracker.arrived(listOf(states[1])) // held + tracker.arrived(listOf(states[2])) // held + assertEquals(listOf("start 5", "progress 1/5"), calls) + scope.advanceTimeBy(100) + scope.runCurrent() + assertEquals(listOf("start 5", "progress 1/5", "progress 3/5"), calls) + tracker.arrived(listOf(states[3], states[4])) + assertEquals("end 5", calls.last()) + } + + @Test + fun `a state removed before it arrived no longer counts`() { + val (a, b, c) = listOf(circle("a"), circle("b"), circle("c")) + set(listOf(a, b, c)) + tracker.arrived(listOf(a)) + set(listOf(a, b)) + tracker.arrived(listOf(b)) + assertEquals(listOf("progress 2/2", "end 2"), calls.takeLast(2)) + } + + @Test + fun `removing the rest ends at once`() { + val (a, b) = listOf(circle("a"), circle("b")) + set(listOf(a, b)) + tracker.arrived(listOf(a)) + set(listOf(a)) + assertEquals(listOf("progress 1/1", "end 1"), calls.takeLast(2)) + } + + @Test + fun `a still-coming state handed over again as a new object is counted by the new one`() { + val a = circle("a") + set(listOf(a)) + val a2 = circle("a") + set(listOf(a2)) + tracker.arrived(listOf(a2)) + assertEquals(listOf("start 1", "progress 1/1", "end 1"), calls) + } + + @Test + fun `new states while others are coming start over without an end for the earlier list`() { + val (a, b, c) = listOf(circle("a"), circle("b"), circle("c")) + set(listOf(a, b)) + set(listOf(a, b, c)) + assertEquals(listOf("start 2", "start 1"), calls) + tracker.arrived(listOf(a, b)) + tracker.arrived(listOf(c)) + assertEquals(listOf("start 2", "start 1", "progress 1/1", "end 1"), calls) + } + + @Test + fun `updates, removals and other collections' states call nothing`() { + val (a, b) = listOf(circle("a"), circle("b")) + set(listOf(a, b)) + tracker.arrived(listOf(a, b)) + calls.clear() + set(listOf(circle("a"))) // a updated, b removed + tracker.arrived(listOf(circle("other"), circle("a"))) + assertEquals(emptyList(), calls) + } +} From 4dfb02123345f49fef92fe7879e08177e46666b6 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Fri, 13 Nov 2026 03:40:52 -0800 Subject: [PATCH 15/15] Release 1.4.0 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 2878720..abba3dd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,5 @@ minSdk=26 javaVersion=17 jvmTarget=17 -libraryVersion=1.3.1 -coreLibraryVersion=1.3.1 +libraryVersion=1.4.0 +coreLibraryVersion=1.4.0