diff --git a/README.md b/README.md index 6bfb3245..d31dcb5c 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,38 @@ and don't use `Marker` composables (unless you don't care about `onClick` events). Clustering is the only use-case tested with `MapEffect`, there may be gotchas depending on what features you use in the utility library. +## Widgets + +This library also provides optional composable widgets in the `maps-compose-widgets` library that you can use alongside the `GoogleMap` composable. + +### ScaleBar + +This widget shows the current scale of the map in feet and meters when zoomed into the map, changing to miles and kilometers, respectively, when zooming out. A `DisappearingScaleBar` is also included, which appears when the zoom level of the map changes, and then disappears after a configurable timeout period. + +The [ScaleBarActivity](app/src/main/java/com/google/maps/android/compose/ScaleBarActivity.kt) demonstrates both of these, with the `DisappearingScaleBar` in the upper left corner and the normal base `ScaleBar` in the upper right: + +![maps-compose-scale-bar-cropped](https://user-images.githubusercontent.com/928045/175665891-a0635004-2201-4392-83b3-0c6553b96926.gif) + +Both versions of this widget leverage the `CameraPositionState` in `maps-compose` and therefore are very simple to configure with their defaults: + +```kotlin +ScaleBar( + modifier = Modifier + .padding(top = 5.dp, end = 15.dp) + .align(Alignment.TopEnd), + cameraPositionState = cameraPositionState +) + +DisappearingScaleBar( + modifier = Modifier + .padding(top = 5.dp, end = 15.dp) + .align(Alignment.TopStart), + cameraPositionState = cameraPositionState +) +``` + +The colors of the text, line, and shadow are also all configurable (e.g., based on `isSystemInDarkTheme()` on a dark map). Similarly, the `DisappearingScaleBar` animations can be configured. + ## Sample App This repository includes a [sample app](app). @@ -218,7 +250,10 @@ dependencies { implementation 'com.google.android.gms:play-services-maps:18.0.2' // Also include Compose version `1.2.0-alpha03` or higher - for example: - implementation "androidx.compose.foundation:foundation:2.4.0-alpha03" + implementation 'androidx.compose.foundation:foundation:2.4.0-alpha03' + + // Optionally, you can include the widgets library if you want to use ScaleBar, etc. + implementation 'com.google.maps.android:maps-compose-widgets:1.0.0' } ``` diff --git a/app/build.gradle b/app/build.gradle index 68f34789..c979f201 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -59,6 +59,8 @@ dependencies { // declaration if you want to test the sample app with a Maven Central release of the library. //implementation "com.google.maps.android:maps-compose:2.2.1" implementation project(':maps-compose') + //implementation "com.google.maps.android:maps-compose-widgets:1.0.0" + implementation project(':maps-compose-widgets') implementation 'com.google.android.gms:play-services-maps:18.0.2' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 69904cab..7532eb52 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -52,6 +52,9 @@ + diff --git a/app/src/main/java/com/google/maps/android/compose/MainActivity.kt b/app/src/main/java/com/google/maps/android/compose/MainActivity.kt index ad7a75b0..4ca344be 100644 --- a/app/src/main/java/com/google/maps/android/compose/MainActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/MainActivity.kt @@ -81,6 +81,13 @@ class MainActivity : ComponentActivity() { }) { Text(getString(R.string.location_tracking_activity)) } + Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity(Intent(context, ScaleBarActivity::class.java)) + }) { + Text(getString(R.string.scale_bar_activity)) + } } } } diff --git a/app/src/main/java/com/google/maps/android/compose/ScaleBarActivity.kt b/app/src/main/java/com/google/maps/android/compose/ScaleBarActivity.kt new file mode 100644 index 00000000..ca3e7266 --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/ScaleBarActivity.kt @@ -0,0 +1,99 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.widgets.DisappearingScaleBar +import com.google.maps.android.compose.widgets.ScaleBar + +private const val TAG = "ScaleBarActivity" + +private const val zoom = 8f +private val singapore = LatLng(1.35, 103.87) +private val defaultCameraPosition = CameraPosition.fromLatLngZoom(singapore, zoom) + +class ScaleBarActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + var isMapLoaded by remember { mutableStateOf(false) } + + // To control and observe the map camera + val cameraPositionState = rememberCameraPositionState { + position = defaultCameraPosition + } + + Box(Modifier.fillMaxSize()) { + GoogleMap( + modifier = Modifier.matchParentSize(), + cameraPositionState = cameraPositionState, + onMapLoaded = { + isMapLoaded = true + } + ) + DisappearingScaleBar( + modifier = Modifier + .padding(top = 5.dp, end = 15.dp) + .align(Alignment.TopStart), + cameraPositionState = cameraPositionState + ) + ScaleBar( + modifier = Modifier + .padding(top = 5.dp, end = 15.dp) + .align(Alignment.TopEnd), + cameraPositionState = cameraPositionState + ) + if (!isMapLoaded) { + AnimatedVisibility( + modifier = Modifier + .matchParentSize(), + visible = !isMapLoaded, + enter = EnterTransition.None, + exit = fadeOut() + ) { + CircularProgressIndicator( + modifier = Modifier + .background(MaterialTheme.colors.background) + .wrapContentSize() + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49f6e6e9..51a91f28 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,4 +21,5 @@ Map In Column Map Clustering Location Tracking + Scale Bar \ No newline at end of file diff --git a/build.gradle b/build.gradle index cb30c3ae..0566fd47 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,7 @@ plugins { } ext.projectArtifactId = { project -> - if (project.name == 'maps-compose') { + if (project.name == 'maps-compose' || project.name == 'maps-compose-widgets') { return project.name } else { return null diff --git a/maps-compose-widgets/.gitignore b/maps-compose-widgets/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/maps-compose-widgets/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/maps-compose-widgets/build.gradle b/maps-compose-widgets/build.gradle new file mode 100644 index 00000000..a3dc1b8f --- /dev/null +++ b/maps-compose-widgets/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'kotlin-android' + id 'kotlin-parcelize' +} + +android { + compileSdk 31 + + defaultConfig { + minSdk 21 + targetSdk 31 + versionCode 1 + versionName "1.0" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + composeOptions { + kotlinCompilerExtensionVersion "$compose_version" + } + + buildFeatures { + buildConfig false + compose true + } + + kotlinOptions { + jvmTarget = '1.8' + freeCompilerArgs += '-Xexplicit-api=strict' + freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' + } +} + +dependencies { + implementation "androidx.compose.foundation:foundation:$compose_version" + implementation project(':maps-compose') + implementation 'androidx.compose.material:material:1.1.1' + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'com.google.android.gms:play-services-maps:18.0.2' + implementation 'com.google.maps.android:maps-ktx:3.4.0' + implementation 'com.google.maps.android:maps-utils-ktx:3.3.0' + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + implementation "androidx.core:core-ktx:1.7.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} \ No newline at end of file diff --git a/maps-compose-widgets/consumer-rules.pro b/maps-compose-widgets/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/maps-compose-widgets/proguard-rules.pro b/maps-compose-widgets/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/maps-compose-widgets/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/widgets/ExampleInstrumentedTest.kt b/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/widgets/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..509b3912 --- /dev/null +++ b/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/widgets/ExampleInstrumentedTest.kt @@ -0,0 +1,36 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose.widgets + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +internal class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.google.maps.android.compose.widgets.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/maps-compose-widgets/src/main/AndroidManifest.xml b/maps-compose-widgets/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7931c287 --- /dev/null +++ b/maps-compose-widgets/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt b/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt new file mode 100644 index 00000000..d988268d --- /dev/null +++ b/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt @@ -0,0 +1,286 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.maps.android.compose.widgets + +import android.graphics.Point +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.CameraPositionState +import com.google.maps.android.ktx.utils.sphericalDistance +import kotlinx.coroutines.delay + +public val DarkGray: Color = Color(0xFF3a3c3b) +private val defaultWidth: Dp = 65.dp +private val defaultHeight: Dp = 50.dp + +/** + * A scale bar composable that shows the current scale of the map in feet and meters when zoomed in + * to the map, changing to miles and kilometers, respectively, when zooming out. + * + * Implement your own observer on camera move events using [CameraPositionState] and pass it in + * as [cameraPositionState]. + */ +@Composable +public fun ScaleBar( + modifier: Modifier = Modifier, + width: Dp = defaultWidth, + height: Dp = defaultHeight, + cameraPositionState: CameraPositionState, + textColor: Color = DarkGray, + lineColor: Color = DarkGray, + shadowColor: Color = Color.White, +) { + Box( + modifier = modifier + .size(width = width, height = height) + ) { + var horizontalLineWidthMeters by remember { + mutableStateOf(0) + } + + Canvas( + modifier = Modifier.fillMaxSize(), + onDraw = { + // Get width of canvas in meters + val upperLeftLatLng = + cameraPositionState.projection?.fromScreenLocation(Point(0, 0)) + ?: LatLng(0.0, 0.0) + val upperRightLatLng = + cameraPositionState.projection?.fromScreenLocation(Point(0, size.width.toInt())) + ?: LatLng(0.0, 0.0) + val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) + val eightNinthsCanvasMeters = (canvasWidthMeters * 8 / 9).toInt() + + horizontalLineWidthMeters = eightNinthsCanvasMeters + + val oneNinthWidth = size.width / 9 + val midHeight = size.height / 2 + val oneThirdHeight = size.height / 3 + val twoThirdsHeight = size.height * 2 / 3 + val strokeWidth = 4f + val shadowStrokeWidth = strokeWidth + 3 + + // Middle horizontal line shadow (drawn under main lines) + drawLine( + color = shadowColor, + start = Offset(oneNinthWidth, midHeight), + end = Offset(size.width, midHeight), + strokeWidth = shadowStrokeWidth, + cap = StrokeCap.Round + ) + // Top vertical line shadow (drawn under main lines) + drawLine( + color = shadowColor, + start = Offset(oneNinthWidth, oneThirdHeight), + end = Offset(oneNinthWidth, midHeight), + strokeWidth = shadowStrokeWidth, + cap = StrokeCap.Round + ) + // Bottom vertical line shadow (drawn under main lines) + drawLine( + color = shadowColor, + start = Offset(oneNinthWidth, midHeight), + end = Offset(oneNinthWidth, twoThirdsHeight), + strokeWidth = shadowStrokeWidth, + cap = StrokeCap.Round + ) + + // Middle horizontal line + drawLine( + color = lineColor, + start = Offset(oneNinthWidth, midHeight), + end = Offset(size.width, midHeight), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + // Top vertical line + drawLine( + color = lineColor, + start = Offset(oneNinthWidth, oneThirdHeight), + end = Offset(oneNinthWidth, midHeight), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + // Bottom vertical line + drawLine( + color = lineColor, + start = Offset(oneNinthWidth, midHeight), + end = Offset(oneNinthWidth, twoThirdsHeight), + strokeWidth = strokeWidth, + cap = StrokeCap.Round + ) + } + ) + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceAround + ) { + var metricUnits = "m" + var metricDistance = horizontalLineWidthMeters + if (horizontalLineWidthMeters > METERS_IN_KILOMETER) { + // Switch from meters to kilometers as unit + metricUnits = "km" + metricDistance /= METERS_IN_KILOMETER.toInt() + } + + var imperialUnits = "ft" + var imperialDistance = horizontalLineWidthMeters.toDouble().toFeet() + if (imperialDistance > FEET_IN_MILE) { + // Switch from ft to miles as unit + imperialUnits = "mi" + imperialDistance = imperialDistance.toMiles() + } + + ScaleText( + modifier = Modifier.align(End), + textColor = textColor, + shadowColor = shadowColor, + text = "${imperialDistance.toInt()} $imperialUnits" + ) + ScaleText( + modifier = Modifier.align(End), + textColor = textColor, + shadowColor = shadowColor, + text = "$metricDistance $metricUnits" + ) + } + } +} + +/** + * An animated scale bar that appears when the zoom level of the map changes, and then disappears + * after [visibilityDurationMillis]. This composable wraps [ScaleBar] with visibility animations. + * + * Implement your own observer on camera move events using [CameraPositionState] and pass it in + * as [cameraPositionState]. + */ +@Composable +public fun DisappearingScaleBar( + modifier: Modifier = Modifier, + width: Dp = defaultWidth, + height: Dp = defaultHeight, + cameraPositionState: CameraPositionState, + textColor: Color = DarkGray, + lineColor: Color = DarkGray, + shadowColor: Color = Color.White, + visibilityDurationMillis: Int = 3_000, + enterTransition: EnterTransition = fadeIn(), + exitTransition: ExitTransition = fadeOut(), +) { + val visible = remember { + MutableTransitionState(true) + } + + LaunchedEffect(key1 = cameraPositionState.position.zoom) { + if (visible.isIdle && !visible.currentState) { + // Show ScaleBar + visible.targetState = true + } else if (visible.isIdle && visible.currentState) { + // Hide ScaleBar after timeout period + delay(visibilityDurationMillis.toLong()) + visible.targetState = false + } + } + + AnimatedVisibility( + visibleState = visible, + enter = enterTransition, + exit = exitTransition + ) { + ScaleBar( + modifier = modifier, + width = width, + height = height, + cameraPositionState = cameraPositionState, + textColor = textColor, + lineColor = lineColor, + shadowColor = shadowColor + ) + } +} + +@Composable +private fun ScaleText( + modifier: Modifier = Modifier, + text: String, + textColor: Color = DarkGray, + shadowColor: Color = Color.White, +) { + Text( + text = text, + fontSize = 12.sp, + color = textColor, + textAlign = TextAlign.End, + modifier = modifier, + style = MaterialTheme.typography.h4.copy( + shadow = Shadow( + color = shadowColor, + offset = Offset(2f, 2f), + blurRadius = 1f + ) + ) + ) +} + +/** + * Converts [this] value in meters to the corresponding value in feet + * @return [this] meters value converted to feet + */ +internal fun Double.toFeet(): Double { + return this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT +} + +/** + * Converts [this] value in feet to the corresponding value in miles + * @return [this] feet value converted to miles + */ +internal fun Double.toMiles(): Double { + return this / FEET_IN_MILE +} + +private const val CENTIMETERS_IN_METER: Double = 100.0 +private const val METERS_IN_KILOMETER: Double = 1000.0 +private const val CENTIMETERS_IN_INCH: Double = 2.54 +private const val INCHES_IN_FOOT: Double = 12.0 +private const val FEET_IN_MILE: Double = 5280.0 \ No newline at end of file diff --git a/maps-compose/build.gradle b/maps-compose/build.gradle index a29ec320..c1d45183 100644 --- a/maps-compose/build.gradle +++ b/maps-compose/build.gradle @@ -38,7 +38,7 @@ dependencies { implementation "androidx.compose.foundation:foundation:$compose_version" implementation 'androidx.core:core-ktx:1.7.0' implementation 'com.google.android.gms:play-services-maps:18.0.2' - implementation 'com.google.maps.android:maps-ktx:3.3.0' + implementation 'com.google.maps.android:maps-ktx:3.4.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' diff --git a/settings.gradle b/settings.gradle index 797e7c77..5f7603c4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,3 +9,4 @@ dependencyResolutionManagement { rootProject.name = "android-maps-compose" include ':app' include ':maps-compose' +include ':maps-compose-widgets'