Skip to content
Open
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 @@ -4,6 +4,7 @@ public class com.mapconductor.compose.MapViewScope {
public final kotlinx.coroutines.flow.MutableStateFlow<java.util.Map<java.lang.String, com.mapconductor.compose.info.InfoBubbleEntry>> getBubbleFlow();
public final kotlinx.coroutines.flow.MutableStateFlow<java.util.Map<java.lang.String, com.mapconductor.core.marker.MarkerAnimationOverlayEntry>> getMarkerAnimationFlow();
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 com.mapconductor.core.OverlayCollector<com.mapconductor.core.polygon.PolygonState> getPolygonCollector();
public final com.mapconductor.core.OverlayCollector<com.mapconductor.core.groundimage.GroundImageState> getGroundImageCollector();
Expand Down Expand Up @@ -93,6 +94,8 @@ public final class com.mapconductor.compose.polyline.LocalPolylineCollectorKt {
}
public final class com.mapconductor.compose.polyline.PolylineComponentKt {
public static final void Polyline(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineState, androidx.compose.runtime.Composer, int);
public static final void Polylines(com.mapconductor.compose.MapViewScope, java.util.List<com.mapconductor.core.polyline.PolylineState>, androidx.compose.runtime.Composer, int);
public static final void PolylineTiling(com.mapconductor.compose.MapViewScope, com.mapconductor.core.polyline.PolylineTilingOptions, androidx.compose.runtime.Composer, int);
public static final void Polyline-bbrV0mI(com.mapconductor.compose.MapViewScope, java.util.List<? extends com.mapconductor.core.features.GeoPointInterface>, java.lang.String, long, float, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1<? super com.mapconductor.core.polyline.PolylineEvent, kotlin.Unit>, 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<? super com.mapconductor.core.polyline.PolylineEvent, kotlin.Unit>, androidx.compose.runtime.Composer, int, int);
}
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 @@ -19,6 +19,7 @@ import com.mapconductor.core.polygon.PolygonOverlay
import com.mapconductor.core.polygon.PolygonState
import com.mapconductor.core.polyline.PolylineOverlay
import com.mapconductor.core.polyline.PolylineState
import com.mapconductor.core.polyline.PolylineTilingOptions
import com.mapconductor.core.raster.RasterLayerOverlay
import com.mapconductor.core.raster.RasterLayerState
import com.mapconductor.settings.Settings
Expand All @@ -38,6 +39,12 @@ open class MapViewScope {
OverlayCollector<PolylineState>(
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<PolylineTilingOptions?>(null)
val circleCollector =
OverlayCollector<CircleState>(
updateDebounce = Settings.Default.composeEventDebounce,
Expand All @@ -59,7 +66,7 @@ open class MapViewScope {
val registry = MapOverlayRegistry()
registry.register(MarkerOverlay(markerCollector.flow))
registry.register(CircleOverlay(circleCollector.flow))
registry.register(PolylineOverlay(polylineCollector.flow))
registry.register(PolylineOverlay(polylineCollector.flow, polylineTilingOptions))
registry.register(PolygonOverlay(polygonCollector.flow))
registry.register(GroundImageOverlay(groundImageCollector.flow))
registry.register(RasterLayerOverlay(rasterLayerCollector.flow))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package com.mapconductor.compose.polyline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
Expand All @@ -12,7 +15,11 @@ import com.mapconductor.core.features.GeoPointInterface
import com.mapconductor.core.features.GeoRectBounds
import com.mapconductor.core.polyline.OnPolylineEventHandler
import com.mapconductor.core.polyline.PolylineState
import com.mapconductor.core.polyline.PolylineTilingOptions
import java.io.Serializable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext

@Composable
fun MapViewScope.Polyline(state: PolylineState) {
Expand All @@ -28,6 +35,69 @@ fun MapViewScope.Polyline(state: PolylineState) {
}
}

/**
* Efficiently add many polylines without creating one Composable per polyline.
*
* The same shape as [com.mapconductor.compose.polygon.Polygons] and
* [com.mapconductor.compose.marker.Markers]: one effect diffs the list against
* the ids this call added last time and publishes the result in a single
* collector update. Several Polylines()/Polyline() can share one map.
*/
@Composable
fun MapViewScope.Polylines(states: List<PolylineState>) {
val collector = LocalPolylineCollector.current
val prevIdsState = remember { mutableStateOf<Set<String>>(emptySet()) }

LaunchedEffect(states) {
withContext(Dispatchers.Default) {
val prevIds = prevIdsState.value
val nextIds = states.asSequence().map { it.id }.toSet()
val removedIds = prevIds - nextIds
prevIdsState.value = nextIds
// Merge only the ids this Polylines() owns, so several
// Polylines()/Polyline() can share one map's collector.
collector.flow.update { current ->
val next = current.toMutableMap()
for (id in removedIds) next.remove(id)
for (state in states) next[state.id] = state
next
}
}
}

DisposableEffect(Unit) {
onDispose {
// Remove only the polylines this Polylines() owns on dispose.
val ownIds = prevIdsState.value
if (ownIds.isNotEmpty()) {
collector.flow.update { current ->
val next = current.toMutableMap()
for (id in ownIds) next.remove(id)
next
}
}
prevIdsState.value = emptySet()
}
}
}

/**
* How this map draws many polylines: as raster tiles once there are enough
* of them ([PolylineTilingOptions.Default]), never ([PolylineTilingOptions.Disabled]),
* or at other thresholds. Without it the map uses [PolylineTilingOptions.Default].
*
* Read when the polylines are handed to the map, so put it in the map's
* content before them; a change takes effect the next time they are handed
* over (recreate the map to apply it to an unchanged list).
*/
@Composable
fun MapViewScope.PolylineTiling(options: PolylineTilingOptions) {
val scope = this
SideEffect {
scope.polylineTilingOptions.value = options
}
}

@Composable
fun MapViewScope.Polyline(
points: List<GeoPointInterface>,
Expand Down
Original file line number Diff line number Diff line change
@@ -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>(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<Set<String>>()

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