Skip to content

Commit 9d895ff

Browse files
committed
feat: add WMS tile overlay support to maps-compose-utils (#880)
1 parent 8994e12 commit 9d895ff

8 files changed

Lines changed: 286 additions & 0 deletions

File tree

maps-app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
<activity
110110
android:name=".TileOverlayActivity"
111111
android:exported="true" />
112+
<activity
113+
android:name=".WmsTileOverlayActivity"
114+
android:exported="true" />
112115
<activity
113116
android:name=".GroundOverlayActivity"
114117
android:exported="true" />

maps-app/src/main/java/com/google/maps/android/compose/Demo.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ sealed class ActivityGroup(
116116
R.string.tile_overlay_activity_description,
117117
TileOverlayActivity::class
118118
),
119+
Activity(
120+
R.string.wms_tile_overlay_activity,
121+
R.string.wms_tile_overlay_activity_description,
122+
WmsTileOverlayActivity::class
123+
),
119124
Activity(
120125
R.string.ground_overlay_activity,
121126
R.string.ground_overlay_activity_description,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.compose
18+
19+
import android.os.Bundle
20+
import androidx.activity.ComponentActivity
21+
import androidx.activity.compose.setContent
22+
import androidx.compose.foundation.layout.fillMaxSize
23+
import androidx.compose.ui.Modifier
24+
import com.google.android.gms.maps.model.CameraPosition
25+
import com.google.android.gms.maps.model.LatLng
26+
import com.google.maps.android.compose.wms.WmsTileOverlay
27+
28+
/**
29+
* This activity demonstrates how to use [WmsTileOverlay] to display a Web Map Service (WMS)
30+
* layer on a map.
31+
*/
32+
class WmsTileOverlayActivity : ComponentActivity() {
33+
34+
override fun onCreate(savedInstanceState: Bundle?) {
35+
super.onCreate(savedInstanceState)
36+
setContent {
37+
val center = LatLng(39.50, -98.35) // Center of US
38+
val cameraPositionState = rememberCameraPositionState {
39+
position = CameraPosition.fromLatLngZoom(center, 4f)
40+
}
41+
42+
GoogleMap(
43+
modifier = Modifier.fillMaxSize(),
44+
cameraPositionState = cameraPositionState
45+
) {
46+
// Example: USGS National Map Shaded Relief (WMS)
47+
WmsTileOverlay(
48+
urlFormatter = { xMin, yMin, xMax, yMax, _ ->
49+
"https://basemap.nationalmap.gov/arcgis/services/USGSShadedReliefOnly/MapServer/WmsServer?" +
50+
"SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap" +
51+
"&FORMAT=image/png&TRANSPARENT=true&LAYERS=0" +
52+
"&SRS=EPSG:3857&WIDTH=256&HEIGHT=256" +
53+
"&BBOX=$xMin,$yMin,$xMax,$yMax"
54+
},
55+
transparency = 0.5f
56+
)
57+
}
58+
}
59+
}
60+
}

maps-app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@
7575
<string name="tile_overlay_activity">Tile Overlay</string>
7676
<string name="tile_overlay_activity_description">Adding a tile overlay to the map.</string>
7777

78+
<string name="wms_tile_overlay_activity">WMS Tile Overlay</string>
79+
<string name="wms_tile_overlay_activity_description">Adding a WMS (EPSG:3857) tile overlay to the map.</string>
80+
7881
<string name="ground_overlay_activity">Ground Overlay</string>
7982
<string name="ground_overlay_activity_description">Adding a ground overlay to the map.</string>
8083

maps-compose-utils/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,6 @@ dependencies {
8585
implementation(libs.kotlin)
8686
implementation(libs.kotlinx.coroutines.android)
8787
api(libs.maps.ktx.utils)
88+
89+
testImplementation(libs.test.junit)
8890
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.compose.wms
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.remember
21+
import com.google.android.gms.maps.model.TileOverlay
22+
import com.google.maps.android.compose.TileOverlay
23+
import com.google.maps.android.compose.TileOverlayState
24+
import com.google.maps.android.compose.rememberTileOverlayState
25+
26+
/**
27+
* A Composable that displays a Web Map Service (WMS) layer using the EPSG:3857 projection.
28+
*
29+
* @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates.
30+
* @param state the [TileOverlayState] to be used to control the tile overlay.
31+
* @param fadeIn boolean indicating whether the tiles should fade in.
32+
* @param transparency the transparency of the tile overlay.
33+
* @param visible the visibility of the tile overlay.
34+
* @param zIndex the z-index of the tile overlay.
35+
* @param onClick a lambda invoked when the tile overlay is clicked.
36+
* @param tileWidth the width of the tiles in pixels (default 256).
37+
* @param tileHeight the height of the tiles in pixels (default 256).
38+
*/
39+
@Composable
40+
public fun WmsTileOverlay(
41+
urlFormatter: (xMin: Double, yMin: Double, xMax: Double, yMax: Double, zoom: Int) -> String,
42+
state: TileOverlayState = rememberTileOverlayState(),
43+
fadeIn: Boolean = true,
44+
transparency: Float = 0f,
45+
visible: Boolean = true,
46+
zIndex: Float = 0f,
47+
onClick: (TileOverlay) -> Unit = {},
48+
tileWidth: Int = 256,
49+
tileHeight: Int = 256
50+
) {
51+
val tileProvider = remember(urlFormatter, tileWidth, tileHeight) {
52+
WmsUrlTileProvider(
53+
width = tileWidth,
54+
height = tileHeight,
55+
urlFormatter = urlFormatter
56+
)
57+
}
58+
TileOverlay(
59+
tileProvider = tileProvider,
60+
state = state,
61+
fadeIn = fadeIn,
62+
transparency = transparency,
63+
visible = visible,
64+
zIndex = zIndex,
65+
onClick = onClick
66+
)
67+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.compose.wms
18+
19+
import com.google.android.gms.maps.model.UrlTileProvider
20+
import java.net.MalformedURLException
21+
import java.net.URL
22+
import kotlin.math.pow
23+
24+
/**
25+
* A [UrlTileProvider] for Web Map Service (WMS) layers that use the EPSG:3857 (Web Mercator)
26+
* projection.
27+
*
28+
* @param width the width of the tile in pixels.
29+
* @param height the height of the tile in pixels.
30+
* @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates
31+
* (xMin, yMin, xMax, yMax) and zoom level.
32+
*/
33+
public class WmsUrlTileProvider(
34+
width: Int = 256,
35+
height: Int = 256,
36+
private val urlFormatter: (
37+
xMin: Double,
38+
yMin: Double,
39+
xMax: Double,
40+
yMax: Double,
41+
zoom: Int
42+
) -> String
43+
) : UrlTileProvider(width, height) {
44+
45+
override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
46+
val bbox = getBoundingBox(x, y, zoom)
47+
val urlString = urlFormatter(bbox[0], bbox[1], bbox[2], bbox[3], zoom)
48+
return try {
49+
URL(urlString)
50+
} catch (e: MalformedURLException) {
51+
null
52+
}
53+
}
54+
55+
private companion object {
56+
/**
57+
* The Earth's circumference in meters at the equator according to EPSG:3857.
58+
*/
59+
private const val EARTH_CIRCUMFERENCE = 2 * 20037508.34789244
60+
}
61+
62+
/**
63+
* Calculates the bounding box for the given tile in EPSG:3857 coordinates.
64+
*
65+
* @return an array containing [xMin, yMin, xMax, yMax] in meters.
66+
*/
67+
internal fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
68+
val numTiles = 2.0.pow(zoom.toDouble())
69+
val tileSizeMeters = EARTH_CIRCUMFERENCE / numTiles
70+
71+
val xMin = -20037508.34789244 + (x * tileSizeMeters)
72+
val xMax = -20037508.34789244 + ((x + 1) * tileSizeMeters)
73+
74+
// Y is inverted in TMS/Google Maps tiles vs WMS BBOX
75+
// Top of map (y=0) is +20037508.34789244
76+
val yMax = 20037508.34789244 - (y * tileSizeMeters)
77+
val yMin = 20037508.34789244 - ((y + 1) * tileSizeMeters)
78+
79+
return doubleArrayOf(xMin, yMin, xMax, yMax)
80+
}
81+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.compose.wms
18+
19+
import org.junit.Assert.assertArrayEquals
20+
import org.junit.Test
21+
22+
public class WmsUrlTileProviderTest {
23+
24+
private val worldSize: Double = 20037508.34789244
25+
26+
@Test
27+
public fun testGetBoundingBoxZoom0() {
28+
val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
29+
val bbox = provider.getBoundingBox(0, 0, 0)
30+
31+
// Zoom 0, Tile 0,0 should cover the entire world
32+
val expected = doubleArrayOf(-worldSize, -worldSize, worldSize, worldSize)
33+
assertArrayEquals(expected, bbox, 0.001)
34+
}
35+
36+
@Test
37+
public fun testGetBoundingBoxZoom1() {
38+
val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
39+
40+
// Zoom 1, Tile 0,0 (Top Left)
41+
val bbox00 = provider.getBoundingBox(0, 0, 1)
42+
val expected00 = doubleArrayOf(-worldSize, 0.0, 0.0, worldSize)
43+
assertArrayEquals(expected00, bbox00, 0.001)
44+
45+
// Zoom 1, Tile 1,1 (Bottom Right)
46+
val bbox11 = provider.getBoundingBox(1, 1, 1)
47+
val expected11 = doubleArrayOf(0.0, -worldSize, worldSize, 0.0)
48+
assertArrayEquals(expected11, bbox11, 0.001)
49+
}
50+
51+
@Test
52+
public fun testGetBoundingBoxSpecificTile() {
53+
val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
54+
55+
// Zoom 2, Tile 1,1
56+
// Num tiles = 4x4. Tile size = 2 * worldSize / 4 = worldSize / 2
57+
// xMin = -worldSize + 1 * (worldSize/2) = -worldSize/2
58+
// xMax = -worldSize + 2 * (worldSize/2) = 0
59+
// yMax = worldSize - 1 * (worldSize/2) = worldSize/2
60+
// yMin = worldSize - 2 * (worldSize/2) = 0
61+
val bbox = provider.getBoundingBox(1, 1, 2)
62+
val expected = doubleArrayOf(-worldSize / 2, 0.0, 0.0, worldSize / 2)
63+
assertArrayEquals(expected, bbox, 0.001)
64+
}
65+
}

0 commit comments

Comments
 (0)