diff --git a/api/android-sdk-compose.api b/api/android-sdk-compose.api index 7c56c60..964f159 100644 --- a/api/android-sdk-compose.api +++ b/api/android-sdk-compose.api @@ -1,13 +1,13 @@ public class com.mapconductor.compose.MapViewScope { public com.mapconductor.compose.MapViewScope(); - public final com.mapconductor.core.OverlayCollector getMarkerCollector(); + public final com.mapconductor.core.OverlayCollector getMarkerCollector(); public final kotlinx.coroutines.flow.MutableStateFlow> getBubbleFlow(); public final kotlinx.coroutines.flow.MutableStateFlow> getMarkerAnimationFlow(); - public final com.mapconductor.core.OverlayCollector getPolylineCollector(); - public final com.mapconductor.core.OverlayCollector getCircleCollector(); - public final com.mapconductor.core.OverlayCollector getPolygonCollector(); - public final com.mapconductor.core.OverlayCollector getGroundImageCollector(); - public final com.mapconductor.core.OverlayCollector getRasterLayerCollector(); + public final com.mapconductor.core.OverlayCollector getPolylineCollector(); + public final com.mapconductor.core.OverlayCollector getCircleCollector(); + public final com.mapconductor.core.OverlayCollector getPolygonCollector(); + public final com.mapconductor.core.OverlayCollector getGroundImageCollector(); + public final com.mapconductor.core.OverlayCollector getRasterLayerCollector(); public final com.mapconductor.core.map.MapOverlayRegistry buildRegistry(); } public final class com.mapconductor.compose.OverlayProviderKt { @@ -29,6 +29,7 @@ public final class com.mapconductor.compose.groundimage.LocalGroundImageCollecto } public final class com.mapconductor.compose.info.DrawInfoBubbleKt { public static final void DrawInfoBubble-5cZf5Bw(androidx.compose.ui.Modifier, long, long, float, float, float, float, kotlin.jvm.functions.Function2, androidx.compose.runtime.Composer, int); + public static final boolean isInsideBubble-M_7yMNQ(long, float, float, float); } public final class com.mapconductor.compose.info.InfoBubbleComposeKt { public static final void InfoBubble-j2PKIZY(com.mapconductor.compose.MapViewScope, com.mapconductor.core.marker.MarkerState, long, long, float, float, float, float, kotlin.jvm.functions.Function2, androidx.compose.runtime.Composer, int, int); @@ -55,9 +56,6 @@ public abstract class com.mapconductor.compose.map.BaseMapViewSaver createSaver(); protected abstract java.lang.String getStateId(T); } -public final class com.mapconductor.compose.map.CollectAndUpdateEachKt { - public static final void CollectAndUpdateEach-NcHsxvU(kotlinx.coroutines.flow.StateFlow>, long, kotlin.jvm.functions.Function1>, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, androidx.compose.runtime.Composer, int); -} public final class com.mapconductor.compose.map.MapAttributionOverlayKt { public static final void MapAttributionOverlay(java.util.List, androidx.compose.runtime.Composer, int); } diff --git a/build.gradle.kts b/build.gradle.kts index 588d645..5517ed4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -62,6 +62,9 @@ kotlin { project.property("jvmTarget").toString(), ), ) + // ドライバー実装点(@InternalMapConductorApi)を使うためのオプトイン。 + // この層はオーバーレイ収集の配線そのものなので、モジュール単位で許可する。 + optIn.add("com.mapconductor.core.InternalMapConductorApi") } } diff --git a/src/main/java/com/mapconductor/compose/OverlayProvider.kt b/src/main/java/com/mapconductor/compose/OverlayProvider.kt index acf22b1..29c804f 100644 --- a/src/main/java/com/mapconductor/compose/OverlayProvider.kt +++ b/src/main/java/com/mapconductor/compose/OverlayProvider.kt @@ -1,97 +1,85 @@ -package com.mapconductor.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import com.mapconductor.compose.info.InfoBubbleEntry -import com.mapconductor.core.OverlayCollector -import com.mapconductor.core.circle.CircleFingerPrint -import com.mapconductor.core.circle.CircleOverlay -import com.mapconductor.core.circle.CircleState -import com.mapconductor.core.controller.MapViewControllerInterface -import com.mapconductor.core.groundimage.GroundImageFingerPrint -import com.mapconductor.core.groundimage.GroundImageOverlay -import com.mapconductor.core.groundimage.GroundImageState -import com.mapconductor.core.map.MapOverlayInterface -import com.mapconductor.core.map.MapOverlayRegistry -import com.mapconductor.core.marker.MarkerAnimationOverlayEntry -import com.mapconductor.core.marker.MarkerFingerPrint -import com.mapconductor.core.marker.MarkerOverlay -import com.mapconductor.core.marker.MarkerState -import com.mapconductor.core.polygon.PolygonFingerPrint -import com.mapconductor.core.polygon.PolygonOverlay -import com.mapconductor.core.polygon.PolygonState -import com.mapconductor.core.polyline.PolylineFingerPrint -import com.mapconductor.core.polyline.PolylineOverlay -import com.mapconductor.core.polyline.PolylineState -import com.mapconductor.core.raster.RasterLayerFingerPrint -import com.mapconductor.core.raster.RasterLayerOverlay -import com.mapconductor.core.raster.RasterLayerState -import com.mapconductor.settings.Settings -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow - -open class MapViewScope { - val markerCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val bubbleFlow = MutableStateFlow>(mutableMapOf()) - - /** Active screen-space marker animations, keyed by marker id. */ - val markerAnimationFlow = MutableStateFlow>(emptyMap()) - val polylineCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val circleCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val polygonCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val groundImageCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - val rasterLayerCollector = - OverlayCollector( - fingerPrintOf = { it.fingerPrint() }, - updateDebounce = Settings.Default.composeEventDebounce, - ) - - fun buildRegistry(): MapOverlayRegistry { - val registry = MapOverlayRegistry() - registry.register(MarkerOverlay(markerCollector.flow)) - registry.register(CircleOverlay(circleCollector.flow)) - registry.register(PolylineOverlay(polylineCollector.flow)) - registry.register(PolygonOverlay(polygonCollector.flow)) - registry.register(GroundImageOverlay(groundImageCollector.flow)) - registry.register(RasterLayerOverlay(rasterLayerCollector.flow)) - return registry - } -} - -@OptIn(FlowPreview::class) -@Composable -fun CollectAndRenderOverlays( - registry: MapOverlayRegistry, - controller: MapViewControllerInterface, -) { - registry.getAll().forEach { overlay -> - @Suppress("UNCHECKED_CAST") - val typedOverlay = overlay as MapOverlayInterface - val flowState = typedOverlay.flow.collectAsState() - - LaunchedEffect(flowState.value) { - typedOverlay.render(flowState.value, controller) - } - } -} +package com.mapconductor.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import com.mapconductor.compose.info.InfoBubbleEntry +import com.mapconductor.core.OverlayCollector +import com.mapconductor.core.circle.CircleOverlay +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.controller.MapViewControllerInterface +import com.mapconductor.core.groundimage.GroundImageOverlay +import com.mapconductor.core.groundimage.GroundImageState +import com.mapconductor.core.map.MapOverlayInterface +import com.mapconductor.core.map.MapOverlayRegistry +import com.mapconductor.core.marker.MarkerAnimationOverlayEntry +import com.mapconductor.core.marker.MarkerOverlay +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.polygon.PolygonOverlay +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.PolylineOverlay +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.raster.RasterLayerOverlay +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.settings.Settings +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow + +open class MapViewScope { + val markerCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val bubbleFlow = MutableStateFlow>(mutableMapOf()) + + /** Active screen-space marker animations, keyed by marker id. */ + val markerAnimationFlow = MutableStateFlow>(emptyMap()) + val polylineCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val circleCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val polygonCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val groundImageCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + val rasterLayerCollector = + OverlayCollector( + updateDebounce = Settings.Default.composeEventDebounce, + ) + + fun buildRegistry(): MapOverlayRegistry { + val registry = MapOverlayRegistry() + registry.register(MarkerOverlay(markerCollector.flow)) + registry.register(CircleOverlay(circleCollector.flow)) + registry.register(PolylineOverlay(polylineCollector.flow)) + registry.register(PolygonOverlay(polygonCollector.flow)) + registry.register(GroundImageOverlay(groundImageCollector.flow)) + registry.register(RasterLayerOverlay(rasterLayerCollector.flow)) + return registry + } +} + +@OptIn(FlowPreview::class) +@Composable +fun CollectAndRenderOverlays( + registry: MapOverlayRegistry, + controller: MapViewControllerInterface, +) { + registry.getAll().forEach { overlay -> + @Suppress("UNCHECKED_CAST") + val typedOverlay = overlay as MapOverlayInterface + val flowState = typedOverlay.flow.collectAsState() + + LaunchedEffect(flowState.value) { + typedOverlay.render(flowState.value, controller) + } + } +} diff --git a/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt b/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt index 7476f34..e5e9946 100644 --- a/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt +++ b/src/main/java/com/mapconductor/compose/info/DrawInfoBubble.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp @Composable @@ -27,7 +28,37 @@ internal fun DrawInfoBubble( tailSize: Dp, content: @Composable () -> Unit, ) { - Box(modifier = modifier.wrapContentSize()) { + Box( + modifier = + modifier + .wrapContentSize() + .pointerInput(tailSize) { + // 吹き出しの上のタップを地図へ落とさない。 + // + // 地図は下に敷かれた AndroidView で、誰も消費しなかった + // イベントはそこへ届く。吹き出しの余白を叩くと地図のタップ + // として扱われ、下にいたマーカーが選ばれたり吹き出しが閉じ + // たりする。「当たったが誰も使わなかった」と「当たらなかっ + // た」は別なので、前者をここで止める。 + // + // 消費は Main パスで行う。Compose は Main を子から親へ配る + // ため、ここへ来た時点で中身のボタンや clickable は既に自分 + // の分を受け取っている。Initial パスで消費すると、それらが + // 動かなくなる。 + val tailPx = tailSize.toPx() + val boxHeight = size.height.toFloat() + val boxWidth = size.width.toFloat() + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { change -> + if (isInsideBubble(change.position, boxHeight, boxWidth, tailPx)) { + change.consume() + } + } + } + } + }, + ) { Canvas(modifier = Modifier.matchParentSize()) { val width = size.width val height = size.height @@ -110,3 +141,25 @@ internal fun DrawInfoBubble( } } } + +/** + * 吹き出しの本体の内側か。 + * + * Box はしっぽを含む外接矩形なので、しっぽの左右の角は箱の中だが吹き出しでは + * ない。そこまで吹き出し扱いにすると、地図が見えている場所を叩いても何も起き + * ない。 + * + * しっぽ自体も外している。既定 8dp の三角形で、外して困るのは先端を狙って + * 叩いた場合だけ。そのとき起きるのは下の地図が反応することで、狙っていた + * ものが動かないのとは違う。角の丸めも同じ理由で無視している。 + */ +internal fun isInsideBubble( + point: Offset, + height: Float, + width: Float, + tailPx: Float, +): Boolean = + point.x >= 0f && + point.x <= width && + point.y >= 0f && + point.y <= height - tailPx diff --git a/src/main/java/com/mapconductor/compose/map/CollectAndUpdateEach.kt b/src/main/java/com/mapconductor/compose/map/CollectAndUpdateEach.kt deleted file mode 100644 index 2037248..0000000 --- a/src/main/java/com/mapconductor/compose/map/CollectAndUpdateEach.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.mapconductor.compose.map - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import com.mapconductor.core.ComponentState -import kotlin.time.Duration -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce - -@OptIn(FlowPreview::class) -@Composable -internal fun CollectAndUpdateEach( - states: kotlinx.coroutines.flow.StateFlow>, - debounce: Duration, - asFlow: (T) -> Flow, - onUpdate: suspend (T) -> Unit, -) { - val snapshot = states.collectAsState() - snapshot.value.values.forEach { state -> - LaunchedEffect(state.id) { - asFlow(state).debounce(debounce).collectLatest { - onUpdate(state) - } - } - } -} diff --git a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt index ae4b1ea..541264b 100644 --- a/src/main/java/com/mapconductor/compose/map/MapViewBase.kt +++ b/src/main/java/com/mapconductor/compose/map/MapViewBase.kt @@ -57,11 +57,13 @@ import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapDesignTypeInterface import com.mapconductor.core.map.MapOverlayRegistry +import com.mapconductor.core.map.MapPaddings import com.mapconductor.core.map.MapViewHolderInterface import com.mapconductor.core.map.MapViewStateInterface import com.mapconductor.core.map.ScreenProjectionRequirement import com.mapconductor.core.map.resolveMapAttributions import com.mapconductor.core.marker.MarkerCapableInterface +import com.mapconductor.core.map.VectorStyleSupportKey import com.mapconductor.core.marker.MarkerRenderingSupportKey import com.mapconductor.core.polygon.PolygonCapableInterface import com.mapconductor.core.polyline.PolylineCapableInterface @@ -120,7 +122,10 @@ fun < // 登録側は各プロバイダ(コントローラ生成時)。content の合成より前に登録される必要があり、 // `MarkerClusterGroup` は登録が無ければその場で return するため、ここで put はしない。 DisposableEffect(state) { - onDispose { state.serviceRegistry.remove(MarkerRenderingSupportKey) } + onDispose { + state.serviceRegistry.remove(MarkerRenderingSupportKey) + state.serviceRegistry.remove(VectorStyleSupportKey) + } } controller?.also { diff --git a/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt b/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt new file mode 100644 index 0000000..525391e --- /dev/null +++ b/src/test/java/com/mapconductor/compose/info/DrawInfoBubbleHitTest.kt @@ -0,0 +1,68 @@ +package com.mapconductor.compose.info + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 吹き出しが自分のタップを消費する範囲。 + * + * 消費しすぎても消費しなさすぎても、落ちも警告も出ない。狭ければタップが下の + * 地図へ抜けて別のマーカーが選ばれ、広ければ地図が見えている場所を叩いても + * 何も起きない。どちらも「たまに反応がおかしい」としか見えないので、境界は + * ここで固定する。 + * + * 座標系は [DrawInfoBubble] の Box の左上原点。高さはしっぽを含む。 + */ +class DrawInfoBubbleHitTest { + private val width = 200f + private val height = 108f + private val tail = 8f + private val bodyBottom = height - tail // 100f + + private fun inside( + x: Float, + y: Float, + ) = isInsideBubble(Offset(x, y), height, width, tail) + + @Test + fun bodyIsInside() { + assertTrue("中央", inside(width / 2f, bodyBottom / 2f)) + assertTrue("左上の角", inside(0f, 0f)) + assertTrue("右上の角", inside(width, 0f)) + assertTrue("本体の下端", inside(10f, bodyBottom)) + } + + /** + * しっぽの左右は箱の中だが吹き出しではない。ここが本題で、外接矩形をその + * まま使っていたときはこの領域まで吹き出し扱いになっていた。 + */ + @Test + fun besideTheTailIsOutside() { + assertFalse("しっぽの左", inside(10f, bodyBottom + tail / 2f)) + assertFalse("しっぽの右", inside(width - 10f, bodyBottom + tail / 2f)) + assertFalse("左下の角", inside(0f, height)) + assertFalse("右下の角", inside(width, height)) + } + + /** + * しっぽ自体も外側。 + * + * 既定 8dp の三角形なので、外して困るのは先端を狙って叩いた場合だけで、 + * そのとき起きるのは下の地図が反応することにすぎない。 + */ + @Test + fun theTailIsOutside() { + assertFalse("しっぽの中心", inside(width / 2f, bodyBottom + tail / 2f)) + assertFalse("先端", inside(width / 2f, height)) + } + + @Test + fun outsideTheBoxIsOutside() { + assertFalse(inside(-1f, 10f)) + assertFalse(inside(width + 1f, 10f)) + assertFalse(inside(10f, -1f)) + assertFalse(inside(10f, height + 1f)) + } +}