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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/android-sdk-compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public class com.mapconductor.compose.MapViewScope {
public final com.mapconductor.core.OverlayCollector<com.mapconductor.core.polyline.PolylineState> getPolylineCollector();
public final kotlinx.coroutines.flow.MutableStateFlow<com.mapconductor.core.polyline.PolylineTilingOptions> getPolylineTilingOptions();
public final com.mapconductor.core.OverlayCollector<com.mapconductor.core.circle.CircleState> getCircleCollector();
public final kotlinx.coroutines.flow.MutableStateFlow<com.mapconductor.core.circle.CircleTilingOptions> getCircleTilingOptions();
public final com.mapconductor.core.OverlayCollector<com.mapconductor.core.polygon.PolygonState> getPolygonCollector();
public final com.mapconductor.core.OverlayCollector<com.mapconductor.core.groundimage.GroundImageState> getGroundImageCollector();
public final com.mapconductor.core.OverlayCollector<com.mapconductor.core.raster.RasterLayerState> getRasterLayerCollector();
Expand All @@ -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<com.mapconductor.core.circle.CircleState>, 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<? super com.mapconductor.core.circle.CircleEvent, kotlin.Unit>, androidx.compose.runtime.Composer, int, int, int);
}
public final class com.mapconductor.compose.circle.LocalCircleCollectorKt {
Expand Down
9 changes: 8 additions & 1 deletion src/main/java/com/mapconductor/compose/OverlayProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +50,12 @@ open class MapViewScope {
OverlayCollector<CircleState>(
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<CircleTilingOptions?>(null)
val polygonCollector =
OverlayCollector<PolygonState>(
updateDebounce = Settings.Default.composeEventDebounce,
Expand All @@ -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))
Expand Down
71 changes: 71 additions & 0 deletions src/main/java/com/mapconductor/compose/circle/CircleCompose.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<CircleState>) {
val collector = LocalCircleCollector.current
val prevIdsState = remember { mutableStateOf<Set<String>>(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,
Expand Down
169 changes: 169 additions & 0 deletions src/test/java/com/mapconductor/compose/circle/CirclesCollectionTest.kt
Original file line number Diff line number Diff line change
@@ -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>(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<Set<String>>()

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<CircleTilingOptions?>()
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)
}
}
Loading