From 25625301921ed2859c99ecfa9ca3adf47ada10de Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Thu, 20 Aug 2026 17:12:26 -0600
Subject: [PATCH 1/9] Enhance Advanced Camera Animation and Path Following
samples
- Advanced Camera Animation:
- Add modular step animation pipeline (FlyToStep, OrbitStep, DwellStep, FlyAroundStep, KeyframeStep).
- Add high-altitude San Francisco starting camera view.
- Implement full reset and continuous orbit support.
- Sync Java, Kotlin, and Jetpack Compose implementations.
- Path Following:
- Implement two-polyline progress tracking with wide blue base route (lower z-index) and narrow purple progress route (higher z-index).
- Use in-place fixed polyline IDs to eliminate rendering flickering.
- Default altitude mode to 'Clamp to Ground' with support for Relative to Ground, Relative to Mesh, and Absolute.
- Add dynamic path height slider to avoid z-fighting with terrain.
- Add collapsible control panel with explicit collapse/expand button, auto-slide dismissal, and subtle idle opacity.
- Extract all hardcoded strings into strings.xml resources.
- Align Java and Kotlin implementations.
---
.gitignore | 2 +
.../main/res/drawable/expand_less_24px.xml | 9 +
.../main/res/drawable/expand_more_24px.xml | 9 +
.../res/layout/activity_path_following.xml | 136 +++-
.../control_panel_advanced_animation.xml | 50 +-
.../common/src/main/res/values/strings.xml | 65 ++
.../AdvancedCameraAnimationActivity.java | 649 +++++-------------
.../AnimationStep.java | 62 ++
.../advancedcameraanimation/DwellStep.java | 75 ++
.../FlyAroundStep.java | 72 ++
.../advancedcameraanimation/FlyToStep.java | 81 +++
.../advancedcameraanimation/KeyframeStep.java | 51 ++
.../Map3DAnimator.java | 267 +++++++
.../advancedcameraanimation/OrbitOptions.java | 138 ++++
.../advancedcameraanimation/OrbitStep.java | 134 ++++
.../advancedcameraanimation/StepCallback.java | 29 +
.../pathfollowing/PathFollowingActivity.java | 272 +++++++-
.../AdvancedCameraAnimationActivity.kt | 32 +-
.../pathfollowing/PathFollowingActivity.kt | 333 ++++++---
.../AdvancedCameraAnimationActivity.kt | 29 +-
gradle/gradle-daemon-jvm.properties | 12 +
21 files changed, 1876 insertions(+), 631 deletions(-)
create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml
create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java
create mode 100644 gradle/gradle-daemon-jvm.properties
diff --git a/.gitignore b/.gitignore
index c32cfa86..f225e479 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,5 @@ google-services.json
.vscode/
snippets/docs/
.kotlin/
+.android/
+scratch/
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml
new file mode 100644
index 00000000..df9af507
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml
new file mode 100644
index 00000000..391e875f
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml
index 2d9a0c2d..3d5da955 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml
@@ -41,25 +41,67 @@
app:cardElevation="8dp"
>
-
+
+
+
+
+
+
+
+
+
+
@@ -77,7 +119,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:checked="true"
- android:text="Urban"
+ android:text="@string/urban"
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml
index d0a40acc..c66d9171 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml
@@ -36,57 +36,29 @@
>
-
-
-
-
-
-
-
-
-
-
+ android:text="@string/aerial_tour_status_idle"
+ android:textColor="?android:attr/textColorSecondary"
+ />
@@ -103,7 +75,7 @@
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Play"
+ android:text="@string/play"
/>
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
index 41293504..627f71bf 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
@@ -126,8 +126,73 @@
Vehicle Speed: %1$dm/sCamera Yaw Offset: %1$d°Offline: Using local Oahu fallback route
+ Play
+ Pause
+ Reset
+
+
Map ModeRoadmapHybridSatellite
+
+
+ San Francisco Aerial Tour
+ Press Play to begin the multi-step aerial tour.
+ Step %1$d of %2$d: %3$s
+ Tour complete. Press Reset to replay.
+ 1. Swoop to Golden Gate
+ Descend from high-altitude SF panorama to Golden Gate Bridge
+ 2. Mid-Air Observation
+ Dwell pause observing 3D airplane over Golden Gate
+ 3. Golden Gate 360° Orbit
+ 360° orbital camera spin around airplane
+ 4. Transit to Coit Tower
+ Airplane flight across San Francisco to Coit Tower
+ Continuous 360° Orbit
+ Continuous orbital camera spin around Golden Gate Bridge
+ Dwell Pause
+ Observing current location
+
+
+ Path Controls
+ Collapse Controls
+ Expand Controls
+ Path Environment:
+ Urban
+ Rural
+ Altitude Mode:
+ Relative to Ground
+ Clamp to Ground
+ Relative to Mesh
+ Absolute
+ Path Height: %1$.1fm
+ Camera Range: %1$dm
+ Ground Altitude: %1$dm
+ Heading Offset: %1$d°
+ Camera Tilt: %1$d°
+ Follow Speed: %1$d m/s
+ Path Height: 0.5m
+ Camera Range: 300m
+ Ground Altitude: 20m
+ Heading Offset: 0°
+ Camera Tilt: 70°
+ Follow Speed: 30 m/s
+ Play or pause animation
+
+
+ Flood Elevation: +%1$.1f m (%2$.1f ft)
+ 🚨 Flood Hazard (+%1$.0fm)
+ ✅ Normal Tide Level
+ Continuous Tide Simulation:
+ ▶ Start Simulation
+ ⏹ Stop Simulation
+
+
+ Field of View: %1$d°
+ FOV Presets:
+ 20° Tele
+ 45° Standard
+ 90° Wide
+ 120° Ultra
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
index 2c174042..68b3cd65 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
@@ -19,13 +19,12 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.util.Log;
import android.view.ViewGroup;
import android.widget.Button;
-import android.widget.RadioGroup;
+import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import com.example.maps3d.common.PositionAndHeading;
-import com.example.maps3d.common.RouteEngine;
import com.example.maps3dcommon.R;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
import com.google.android.gms.maps.model.LatLng;
@@ -40,134 +39,98 @@
import com.google.android.gms.maps3d.model.Vector3D;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.maps.android.SphericalUtil;
-import java.util.Arrays;
-import java.util.List;
+/**
+ * Demonstrates advanced camera animations on Google Maps 3D using {@link Map3DAnimator}.
+ *
+ *
Showcases a declarative multi-step aerial tour with native flight transitions ({@link
+ * FlyToStep}), stationary observation pauses ({@link DwellStep}), and 360-degree orbital spins
+ * ({@link OrbitStep}) around synchronized 3D assets.
+ */
public class AdvancedCameraAnimationActivity extends SampleBaseActivity {
- public enum AnimationApproach {
- SIMPLE_FLY_TO,
- KEYFRAME_TOUR,
- DISPATCHER_FRAME_LOOP,
- ORBIT_360_SPIN
- }
-
- public abstract static class Keyframe {
-
- public final String title;
- public final String description;
-
- public Keyframe(String title, String description) {
- this.title = title;
- this.description = description;
- }
- }
-
- public static class KeyframeFlyTo extends Keyframe {
-
- public final LatLng targetCenter;
- public final double targetAltitude;
- public final double targetHeading;
- public final double targetTilt;
- public final double targetRange;
- public final long durationMs;
-
- public KeyframeFlyTo(String title, String description, LatLng targetCenter,
- double targetAltitude, double targetHeading, double targetTilt, double targetRange,
- long durationMs) {
- super(title, description);
- this.targetCenter = targetCenter;
- this.targetAltitude = targetAltitude;
- this.targetHeading = targetHeading;
- this.targetTilt = targetTilt;
- this.targetRange = targetRange;
- this.durationMs = durationMs;
- }
- }
-
- public static class KeyframeDwell extends Keyframe {
-
- public final long durationMs;
-
- public KeyframeDwell(String title, String description, long durationMs) {
- super(title, description);
- this.durationMs = durationMs;
- }
- }
-
- public static class KeyframeOrbit extends Keyframe {
-
- public final LatLng center;
- public final double altitude;
- public final double range;
- public final double tilt;
- public final double startHeading;
- public final double endHeading;
- public final long durationMs;
-
- public KeyframeOrbit(String title, String description, LatLng center, double altitude,
- double range, double tilt, double startHeading, double endHeading, long durationMs) {
- super(title, description);
- this.center = center;
- this.altitude = altitude;
- this.range = range;
- this.tilt = tilt;
- this.startHeading = startHeading;
- this.endHeading = endHeading;
- this.durationMs = durationMs;
- }
- }
-
private static final String MODEL_ID = "airplane_model";
- private static final String PLANE_URL = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb";
+ private static final String PLANE_URL =
+ "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb";
+ // Key landmarks for the aerial tour
+ private static final LatLng SF_PANORAMA_CENTER = new LatLng(37.7650, -122.4400);
+ private static final LatLng GOLDEN_GATE_BRIDGE = new LatLng(37.8199, -122.4783);
+ private static final LatLng COIT_TOWER = new LatLng(37.8024, -122.4058);
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Map3DAnimator tourAnimator;
private Model airplaneModel;
- private int currentStepIndex = 0;
private boolean isPlaying = false;
- private AnimationApproach selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP;
+ private TextView tvTourStatus;
private Button btnPlayPause;
- private final Handler handler = new Handler(Looper.getMainLooper());
- private Runnable animationRunnable = null;
-
- // 15 Fine-Grained Waypoints on the direct route from Golden Gate Bridge to Coit Tower
- public static final List AIRPLANE_FLIGHT_PATH = Arrays.asList(
- new LatLng(37.8199, -122.4783), // 1. Golden Gate Bridge (Source)
- new LatLng(37.8188, -122.4735), // 2. Fort Point / Presidio Overlook
- new LatLng(37.8175, -122.4685), // 3. Crissy Field West
- new LatLng(37.8160, -122.4635), // 4. Crissy Field East
- new LatLng(37.8145, -122.4585), // 5. Marina Green West
- new LatLng(37.8130, -122.4530), // 6. Marina District Center
- new LatLng(37.8115, -122.4475), // 7. Fort Mason West
- new LatLng(37.8100, -122.4420), // 8. Fort Mason Heights
- new LatLng(37.8085, -122.4365), // 9. Aquatic Park Cove
- new LatLng(37.8070, -122.4310), // 10. Fisherman's Wharf West
- new LatLng(37.8058, -122.4250), // 11. Fisherman's Wharf Center
- new LatLng(37.8048, -122.4195), // 12. Pier 39 Promenade
- new LatLng(37.8038, -122.4140), // 13. Embarcadero North
- new LatLng(37.8030, -122.4090), // 14. Telegraph Hill Slopes
- new LatLng(37.8024, -122.4058) // 15. Coit Tower (Destination)
- );
-
- public static final List SAN_FRANCISCO_TOUR = Arrays.asList(
- new KeyframeFlyTo("1. Golden Gate Flight", "3D Airplane flight over Golden Gate Bridge",
- new LatLng(37.8199, -122.4783), 200.0, 105.0, 65.0, 600.0, 2500L),
- new KeyframeDwell("2. Mid-Air Observation", "Dwell pause observing 3D airplane", 1500L),
- new KeyframeOrbit("3. Golden Gate 360° Orbit",
- "360° orbital camera spin around flying airplane", new LatLng(37.8199, -122.4783), 200.0,
- 600.0, 65.0, 105.0, 465.0, 4000L),
- new KeyframeFlyTo("4. Transit to Coit Tower", "Airplane flight to Coit Tower Landmark",
- new LatLng(37.8024, -122.4058), 200.0, 105.0, 65.0, 600.0, 3000L)
- );
-
- private double[] cumulativeDistances;
+ /**
+ * Constructs the declarative multi-step camera tour using {@link Map3DAnimator.Builder}.
+ */
+ private Map3DAnimator buildTourAnimator() {
+ double planeHeading = SphericalUtil.computeHeading(GOLDEN_GATE_BRIDGE, COIT_TOWER);
+
+ OrbitOptions goldenGateOrbit =
+ new OrbitOptions.Builder()
+ .setCenter(GOLDEN_GATE_BRIDGE)
+ .setAltitude(200.0)
+ .setRange(600.0)
+ .setTilt(65.0)
+ .setHeadingRange(/* startHeading= */ 105.0, /* endHeading= */ 465.0)
+ .setDurationMs(4500L)
+ .build();
+
+ return new Map3DAnimator.Builder()
+ // Step 1: Smooth swoop from high-altitude SF panorama down into Golden Gate flight path
+ .flyTo(
+ getString(R.string.tour_step_1_title),
+ getString(R.string.tour_step_1_desc),
+ new FlyToOptions(
+ new Camera(
+ new LatLngAltitude(
+ GOLDEN_GATE_BRIDGE.latitude, GOLDEN_GATE_BRIDGE.longitude, 200.0),
+ /* heading= */ 105.0,
+ /* tilt= */ 65.0,
+ /* roll= */ 0.0,
+ /* range= */ 600.0),
+ /* durationMs= */ 3000L),
+ /* durationMs= */ 3000L,
+ () -> updateAirplaneModel(GOLDEN_GATE_BRIDGE, planeHeading + 180.0))
+ // Step 2: Dwell pause holding camera steady on the airplane over the bridge
+ .dwell(
+ getString(R.string.tour_step_2_title),
+ getString(R.string.tour_step_2_desc),
+ /* durationMs= */ 1500L)
+ // Step 3: 360-degree orbital camera spin around the airplane
+ .orbit(
+ getString(R.string.tour_step_3_title),
+ getString(R.string.tour_step_3_desc),
+ goldenGateOrbit)
+ // Step 4: High-speed transit across San Francisco coastline to Coit Tower
+ .flyTo(
+ getString(R.string.tour_step_4_title),
+ getString(R.string.tour_step_4_desc),
+ new FlyToOptions(
+ new Camera(
+ new LatLngAltitude(COIT_TOWER.latitude, COIT_TOWER.longitude, 200.0),
+ /* heading= */ 115.0,
+ /* tilt= */ 65.0,
+ /* roll= */ 0.0,
+ /* range= */ 600.0),
+ /* durationMs= */ 3500L),
+ /* durationMs= */ 3500L,
+ () -> handler.postDelayed(
+ () -> updateAirplaneModel(COIT_TOWER, planeHeading + 180.0),
+ /* delayMillis= */ 3500L / 2))
+ .build();
+ }
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- // Inflate control panel overlay into the map container managed by SampleBaseActivity
ViewGroup container = findViewById(R.id.map_container);
if (container != null) {
getLayoutInflater().inflate(R.layout.control_panel_advanced_animation, container, true);
@@ -175,61 +138,39 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
MaterialToolbar topBar = findViewById(R.id.top_bar);
if (topBar != null) {
- topBar.setTitle("Advanced Camera Animation");
+ topBar.setTitle(R.string.feature_title_advanced_camera_animation);
topBar.setNavigationOnClickListener(v -> finish());
}
- RadioGroup rgApproach = findViewById(R.id.rg_approach);
- if (rgApproach != null) {
- rgApproach.setOnCheckedChangeListener((group, checkedId) -> {
- if (checkedId == R.id.rb_simple_flyto) {
- selectedApproach = AnimationApproach.SIMPLE_FLY_TO;
- } else if (checkedId == R.id.rb_keyframe_tour) {
- selectedApproach = AnimationApproach.KEYFRAME_TOUR;
- } else if (checkedId == R.id.rb_orbit_spin) {
- selectedApproach = AnimationApproach.ORBIT_360_SPIN;
- } else {
- selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP;
- }
- resetAndRestartTour();
- });
- }
-
+ tvTourStatus = findViewById(R.id.tv_tour_status);
+ btnPlayPause = findViewById(R.id.btn_play_pause);
Button btnReset = findViewById(R.id.btn_reset);
- if (btnReset != null) {
- btnReset.setOnClickListener(v -> resetAndRestartTour());
- }
- btnPlayPause = findViewById(R.id.btn_play_pause);
if (btnPlayPause != null) {
- btnPlayPause.setOnClickListener(v -> {
- if (isPlaying) {
- stopTour();
- } else {
- startSelectedApproach();
- }
- });
+ btnPlayPause.setOnClickListener(
+ v -> {
+ if (isPlaying) {
+ pauseTour();
+ } else {
+ startOrResumeTour();
+ }
+ });
}
- }
- private void updatePlayPauseButtonState() {
- if (btnPlayPause != null) {
- btnPlayPause.setText(isPlaying ? "Pause" : "Play");
+ if (btnReset != null) {
+ btnReset.setOnClickListener(v -> resetTour());
}
}
@NonNull
@Override
public Camera getInitialCamera() {
- LatLng startLoc = AIRPLANE_FLIGHT_PATH.get(0);
- double initialHeading = SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH.get(1));
return new Camera(
- new LatLngAltitude(startLoc.latitude, startLoc.longitude, 200.0),
- normalizeHeading(initialHeading),
- 65.0,
- 0.0,
- 600.0
- );
+ new LatLngAltitude(SF_PANORAMA_CENTER.latitude, SF_PANORAMA_CENTER.longitude, 300.0),
+ /* heading= */ 30.0,
+ /* tilt= */ 60.0,
+ /* roll= */ 0.0,
+ /* range= */ 4500.0);
}
@NonNull
@@ -241,327 +182,126 @@ public String getTAG() {
@Override
public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setCamera(getInitialCamera());
+ googleMap3D.flyCameraTo(new FlyToOptions(getInitialCamera(), 0L));
+
+ // Re-apply after a short delay to guarantee native viewport receives target coordinates
+ handler.postDelayed(
+ () -> {
+ if (this.googleMap3D != null) {
+ this.googleMap3D.setCamera(getInitialCamera());
+ this.googleMap3D.flyCameraTo(new FlyToOptions(getInitialCamera(), 0L));
+ }
+ },
+ 150L);
- cumulativeDistances = RouteEngine.calculateCumulativeDistances(AIRPLANE_FLIGHT_PATH);
-
- LatLng startLoc = AIRPLANE_FLIGHT_PATH.get(0);
- double initialHeading = SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH.get(1));
- updateAirplaneModel(startLoc, initialHeading + 180.0);
-
- // Auto-start smooth flight animation after 1 second delay
- handler.postDelayed(this::startSelectedApproach, 1000L);
- }
-
- private void updateAirplaneModel(LatLng position, double planeHeadingDeg) {
- if (googleMap3D != null) {
- ModelOptions modelOptions = new ModelOptions();
- modelOptions.setId(MODEL_ID);
- modelOptions.setPosition(new LatLngAltitude(position.latitude, position.longitude, 200.0));
- modelOptions.setUrl(PLANE_URL);
- modelOptions.setAltitudeMode(AltitudeMode.ABSOLUTE);
- modelOptions.setScale(new Vector3D(0.08, 0.08, 0.08));
- modelOptions.setOrientation(new Orientation(normalizeHeading(planeHeadingDeg), -90.0, 0.0));
-
- airplaneModel = googleMap3D.addModel(modelOptions);
- }
- }
-
- private void startSelectedApproach() {
- stopTour();
- if (selectedApproach == AnimationApproach.SIMPLE_FLY_TO) {
- runSimpleFlyTo();
- } else if (selectedApproach == AnimationApproach.KEYFRAME_TOUR) {
- startOrResumeTour();
- } else if (selectedApproach == AnimationApproach.DISPATCHER_FRAME_LOOP) {
- runFrameDispatcherLoop();
- } else if (selectedApproach == AnimationApproach.ORBIT_360_SPIN) {
- run360OrbitSpin();
- }
- }
-
- private void runSimpleFlyTo() {
- stopTour();
- isPlaying = true;
- updatePlayPauseButtonState();
-
- LatLng target = AIRPLANE_FLIGHT_PATH.get(AIRPLANE_FLIGHT_PATH.size() - 1);
- double heading = SphericalUtil.computeHeading(
- AIRPLANE_FLIGHT_PATH.get(AIRPLANE_FLIGHT_PATH.size() - 2), target);
- updateAirplaneModel(target, heading + 180.0);
-
- Camera targetCam = new Camera(
- new LatLngAltitude(target.latitude, target.longitude, 200.0),
- normalizeHeading(heading),
- 65.0,
- 0.0,
- 600.0
- );
- googleMap3D.flyCameraTo(new FlyToOptions(targetCam, 1500L));
- isPlaying = false;
- updatePlayPauseButtonState();
+ double planeHeading = SphericalUtil.computeHeading(GOLDEN_GATE_BRIDGE, COIT_TOWER);
+ updateAirplaneModel(GOLDEN_GATE_BRIDGE, planeHeading + 180.0);
}
- /**
- * Executes multi-step keyframe queue tour stage by stage in Java.
- */
private void startOrResumeTour() {
- stopTour();
+ if (tourAnimator == null) {
+ tourAnimator = buildTourAnimator();
+ }
isPlaying = true;
updatePlayPauseButtonState();
- currentStepIndex = 0;
- executeNextKeyframeStep();
- }
-
- private void executeNextKeyframeStep() {
- if (!isPlaying || currentStepIndex >= SAN_FRANCISCO_TOUR.size()) {
- isPlaying = false;
- updatePlayPauseButtonState();
- return;
- }
- Keyframe step = SAN_FRANCISCO_TOUR.get(currentStepIndex);
-
- if (step instanceof KeyframeFlyTo) {
- KeyframeFlyTo flyToStep = (KeyframeFlyTo) step;
- updateAirplaneModel(flyToStep.targetCenter, flyToStep.targetHeading + 180.0);
- Camera targetCam = new Camera(
- new LatLngAltitude(flyToStep.targetCenter.latitude, flyToStep.targetCenter.longitude,
- flyToStep.targetAltitude),
- normalizeHeading(flyToStep.targetHeading),
- flyToStep.targetTilt,
- 0.0,
- flyToStep.targetRange
- );
- if (googleMap3D != null) {
- googleMap3D.flyCameraTo(new FlyToOptions(targetCam, flyToStep.durationMs));
- }
- handler.postDelayed(() -> {
- currentStepIndex++;
- executeNextKeyframeStep();
- }, flyToStep.durationMs);
-
- } else if (step instanceof KeyframeDwell) {
- KeyframeDwell dwellStep = (KeyframeDwell) step;
- handler.postDelayed(() -> {
- currentStepIndex++;
- executeNextKeyframeStep();
- }, dwellStep.durationMs);
-
- } else if (step instanceof KeyframeOrbit) {
- KeyframeOrbit orbitStep = (KeyframeOrbit) step;
- final long frameMs = 16L;
- final long totalFrames = Math.max(1, orbitStep.durationMs / frameMs);
-
- animationRunnable = new Runnable() {
- private long currentFrame = 0;
-
- @Override
- public void run() {
- if (!isPlaying) {
- return;
+ if (googleMap3D != null) {
+ tourAnimator.start(
+ googleMap3D,
+ new Map3DAnimator.Listener() {
+ @Override
+ public void onStepStarted(int index, @NonNull KeyframeStep step) {
+ Log.d(getTAG(), "Keyframe Step " + (index + 1) + " started: " + step.getTitle());
+ if (tvTourStatus != null) {
+ tvTourStatus.setText(
+ getString(
+ R.string.aerial_tour_status_running,
+ index + 1,
+ tourAnimator.getSteps().size(),
+ step.getTitle()));
+ }
}
- double t = (double) currentFrame / totalFrames;
- double orbitHeading = interpolateAngle(orbitStep.startHeading, orbitStep.endHeading, t);
-
- updateAirplaneModel(orbitStep.center, orbitHeading + 180.0);
-
- Camera updatedCam = new Camera(
- new LatLngAltitude(orbitStep.center.latitude, orbitStep.center.longitude,
- orbitStep.altitude),
- normalizeHeading(orbitHeading),
- orbitStep.tilt,
- 0.0,
- orbitStep.range
- );
- if (googleMap3D != null) {
- googleMap3D.setCamera(updatedCam);
- }
+ @Override
+ public void onStepCompleted(int index, @NonNull KeyframeStep step) {
+ Log.d(getTAG(), "Keyframe Step " + (index + 1) + " completed: " + step.getTitle());
+ }
- currentFrame++;
- if (currentFrame <= totalFrames) {
- handler.postDelayed(this, frameMs);
- } else {
- currentStepIndex++;
- executeNextKeyframeStep();
- }
- }
- };
- handler.post(animationRunnable);
+ @Override
+ public void onAnimationFinished() {
+ Log.d(getTAG(), "Aerial tour completed successfully.");
+ isPlaying = false;
+ updatePlayPauseButtonState();
+ if (tvTourStatus != null) {
+ tvTourStatus.setText(R.string.aerial_tour_status_finished);
+ }
+ }
+ });
}
}
- /**
- * Frame Dispatcher Animation Loop. High-speed flight animation (400 m/s) stopping cleanly at
- * destination.
- */
- private void runFrameDispatcherLoop() {
- stopTour();
- isPlaying = true;
- updatePlayPauseButtonState();
-
- final double totalDistance = Math.max(1.0, cumulativeDistances[cumulativeDistances.length - 1]);
- final double flightSpeedMps = 400.0;
-
- animationRunnable = new Runnable() {
- private double elapsedDistance = 0.0;
- private long lastTime = System.currentTimeMillis();
-
- @Override
- public void run() {
- if (!isPlaying) {
- return;
- }
-
- long now = System.currentTimeMillis();
- double dt = (now - lastTime) / 1000.0;
- lastTime = now;
-
- elapsedDistance += flightSpeedMps * dt;
-
- // Stop cleanly at destination
- if (elapsedDistance >= totalDistance) {
- elapsedDistance = totalDistance;
- PositionAndHeading posAndHeading = RouteEngine.calculatePositionAndHeading(
- AIRPLANE_FLIGHT_PATH,
- cumulativeDistances,
- elapsedDistance,
- 30.0
- );
- double planeHeading = posAndHeading.getHeading() + 180.0;
- updateAirplaneModel(posAndHeading.getPosition(), planeHeading);
-
- Camera finalCam = new Camera(
- new LatLngAltitude(posAndHeading.getPosition().latitude,
- posAndHeading.getPosition().longitude, 200.0),
- normalizeHeading(posAndHeading.getHeading()),
- 65.0,
- 0.0,
- 600.0
- );
- if (googleMap3D != null) {
- googleMap3D.setCamera(finalCam);
- }
- isPlaying = false;
- updatePlayPauseButtonState();
- return;
- }
-
- PositionAndHeading posAndHeading = RouteEngine.calculatePositionAndHeading(
- AIRPLANE_FLIGHT_PATH,
- cumulativeDistances,
- elapsedDistance,
- 30.0
- );
-
- double planeHeading = posAndHeading.getHeading() + 180.0;
- updateAirplaneModel(posAndHeading.getPosition(), planeHeading);
-
- Camera updatedCam = new Camera(
- new LatLngAltitude(posAndHeading.getPosition().latitude,
- posAndHeading.getPosition().longitude, 200.0),
- normalizeHeading(posAndHeading.getHeading()),
- 65.0,
- 0.0,
- 600.0
- );
-
- if (googleMap3D != null) {
- googleMap3D.setCamera(updatedCam);
- }
-
- handler.postDelayed(this, 16L);
- }
- };
- handler.post(animationRunnable);
- }
-
- /**
- * Option 4: Continuous 360-degree orbital camera spin around landmark.
- */
- private void run360OrbitSpin() {
- stopTour();
- isPlaying = true;
+ private void pauseTour() {
+ isPlaying = false;
updatePlayPauseButtonState();
-
- final LatLng targetCenter = AIRPLANE_FLIGHT_PATH.get(0);
- updateAirplaneModel(targetCenter, 105.0 + 180.0);
-
- final long frameMs = 16L;
- final long totalMs = 6000L;
- final long totalFrames = Math.max(1, totalMs / frameMs);
- final double startHeading = 105.0;
-
- animationRunnable = new Runnable() {
- private long currentFrame = 0;
-
- @Override
- public void run() {
- if (!isPlaying) {
- return;
- }
-
- double t = (double) currentFrame / totalFrames;
- double headingDeg = (startHeading + t * 360.0) % 360.0;
-
- Camera currentCam = new Camera(
- new LatLngAltitude(targetCenter.latitude, targetCenter.longitude, 200.0),
- normalizeHeading(headingDeg),
- 65.0,
- 0.0,
- 600.0
- );
- if (googleMap3D != null) {
- googleMap3D.setCamera(currentCam);
- }
-
- currentFrame++;
- if (currentFrame <= totalFrames) {
- handler.postDelayed(this, frameMs);
- } else {
- isPlaying = false;
- updatePlayPauseButtonState();
- }
- }
- };
- handler.post(animationRunnable);
+ handler.removeCallbacksAndMessages(null);
+ if (tourAnimator != null) {
+ tourAnimator.pause();
+ }
}
private void stopTour() {
isPlaying = false;
updatePlayPauseButtonState();
- if (animationRunnable != null) {
- handler.removeCallbacks(animationRunnable);
- animationRunnable = null;
+ handler.removeCallbacksAndMessages(null);
+ if (tourAnimator != null) {
+ tourAnimator.stop();
}
if (googleMap3D != null) {
+ googleMap3D.setCameraAnimationEndListener(null);
googleMap3D.stopCameraAnimation();
}
}
- /**
- * Resets the camera and airplane model to the initial start location and restarts animation.
- */
- public void resetAndRestartTour() {
+ public void resetTour() {
stopTour();
- currentStepIndex = 0;
-
- LatLng startLoc = AIRPLANE_FLIGHT_PATH.get(0);
- double initialHeading = SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH.get(1));
- updateAirplaneModel(startLoc, initialHeading + 180.0);
-
- Camera resetCam = new Camera(
- new LatLngAltitude(startLoc.latitude, startLoc.longitude, 200.0),
- normalizeHeading(initialHeading),
- 65.0,
- 0.0,
- 600.0
- );
+ tourAnimator = null;
+
+ if (googleMap3D != null) {
+ googleMap3D.setCamera(getInitialCamera());
+ googleMap3D.flyCameraTo(new FlyToOptions(getInitialCamera(), 0L));
+ }
+
+ double planeHeading = SphericalUtil.computeHeading(GOLDEN_GATE_BRIDGE, COIT_TOWER);
+ updateAirplaneModel(GOLDEN_GATE_BRIDGE, planeHeading + 180.0);
+
+ if (tvTourStatus != null) {
+ tvTourStatus.setText(R.string.aerial_tour_status_idle);
+ }
+ }
+
+ private void updateAirplaneModel(LatLng position, double planeHeadingDeg) {
if (googleMap3D != null) {
- googleMap3D.setCamera(resetCam);
+ ModelOptions modelOptions = new ModelOptions();
+ modelOptions.setId(MODEL_ID);
+ modelOptions.setPosition(new LatLngAltitude(position.latitude, position.longitude, 200.0));
+ modelOptions.setUrl(PLANE_URL);
+ modelOptions.setAltitudeMode(AltitudeMode.ABSOLUTE);
+ modelOptions.setScale(new Vector3D(0.08, 0.08, 0.08));
+ modelOptions.setOrientation(
+ new Orientation(
+ /* heading= */ normalizeHeading(planeHeadingDeg),
+ /* tilt= */ -90.0,
+ /* roll= */ 0.0));
+
+ airplaneModel = googleMap3D.addModel(modelOptions);
}
+ }
- handler.postDelayed(this::startSelectedApproach, 300L);
+ private void updatePlayPauseButtonState() {
+ if (btnPlayPause != null) {
+ btnPlayPause.setText(isPlaying ? R.string.pause : R.string.play);
+ }
}
private static double normalizeHeading(double headingDeg) {
@@ -569,20 +309,9 @@ private static double normalizeHeading(double headingDeg) {
return normalized < 0.0 ? normalized + 360.0 : normalized;
}
- private static double interpolateAngle(double start, double end, double fraction) {
- double diff = (end - start) % 360.0;
- if (diff > 180.0) {
- diff -= 360.0;
- }
- if (diff < -180.0) {
- diff += 360.0;
- }
- return (start + diff * fraction + 360.0) % 360.0;
- }
-
@Override
protected void onPause() {
super.onPause();
- stopTour();
+ pauseTour();
}
}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java
new file mode 100644
index 00000000..9296f4d7
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import com.google.android.gms.maps3d.GoogleMap3D;
+
+/**
+ * Abstract base class for camera animation steps.
+ */
+public abstract class AnimationStep implements KeyframeStep {
+
+ private final String title;
+ private final String description;
+ private final long durationMs;
+ protected GoogleMap3D activeMap;
+
+ public AnimationStep(@NonNull String title, @NonNull String description, long durationMs) {
+ this.title = title;
+ this.description = description;
+ this.durationMs = durationMs;
+ }
+
+ @NonNull
+ @Override
+ public String getTitle() {
+ return title;
+ }
+
+ @NonNull
+ @Override
+ public String getDescription() {
+ return description;
+ }
+
+ public long getDurationMs() {
+ return durationMs;
+ }
+
+ @Override
+ public void cancel() {
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap.stopCameraAnimation();
+ activeMap = null;
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java
new file mode 100644
index 00000000..4d3a4914
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+import com.google.android.gms.maps3d.GoogleMap3D;
+
+/**
+ * Keyframe step that pauses for a specified duration without animating the camera.
+ */
+public class DwellStep implements KeyframeStep {
+
+ private final String title;
+ private final String description;
+ private final long durationMs;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Runnable pendingRunnable;
+
+ public DwellStep(@NonNull String title, @NonNull String description, long durationMs) {
+ this.title = title;
+ this.description = description;
+ this.durationMs = durationMs;
+ }
+
+ public DwellStep(long durationMs) {
+ this("Dwell Pause", "Observing current location", durationMs);
+ }
+
+ @NonNull
+ @Override
+ public String getTitle() {
+ return title;
+ }
+
+ @NonNull
+ @Override
+ public String getDescription() {
+ return description;
+ }
+
+ public long getDurationMs() {
+ return durationMs;
+ }
+
+ @Override
+ public void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback) {
+ cancel();
+ pendingRunnable = callback::onComplete;
+ handler.postDelayed(pendingRunnable, durationMs);
+ }
+
+ @Override
+ public void cancel() {
+ if (pendingRunnable != null) {
+ handler.removeCallbacks(pendingRunnable);
+ pendingRunnable = null;
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java
new file mode 100644
index 00000000..9e7f52ab
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.FlyAroundOptions;
+
+/**
+ * Animation step that executes an orbital camera fly-around using the native Maps 3D SDK.
+ */
+public class FlyAroundStep extends AnimationStep {
+
+ private final FlyAroundOptions options;
+ private final Runnable onStartAction;
+
+ public FlyAroundStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyAroundOptions options,
+ long durationMs,
+ @Nullable Runnable onStartAction) {
+ super(title, description, durationMs);
+ this.options = options;
+ this.onStartAction = onStartAction;
+ }
+
+ public FlyAroundStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyAroundOptions options,
+ long durationMs) {
+ this(title, description, options, durationMs, null);
+ }
+
+ @NonNull
+ public FlyAroundOptions getOptions() {
+ return options;
+ }
+
+ @Override
+ public void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback) {
+ cancel();
+ this.activeMap = map;
+ if (onStartAction != null) {
+ onStartAction.run();
+ }
+ map.setCameraAnimationEndListener(() -> {
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap = null;
+ }
+ callback.onComplete();
+ });
+ map.flyCameraAround(options);
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java
new file mode 100644
index 00000000..759859a5
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.FlyToOptions;
+
+/**
+ * Animation step that flies the camera to a target position using native Maps 3D SDK animations.
+ */
+public class FlyToStep extends AnimationStep {
+
+ private final FlyToOptions options;
+ private final Runnable onStartAction;
+
+ public FlyToStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyToOptions options,
+ long durationMs,
+ @Nullable Runnable onStartAction) {
+ super(title, description, durationMs);
+ this.options = options;
+ this.onStartAction = onStartAction;
+ }
+
+ public FlyToStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyToOptions options,
+ long durationMs) {
+ this(title, description, options, durationMs, null);
+ }
+
+ public FlyToStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull Camera targetCamera,
+ long durationMs) {
+ this(title, description, new FlyToOptions(targetCamera, durationMs), durationMs, null);
+ }
+
+ @NonNull
+ public FlyToOptions getOptions() {
+ return options;
+ }
+
+ @Override
+ public void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback) {
+ cancel();
+ this.activeMap = map;
+ if (onStartAction != null) {
+ onStartAction.run();
+ }
+ map.setCameraAnimationEndListener(() -> {
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap = null;
+ }
+ callback.onComplete();
+ });
+ map.flyCameraTo(options);
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java
new file mode 100644
index 00000000..b23dd009
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import com.google.android.gms.maps3d.GoogleMap3D;
+
+/**
+ * Interface representing an executable keyframe step in a multi-step camera tour.
+ */
+public interface KeyframeStep {
+
+ /**
+ * Returns human-readable title for this step.
+ */
+ @NonNull
+ String getTitle();
+
+ /**
+ * Returns description of what occurs during this step.
+ */
+ @NonNull
+ String getDescription();
+
+ /**
+ * Executes this step against the provided non-null {@link GoogleMap3D} instance.
+ *
+ * @param map The map instance to manipulate.
+ * @param callback Callback to invoke when this step has finished.
+ */
+ void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback);
+
+ /**
+ * Cancels this step if it is currently executing, clearing any scheduled callbacks or animations.
+ */
+ void cancel();
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java
new file mode 100644
index 00000000..3a4cf35c
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java
@@ -0,0 +1,267 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.FlyAroundOptions;
+import com.google.android.gms.maps3d.model.FlyToOptions;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Orchestrates multi-step keyframe camera animations on a {@link GoogleMap3D}.
+ *
+ * Supports sequential playback, step lifecycle callbacks, pause, resume, and cancellation.
+ */
+public class Map3DAnimator {
+
+ /**
+ * Listener interface for monitoring animator lifecycle events.
+ */
+ public interface Listener {
+
+ /**
+ * Called when a keyframe step begins execution.
+ */
+ default void onStepStarted(int index, @NonNull KeyframeStep step) {}
+
+ /**
+ * Called when a keyframe step successfully finishes.
+ */
+ default void onStepCompleted(int index, @NonNull KeyframeStep step) {}
+
+ /**
+ * Called when all steps in the animation sequence have completed.
+ */
+ default void onAnimationFinished() {}
+
+ /**
+ * Called when the animation sequence is stopped or cancelled.
+ */
+ default void onAnimationCancelled() {}
+ }
+
+ private final List steps;
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+ private GoogleMap3D activeMap;
+ private Listener listener;
+ private int currentStepIndex = 0;
+ private boolean isPlaying = false;
+ private KeyframeStep currentlyExecutingStep;
+
+ public Map3DAnimator(@NonNull List steps) {
+ this.steps = Collections.unmodifiableList(new ArrayList<>(steps));
+ }
+
+ @NonNull
+ public List getSteps() {
+ return steps;
+ }
+
+ public int getCurrentStepIndex() {
+ return currentStepIndex;
+ }
+
+ public boolean isPlaying() {
+ return isPlaying;
+ }
+
+ /**
+ * Starts or restarts the keyframe animation sequence from the beginning.
+ */
+ public void start(@NonNull GoogleMap3D map, @Nullable Listener listener) {
+ stop();
+ this.activeMap = map;
+ this.listener = listener;
+ this.isPlaying = true;
+ this.currentStepIndex = 0;
+ mainHandler.post(this::executeNextStep);
+ }
+
+ /**
+ * Pauses the currently running animation step.
+ */
+ public void pause() {
+ if (!isPlaying) {
+ return;
+ }
+ isPlaying = false;
+ if (currentlyExecutingStep != null) {
+ currentlyExecutingStep.cancel();
+ currentlyExecutingStep = null;
+ }
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap.stopCameraAnimation();
+ }
+ }
+
+ /**
+ * Resumes playback from the current step index.
+ */
+ public void resume() {
+ if (isPlaying || activeMap == null || currentStepIndex >= steps.size()) {
+ return;
+ }
+ isPlaying = true;
+ mainHandler.post(this::executeNextStep);
+ }
+
+ /**
+ * Stops the animation, cancels executing steps, and resets step index to 0.
+ */
+ public void stop() {
+ isPlaying = false;
+ if (currentlyExecutingStep != null) {
+ currentlyExecutingStep.cancel();
+ currentlyExecutingStep = null;
+ }
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap.stopCameraAnimation();
+ activeMap = null;
+ }
+ currentStepIndex = 0;
+ }
+
+ private void executeNextStep() {
+ if (!isPlaying || activeMap == null) {
+ return;
+ }
+
+ if (currentStepIndex >= steps.size()) {
+ isPlaying = false;
+ if (listener != null) {
+ listener.onAnimationFinished();
+ }
+ return;
+ }
+
+ KeyframeStep step = steps.get(currentStepIndex);
+ currentlyExecutingStep = step;
+
+ if (listener != null) {
+ listener.onStepStarted(currentStepIndex, step);
+ }
+
+ step.execute(
+ activeMap,
+ () -> mainHandler.post(() -> {
+ if (!isPlaying) {
+ return;
+ }
+ currentlyExecutingStep = null;
+ if (listener != null) {
+ listener.onStepCompleted(currentStepIndex, step);
+ }
+ currentStepIndex++;
+ executeNextStep();
+ }));
+ }
+
+ /**
+ * Builder for constructing a {@link Map3DAnimator} with a fluent API.
+ */
+ public static class Builder {
+
+ private final List steps = new ArrayList<>();
+
+ public Builder addStep(@NonNull KeyframeStep step) {
+ steps.add(step);
+ return this;
+ }
+
+ public Builder flyTo(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull Camera targetCamera,
+ long durationMs) {
+ return addStep(new FlyToStep(title, description, targetCamera, durationMs));
+ }
+
+ public Builder flyTo(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyToOptions options,
+ long durationMs,
+ @Nullable Runnable onStartAction) {
+ return addStep(new FlyToStep(title, description, options, durationMs, onStartAction));
+ }
+
+ public Builder flyTo(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyToOptions options,
+ long durationMs) {
+ return addStep(new FlyToStep(title, description, options, durationMs));
+ }
+
+ public Builder dwell(
+ @NonNull String title,
+ @NonNull String description,
+ long durationMs) {
+ return addStep(new DwellStep(title, description, durationMs));
+ }
+
+ public Builder dwell(long durationMs) {
+ return addStep(new DwellStep(durationMs));
+ }
+
+ public Builder flyAround(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyAroundOptions options,
+ long durationMs,
+ @Nullable Runnable onStartAction) {
+ return addStep(new FlyAroundStep(title, description, options, durationMs, onStartAction));
+ }
+
+ public Builder flyAround(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyAroundOptions options,
+ long durationMs) {
+ return addStep(new FlyAroundStep(title, description, options, durationMs));
+ }
+
+ public Builder orbit(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull OrbitOptions options) {
+ return addStep(new OrbitStep(title, description, options));
+ }
+
+ public Builder orbit(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull OrbitOptions options,
+ @Nullable OrbitStep.OnOrbitFrameListener frameListener) {
+ return addStep(new OrbitStep(title, description, options, frameListener));
+ }
+
+ @NonNull
+ public Map3DAnimator build() {
+ return new Map3DAnimator(steps);
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java
new file mode 100644
index 00000000..940f53b5
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import com.google.android.gms.maps.model.LatLng;
+
+/**
+ * Immutable configuration options for orbital camera animations.
+ *
+ * Use {@link OrbitOptions.Builder} to construct instances with clear, self-documenting parameters.
+ */
+public class OrbitOptions {
+
+ private final LatLng center;
+ private final double altitude;
+ private final double tilt;
+ private final double range;
+ private final double startHeading;
+ private final double endHeading;
+ private final long durationMs;
+
+ private OrbitOptions(Builder builder) {
+ this.center = builder.center;
+ this.altitude = builder.altitude;
+ this.tilt = builder.tilt;
+ this.range = builder.range;
+ this.startHeading = builder.startHeading;
+ this.endHeading = builder.endHeading;
+ this.durationMs = builder.durationMs;
+ }
+
+ @NonNull
+ public LatLng getCenter() {
+ return center;
+ }
+
+ public double getAltitude() {
+ return altitude;
+ }
+
+ public double getTilt() {
+ return tilt;
+ }
+
+ public double getRange() {
+ return range;
+ }
+
+ public double getStartHeading() {
+ return startHeading;
+ }
+
+ public double getEndHeading() {
+ return endHeading;
+ }
+
+ public long getDurationMs() {
+ return durationMs;
+ }
+
+ /**
+ * Builder for creating {@link OrbitOptions} with named, self-describing parameters.
+ */
+ public static class Builder {
+
+ private LatLng center;
+ private double altitude = 200.0;
+ private double tilt = 65.0;
+ private double range = 600.0;
+ private double startHeading = 0.0;
+ private double endHeading = 360.0;
+ private long durationMs = 4000L;
+
+ public Builder setCenter(@NonNull LatLng center) {
+ this.center = center;
+ return this;
+ }
+
+ public Builder setAltitude(double altitude) {
+ this.altitude = altitude;
+ return this;
+ }
+
+ public Builder setTilt(double tilt) {
+ this.tilt = tilt;
+ return this;
+ }
+
+ public Builder setRange(double range) {
+ this.range = range;
+ return this;
+ }
+
+ public Builder setStartHeading(double startHeading) {
+ this.startHeading = startHeading;
+ return this;
+ }
+
+ public Builder setEndHeading(double endHeading) {
+ this.endHeading = endHeading;
+ return this;
+ }
+
+ public Builder setHeadingRange(double startHeading, double endHeading) {
+ this.startHeading = startHeading;
+ this.endHeading = endHeading;
+ return this;
+ }
+
+ public Builder setDurationMs(long durationMs) {
+ this.durationMs = durationMs;
+ return this;
+ }
+
+ @NonNull
+ public OrbitOptions build() {
+ if (center == null) {
+ throw new IllegalStateException("Center LatLng must not be null for OrbitOptions");
+ }
+ return new OrbitOptions(this);
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java
new file mode 100644
index 00000000..b044c075
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+
+/**
+ * Keyframe step that runs a smooth frame-by-frame orbital spin around a landmark.
+ */
+public class OrbitStep extends AnimationStep {
+
+ /**
+ * Callback invoked on every frame of the orbital animation.
+ */
+ @FunctionalInterface
+ public interface OnOrbitFrameListener {
+ void onFrame(double fraction, double currentHeading);
+ }
+
+ private final OrbitOptions options;
+ private final OnOrbitFrameListener frameListener;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Runnable frameRunnable;
+
+ public OrbitStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull OrbitOptions options,
+ @Nullable OnOrbitFrameListener frameListener) {
+ super(title, description, options.getDurationMs());
+ this.options = options;
+ this.frameListener = frameListener;
+ }
+
+ public OrbitStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull OrbitOptions options) {
+ this(title, description, options, null);
+ }
+
+ @NonNull
+ public OrbitOptions getOptions() {
+ return options;
+ }
+
+ @Override
+ public void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback) {
+ cancel();
+ this.activeMap = map;
+
+ final long frameMs = 16L;
+ final long totalFrames = Math.max(1, options.getDurationMs() / frameMs);
+
+ frameRunnable = new Runnable() {
+ private long currentFrame = 0;
+
+ @Override
+ public void run() {
+ if (activeMap == null) {
+ return;
+ }
+
+ double t = (double) currentFrame / totalFrames;
+ double heading = interpolateAngle(options.getStartHeading(), options.getEndHeading(), t);
+
+ if (frameListener != null) {
+ frameListener.onFrame(t, heading);
+ }
+
+ Camera orbitCam = new Camera(
+ new LatLngAltitude(
+ options.getCenter().latitude,
+ options.getCenter().longitude,
+ options.getAltitude()),
+ normalizeHeading(heading),
+ options.getTilt(),
+ 0.0,
+ options.getRange()
+ );
+ activeMap.setCamera(orbitCam);
+
+ currentFrame++;
+ if (currentFrame <= totalFrames) {
+ handler.postDelayed(this, frameMs);
+ } else {
+ activeMap = null;
+ frameRunnable = null;
+ callback.onComplete();
+ }
+ }
+ };
+
+ handler.post(frameRunnable);
+ }
+
+ @Override
+ public void cancel() {
+ super.cancel();
+ if (frameRunnable != null) {
+ handler.removeCallbacks(frameRunnable);
+ frameRunnable = null;
+ }
+ }
+
+ private static double normalizeHeading(double headingDeg) {
+ double normalized = headingDeg % 360.0;
+ return normalized < 0.0 ? normalized + 360.0 : normalized;
+ }
+
+ private static double interpolateAngle(double start, double end, double fraction) {
+ return start + (end - start) * fraction;
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java
new file mode 100644
index 00000000..9d7232b1
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2025 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.maps3djava.advancedcameraanimation;
+
+/**
+ * Callback invoked when an individual {@link KeyframeStep} finishes execution.
+ */
+@FunctionalInterface
+public interface StepCallback {
+
+ /**
+ * Invoked when the keyframe step has completed its execution.
+ */
+ void onComplete();
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
index c0551815..d98c3ce6 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
@@ -22,10 +22,12 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.view.MotionEvent;
import android.widget.RadioGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.cardview.widget.CardView;
import androidx.core.view.WindowCompat;
import com.example.maps3dcommon.R;
import com.google.android.gms.maps.model.LatLng;
@@ -43,13 +45,14 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Locale;
/**
* Advanced sample demonstrating ground-level path following in Java.
*
* Features: - Urban vs Rural ground-level paths - Real-time camera controls via sliders: Range,
* Ground Altitude, Heading Offset, Tilt, Follow Speed - Smooth Handler-based frame animation along
- * the route
+ * the route - Contracting and fading polyline playback visualization
*/
public class PathFollowingActivity extends AppCompatActivity implements OnMap3DViewReadyCallback {
@@ -91,6 +94,9 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV
// View Bindings
private RadioGroup rgEnvironment;
+ private RadioGroup rgAltitudeMode;
+ private Slider pathAltitudeSlider;
+ private TextView pathAltitudeSliderLabel;
private MaterialButton btnPlayPause;
private Slider progressSlider;
private Slider rangeSlider;
@@ -110,6 +116,8 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV
private double headingOffset = 0.0;
private double cameraTilt = 70.0;
private double followSpeedMps = 30.0;
+ private @AltitudeMode int pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND;
+ private double pathAltitudeOffset = 0.5;
// Path state
private List currentPath = URBAN_PATH;
@@ -119,10 +127,84 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV
private boolean isPlaying = false;
private boolean isUserScrubbing = false;
- private Polyline pathPolyline = null;
+ // Polyline IDs
+ private static final String STATIC_ROUTE_POLYLINE_ID = "path_following_static_route";
+ private static final String PROGRESS_POLYLINE_ID = "path_following_progress_route";
+
+ private Polyline staticRoutePolyline = null;
+ private Polyline progressPolyline = null;
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable animationRunnable;
+ private CardView controlsCard;
+ private android.view.View cardHeader;
+ private MaterialButton btnCollapse;
+ private boolean isCollapsed = false;
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.8f) // Subtle fade when idle
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ private void collapseControls() {
+ if (controlsCard == null) {
+ return;
+ }
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ int headerHeight = (cardHeader != null && cardHeader.getHeight() > 0)
+ ? cardHeader.getHeight()
+ : (int) (48 * getResources().getDisplayMetrics().density);
+ float targetTranslationY = Math.max(0, controlsCard.getHeight() - headerHeight);
+ controlsCard.animate()
+ .translationY(targetTranslationY)
+ .alpha(0.9f)
+ .setDuration(300)
+ .start();
+ }
+
+ private void expandControls() {
+ if (controlsCard == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ controlsCard.animate()
+ .translationY(0f)
+ .alpha(1.0f)
+ .setDuration(250)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
@Override
protected void onCreate(Bundle savedInstanceState) {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
@@ -154,9 +236,14 @@ protected void onPause() {
protected void onDestroy() {
super.onDestroy();
pauseAnimation();
- if (pathPolyline != null) {
- pathPolyline.remove();
- pathPolyline = null;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (staticRoutePolyline != null) {
+ staticRoutePolyline.remove();
+ staticRoutePolyline = null;
+ }
+ if (progressPolyline != null) {
+ progressPolyline.remove();
+ progressPolyline = null;
}
map3DView.onDestroy();
}
@@ -174,7 +261,34 @@ protected void onSaveInstanceState(@NonNull Bundle outState) {
}
private void initViews() {
+ controlsCard = findViewById(R.id.controls_card);
+ cardHeader = findViewById(R.id.card_header);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ }
+ });
+ }
+
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+
rgEnvironment = findViewById(R.id.rg_environment);
+ rgAltitudeMode = findViewById(R.id.rg_altitude_mode);
+ pathAltitudeSlider = findViewById(R.id.path_altitude_slider);
+ pathAltitudeSliderLabel = findViewById(R.id.path_altitude_slider_label);
btnPlayPause = findViewById(R.id.btn_play_pause);
progressSlider = findViewById(R.id.progress_slider);
@@ -189,6 +303,8 @@ private void initViews() {
speedSlider = findViewById(R.id.speed_slider);
speedSliderLabel = findViewById(R.id.speed_slider_label);
+ updateControlLabels();
+
// Radio group environment selection
rgEnvironment.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.rb_urban) {
@@ -200,6 +316,30 @@ private void initViews() {
}
});
+ // Radio group altitude mode selection
+ rgAltitudeMode.setOnCheckedChangeListener((group, checkedId) -> {
+ if (checkedId == R.id.rb_relative_to_ground) {
+ pathAltitudeMode = AltitudeMode.RELATIVE_TO_GROUND;
+ } else if (checkedId == R.id.rb_clamp_to_ground) {
+ pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND;
+ } else if (checkedId == R.id.rb_relative_to_mesh) {
+ pathAltitudeMode = AltitudeMode.RELATIVE_TO_MESH;
+ } else if (checkedId == R.id.rb_absolute) {
+ pathAltitudeMode = AltitudeMode.ABSOLUTE;
+ }
+ drawStaticRoutePolyline();
+ updateCameraPositionForDistance(elapsedDistance);
+ });
+
+ // Path height slider (relative altitude)
+ pathAltitudeSlider.addOnChangeListener((slider, value, fromUser) -> {
+ pathAltitudeOffset = value;
+ pathAltitudeSliderLabel.setText(
+ getString(R.string.path_height_format, pathAltitudeOffset));
+ drawStaticRoutePolyline();
+ updateCameraPositionForDistance(elapsedDistance);
+ });
+
// Play/Pause button
btnPlayPause.setOnClickListener(v -> {
if (isPlaying) {
@@ -232,41 +372,57 @@ public void onStopTrackingTouch(@NonNull Slider slider) {
// Sliders listeners
rangeSlider.addOnChangeListener((slider, value, fromUser) -> {
cameraRange = value;
- rangeSliderLabel.setText(String.format("Camera Range: %dm", (int) cameraRange));
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange));
updateCameraPositionForDistance(elapsedDistance);
});
altitudeSlider.addOnChangeListener((slider, value, fromUser) -> {
groundAltitude = value;
- altitudeSliderLabel.setText(String.format("Ground Altitude: %dm", (int) groundAltitude));
+ altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, (int) groundAltitude));
updateCameraPositionForDistance(elapsedDistance);
});
headingSlider.addOnChangeListener((slider, value, fromUser) -> {
headingOffset = value;
- headingSliderLabel.setText(String.format("Heading Offset: %d°", (int) headingOffset));
+ headingSliderLabel.setText(getString(R.string.heading_offset_format, (int) headingOffset));
updateCameraPositionForDistance(elapsedDistance);
});
tiltSlider.addOnChangeListener((slider, value, fromUser) -> {
cameraTilt = value;
- tiltSliderLabel.setText(String.format("Camera Tilt: %d°", (int) cameraTilt));
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt));
updateCameraPositionForDistance(elapsedDistance);
});
speedSlider.addOnChangeListener((slider, value, fromUser) -> {
followSpeedMps = value;
- speedSliderLabel.setText(String.format("Follow Speed: %d m/s", (int) followSpeedMps));
+ speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) followSpeedMps));
});
}
+ private void updateControlLabels() {
+ pathAltitudeSliderLabel.setText(
+ getString(R.string.path_height_format, pathAltitudeOffset));
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange));
+ altitudeSliderLabel.setText(
+ getString(R.string.ground_altitude_format, (int) groundAltitude));
+ headingSliderLabel.setText(
+ getString(R.string.heading_offset_format, (int) headingOffset));
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt));
+ speedSliderLabel.setText(
+ getString(R.string.follow_speed_format, (int) followSpeedMps));
+ }
+
@Override
public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
this.googleMap3D = googleMap3D;
googleMap3D.setOnMapReadyListener((map) -> {
googleMap3D.setOnMapReadyListener(null);
- drawPathPolyline();
- updateCameraPositionForDistance(0.0);
+ runOnUiThread(() -> {
+ drawPathPolylines();
+ updateCameraPositionForDistance(0.0);
+ startAnimation();
+ });
});
}
@@ -285,9 +441,9 @@ private void switchEnvironment(List path) {
rangeSlider.setValue(450f);
altitudeSlider.setValue(40f);
tiltSlider.setValue(75f);
- rangeSliderLabel.setText("Camera Range: 450m");
- altitudeSliderLabel.setText("Ground Altitude: 40m");
- tiltSliderLabel.setText("Camera Tilt: 75°");
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, 450));
+ altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, 40));
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, 75));
} else {
cameraRange = 300.0;
groundAltitude = 20.0;
@@ -295,13 +451,13 @@ private void switchEnvironment(List path) {
rangeSlider.setValue(300f);
altitudeSlider.setValue(20f);
tiltSlider.setValue(70f);
- rangeSliderLabel.setText("Camera Range: 300m");
- altitudeSliderLabel.setText("Ground Altitude: 20m");
- tiltSliderLabel.setText("Camera Tilt: 70°");
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, 300));
+ altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, 20));
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, 70));
}
loadPathData(path);
- drawPathPolyline();
+ drawPathPolylines();
updateCameraPositionForDistance(0.0);
}
@@ -317,26 +473,79 @@ private void loadPathData(List path) {
}
}
- private void drawPathPolyline() {
- if (googleMap3D == null) {
+ private void drawPathPolylines() {
+ drawStaticRoutePolyline();
+ if (!currentPath.isEmpty()) {
+ updateProgressPolyline(elapsedDistance, currentPath.get(0), 0);
+ }
+ }
+
+ private void drawStaticRoutePolyline() {
+ if (googleMap3D == null || currentPath.isEmpty()) {
return;
}
- if (pathPolyline != null) {
- pathPolyline.remove();
+
+ double pathAltitude = (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND)
+ ? 0.0
+ : pathAltitudeOffset;
+ if (pathAltitudeMode == AltitudeMode.ABSOLUTE) {
+ pathAltitude = (currentPath.equals(RURAL_PATH)) ? 40.0 : 15.0;
}
- List vertices = new ArrayList<>();
+ List staticVertices = new ArrayList<>();
for (LatLng latLng : currentPath) {
- vertices.add(new LatLngAltitude(latLng.latitude, latLng.longitude, 5.0));
+ staticVertices.add(new LatLngAltitude(latLng.latitude, latLng.longitude, pathAltitude));
+ }
+
+ PolylineOptions staticOptions = new PolylineOptions();
+ staticOptions.setId(STATIC_ROUTE_POLYLINE_ID); // Fixed ID prevents flickering and updates in place
+ staticOptions.setPath(staticVertices);
+ staticOptions.setStrokeColor(Color.parseColor("#4285F4")); // Route line: blue
+ staticOptions.setStrokeWidth(16.0); // Route line: wider
+ staticOptions.setZIndex(1); // Route line: lower z-index
+ staticOptions.setAltitudeMode(pathAltitudeMode);
+
+ staticRoutePolyline = googleMap3D.addPolyline(staticOptions);
+ }
+
+ private void updateProgressPolyline(double dist, LatLng currentLatLng, int index) {
+ if (googleMap3D == null || currentPath.isEmpty() || totalDistance <= 0.0) {
+ return;
+ }
+
+ double pathAltitude = (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND)
+ ? 0.0
+ : pathAltitudeOffset;
+ if (pathAltitudeMode == AltitudeMode.ABSOLUTE) {
+ pathAltitude = (currentPath.equals(RURAL_PATH)) ? 40.0 : 15.0;
+ }
+
+ double progressAltitude = pathAltitude + 0.2;
+
+ // Build traversed route from start to current position
+ List progressCoordinates = new ArrayList<>();
+ for (int i = 0; i <= index && i < currentPath.size(); i++) {
+ LatLng pt = currentPath.get(i);
+ progressCoordinates.add(new LatLngAltitude(pt.latitude, pt.longitude, progressAltitude));
+ }
+ progressCoordinates.add(
+ new LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude));
+
+ // Polyline requires at least 2 vertices
+ if (progressCoordinates.size() < 2) {
+ LatLng startPt = currentPath.get(0);
+ progressCoordinates.add(new LatLngAltitude(startPt.latitude, startPt.longitude, progressAltitude));
}
- PolylineOptions polyOptions = new PolylineOptions();
- polyOptions.setPath(vertices);
- polyOptions.setStrokeColor(Color.parseColor("#4285F4"));
- polyOptions.setStrokeWidth(10.0);
- polyOptions.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND);
+ PolylineOptions progressOptions = new PolylineOptions();
+ progressOptions.setId(PROGRESS_POLYLINE_ID); // Same ID every time to eliminate flickering
+ progressOptions.setPath(progressCoordinates);
+ progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); // Progress line: purple
+ progressOptions.setStrokeWidth(8.0); // Progress line: narrower
+ progressOptions.setZIndex(2); // Progress line: higher z-index
+ progressOptions.setAltitudeMode(pathAltitudeMode);
- pathPolyline = googleMap3D.addPolyline(polyOptions);
+ progressPolyline = googleMap3D.addPolyline(progressOptions);
}
private void startAnimation() {
@@ -433,5 +642,6 @@ private void updateCameraPositionForDistance(double dist) {
);
googleMap3D.setCamera(newCamera);
+ updateProgressPolyline(dist, currentLatLng, index);
}
}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
index c1ef67fe..58415873 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
@@ -38,13 +38,33 @@ import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.android.material.appbar.MaterialToolbar
import com.google.maps.android.SphericalUtil
+import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.time.Duration.Companion.milliseconds
+/**
+ * Suspends until the 3D map camera animation completes using [com.google.android.gms.maps3d.OnCameraAnimationEndListener].
+ */
+private suspend fun GoogleMap3D.awaitFlyCameraTo(options: FlyToOptions) =
+ suspendCancellableCoroutine { continuation ->
+ setCameraAnimationEndListener {
+ setCameraAnimationEndListener(null)
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+ flyCameraTo(options)
+ continuation.invokeOnCancellation {
+ setCameraAnimationEndListener(null)
+ stopCameraAnimation()
+ }
+ }
+
enum class AnimationApproach {
SIMPLE_FLY_TO,
KEYFRAME_TOUR,
@@ -225,9 +245,11 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() {
tilt = 65.0
range = 600.0
}
- googleMap3D?.flyCameraTo(FlyToOptions(targetCam, 1500L))
- isPlaying = false
- updatePlayPauseButtonState()
+ tourJob = lifecycleScope.launch(Dispatchers.Main) {
+ googleMap3D?.awaitFlyCameraTo(FlyToOptions(targetCam, 1500L))
+ isPlaying = false
+ updatePlayPauseButtonState()
+ }
}
/**
@@ -258,8 +280,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() {
range = step.targetRange
}
updateAirplaneModel(step.targetCenter, step.targetHeading + 180.0)
- googleMap3D?.flyCameraTo(FlyToOptions(targetCam, step.durationMs))
- delay(step.durationMs.milliseconds)
+ googleMap3D?.awaitFlyCameraTo(FlyToOptions(targetCam, step.durationMs))
}
is CameraKeyframe.DwellPause -> {
@@ -440,6 +461,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() {
restartJob = null
tourJob?.cancel()
tourJob = null
+ googleMap3D?.setCameraAnimationEndListener(null)
googleMap3D?.stopCameraAnimation()
}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
index 957d0921..de8e7605 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
@@ -18,11 +18,15 @@ package com.example.maps3dkotlin.pathfollowing
import android.graphics.Color
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.MotionEvent
+import android.view.View
import android.widget.RadioGroup
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
+import androidx.cardview.widget.CardView
import androidx.core.view.WindowCompat
-import androidx.lifecycle.lifecycleScope
import com.example.maps3d.common.toHeading
import com.example.maps3dcommon.R
import com.google.android.gms.maps.model.LatLng
@@ -30,28 +34,27 @@ import com.google.android.gms.maps3d.GoogleMap3D
import com.google.android.gms.maps3d.Map3DView
import com.google.android.gms.maps3d.OnMap3DViewReadyCallback
import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.android.gms.maps3d.model.Polyline
+import com.google.android.gms.maps3d.model.PolylineOptions
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
-import com.google.android.gms.maps3d.model.polylineOptions
import com.google.android.material.button.MaterialButton
import com.google.android.material.slider.Slider
import com.google.maps.android.SphericalUtil
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import android.view.Choreographer
-import android.view.MotionEvent
-import androidx.cardview.widget.CardView
/**
* Advanced sample demonstrating ground-level path following in Kotlin.
*
* Features:
* - Urban vs Rural ground-level paths
+ * - Two-polyline architecture: wide blue base route (lower z-index) + narrow purple active progress route (higher z-index)
+ * - In-place polyline ID updates eliminating render flickering
+ * - Configurable altitude modes (Clamp to Ground default, Relative to Ground, Relative to Mesh, Absolute)
+ * - Dynamic path elevation slider to eliminate z-fighting
+ * - Explicit collapse dialog button and smooth slide-down controls
* - Real-time camera controls via sliders: Range, Ground Altitude, Heading Offset, Tilt, Follow Speed
- * - Smooth frame-by-frame animation along the route
*/
class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
@@ -59,7 +62,15 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
private var googleMap3D: GoogleMap3D? = null
// View Bindings
+ private var controlsCard: CardView? = null
+ private var cardHeader: View? = null
+ private var btnCollapse: MaterialButton? = null
+ private var isCollapsed = false
+
private lateinit var rgEnvironment: RadioGroup
+ private lateinit var rgAltitudeMode: RadioGroup
+ private lateinit var pathAltitudeSlider: Slider
+ private lateinit var pathAltitudeSliderLabel: TextView
private lateinit var btnPlayPause: MaterialButton
private lateinit var progressSlider: Slider
private lateinit var rangeSlider: Slider
@@ -73,30 +84,72 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
private lateinit var speedSlider: Slider
private lateinit var speedSliderLabel: TextView
- private var controlsCard: CardView? = null
- private var fadeOutJob: Job? = null
+ private val fadeHandler = Handler(Looper.getMainLooper())
+ private val fadeOutRunnable = Runnable {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard?.animate()
+ ?.alpha(0.8f)
+ ?.setDuration(400)
+ ?.start()
+ }
+ }
+
+ private fun collapseControls() {
+ val card = controlsCard ?: return
+ isCollapsed = true
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ btnCollapse?.setIconResource(R.drawable.expand_less_24px)
+ btnCollapse?.contentDescription = getString(R.string.expand_controls)
+
+ val headerHeight = if (cardHeader != null && cardHeader!!.height > 0) {
+ cardHeader!!.height
+ } else {
+ (48 * resources.displayMetrics.density).toInt()
+ }
+ val targetTranslationY = (card.height - headerHeight).coerceAtLeast(0).toFloat()
+ card.animate()
+ .translationY(targetTranslationY)
+ .alpha(0.9f)
+ .setDuration(300)
+ .start()
+ }
+
+ private fun expandControls() {
+ val card = controlsCard ?: return
+ isCollapsed = false
+ btnCollapse?.setIconResource(R.drawable.expand_more_24px)
+ btnCollapse?.contentDescription = getString(R.string.collapse_controls)
+ card.animate()
+ .translationY(0f)
+ .alpha(1.0f)
+ .setDuration(250)
+ .start()
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L)
+ }
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) {
- controlsCard?.let { card ->
- card.animate().alpha(1.0f).setDuration(150).start()
- fadeOutJob?.cancel()
- fadeOutJob = lifecycleScope.launch {
- delay(3000L)
- card.animate().alpha(0.2f).setDuration(500).start()
- }
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard?.animate()
+ ?.alpha(1.0f)
+ ?.setDuration(150)
+ ?.start()
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L)
}
}
return super.dispatchTouchEvent(ev)
}
-
// Control parameters
private var cameraRange = 300.0
private var groundAltitude = 20.0
private var headingOffset = 0.0
private var cameraTilt = 70.0
private var followSpeedMps = 30.0
+ private var pathAltitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND
+ private var pathAltitudeOffset: Double = 0.5
// Path state
private var currentPath: List = URBAN_PATH
@@ -106,8 +159,11 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
private var isPlaying = false
private var isUserScrubbing = false
- private var pathPolyline: Polyline? = null
- private var animationJob: Job? = null
+ // Polylines
+ private var staticRoutePolyline: Polyline? = null
+ private var progressPolyline: Polyline? = null
+ private val animationHandler = Handler(Looper.getMainLooper())
+ private var animationRunnable: Runnable? = null
override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -136,8 +192,11 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
override fun onDestroy() {
super.onDestroy()
pauseAnimation()
- pathPolyline?.remove()
- pathPolyline = null
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ staticRoutePolyline?.remove()
+ staticRoutePolyline = null
+ progressPolyline?.remove()
+ progressPolyline = null
map3DView.onDestroy()
}
@@ -155,19 +214,39 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
this.googleMap3D = googleMap3D
googleMap3D.setOnMapReadyListener {
googleMap3D.setOnMapReadyListener(null)
- drawPathPolyline()
- updateCameraPositionForDistance(0.0)
+ runOnUiThread {
+ drawPathPolylines()
+ updateCameraPositionForDistance(0.0)
+ startAnimation()
+ }
}
}
private fun initViews() {
controlsCard = findViewById(R.id.controls_card)
- fadeOutJob = lifecycleScope.launch {
- delay(3000L)
- controlsCard?.animate()?.alpha(0.2f)?.setDuration(500)?.start()
+ cardHeader = findViewById(R.id.card_header)
+ btnCollapse = findViewById(R.id.btn_collapse)
+
+ btnCollapse?.setOnClickListener {
+ if (isCollapsed) {
+ expandControls()
+ } else {
+ collapseControls()
+ }
+ }
+
+ cardHeader?.setOnClickListener {
+ if (isCollapsed) {
+ expandControls()
+ }
}
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L)
+
rgEnvironment = findViewById(R.id.rg_environment)
+ rgAltitudeMode = findViewById(R.id.rg_altitude_mode)
+ pathAltitudeSlider = findViewById(R.id.path_altitude_slider)
+ pathAltitudeSliderLabel = findViewById(R.id.path_altitude_slider_label)
btnPlayPause = findViewById(R.id.btn_play_pause)
progressSlider = findViewById(R.id.progress_slider)
@@ -182,7 +261,9 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
speedSlider = findViewById(R.id.speed_slider)
speedSliderLabel = findViewById(R.id.speed_slider_label)
- // Radio group environment listener
+ updateControlLabels()
+
+ // Radio group environment selection
rgEnvironment.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.rb_urban -> {
@@ -197,6 +278,27 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
}
}
+ // Radio group altitude mode selection
+ rgAltitudeMode.setOnCheckedChangeListener { _, checkedId ->
+ pathAltitudeMode = when (checkedId) {
+ R.id.rb_relative_to_ground -> AltitudeMode.RELATIVE_TO_GROUND
+ R.id.rb_relative_to_mesh -> AltitudeMode.RELATIVE_TO_MESH
+ R.id.rb_absolute -> AltitudeMode.ABSOLUTE
+ else -> AltitudeMode.CLAMP_TO_GROUND
+ }
+ drawStaticRoutePolyline()
+ updateCameraPositionForDistance(elapsedDistance)
+ }
+
+ // Path height slider (relative altitude)
+ pathAltitudeSlider.addOnChangeListener { _, value, _ ->
+ pathAltitudeOffset = value.toDouble()
+ pathAltitudeSliderLabel.text =
+ getString(R.string.path_height_format, pathAltitudeOffset)
+ drawStaticRoutePolyline()
+ updateCameraPositionForDistance(elapsedDistance)
+ }
+
// Play/Pause button
btnPlayPause.setOnClickListener {
if (isPlaying) {
@@ -224,37 +326,54 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
}
})
- // Slider listeners for interactive camera inputs
+ // Sliders listeners
rangeSlider.addOnChangeListener { _, value, _ ->
cameraRange = value.toDouble()
- rangeSliderLabel.text = "Camera Range: ${cameraRange.toInt()}m"
+ rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt())
updateCameraPositionForDistance(elapsedDistance)
}
altitudeSlider.addOnChangeListener { _, value, _ ->
groundAltitude = value.toDouble()
- altitudeSliderLabel.text = "Ground Altitude: ${groundAltitude.toInt()}m"
+ altitudeSliderLabel.text =
+ getString(R.string.ground_altitude_format, groundAltitude.toInt())
updateCameraPositionForDistance(elapsedDistance)
}
headingSlider.addOnChangeListener { _, value, _ ->
headingOffset = value.toDouble()
- headingSliderLabel.text = "Heading Offset: ${headingOffset.toInt()}°"
+ headingSliderLabel.text =
+ getString(R.string.heading_offset_format, headingOffset.toInt())
updateCameraPositionForDistance(elapsedDistance)
}
tiltSlider.addOnChangeListener { _, value, _ ->
cameraTilt = value.toDouble()
- tiltSliderLabel.text = "Camera Tilt: ${cameraTilt.toInt()}°"
+ tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt())
updateCameraPositionForDistance(elapsedDistance)
}
speedSlider.addOnChangeListener { _, value, _ ->
followSpeedMps = value.toDouble()
- speedSliderLabel.text = "Follow Speed: ${followSpeedMps.toInt()} m/s"
+ speedSliderLabel.text =
+ getString(R.string.follow_speed_format, followSpeedMps.toInt())
}
}
+ private fun updateControlLabels() {
+ pathAltitudeSliderLabel.text =
+ getString(R.string.path_height_format, pathAltitudeOffset)
+ rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt())
+ altitudeSliderLabel.text =
+ getString(R.string.ground_altitude_format, groundAltitude.toInt())
+ headingSliderLabel.text =
+ getString(R.string.heading_offset_format, headingOffset.toInt())
+ tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt())
+ speedSliderLabel.text =
+ getString(R.string.follow_speed_format, followSpeedMps.toInt())
+ }
+
+ private var currentHeading: Double? = null
private fun switchEnvironment(path: List) {
pauseAnimation()
@@ -269,9 +388,9 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
rangeSlider.value = 450f
altitudeSlider.value = 40f
tiltSlider.value = 75f
- rangeSliderLabel.text = "Camera Range: 450m"
- altitudeSliderLabel.text = "Ground Altitude: 40m"
- tiltSliderLabel.text = "Camera Tilt: 75°"
+ rangeSliderLabel.text = getString(R.string.camera_range_format, 450)
+ altitudeSliderLabel.text = getString(R.string.ground_altitude_format, 40)
+ tiltSliderLabel.text = getString(R.string.camera_tilt_format, 75)
} else {
cameraRange = 300.0
groundAltitude = 20.0
@@ -279,13 +398,13 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
rangeSlider.value = 300f
altitudeSlider.value = 20f
tiltSlider.value = 70f
- rangeSliderLabel.text = "Camera Range: 300m"
- altitudeSliderLabel.text = "Ground Altitude: 20m"
- tiltSliderLabel.text = "Camera Tilt: 70°"
+ rangeSliderLabel.text = getString(R.string.camera_range_format, 300)
+ altitudeSliderLabel.text = getString(R.string.ground_altitude_format, 20)
+ tiltSliderLabel.text = getString(R.string.camera_tilt_format, 70)
}
loadPathData(path)
- drawPathPolyline()
+ drawPathPolylines()
updateCameraPositionForDistance(0.0)
}
@@ -301,22 +420,81 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
}
}
- private fun drawPathPolyline() {
+ private fun drawPathPolylines() {
+ drawStaticRoutePolyline()
+ if (currentPath.isNotEmpty()) {
+ updateProgressPolyline(elapsedDistance, currentPath[0], 0)
+ }
+ }
+
+ private fun drawStaticRoutePolyline() {
val map = googleMap3D ?: return
- pathPolyline?.remove()
- val polyOptions = polylineOptions {
- strokeColor = Color.parseColor("#4285F4")
- strokeWidth = 10.0
- altitudeMode = AltitudeMode.RELATIVE_TO_GROUND
- path = currentPath.map { latLng ->
- latLngAltitude {
- latitude = latLng.latitude
- longitude = latLng.longitude
- altitude = 5.0
- }
- }
+ if (currentPath.isEmpty()) return
+
+ var pathAltitude = if (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) {
+ 0.0
+ } else {
+ pathAltitudeOffset
+ }
+ if (pathAltitudeMode == AltitudeMode.ABSOLUTE) {
+ pathAltitude = if (currentPath == RURAL_PATH) 40.0 else 15.0
+ }
+
+ val staticVertices = currentPath.map { latLng ->
+ LatLngAltitude(latLng.latitude, latLng.longitude, pathAltitude)
}
- pathPolyline = map.addPolyline(polyOptions)
+
+ val staticOptions = PolylineOptions().apply {
+ id = STATIC_ROUTE_POLYLINE_ID
+ path = staticVertices
+ strokeColor = Color.parseColor("#4285F4") // Wide blue route
+ strokeWidth = 16.0
+ zIndex = 1
+ altitudeMode = pathAltitudeMode
+ }
+
+ staticRoutePolyline = map.addPolyline(staticOptions)
+ }
+
+ private fun updateProgressPolyline(dist: Double, currentLatLng: LatLng, index: Int) {
+ val map = googleMap3D ?: return
+ if (currentPath.isEmpty() || totalDistance <= 0.0) return
+
+ var pathAltitude = if (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) {
+ 0.0
+ } else {
+ pathAltitudeOffset
+ }
+ if (pathAltitudeMode == AltitudeMode.ABSOLUTE) {
+ pathAltitude = if (currentPath == RURAL_PATH) 40.0 else 15.0
+ }
+
+ val progressAltitude = pathAltitude + 0.2
+
+ val progressCoordinates = ArrayList()
+ for (i in 0..index.coerceAtMost(currentPath.size - 1)) {
+ val pt = currentPath[i]
+ progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, progressAltitude))
+ }
+ progressCoordinates.add(
+ LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude)
+ )
+
+ if (progressCoordinates.size < 2) {
+ val startPt = currentPath[0]
+ progressCoordinates.add(LatLngAltitude(startPt.latitude, startPt.longitude, progressAltitude))
+ }
+
+ val progressOptions = PolylineOptions().apply {
+ id = PROGRESS_POLYLINE_ID
+ path = progressCoordinates
+ strokeColor = Color.parseColor("#9C27B0") // Narrow purple progress
+ strokeWidth = 8.0
+ zIndex = 2
+ altitudeMode = pathAltitudeMode
+ }
+
+ progressPolyline = map.addPolyline(progressOptions)
}
private fun startAnimation() {
@@ -324,48 +502,39 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
isPlaying = true
btnPlayPause.setIconResource(R.drawable.pause_24px)
- val frameCallback = object : Choreographer.FrameCallback {
- private var lastTimeNanos = 0L
-
- override fun doFrame(frameTimeNanos: Long) {
+ val frameDurationMs = 16L
+ animationRunnable = object : Runnable {
+ override fun run() {
if (!isPlaying) return
- if (lastTimeNanos == 0L) {
- lastTimeNanos = frameTimeNanos
- Choreographer.getInstance().postFrameCallback(this)
- return
- }
-
- val dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0
- lastTimeNanos = frameTimeNanos
-
- val stepDistance = followSpeedMps * dt
+ val stepDistance = followSpeedMps * (frameDurationMs / 1000.0)
elapsedDistance += stepDistance
if (elapsedDistance >= totalDistance) {
elapsedDistance = 0.0
}
- if (!isUserScrubbing) {
- progressSlider.value = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f)
+ if (!isUserScrubbing && totalDistance > 0) {
+ val progress = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f)
+ progressSlider.value = progress
}
- updateCameraPositionForDistance(elapsedDistance)
- Choreographer.getInstance().postFrameCallback(this)
+ updateCameraPositionForDistance(elapsedDistance)
+ animationHandler.postDelayed(this, frameDurationMs)
}
}
- Choreographer.getInstance().postFrameCallback(frameCallback)
+ animationHandler.post(animationRunnable!!)
}
private fun pauseAnimation() {
isPlaying = false
btnPlayPause.setIconResource(R.drawable.play_arrow_24px)
- animationJob?.cancel()
- animationJob = null
+ animationRunnable?.let {
+ animationHandler.removeCallbacks(it)
+ animationRunnable = null
+ }
}
- private var currentHeading: Double? = null
-
private fun updateCameraPositionForDistance(dist: Double) {
val map = googleMap3D ?: return
if (currentPath.isEmpty()) return
@@ -410,9 +579,13 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
}
map.setCamera(newCamera)
+ updateProgressPolyline(dist, currentLatLng, index)
}
companion object {
+ private const val STATIC_ROUTE_POLYLINE_ID = "path_following_static_route"
+ private const val PROGRESS_POLYLINE_ID = "path_following_progress_route"
+
// Urban Path (New York City - Central Park Block Circuit)
val URBAN_PATH = listOf(
LatLng(40.7783119, -73.9627630),
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
index 7c2e5564..c5de808c 100644
--- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
@@ -75,8 +75,27 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.milliseconds
+/**
+ * Suspends until the 3D map camera animation completes using [com.google.android.gms.maps3d.OnCameraAnimationEndListener].
+ */
+private suspend fun com.google.android.gms.maps3d.GoogleMap3D.awaitFlyCameraTo(options: FlyToOptions) = suspendCancellableCoroutine { continuation ->
+ setCameraAnimationEndListener {
+ setCameraAnimationEndListener(null)
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+ flyCameraTo(options)
+ continuation.invokeOnCancellation {
+ setCameraAnimationEndListener(null)
+ stopCameraAnimation()
+ }
+}
+
enum class AnimationApproach(val title: String) {
SIMPLE_FLY_TO("1. SDK Simple flyTo (Native Transition)"),
KEYFRAME_TOUR("2. Keyframe Queue (Multi-step Camera Tour)"),
@@ -285,6 +304,7 @@ fun AdvancedCameraAnimationScreen() {
tourJob = null
restartJob?.cancel()
restartJob = null
+ mapInstance?.setCameraAnimationEndListener(null)
mapInstance?.stopCameraAnimation()
}
@@ -311,8 +331,10 @@ fun AdvancedCameraAnimationScreen() {
tilt = 65.0
range = 600.0
}
- mapInstance?.flyCameraTo(FlyToOptions(targetCam, 1500L))
- isPlaying = false
+ tourJob = scope.launch(Dispatchers.Main) {
+ mapInstance?.awaitFlyCameraTo(FlyToOptions(targetCam, 1500L))
+ isPlaying = false
+ }
}
fun runKeyframeTour() {
@@ -342,8 +364,7 @@ fun AdvancedCameraAnimationScreen() {
altitude = 200.0
}
planeHeading = normalizeHeading(step.targetHeading + 180.0)
- mapInstance?.flyCameraTo(FlyToOptions(targetCam, step.durationMs))
- delay(step.durationMs.milliseconds)
+ mapInstance?.awaitFlyCameraTo(FlyToOptions(targetCam, step.durationMs))
}
is CameraKeyframe.DwellPause -> {
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 00000000..fa4ed510
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
+toolchainVersion=25
From 873161841bfc152229129eaee3650151c5973cd8 Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Fri, 21 Aug 2026 11:07:20 -0600
Subject: [PATCH 2/9] feat(fieldofview,datavisualization): polish FOV and Data
Visualization with collapsible cards and literate docs
---
.../control_panel_data_visualization.xml | 136 ++++---
.../layout/control_panel_field_of_view.xml | 181 +++++----
.../common/src/main/res/values/strings.xml | 12 +-
.../DataVisualizationActivity.java | 286 +++++++++++---
.../fieldofview/FieldOfViewActivity.java | 371 +++++++++++++-----
.../AdvancedCameraAnimationActivity.kt | 11 -
.../DataVisualizationActivity.kt | 198 ++++++++--
.../fieldofview/FieldOfViewActivity.kt | 177 ++++++++-
8 files changed, 1055 insertions(+), 317 deletions(-)
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml
index ad9d35c0..43767b1b 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml
@@ -31,79 +31,127 @@
+
-
-
-
+
-
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="2dp"
+ >
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml
index 67695a75..9025799e 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml
@@ -31,95 +31,142 @@
-
-
-
-
-
-
+
-
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
index 627f71bf..abbd2537 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
@@ -181,15 +181,23 @@
Play or pause animation
+ Flood Simulation ControlsFlood Elevation: +%1$.1f m (%2$.1f ft)
- 🚨 Flood Hazard (+%1$.0fm)
- ✅ Normal Tide Level
+ Flood Elevation: +10.0 m (32.8 ft)
+ 🌊 Baseline Tide
+ ⚠️ Minor Inundation
+ 🌊 Moderate Flooding
+ 🚨 Storm Surge (Cat 3)
+ ⛔ Extreme InundationContinuous Tide Simulation:▶ Start Simulation⏹ Stop Simulation
+ San Francisco Waterfront - Water Level: +%1$.1f m
+ Field of View ControlsField of View: %1$d°
+ Field of View: 45°FOV Presets:20° Tele45° Standard
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
index 8f3578d3..2b18ea0a 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
@@ -22,13 +22,15 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.view.MotionEvent;
+import android.view.View;
import android.view.ViewGroup;
-import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
import com.example.maps3dcommon.R;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
@@ -42,22 +44,50 @@
import com.google.android.gms.maps3d.model.Polygon;
import com.google.android.gms.maps3d.model.PolygonOptions;
import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
import com.google.android.material.slider.Slider;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
-import java.util.Locale;
/**
- * Showcases dynamic 3D volume extrusion in Google Maps 3D SDK (Java) by simulating elevated flood
- * tides.
+ * =================================================================================================
+ * Data Visualization: 3D Extruded Flood Simulation (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates how to render and dynamically animate volumetric 3D extruded polygons
+ * using the Google Maps 3D SDK.
+ *
+ * Key Concepts Demonstrated:
+ * 1. 3D Volumetric Polygon Extrusion:
+ * - Uses {@link PolygonOptions#setExtruded(boolean)} to generate 3D vertical walls extending
+ * from ground level up to an absolute altitude ceiling.
+ * - Configures {@link AltitudeMode#ABSOLUTE} so the polygon elevation represents true mean sea
+ * level (MSL) altitude rather than terrain-relative offsets.
+ *
+ * 2. Real-Time Elevation Updates & Animation:
+ * - Updates the polygon altitude in real-time in response to slider gestures or an automated
+ * continuous tide simulation loop.
+ * - Re-uses a static {@link PolygonOptions#setId(String)} to upsert the polygon in place within
+ * the Maps 3D rendering engine.
+ *
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Provides a bottom overlay card with dynamic flood elevation readouts and risk badges.
+ * - Features a header toggle with {@link MaterialButton} to expand/collapse controls for
+ * unobstructed 3D scene inspection.
*/
public class DataVisualizationActivity extends SampleBaseActivity {
+ // --- Constants & Geographical Bounds ---
+
+ /** Focal viewpoint centered on the San Francisco Embarcadero waterfront. */
public static final LatLng SF_FLOOD_CENTER = new LatLng(37.8025, -122.4030);
+
+ /** Stable identifier for upserting the flood polygon in the 3D map engine. */
private static final String POLYGON_ID = "flood_zone_polygon";
+ /** Boundary coordinates outlining the San Francisco waterfront flood study area. */
public static final List floodZoneCoords = Arrays.asList(
new double[]{37.805156, -122.403256},
new double[]{37.803370, -122.401287},
@@ -67,21 +97,49 @@ public class DataVisualizationActivity extends SampleBaseActivity {
new double[]{37.805156, -122.403256}
);
+ /** Translucent water body fill color. */
private final int waterFillColor = Color.argb(140, 230, 40, 40);
+
+ /** Opaque perimeter boundary stroke color. */
private final int waterStrokeColor = Color.argb(255, 180, 0, 0);
+
+ /** Width of the polygon boundary line in screen pixels. */
private final double waterStrokeWidth = 2.5;
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
private TextView floodDepthLabel;
private TextView floodRiskBadge;
private Slider floodSlider;
- private Button btnAnimateFlood;
+ private MaterialButton btnAnimateFlood;
+
+ // --- State Variables ---
private Polygon floodPolygon = null;
private double currentFloodElevation = 10.0;
+ private boolean isSimulating = false;
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
private final Handler simulationHandler = new Handler(Looper.getMainLooper());
private Runnable simulationRunnable;
- private boolean isSimulating = false;
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ // --- Base Activity Overrides ---
@NonNull
@Override
@@ -101,10 +159,18 @@ public Camera getInitialCamera() {
));
}
+ // --- Lifecycle & Initialization ---
+
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
+ }
+
ViewGroup container = findViewById(R.id.map_container);
if (container != null) {
getLayoutInflater().inflate(R.layout.control_panel_data_visualization, container, true);
@@ -116,11 +182,44 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
topBar.setNavigationOnClickListener(v -> finish());
}
+ initViews();
+ updateControlLabels(currentFloodElevation);
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
floodDepthLabel = findViewById(R.id.tv_flood_depth_label);
floodRiskBadge = findViewById(R.id.tv_flood_risk_badge);
floodSlider = findViewById(R.id.flood_slider);
btnAnimateFlood = findViewById(R.id.btn_animate_flood);
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
if (floodSlider != null) {
floodSlider.addOnChangeListener((slider, value, fromUser) -> {
if (fromUser) {
@@ -139,8 +238,77 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
}
});
}
+
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
}
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null) {
+ return;
+ }
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ int headerHeight = (cardHeader != null && cardHeader.getHeight() > 0)
+ ? cardHeader.getHeight()
+ : (int) (48 * getResources().getDisplayMetrics().density);
+ float targetTranslationY = (cardContent != null && cardContent.getHeight() > 0)
+ ? cardContent.getHeight()
+ : Math.max(0, controlsCard.getHeight() - headerHeight);
+ controlsCard.animate()
+ .translationY(targetTranslationY)
+ .alpha(0.9f)
+ .setDuration(300)
+ .start();
+ }
+
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ controlsCard.animate()
+ .translationY(0f)
+ .alpha(1.0f)
+ .setDuration(250)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
+ // --- 3D Map Setup & Extrusion Engine ---
+
@Override
public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
super.onMap3DViewReady(googleMap3D);
@@ -154,50 +322,28 @@ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
});
}
+ /**
+ * Updates the 3D polygon height and refreshes formatted textual status indicators.
+ *
+ * @param currentFloodHeightMeters Target sea level elevation in meters.
+ */
public void updateFloodElevation(double currentFloodHeightMeters) {
currentFloodElevation = currentFloodHeightMeters;
runOnUiThread(() -> {
- double feet = currentFloodHeightMeters * 3.28084;
- if (floodDepthLabel != null) {
- floodDepthLabel.setText(
- String.format(Locale.US, "Flood Elevation: +%.1f m (%.1f ft)", currentFloodHeightMeters,
- feet));
- }
+ updateControlLabels(currentFloodHeightMeters);
- if (floodRiskBadge != null) {
- if (currentFloodHeightMeters <= 2.0) {
- floodRiskBadge.setText("🌊 Baseline Tide");
- floodRiskBadge.setTextColor(Color.parseColor("#008800"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#2000AA00"));
- } else if (currentFloodHeightMeters <= 8.0) {
- floodRiskBadge.setText("⚠️ Minor Inundation");
- floodRiskBadge.setTextColor(Color.parseColor("#BB7700"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#20FFAA00"));
- } else if (currentFloodHeightMeters <= 20.0) {
- floodRiskBadge.setText("🌊 Moderate Flooding");
- floodRiskBadge.setTextColor(Color.parseColor("#0077CC"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#200088FF"));
- } else if (currentFloodHeightMeters <= 35.0) {
- floodRiskBadge.setText("🚨 Storm Surge (Cat 3)");
- floodRiskBadge.setTextColor(Color.parseColor("#DD4400"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF5500"));
- } else {
- floodRiskBadge.setText("⛔ Extreme Inundation");
- floodRiskBadge.setTextColor(Color.parseColor("#CC0000"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF0000"));
- }
+ if (googleMap3D == null) {
+ return;
}
- if (googleMap3D == null) {
- return;
- }
-
+ // Build 3D path vertices at the specified absolute altitude
List path = new ArrayList<>();
for (double[] coord : floodZoneCoords) {
path.add(new LatLngAltitude(coord[0], coord[1], currentFloodHeightMeters));
}
+ // Configure volumetric extruded polygon options
PolygonOptions options = new PolygonOptions();
options.setId(POLYGON_ID);
options.setPath(path);
@@ -213,14 +359,53 @@ public void updateFloodElevation(double currentFloodHeightMeters) {
if (floodPolygon != null) {
floodPolygon.setClickListener(() -> runOnUiThread(() -> Toast.makeText(
DataVisualizationActivity.this,
- String.format(Locale.US, "San Francisco Waterfront - Water Level: +%.1f m",
- currentFloodElevation),
+ getString(R.string.flood_toast_format, currentFloodElevation),
Toast.LENGTH_SHORT
).show()));
}
});
}
+ /**
+ * Refreshes textual status labels and risk severity badges based on water height.
+ */
+ private void updateControlLabels(double currentFloodHeightMeters) {
+ double feet = currentFloodHeightMeters * 3.28084;
+ if (floodDepthLabel != null) {
+ floodDepthLabel.setText(
+ getString(R.string.flood_elevation_format, currentFloodHeightMeters, feet));
+ }
+
+ if (floodRiskBadge != null) {
+ if (currentFloodHeightMeters <= 2.0) {
+ floodRiskBadge.setText(R.string.flood_risk_baseline);
+ floodRiskBadge.setTextColor(Color.parseColor("#008800"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#2000AA00"));
+ } else if (currentFloodHeightMeters <= 8.0) {
+ floodRiskBadge.setText(R.string.flood_risk_minor);
+ floodRiskBadge.setTextColor(Color.parseColor("#BB7700"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#20FFAA00"));
+ } else if (currentFloodHeightMeters <= 20.0) {
+ floodRiskBadge.setText(R.string.flood_risk_moderate);
+ floodRiskBadge.setTextColor(Color.parseColor("#0077CC"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#200088FF"));
+ } else if (currentFloodHeightMeters <= 35.0) {
+ floodRiskBadge.setText(R.string.flood_risk_storm_surge);
+ floodRiskBadge.setTextColor(Color.parseColor("#DD4400"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF5500"));
+ } else {
+ floodRiskBadge.setText(R.string.flood_risk_extreme);
+ floodRiskBadge.setTextColor(Color.parseColor("#CC0000"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF0000"));
+ }
+ }
+ }
+
+ // --- Automated Continuous Simulation Loop ---
+
+ /**
+ * Starts continuous incremental sea level rise simulation.
+ */
private void startSimulation() {
double maxVal = floodSlider != null ? floodSlider.getValueTo() : 100.0;
double minVal = floodSlider != null ? floodSlider.getValueFrom() : 0.0;
@@ -233,15 +418,15 @@ private void startSimulation() {
isSimulating = true;
if (btnAnimateFlood != null) {
- btnAnimateFlood.setText("⏹ Stop Simulation");
+ btnAnimateFlood.setText(R.string.stop_simulation);
}
simulationRunnable = new Runnable() {
@Override
public void run() {
- if (!isSimulating) {
- return;
- }
+ if (!isSimulating) {
+ return;
+ }
double currentMax = floodSlider != null ? floodSlider.getValueTo() : 100.0;
double newElevation = currentFloodElevation + 0.2;
@@ -262,6 +447,9 @@ public void run() {
simulationHandler.post(simulationRunnable);
}
+ /**
+ * Stops the ongoing sea level rise simulation loop.
+ */
private void stopSimulation() {
isSimulating = false;
if (simulationRunnable != null) {
@@ -269,13 +457,21 @@ private void stopSimulation() {
simulationRunnable = null;
}
if (btnAnimateFlood != null) {
- btnAnimateFlood.setText("▶ Start Simulation");
+ btnAnimateFlood.setText(R.string.start_simulation);
}
}
+ // --- Teardown ---
+
@Override
protected void onDestroy() {
stopSimulation();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (floodPolygon != null) {
+ floodPolygon.remove();
+ floodPolygon = null;
+ }
super.onDestroy();
}
}
+
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
index 2d5c352e..f2585ec2 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
@@ -16,15 +16,21 @@
package com.example.maps3djava.fieldofview;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.MotionEvent;
+import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
-import com.example.maps3d.common.UtilitiesKt;
import com.example.maps3dcommon.R;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
import com.google.android.gms.maps.model.LatLng;
@@ -32,122 +38,311 @@
import com.google.android.gms.maps3d.model.Camera;
import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
import com.google.android.material.slider.Slider;
/**
- * Showcases Field of View (FOV) perspective scaling in Google Maps 3D SDK (Java implementation).
+ * =================================================================================================
+ * Field of View (FOV) Perspective Scaling (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates perspective Field of View (FOV) scaling and optical dolly-zoom simulation
+ * using the Google Maps 3D SDK.
+ *
+ * Key Concepts Demonstrated:
+ * 1. Perspective Field of View (FOV) Adjustments:
+ * - Adjusts the camera distance (range) proportionally using trigonometric perspective projection
+ * math to simulate optical lens focal length changes (Telephoto, Standard, Wide, Ultra-Wide).
+ * - Formula: range = baseRange * (tan(baseFov / 2) / tan(targetFov / 2)).
+ *
+ * 2. Seamless Camera State Preservation:
+ * - Inspects the active live camera before adjusting perspective, ensuring custom panning,
+ * heading rotations, and tilt angles applied by user gestures are smoothly retained.
*
- * Features:
- * - Interactive FOV slider and quick presets preserving current active map location smoothly.
- * - Robust toValidCamera validation preventing out-of-range heading/tilt crash during manual map rotation.
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Features a bottom control card with dynamic FOV angle readout and quick preset buttons.
+ * - Supports expanding and collapsing the card header to maximize the visible 3D map scene.
+ * - Implements subtle UI idle auto-fade with touch-to-wake responsiveness.
*/
public class FieldOfViewActivity extends SampleBaseActivity {
- public static final LatLng SF_FINANCIAL_DISTRICT = new LatLng(37.7952, -122.4028);
+ // --- Constants & Perspective Geometry ---
+
+ /** Focal landmark centered near the San Francisco Transamerica Pyramid & Financial District. */
+ public static final LatLng SF_FINANCIAL_DISTRICT = new LatLng(37.7952, -122.4028);
+
+ /** Baseline field of view angle in degrees (human eye / standard focal length). */
+ private static final double BASE_FOV_DEGREES = 45.0;
+
+ /** Baseline camera range in meters corresponding to the standard 45° FOV baseline. */
+ private static final double BASE_RANGE_METERS = 800.0;
- private TextView fovSliderLabel;
- private Slider fovSlider;
- private double currentFov = 45.0;
+ /** Minimum optical range boundary in meters. */
+ private static final double MIN_RANGE_METERS = 150.0;
- @NonNull
- @Override
- public String getTAG() {
- return "FieldOfViewActivity";
+ /** Maximum optical range boundary in meters. */
+ private static final double MAX_RANGE_METERS = 3000.0;
+
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+ private TextView fovSliderLabel;
+ private Slider fovSlider;
+
+ // --- State Variables ---
+
+ private double currentFov = 45.0;
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
}
+ };
+
+ // --- Base Activity Overrides ---
- @NonNull
- @Override
- public Camera getInitialCamera() {
- return UtilitiesKt.toValidCamera(new Camera(
- new LatLngAltitude(SF_FINANCIAL_DISTRICT.latitude, SF_FINANCIAL_DISTRICT.longitude, 150.0),
- 45.0,
- 65.0,
- 0.0,
- 800.0
- ));
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "FieldOfViewActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(new Camera(
+ new LatLngAltitude(SF_FINANCIAL_DISTRICT.latitude, SF_FINANCIAL_DISTRICT.longitude, 150.0),
+ 45.0,
+ 65.0,
+ 0.0,
+ BASE_RANGE_METERS
+ ));
+ }
+
+ // --- Lifecycle & Initialization ---
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
}
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
+ ViewGroup container = findViewById(R.id.map_container);
+ if (container != null) {
+ getLayoutInflater().inflate(R.layout.control_panel_field_of_view, container, true);
+ }
+
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_field_of_view);
+ topBar.setNavigationOnClickListener(v -> finish());
+ }
- // Inflate FOV control panel overlay into map container managed by SampleBaseActivity
- ViewGroup container = findViewById(R.id.map_container);
- if (container != null) {
- getLayoutInflater().inflate(R.layout.control_panel_field_of_view, container, true);
+ initViews();
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
+ fovSliderLabel = findViewById(R.id.fov_slider_label);
+ fovSlider = findViewById(R.id.fov_slider);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
}
+ });
+ }
- MaterialToolbar topBar = findViewById(R.id.top_bar);
- if (topBar != null) {
- topBar.setTitle("Field of View (FOV)");
- topBar.setNavigationOnClickListener(v -> finish());
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
}
+ });
+ }
- fovSliderLabel = findViewById(R.id.fov_slider_label);
- fovSlider = findViewById(R.id.fov_slider);
+ if (fovSlider != null) {
+ fovSlider.addOnChangeListener((slider, value, fromUser) -> updateFov(value));
+ }
+ Button btnTele = findViewById(R.id.btn_fov_telephoto);
+ if (btnTele != null) {
+ btnTele.setOnClickListener(v -> {
if (fovSlider != null) {
- fovSlider.addOnChangeListener((slider, value, fromUser) -> updateFov(value));
+ fovSlider.setValue(20.0f);
}
+ });
+ }
- Button btnTele = findViewById(R.id.btn_fov_telephoto);
- if (btnTele != null) {
- btnTele.setOnClickListener(v -> { if (fovSlider != null) fovSlider.setValue(20.0f); });
+ Button btnStd = findViewById(R.id.btn_fov_standard);
+ if (btnStd != null) {
+ btnStd.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(45.0f);
}
+ });
+ }
- Button btnStd = findViewById(R.id.btn_fov_standard);
- if (btnStd != null) {
- btnStd.setOnClickListener(v -> { if (fovSlider != null) fovSlider.setValue(45.0f); });
+ Button btnWide = findViewById(R.id.btn_fov_wide);
+ if (btnWide != null) {
+ btnWide.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(90.0f);
}
+ });
+ }
- Button btnWide = findViewById(R.id.btn_fov_wide);
- if (btnWide != null) {
- btnWide.setOnClickListener(v -> { if (fovSlider != null) fovSlider.setValue(90.0f); });
+ Button btnUltra = findViewById(R.id.btn_fov_ultrawide);
+ if (btnUltra != null) {
+ btnUltra.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(120.0f);
}
+ });
+ }
- Button btnUltra = findViewById(R.id.btn_fov_ultrawide);
- if (btnUltra != null) {
- btnUltra.setOnClickListener(v -> { if (fovSlider != null) fovSlider.setValue(120.0f); });
- }
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
}
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ android.transition.TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ }
- @Override
- public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
- super.onMap3DViewReady(googleMap3D);
- googleMap3D.setOnMapReadyListener(sceneReadiness -> {
- googleMap3D.setOnMapReadyListener(null);
- runOnUiThread(() -> updateFov((float) currentFov));
- });
- }
-
- private void updateFov(float fovAngle) {
- currentFov = fovAngle;
- runOnUiThread(() -> {
- if (fovSliderLabel != null) {
- fovSliderLabel.setText("Field of View: " + (int) fovAngle + "°");
- }
- });
-
- if (googleMap3D != null) {
- Camera liveCam = googleMap3D.getCamera() != null ? UtilitiesKt.toValidCamera(googleMap3D.getCamera()) : null;
- Camera currCam = (liveCam != null && (Math.abs(liveCam.getCenter().getLatitude()) > 0.001 || Math.abs(liveCam.getCenter().getLongitude()) > 0.001))
- ? liveCam
- : getInitialCamera();
- double baseFovRad = Math.toRadians(45.0 / 2.0);
- double targetFovRad = Math.toRadians(fovAngle / 2.0);
-
- double targetRange = 800.0 * Math.tan(baseFovRad) / Math.tan(targetFovRad);
- if (targetRange < 150.0) targetRange = 150.0;
- if (targetRange > 3000.0) targetRange = 3000.0;
-
- Camera updatedCam = UtilitiesKt.toValidCamera(new Camera(
- currCam.getCenter(),
- currCam.getHeading(),
- currCam.getTilt(),
- currCam.getRoll(),
- targetRange
- ));
- googleMap3D.setCamera(updatedCam);
- }
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
}
+ android.transition.TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
+ // --- 3D Map Setup & Perspective Scaling Engine ---
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setOnMapReadyListener(sceneReadiness -> {
+ googleMap3D.setOnMapReadyListener(null);
+ runOnUiThread(() -> updateFov((float) currentFov));
+ });
+ }
+
+ /**
+ * Calculates and applies the optical dolly-zoom perspective range for the specified FOV angle.
+ *
+ * @param fovAngle Perspective field of view angle in degrees (e.g. 15° to 120°).
+ */
+ private void updateFov(float fovAngle) {
+ currentFov = fovAngle;
+ runOnUiThread(() -> {
+ if (fovSliderLabel != null) {
+ fovSliderLabel.setText(getString(R.string.field_of_view_format, (int) fovAngle));
+ }
+ });
+
+ if (googleMap3D != null) {
+ Camera liveCam = googleMap3D.getCamera() != null ? toValidCamera(googleMap3D.getCamera()) : null;
+ Camera currCam = (liveCam != null && (Math.abs(liveCam.getCenter().getLatitude()) > 0.001
+ || Math.abs(liveCam.getCenter().getLongitude()) > 0.001))
+ ? liveCam
+ : getInitialCamera();
+
+ // Optical perspective transformation: range = baseRange * tan(baseFov/2) / tan(targetFov/2)
+ double baseFovRad = Math.toRadians(BASE_FOV_DEGREES / 2.0);
+ double targetFovRad = Math.toRadians(fovAngle / 2.0);
+
+ double targetRange = BASE_RANGE_METERS * Math.tan(baseFovRad) / Math.tan(targetFovRad);
+ if (targetRange < MIN_RANGE_METERS) {
+ targetRange = MIN_RANGE_METERS;
+ }
+ if (targetRange > MAX_RANGE_METERS) {
+ targetRange = MAX_RANGE_METERS;
+ }
+
+ Camera updatedCam = toValidCamera(new Camera(
+ currCam.getCenter(),
+ currCam.getHeading(),
+ currCam.getTilt(),
+ currCam.getRoll(),
+ targetRange
+ ));
+ googleMap3D.setCamera(updatedCam);
+ }
+ }
+
+ // --- Teardown ---
+
+ @Override
+ protected void onDestroy() {
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ super.onDestroy();
+ }
}
+
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
index 58415873..3f718a1a 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
@@ -19,7 +19,6 @@ package com.example.maps3dkotlin.advancedcameraanimation
import android.os.Bundle
import android.view.ViewGroup
import android.widget.Button
-import android.widget.RadioGroup
import androidx.lifecycle.lifecycleScope
import com.example.maps3d.common.RouteEngine
import com.example.maps3dcommon.R
@@ -152,16 +151,6 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() {
setNavigationOnClickListener { finish() }
}
- findViewById(R.id.rg_approach)?.setOnCheckedChangeListener { _, checkedId ->
- selectedApproach = when (checkedId) {
- R.id.rb_simple_flyto -> AnimationApproach.SIMPLE_FLY_TO
- R.id.rb_keyframe_tour -> AnimationApproach.KEYFRAME_TOUR
- R.id.rb_orbit_spin -> AnimationApproach.ORBIT_360_SPIN
- else -> AnimationApproach.DISPATCHER_FRAME_LOOP
- }
- resetAndRestartTour()
- }
-
findViewById
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
index abbd2537..80b49990 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
@@ -131,6 +131,7 @@
Reset
+ 3D Map Mode ControlsMap ModeRoadmapHybrid
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
index 76dc8340..b77a592a 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
@@ -16,12 +16,20 @@
package com.example.maps3djava.roadmapmode;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.transition.TransitionManager;
+import android.view.MotionEvent;
+import android.view.View;
import android.view.ViewGroup;
import android.widget.RadioGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
import com.example.maps3dcommon.R;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
@@ -31,16 +39,67 @@
import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.gms.maps3d.model.Map3DMode;
import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
/**
- * Showcases 3D Roadmap mode in Google Maps 3D SDK (Java implementation).
- * Features: - Switching between ROADMAP, HYBRID, and SATELLITE 3D render modes focused on San
- * Francisco.
+ * =================================================================================================
+ * 3D Roadmap Mode & Render Style Switching (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates switching between distinct visual rendering modes provided by the
+ * Google Maps 3D SDK:
+ *
+ * Key Concepts Demonstrated:
+ * 1. 3D Map Rendering Modes ({@link Map3DMode}):
+ * - {@link Map3DMode#ROADMAP}: High-contrast 3D vector street network with clean white building
+ * massings, road labels, and stylized transit geometry.
+ * - {@link Map3DMode#HYBRID}: High-resolution 3D photorealistic mesh overlaid with prominent
+ * vector road networks, street names, and point-of-interest labels.
+ * - {@link Map3DMode#SATELLITE}: Pure photorealistic 3D mesh rendering without overlay labels
+ * or vector lines, ideal for cinematic aerial exploration.
+ *
+ * 2. Camera Stability & Safe Angle Validation:
+ * - Configures a dramatic 3D perspective centered on the San Francisco Financial District with
+ * {@link com.example.maps3d.common.UtilitiesKt#toValidCamera(Camera)}.
+ *
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Bottom control card with quick radio button switching between map rendering styles.
+ * - Expandable / collapsible header bar for unobstructed 3D scene inspection.
+ * - Subtle UI idle auto-fade with touch-to-wake responsiveness.
*/
public class RoadmapModeActivity extends SampleBaseActivity {
+ // --- Constants & Geographical Bounds ---
+
+ /** Focal landmark centered on the San Francisco Financial District. */
public static final LatLng SF_LOCATION = new LatLng(37.7915, -122.4010);
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+ private RadioGroup rgMapMode;
+
+ // --- State Variables ---
+
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ // --- Base Activity Overrides ---
+
@NonNull
@Override
public String getTAG() {
@@ -50,20 +109,27 @@ public String getTAG() {
@NonNull
@Override
public Camera getInitialCamera() {
- return new Camera(
+ return toValidCamera(new Camera(
new LatLngAltitude(SF_LOCATION.latitude, SF_LOCATION.longitude, 250.0),
45.0,
65.0,
0.0,
800.0
- );
+ ));
}
+ // --- Lifecycle & Initialization ---
+
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- // Inflate roadmap control panel overlay into map container managed by SampleBaseActivity
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
+ }
+
ViewGroup container = findViewById(R.id.map_container);
if (container != null) {
getLayoutInflater().inflate(R.layout.control_panel_roadmap_mode, container, true);
@@ -71,34 +137,132 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
MaterialToolbar topBar = findViewById(R.id.top_bar);
if (topBar != null) {
- topBar.setTitle("3D Roadmap Mode");
+ topBar.setTitle(R.string.feature_title_roadmap_mode);
topBar.setNavigationOnClickListener(v -> finish());
}
- RadioGroup rgMapMode = findViewById(R.id.rg_map_mode);
+ initViews();
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+ rgMapMode = findViewById(R.id.rg_map_mode);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
if (rgMapMode != null) {
rgMapMode.setOnCheckedChangeListener((group, checkedId) -> {
- if (googleMap3D != null) {
- if (checkedId == R.id.rb_roadmap) {
- googleMap3D.setMapMode(Map3DMode.ROADMAP);
- } else if (checkedId == R.id.rb_hybrid) {
- googleMap3D.setMapMode(Map3DMode.HYBRID);
- } else if (checkedId == R.id.rb_satellite) {
- googleMap3D.setMapMode(Map3DMode.SATELLITE);
- }
+ if (googleMap3D == null) {
+ return;
+ }
+ if (checkedId == R.id.rb_roadmap) {
+ googleMap3D.setMapMode(Map3DMode.ROADMAP);
+ } else if (checkedId == R.id.rb_hybrid) {
+ googleMap3D.setMapMode(Map3DMode.HYBRID);
+ } else if (checkedId == R.id.rb_satellite) {
+ googleMap3D.setMapMode(Map3DMode.SATELLITE);
}
});
}
+
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ }
+
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
}
+ // --- 3D Map Setup ---
+
@Override
public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
super.onMap3DViewReady(googleMap3D);
- googleMap3D.setOnMapReadyListener((map) -> {
+ googleMap3D.setOnMapReadyListener(sceneReadiness -> {
googleMap3D.setOnMapReadyListener(null);
googleMap3D.setMapMode(Map3DMode.ROADMAP);
googleMap3D.setCamera(getInitialCamera());
});
+ }
+ // --- Teardown ---
+
+ @Override
+ protected void onDestroy() {
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ super.onDestroy();
}
}
+
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/roadmapmode/RoadmapModeActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/roadmapmode/RoadmapModeActivity.kt
index acf45c21..7ac4db40 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/roadmapmode/RoadmapModeActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/roadmapmode/RoadmapModeActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
@@ -17,8 +17,15 @@
package com.example.maps3dkotlin.roadmapmode
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.transition.TransitionManager
+import android.view.MotionEvent
+import android.view.View
import android.view.ViewGroup
import android.widget.RadioGroup
+import androidx.cardview.widget.CardView
+import com.example.maps3d.common.toValidCamera
import com.example.maps3dcommon.R
import com.example.maps3dkotlin.sampleactivity.SampleBaseActivity
import com.google.android.gms.maps.model.LatLng
@@ -28,62 +35,194 @@ import com.google.android.gms.maps3d.model.Map3DMode
import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.button.MaterialButton
/**
- * Showcases 3D Roadmap mode in Google Maps 3D SDK focused on San Francisco.
+ * =================================================================================================
+ * 3D Roadmap Mode & Render Style Switching (Kotlin)
+ * =================================================================================================
*
- * Features:
- * - Switching between ROADMAP (Vector 3D Buildings & Street Layout), HYBRID, and SATELLITE modes.
+ * This sample demonstrates switching between distinct visual rendering modes provided by the
+ * Google Maps 3D SDK:
+ *
+ * Key Concepts Demonstrated:
+ * 1. 3D Map Rendering Modes ([Map3DMode]):
+ * - [Map3DMode.ROADMAP]: High-contrast 3D vector street network with clean white building
+ * massings, road labels, and stylized transit geometry.
+ * - [Map3DMode.HYBRID]: High-resolution 3D photorealistic mesh overlaid with prominent vector
+ * road networks, street names, and point-of-interest labels.
+ * - [Map3DMode.SATELLITE]: Pure photorealistic 3D mesh rendering without overlay labels or
+ * vector lines, ideal for cinematic aerial exploration.
+ *
+ * 2. Camera Stability & Safe Angle Validation:
+ * - Configures a dramatic 3D perspective centered on the San Francisco Financial District with
+ * [toValidCamera].
+ *
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Bottom control card with quick radio button switching between map rendering styles.
+ * - Expandable / collapsible header bar for unobstructed 3D scene inspection.
+ * - Subtle UI idle auto-fade with touch-to-wake responsiveness.
*/
class RoadmapModeActivity : SampleBaseActivity() {
- override val TAG = "RoadmapModeActivity"
-
- override val initialCamera: Camera
- get() = camera {
- center = latLngAltitude {
- latitude = SF_LOCATION.latitude
- longitude = SF_LOCATION.longitude
- altitude = 250.0
- }
- heading = 45.0
- tilt = 65.0
- roll = 0.0
- range = 800.0
- }
+ override val TAG = "RoadmapModeActivity"
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
+ override val initialCamera: Camera
+ get() = camera {
+ center = latLngAltitude {
+ latitude = SF_LOCATION.latitude
+ longitude = SF_LOCATION.longitude
+ altitude = 250.0
+ }
+ heading = 45.0
+ tilt = 65.0
+ roll = 0.0
+ range = 800.0
+ }.toValidCamera()
- // Inflate roadmap control panel overlay into map container managed by SampleBaseActivity
- findViewById(R.id.map_container)?.let { container ->
- layoutInflater.inflate(R.layout.control_panel_roadmap_mode, container, true)
- }
+ // --- UI Elements ---
- findViewById(R.id.top_bar)?.apply {
- title = "3D Roadmap Mode"
- setNavigationOnClickListener { finish() }
- }
+ private var controlsCard: CardView? = null
+ private var cardHeader: View? = null
+ private var cardContent: View? = null
+ private var btnCollapse: MaterialButton? = null
+ private var rgMapMode: RadioGroup? = null
- findViewById(R.id.rg_map_mode)?.setOnCheckedChangeListener { _, checkedId ->
- googleMap3D?.let { map ->
- when (checkedId) {
- R.id.rb_roadmap -> map.setMapMode(Map3DMode.ROADMAP)
- R.id.rb_hybrid -> map.setMapMode(Map3DMode.HYBRID)
- R.id.rb_satellite -> map.setMapMode(Map3DMode.SATELLITE)
- }
- }
- }
+ // --- State Variables ---
+
+ private var isCollapsed = false
+
+ // --- Handlers & Runnables ---
+
+ private val fadeHandler = Handler(Looper.getMainLooper())
+ private val fadeOutRunnable = Runnable {
+ if (!isCollapsed) {
+ controlsCard?.animate()
+ ?.alpha(0.85f)
+ ?.setDuration(400)
+ ?.start()
}
+ }
+
+ // --- Lifecycle & Initialization ---
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Hide base pill scroll view as this activity manages its own overlay card
+ findViewById(R.id.control_scroll_view)?.visibility = View.GONE
+
+ // Inflate roadmap control panel overlay into map container managed by SampleBaseActivity
+ findViewById(R.id.map_container)?.let { container ->
+ layoutInflater.inflate(R.layout.control_panel_roadmap_mode, container, true)
+ }
+
+ findViewById(R.id.top_bar)?.apply {
+ setTitle(R.string.feature_title_roadmap_mode)
+ setNavigationOnClickListener { finish() }
+ }
+
+ initViews()
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private fun initViews() {
+ controlsCard = findViewById(R.id.control_panel)
+ cardHeader = findViewById(R.id.card_header)
+ cardContent = findViewById(R.id.card_content)
+ btnCollapse = findViewById(R.id.btn_collapse)
+ rgMapMode = findViewById(R.id.rg_map_mode)
- override fun onMapReady(googleMap3D: GoogleMap3D) {
- super.onMapReady(googleMap3D)
- googleMap3D.setMapMode(Map3DMode.ROADMAP)
- googleMap3D.setCamera(initialCamera)
+ btnCollapse?.setOnClickListener {
+ if (isCollapsed) expandControls() else collapseControls()
}
- companion object {
- // San Francisco Financial District
- val SF_LOCATION = LatLng(37.7915, -122.4010)
+ cardHeader?.setOnClickListener {
+ if (isCollapsed) expandControls() else collapseControls()
}
+
+ rgMapMode?.setOnCheckedChangeListener { _, checkedId ->
+ googleMap3D?.let { map ->
+ when (checkedId) {
+ R.id.rb_roadmap -> map.setMapMode(Map3DMode.ROADMAP)
+ R.id.rb_hybrid -> map.setMapMode(Map3DMode.HYBRID)
+ R.id.rb_satellite -> map.setMapMode(Map3DMode.SATELLITE)
+ }
+ }
+ }
+
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L)
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private fun collapseControls() {
+ val card = controlsCard ?: return
+ val content = cardContent ?: return
+ isCollapsed = true
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ btnCollapse?.setIconResource(R.drawable.expand_less_24px)
+ btnCollapse?.contentDescription = getString(R.string.expand_controls)
+
+ TransitionManager.beginDelayedTransition(card)
+ content.visibility = View.GONE
+ }
+
+ /**
+ * Expands the control card back to its full height.
+ */
+ private fun expandControls() {
+ val card = controlsCard ?: return
+ val content = cardContent ?: return
+ isCollapsed = false
+ btnCollapse?.setIconResource(R.drawable.expand_more_24px)
+ btnCollapse?.contentDescription = getString(R.string.collapse_controls)
+
+ TransitionManager.beginDelayedTransition(card)
+ content.visibility = View.VISIBLE
+
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L)
+ }
+
+ override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
+ if (ev?.action == MotionEvent.ACTION_DOWN || ev?.action == MotionEvent.ACTION_MOVE) {
+ if (!isCollapsed) {
+ controlsCard?.animate()
+ ?.alpha(1.0f)
+ ?.setDuration(150)
+ ?.start()
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L)
+ }
+ }
+ return super.dispatchTouchEvent(ev)
+ }
+
+ // --- 3D Map Setup ---
+
+ override fun onMapReady(googleMap3D: GoogleMap3D) {
+ super.onMapReady(googleMap3D)
+ googleMap3D.setMapMode(Map3DMode.ROADMAP)
+ googleMap3D.setCamera(initialCamera)
+ }
+
+ // --- Teardown ---
+
+ override fun onDestroy() {
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ super.onDestroy()
+ }
+
+ companion object {
+ /** Focal landmark centered on the San Francisco Financial District. */
+ val SF_LOCATION = LatLng(37.7915, -122.4010)
+ }
}
+
From f947b517c5b121612c527a61d8736ceb63599440 Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Fri, 21 Aug 2026 13:12:57 -0600
Subject: [PATCH 4/9] feat(routes): polish Routes API sample with collapsible
control panel, auto-fade, and literate docs
---
...ty_routes.xml => control_panel_routes.xml} | 98 +--
.../common/src/main/res/values/strings.xml | 2 +
.../maps3djava/routes/RoutesActivity.java | 648 ++++++++++-------
.../maps3dkotlin/routes/RoutesActivity.kt | 662 +++++++++++-------
4 files changed, 855 insertions(+), 555 deletions(-)
rename Maps3DSamples/ApiDemos/common/src/main/res/layout/{activity_routes.xml => control_panel_routes.xml} (70%)
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
similarity index 70%
rename from Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml
rename to Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
index 014aa8b2..583a8265 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
@@ -14,61 +14,71 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -99,7 +109,6 @@
android:value="0.0"
app:labelBehavior="gone"
/>
-
@@ -163,6 +172,5 @@
/>
-
-
-
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
index 80b49990..29ec21da 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
@@ -121,6 +121,8 @@
Fly to NYC
+ Route Simulation Controls
+ Route is still loading…Play or pause route animationCamera Altitude: %1$dmVehicle Speed: %1$dm/s
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
index e8b7d7c4..84743daa 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
@@ -17,22 +17,28 @@
package com.example.maps3djava.routes;
import static com.example.maps3d.common.UtilitiesKt.toHeading;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+import android.annotation.SuppressLint;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.transition.TransitionManager;
import android.util.Log;
+import android.view.MotionEvent;
import android.view.View;
+import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
-import androidx.core.view.WindowCompat;
+import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
+import com.example.maps3d.common.OahuRouteData;
import com.example.maps3d.common.PositionAndHeading;
import com.example.maps3d.common.RouteEngine;
-import com.example.maps3d.common.OahuRouteData;
import com.example.maps3dcommon.R;
import com.example.maps3djava.BuildConfig;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
@@ -41,6 +47,7 @@
import com.google.android.gms.maps3d.OnMap3DViewReadyCallback;
import com.google.android.gms.maps3d.model.AltitudeMode;
import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.gms.maps3d.model.Map3DMode;
import com.google.android.gms.maps3d.model.Model;
import com.google.android.gms.maps3d.model.ModelOptions;
@@ -48,7 +55,6 @@
import com.google.android.gms.maps3d.model.Polyline;
import com.google.android.gms.maps3d.model.PolylineOptions;
import com.google.android.gms.maps3d.model.Vector3D;
-import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.slider.Slider;
@@ -58,202 +64,358 @@
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
/**
- * A premium View-based sample activity demonstrating cross-product integration with the Routes API in Java.
+ * Demonstrates cross-product integration between Google Maps Platform Routes API and GoogleMap3D.
*
- * This sample executes background threads to fetch driving routes in Honolulu, parses the encoded polyline,
- * renders the route line on [GoogleMap3D], loads a 3D model (.glb), and implements an android.os.Handler
- * framework to animate the car smoothly along the path with real-time camera tracking.
+ *
Key Architecture Highlights:
+ *
+ *
Async Route Calculation: Dispatches background tasks via {@link RouteRepository} to
+ * fetch driving directions in Honolulu, falling back gracefully to bundled Oahu coordinates
+ * when offline or unauthenticated.
+ *
3D Polyline Visualization: Renders clamped-to-ground 3D route paths that conform
+ * seamlessly to elevation and terrain variations.
+ *
3D glTF Model Anchoring: Loads a 3D vehicle model and updates its geographic
+ * coordinates and heading on each tick along the path.
+ *
Dynamic Camera Tracking: Synchronizes the 3D camera to follow behind the vehicle
+ * with configurable altitude, speed, and yaw offsets.
+ *
Ergonomic Collapsible UI: Employs an animated Material3 floating card with
+ * touch-aware auto-fade after 3 seconds of inactivity.
Async Route Calculation: Dispatches background tasks via {@link RouteRepository} to
+ * fetch driving directions in Honolulu, falling back gracefully to bundled Oahu coordinates
+ * when offline or unauthenticated.
+ *
3D Polyline Visualization: Renders clamped-to-ground 3D route paths that conform
+ * seamlessly to elevation and terrain variations.
+ *
3D glTF Model Anchoring: Loads a 3D vehicle model and updates its geographic
+ * coordinates and heading on each tick along the path.
+ *
Dynamic Camera Tracking: Synchronizes the 3D camera to follow behind the vehicle
+ * with configurable altitude, speed, and yaw offsets.
+ *
Ergonomic Collapsible UI: Employs an animated Material3 floating card with
+ * touch-aware auto-fade after 3 seconds of inactivity.
+ *
*/
class RoutesActivity : SampleBaseActivity() {
- override val TAG = "RoutesActivity"
- // Honolulu Overview Camera looking over the beautiful island of Oahu.
- override val initialCamera: Camera = camera {
+ companion object {
+ private const val FADE_DELAY_MS = 3000L
+ private const val ACTIVE_ALPHA = 1.0f
+ private const val FADED_ALPHA = 0.85f
+ }
+
+ override val TAG = "RoutesActivity"
+
+ // Honolulu Overview starting camera
+ override val initialCamera: Camera
+ get() =
+ camera {
center = latLngAltitude {
- latitude = 21.348567
- longitude = -157.803961
- altitude = 0.0
+ latitude = 21.348567
+ longitude = -157.803961
+ altitude = 0.0
}
heading = 38.6
tilt = 45.0
range = 20000.0
- }
+ }.toValidCamera()
- // View Bindings
- private lateinit var btnPlayPause: MaterialButton
- private lateinit var progressSlider: Slider
- private lateinit var rangeSlider: Slider
- private lateinit var rangeSliderLabel: TextView
- private lateinit var speedSlider: Slider
- private lateinit var speedSliderLabel: TextView
- private lateinit var headingSlider: Slider
- private lateinit var headingSliderLabel: TextView
-
- // Core State Variables
- private val routeRepository = RouteRepository()
- private var decodedRoute: List = emptyList()
- private var cumulativeDistances: DoubleArray = doubleArrayOf(0.0)
- private var totalDistance: Double = 0.0
- private var elapsedDistance: Double = 0.0
-
- private var isPlaying = false
- private var isUserScrubbing = false
-
- // Sliders Values
- private var cameraRange = 1500f // Slider range: 200m to 5000m
- private var vehicleSpeedMps = 150f // Slider range: 10m/s to 500m/s
- private var yawOffset = 0f // Slider range: -180° to 180°
-
- // Map References
- private var routePolyline: Polyline? = null
- private var vehicleModel: Model? = null
-
- // Background Coroutine Jobs
- private var animationJob: Job? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- // Initialize window flags and custom layouts before map callback gets triggered
- WindowCompat.setDecorFitsSystemWindows(window, false)
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_routes)
-
- // Re-bind map3DView to the new active instance in activity_routes.xml and forward lifecycle
- map3DView = findViewById(R.id.map3dView)
- map3DView.onCreate(savedInstanceState)
- map3DView.getMap3DViewAsync(this)
-
- // Override toolbar back action
- findViewById(R.id.top_bar).apply {
- title = getString(R.string.feature_title_routes_api)
- setNavigationOnClickListener { finish() }
- }
+ // --- View References ---
- // Bind control views
- btnPlayPause = findViewById(R.id.btn_play_pause)
- progressSlider = findViewById(R.id.progress_slider)
- rangeSlider = findViewById(R.id.range_slider)
- rangeSliderLabel = findViewById(R.id.range_slider_label)
- speedSlider = findViewById(R.id.speed_slider)
- speedSliderLabel = findViewById(R.id.speed_slider_label)
- headingSlider = findViewById(R.id.heading_slider)
- headingSliderLabel = findViewById(R.id.heading_slider_label)
-
- setupControls()
- }
+ private var controlsCard: CardView? = null
+ private var cardHeader: View? = null
+ private var cardContent: View? = null
+ private var btnCollapse: MaterialButton? = null
- /**
- * Registers interactive callbacks for playback buttons and material sliders.
- */
- private fun setupControls() {
- btnPlayPause.setOnClickListener {
- if (decodedRoute.isEmpty()) {
- Toast.makeText(this, "Route is still loading...", Toast.LENGTH_SHORT).show()
- return@setOnClickListener
- }
- togglePlayback(!isPlaying)
- }
+ private var btnPlayPause: MaterialButton? = null
+ private var progressSlider: Slider? = null
+ private var rangeSlider: Slider? = null
+ private var rangeSliderLabel: TextView? = null
+ private var speedSlider: Slider? = null
+ private var speedSliderLabel: TextView? = null
+ private var headingSlider: Slider? = null
+ private var headingSliderLabel: TextView? = null
- // Let the user scrub the progress slider manually
- progressSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
- override fun onStartTrackingTouch(slider: Slider) {
- isUserScrubbing = true
- }
+ // --- State Variables ---
- override fun onStopTrackingTouch(slider: Slider) {
- isUserScrubbing = false
- elapsedDistance = totalDistance * slider.value.toDouble()
- updateVehiclePositionAndCamera()
- }
- })
+ private val routeRepository = RouteRepository()
+ private var decodedRoute: List = emptyList()
+ private var cumulativeDistances: DoubleArray = doubleArrayOf(0.0)
+ private var totalDistance: Double = 0.0
+ private var elapsedDistance: Double = 0.0
- progressSlider.addOnChangeListener { _, value, fromUser ->
- if (fromUser && isUserScrubbing) {
- elapsedDistance = totalDistance * value.toDouble()
- updateVehiclePositionAndCamera()
- }
- }
+ private var isPlaying = false
+ private var isUserScrubbing = false
+ private var isCollapsed = false
- // Initialize Slider Labels and Listeners
- rangeSliderLabel.text = getString(R.string.camera_altitude_format, cameraRange.toInt())
- rangeSlider.value = cameraRange
- rangeSlider.addOnChangeListener { _, value, _ ->
- cameraRange = value
- rangeSliderLabel.text = getString(R.string.camera_altitude_format, value.toInt())
- updateVehiclePositionAndCamera()
- }
+ // --- Slider Parameters ---
- speedSliderLabel.text = getString(R.string.vehicle_speed_format, vehicleSpeedMps.toInt())
- speedSlider.value = vehicleSpeedMps
- speedSlider.addOnChangeListener { _, value, _ ->
- vehicleSpeedMps = value
- speedSliderLabel.text = getString(R.string.vehicle_speed_format, value.toInt())
- }
+ private var cameraRange = 1500f // Range: 200m to 5000m
+ private var vehicleSpeedMps = 150f // Range: 10m/s to 500m/s
+ private var yawOffset = 0f // Range: -180° to 180°
- headingSliderLabel.text = getString(R.string.camera_yaw_offset_format, yawOffset.toInt())
- headingSlider.value = yawOffset
- headingSlider.addOnChangeListener { _, value, _ ->
- yawOffset = value
- headingSliderLabel.text = getString(R.string.camera_yaw_offset_format, value.toInt())
- updateVehiclePositionAndCamera()
- }
+ // --- Map References ---
+
+ private var routePolyline: Polyline? = null
+ private var vehicleModel: Model? = null
+
+ // --- Background Jobs & Handlers ---
+
+ private var animationJob: Job? = null
+ private val fadeHandler = Handler(Looper.getMainLooper())
+ private val fadeOutRunnable = Runnable {
+ if (!isCollapsed) {
+ controlsCard?.animate()?.alpha(FADED_ALPHA)?.setDuration(400)?.start()
}
+ }
- private fun togglePlayback(play: Boolean) {
- isPlaying = play
- if (play) {
- btnPlayPause.setIconResource(R.drawable.pause_24px)
- startAnimationLoop()
- } else {
- btnPlayPause.setIconResource(R.drawable.play_arrow_24px)
- stopAnimationLoop()
- }
+ // --- Lifecycle & Layout Setup ---
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Hide base pill scroll view as this activity manages its own overlay card
+ findViewById(R.id.control_scroll_view)?.visibility = View.GONE
+
+ findViewById(R.id.map_container)?.let { container ->
+ layoutInflater.inflate(R.layout.control_panel_routes, container, true)
}
- override fun onMapReady(googleMap3D: GoogleMap3D) {
- super.onMapReady(googleMap3D)
- googleMap3D.setMapMode(Map3DMode.SATELLITE)
+ findViewById(R.id.top_bar)?.apply {
+ setTitle(R.string.feature_title_routes_api)
+ setNavigationOnClickListener { finish() }
+ }
- // Trigger background route loading
- lifecycleScope.launch(Dispatchers.Default) {
- loadAndRenderRoute(googleMap3D)
- }
+ initViews()
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private fun initViews() {
+ controlsCard = findViewById(R.id.control_panel)
+ cardHeader = findViewById(R.id.card_header)
+ cardContent = findViewById(R.id.card_content)
+ btnCollapse = findViewById(R.id.btn_collapse)
+
+ btnPlayPause = findViewById(R.id.btn_play_pause)
+ progressSlider = findViewById(R.id.progress_slider)
+ rangeSlider = findViewById(R.id.range_slider)
+ rangeSliderLabel = findViewById(R.id.range_slider_label)
+ speedSlider = findViewById(R.id.speed_slider)
+ speedSliderLabel = findViewById(R.id.speed_slider_label)
+ headingSlider = findViewById(R.id.heading_slider)
+ headingSliderLabel = findViewById(R.id.heading_slider_label)
+
+ btnCollapse?.setOnClickListener {
+ if (isCollapsed) {
+ expandControls()
+ } else {
+ collapseControls()
+ }
+ }
+
+ cardHeader?.setOnClickListener {
+ if (isCollapsed) {
+ expandControls()
+ }
+ }
+
+ controlsCard?.setOnTouchListener { _, event ->
+ if (event.action == MotionEvent.ACTION_DOWN) {
+ resetFadeTimer()
+ }
+ false
+ }
+
+ setupControls()
+ resetFadeTimer()
+ }
+
+ private fun setupControls() {
+ btnPlayPause?.setOnClickListener {
+ resetFadeTimer()
+ if (decodedRoute.isEmpty()) {
+ Toast.makeText(this, R.string.route_loading, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ togglePlayback(!isPlaying)
}
- /**
- * Fetches driving direction coordinates from Routes API, decodes the polyline payload
- * on background threads, and populates the map geometry.
- */
- private suspend fun loadAndRenderRoute(googleMap3D: GoogleMap3D) {
- val apiKey = BuildConfig.MAPS3D_API_KEY
- val origin = LatLng(21.307043, -157.858984)
- val destination = LatLng(21.390177, -157.719454)
- var decoded: List
-
- try {
- if (apiKey.isEmpty() || apiKey.contains("YOUR_API_KEY")) {
- throw Exception("Invalid or missing API Key")
+ progressSlider?.apply {
+ addOnSliderTouchListener(
+ object : Slider.OnSliderTouchListener {
+ override fun onStartTrackingTouch(slider: Slider) {
+ resetFadeTimer()
+ isUserScrubbing = true
}
- val routeData = routeRepository.fetchRoute(apiKey, origin, destination)
- decoded = PolyUtil.decode(routeData.encodedPolyline)
- } catch (e: Exception) {
- Log.w(TAG, "Routes API fetch failed: ${e.localizedMessage}. Falling back to pre-baked Oahu mountain route.")
- decoded = OahuRouteData.FALLBACK_ROUTE
- withContext(Dispatchers.Main) {
- Toast.makeText(
- this@RoutesActivity,
- "Offline: Using local Oahu fallback route",
- Toast.LENGTH_LONG
- ).show()
+
+ override fun onStopTrackingTouch(slider: Slider) {
+ resetFadeTimer()
+ isUserScrubbing = false
+ elapsedDistance = totalDistance * slider.value.toDouble()
+ updateVehiclePositionAndCamera()
}
+ })
+
+ addOnChangeListener { _, value, fromUser ->
+ if (fromUser && isUserScrubbing) {
+ resetFadeTimer()
+ elapsedDistance = totalDistance * value.toDouble()
+ updateVehiclePositionAndCamera()
}
+ }
+ }
- withContext(Dispatchers.Main) {
- decodedRoute = decoded
- cumulativeDistances = RouteEngine.calculateCumulativeDistances(decoded)
- totalDistance = cumulativeDistances.last()
+ rangeSliderLabel?.text = getString(R.string.camera_altitude_format, cameraRange.toInt())
+ rangeSlider?.apply {
+ value = cameraRange
+ addOnChangeListener { _, value, _ ->
+ resetFadeTimer()
+ cameraRange = value
+ rangeSliderLabel?.text = getString(R.string.camera_altitude_format, value.toInt())
+ updateVehiclePositionAndCamera()
+ }
+ }
+
+ speedSliderLabel?.text = getString(R.string.vehicle_speed_format, vehicleSpeedMps.toInt())
+ speedSlider?.apply {
+ value = vehicleSpeedMps
+ addOnChangeListener { _, value, _ ->
+ resetFadeTimer()
+ vehicleSpeedMps = value
+ speedSliderLabel?.text = getString(R.string.vehicle_speed_format, value.toInt())
+ }
+ }
- // 1. Draw the blue Polyline representational trail
- routePolyline = googleMap3D.addPolyline(polylineOptions {
- path = decoded.map { latLngAltitude { latitude = it.latitude; longitude = it.longitude; altitude = 0.0 } }
+ headingSliderLabel?.text = getString(R.string.camera_yaw_offset_format, yawOffset.toInt())
+ headingSlider?.apply {
+ value = yawOffset
+ addOnChangeListener { _, value, _ ->
+ resetFadeTimer()
+ yawOffset = value
+ headingSliderLabel?.text = getString(R.string.camera_yaw_offset_format, value.toInt())
+ updateVehiclePositionAndCamera()
+ }
+ }
+ }
+
+ // --- Collapsible UI Transitions ---
+
+ private fun collapseControls() {
+ val card = controlsCard ?: return
+ val content = cardContent ?: return
+ if (isCollapsed) return
+ isCollapsed = true
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ card.animate().alpha(ACTIVE_ALPHA).setDuration(150).start()
+ TransitionManager.beginDelayedTransition(card)
+ content.visibility = View.GONE
+ btnCollapse?.apply {
+ setIconResource(R.drawable.expand_less_24px)
+ contentDescription = getString(R.string.expand_controls)
+ }
+ }
+
+ private fun expandControls() {
+ val card = controlsCard ?: return
+ val content = cardContent ?: return
+ if (!isCollapsed) return
+ isCollapsed = false
+ TransitionManager.beginDelayedTransition(card)
+ content.visibility = View.VISIBLE
+ btnCollapse?.apply {
+ setIconResource(R.drawable.expand_more_24px)
+ contentDescription = getString(R.string.collapse_controls)
+ }
+ resetFadeTimer()
+ }
+
+ private fun resetFadeTimer() {
+ controlsCard?.animate()?.alpha(ACTIVE_ALPHA)?.setDuration(150)?.start()
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ if (!isCollapsed) {
+ fadeHandler.postDelayed(fadeOutRunnable, FADE_DELAY_MS)
+ }
+ }
+
+ private fun togglePlayback(play: Boolean) {
+ isPlaying = play
+ btnPlayPause?.apply {
+ if (play) {
+ setIconResource(R.drawable.pause_24px)
+ startAnimationLoop()
+ } else {
+ setIconResource(R.drawable.play_arrow_24px)
+ stopAnimationLoop()
+ }
+ }
+ }
+
+ // --- Map Readiness & Route Rendering ---
+
+ override fun onMapReady(googleMap3D: GoogleMap3D) {
+ super.onMapReady(googleMap3D)
+ googleMap3D.setMapMode(Map3DMode.SATELLITE)
+
+ lifecycleScope.launch(Dispatchers.Default) { loadAndRenderRoute(googleMap3D) }
+ }
+
+ private suspend fun loadAndRenderRoute(googleMap3D: GoogleMap3D) {
+ val apiKey = BuildConfig.MAPS3D_API_KEY
+ val origin = LatLng(21.307043, -157.858984)
+ val destination = LatLng(21.390177, -157.719454)
+ var decoded: List
+
+ try {
+ if (apiKey.isEmpty() || apiKey.contains("YOUR_API_KEY")) {
+ throw Exception("Invalid or missing API Key")
+ }
+ val routeData = routeRepository.fetchRoute(apiKey, origin, destination)
+ decoded = PolyUtil.decode(routeData.encodedPolyline)
+ } catch (e: Exception) {
+ Log.w(
+ TAG,
+ "Routes API fetch failed: ${e.localizedMessage}. Falling back to pre-baked Oahu mountain route.")
+ decoded = OahuRouteData.FALLBACK_ROUTE
+ withContext(Dispatchers.Main) {
+ Toast.makeText(this@RoutesActivity, R.string.routes_offline_fallback, Toast.LENGTH_LONG)
+ .show()
+ }
+ }
+
+ withContext(Dispatchers.Main) {
+ decodedRoute = decoded
+ cumulativeDistances = RouteEngine.calculateCumulativeDistances(decoded)
+ totalDistance = cumulativeDistances.last()
+
+ // 1. Draw the blue Polyline representational trail
+ routePolyline =
+ googleMap3D.addPolyline(
+ polylineOptions {
+ path =
+ decoded.map {
+ latLngAltitude {
+ latitude = it.latitude
+ longitude = it.longitude
+ altitude = 0.0
+ }
+ }
strokeColor = Color.BLUE
strokeWidth = 10.0
altitudeMode = AltitudeMode.CLAMP_TO_GROUND
zIndex = 5
- })
+ })
- // 2. Place the 3D model of the Red Car at starting coordinate
- vehicleModel = googleMap3D.addModel(modelOptions {
+ // 2. Place the 3D model of the Red Car at starting coordinate
+ vehicleModel =
+ googleMap3D.addModel(
+ modelOptions {
id = "vehicle_car"
position = latLngAltitude {
- latitude = decoded.first().latitude
- longitude = decoded.first().longitude
- altitude = 25.0 // Hover altitude above terrain
+ latitude = decoded.first().latitude
+ longitude = decoded.first().longitude
+ altitude = 25.0
}
altitudeMode = AltitudeMode.RELATIVE_TO_GROUND
orientation = orientation {
- heading = 0.0
- tilt = -90.0
- roll = 0.0
+ heading = 0.0
+ tilt = -90.0
+ roll = 0.0
}
url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb"
scale = vector3D {
- x = 50.0
- y = 50.0
- z = 50.0
+ x = 50.0
+ y = 50.0
+ z = 50.0
}
- })
+ })
- // Position camera directly behind the starting model position
- updateVehiclePositionAndCamera()
-
- // Auto-play to start
- togglePlayback(true)
- }
+ updateVehiclePositionAndCamera()
+ togglePlayback(true)
}
+ }
- /**
- * Runs the high-fidelity physics animation tick loop.
- */
- private fun startAnimationLoop() {
- animationJob = lifecycleScope.launch(Dispatchers.Main) {
- var lastTime = System.currentTimeMillis()
- while (isPlaying && totalDistance > 0.0) {
- val now = System.currentTimeMillis()
- val dt = (now - lastTime) / 1000.0 // Delta time in seconds
- lastTime = now
-
- // Increment geographic distance traversed
- elapsedDistance += vehicleSpeedMps * dt
-
- // Loop/clamp playback boundaries
- if (elapsedDistance >= totalDistance) {
- elapsedDistance = 0.0
- }
+ // --- Animation Tick Loop ---
- // Synchronize UI progress slider
- if (!isUserScrubbing) {
- progressSlider.value = (elapsedDistance / totalDistance).toFloat()
- }
+ private fun startAnimationLoop() {
+ animationJob =
+ lifecycleScope.launch(Dispatchers.Main) {
+ var lastTime = System.currentTimeMillis()
+ while (isPlaying && totalDistance > 0.0) {
+ val now = System.currentTimeMillis()
+ val dt = (now - lastTime) / 1000.0 // Delta time in seconds
+ lastTime = now
+
+ elapsedDistance += vehicleSpeedMps * dt
- updateVehiclePositionAndCamera()
-
- // Cap framerate to approx 60fps (16ms ticks)
- delay(16)
+ if (elapsedDistance >= totalDistance) {
+ elapsedDistance = 0.0
}
+
+ if (!isUserScrubbing) {
+ progressSlider?.value = (elapsedDistance / totalDistance).toFloat()
+ }
+
+ updateVehiclePositionAndCamera()
+ delay(16)
+ }
}
- }
+ }
- private fun stopAnimationLoop() {
- animationJob?.cancel()
- animationJob = null
- }
+ private fun stopAnimationLoop() {
+ animationJob?.cancel()
+ animationJob = null
+ }
+
+ private fun updateVehiclePositionAndCamera() {
+ val route = decodedRoute
+ if (route.isEmpty() || totalDistance <= 0.0) return
- /**
- * Interpolates exact geographic position & heading using precomputed binary-searches,
- * updating the 3D model coordinates and camera focus vectors.
- */
- private fun updateVehiclePositionAndCamera() {
- val route = decodedRoute
- if (route.isEmpty() || totalDistance <= 0.0) return
-
- val posAndHeading = RouteEngine.calculatePositionAndHeading(
- route,
- cumulativeDistances,
- elapsedDistance,
- 30.0
- )
-
- // Upsert Model position and rotation on every tick using the same ID
- googleMap3D?.let { map ->
- vehicleModel = map.addModel(modelOptions {
+ val posAndHeading =
+ RouteEngine.calculatePositionAndHeading(
+ route, cumulativeDistances, elapsedDistance, 30.0)
+
+ googleMap3D?.let { map ->
+ vehicleModel =
+ map.addModel(
+ modelOptions {
id = "vehicle_car"
position = latLngAltitude {
- latitude = posAndHeading.position.latitude
- longitude = posAndHeading.position.longitude
- altitude = 25.0 // Keep consistent vehicle altitude hover
+ latitude = posAndHeading.position.latitude
+ longitude = posAndHeading.position.longitude
+ altitude = 25.0
}
altitudeMode = AltitudeMode.RELATIVE_TO_GROUND
orientation = orientation {
- heading = posAndHeading.heading.toDouble()
- tilt = -90.0
- roll = 0.0
+ heading = posAndHeading.heading.toDouble()
+ tilt = -90.0
+ roll = 0.0
}
url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb"
scale = vector3D {
- x = 50.0
- y = 50.0
- z = 50.0
+ x = 50.0
+ y = 50.0
+ z = 50.0
}
- })
- }
-
- // Track camera following vehicle
- googleMap3D?.setCamera(camera {
- center = latLngAltitude {
- latitude = posAndHeading.position.latitude
- longitude = posAndHeading.position.longitude
- altitude = 0.0
- }
- heading = (posAndHeading.heading.toDouble() + yawOffset.toDouble()).toHeading()
- tilt = 65.0
- range = cameraRange.toDouble()
- })
+ })
}
- override fun onPause() {
- super.onPause()
- togglePlayback(false)
- }
-
- override fun onDestroy() {
- super.onDestroy()
- stopAnimationLoop()
- }
+ googleMap3D?.setCamera(
+ camera {
+ center = latLngAltitude {
+ latitude = posAndHeading.position.latitude
+ longitude = posAndHeading.position.longitude
+ altitude = 0.0
+ }
+ heading = (posAndHeading.heading.toDouble() + yawOffset.toDouble()).toHeading()
+ tilt = 65.0
+ range = cameraRange.toDouble()
+ }.toValidCamera())
+ }
+
+ override fun onPause() {
+ super.onPause()
+ togglePlayback(false)
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ stopAnimationLoop()
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ }
}
From f6d7d73238feda65ff3a12c28ebe633e83791166 Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Fri, 21 Aug 2026 16:58:57 -0600
Subject: [PATCH 5/9] docs(samples): augment literate comments explaining 3D
coordinates, extrusion, and smoothing
---
.../DataVisualizationActivity.java | 8 +++++++-
.../pathfollowing/PathFollowingActivity.java | 13 ++++++++++--
.../maps3djava/routes/RoutesActivity.java | 20 ++++++++++++++++---
.../DataVisualizationActivity.kt | 7 +++++++
.../pathfollowing/PathFollowingActivity.kt | 9 +++++++++
.../maps3dkotlin/routes/RoutesActivity.kt | 19 +++++++++++++++---
6 files changed, 67 insertions(+), 9 deletions(-)
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
index 2b18ea0a..0c477e43 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
@@ -343,7 +343,13 @@ public void updateFloodElevation(double currentFloodHeightMeters) {
path.add(new LatLngAltitude(coord[0], coord[1], currentFloodHeightMeters));
}
- // Configure volumetric extruded polygon options
+ // Volumetric 3D Polygon Extrusion Technique:
+ // 1. AltitudeMode.ABSOLUTE: Water elevation represents true Mean Sea Level (MSL).
+ // Unlike RELATIVE_TO_GROUND, ABSOLUTE ensures a flat, uniform horizontal water plane.
+ // 2. setExtruded(true): Instructs the 3D rendering engine to drop vertical skirt walls
+ // from the polygon vertices down to the ground terrain mesh, forming a 3D volumetric water body.
+ // 3. setId(POLYGON_ID): Re-using a stable ID upserts the existing polygon in place,
+ // eliminating render flickering during rapid slider or animation updates.
PolygonOptions options = new PolygonOptions();
options.setId(POLYGON_ID);
options.setPath(path);
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
index d98c3ce6..21b4c70b 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
@@ -537,12 +537,17 @@ private void updateProgressPolyline(double dist, LatLng currentLatLng, int index
progressCoordinates.add(new LatLngAltitude(startPt.latitude, startPt.longitude, progressAltitude));
}
+ // Dual-Polyline Rendering Technique:
+ // We render two layered polylines to clearly visualize completed vs. remaining route:
+ // 1. Base Static Polyline: A wider (#4285F4 blue, 16dp) static path at ZIndex=1.
+ // 2. Traversed Progress Polyline: A narrower (#9C27B0 purple, 8dp) line at ZIndex=2.
+ // We also apply a slight vertical altitude bias (+0.2m) to prevent 3D depth buffer z-fighting.
PolylineOptions progressOptions = new PolylineOptions();
- progressOptions.setId(PROGRESS_POLYLINE_ID); // Same ID every time to eliminate flickering
+ progressOptions.setId(PROGRESS_POLYLINE_ID); // Fixed ID upserts in place to eliminate flickering
progressOptions.setPath(progressCoordinates);
progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); // Progress line: purple
progressOptions.setStrokeWidth(8.0); // Progress line: narrower
- progressOptions.setZIndex(2); // Progress line: higher z-index
+ progressOptions.setZIndex(2); // Progress line: higher z-index on top of static route
progressOptions.setAltitudeMode(pathAltitudeMode);
progressPolyline = googleMap3D.addPolyline(progressOptions);
@@ -617,6 +622,10 @@ private void updateCameraPositionForDistance(double dist) {
LatLng currentLatLng = SphericalUtil.interpolate(p1, p2, fraction);
double bearing = SphericalUtil.computeHeading(p1, p2);
+ // Kinematic Heading Smoothing (Exponential Moving Average):
+ // Sharp polyline bends can produce disorienting camera jerks. We compute the shortest
+ // angular difference wrapped to [-180, 180] and apply an exponential low-pass filter (12% lerp)
+ // to smoothly turn the camera around street corners and mountain switchbacks.
double targetHeadingRaw = toHeading(bearing + headingOffset);
double targetHeading;
if (currentHeading == null || isUserScrubbing || !isPlaying) {
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
index 84743daa..bf694e77 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
@@ -323,6 +323,13 @@ public void onStopTrackingTouch(@NonNull Slider slider) {
// --- Collapsible UI Transitions ---
+ /**
+ * Collapses the control card to reveal more of the 3D map scene.
+ *
+ * Note on TransitionManager: Using TransitionManager.beginDelayedTransition automatically
+ * animates the parent CardView height changes smoothly while preserving system navigation bar
+ * insets on edge-to-edge layouts, avoiding visual bottom-clipping.
+ */
private void collapseControls() {
if (controlsCard == null || cardContent == null || isCollapsed) return;
isCollapsed = true;
@@ -420,7 +427,8 @@ private void loadAndRenderRouteAsync(@NonNull GoogleMap3D googleMap3D) {
cumulativeDistances = RouteEngine.calculateCumulativeDistances(finalDecoded);
totalDistance = cumulativeDistances[cumulativeDistances.length - 1];
- // 1. Draw the blue route polyline
+ // 1. Draw the blue route polyline using CLAMP_TO_GROUND so it drapes naturally
+ // over the 3D terrain mesh and elevations without floating or clipping into hills.
List linePath = new ArrayList<>();
for (LatLng point : finalDecoded) {
linePath.add(new LatLngAltitude(point.latitude, point.longitude, 0.0));
@@ -434,7 +442,10 @@ private void loadAndRenderRouteAsync(@NonNull GoogleMap3D googleMap3D) {
polyOptions.setZIndex(5);
routePolyline = googleMap3D.addPolyline(polyOptions);
- // 2. Load the 3D Car model
+ // 2. Load the 3D Car model (.glb).
+ // Note on glTF Orientation: Pitch is set to -90.0 degrees because standard glTF
+ // models use a +Y up coordinate system, requiring a -90 degree pitch adjustment
+ // to align the car body horizontally with the tangent plane of the Earth surface.
ModelOptions modelOpts = new ModelOptions();
modelOpts.setId("vehicle_car_java");
modelOpts.setPosition(
@@ -465,7 +476,10 @@ public void run() {
if (!isPlaying || totalDistance <= 0.0) return;
long now = System.currentTimeMillis();
- double dt = (now - lastTime) / 1000.0; // Delta time in seconds
+ // Delta-time (dt) integration: Multiply velocity by actual elapsed wall-clock seconds.
+ // This guarantees consistent movement speed regardless of whether the device displays
+ // at 60Hz, 90Hz, or 120Hz refresh rates.
+ double dt = (now - lastTime) / 1000.0;
lastTime = now;
elapsedDistance += vehicleSpeedMps * dt;
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/datavisualization/DataVisualizationActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/datavisualization/DataVisualizationActivity.kt
index 3eaba3ea..23e469e7 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/datavisualization/DataVisualizationActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/datavisualization/DataVisualizationActivity.kt
@@ -247,6 +247,13 @@ class DataVisualizationActivity : SampleBaseActivity() {
}
}
+ // Volumetric 3D Polygon Extrusion Technique:
+ // 1. AltitudeMode.ABSOLUTE: Water elevation represents true Mean Sea Level (MSL).
+ // Unlike RELATIVE_TO_GROUND, ABSOLUTE ensures a flat, uniform horizontal water plane.
+ // 2. extruded = true: Instructs the 3D rendering engine to drop vertical skirt walls
+ // from the polygon vertices down to the ground terrain mesh, forming a 3D volumetric water body.
+ // 3. id = POLYGON_ID: Re-using a stable ID upserts the existing polygon in place,
+ // eliminating render flickering during rapid slider or animation updates.
val options = polygonOptions {
id = POLYGON_ID
this.path = path
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
index de8e7605..d9dae33d 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
@@ -485,6 +485,11 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
progressCoordinates.add(LatLngAltitude(startPt.latitude, startPt.longitude, progressAltitude))
}
+ // Dual-Polyline Rendering Technique:
+ // We render two layered polylines to clearly visualize completed vs. remaining route:
+ // 1. Base Static Polyline: A wider (#4285F4 blue, 16dp) static path at ZIndex=1.
+ // 2. Traversed Progress Polyline: A narrower (#9C27B0 purple, 8dp) line at ZIndex=2.
+ // We also apply a slight vertical altitude bias (+0.2m) to prevent 3D depth buffer z-fighting.
val progressOptions = PolylineOptions().apply {
id = PROGRESS_POLYLINE_ID
path = progressCoordinates
@@ -556,6 +561,10 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
val currentLatLng = SphericalUtil.interpolate(p1, p2, fraction)
val bearing = SphericalUtil.computeHeading(p1, p2)
+ // Kinematic Heading Smoothing (Exponential Moving Average):
+ // Sharp polyline bends can produce disorienting camera jerks. We compute the shortest
+ // angular difference wrapped to [-180, 180] and apply an exponential low-pass filter (12% lerp)
+ // to smoothly turn the camera around street corners and mountain switchbacks.
val targetHeadingRaw = (bearing + headingOffset).toHeading()
val targetHeading = if (currentHeading == null || isUserScrubbing || !isPlaying) {
targetHeadingRaw
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/routes/RoutesActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/routes/RoutesActivity.kt
index d9414498..82a6003c 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/routes/RoutesActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/routes/RoutesActivity.kt
@@ -282,6 +282,13 @@ class RoutesActivity : SampleBaseActivity() {
// --- Collapsible UI Transitions ---
+ /**
+ * Collapses the control card to reveal more of the 3D map scene.
+ *
+ * Note on TransitionManager: Using TransitionManager.beginDelayedTransition automatically
+ * animates the parent CardView height changes smoothly while preserving system navigation bar
+ * insets on edge-to-edge layouts, avoiding visual bottom-clipping.
+ */
private fun collapseControls() {
val card = controlsCard ?: return
val content = cardContent ?: return
@@ -369,7 +376,8 @@ class RoutesActivity : SampleBaseActivity() {
cumulativeDistances = RouteEngine.calculateCumulativeDistances(decoded)
totalDistance = cumulativeDistances.last()
- // 1. Draw the blue Polyline representational trail
+ // 1. Draw the blue Polyline representational trail using CLAMP_TO_GROUND so it
+ // conforms directly to the 3D photorealistic terrain mesh without clipping into hills.
routePolyline =
googleMap3D.addPolyline(
polylineOptions {
@@ -387,7 +395,10 @@ class RoutesActivity : SampleBaseActivity() {
zIndex = 5
})
- // 2. Place the 3D model of the Red Car at starting coordinate
+ // 2. Place the 3D model of the Red Car (.glb) at starting coordinate.
+ // Note on glTF Orientation: Pitch is set to -90.0 degrees because standard glTF assets
+ // use +Y up, requiring a -90 degree pitch adjustment to align the vehicle body horizontally
+ // with the tangent plane of the Earth surface.
vehicleModel =
googleMap3D.addModel(
modelOptions {
@@ -424,7 +435,9 @@ class RoutesActivity : SampleBaseActivity() {
var lastTime = System.currentTimeMillis()
while (isPlaying && totalDistance > 0.0) {
val now = System.currentTimeMillis()
- val dt = (now - lastTime) / 1000.0 // Delta time in seconds
+ // Delta-time (dt) integration: Scale motion by actual elapsed seconds to ensure
+ // consistent travel speed across 60Hz, 90Hz, and 120Hz display refresh rates.
+ val dt = (now - lastTime) / 1000.0
lastTime = now
elapsedDistance += vehicleSpeedMps * dt
From d262c6d451c5f34cc971fad5319d49cd19c35190 Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Fri, 21 Aug 2026 17:02:19 -0600
Subject: [PATCH 6/9] build: untrack and gitignore gradle-daemon-jvm.properties
---
.gitignore | 1 +
gradle/gradle-daemon-jvm.properties | 12 ------------
2 files changed, 1 insertion(+), 12 deletions(-)
delete mode 100644 gradle/gradle-daemon-jvm.properties
diff --git a/.gitignore b/.gitignore
index f225e479..2b8fb646 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Gradle files
.gradle/
build/
+gradle/gradle-daemon-jvm.properties
# Local configuration file (sdk path, etc)
local.properties
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
deleted file mode 100644
index fa4ed510..00000000
--- a/gradle/gradle-daemon-jvm.properties
+++ /dev/null
@@ -1,12 +0,0 @@
-#This file is generated by updateDaemonJvm
-toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
-toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
-toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
-toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
-toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
-toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
-toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
-toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
-toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
-toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
-toolchainVersion=25
From dd6b6973519196780cd40045eec9eb4f26011eab Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Fri, 21 Aug 2026 17:07:32 -0600
Subject: [PATCH 7/9] docs: update copyright headers to 2026 for camera
animation step classes
---
.../maps3djava/advancedcameraanimation/AnimationStep.java | 2 +-
.../example/maps3djava/advancedcameraanimation/DwellStep.java | 2 +-
.../maps3djava/advancedcameraanimation/FlyAroundStep.java | 2 +-
.../example/maps3djava/advancedcameraanimation/FlyToStep.java | 2 +-
.../maps3djava/advancedcameraanimation/KeyframeStep.java | 2 +-
.../maps3djava/advancedcameraanimation/Map3DAnimator.java | 2 +-
.../maps3djava/advancedcameraanimation/OrbitOptions.java | 2 +-
.../example/maps3djava/advancedcameraanimation/OrbitStep.java | 2 +-
.../maps3djava/advancedcameraanimation/StepCallback.java | 2 +-
9 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java
index 9296f4d7..d8253cc6 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java
index 4d3a4914..a206d473 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java
index 9e7f52ab..851ad685 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java
index 759859a5..011432f0 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java
index b23dd009..22464d17 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java
index 3a4cf35c..cab7684d 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java
index 940f53b5..3d1c66c6 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java
index b044c075..b70c7b1c 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java
index 9d7232b1..0d215ae3 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
From 4e290f364382d8fa0920da0392d8c08884d5d9db Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Mon, 24 Aug 2026 15:11:11 -0600
Subject: [PATCH 8/9] fix(samples): address PR review feedback on animation
timing, tour resume, onPause, and TransitionManager collapse
---
.../AdvancedCameraAnimationActivity.java | 73 ++++++++++---------
.../DataVisualizationActivity.java | 41 ++++++-----
.../pathfollowing/PathFollowingActivity.java | 10 ++-
.../DataVisualizationActivity.kt | 40 +++++-----
.../pathfollowing/PathFollowingActivity.kt | 10 ++-
5 files changed, 97 insertions(+), 77 deletions(-)
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
index 68b3cd65..11e05b62 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
@@ -199,45 +199,50 @@ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
updateAirplaneModel(GOLDEN_GATE_BRIDGE, planeHeading + 180.0);
}
+ private final Map3DAnimator.Listener animatorListener =
+ new Map3DAnimator.Listener() {
+ @Override
+ public void onStepStarted(int index, @NonNull KeyframeStep step) {
+ Log.d(getTAG(), "Keyframe Step " + (index + 1) + " started: " + step.getTitle());
+ if (tvTourStatus != null && tourAnimator != null) {
+ tvTourStatus.setText(
+ getString(
+ R.string.aerial_tour_status_running,
+ index + 1,
+ tourAnimator.getSteps().size(),
+ step.getTitle()));
+ }
+ }
+
+ @Override
+ public void onStepCompleted(int index, @NonNull KeyframeStep step) {
+ Log.d(getTAG(), "Keyframe Step " + (index + 1) + " completed: " + step.getTitle());
+ }
+
+ @Override
+ public void onAnimationFinished() {
+ Log.d(getTAG(), "Aerial tour completed successfully.");
+ isPlaying = false;
+ updatePlayPauseButtonState();
+ if (tvTourStatus != null) {
+ tvTourStatus.setText(R.string.aerial_tour_status_finished);
+ }
+ }
+ };
+
private void startOrResumeTour() {
- if (tourAnimator == null) {
- tourAnimator = buildTourAnimator();
- }
isPlaying = true;
updatePlayPauseButtonState();
if (googleMap3D != null) {
- tourAnimator.start(
- googleMap3D,
- new Map3DAnimator.Listener() {
- @Override
- public void onStepStarted(int index, @NonNull KeyframeStep step) {
- Log.d(getTAG(), "Keyframe Step " + (index + 1) + " started: " + step.getTitle());
- if (tvTourStatus != null) {
- tvTourStatus.setText(
- getString(
- R.string.aerial_tour_status_running,
- index + 1,
- tourAnimator.getSteps().size(),
- step.getTitle()));
- }
- }
-
- @Override
- public void onStepCompleted(int index, @NonNull KeyframeStep step) {
- Log.d(getTAG(), "Keyframe Step " + (index + 1) + " completed: " + step.getTitle());
- }
-
- @Override
- public void onAnimationFinished() {
- Log.d(getTAG(), "Aerial tour completed successfully.");
- isPlaying = false;
- updatePlayPauseButtonState();
- if (tvTourStatus != null) {
- tvTourStatus.setText(R.string.aerial_tour_status_finished);
- }
- }
- });
+ if (tourAnimator == null) {
+ tourAnimator = buildTourAnimator();
+ tourAnimator.start(googleMap3D, animatorListener);
+ } else if (tourAnimator.getCurrentStepIndex() < tourAnimator.getSteps().size()) {
+ tourAnimator.resume();
+ } else {
+ tourAnimator.start(googleMap3D, animatorListener);
+ }
}
}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
index 0c477e43..242e9b4b 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
@@ -22,6 +22,7 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.transition.TransitionManager;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
@@ -249,7 +250,10 @@ private void initViews() {
* Collapses the control card downward, leaving only the title header visible.
*/
private void collapseControls() {
- if (controlsCard == null) {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ if (isCollapsed) {
return;
}
isCollapsed = true;
@@ -258,24 +262,18 @@ private void collapseControls() {
btnCollapse.setIconResource(R.drawable.expand_less_24px);
btnCollapse.setContentDescription(getString(R.string.expand_controls));
}
- int headerHeight = (cardHeader != null && cardHeader.getHeight() > 0)
- ? cardHeader.getHeight()
- : (int) (48 * getResources().getDisplayMetrics().density);
- float targetTranslationY = (cardContent != null && cardContent.getHeight() > 0)
- ? cardContent.getHeight()
- : Math.max(0, controlsCard.getHeight() - headerHeight);
- controlsCard.animate()
- .translationY(targetTranslationY)
- .alpha(0.9f)
- .setDuration(300)
- .start();
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
}
/**
* Expands the control card back to its full height.
*/
private void expandControls() {
- if (controlsCard == null) {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ if (!isCollapsed) {
return;
}
isCollapsed = false;
@@ -283,11 +281,9 @@ private void expandControls() {
btnCollapse.setIconResource(R.drawable.expand_more_24px);
btnCollapse.setContentDescription(getString(R.string.collapse_controls));
}
- controlsCard.animate()
- .translationY(0f)
- .alpha(1.0f)
- .setDuration(250)
- .start();
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+
fadeHandler.removeCallbacks(fadeOutRunnable);
fadeHandler.postDelayed(fadeOutRunnable, 3000L);
}
@@ -467,7 +463,14 @@ private void stopSimulation() {
}
}
- // --- Teardown ---
+ // --- Lifecycle Teardown ---
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ stopSimulation();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ }
@Override
protected void onDestroy() {
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
index 21b4c70b..b1daec95 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
@@ -562,13 +562,21 @@ private void startAnimation() {
final long frameDurationMs = 16L;
animationRunnable = new Runnable() {
+ private long lastTime = System.currentTimeMillis();
+
@Override
public void run() {
if (!isPlaying) {
return;
}
- double stepDistance = followSpeedMps * (frameDurationMs / 1000.0);
+ long now = System.currentTimeMillis();
+ // Delta-time (dt) integration: Scale motion by actual elapsed seconds to ensure
+ // consistent travel speed across 60Hz, 90Hz, and 120Hz display refresh rates.
+ double dt = (now - lastTime) / 1000.0;
+ lastTime = now;
+
+ double stepDistance = followSpeedMps * dt;
elapsedDistance += stepDistance;
if (elapsedDistance >= totalDistance) {
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/datavisualization/DataVisualizationActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/datavisualization/DataVisualizationActivity.kt
index 23e469e7..bba51404 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/datavisualization/DataVisualizationActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/datavisualization/DataVisualizationActivity.kt
@@ -20,6 +20,7 @@ import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.transition.TransitionManager
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
@@ -75,6 +76,7 @@ class DataVisualizationActivity : SampleBaseActivity() {
private var controlsCard: CardView? = null
private var cardHeader: View? = null
+ private var cardContent: View? = null
private var btnCollapse: MaterialButton? = null
private lateinit var floodDepthLabel: TextView
private lateinit var floodRiskBadge: TextView
@@ -121,6 +123,7 @@ class DataVisualizationActivity : SampleBaseActivity() {
private fun initViews() {
controlsCard = findViewById(R.id.control_panel)
cardHeader = findViewById(R.id.card_header)
+ cardContent = findViewById(R.id.card_content)
btnCollapse = findViewById(R.id.btn_collapse)
floodDepthLabel = findViewById(R.id.tv_flood_depth_label)
@@ -164,40 +167,27 @@ class DataVisualizationActivity : SampleBaseActivity() {
private fun collapseControls() {
val card = controlsCard ?: return
+ val content = cardContent ?: return
+ if (isCollapsed) return
isCollapsed = true
fadeHandler.removeCallbacks(fadeOutRunnable)
btnCollapse?.setIconResource(R.drawable.expand_less_24px)
btnCollapse?.contentDescription = getString(R.string.expand_controls)
- val content = findViewById(R.id.card_content)
- val headerHeight = if (cardHeader != null && cardHeader!!.height > 0) {
- cardHeader!!.height
- } else {
- (48 * resources.displayMetrics.density).toInt()
- }
- val targetTranslationY = if (content != null && content.height > 0) {
- content.height.toFloat()
- } else {
- (card.height - headerHeight).coerceAtLeast(0).toFloat()
- }
- card.animate()
- .translationY(targetTranslationY)
- .alpha(0.9f)
- .setDuration(300)
- .start()
+ TransitionManager.beginDelayedTransition(card)
+ content.visibility = View.GONE
}
private fun expandControls() {
val card = controlsCard ?: return
+ val content = cardContent ?: return
+ if (!isCollapsed) return
isCollapsed = false
btnCollapse?.setIconResource(R.drawable.expand_more_24px)
btnCollapse?.contentDescription = getString(R.string.collapse_controls)
- card.animate()
- .translationY(0f)
- .alpha(1.0f)
- .setDuration(250)
- .start()
+ TransitionManager.beginDelayedTransition(card)
+ content.visibility = View.VISIBLE
fadeHandler.removeCallbacks(fadeOutRunnable)
fadeHandler.postDelayed(fadeOutRunnable, 3000L)
@@ -266,7 +256,7 @@ class DataVisualizationActivity : SampleBaseActivity() {
geodesic = false
}
- floodPolygon = map.addPolygon(options)?.apply {
+ floodPolygon = map.addPolygon(options).apply {
setClickListener {
runOnUiThread {
Toast.makeText(
@@ -348,6 +338,12 @@ class DataVisualizationActivity : SampleBaseActivity() {
btnAnimateFlood.setText(R.string.start_simulation)
}
+ override fun onPause() {
+ super.onPause()
+ stopSimulation()
+ fadeHandler.removeCallbacks(fadeOutRunnable)
+ }
+
override fun onDestroy() {
stopSimulation()
fadeHandler.removeCallbacks(fadeOutRunnable)
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
index d9dae33d..d6de8341 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt
@@ -509,10 +509,18 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback {
val frameDurationMs = 16L
animationRunnable = object : Runnable {
+ private var lastTime = System.currentTimeMillis()
+
override fun run() {
if (!isPlaying) return
- val stepDistance = followSpeedMps * (frameDurationMs / 1000.0)
+ val now = System.currentTimeMillis()
+ // Delta-time (dt) integration: Scale motion by actual elapsed seconds to ensure
+ // consistent travel speed across 60Hz, 90Hz, and 120Hz display refresh rates.
+ val dt = (now - lastTime) / 1000.0
+ lastTime = now
+
+ val stepDistance = followSpeedMps * dt
elapsedDistance += stepDistance
if (elapsedDistance >= totalDistance) {
From 0d84e3c2bb4cb104db359ff64236aed2c430ebd4 Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Thu, 20 Aug 2026 12:01:52 -0600
Subject: [PATCH 9/9] feat(samples): add Cloud-Based Map Styling demo and
visual tests
---
.../res/layout/activity_cloud_styling.xml | 133 ++++++++++++++++++
.../layout/control_panel_cloud_styling.xml | 82 +++++++++++
.../maps3djava/CloudStylingVisualTest.java | 79 +++++++++++
.../cloudstyling/CloudStylingActivity.java | 85 ++++++++++-
.../maps3dkotlin/CloudStylingVisualTest.kt | 82 +++++++++++
.../cloudstyling/CloudStylingActivity.kt | 73 +++++++++-
.../com/example/composedemos/MainActivity.kt | 5 +
.../cloudstyling/CloudStylingActivity.kt | 81 ++++++++---
8 files changed, 590 insertions(+), 30 deletions(-)
create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_cloud_styling.xml
create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_cloud_styling.xml
create mode 100644 Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/CloudStylingVisualTest.java
create mode 100644 Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/CloudStylingVisualTest.kt
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_cloud_styling.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_cloud_styling.xml
new file mode 100644
index 00000000..61ebe477
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_cloud_styling.xml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_cloud_styling.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_cloud_styling.xml
new file mode 100644
index 00000000..ec83a2bd
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_cloud_styling.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/CloudStylingVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/CloudStylingVisualTest.java
new file mode 100644
index 00000000..c20904d3
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/CloudStylingVisualTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.maps3djava;
+
+import static org.junit.Assert.assertTrue;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.uiautomator.By;
+import androidx.test.uiautomator.Until;
+
+import com.example.maps3djava.cloudstyling.CloudStylingActivity;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A premium visual regression test for the View-based Java Cloud-Based Map Styling sample.
+ *
+ * Demonstrates robust programmatic testing of custom cloud-styled 3D map features by launching the Java-based
+ * [CloudStylingActivity], waiting for cloud-styled map tiles over San Francisco to load, capturing a screenshot of the
+ * active map scene, and verifying visual correctness using the Gemini API.
+ */
+@RunWith(AndroidJUnit4.class)
+public class CloudStylingVisualTest extends BaseVisualTest {
+
+ @Test
+ public void verifyCloudStylingRenders() {
+ // Launch CloudStylingActivity
+ Intent intent = new Intent(context, CloudStylingActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000);
+
+ // Wait for Cloud-styled 3D map tiles to render and feature styling to settle
+ waitForMapRendering(15);
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ Bitmap screenshotBitmap = captureScreenshot("cloud_styling_screenshot.png");
+
+ // Define the verification prompt for the visual testing agent
+ String prompt = "Please act as a UI tester and analyze this screenshot.\n" +
+ "1. Confirm that a 3D map view is visible over San Francisco.\n" +
+ "2. Confirm that custom cloud-based stylized map tiles / elements are displayed on the 3D map scene.\n" +
+ "3. Confirm that the Map Mode selector card (with radio options for Roadmap, Hybrid, and Satellite) is visible at the bottom of the screen.\n" +
+ "\n" +
+ "If and ONLY IF you can clearly see the 3D Cloud-styled map view and bottom Map Mode selection card, reply with \"PASSED\".\n" +
+ "If you cannot see the 3D map scene or control card, reply with \"FAILED: 3D map scene or Map Mode controls not visible\".\n" +
+ "Report what you see in detail.";
+
+ // Analyze the image using Gemini (using blocking wrapper)
+ String geminiResponse = helper.analyzeImageBlocking(screenshotBitmap, prompt, geminiApiKey);
+ System.out.println("Gemini's analysis: " + geminiResponse);
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: " + geminiResponse,
+ geminiResponse != null && geminiResponse.toUpperCase().contains("PASSED")
+ );
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java
index 6997460a..a8599bed 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java
@@ -16,15 +16,88 @@
package com.example.maps3djava.cloudstyling;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
+import android.os.Bundle;
+import android.widget.RadioGroup;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import com.example.maps3dcommon.R;
-import com.example.maps3djava.common.BaseSkeletonActivity;
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+import com.google.android.gms.maps.model.LatLng;
+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.android.gms.maps3d.model.Map3DMode;
+import com.google.android.material.appbar.MaterialToolbar;
/**
- * Skeleton activity for CloudStylingActivity.
+ * Showcases **Cloud-Based Map Styling** in Google Maps 3D SDK (Java implementation). Uses
+ * standalone layout [R.layout.activity_cloud_styling] with declarative custom Map ID.
*/
-public class CloudStylingActivity extends BaseSkeletonActivity {
- @Override
- protected int getTitleResId() {
- return R.string.feature_title_cloud_styling;
+public class CloudStylingActivity extends SampleBaseActivity {
+
+ public static final LatLng SF_LOCATION = new LatLng(37.7915, -122.4010);
+
+ private int currentMapMode = Map3DMode.ROADMAP;
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return getClass().getSimpleName();
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(new Camera(
+ new LatLngAltitude(SF_LOCATION.latitude, SF_LOCATION.longitude, 250.0),
+ 45.0,
+ 65.0,
+ 0.0,
+ 800.0
+ ));
+ }
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Set dedicated standalone layout with declarative mapId="9a35234a36da44d2c47bf626"
+ setContentView(R.layout.activity_cloud_styling);
+
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_cloud_styling);
+ topBar.setNavigationOnClickListener(v -> finish());
+ }
+
+ map3DView = findViewById(R.id.map3dView);
+ if (map3DView != null) {
+ map3DView.onCreate(savedInstanceState);
+ map3DView.getMap3DViewAsync(this);
}
+
+ RadioGroup rgMapMode = findViewById(R.id.rg_map_mode);
+ if (rgMapMode != null) {
+ rgMapMode.setOnCheckedChangeListener((group, checkedId) -> {
+ int newMode = Map3DMode.ROADMAP;
+ if (checkedId == R.id.rb_hybrid) {
+ newMode = Map3DMode.HYBRID;
+ } else if (checkedId == R.id.rb_satellite) {
+ newMode = Map3DMode.SATELLITE;
+ }
+ currentMapMode = newMode;
+ if (googleMap3D != null) {
+ googleMap3D.setMapMode(newMode);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setMapMode(currentMapMode);
+ }
}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/CloudStylingVisualTest.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/CloudStylingVisualTest.kt
new file mode 100644
index 00000000..e83306ce
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/CloudStylingVisualTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.maps3dkotlin
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.maps3dkotlin.cloudstyling.CloudStylingActivity
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * A premium visual regression test for the View-based Kotlin Cloud-Based Map Styling sample.
+ *
+ * This test automates launching the [CloudStylingActivity], waiting for cloud-styled 3D map tiles configured via custom
+ * Map ID over San Francisco to initialize, capturing a screenshot of the live rendering scene, and verifying visual
+ * correctness using the Gemini API.
+ */
+@RunWith(AndroidJUnit4::class)
+class CloudStylingVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyCloudStylingRenders() {
+ runBlocking {
+ // Launch CloudStylingActivity
+ val intent = Intent(context, CloudStylingActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for Cloud-styled 3D map tiles to render and feature styling to settle
+ waitForMapRendering(15)
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ val screenshotBitmap = captureScreenshot("cloud_styling_screenshot.png")
+
+ // Define the verification prompt for the visual testing agent
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot.
+ 1. Confirm that a 3D map view is visible over San Francisco.
+ 2. Confirm that custom cloud-based stylized map tiles / elements are displayed on the 3D map scene.
+ 3. Confirm that the Map Mode selector card (with radio options for Roadmap, Hybrid, and Satellite) is visible at the bottom of the screen.
+
+ If and ONLY IF you can clearly see the 3D Cloud-styled map view and bottom Map Mode selection card, reply with "PASSED".
+ If you cannot see the 3D map scene or control card, reply with "FAILED: 3D map scene or Map Mode controls not visible".
+ Report what you see in detail.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini's analysis: ${'$'}geminiResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: ${'$'}geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/cloudstyling/CloudStylingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/cloudstyling/CloudStylingActivity.kt
index 4584108f..7c713ceb 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/cloudstyling/CloudStylingActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/cloudstyling/CloudStylingActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * 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.
@@ -16,14 +16,75 @@
package com.example.maps3dkotlin.cloudstyling
+import android.os.Bundle
+import android.widget.RadioGroup
+import androidx.activity.enableEdgeToEdge
+import com.example.maps3d.common.toValidCamera
import com.example.maps3dcommon.R
-import com.example.maps3dkotlin.common.BaseSkeletonActivity
+import com.example.maps3dkotlin.sampleactivity.SampleBaseActivity
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps3d.GoogleMap3D
+import com.google.android.gms.maps3d.Map3DView
+import com.google.android.gms.maps3d.model.Camera
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.android.material.appbar.MaterialToolbar
/**
- * Skeleton activity for CloudStylingActivity.
+ * Showcases **Cloud-Based Map Styling** in Google Maps 3D SDK.
+ * Uses standalone layout [R.layout.activity_cloud_styling] with declarative custom Map ID.
*/
-class CloudStylingActivity : BaseSkeletonActivity() {
- override fun getTitleResId(): Int {
- return R.string.feature_title_cloud_styling
+class CloudStylingActivity : SampleBaseActivity() {
+
+ override val TAG: String = this::class.java.simpleName
+
+ override val initialCamera: Camera = camera {
+ center = latLngAltitude {
+ latitude = SF_LOCATION.latitude
+ longitude = SF_LOCATION.longitude
+ altitude = 250.0
+ }
+ heading = 45.0
+ tilt = 65.0
+ range = 800.0
+ }.toValidCamera()
+
+ @Map3DMode
+ private var currentMapMode: Int = Map3DMode.ROADMAP
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_cloud_styling)
+
+ findViewById(R.id.top_bar)?.apply {
+ title = getString(R.string.feature_title_cloud_styling)
+ setNavigationOnClickListener { finish() }
+ }
+
+ map3DView = findViewById(R.id.map3dView).apply {
+ onCreate(savedInstanceState)
+ getMap3DViewAsync(this@CloudStylingActivity)
+ }
+
+ findViewById(R.id.rg_map_mode)?.setOnCheckedChangeListener { _, checkedId ->
+ val newMode = when (checkedId) {
+ R.id.rb_hybrid -> Map3DMode.HYBRID
+ R.id.rb_satellite -> Map3DMode.SATELLITE
+ else -> Map3DMode.ROADMAP
+ }
+ currentMapMode = newMode
+ googleMap3D?.setMapMode(newMode)
+ }
+ }
+
+ override fun onMapReady(googleMap3D: GoogleMap3D) {
+ super.onMapReady(googleMap3D)
+ googleMap3D.setMapMode(currentMapMode)
+ }
+
+ companion object {
+ val SF_LOCATION = LatLng(37.7915, -122.4010)
}
}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt
index 5e475d91..b9705841 100644
--- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/MainActivity.kt
@@ -207,6 +207,11 @@ fun CatalogScreen() {
context.startActivity(Intent(context, FieldOfViewActivity::class.java))
}
}
+ item {
+ SampleItem("Cloud Map Styling") {
+ context.startActivity(Intent(context, CloudStylingActivity::class.java))
+ }
+ }
}
}
diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt
index 802b7f50..ac803e86 100644
--- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt
+++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cloudstyling/CloudStylingActivity.kt
@@ -24,13 +24,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.ui.Alignment
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.core.view.WindowCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.WindowInsetsControllerCompat
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps3d.Map3DInitConfig
+import com.google.android.gms.maps3d.model.Map3DMode
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.compose3d.GoogleMap3D
class CloudStylingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -38,20 +43,60 @@ class CloudStylingActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
MaterialTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = "TODO: Cloud Map Styling sample will be implemented here.",
- style = MaterialTheme.typography.headlineSmall,
- )
- }
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ CloudStylingScreen()
}
}
}
}
}
+
+@Composable
+fun CloudStylingScreen() {
+ val initialLocation = LatLng(37.7915, -122.4010)
+ val currentCameraState by remember {
+ mutableStateOf(
+ camera {
+ center = latLngAltitude {
+ latitude = initialLocation.latitude
+ longitude = initialLocation.longitude
+ altitude = 250.0
+ }
+ heading = 45.0
+ tilt = 65.0
+ range = 800.0
+ },
+ )
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ GoogleMap3D(
+ camera = currentCameraState,
+ mapMode = Map3DMode.ROADMAP,
+ options = Map3DInitConfig.create(
+ centerLat = initialLocation.latitude,
+ centerLng = initialLocation.longitude,
+ centerAlt = 0.0,
+ heading = 45.0,
+ tilt = 65.0,
+ roll = 0.0,
+ range = 800.0,
+ minAltitude = 0.0,
+ maxAltitude = 1000000.0,
+ minHeading = 0.0,
+ maxHeading = 360.0,
+ minTilt = 0.0,
+ maxTilt = 90.0,
+ bounds = null,
+ mapMode = Map3DMode.ROADMAP,
+ mapId = "9a35234a36da44d2c47bf626",
+ language = java.util.Locale.getDefault().language,
+ region = java.util.Locale.getDefault().country,
+ ),
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+}