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": "" + } +} 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 .. diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index bac0a49..7844c40 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -4,7 +4,9 @@ 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 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(); @@ -15,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, 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); } public final class com.mapconductor.compose.circle.LocalCircleCollectorKt { @@ -23,6 +27,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(); @@ -60,7 +65,12 @@ 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 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(); @@ -70,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 { @@ -78,6 +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, 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); } @@ -86,6 +97,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, 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/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.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 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/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/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/OverlayProvider.kt b/src/main/java/com/mapconductor/compose/OverlayProvider.kt index 29c804f..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 @@ -19,6 +20,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,10 +40,22 @@ 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, ) + + /** + * 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, @@ -58,8 +72,8 @@ open class MapViewScope { fun buildRegistry(): MapOverlayRegistry { val registry = MapOverlayRegistry() registry.register(MarkerOverlay(markerCollector.flow)) - registry.register(CircleOverlay(circleCollector.flow)) - registry.register(PolylineOverlay(polylineCollector.flow)) + registry.register(CircleOverlay(circleCollector.flow, circleTilingOptions)) + 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/circle/CircleCompose.kt b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt index aa8872b..881efc0 100644 --- a/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt +++ b/src/main/java/com/mapconductor/compose/circle/CircleCompose.kt @@ -1,16 +1,26 @@ 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 +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 +36,90 @@ 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. + * + * [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, + 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) { + // 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, @@ -34,7 +128,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 e2a849b..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, @@ -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 = 1.0f, + 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) +} 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/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()) } diff --git a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt index 8d762d8..d049d37 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 @@ -60,10 +61,12 @@ 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 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 @@ -109,11 +112,23 @@ 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) 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() @@ -132,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) } } @@ -441,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 } @@ -450,16 +473,60 @@ fun < LaunchedEffect(state.uiSettings, initState) { controllerRef.value?.applyUISettings(state.uiSettings) } + val currentOnDestroyMapView by rememberUpdatedState(onDestroyMapView) + val currentState by rememberUpdatedState(state) DisposableEffect(Unit) { - // Setup logic when the composable enters the screen - onDispose { - controllerRef.value?.destroy() + // Overlays first, while the map they live on is still alive. + 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. 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 + mapViewRef.value = null + destroyMapView( + mapView = mapView, + provider = state::class.java.simpleName, + onDestroyMapView = currentOnDestroyMapView, + ) } } customDisposableEffect?.invoke(initState, holderRef) } +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( 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 + } + } +} 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 9bc164f..2b0684d 100644 --- a/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt +++ b/src/main/java/com/mapconductor/compose/polygon/PolygonComponent.kt @@ -1,8 +1,11 @@ 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 +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 +17,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 +38,79 @@ 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. + * + * [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, + 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) { + 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 + // 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 -> + 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, diff --git a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt index ca6a3b1..28c83ed 100644 --- a/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt +++ b/src/main/java/com/mapconductor/compose/polyline/PolylineComponent.kt @@ -1,8 +1,12 @@ 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 +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 +16,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 +36,91 @@ 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. + * + * [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, + 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 removedIds = prevIds - 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/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) + } +} 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) + } +} 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) + } +} 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) + } +}