From e8f5df305591adde16b663d6d150c70282d8364c Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Wed, 14 Oct 2026 15:43:43 +0200 Subject: [PATCH] Add setOnMapLoadedCallback like in GoogleMap - Called once after a draw where all visible base tiles were shown and the camera was not moving, on the main thread outside the draw - Setting it on a loaded map triggers a redraw that reports it - Tile overlays are not waited for, sparse overlays have gaps Fixes #59 --- docs/PUBLIC_API.md | 2 +- docs/TESTING_UNIT.md | 3 +- .../de/afarber/openmapview/MapController.kt | 32 +++- .../openmapview/OnMapLoadedCallback.kt | 22 +++ .../de/afarber/openmapview/OpenMapView.kt | 21 +++ .../openmapview/MapLoadedCallbackTest.kt | 162 ++++++++++++++++++ 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 openmapview/src/main/kotlin/de/afarber/openmapview/OnMapLoadedCallback.kt create mode 100644 openmapview/src/test/kotlin/de/afarber/openmapview/MapLoadedCallbackTest.kt diff --git a/docs/PUBLIC_API.md b/docs/PUBLIC_API.md index 4b1a615..b330b37 100644 --- a/docs/PUBLIC_API.md +++ b/docs/PUBLIC_API.md @@ -297,7 +297,7 @@ OpenMapView provides comprehensive event listener support using Kotlin `fun inte | `setOnCameraMoveListener(OnCameraMoveListener)` | `void` | | | `setOnCameraIdleListener(OnCameraIdleListener)` | `void` | | | `setOnCameraMoveCanceledListener(OnCameraMoveCanceledListener)` | `void` | | -| `setOnMapLoadedCallback(OnMapLoadedCallback)` | `void` | Not implemented - tiles load asynchronously, callback could be added | +| `setOnMapLoadedCallback(OnMapLoadedCallback)` | `void` | Fires once when all visible base tiles are drawn (tile overlays are not awaited) | | `setInfoWindowAdapter(InfoWindowAdapter)` | `void` | Not implemented - custom adapters not yet implemented | | `setOnInfoWindowClickListener(OnInfoWindowClickListener)` | `void` | | | `setOnInfoWindowCloseListener(OnInfoWindowCloseListener)` | `void` | Called when info window is closed (manual or auto-dismiss) | diff --git a/docs/TESTING_UNIT.md b/docs/TESTING_UNIT.md index 3603b5d..7548f2f 100644 --- a/docs/TESTING_UNIT.md +++ b/docs/TESTING_UNIT.md @@ -52,7 +52,7 @@ The `--continue` flag ensures all tests run even if some fail, useful for gettin ## Test Structure -### Current Test Coverage (482 tests across 36 test classes) +### Current Test Coverage (489 tests across 37 test classes) | Test Class | Tests | Description | |------------|-------|-------------| @@ -72,6 +72,7 @@ The `--continue` flag ensures all tests run even if some fail, useful for gettin | **MapControllerTest** | 103 | Zoom, pan, marker/polyline/polygon management, touch detection, padding, camera bounds, edge effects | | **MapControllerTileLoadingTest** | 7 | Base tile loading order (disk, network, stale fallback) and retry backoff | | **MapFeaturesTest** | 5 | Feature query methods (traffic, indoor, buildings, myLocation) | +| **MapLoadedCallbackTest** | 7 | setOnMapLoadedCallback: fires once when all visible tiles are drawn, waits for missing tiles and camera moves | | **MapProjectionTest** | 8 | Projection screen/LatLng conversion, pan offset, padding, visible region | | **MapTypeTest** | 18 | Map type switching and validation | | **MarkerIconFactoryTest** | 3 | Default marker icon generation and caching | diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt index b4642d1..448219e 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt @@ -222,6 +222,9 @@ class MapController( private var tileRetries = TileRetryTracker() private var onTileLoadedCallback: (() -> Unit)? = null + // One-shot callback for setOnMapLoadedCallback, checked at the end of every draw + private var onMapLoadedCallback: OnMapLoadedCallback? = null + // API key error overlay (null if no error) private var apiKeyErrorOverlay: ApiKeyErrorOverlay? = null private var requestedMapType: Int = MapType.STANDARD @@ -1137,6 +1140,22 @@ class MapController( paddingBottom = bottom } + /** + * Sets a callback that is invoked once, after a draw in which all visible base map + * tiles were shown and the camera was not moving. If the map is already fully drawn, + * the callback is invoked after the next frame. Tile overlays are not waited for, + * because sparse overlays have no tiles for many areas. + * + * The callback runs on the main thread, outside of the draw pass. + * + * @param callback The callback, or null to remove a pending one + */ + fun setOnMapLoadedCallback(callback: OnMapLoadedCallback?) { + onMapLoadedCallback = callback + // Redraw, so an already loaded map reports it without waiting for a camera change + if (callback != null) onTileLoadedCallback?.invoke() + } + /** * Sets a callback to be invoked when tiles finish loading. * @@ -1252,7 +1271,9 @@ class MapController( drawTileOverlay(canvas, visibleTiles, centerPixelX, centerPixelY, overlay) } - // Draw base map tiles (z-index 0) + // Draw base map tiles (z-index 0). Without a tile source (MapType.NONE) there is + // nothing to wait for, a stale offline fallback tile counts as drawn. + var allTilesDrawn = true for (tile in visibleTiles) { val (tilePixelX, tilePixelY) = ProjectionUtils.tileToPixel(tile) @@ -1265,6 +1286,7 @@ class MapController( } else { canvas.drawRect(screenX, screenY, screenX + TILE_SIZE, screenY + TILE_SIZE, tilePlaceholderPaint) canvas.drawRect(screenX, screenY, screenX + TILE_SIZE, screenY + TILE_SIZE, tileBorderPaint) + if (tileSource != null) allTilesDrawn = false } if (needsDownload(tile, SystemClock.elapsedRealtime())) { @@ -1321,6 +1343,14 @@ class MapController( // Draw API key error overlay if present (on top of everything, but touch events pass through) apiKeyErrorOverlay?.draw(canvas, viewWidth, viewHeight) + + if (allTilesDrawn && !isCameraMoving) { + onMapLoadedCallback?.let { callback -> + onMapLoadedCallback = null + // Run outside of the draw pass, so the callback may e.g. take a snapshot + scope.launch { callback.onMapLoaded() } + } + } } /** diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/OnMapLoadedCallback.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/OnMapLoadedCallback.kt new file mode 100644 index 0000000..b9375a6 --- /dev/null +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/OnMapLoadedCallback.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +/** + * Callback for when the map has finished rendering. + * + * Set it using [OpenMapView.setOnMapLoadedCallback]. It is called once, after all visible + * base map tiles have been drawn and the camera is not moving. It is not called if the + * tiles can't be loaded (e.g. offline) or while the camera keeps moving. + */ +fun interface OnMapLoadedCallback { + /** + * Called when the map has finished rendering. + */ + fun onMapLoaded() +} diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt index d27c230..5043de4 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt @@ -1253,6 +1253,27 @@ class OpenMapView controller.onCameraIdleListener = listener } + /** + * Sets a callback that is invoked once when the map has finished rendering, i.e. + * all visible base map tiles are drawn and the camera is not moving. + * + * If the map is already fully drawn, the callback is invoked after the next frame. + * It is not invoked while tiles can't be loaded (e.g. offline) or while the camera + * keeps moving. Tile overlays are not waited for. + * + * Example usage: + * ```kotlin + * mapView.setOnMapLoadedCallback { + * mapView.snapshot { bitmap -> bitmap?.let { saveToFile(it) } } + * } + * ``` + * + * @param callback The callback to invoke, or null to remove a pending one + */ + fun setOnMapLoadedCallback(callback: OnMapLoadedCallback?) { + controller.setOnMapLoadedCallback(callback) + } + /** * Sets a listener to handle camera movement cancellation events. * diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/MapLoadedCallbackTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/MapLoadedCallbackTest.kt new file mode 100644 index 0000000..9df3d9d --- /dev/null +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/MapLoadedCallbackTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Looper +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class MapLoadedCallbackTest { + private lateinit var controller: MapController + private var loadedCount = 0 + private val callback = OnMapLoadedCallback { loadedCount++ } + + @Before + fun setUp() { + controller = MapController(RuntimeEnvironment.getApplication()) + controller.setViewSize(SIZE, SIZE) + // Memory-only cache, so no tile comes from disk and no download can finish + setField("tileCache", TileCache()) + } + + @After + fun tearDown() { + controller.onDestroy() + } + + @Test + fun testFiresOnceWhenAllTilesAreDrawn() { + controller.setOnMapLoadedCallback(callback) + putVisibleTiles() + + draw() + draw() + + assertEquals(1, loadedCount) + } + + @Test + fun testWaitsForMissingTile() { + controller.setOnMapLoadedCallback(callback) + val tiles = visibleTiles() + tiles.drop(1).forEach { tileCache().put(it, tileBitmap()) } + + draw() + assertEquals(0, loadedCount) + + tileCache().put(tiles.first(), tileBitmap()) + draw() + assertEquals(1, loadedCount) + } + + @Test + fun testFiresForAlreadyLoadedMap() { + putVisibleTiles() + draw() + var redrawRequested = false + controller.setOnTileLoadedCallback { redrawRequested = true } + + controller.setOnMapLoadedCallback(callback) + + // OpenMapView redraws on this request, the draw then reports the loaded map + assertEquals(true, redrawRequested) + draw() + assertEquals(1, loadedCount) + } + + @Test + fun testFiresWithoutBaseMap() { + controller.setMapType(MapType.NONE) + controller.setOnMapLoadedCallback(callback) + + draw() + + assertEquals(1, loadedCount) + } + + @Test + fun testWaitsWhileCameraIsMoving() { + controller.setOnMapLoadedCallback(callback) + putVisibleTiles() + controller.isCameraMoving = true + + draw() + assertEquals(0, loadedCount) + + controller.isCameraMoving = false + draw() + assertEquals(1, loadedCount) + } + + @Test + fun testNullRemovesPendingCallback() { + controller.setOnMapLoadedCallback(callback) + controller.setOnMapLoadedCallback(null) + putVisibleTiles() + + draw() + + assertEquals(0, loadedCount) + } + + @Test + fun testOpenMapViewForwardsCallback() { + val mapView = OpenMapView(RuntimeEnvironment.getApplication()) + mapView.layout(0, 0, SIZE, SIZE) + mapView.setMapType(MapType.NONE) + mapView.setOnMapLoadedCallback(callback) + + mapView.draw(Canvas(Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888))) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, loadedCount) + } + + // Draws once and runs the callbacks posted to the main thread. A mocked canvas, because + // Robolectric's legacy graphics can't draw bitmaps that are partly outside the canvas. + private fun draw() { + controller.draw(mockk(relaxed = true)) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun visibleTiles(): List = + ViewportCalculator.getVisibleTiles(controller.getCenter(), controller.getZoom().toInt(), SIZE, SIZE) + + private fun putVisibleTiles() = visibleTiles().forEach { tileCache().put(it, tileBitmap()) } + + private fun tileBitmap(): Bitmap = Bitmap.createBitmap(256, 256, Bitmap.Config.RGB_565) + + private fun tileCache(): TileCache { + val field = MapController::class.java.getDeclaredField("tileCache") + field.isAccessible = true + return field.get(controller) as TileCache + } + + private fun setField( + name: String, + value: Any, + ) { + val field = MapController::class.java.getDeclaredField(name) + field.isAccessible = true + field.set(controller, value) + } + + private companion object { + const val SIZE = 256 + } +}