From 4c09a61bac79ea5b54c9b1920ab6fa3c06e088ae Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:31:26 -0600 Subject: [PATCH 1/2] feat(settings): add 3D map quick settings widget with camera controls toggle - Introduce Map3DQuickSettingsButton for Kotlin and Java Views - Introduce Map3DQuickSettingsWidget for Jetpack Compose - Support live switching of Map Mode (Satellite, Hybrid, Roadmap) - Support live switching of Color Scheme (System, Light, Dark) - Support master and granular toggling of Maps 3D camera UI controls - Protect top camera controls and settings FAB from status bar insets - Add Map3DSettingsControllerTest with 100% passing JVM unit tests --- .../maps3d/common/Map3DQuickSettingsButton.kt | 113 ++++++++ .../maps3d/common/Map3DQuickSettingsDialog.kt | 173 ++++++++++++ .../maps3d/common/Map3DQuickSettingsWidget.kt | 259 ++++++++++++++++++ .../example/maps3d/common/Map3DSettings.kt | 154 +++++++++++ .../maps3d/common/Map3DSettingsController.kt | 150 ++++++++++ .../src/main/res/drawable/settings_24px.xml | 25 ++ .../main/res/layout/activity_common_map.xml | 12 + .../layout/dialog_map3d_quick_settings.xml | 196 +++++++++++++ .../common/src/main/res/values/strings.xml | 21 ++ .../common/Map3DSettingsControllerTest.kt | 208 ++++++++++++++ .../sampleactivity/SampleBaseActivity.java | 78 ++++++ .../sampleactivity/SampleBaseActivity.kt | 110 ++++++++ .../composedemos/hellomap/HelloMapActivity.kt | 15 + 13 files changed, 1514 insertions(+) create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsButton.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsDialog.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsWidget.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettings.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettingsController.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/drawable/settings_24px.xml create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/layout/dialog_map3d_quick_settings.xml create mode 100644 Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/Map3DSettingsControllerTest.kt diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsButton.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsButton.kt new file mode 100644 index 00000000..16bdc01f --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsButton.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 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.example.maps3d.common + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.app.AlertDialog +import com.example.maps3dcommon.R +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.material.floatingactionbutton.FloatingActionButton + +// [START maps_3d_quick_settings_button] +/** + * A self-contained settings button overlay for 3D map activities. + * + * Can be positioned anywhere in a layout (e.g., top-end or bottom-end of the map). + * Clicking the button opens a modal [Map3DQuickSettingsDialog] allowing the user + * to switch map modes (Satellite, Hybrid, Roadmap) and color schemes (System, Light, Dark). + * + * When attached to a [GoogleMap3D] instance via [attachMap], changes are automatically + * applied in real time to the 3D map. + */ +class Map3DQuickSettingsButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = com.google.android.material.R.attr.floatingActionButtonStyle, +) : FloatingActionButton(context, attrs, defStyleAttr) { + + fun interface OnSettingsChangedListener { + fun onSettingsChanged(settings: Map3DSettings) + } + + private val controller = Map3DSettingsController() + private var attachedMap: GoogleMap3D? = null + private var mapProvider: (() -> GoogleMap3D?)? = null + private var activeDialog: AlertDialog? = null + private var onSettingsChangedListener: OnSettingsChangedListener? = null + + init { + setImageResource(R.drawable.settings_24px) + contentDescription = context.getString(R.string.map3d_settings_button_description) + size = SIZE_MINI + + controller.addListener { updatedSettings -> + val map = attachedMap ?: mapProvider?.invoke() + map?.let { updatedSettings.applyTo(it) } + onSettingsChangedListener?.onSettingsChanged(updatedSettings) + } + + setOnClickListener { + showDialog() + } + } + + /** + * Attaches a [GoogleMap3D] instance. + * + * Settings changes will automatically be applied to this map instance. + */ + fun attachMap(googleMap3D: GoogleMap3D?) { + this.attachedMap = googleMap3D + if (googleMap3D != null) { + controller.getSettings().applyTo(googleMap3D) + } + } + + /** + * Sets a provider lambda that resolves the active [GoogleMap3D] instance on demand. + */ + fun setMapProvider(provider: () -> GoogleMap3D?) { + this.mapProvider = provider + } + + /** Returns the current [Map3DSettings] snapshot. */ + fun getSettings(): Map3DSettings = controller.getSettings() + + /** Programmatically sets new [Map3DSettings]. */ + fun setSettings(settings: Map3DSettings) { + controller.setSettings(settings) + } + + /** Sets a callback invoked whenever the user updates settings in the dialog. */ + fun setOnSettingsChangedListener(listener: OnSettingsChangedListener) { + this.onSettingsChangedListener = listener + } + + /** Displays the [Map3DQuickSettingsDialog] modal. */ + fun showDialog() { + val map = attachedMap ?: mapProvider?.invoke() + activeDialog?.dismiss() + activeDialog = Map3DQuickSettingsDialog.show( + context = context, + controller = controller, + googleMap3D = map, + onDismiss = { activeDialog = null }, + ) + } +} +// [END maps_3d_quick_settings_button] diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsDialog.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsDialog.kt new file mode 100644 index 00000000..96ce7f08 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsDialog.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2026 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.example.maps3d.common + +import android.content.Context +import android.view.LayoutInflater +import android.widget.RadioGroup +import androidx.appcompat.app.AlertDialog +import com.example.maps3dcommon.R +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.materialswitch.MaterialSwitch + +// [START maps_3d_quick_settings_dialog] +/** + * Modal dialog for inspecting and adjusting 3D map settings. + * + * Provides single-choice radio controls for Map Mode (Satellite, Hybrid, Roadmap) + * and Color Scheme (Follow System, Light, Dark). + * Directly applies changes to [GoogleMap3D] and app theme for instant live feedback. + */ +object Map3DQuickSettingsDialog { + + /** + * Builds and displays the modal [AlertDialog]. + * + * @param context Host Android UI [Context]. + * @param controller The [Map3DSettingsController] managing settings state. + * @param googleMap3D Optional [GoogleMap3D] instance to apply settings immediately. + * @param onDismiss Optional callback invoked when the dialog is closed. + * @return The displayed [AlertDialog] instance. + */ + fun show( + context: Context, + controller: Map3DSettingsController, + googleMap3D: GoogleMap3D? = null, + onDismiss: (() -> Unit)? = null, + ): AlertDialog { + val view = LayoutInflater.from(context).inflate( + R.layout.dialog_map3d_quick_settings, + null, + false, + ) + + val rgMapMode = view.findViewById(R.id.rg_map_mode) + val rgColorScheme = view.findViewById(R.id.rg_color_scheme) + val currentSettings = controller.getSettings() + + // Set initial Map Mode selection + val initialModeId = when (currentSettings.mapMode) { + Map3DMode.SATELLITE -> R.id.rb_mode_satellite + Map3DMode.ROADMAP -> R.id.rb_mode_roadmap + else -> R.id.rb_mode_hybrid + } + rgMapMode.check(initialModeId) + + // Set initial Color Scheme selection + val initialSchemeId = when (currentSettings.colorScheme) { + Map3DColorScheme.LIGHT -> R.id.rb_scheme_light + Map3DColorScheme.DARK -> R.id.rb_scheme_dark + else -> R.id.rb_scheme_system + } + rgColorScheme.check(initialSchemeId) + + // Listen for Map Mode changes + rgMapMode.setOnCheckedChangeListener { _, checkedId -> + val newMode = when (checkedId) { + R.id.rb_mode_satellite -> Map3DMode.SATELLITE + R.id.rb_mode_roadmap -> Map3DMode.ROADMAP + else -> Map3DMode.HYBRID + } + val updated = controller.setMapMode(newMode) + googleMap3D?.let { updated.applyTo(it) } + } + + // Listen for Color Scheme changes + rgColorScheme.setOnCheckedChangeListener { _, checkedId -> + val newScheme = when (checkedId) { + R.id.rb_scheme_light -> Map3DColorScheme.LIGHT + R.id.rb_scheme_dark -> Map3DColorScheme.DARK + else -> Map3DColorScheme.FOLLOW_SYSTEM + } + val updated = controller.setColorScheme(newScheme) + googleMap3D?.let { updated.applyTo(it) } + } + + // Camera UI Controls Checkboxes & Master Switch + val switchAll = view.findViewById(R.id.switch_toggle_all_controls) + val cbZoom = view.findViewById(R.id.cb_control_zoom) + val cbTilt = view.findViewById(R.id.cb_control_tilt) + val cbRotate = view.findViewById(R.id.cb_control_rotate) + val cbCompass = view.findViewById(R.id.cb_control_compass) + val cbPanV = view.findViewById(R.id.cb_control_pan_v) + val cbPanH = view.findViewById(R.id.cb_control_pan_h) + + var isUpdatingUi = false + fun syncCheckboxes(ui: Map3DUiControls) { + isUpdatingUi = true + switchAll.isChecked = ui.allEnabled + cbZoom.isChecked = ui.zoom + cbTilt.isChecked = ui.tilt + cbRotate.isChecked = ui.rotate + cbCompass.isChecked = ui.compass + cbPanV.isChecked = ui.panVertical + cbPanH.isChecked = ui.panHorizontal + isUpdatingUi = false + } + syncCheckboxes(currentSettings.uiControls) + + // Master switch toggle + switchAll.setOnCheckedChangeListener { _, isChecked -> + if (isUpdatingUi) return@setOnCheckedChangeListener + val updated = controller.setAllUiControls(isChecked) + syncCheckboxes(updated.uiControls) + googleMap3D?.let { updated.applyTo(it) } + } + + // Helper for individual checkboxes + fun onIndividualControlToggled() { + if (isUpdatingUi) return + val updated = controller.updateUiControl( + zoom = cbZoom.isChecked, + tilt = cbTilt.isChecked, + rotate = cbRotate.isChecked, + compass = cbCompass.isChecked, + panVertical = cbPanV.isChecked, + panHorizontal = cbPanH.isChecked, + ) + isUpdatingUi = true + switchAll.isChecked = updated.uiControls.allEnabled + isUpdatingUi = false + googleMap3D?.let { updated.applyTo(it) } + } + + cbZoom.setOnCheckedChangeListener { _, _ -> onIndividualControlToggled() } + cbTilt.setOnCheckedChangeListener { _, _ -> onIndividualControlToggled() } + cbRotate.setOnCheckedChangeListener { _, _ -> onIndividualControlToggled() } + cbCompass.setOnCheckedChangeListener { _, _ -> onIndividualControlToggled() } + cbPanV.setOnCheckedChangeListener { _, _ -> onIndividualControlToggled() } + cbPanH.setOnCheckedChangeListener { _, _ -> onIndividualControlToggled() } + + val dialog = MaterialAlertDialogBuilder(context) + .setTitle(R.string.map3d_settings_title) + .setView(view) + .setPositiveButton(R.string.map3d_settings_done) { d, _ -> + d.dismiss() + } + .setOnDismissListener { + onDismiss?.invoke() + } + .create() + + dialog.show() + return dialog + } +} +// [END maps_3d_quick_settings_dialog] diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsWidget.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsWidget.kt new file mode 100644 index 00000000..9b573ffe --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsWidget.kt @@ -0,0 +1,259 @@ +/* + * Copyright 2026 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.example.maps3d.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.example.maps3dcommon.R +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Map3DMode + +// [START maps_3d_quick_settings_compose_widget] +/** + * A self-contained Jetpack Compose quick settings widget for 3D map layouts. + * + * Renders a compact [FloatingActionButton] that opens a modal [AlertDialog] to select + * Map Mode (Satellite, Hybrid, Roadmap) and Color Scheme (Follow System, Light, Dark). + * + * @param modifier Layout modifier applied to the button (e.g. alignment inside a Box). + * @param googleMap3D Optional live [GoogleMap3D] instance to apply settings directly. + * @param initialSettings The initial [Map3DSettings] configuration. + * @param onSettingsChanged Callback invoked when map settings are modified. + */ +@Composable +fun Map3DQuickSettingsWidget( + modifier: Modifier = Modifier, + googleMap3D: GoogleMap3D? = null, + initialSettings: Map3DSettings = Map3DSettings(), + onSettingsChanged: (Map3DSettings) -> Unit = {}, +) { + var settings by remember { mutableStateOf(initialSettings) } + var showDialog by remember { mutableStateOf(false) } + + fun updateSettings(newSettings: Map3DSettings) { + settings = newSettings + googleMap3D?.let { newSettings.applyTo(it) } + onSettingsChanged(newSettings) + } + + FloatingActionButton( + onClick = { showDialog = true }, + modifier = modifier.size(40.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Icon( + painter = painterResource(id = R.drawable.settings_24px), + contentDescription = stringResource(id = R.string.map3d_settings_button_description), + modifier = Modifier.size(20.dp), + ) + } + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { + Text( + text = stringResource(id = R.string.map3d_settings_title), + style = MaterialTheme.typography.titleMedium, + ) + }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + // Map Mode Section + Text( + text = stringResource(id = R.string.map3d_mode_section), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Column(Modifier.selectableGroup()) { + val modes = listOf( + Map3DMode.SATELLITE to stringResource(id = R.string.map3d_mode_satellite), + Map3DMode.HYBRID to stringResource(id = R.string.map3d_mode_hybrid), + Map3DMode.ROADMAP to stringResource(id = R.string.map3d_mode_roadmap), + ) + modes.forEach { (mode, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .selectable( + selected = (settings.mapMode == mode), + onClick = { updateSettings(settings.copy(mapMode = mode)) }, + role = Role.RadioButton, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = (settings.mapMode == mode), + onClick = null, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // Color Scheme Section + Text( + text = stringResource(id = R.string.map3d_color_scheme_section), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Column(Modifier.selectableGroup()) { + val schemes = listOf( + Map3DColorScheme.FOLLOW_SYSTEM to stringResource(id = R.string.map3d_color_scheme_system), + Map3DColorScheme.LIGHT to stringResource(id = R.string.map3d_color_scheme_light), + Map3DColorScheme.DARK to stringResource(id = R.string.map3d_color_scheme_dark), + ) + schemes.forEach { (scheme, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .selectable( + selected = (settings.colorScheme == scheme), + onClick = { updateSettings(settings.copy(colorScheme = scheme)) }, + role = Role.RadioButton, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = (settings.colorScheme == scheme), + onClick = null, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + // UI Controls Section + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(id = R.string.map3d_controls_section), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Switch( + checked = settings.uiControls.allEnabled, + onCheckedChange = { checked -> + updateSettings(settings.copy(uiControls = settings.uiControls.withAll(checked))) + }, + ) + } + + val controlItems = listOf( + Triple(stringResource(id = R.string.map3d_control_zoom), settings.uiControls.zoom) { checked: Boolean -> + updateSettings(settings.copy(uiControls = settings.uiControls.copy(zoom = checked))) + }, + Triple(stringResource(id = R.string.map3d_control_tilt), settings.uiControls.tilt) { checked: Boolean -> + updateSettings(settings.copy(uiControls = settings.uiControls.copy(tilt = checked))) + }, + Triple(stringResource(id = R.string.map3d_control_rotate), settings.uiControls.rotate) { checked: Boolean -> + updateSettings(settings.copy(uiControls = settings.uiControls.copy(rotate = checked))) + }, + Triple(stringResource(id = R.string.map3d_control_compass), settings.uiControls.compass) { checked: Boolean -> + updateSettings(settings.copy(uiControls = settings.uiControls.copy(compass = checked))) + }, + Triple(stringResource(id = R.string.map3d_control_pan_v), settings.uiControls.panVertical) { checked: Boolean -> + updateSettings(settings.copy(uiControls = settings.uiControls.copy(panVertical = checked))) + }, + Triple(stringResource(id = R.string.map3d_control_pan_h), settings.uiControls.panHorizontal) { checked: Boolean -> + updateSettings(settings.copy(uiControls = settings.uiControls.copy(panHorizontal = checked))) + }, + ) + + controlItems.forEach { (label, isChecked, onToggle) -> + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isChecked, + onCheckedChange = onToggle, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showDialog = false }) { + Text(text = stringResource(id = R.string.map3d_settings_done)) + } + }, + ) + } +} +// [END maps_3d_quick_settings_compose_widget] diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettings.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettings.kt new file mode 100644 index 00000000..b622a109 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettings.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2026 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.example.maps3d.common + +import android.util.Log +import androidx.appcompat.app.AppCompatDelegate +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.UiControlSettings.ControlPlacement +import com.google.android.gms.maps3d.model.CompassControl +import com.google.android.gms.maps3d.model.Map3DControl +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.PanHorizontalControl +import com.google.android.gms.maps3d.model.PanVerticalControl +import com.google.android.gms.maps3d.model.RotateControl +import com.google.android.gms.maps3d.model.Spacer +import com.google.android.gms.maps3d.model.TiltControl +import com.google.android.gms.maps3d.model.ZoomControl + +// [START maps_3d_quick_settings_model] +/** + * Supported color schemes for 3D map and sample UI styling. + */ +enum class Map3DColorScheme(val value: Int) { + FOLLOW_SYSTEM(2), + LIGHT(0), + DARK(1); + + companion object { + fun fromValue(value: Int): Map3DColorScheme { + return entries.firstOrNull { it.value == value } ?: FOLLOW_SYSTEM + } + } +} + +// [START maps_3d_quick_settings_ui_controls_model] +/** + * Visibility state for built-in 3D camera UI controls. + * + * @property zoom Zoom in/out (+ / -) control on trailing edge. + * @property tilt Pitch up/down control on trailing edge. + * @property rotate Heading rotation (CW / CCW) control on trailing edge. + * @property compass North orientation indicator & reset control on trailing edge. + * @property panVertical North/South pan control on leading edge. + * @property panHorizontal East/West pan control on leading edge. + */ +data class Map3DUiControls( + val zoom: Boolean = true, + val tilt: Boolean = true, + val rotate: Boolean = true, + val compass: Boolean = true, + val panVertical: Boolean = true, + val panHorizontal: Boolean = true, +) { + val allEnabled: Boolean + get() = zoom && tilt && rotate && compass && panVertical && panHorizontal + + val noneEnabled: Boolean + get() = !zoom && !tilt && !rotate && !compass && !panVertical && !panHorizontal + + fun withAll(enabled: Boolean): Map3DUiControls = copy( + zoom = enabled, + tilt = enabled, + rotate = enabled, + compass = enabled, + panVertical = enabled, + panHorizontal = enabled, + ) +} +// [END maps_3d_quick_settings_ui_controls_model] + +/** + * Immutable configuration snapshot for 3D map-level settings. + * + * Encapsulates global rendering options that apply to the 3D map surface itself + * rather than individual scene objects (markers, polylines, or models). + * + * @property mapMode The 3D map rendering mode: [Map3DMode.HYBRID], [Map3DMode.SATELLITE], or [Map3DMode.ROADMAP]. + * @property colorScheme The map color scheme: [Map3DColorScheme.FOLLOW_SYSTEM], [Map3DColorScheme.LIGHT], or [Map3DColorScheme.DARK]. + * @property uiControls Visibility state for built-in 3D camera UI controls. + */ +data class Map3DSettings( + @Map3DMode val mapMode: Int = Map3DMode.HYBRID, + val colorScheme: Map3DColorScheme = Map3DColorScheme.FOLLOW_SYSTEM, + val uiControls: Map3DUiControls = Map3DUiControls(), +) { + /** + * Applies this configuration to a live [GoogleMap3D] instance and updates app day/night theme. + */ + fun applyTo(googleMap3D: GoogleMap3D) { + googleMap3D.setMapMode(mapMode) + + // Apply dark / light mode via AppCompatDelegate + val nightMode = when (colorScheme) { + Map3DColorScheme.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + Map3DColorScheme.DARK -> AppCompatDelegate.MODE_NIGHT_YES + Map3DColorScheme.FOLLOW_SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + if (AppCompatDelegate.getDefaultNightMode() != nightMode) { + AppCompatDelegate.setDefaultNightMode(nightMode) + } + + // Forward to SDK delegate if available at runtime + try { + val method = googleMap3D.javaClass.getMethod( + "setMapColorScheme", + Int::class.javaPrimitiveType, + ) + method.invoke(googleMap3D, colorScheme.value) + } catch (_: NoSuchMethodException) { + // Unexported in current SDK RC client stub; handled by AppCompatDelegate + } catch (e: Exception) { + Log.w("Map3DSettings", "Could not apply colorScheme directly to GoogleMap3D", e) + } + + // Apply built-in UI controls configuration + try { + googleMap3D.getUiControlSettings()?.let { settings -> + val trailingControls = mutableListOf() + if (uiControls.zoom) trailingControls.add(ZoomControl(null)) + if (uiControls.tilt) trailingControls.add(TiltControl(null)) + if (uiControls.rotate) trailingControls.add(RotateControl(null)) + if (uiControls.compass) { + if (trailingControls.isNotEmpty()) { + trailingControls.add(Spacer()) + } + trailingControls.add(CompassControl()) + } + settings.setUiControlsPlacement(trailingControls, ControlPlacement.TRAILING) + + val leadingControls = mutableListOf() + if (uiControls.panVertical) leadingControls.add(PanVerticalControl(null)) + if (uiControls.panHorizontal) leadingControls.add(PanHorizontalControl(null)) + settings.setUiControlsPlacement(leadingControls, ControlPlacement.LEADING) + } + } catch (e: Throwable) { + Log.e("Map3DSettings", "Could not apply UI controls placement to GoogleMap3D", e) + } + } +} +// [END maps_3d_quick_settings_model] diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettingsController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettingsController.kt new file mode 100644 index 00000000..1681b7c4 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettingsController.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2026 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.example.maps3d.common + +import com.google.android.gms.maps3d.model.Map3DMode + +// [START maps_3d_quick_settings_controller] +/** + * Pure Kotlin state machine controller for managing 3D map settings. + * + * Manages transitions between map rendering modes and color schemes. + * Completely decoupled from Android UI and Views for deterministic JVM unit testing. + * + * @param initialSettings Initial settings snapshot (defaults to [Map3DMode.HYBRID] and [Map3DColorScheme.FOLLOW_SYSTEM]). + */ +class Map3DSettingsController( + initialSettings: Map3DSettings = Map3DSettings(), +) { + private var state: Map3DSettings = initialSettings + private val listeners = mutableListOf<(Map3DSettings) -> Unit>() + + /** Returns the current [Map3DSettings] snapshot. */ + fun getSettings(): Map3DSettings = state + + /** + * Updates the map rendering mode ([Map3DMode.SATELLITE], [Map3DMode.HYBRID], or [Map3DMode.ROADMAP]). + * + * Invalid or unknown modes are ignored. + */ + fun setMapMode(@Map3DMode mode: Int): Map3DSettings { + val sanitized = sanitizeMapMode(mode) + if (sanitized == null || sanitized == state.mapMode) { + return state + } + state = state.copy(mapMode = sanitized) + notifyListeners() + return state + } + + /** + * Updates the color scheme ([Map3DColorScheme.FOLLOW_SYSTEM], [Map3DColorScheme.LIGHT], or [Map3DColorScheme.DARK]). + */ + fun setColorScheme(scheme: Map3DColorScheme): Map3DSettings { + if (scheme == state.colorScheme) { + return state + } + state = state.copy(colorScheme = scheme) + notifyListeners() + return state + } + + /** + * Updates the UI controls visibility configuration. + */ + fun setUiControls(uiControls: Map3DUiControls): Map3DSettings { + if (uiControls == state.uiControls) { + return state + } + state = state.copy(uiControls = uiControls) + notifyListeners() + return state + } + + /** + * Toggles all UI controls on or off simultaneously. + */ + fun setAllUiControls(enabled: Boolean): Map3DSettings { + return setUiControls(state.uiControls.withAll(enabled)) + } + + /** + * Updates a single UI control's visibility. + */ + fun updateUiControl( + zoom: Boolean = state.uiControls.zoom, + tilt: Boolean = state.uiControls.tilt, + rotate: Boolean = state.uiControls.rotate, + compass: Boolean = state.uiControls.compass, + panVertical: Boolean = state.uiControls.panVertical, + panHorizontal: Boolean = state.uiControls.panHorizontal, + ): Map3DSettings { + val updatedControls = state.uiControls.copy( + zoom = zoom, + tilt = tilt, + rotate = rotate, + compass = compass, + panVertical = panVertical, + panHorizontal = panHorizontal, + ) + return setUiControls(updatedControls) + } + + /** + * Replaces the entire settings snapshot. + */ + fun setSettings(newSettings: Map3DSettings): Map3DSettings { + if (newSettings == state) { + return state + } + val sanitizedMode = sanitizeMapMode(newSettings.mapMode) ?: state.mapMode + state = Map3DSettings( + mapMode = sanitizedMode, + colorScheme = newSettings.colorScheme, + uiControls = newSettings.uiControls, + ) + notifyListeners() + return state + } + + /** Registers a listener invoked whenever settings change. */ + fun addListener(listener: (Map3DSettings) -> Unit) { + if (!listeners.contains(listener)) { + listeners.add(listener) + } + } + + /** Unregisters a previously registered listener. */ + fun removeListener(listener: (Map3DSettings) -> Unit) { + listeners.remove(listener) + } + + private fun sanitizeMapMode(mode: Int): Int? { + return when (mode) { + Map3DMode.HYBRID, Map3DMode.SATELLITE, Map3DMode.ROADMAP -> mode + else -> null + } + } + + private fun notifyListeners() { + val currentSnapshot = state + for (listener in listeners) { + listener(currentSnapshot) + } + } +} +// [END maps_3d_quick_settings_controller] diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/settings_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/settings_24px.xml new file mode 100644 index 00000000..51fa2bab --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/settings_24px.xml @@ -0,0 +1,25 @@ + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_common_map.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_common_map.xml index 175d9f17..a57d52d7 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_common_map.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_common_map.xml @@ -184,4 +184,16 @@ + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/dialog_map3d_quick_settings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/dialog_map3d_quick_settings.xml new file mode 100644 index 00000000..9a1b66c7 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/dialog_map3d_quick_settings.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml index a19e866c..c3b8e835 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml @@ -264,4 +264,25 @@ Landmark Targets: (%1$d px, %2$d px) Screen: (Out of view) + + + 3D Map Settings + Map Mode + Satellite (3D Mesh) + Hybrid (3D Mesh + Roads & Labels) + Roadmap (Vector Roads) + Color Scheme + Follow System + Light Mode + Dark Mode + Camera Controls + All Controls + Zoom (+ / -) + Tilt (Up / Down) + Rotate (Heading) + Compass (Reset North) + Pan Vertical (Up / Down) + Pan Horizontal (Left / Right) + Done + Open 3D map quick settings diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/Map3DSettingsControllerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/Map3DSettingsControllerTest.kt new file mode 100644 index 00000000..ade1ed0d --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/Map3DSettingsControllerTest.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2026 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.example.maps3d.common + +import com.google.android.gms.maps3d.model.Map3DMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * JVM Unit Tests for [Map3DSettingsController] and [Map3DSettings]. + */ +class Map3DSettingsControllerTest { + + private lateinit var controller: Map3DSettingsController + + @Before + fun setup() { + controller = Map3DSettingsController() + } + + @Test + fun initialState_isConfiguredWithDefaults() { + val state = controller.getSettings() + assertEquals(Map3DMode.HYBRID, state.mapMode) + assertEquals(Map3DColorScheme.FOLLOW_SYSTEM, state.colorScheme) + } + + @Test + fun customInitialState_isPreserved() { + val custom = Map3DSettings( + mapMode = Map3DMode.SATELLITE, + colorScheme = Map3DColorScheme.DARK, + ) + val customController = Map3DSettingsController(initialSettings = custom) + assertEquals(Map3DMode.SATELLITE, customController.getSettings().mapMode) + assertEquals(Map3DColorScheme.DARK, customController.getSettings().colorScheme) + } + + @Test + fun setMapMode_satellite_updatesStateAndNotifiesListener() { + var notified = false + var capturedSettings: Map3DSettings? = null + controller.addListener { settings -> + notified = true + capturedSettings = settings + } + + val updated = controller.setMapMode(Map3DMode.SATELLITE) + + assertTrue(notified) + assertEquals(Map3DMode.SATELLITE, updated.mapMode) + assertEquals(Map3DMode.SATELLITE, capturedSettings?.mapMode) + } + + @Test + fun setMapMode_roadmap_updatesState() { + val updated = controller.setMapMode(Map3DMode.ROADMAP) + assertEquals(Map3DMode.ROADMAP, updated.mapMode) + } + + @Test + fun setMapMode_unknownOrInvalid_doesNotChangeState() { + var callCount = 0 + controller.addListener { callCount++ } + + val result = controller.setMapMode(999) + + assertEquals(Map3DMode.HYBRID, result.mapMode) + assertEquals(0, callCount) + } + + @Test + fun setMapMode_sameValue_doesNotNotifyListener() { + var callCount = 0 + controller.addListener { callCount++ } + + controller.setMapMode(Map3DMode.HYBRID) // Already HYBRID + + assertEquals(0, callCount) + } + + @Test + fun setColorScheme_dark_updatesStateAndNotifiesListener() { + var notified = false + controller.addListener { notified = true } + + val updated = controller.setColorScheme(Map3DColorScheme.DARK) + + assertTrue(notified) + assertEquals(Map3DColorScheme.DARK, updated.colorScheme) + } + + @Test + fun setColorScheme_light_updatesState() { + val updated = controller.setColorScheme(Map3DColorScheme.LIGHT) + assertEquals(Map3DColorScheme.LIGHT, updated.colorScheme) + } + + @Test + fun setColorScheme_sameValue_doesNotNotify() { + var callCount = 0 + controller.addListener { callCount++ } + + controller.setColorScheme(Map3DColorScheme.FOLLOW_SYSTEM) // Already FOLLOW_SYSTEM + + assertEquals(0, callCount) + } + + @Test + fun setSettings_updatesStateAndNotifiesListener() { + var callCount = 0 + controller.addListener { callCount++ } + + val newSettings = Map3DSettings( + mapMode = Map3DMode.SATELLITE, + colorScheme = Map3DColorScheme.DARK, + ) + val updated = controller.setSettings(newSettings) + + assertEquals(1, callCount) + assertEquals(Map3DMode.SATELLITE, updated.mapMode) + assertEquals(Map3DColorScheme.DARK, updated.colorScheme) + } + + @Test + fun removeListener_stopsReceivingUpdates() { + var callCount = 0 + val listener: (Map3DSettings) -> Unit = { callCount++ } + + controller.addListener(listener) + controller.setMapMode(Map3DMode.SATELLITE) + assertEquals(1, callCount) + + controller.removeListener(listener) + controller.setMapMode(Map3DMode.ROADMAP) + assertEquals(1, callCount) + } + + @Test + fun initialState_uiControlsDefaultToEnabled() { + val state = controller.getSettings() + assertTrue(state.uiControls.allEnabled) + assertTrue(state.uiControls.zoom) + assertTrue(state.uiControls.tilt) + assertTrue(state.uiControls.rotate) + assertTrue(state.uiControls.compass) + assertTrue(state.uiControls.panVertical) + assertTrue(state.uiControls.panHorizontal) + } + + @Test + fun setAllUiControls_false_disablesAllAndNotifies() { + var notified = false + controller.addListener { notified = true } + + val updated = controller.setAllUiControls(false) + + assertTrue(notified) + assertTrue(updated.uiControls.noneEnabled) + assertFalse(updated.uiControls.zoom) + } + + @Test + fun setAllUiControls_true_enablesAll() { + controller.setAllUiControls(false) + val updated = controller.setAllUiControls(true) + assertTrue(updated.uiControls.allEnabled) + } + + @Test + fun updateUiControl_togglesSingleControl() { + var captured: Map3DSettings? = null + controller.addListener { captured = it } + + val updated = controller.updateUiControl(zoom = false) + + assertFalse(updated.uiControls.zoom) + assertTrue(updated.uiControls.tilt) + assertFalse(captured?.uiControls?.zoom ?: true) + } + + @Test + fun setUiControls_sameState_doesNotNotify() { + var callCount = 0 + controller.addListener { callCount++ } + + controller.setUiControls(Map3DUiControls()) // Already default + + assertEquals(0, callCount) + } +} diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/sampleactivity/SampleBaseActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/sampleactivity/SampleBaseActivity.java index bbc65a7a..232db168 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/sampleactivity/SampleBaseActivity.java +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/sampleactivity/SampleBaseActivity.java @@ -23,7 +23,11 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import android.view.Gravity; import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; import android.widget.Toast; import androidx.annotation.CallSuper; @@ -33,6 +37,8 @@ import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; +import com.example.maps3d.common.Map3DQuickSettingsButton; +import com.example.maps3d.common.Map3DUiControls; import com.example.maps3dcommon.R; import com.google.android.gms.maps3d.GoogleMap3D; import com.google.android.gms.maps3d.Map3DView; @@ -78,6 +84,51 @@ public abstract class SampleBaseActivity extends AppCompatActivity implements On private OnCameraChangedListener cameraChangedListener; + private int lastStatusBarTop = 0; + + private int calculateFabMarginDp(Map3DUiControls ui) { + int count = 0; + if (ui.getZoom()) count++; + if (ui.getTilt()) count++; + if (ui.getRotate()) count++; + return count > 0 ? 16 + count * 104 + 8 : 16; + } + + private void applyStatusBarInsetsToMapControls(int statusBarTop, Map3DUiControls uiControls) { + if (statusBarTop <= 0) return; + lastStatusBarTop = statusBarTop; + float density = getResources().getDisplayMetrics().density; + int baseMargin = (int) (16 * density); + int targetTopMargin = statusBarTop + baseMargin; + + if (map3DView != null) { + for (int i = 0; i < map3DView.getChildCount(); i++) { + View child = map3DView.getChildAt(i); + if (child instanceof LinearLayout) { + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) child.getLayoutParams(); + if (lp != null && (lp.gravity & Gravity.TOP) != 0 && lp.topMargin != targetTopMargin) { + lp.topMargin = targetTopMargin; + child.setLayoutParams(lp); + } + } + } + } + + Map3DQuickSettingsButton quickSettingsButton = findViewById(R.id.quick_settings_button); + if (quickSettingsButton != null) { + ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) quickSettingsButton.getLayoutParams(); + if (lp != null) { + Map3DUiControls currentUi = uiControls != null ? uiControls : quickSettingsButton.getSettings().getUiControls(); + int baseMarginDp = calculateFabMarginDp(currentUi); + int targetButtonMargin = statusBarTop + (int) (baseMarginDp * density); + if (lp.topMargin != targetButtonMargin) { + lp.topMargin = targetButtonMargin; + quickSettingsButton.setLayoutParams(lp); + } + } + } + } + @CallSuper @Override protected void onCreate(Bundle savedInstanceState) { @@ -100,6 +151,8 @@ protected void onCreate(Bundle savedInstanceState) { if (appBarLayout != null) { appBarLayout.setPadding(0, statusBarInsets.top, 0, 0); } + applyStatusBarInsetsToMapControls(statusBarInsets.top, null); + View controlScrollView = findViewById(R.id.control_scroll_view); if (controlScrollView != null) { android.view.ViewGroup.MarginLayoutParams layoutParams = (android.view.ViewGroup.MarginLayoutParams) controlScrollView @@ -113,6 +166,22 @@ protected void onCreate(Bundle savedInstanceState) { }); map3DView = findViewById(R.id.map3dView); + map3DView.setOnHierarchyChangeListener(new ViewGroup.OnHierarchyChangeListener() { + @Override + public void onChildViewAdded(View parent, View child) { + if (child instanceof LinearLayout && lastStatusBarTop > 0) { + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) child.getLayoutParams(); + if (lp != null && (lp.gravity & Gravity.TOP) != 0) { + int baseMargin = (int) (16 * getResources().getDisplayMetrics().density); + lp.topMargin = lastStatusBarTop + baseMargin; + child.setLayoutParams(lp); + } + } + } + + @Override + public void onChildViewRemoved(View parent, View child) {} + }); map3DView.onCreate(savedInstanceState); map3DView.getMap3DViewAsync(this); @@ -184,6 +253,15 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { public void onMap3DViewReady(GoogleMap3D googleMap3D) { this.googleMap3D = googleMap3D; + Map3DQuickSettingsButton quickSettings = findViewById(R.id.quick_settings_button); + if (quickSettings != null) { + quickSettings.attachMap(googleMap3D); + quickSettings.setOnSettingsChangedListener(updatedSettings -> { + applyStatusBarInsetsToMapControls(lastStatusBarTop, updatedSettings.getUiControls()); + }); + } + applyStatusBarInsetsToMapControls(lastStatusBarTop, null); + // Workaround: The Maps 3D SDK onMap3DViewReady callback fires when the map object // is instantiated, but the internal native rendering pipeline and layout pass may briefly // override initial programmatic camera positions. A short delay ensures the native map viewport diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/sampleactivity/SampleBaseActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/sampleactivity/SampleBaseActivity.kt index b8c5ed81..f9206c42 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/sampleactivity/SampleBaseActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/sampleactivity/SampleBaseActivity.kt @@ -18,8 +18,12 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.Log +import android.view.Gravity import android.view.KeyEvent import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout import androidx.activity.enableEdgeToEdge import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity @@ -30,6 +34,16 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.example.maps3d.common.DEFAULT_CAMERA +import com.example.maps3d.common.Map3DQuickSettingsButton +import com.example.maps3d.common.Map3DUiControls +import com.google.android.gms.maps3d.UiControlSettings.ControlPlacement +import com.google.android.gms.maps3d.model.CompassControl +import com.google.android.gms.maps3d.model.PanHorizontalControl +import com.google.android.gms.maps3d.model.PanVerticalControl +import com.google.android.gms.maps3d.model.RotateControl +import com.google.android.gms.maps3d.model.Spacer +import com.google.android.gms.maps3d.model.TiltControl +import com.google.android.gms.maps3d.model.ZoomControl import com.example.maps3d.common.toCameraString import com.example.maps3d.common.toValidCamera import com.example.maps3d.common.toHeading @@ -129,6 +143,51 @@ abstract class SampleBaseActivity : AppCompatActivity(), OnMap3DViewReadyCallbac } } + private var lastStatusBarTop: Int = 0 + + private fun calculateFabMarginDp(ui: Map3DUiControls): Int { + var count = 0 + if (ui.zoom) count++ + if (ui.tilt) count++ + if (ui.rotate) count++ + return if (count > 0) 16 + count * 104 + 8 else 16 + } + + private fun applyStatusBarInsetsToMapControls( + statusBarTop: Int = lastStatusBarTop, + uiControls: Map3DUiControls? = null, + ) { + if (statusBarTop <= 0) return + lastStatusBarTop = statusBarTop + val density = resources.displayMetrics.density + val baseMargin = (16 * density).toInt() + val targetTopMargin = statusBarTop + baseMargin + + // Offset any top-anchored edge layout within Map3DView below the status bar + for (i in 0 until map3DView.childCount) { + val child = map3DView.getChildAt(i) + if (child is LinearLayout) { + val lp = child.layoutParams as? FrameLayout.LayoutParams ?: continue + if ((lp.gravity and Gravity.TOP) != 0 && lp.topMargin != targetTopMargin) { + lp.topMargin = targetTopMargin + child.layoutParams = lp + } + } + } + + // Keep the Quick Settings FAB directly below the active trailing controls + findViewById(R.id.quick_settings_button)?.let { button -> + val lp = button.layoutParams as? android.view.ViewGroup.MarginLayoutParams ?: return@let + val currentUi = uiControls ?: button.getSettings().uiControls + val baseMarginDp = calculateFabMarginDp(currentUi) + val targetButtonMargin = statusBarTop + (baseMarginDp * density).toInt() + if (lp.topMargin != targetButtonMargin) { + lp.topMargin = targetButtonMargin + button.layoutParams = lp + } + } + } + @CallSuper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -145,6 +204,7 @@ abstract class SampleBaseActivity : AppCompatActivity(), OnMap3DViewReadyCallbac val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) findViewById(R.id.app_bar_layout)?.updatePadding(top = statusBarInsets.top) + applyStatusBarInsetsToMapControls(statusBarInsets.top) findViewById(R.id.control_scroll_view)?.let { scrollView -> val layoutParams = scrollView.layoutParams as android.view.ViewGroup.MarginLayoutParams @@ -157,6 +217,20 @@ abstract class SampleBaseActivity : AppCompatActivity(), OnMap3DViewReadyCallbac } map3DView = findViewById(R.id.map3dView) + map3DView.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener { + override fun onChildViewAdded(parent: View?, child: View?) { + if (child is LinearLayout && lastStatusBarTop > 0) { + val lp = child.layoutParams as? FrameLayout.LayoutParams ?: return + if ((lp.gravity and Gravity.TOP) != 0) { + val baseMargin = (16 * resources.displayMetrics.density).toInt() + lp.topMargin = lastStatusBarTop + baseMargin + child.layoutParams = lp + } + } + } + + override fun onChildViewRemoved(parent: View?, child: View?) {} + }) map3DView.onCreate(savedInstanceState) map3DView.getMap3DViewAsync(this) @@ -228,6 +302,42 @@ abstract class SampleBaseActivity : AppCompatActivity(), OnMap3DViewReadyCallbac if (isMapInitialized) return isMapInitialized = true Log.d(TAG, "onMapReady called (guaranteed once)") + findViewById(R.id.quick_settings_button)?.apply { + attachMap(googleMap3D) + setOnSettingsChangedListener { updatedSettings -> + applyStatusBarInsetsToMapControls(lastStatusBarTop, updatedSettings.uiControls) + } + } + + // Configure Maps 3D SDK built-in UI controls (fallback if quick settings button is not present) + try { + if (findViewById(R.id.quick_settings_button) == null) { + googleMap3D.getUiControlSettings()?.let { uiControls -> + Log.d(TAG, "Configuring Maps 3D uiControlSettings default fallback...") + val zoom = ZoomControl(null) + val tilt = TiltControl(null) + val rotate = RotateControl(null) + val spacer = Spacer() + val compass = CompassControl() + val panV = PanVerticalControl(null) + val panH = PanHorizontalControl(null) + + uiControls.setUiControlsPlacement( + listOf(zoom, tilt, rotate, spacer, compass), + ControlPlacement.TRAILING + ) + uiControls.setUiControlsPlacement( + listOf(panV, panH), + ControlPlacement.LEADING + ) + } + } + applyStatusBarInsetsToMapControls() + Log.d(TAG, "Maps 3D uiControlSettings configured successfully") + } catch (e: Throwable) { + Log.e(TAG, "Failed to configure uiControlSettings", e) + } + // Workaround: The Maps 3D SDK onMapReady callback fires when the map object // is instantiated, but the internal native rendering pipeline and layout pass may briefly // override initial programmatic camera positions. A short delay ensures the native map viewport diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt index 45caf24a..737f484d 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import com.example.maps3d.common.Map3DQuickSettingsWidget +import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.model.camera import com.google.android.gms.maps3d.model.latLngAltitude import com.google.maps.android.compose3d.GoogleMap3D @@ -72,6 +74,7 @@ class HelloMapActivity : ComponentActivity() { @Composable fun HelloMapScreen() { var isMapSteady by remember { mutableStateOf(false) } + var googleMap3D by remember { mutableStateOf(null) } val delicateArchCamera = remember { camera { @@ -97,6 +100,9 @@ fun HelloMapScreen() { GoogleMap3D( camera = delicateArchCamera, modifier = Modifier.fillMaxSize(), + onMapReady = { map -> + googleMap3D = map + }, onMapSteady = { isMapSteady = true }, @@ -118,5 +124,14 @@ fun HelloMapScreen() { color = MaterialTheme.colorScheme.onSurface, ) } + + // 3. Quick Settings Overlay + Map3DQuickSettingsWidget( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = 8.dp, end = 8.dp), + googleMap3D = googleMap3D, + ) } } From 6ff2904642809636924fa3a25f10f940ed068697 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:27:29 -0600 Subject: [PATCH 2/2] fix(compose): prevent Compose stability crash in Map3DQuickSettingsWidget and fix GoogleMap3D syntax --- .../com/example/maps3d/common/Map3DQuickSettingsWidget.kt | 7 ++----- .../com/example/composedemos/hellomap/HelloMapActivity.kt | 8 +++++--- .../java/com/google/maps/android/compose3d/GoogleMap3D.kt | 7 ++++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsWidget.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsWidget.kt index 9b573ffe..42352f67 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsWidget.kt +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DQuickSettingsWidget.kt @@ -49,7 +49,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.example.maps3dcommon.R -import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.model.Map3DMode // [START maps_3d_quick_settings_compose_widget] @@ -57,17 +56,16 @@ import com.google.android.gms.maps3d.model.Map3DMode * A self-contained Jetpack Compose quick settings widget for 3D map layouts. * * Renders a compact [FloatingActionButton] that opens a modal [AlertDialog] to select - * Map Mode (Satellite, Hybrid, Roadmap) and Color Scheme (Follow System, Light, Dark). + * Map Mode (Satellite, Hybrid, Roadmap), Color Scheme (Follow System, Light, Dark), and + * toggle camera UI controls. * * @param modifier Layout modifier applied to the button (e.g. alignment inside a Box). - * @param googleMap3D Optional live [GoogleMap3D] instance to apply settings directly. * @param initialSettings The initial [Map3DSettings] configuration. * @param onSettingsChanged Callback invoked when map settings are modified. */ @Composable fun Map3DQuickSettingsWidget( modifier: Modifier = Modifier, - googleMap3D: GoogleMap3D? = null, initialSettings: Map3DSettings = Map3DSettings(), onSettingsChanged: (Map3DSettings) -> Unit = {}, ) { @@ -76,7 +74,6 @@ fun Map3DQuickSettingsWidget( fun updateSettings(newSettings: Map3DSettings) { settings = newSettings - googleMap3D?.let { newSettings.applyTo(it) } onSettingsChanged(newSettings) } diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt index 737f484d..c5db7938 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/hellomap/HelloMapActivity.kt @@ -74,7 +74,7 @@ class HelloMapActivity : ComponentActivity() { @Composable fun HelloMapScreen() { var isMapSteady by remember { mutableStateOf(false) } - var googleMap3D by remember { mutableStateOf(null) } + val currentMap = remember { arrayOfNulls(1) } val delicateArchCamera = remember { camera { @@ -101,7 +101,7 @@ fun HelloMapScreen() { camera = delicateArchCamera, modifier = Modifier.fillMaxSize(), onMapReady = { map -> - googleMap3D = map + currentMap[0] = map }, onMapSteady = { isMapSteady = true @@ -131,7 +131,9 @@ fun HelloMapScreen() { .align(Alignment.TopEnd) .statusBarsPadding() .padding(top = 8.dp, end = 8.dp), - googleMap3D = googleMap3D, + onSettingsChanged = { settings -> + currentMap[0]?.let { settings.applyTo(it) } + }, ) } } diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt index 30d63a6b..e0a3b941 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt @@ -203,10 +203,11 @@ fun GoogleMap3D( } return@setMap3DClickListener false } + } - override fun onError(error: Exception): Unit = throw error - }, - ) + override fun onError(error: Exception): Unit = throw error + }, + ) map3DViewState.value = map3dView map3dView