diff --git a/map-sample/app/build.gradle b/map-sample/app/build.gradle
index 7f05c48..a6ff39a 100755
--- a/map-sample/app/build.gradle
+++ b/map-sample/app/build.gradle
@@ -21,7 +21,7 @@ apply plugin: 'org.jetbrains.kotlin.android'
ext {
androidxLifecycleVersion = "2.6.2"
glideVersion = "4.13.2"
- navSdkVersion = "7.0.0"
+ navSdkVersion = "7.9.0"
}
android {
@@ -117,12 +117,12 @@ dependencies {
api "joda-time:joda-time:2.10.14"
api "com.google.android.material:material:1.12.0"
api 'org.jetbrains.kotlin:kotlin-reflect:2.1.10'
- api 'org.jetbrains.kotlin:kotlin-stdlib:2.1.10'
+ api 'org.jetbrains.kotlin:kotlin-stdlib:2.3.0'
annotationProcessor "androidx.annotation:annotation:1.7.0"
annotationProcessor "com.github.bumptech.glide:compiler:${glideVersion}"
- coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4'
+ coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
}
secrets {
diff --git a/map-sample/build.gradle b/map-sample/build.gradle
index 29fd0fa..05026aa 100755
--- a/map-sample/build.gradle
+++ b/map-sample/build.gradle
@@ -22,7 +22,7 @@ buildscript {
google()
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.7.3'
+ classpath 'com.android.tools.build:gradle:8.13.2'
classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1"
// NOTE: Do not place your application dependencies here; they belong
@@ -31,7 +31,7 @@ buildscript {
}
plugins {
- id 'org.jetbrains.kotlin.android' version '2.1.10' apply false
+ id 'org.jetbrains.kotlin.android' version '2.3.0' apply false
}
allprojects {
diff --git a/map-sample/gradle/wrapper/gradle-wrapper.properties b/map-sample/gradle/wrapper/gradle-wrapper.properties
index df97d72..37f853b 100755
--- a/map-sample/gradle/wrapper/gradle-wrapper.properties
+++ b/map-sample/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/navigation-sample/app/build.gradle b/navigation-sample/app/build.gradle
index b017abb..6d32458 100644
--- a/navigation-sample/app/build.gradle
+++ b/navigation-sample/app/build.gradle
@@ -21,7 +21,7 @@ apply plugin: 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
ext {
androidxLifecycleVersion = "2.6.2"
glideVersion = "4.13.2"
- navSdkVersion = "7.0.0"
+ navSdkVersion = "7.9.0"
}
android {
@@ -116,13 +116,13 @@ dependencies {
// isn't required by the Navigation SDK.
api "com.google.android.libraries.places:places:4.0.0"
api "com.google.android.material:material:1.12.0"
- api 'org.jetbrains.kotlin:kotlin-reflect:2.1.10'
- api 'org.jetbrains.kotlin:kotlin-stdlib:2.1.10'
+ api 'org.jetbrains.kotlin:kotlin-reflect:2.3.0'
+ api 'org.jetbrains.kotlin:kotlin-stdlib:2.3.0'
api 'com.google.guava:guava:31.0.1-android'
annotationProcessor "androidx.annotation:annotation:1.7.0"
annotationProcessor "com.github.bumptech.glide:compiler:${glideVersion}"
- coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4'
+ coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.1.5'
}
secrets {
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/AllUiElementsLayoutDelegate.java b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/AllUiElementsLayoutDelegate.java
new file mode 100644
index 0000000..a1b16b9
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/AllUiElementsLayoutDelegate.java
@@ -0,0 +1,717 @@
+/*
+ * 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.navigationapidemo.layoutdelegate;
+
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+import static androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.MATCH_CONSTRAINT;
+import static com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton.ButtonKnownType.COMPASS;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import androidx.annotation.ColorInt;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
+import com.google.android.libraries.navigation.layoutcustomization.ActiveGuidanceUiState;
+import com.google.android.libraries.navigation.layoutcustomization.AutoHidingVerticalLayout;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationLayoutDelegate;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationReadyUiState;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiParent;
+import com.google.android.libraries.navigation.layoutcustomization.StyleValues;
+import com.google.android.libraries.navigation.layoutcustomization.UiState;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+/**
+ * A robust implementation of {@link NavigationLayoutDelegate} demonstrating a navigation user
+ * interface optimized for both portrait and landscape (wide mode).
+ *
+ *
Understanding the Layout Delegate State Machine: Navigation SDK transitions through
+ * distinct states, each calling corresponding lifecycle methods on this delegate:
+ *
+ *
+ * - Navigation Ready: Initiated by {@link #onEnterNavigationReady}. We initialize the
+ * layout here and add non-guidance views, then pass it to {@link NavigationUiParent} as the
+ * navigation layout.
+ *
- Active Guidance (Turn-by-Turn Mode): Initiated by {@link #onEnterActiveGuidance}. We
+ * set up the layout for Active Guidance, adding elements such as the turn card and ETA card.
+ *
- Prompts: Prompts (e.g., incident alerts) may be triggered during Active Guidance
+ * mode and can be added to the layout via {@link #onShowPrompt}.
+ *
+ *
+ * This class caches its {@link ConstraintSet}s to ensure smooth transitions without needing to
+ * recreate or inflate layouts continuously.
+ *
+ * @see NavigationLayoutDelegate
+ * @see UiState
+ */
+public class AllUiElementsLayoutDelegate extends NavigationLayoutDelegate {
+ private static final int CUSTOM_BUTTON_SIZE_DP = 56;
+
+ @ColorInt private static final int CUSTOM_UI_ELEMENT_COLOR = Color.parseColor("#FFA500");
+
+ private final int layoutId;
+ private final int exampleCustomButtonId;
+ private final int buttonsContainerId;
+ private final int startUiControlsContainerId;
+
+ private ConstraintLayout layout;
+ private View exampleCustomButton;
+ private AutoHidingVerticalLayout buttonsContainer;
+ private AutoHidingVerticalLayout startUiControlsContainer;
+
+ // We cache our ConstraintSet definitions to avoid cloning or rebuilding
+ // constraint configurations programmatically on every transition. This optimization
+ // keeps UI state switches (such as entering active guidance or popping up prompts) highly
+ // performant.
+ private ConstraintSet navigationReadyConstraintSet;
+ private ConstraintSet activeGuidanceConstraintSet;
+ private ConstraintSet activeGuidanceWideModeConstraintSet;
+ private ConstraintSet activeGuidanceWithPromptConstraintSet;
+
+ private boolean isActiveGuidance = false;
+ private ActiveGuidanceUiState activeGuidanceUiState;
+
+ public AllUiElementsLayoutDelegate() {
+ layoutId = View.generateViewId();
+ exampleCustomButtonId = View.generateViewId();
+ buttonsContainerId = View.generateViewId();
+ startUiControlsContainerId = View.generateViewId();
+ }
+
+ @Override
+ public void onEnterNavigationReady(
+ NavigationUiParent navigationUiParent, NavigationReadyUiState newState) {
+ isActiveGuidance = false;
+
+ Context context = navigationUiParent.getViewContext();
+
+ // Implementation Tip: For simplicity, this sample instantiates views and constraints
+ // programmatically. In a production application, you can safely inflate standard XML
+ // layout templates to build your layout hierarchies and define base UI constraints.
+
+ // Create the root layout
+ if (layout == null) {
+ layout = new ConstraintLayout(context);
+ LayoutParams layoutParams = new LayoutParams(MATCH_PARENT, MATCH_PARENT);
+ layout.setLayoutParams(layoutParams);
+ layout.setId(layoutId);
+ }
+
+ // Add the Viewport (REQUIRED):
+ // The viewport is an invisible bounding box used by Nav SDK to frame the vehicle
+ // chevron and the upcoming route line. We want to position this view such that it avoids
+ // being obscured by fully-opaque UI elements (like the turn card or the ETA card).
+ removeFromParentView(newState.getViewport());
+ LayoutParams viewportLayoutParams = new LayoutParams(MATCH_PARENT, MATCH_PARENT);
+ layout.addView(newState.getViewport(), viewportLayoutParams);
+
+ // Add the Google Logo / Re-center Button (REQUIRED):
+ // This view displays the Google logo during guidance and may transition into a
+ // "Re-center" button if the user scrolls away from the vehicle chevron. It must
+ // be added to the view hierarchy in all states.
+ removeFromParentView(newState.getGoogleLogo());
+ LayoutParams googleLogoLayoutParams = new LayoutParams(MATCH_PARENT, WRAP_CONTENT);
+ layout.addView(newState.getGoogleLogo(), googleLogoLayoutParams);
+
+ // Add the container for UI buttons
+ if (buttonsContainer == null) {
+ // We use AutoHidingVerticalLayout to create an adaptive vertical button container that
+ // automatically hides or shows child views based on available screen height.
+ buttonsContainer = new AutoHidingVerticalLayout(context);
+ buttonsContainer.setId(buttonsContainerId);
+ buttonsContainer.setPadding(0, 0, 0, 0);
+ }
+ removeFromParentView(buttonsContainer);
+ LayoutParams buttonsContainerLayoutParams = new LayoutParams(WRAP_CONTENT, MATCH_CONSTRAINT);
+ layout.addView(buttonsContainer, buttonsContainerLayoutParams);
+
+ // Add UI buttons to the container
+ for (NavigationUiButton button : newState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ buttonsContainer.addView(button.getView());
+ }
+
+ // Build constraint set for Navigation Ready state
+ if (navigationReadyConstraintSet == null) {
+ navigationReadyConstraintSet = buildNavigationReadyConstraintSet(newState);
+ }
+
+ // Apply the constraints
+ navigationReadyConstraintSet.applyTo(layout);
+
+ // Set the layout in NavigationUiParent
+ navigationUiParent.removeNavigationLayout(layout);
+ navigationUiParent.setNavigationLayout(layout);
+ }
+
+ private ConstraintSet buildNavigationReadyConstraintSet(NavigationReadyUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain viewport to the edges of its parent
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+
+ // Constrain the logo to the bottom start corner
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ constrainButtonsToBottomEnd(constraintSet);
+
+ return constraintSet;
+ }
+
+ @Override
+ public void onLeaveNavigationReady(
+ NavigationUiParent navigationUiParent, NavigationReadyUiState oldState) {
+ buttonsContainer.removeAllViews();
+ layout.removeAllViews();
+ navigationUiParent.removeNavigationLayout(layout);
+ }
+
+ @Override
+ public void onEnterActiveGuidance(
+ NavigationUiParent navigationUiParent,
+ NavigationReadyUiState oldState,
+ ActiveGuidanceUiState newState) {
+ isActiveGuidance = true;
+ activeGuidanceUiState = newState;
+
+ Context context = navigationUiParent.getViewContext();
+
+ // Sizing Guideline: The turn card and ETA card are internally configured to adapt and size
+ // themselves dynamically based on the layout width (non-wideMode vs. wideMode). Forcing fixed
+ // widths or heights on these elements via layouts is unsupported. Always use WRAP_CONTENT to
+ // let the elements determine their optimal proportions.
+
+ // Add the turn card
+ removeFromParentView(newState.getTurnCard());
+ LayoutParams turnCardLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(newState.getTurnCard(), turnCardLayoutParams);
+
+ // Add the ETA card
+ removeFromParentView(newState.getEtaCard());
+ LayoutParams etaCardLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(newState.getEtaCard(), etaCardLayoutParams);
+
+ // Remove the Navigation Ready UI buttons
+ for (NavigationUiButton button : oldState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ }
+
+ // Add an example custom button to the buttons container (Because we're adding it first, this
+ // will be the top-most button displayed in the container)
+ if (exampleCustomButton == null) {
+ exampleCustomButton = createExampleCustomButton(context);
+ }
+ removeFromParentView(exampleCustomButton);
+ LayoutParams customButtonLayoutParams = createExampleCustomButtonLayoutParams(context);
+ buttonsContainer.addView(exampleCustomButton, customButtonLayoutParams);
+
+ // By adding all buttons to the AutoHidingVerticalLayout, we can easily incorporate the latest
+ // set of buttons when upgrading without any code changes required
+ for (NavigationUiButton button : newState.getActiveGuidanceButtons()) {
+ AutoHidingVerticalLayout.LayoutParams buttonLayoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.getType() == COMPASS) {
+ buttonLayoutParams.isHighPriority = true;
+ }
+
+ removeFromParentView(button.getView());
+ buttonsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+
+ // Add the container for start side UI controls (like Speed Widget and Trip Progress Bar)
+ if (startUiControlsContainer == null) {
+ // Using an AutoHidingVerticalLayout makes it easy to auto-hide UI
+ // elements if there isn't enough vertical space to display them all.
+ startUiControlsContainer = new AutoHidingVerticalLayout(context);
+ startUiControlsContainer.setId(startUiControlsContainerId);
+ startUiControlsContainer.setPadding(0, 0, 0, 0);
+ startUiControlsContainer.setChildSpacing(dpToPx(8, context));
+ startUiControlsContainer.setGravity(Gravity.BOTTOM | Gravity.START);
+ }
+ removeFromParentView(startUiControlsContainer);
+ LayoutParams startUiControlsContainerLayoutParams =
+ new LayoutParams(WRAP_CONTENT, MATCH_CONSTRAINT);
+ layout.addView(startUiControlsContainer, startUiControlsContainerLayoutParams);
+
+ // Add the Trip Progress Bar and speed widget to the container
+ LayoutParams startControlLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+
+ removeFromParentView(newState.getTripProgressBar());
+ startUiControlsContainer.addView(newState.getTripProgressBar(), startControlLayoutParams);
+
+ removeFromParentView(newState.getSpeedWidget());
+ startUiControlsContainer.addView(newState.getSpeedWidget(), startControlLayoutParams);
+
+ // Build constraint sets for Active Guidance state
+ if (activeGuidanceConstraintSet == null) {
+ activeGuidanceConstraintSet = buildActiveGuidanceConstraintSet(context, newState);
+ }
+ if (activeGuidanceWideModeConstraintSet == null) {
+ activeGuidanceWideModeConstraintSet =
+ buildActiveGuidanceWideModeConstraintSet(context, newState);
+ }
+
+ // Apply the constraints
+ if (activeGuidanceUiState.isWideMode()) {
+ activeGuidanceWideModeConstraintSet.applyTo(layout);
+ } else {
+ activeGuidanceConstraintSet.applyTo(layout);
+ }
+ }
+
+ private View createExampleCustomButton(Context context) {
+ FloatingActionButton button = new FloatingActionButton(context);
+ button.setId(exampleCustomButtonId);
+ button.setBackgroundTintList(ColorStateList.valueOf(CUSTOM_UI_ELEMENT_COLOR));
+ button.setCompatElevation(0f);
+ return button;
+ }
+
+ private LayoutParams createExampleCustomButtonLayoutParams(Context context) {
+ int sizePx = dpToPx(CUSTOM_BUTTON_SIZE_DP, context);
+ AutoHidingVerticalLayout.LayoutParams layoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(sizePx, sizePx);
+
+ // The StyleValues API provides margins that can be used to align custom elements with Nav SDK
+ // UI elements
+ int horizontalMarginPx = dpToPx(StyleValues.buttonHorizontalPaddingDp(), context);
+ int verticalMarginPx = dpToPx(StyleValues.buttonVerticalPaddingDp(), context);
+ layoutParams.setMargins(horizontalMarginPx, verticalMarginPx, horizontalMarginPx, 0);
+
+ return layoutParams;
+ }
+
+ @Override
+ public void onLeaveActiveGuidance(
+ NavigationUiParent navigationUiParent,
+ ActiveGuidanceUiState oldState,
+ NavigationReadyUiState newState) {
+ isActiveGuidance = false;
+
+ // Remove Active Guidance UI elements
+ removeFromParentView(oldState.getSpeedWidget());
+ removeFromParentView(oldState.getTripProgressBar());
+ removeFromParentView(oldState.getEtaCard());
+ removeFromParentView(oldState.getTurnCard());
+ removeFromParentView(startUiControlsContainer);
+ buttonsContainer.removeAllViews();
+ startUiControlsContainer.removeAllViews();
+
+ // Add Navigation Ready UI buttons
+ for (NavigationUiButton button : newState.getNavigationReadyButtons()) {
+ LayoutParams buttonLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ buttonsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+
+ navigationReadyConstraintSet.applyTo(layout);
+ }
+
+ private ConstraintSet buildActiveGuidanceConstraintSet(
+ Context context, ActiveGuidanceUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain turn card to top start corner
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ // Constrain viewport to top of ETA card
+ constraintSet.clear(uiState.getViewport().getId());
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+
+ // Instead of constraining the viewport's top directly to the bottom of the turn card
+ // (which varies in height and would trigger jumpy camera framing updates), we use a fixed
+ // nominal height to estimate the height of the turncard.
+ constraintSet.setMargin(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ dpToPx(StyleValues.headerNominalHeightDp(), context));
+
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ uiState.getEtaCard().getId(),
+ ConstraintSet.TOP);
+
+ constraintSet.clear(uiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constrainLogoToTopOfEtaCard(uiState, constraintSet);
+
+ constrainEtaCardToBottomStart(uiState, constraintSet);
+
+ constrainButtonsToTopOfEtaCard(uiState, constraintSet);
+
+ constrainStartUiControlsContainerAboveGoogleLogo(uiState, constraintSet);
+
+ return constraintSet;
+ }
+
+ private ConstraintSet buildActiveGuidanceWideModeConstraintSet(
+ Context context, ActiveGuidanceUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Split the screen vertically. Since the turn card and ETA card become half-width in wide mode,
+ // we position the viewport in the end half to make better use of the screen space.
+ int halfGuidelineId = View.generateViewId();
+ createHalfwayVerticalGuideline(constraintSet, halfGuidelineId);
+
+ constraintSet.clear(uiState.getViewport().getId());
+ constrainViewportToEndHalf(uiState, constraintSet, halfGuidelineId);
+
+ // Constrain turn card to top start
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ constraintSet.clear(uiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constrainLogoToTopOfEtaCard(uiState, constraintSet);
+
+ constrainEtaCardToBottomStart(uiState, constraintSet);
+
+ constrainButtonsToBottomEnd(constraintSet);
+
+ constrainStartUiControlsContainerAboveGoogleLogo(uiState, constraintSet);
+
+ return constraintSet;
+ }
+
+ private static void createHalfwayVerticalGuideline(
+ ConstraintSet constraintSet, int halfGuidelineId) {
+ constraintSet.create(halfGuidelineId, ConstraintSet.VERTICAL_GUIDELINE);
+ constraintSet.setGuidelinePercent(halfGuidelineId, 0.5f);
+ }
+
+ private static void constrainEtaCardToBottomStart(
+ ActiveGuidanceUiState uiState, ConstraintSet constraintSet) {
+ constraintSet.connect(
+ uiState.getEtaCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getEtaCard().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ }
+
+ private static void constrainLogoToTopOfEtaCard(
+ ActiveGuidanceUiState uiState, ConstraintSet constraintSet) {
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ uiState.getEtaCard().getId(),
+ ConstraintSet.TOP);
+ }
+
+ private void constrainButtonsToBottomEnd(ConstraintSet constraintSet) {
+ constraintSet.clear(buttonsContainerId, ConstraintSet.BOTTOM);
+ constraintSet.clear(buttonsContainerId, ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ }
+
+ private void constrainButtonsToTopOfEtaCard(
+ ActiveGuidanceUiState uiState, ConstraintSet constraintSet) {
+ constraintSet.clear(buttonsContainerId, ConstraintSet.BOTTOM);
+ constraintSet.clear(buttonsContainerId, ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.TOP, uiState.getTurnCard().getId(), ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.BOTTOM, uiState.getEtaCard().getId(), ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ }
+
+ private void constrainStartUiControlsContainerAboveGoogleLogo(
+ ActiveGuidanceUiState uiState, ConstraintSet constraintSet) {
+ constraintSet.connect(
+ startUiControlsContainerId,
+ ConstraintSet.TOP,
+ uiState.getTurnCard().getId(),
+ ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ startUiControlsContainerId,
+ ConstraintSet.BOTTOM,
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ startUiControlsContainerId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ }
+
+ @Override
+ public void onShowPrompt(NavigationUiParent navigationUiParent, View newPrompt) {
+ Context context = navigationUiParent.getViewContext();
+
+ layout.addView(newPrompt);
+
+ // When a prompt is displayed at the bottom of the screen, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ activeGuidanceUiState.isWideMode()
+ ? buildActiveGuidanceWithPromptWideModeConstraintSet(newPrompt)
+ : buildActiveGuidanceWithPromptConstraintSet(context, newPrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onChangePrompt(
+ NavigationUiParent navigationUiParent, View oldPrompt, View newPrompt) {
+ Context context = navigationUiParent.getViewContext();
+
+ activeGuidanceWithPromptConstraintSet.clear(oldPrompt.getId());
+
+ layout.removeView(oldPrompt);
+ layout.addView(newPrompt);
+
+ // When a prompt is displayed at the bottom of the screen, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ activeGuidanceUiState.isWideMode()
+ ? buildActiveGuidanceWithPromptWideModeConstraintSet(newPrompt)
+ : buildActiveGuidanceWithPromptConstraintSet(context, newPrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onHidePrompt(NavigationUiParent navigationUiParent, View oldPrompt) {
+ activeGuidanceWithPromptConstraintSet.clear(oldPrompt.getId());
+
+ layout.removeView(oldPrompt);
+
+ if (activeGuidanceUiState.isWideMode()) {
+ activeGuidanceWideModeConstraintSet.applyTo(layout);
+ } else {
+ activeGuidanceConstraintSet.applyTo(layout);
+ }
+ }
+
+ private ConstraintSet buildActiveGuidanceWithPromptConstraintSet(Context context, View prompt) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain viewport to top of prompt
+ constraintSet.clear(activeGuidanceUiState.getViewport().getId());
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.setMargin(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ dpToPx(StyleValues.headerNominalHeightDp(), context));
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ prompt.getId(),
+ ConstraintSet.TOP);
+
+ // Constrain logo to top of prompt
+ constraintSet.clear(activeGuidanceUiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ activeGuidanceUiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ prompt.getId(),
+ ConstraintSet.TOP);
+
+ // Constrain prompt to bottom start corner
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START);
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+
+ return constraintSet;
+ }
+
+ private ConstraintSet buildActiveGuidanceWithPromptWideModeConstraintSet(View prompt) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Split the screen vertically. Since the turn card and ETA card become half-width in wide mode,
+ // we position the viewport in the end half to make better use of the screen space.
+ int halfGuidelineId = View.generateViewId();
+ createHalfwayVerticalGuideline(constraintSet, halfGuidelineId);
+
+ // Constrain viewport to end half of parent
+ constraintSet.clear(activeGuidanceUiState.getViewport().getId());
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.START,
+ halfGuidelineId,
+ ConstraintSet.START);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+
+ // Constrain prompt to bottom start corner
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START);
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+
+ return constraintSet;
+ }
+
+ private static void constrainViewportToEndHalf(
+ UiState uiState, ConstraintSet constraintSet, int halfGuidelineId) {
+ constraintSet.connect(
+ uiState.getViewport().getId(), ConstraintSet.START, halfGuidelineId, ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ }
+
+ @Override
+ public void onSizeChanged(NavigationUiParent navigationUiParent, UiState state) {
+ if (isActiveGuidance) {
+ if (state.isWideMode()) {
+ activeGuidanceWideModeConstraintSet.applyTo(layout);
+ } else {
+ activeGuidanceConstraintSet.applyTo(layout);
+ }
+ } else {
+ navigationReadyConstraintSet.applyTo(layout);
+ }
+ }
+
+ private void removeFromParentView(View view) {
+ if (view != null && view.getParent() != null) {
+ ((ViewGroup) view.getParent()).removeView(view);
+ }
+ }
+
+ private static int dpToPx(int dp, Context context) {
+ return (int)
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics());
+ }
+}
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/AllUiElementsLayoutDelegateKt.kt b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/AllUiElementsLayoutDelegateKt.kt
new file mode 100644
index 0000000..98ba221
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/AllUiElementsLayoutDelegateKt.kt
@@ -0,0 +1,614 @@
+/*
+ * 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.navigationapidemo.layoutdelegate
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.core.graphics.toColorInt
+import com.google.android.libraries.navigation.layoutcustomization.ActiveGuidanceUiState
+import com.google.android.libraries.navigation.layoutcustomization.AutoHidingVerticalLayout
+import com.google.android.libraries.navigation.layoutcustomization.NavigationLayoutDelegate
+import com.google.android.libraries.navigation.layoutcustomization.NavigationReadyUiState
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton.ButtonKnownType.COMPASS
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiParent
+import com.google.android.libraries.navigation.layoutcustomization.StyleValues
+import com.google.android.libraries.navigation.layoutcustomization.UiState
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+
+/** Kotlin equivalent of AllUiElementsLayoutDelegate. */
+class AllUiElementsLayoutDelegateKt : NavigationLayoutDelegate() {
+
+ private val layoutId = View.generateViewId()
+ private val exampleCustomButtonId = View.generateViewId()
+ private val buttonsContainerId = View.generateViewId()
+ private val startUiControlsContainerId = View.generateViewId()
+
+ private var layout: ConstraintLayout? = null
+ private var exampleCustomButton: View? = null
+ private var buttonsContainer: AutoHidingVerticalLayout? = null
+ private var startUiControlsContainer: AutoHidingVerticalLayout? = null
+
+ private var navigationReadyConstraintSet: ConstraintSet? = null
+ private var activeGuidanceConstraintSet: ConstraintSet? = null
+ private var activeGuidanceWideModeConstraintSet: ConstraintSet? = null
+ private var activeGuidanceWithPromptConstraintSet: ConstraintSet? = null
+
+ private var isActiveGuidance = false
+ private var activeGuidanceUiState: ActiveGuidanceUiState? = null
+
+ override fun onEnterNavigationReady(
+ navigationUiParent: NavigationUiParent,
+ newState: NavigationReadyUiState,
+ ) {
+ isActiveGuidance = false
+ val context = navigationUiParent.viewContext
+
+ var currentLayout = layout
+ if (currentLayout == null) {
+ currentLayout =
+ ConstraintLayout(context).apply {
+ layoutParams =
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ id = layoutId
+ }
+ layout = currentLayout
+ }
+
+ removeFromParentView(newState.viewport)
+ currentLayout.addView(
+ newState.viewport,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ),
+ )
+
+ removeFromParentView(newState.googleLogo)
+ currentLayout.addView(
+ newState.googleLogo,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+
+ var currentButtonsContainer = buttonsContainer
+ if (currentButtonsContainer == null) {
+ currentButtonsContainer =
+ AutoHidingVerticalLayout(context).apply {
+ id = buttonsContainerId
+ setPadding(0, 0, 0, 0)
+ }
+ buttonsContainer = currentButtonsContainer
+ }
+
+ removeFromParentView(currentButtonsContainer)
+ currentLayout.addView(
+ currentButtonsContainer,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.MATCH_CONSTRAINT,
+ ),
+ )
+
+ for (button in newState.navigationReadyButtons) {
+ removeFromParentView(button.view)
+ currentButtonsContainer.addView(button.view)
+ }
+
+ if (navigationReadyConstraintSet == null) {
+ navigationReadyConstraintSet = buildNavigationReadyConstraintSet(newState)
+ }
+ navigationReadyConstraintSet?.applyTo(currentLayout)
+
+ navigationUiParent.removeNavigationLayout(currentLayout)
+ navigationUiParent.setNavigationLayout(currentLayout)
+ }
+
+ private fun buildNavigationReadyConstraintSet(uiState: NavigationReadyUiState): ConstraintSet {
+ return ConstraintSet().apply {
+ clone(layout)
+
+ connect(
+ uiState.viewport.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ connect(uiState.viewport.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(uiState.viewport.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ connect(
+ uiState.viewport.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ connect(
+ uiState.googleLogo.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ connect(
+ uiState.googleLogo.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+
+ constrainButtonsToBottomEnd()
+ }
+ }
+
+ override fun onLeaveNavigationReady(
+ navigationUiParent: NavigationUiParent,
+ oldState: NavigationReadyUiState,
+ ) {
+ buttonsContainer?.removeAllViews()
+ layout?.removeAllViews()
+ layout?.let { navigationUiParent.removeNavigationLayout(it) }
+ }
+
+ override fun onEnterActiveGuidance(
+ navigationUiParent: NavigationUiParent,
+ oldState: NavigationReadyUiState,
+ newState: ActiveGuidanceUiState,
+ ) {
+ isActiveGuidance = true
+ activeGuidanceUiState = newState
+ val context = navigationUiParent.viewContext
+ val currentLayout = checkNotNull(layout) { "layout must be initialized" }
+ val currentButtonsContainer =
+ checkNotNull(buttonsContainer) { "buttonsContainer must be initialized" }
+
+ removeFromParentView(newState.turnCard)
+ currentLayout.addView(
+ newState.turnCard,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+
+ removeFromParentView(newState.etaCard)
+ currentLayout.addView(
+ newState.etaCard,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+
+ for (button in oldState.navigationReadyButtons) {
+ removeFromParentView(button.view)
+ }
+
+ var currentCustomButton = exampleCustomButton
+ if (currentCustomButton == null) {
+ currentCustomButton = createExampleCustomButton(context)
+ exampleCustomButton = currentCustomButton
+ }
+ removeFromParentView(currentCustomButton)
+ currentButtonsContainer.addView(
+ currentCustomButton,
+ createExampleCustomButtonLayoutParams(context),
+ )
+
+ for (button in newState.activeGuidanceButtons) {
+ val buttonLayoutParams =
+ AutoHidingVerticalLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ )
+ if (button.type == COMPASS) {
+ buttonLayoutParams.isHighPriority = true
+ }
+ removeFromParentView(button.view)
+ currentButtonsContainer.addView(button.view, buttonLayoutParams)
+ }
+
+ var currentStartContainer = startUiControlsContainer
+ if (currentStartContainer == null) {
+ currentStartContainer =
+ AutoHidingVerticalLayout(context).apply {
+ id = startUiControlsContainerId
+ setPadding(0, 0, 0, 0)
+ setChildSpacing(dpToPx(8, context))
+ gravity = Gravity.BOTTOM or Gravity.START
+ }
+ startUiControlsContainer = currentStartContainer
+ }
+ removeFromParentView(currentStartContainer)
+ currentLayout.addView(
+ currentStartContainer,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.MATCH_CONSTRAINT,
+ ),
+ )
+
+ removeFromParentView(newState.tripProgressBar)
+ currentStartContainer.addView(
+ newState.tripProgressBar,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+
+ removeFromParentView(newState.speedWidget)
+ currentStartContainer.addView(
+ newState.speedWidget,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+
+ if (activeGuidanceConstraintSet == null) {
+ activeGuidanceConstraintSet = buildActiveGuidanceConstraintSet(context, newState)
+ }
+ if (activeGuidanceWideModeConstraintSet == null) {
+ activeGuidanceWideModeConstraintSet =
+ buildActiveGuidanceWideModeConstraintSet(context, newState)
+ }
+
+ if (newState.isWideMode) {
+ activeGuidanceWideModeConstraintSet?.applyTo(currentLayout)
+ } else {
+ activeGuidanceConstraintSet?.applyTo(currentLayout)
+ }
+ }
+
+ private fun createExampleCustomButton(context: Context): View {
+ return FloatingActionButton(context).apply {
+ id = exampleCustomButtonId
+ backgroundTintList = ColorStateList.valueOf(CUSTOM_UI_ELEMENT_COLOR)
+ compatElevation = 0f
+ }
+ }
+
+ private fun createExampleCustomButtonLayoutParams(
+ context: Context
+ ): AutoHidingVerticalLayout.LayoutParams {
+ val sizePx = dpToPx(CUSTOM_BUTTON_SIZE_DP, context)
+ val layoutParams = AutoHidingVerticalLayout.LayoutParams(sizePx, sizePx)
+ val horizontalMarginPx = dpToPx(StyleValues.buttonHorizontalPaddingDp(), context)
+ val verticalMarginPx = dpToPx(StyleValues.buttonVerticalPaddingDp(), context)
+ layoutParams.setMargins(horizontalMarginPx, verticalMarginPx, horizontalMarginPx, 0)
+ return layoutParams
+ }
+
+ override fun onLeaveActiveGuidance(
+ navigationUiParent: NavigationUiParent,
+ oldState: ActiveGuidanceUiState,
+ newState: NavigationReadyUiState,
+ ) {
+ isActiveGuidance = false
+
+ removeFromParentView(oldState.speedWidget)
+ removeFromParentView(oldState.tripProgressBar)
+ removeFromParentView(oldState.etaCard)
+ removeFromParentView(oldState.turnCard)
+ removeFromParentView(startUiControlsContainer)
+ buttonsContainer?.removeAllViews()
+ startUiControlsContainer?.removeAllViews()
+
+ val currentButtonsContainer =
+ checkNotNull(buttonsContainer) { "buttonsContainer must be initialized" }
+ for (button in newState.navigationReadyButtons) {
+ currentButtonsContainer.addView(
+ button.view,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+ }
+
+ navigationReadyConstraintSet?.applyTo(layout)
+ }
+
+ private fun buildActiveGuidanceConstraintSet(
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ): ConstraintSet {
+ return ConstraintSet().apply {
+ clone(layout)
+
+ connect(uiState.turnCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(
+ uiState.turnCard.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+
+ clear(uiState.viewport.id)
+ connect(
+ uiState.viewport.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ connect(uiState.viewport.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ setMargin(
+ uiState.viewport.id,
+ ConstraintSet.TOP,
+ dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context),
+ )
+ connect(uiState.viewport.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ connect(uiState.viewport.id, ConstraintSet.BOTTOM, uiState.etaCard.id, ConstraintSet.TOP)
+
+ clear(uiState.googleLogo.id, ConstraintSet.BOTTOM)
+ constrainLogoToTopOfEtaCard(uiState)
+ constrainEtaCardToBottomStart(uiState)
+ constrainButtonsToTopOfEtaCard(context, uiState)
+ constrainStartUiControlsContainerAboveGoogleLogo(context, uiState)
+ }
+ }
+
+ private fun buildActiveGuidanceWideModeConstraintSet(
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ): ConstraintSet {
+ return ConstraintSet().apply {
+ clone(layout)
+
+ val halfGuidelineId = View.generateViewId()
+ createHalfwayVerticalGuideline(halfGuidelineId)
+
+ clear(uiState.viewport.id)
+ constrainViewportToEndHalf(uiState, halfGuidelineId)
+
+ connect(uiState.turnCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(
+ uiState.turnCard.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+
+ clear(uiState.googleLogo.id, ConstraintSet.BOTTOM)
+ constrainLogoToTopOfEtaCard(uiState)
+ constrainEtaCardToBottomStart(uiState)
+ constrainButtonsToBottomEnd()
+ constrainStartUiControlsContainerAboveGoogleLogo(context, uiState)
+ }
+ }
+
+ private fun ConstraintSet.createHalfwayVerticalGuideline(halfGuidelineId: Int) {
+ create(halfGuidelineId, ConstraintSet.VERTICAL_GUIDELINE)
+ setGuidelinePercent(halfGuidelineId, 0.5f)
+ }
+
+ private fun ConstraintSet.constrainEtaCardToBottomStart(uiState: ActiveGuidanceUiState) {
+ connect(uiState.etaCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+ connect(uiState.etaCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
+ }
+
+ private fun ConstraintSet.constrainLogoToTopOfEtaCard(uiState: ActiveGuidanceUiState) {
+ connect(uiState.googleLogo.id, ConstraintSet.BOTTOM, uiState.etaCard.id, ConstraintSet.TOP)
+ }
+
+ private fun ConstraintSet.constrainButtonsToBottomEnd() {
+ clear(buttonsContainerId, ConstraintSet.BOTTOM)
+ clear(buttonsContainerId, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
+ connect(buttonsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ }
+
+ private fun ConstraintSet.constrainButtonsToTopOfEtaCard(
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ) {
+ clear(buttonsContainerId, ConstraintSet.BOTTOM)
+ clear(buttonsContainerId, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.BOTTOM, uiState.etaCard.id, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ setMargin(buttonsContainerId, ConstraintSet.TOP, dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context))
+ }
+
+ private fun ConstraintSet.constrainStartUiControlsContainerAboveGoogleLogo(
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ) {
+ connect(
+ startUiControlsContainerId,
+ ConstraintSet.BOTTOM,
+ uiState.googleLogo.id,
+ ConstraintSet.TOP,
+ )
+ connect(
+ startUiControlsContainerId,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ )
+ connect(
+ startUiControlsContainerId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ setMargin(
+ startUiControlsContainerId,
+ ConstraintSet.TOP,
+ dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context),
+ )
+ }
+
+ override fun onShowPrompt(navigationUiParent: NavigationUiParent, newPrompt: View) {
+ val context = navigationUiParent.viewContext
+ layout?.addView(newPrompt)
+
+ val state = activeGuidanceUiState
+ if (state != null) {
+ activeGuidanceWithPromptConstraintSet =
+ if (state.isWideMode) {
+ buildActiveGuidanceWithPromptWideModeConstraintSet(newPrompt)
+ } else {
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt)
+ }
+ activeGuidanceWithPromptConstraintSet?.applyTo(layout)
+ }
+ }
+
+ override fun onChangePrompt(
+ navigationUiParent: NavigationUiParent,
+ oldPrompt: View,
+ newPrompt: View,
+ ) {
+ val context = navigationUiParent.viewContext
+ activeGuidanceWithPromptConstraintSet?.clear(oldPrompt.id)
+
+ val currentLayout = checkNotNull(layout) { "layout must be initialized" }
+ currentLayout.removeView(oldPrompt)
+ currentLayout.addView(newPrompt)
+
+ val state = activeGuidanceUiState
+ if (state != null) {
+ activeGuidanceWithPromptConstraintSet =
+ if (state.isWideMode) {
+ buildActiveGuidanceWithPromptWideModeConstraintSet(newPrompt)
+ } else {
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt)
+ }
+ activeGuidanceWithPromptConstraintSet?.applyTo(currentLayout)
+ }
+ }
+
+ override fun onHidePrompt(navigationUiParent: NavigationUiParent, oldPrompt: View) {
+ activeGuidanceWithPromptConstraintSet?.clear(oldPrompt.id)
+ layout?.removeView(oldPrompt)
+
+ val state = activeGuidanceUiState
+ if (state != null && state.isWideMode) {
+ activeGuidanceWideModeConstraintSet?.applyTo(layout)
+ } else {
+ activeGuidanceConstraintSet?.applyTo(layout)
+ }
+ }
+
+ private fun buildActiveGuidanceWithPromptConstraintSet(
+ context: Context,
+ prompt: View,
+ ): ConstraintSet {
+ return ConstraintSet().apply {
+ clone(layout)
+
+ val state =
+ checkNotNull(activeGuidanceUiState) { "activeGuidanceUiState must be initialized" }
+
+ clear(state.viewport.id)
+ connect(state.viewport.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+ connect(state.viewport.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ setMargin(state.viewport.id, ConstraintSet.TOP, dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context))
+ connect(state.viewport.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ connect(state.viewport.id, ConstraintSet.BOTTOM, prompt.id, ConstraintSet.TOP)
+
+ clear(state.googleLogo.id, ConstraintSet.BOTTOM)
+ connect(state.googleLogo.id, ConstraintSet.BOTTOM, prompt.id, ConstraintSet.TOP)
+
+ connect(prompt.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+ connect(prompt.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
+ }
+ }
+
+ private fun buildActiveGuidanceWithPromptWideModeConstraintSet(prompt: View): ConstraintSet {
+ return ConstraintSet().apply {
+ clone(layout)
+
+ val halfGuidelineId = View.generateViewId()
+ createHalfwayVerticalGuideline(halfGuidelineId)
+
+ val state =
+ checkNotNull(activeGuidanceUiState) { "activeGuidanceUiState must be initialized" }
+
+ clear(state.viewport.id)
+ connect(state.viewport.id, ConstraintSet.START, halfGuidelineId, ConstraintSet.START)
+ connect(state.viewport.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(state.viewport.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ connect(
+ state.viewport.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ connect(prompt.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+ connect(prompt.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
+ }
+ }
+
+ private fun ConstraintSet.constrainViewportToEndHalf(uiState: UiState, halfGuidelineId: Int) {
+ connect(uiState.viewport.id, ConstraintSet.START, halfGuidelineId, ConstraintSet.START)
+ connect(uiState.viewport.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(uiState.viewport.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ connect(
+ uiState.viewport.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ }
+
+ override fun onSizeChanged(navigationUiParent: NavigationUiParent, state: UiState) {
+ if (isActiveGuidance) {
+ if (state.isWideMode) {
+ activeGuidanceWideModeConstraintSet?.applyTo(layout)
+ } else {
+ activeGuidanceConstraintSet?.applyTo(layout)
+ }
+ } else {
+ navigationReadyConstraintSet?.applyTo(layout)
+ }
+ }
+
+ private fun removeFromParentView(view: View?) {
+ if (view?.parent != null) {
+ (view.parent as ViewGroup).removeView(view)
+ }
+ }
+
+ private fun dpToPx(dp: Int, context: Context): Int {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp.toFloat(),
+ context.resources.displayMetrics,
+ )
+ .toInt()
+ }
+
+ companion object {
+ private const val DEFAULT_TURN_CARD_HEIGHT_DP = 150
+ private const val CUSTOM_BUTTON_SIZE_DP = 56
+ private val CUSTOM_UI_ELEMENT_COLOR = "#FFA500".toColorInt()
+ }
+}
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLandscapeLayoutDelegate.java b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLandscapeLayoutDelegate.java
new file mode 100644
index 0000000..7024e1e
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLandscapeLayoutDelegate.java
@@ -0,0 +1,1067 @@
+/*
+ * 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.navigationapidemo.layoutdelegate;
+
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.Configuration;
+import android.graphics.Color;
+import android.graphics.Outline;
+import android.graphics.drawable.GradientDrawable;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewOutlineProvider;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import androidx.annotation.ColorInt;
+import androidx.annotation.Nullable;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
+import androidx.transition.TransitionManager;
+import com.google.android.libraries.navigation.layoutcustomization.ActiveGuidanceUiState;
+import com.google.android.libraries.navigation.layoutcustomization.AutoHidingVerticalLayout;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationLayoutDelegate;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationReadyUiState;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiParent;
+import com.google.android.libraries.navigation.layoutcustomization.StyleValues;
+import com.google.android.libraries.navigation.layoutcustomization.UiState;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+/**
+ * A sample implementation of {@link NavigationLayoutDelegate} that features an expandable bottom
+ * sheet to which custom content can be added. In this sample, we demonstrate placing a prompt above
+ * the bottom sheet rather than at the bottom of the screen, including adding rounded corners at the
+ * bottom to match those of the top.
+ */
+public class BottomSheetLandscapeLayoutDelegate extends NavigationLayoutDelegate {
+ // The turn card's height varies dynamically based on the current maneuver.
+ // Constraining viewport heights to dynamic-height views causes the map camera
+ // to constantly adjust its zoom and focus, creating a jumpy user experience.
+ // We define a fixed estimate of the turn card's height to use as a stable top margin.
+ private static final int DEFAULT_TURN_CARD_HEIGHT_DP = 150;
+
+ private static final int CUSTOM_BUTTON_SIZE_DP = 56;
+ private static final int BUTTONS_CONTAINER_BOTTOM_MARGIN_DP = 8;
+ private static final int COLLAPSED_BOTTOM_SHEET_HEIGHT_DP = 110;
+
+ @ColorInt private static final int CUSTOM_UI_ELEMENT_COLOR = Color.parseColor("#FFA500");
+
+ private final int layoutId;
+ private final int exampleCustomButtonId;
+ private final int bottomSheetViewId;
+ private final int endControlsContainerId;
+ private final int halfGuidelineId;
+
+ private ConstraintLayout layout;
+ private View exampleCustomButton;
+ private View bottomSheetView;
+ private AutoHidingVerticalLayout endControlsContainer;
+
+ private boolean isBottomSheetExpanded = false;
+ private boolean isActiveGuidance = false;
+ @Nullable private View activePrompt;
+
+ // We cache our ConstraintSet definitions to avoid cloning or rebuilding
+ // constraint configurations programmatically on every transition. This optimization
+ // keeps UI state switches (such as entering active guidance or popping up prompts) highly
+ // performant.
+ private ConstraintSet navigationReadyConstraintSet;
+ private ConstraintSet activeGuidanceConstraintSet;
+ private ConstraintSet activeGuidanceWideModeConstraintSet;
+ private ConstraintSet activeGuidanceWithPromptConstraintSet;
+
+ private ActiveGuidanceUiState activeGuidanceUiState;
+
+ public BottomSheetLandscapeLayoutDelegate() {
+ layoutId = View.generateViewId();
+ exampleCustomButtonId = View.generateViewId();
+ bottomSheetViewId = View.generateViewId();
+ endControlsContainerId = View.generateViewId();
+ halfGuidelineId = View.generateViewId();
+ }
+
+ @Override
+ public void onEnterNavigationReady(
+ NavigationUiParent navigationUiParent, NavigationReadyUiState newState) {
+ Context context = navigationUiParent.getViewContext();
+
+ // Implementation Tip: For simplicity, this sample instantiates views and constraints
+ // programmatically. In a production application, you can safely inflate standard XML
+ // layout templates to build your layout hierarchies and define base UI constraints.
+
+ if (layout == null) {
+ layout = new ConstraintLayout(context);
+ layout.setId(layoutId);
+ }
+ layout.removeAllViews();
+ if (newState != null) {
+ createRootLayout(newState.getViewport(), newState.getGoogleLogo());
+ }
+
+ // We use AutoHidingVerticalLayout to create an adaptive vertical button container that
+ // automatically hides or shows child views based on available screen height.
+ if (endControlsContainer == null) {
+ endControlsContainer = new AutoHidingVerticalLayout(context);
+ endControlsContainer.setId(endControlsContainerId);
+ endControlsContainer.setChildSpacing(dpToPx(StyleValues.buttonVerticalPaddingDp(), context));
+ endControlsContainer.setPadding(0, 0, 0, 0);
+ }
+
+ endControlsContainer.removeAllViews();
+
+ // Add the Navigation Ready UI buttons
+ if (newState != null && newState.getNavigationReadyButtons() != null) {
+ for (NavigationUiButton button : newState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ AutoHidingVerticalLayout.LayoutParams buttonLayoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.getType() == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true;
+ }
+ endControlsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+ }
+
+ if (endControlsContainer.getParent() == null) {
+ LayoutParams endControlsLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(endControlsContainer, endControlsLayoutParams);
+ }
+
+ // Build constraint set for Navigation Ready state
+ if (navigationReadyConstraintSet == null) {
+ navigationReadyConstraintSet = buildNavigationReadyConstraintSet(newState);
+ }
+
+ // Apply the constraints
+ navigationReadyConstraintSet.applyTo(layout);
+
+ navigationUiParent.removeNavigationLayout(layout);
+ navigationUiParent.setNavigationLayout(layout);
+ }
+
+ private void createRootLayout(View viewport, View googleLogo) {
+ // Add the Viewport (REQUIRED):
+ // The viewport is an invisible bounding box used by Nav SDK to frame the vehicle
+ // chevron and the upcoming route line. We want to position this view such that it avoids
+ // being obscured by fully-opaque UI elements (like the turn card or the ETA card).
+ removeFromParentView(viewport);
+ LayoutParams viewportLayoutParams = new LayoutParams(MATCH_PARENT, MATCH_PARENT);
+ layout.addView(viewport, viewportLayoutParams);
+
+ // Add the Google Logo / Re-center Button (REQUIRED):
+ // This view displays the Google Maps logo during guidance and may transition into a
+ // "Re-center" button if the user scrolls away from the vehicle chevron.
+ removeFromParentView(googleLogo);
+ LayoutParams logoLayoutParams = new LayoutParams(MATCH_PARENT, WRAP_CONTENT);
+ layout.addView(googleLogo, logoLayoutParams);
+ }
+
+ private View createExampleCustomButton(Context context) {
+ FloatingActionButton button = new FloatingActionButton(context);
+ button.setId(exampleCustomButtonId);
+ button.setBackgroundTintList(ColorStateList.valueOf(CUSTOM_UI_ELEMENT_COLOR));
+ button.setCompatElevation(0f);
+ return button;
+ }
+
+ private AutoHidingVerticalLayout.LayoutParams createExampleCustomButtonLayoutParams(
+ Context context) {
+ int sizePx = dpToPx(CUSTOM_BUTTON_SIZE_DP, context);
+ AutoHidingVerticalLayout.LayoutParams layoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(sizePx, sizePx);
+
+ // We use the StyleValues API margins to align custom elements with standard
+ // Navigation SDK UI elements.
+ int horizontalMarginPx = dpToPx(StyleValues.buttonHorizontalPaddingDp(), context);
+ int verticalMarginPx = dpToPx(StyleValues.buttonVerticalPaddingDp(), context);
+ layoutParams.setMargins(
+ horizontalMarginPx, verticalMarginPx, horizontalMarginPx, verticalMarginPx);
+
+ return layoutParams;
+ }
+
+ @SuppressWarnings("SetTextI18n") // Sample layout delegate placeholder text is not translated.
+ private View createBottomSheetView(Context context) {
+ LinearLayout sheetView = new LinearLayout(context);
+ sheetView.setId(bottomSheetViewId);
+ sheetView.setOrientation(LinearLayout.VERTICAL);
+ sheetView.setGravity(Gravity.CENTER_HORIZONTAL);
+
+ // We use the StyleValues API margins and corner radii to visually match
+ // or align custom elements (like this bottom sheet) with standard Navigation SDK UI elements
+ // (such as the turn card or ETA card).
+ GradientDrawable sheetBackground = new GradientDrawable();
+ sheetBackground.setShape(GradientDrawable.RECTANGLE);
+ sheetBackground.setColor(CUSTOM_UI_ELEMENT_COLOR);
+ float cornerRadiusPx = dpToPx(StyleValues.defaultCornerRadiusDp(), context);
+ sheetBackground.setCornerRadius(cornerRadiusPx);
+ sheetView.setBackground(sheetBackground);
+
+ sheetView.setOnClickListener(v -> toggleBottomSheetState(context));
+
+ // Create the handle icon
+ View handle = new View(context);
+ LinearLayout.LayoutParams handleParams =
+ new LinearLayout.LayoutParams(dpToPx(36, context), dpToPx(4, context));
+ handleParams.topMargin = dpToPx(10, context);
+ handleParams.bottomMargin = dpToPx(6, context);
+ handle.setLayoutParams(handleParams);
+ GradientDrawable handleDrawable = new GradientDrawable();
+ handleDrawable.setShape(GradientDrawable.RECTANGLE);
+ handleDrawable.setColor(Color.WHITE);
+ handleDrawable.setCornerRadius(dpToPx(2, context));
+ handle.setBackground(handleDrawable);
+ sheetView.addView(handle);
+
+ // Add a text view to demonstrate adding custom content
+ TextView textView = new TextView(context);
+ textView.setText("Application-specific UI here");
+ textView.setGravity(Gravity.CENTER);
+ textView.setTextColor(Color.WHITE);
+ textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
+ LinearLayout.LayoutParams textParams =
+ new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ textParams.topMargin = dpToPx(8, context);
+ sheetView.addView(textView, textParams);
+
+ return sheetView;
+ }
+
+ private int getBottomSheetTargetHeight(Context context) {
+ if (isBottomSheetExpanded) {
+ int screenHeight = context.getResources().getDisplayMetrics().heightPixels;
+ boolean isLandscape =
+ context.getResources().getConfiguration().orientation
+ == Configuration.ORIENTATION_LANDSCAPE;
+ if (isLandscape) {
+ return screenHeight;
+ }
+ return (int) (screenHeight * 0.50f);
+ } else {
+ return dpToPx(COLLAPSED_BOTTOM_SHEET_HEIGHT_DP, context);
+ }
+ }
+
+ private LayoutParams createBottomSheetLayoutParams(Context context) {
+ int targetHeight = getBottomSheetTargetHeight(context);
+ return new LayoutParams(0, targetHeight);
+ }
+
+ private void toggleBottomSheetState(Context context) {
+ if (bottomSheetView == null) {
+ return;
+ }
+ isBottomSheetExpanded = !isBottomSheetExpanded;
+
+ boolean isLandscape =
+ context.getResources().getConfiguration().orientation
+ == Configuration.ORIENTATION_LANDSCAPE;
+ if (isLandscape && activeGuidanceUiState != null) {
+ // Rebuild and apply the wide mode constraint set with a transition animation
+ ConstraintSet constraintSet;
+ if (activePrompt != null) {
+ constraintSet = buildActiveGuidanceWithPromptWideModeConstraintSet(context, activePrompt);
+ activeGuidanceWithPromptConstraintSet = constraintSet;
+ activeGuidanceWideModeConstraintSet =
+ buildActiveGuidanceWideModeConstraintSet(context, activeGuidanceUiState);
+ } else {
+ constraintSet = buildActiveGuidanceWideModeConstraintSet(context, activeGuidanceUiState);
+ activeGuidanceWideModeConstraintSet = constraintSet;
+ }
+ TransitionManager.beginDelayedTransition(layout);
+ constraintSet.applyTo(layout);
+ } else {
+ // In portrait, continue using the old height-based expansion
+ int targetHeight = getBottomSheetTargetHeight(context);
+ LayoutParams params = bottomSheetView.getLayoutParams();
+ if (params != null) {
+ params.height = targetHeight;
+ bottomSheetView.setLayoutParams(params);
+ }
+ }
+ }
+
+ private ConstraintSet buildNavigationReadyConstraintSet(NavigationReadyUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain viewport to the edges of its parent
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+
+ // Constrain the logo to the bottom start corner
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ // Constrain end controls container to the bottom end corner
+ constraintSet.connect(
+ endControlsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP);
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ endControlsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ constraintSet.constrainHeight(endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT);
+ constraintSet.constrainDefaultHeight(
+ endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT_WRAP);
+ constraintSet.setVerticalBias(endControlsContainerId, 1.0f);
+
+ return constraintSet;
+ }
+
+ @Override
+ public void onLeaveNavigationReady(
+ NavigationUiParent navigationUiParent, NavigationReadyUiState oldState) {
+ if (endControlsContainer != null) {
+ endControlsContainer.removeAllViews();
+ }
+ if (oldState != null && oldState.getNavigationReadyButtons() != null) {
+ for (NavigationUiButton button : oldState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ }
+ }
+ if (layout != null) {
+ layout.removeAllViews();
+ navigationUiParent.removeNavigationLayout(layout);
+ }
+ }
+
+ @Override
+ public void onEnterActiveGuidance(
+ NavigationUiParent navigationUiParent,
+ NavigationReadyUiState oldState,
+ ActiveGuidanceUiState newState) {
+ activeGuidanceUiState = newState;
+ isActiveGuidance = true;
+
+ Context context = navigationUiParent.getViewContext();
+ if (layout == null) {
+ layout = new ConstraintLayout(context);
+ layout.setId(layoutId);
+ }
+ layout.removeAllViews();
+ if (newState != null) {
+ createRootLayout(newState.getViewport(), newState.getGoogleLogo());
+ }
+
+ if (endControlsContainer == null) {
+ endControlsContainer = new AutoHidingVerticalLayout(context);
+ endControlsContainer.setId(endControlsContainerId);
+ endControlsContainer.setGravity(Gravity.BOTTOM | Gravity.END);
+ endControlsContainer.setChildSpacing(dpToPx(StyleValues.buttonVerticalPaddingDp(), context));
+ endControlsContainer.setPadding(0, 0, 0, 0);
+ }
+
+ endControlsContainer.removeAllViews();
+
+ // Add the turn card
+ if (newState != null && newState.getTurnCard() != null) {
+ removeFromParentView(newState.getTurnCard());
+ LayoutParams turnCardLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(newState.getTurnCard(), turnCardLayoutParams);
+ }
+
+ // Add the custom bottom sheet
+ if (bottomSheetView == null) {
+ bottomSheetView = createBottomSheetView(context);
+ }
+ if (bottomSheetView.getParent() == null) {
+ LayoutParams bottomSheetLayoutParams = createBottomSheetLayoutParams(context);
+ layout.addView(bottomSheetView, bottomSheetLayoutParams);
+ }
+
+ // Remove the Navigation Ready UI buttons
+ if (oldState != null && oldState.getNavigationReadyButtons() != null) {
+ for (NavigationUiButton button : oldState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ }
+ }
+
+ // Add the example button to its container
+ if (exampleCustomButton == null) {
+ exampleCustomButton = createExampleCustomButton(context);
+ }
+ removeFromParentView(exampleCustomButton);
+ AutoHidingVerticalLayout.LayoutParams customButtonLayoutParams =
+ createExampleCustomButtonLayoutParams(context);
+ endControlsContainer.addView(exampleCustomButton, customButtonLayoutParams);
+
+ // By adding all buttons to the AutoHidingVerticalLayout, we can easily incorporate the latest
+ // set of buttons when upgrading without any code changes required
+ if (newState != null && newState.getActiveGuidanceButtons() != null) {
+ for (NavigationUiButton button : newState.getActiveGuidanceButtons()) {
+ removeFromParentView(button.getView());
+ AutoHidingVerticalLayout.LayoutParams buttonLayoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.getType() == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true;
+ }
+ endControlsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+ }
+
+ if (endControlsContainer.getParent() == null) {
+ LayoutParams endControlsLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(endControlsContainer, endControlsLayoutParams);
+ }
+
+ // Build constraint set for Active Guidance state
+ if (activeGuidanceConstraintSet == null) {
+ activeGuidanceConstraintSet = buildActiveGuidanceConstraintSet(context, newState);
+ }
+ if (activeGuidanceWideModeConstraintSet == null) {
+ activeGuidanceWideModeConstraintSet =
+ buildActiveGuidanceWideModeConstraintSet(context, newState);
+ }
+
+ // Apply the constraints
+ if (newState != null && newState.isWideMode()) {
+ activeGuidanceWideModeConstraintSet.applyTo(layout);
+ } else {
+ activeGuidanceConstraintSet.applyTo(layout);
+ }
+
+ navigationUiParent.removeNavigationLayout(layout);
+ navigationUiParent.setNavigationLayout(layout);
+ }
+
+ @Override
+ public void onLeaveActiveGuidance(
+ NavigationUiParent navigationUiParent,
+ ActiveGuidanceUiState oldState,
+ NavigationReadyUiState newState) {
+ isActiveGuidance = false;
+
+ // Remove Active Guidance UI elements
+ if (oldState != null && oldState.getTurnCard() != null) {
+ removeFromParentView(oldState.getTurnCard());
+ }
+ removeFromParentView(bottomSheetView);
+ removeFromParentView(exampleCustomButton);
+ if (oldState != null && oldState.getActiveGuidanceButtons() != null) {
+ for (NavigationUiButton button : oldState.getActiveGuidanceButtons()) {
+ removeFromParentView(button.getView());
+ }
+ }
+
+ // Add Navigation Ready UI buttons
+ if (newState != null && newState.getNavigationReadyButtons() != null) {
+ for (NavigationUiButton button : newState.getNavigationReadyButtons()) {
+ if (button.getView().getParent() == null) {
+ AutoHidingVerticalLayout.LayoutParams buttonLayoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.getType() == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true;
+ }
+ endControlsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+ }
+ }
+
+ if (navigationReadyConstraintSet != null && layout != null) {
+ navigationReadyConstraintSet.applyTo(layout);
+ }
+ }
+
+ private ConstraintSet buildActiveGuidanceConstraintSet(
+ Context context, ActiveGuidanceUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain turn card to top start corner
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ // Constrain viewport to top of bottom sheet
+ constraintSet.clear(uiState.getViewport().getId());
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+
+ // Instead of constraining the viewport's top directly to the bottom of the turn card
+ // (which varies in height and would trigger jumpy camera framing updates), we position
+ // it relative to the top of the parent layout with a fixed margin representing the
+ // estimated turn card height.
+ constraintSet.setMargin(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context));
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(), ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP);
+
+ // Constrain the logo to the top of the bottom sheet
+ constraintSet.clear(uiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ bottomSheetViewId,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ constrainBottomSheet(constraintSet, context);
+
+ // Constrain end controls container to top of bottom sheet
+ constrainEndControlsContainer(
+ constraintSet, context, uiState, bottomSheetViewId, /* isWideMode= */ false);
+
+ return constraintSet;
+ }
+
+ private void constrainBottomSheet(ConstraintSet constraintSet, Context context) {
+ constraintSet.connect(
+ bottomSheetViewId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START);
+ constraintSet.connect(
+ bottomSheetViewId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ constraintSet.connect(
+ bottomSheetViewId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+ }
+
+ private void constrainEndControlsContainer(
+ ConstraintSet constraintSet,
+ Context context,
+ ActiveGuidanceUiState uiState,
+ int bottomAnchorId,
+ boolean isWideMode) {
+ constraintSet.clear(endControlsContainerId);
+ if (isWideMode) {
+ // In landscape (wide) mode, the turn card is on the left half and doesn't overlap the
+ // buttons container on the right. Connect to parent top to maximize available height.
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ dpToPx(8, context));
+ } else {
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.TOP,
+ uiState.getTurnCard().getId(),
+ ConstraintSet.BOTTOM,
+ dpToPx(8, context));
+ }
+ int marginPx;
+ int bottomPaddingPx;
+ if (bottomAnchorId == ConstraintSet.PARENT_ID) {
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ marginPx = 0;
+ bottomPaddingPx = dpToPx(20, context);
+ } else {
+ constraintSet.connect(
+ endControlsContainerId, ConstraintSet.BOTTOM, bottomAnchorId, ConstraintSet.TOP);
+ marginPx = dpToPx(BUTTONS_CONTAINER_BOTTOM_MARGIN_DP, context);
+ bottomPaddingPx = 0;
+ }
+ constraintSet.setMargin(endControlsContainerId, ConstraintSet.BOTTOM, marginPx);
+
+ if (endControlsContainer != null) {
+ endControlsContainer.setPadding(
+ endControlsContainer.getPaddingLeft(),
+ endControlsContainer.getPaddingTop(),
+ endControlsContainer.getPaddingRight(),
+ bottomPaddingPx);
+ }
+ constraintSet.connect(
+ endControlsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ constraintSet.constrainHeight(endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT);
+ constraintSet.constrainDefaultHeight(
+ endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT_WRAP);
+ constraintSet.setVerticalBias(endControlsContainerId, 1.0f);
+ }
+
+ @Override
+ public void onShowPrompt(NavigationUiParent navigationUiParent, View newPrompt) {
+ activePrompt = newPrompt;
+ Context context = navigationUiParent.getViewContext();
+ // By default, prompts in non-wide mode are positioned at the bottom of the screen with
+ // square bottom corners. When positioning the prompt above a custom bottom sheet, applying
+ // rounded bottom corners gives the prompt a clean, floating appearance that matches its top
+ // corners.
+ applyPromptRoundedBottomCorners(context, newPrompt);
+ layout.addView(newPrompt);
+
+ // When a prompt is displayed above the bottom sheet, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ activeGuidanceUiState != null && activeGuidanceUiState.isWideMode()
+ ? buildActiveGuidanceWithPromptWideModeConstraintSet(context, newPrompt)
+ : buildActiveGuidanceWithPromptConstraintSet(context, newPrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onChangePrompt(
+ NavigationUiParent navigationUiParent, View oldPrompt, View newPrompt) {
+ activePrompt = newPrompt;
+ Context context = navigationUiParent.getViewContext();
+ applyPromptRoundedBottomCorners(context, newPrompt);
+
+ activeGuidanceWithPromptConstraintSet.clear(oldPrompt.getId());
+ layout.removeView(oldPrompt);
+ layout.addView(newPrompt);
+
+ // When a prompt is displayed above the bottom sheet, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ activeGuidanceUiState != null && activeGuidanceUiState.isWideMode()
+ ? buildActiveGuidanceWithPromptWideModeConstraintSet(context, newPrompt)
+ : buildActiveGuidanceWithPromptConstraintSet(context, newPrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onHidePrompt(NavigationUiParent navigationUiParent, View oldPrompt) {
+ activePrompt = null;
+ activeGuidanceWithPromptConstraintSet.clear(oldPrompt.getId());
+ layout.removeView(oldPrompt);
+ if (activeGuidanceUiState != null && activeGuidanceUiState.isWideMode()) {
+ activeGuidanceWideModeConstraintSet.applyTo(layout);
+ } else {
+ activeGuidanceConstraintSet.applyTo(layout);
+ }
+ }
+
+ private static void applyPromptRoundedBottomCorners(Context context, View prompt) {
+ if (prompt == null) {
+ return;
+ }
+
+ // We apply bottom rounded corners to the prompt view so that it matches the top rounded corners
+ // when positioned above the bottom sheet. We use the StyleValues.defaultCornerRadiusDp() API
+ // to retrieve the standard default corner radius. To avoid clipping the top shadow or top
+ // corners, we apply a custom ViewOutlineProvider that offsets the outline bound at the top.
+ float cornerRadius = dpToPx(StyleValues.defaultCornerRadiusDp(), context);
+ int topOffsetPx = 0;
+ int sidePaddingPx = 0;
+
+ GradientDrawable shape = new GradientDrawable();
+ shape.setShape(GradientDrawable.RECTANGLE);
+ shape.setColor(Color.WHITE);
+ shape.setCornerRadius(cornerRadius);
+ prompt.setBackground(shape);
+
+ prompt.setOutlineProvider(
+ new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setRoundRect(
+ sidePaddingPx,
+ topOffsetPx,
+ view.getWidth() - sidePaddingPx,
+ view.getHeight(),
+ cornerRadius);
+ }
+ });
+ prompt.setClipToOutline(true);
+ }
+
+ private ConstraintSet buildActiveGuidanceWithPromptConstraintSet(Context context, View prompt) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain viewport to top of prompt
+ constraintSet.clear(activeGuidanceUiState.getViewport().getId());
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.setMargin(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context));
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ prompt.getId(),
+ ConstraintSet.TOP);
+
+ // Constrain logo to top of prompt
+ constraintSet.clear(activeGuidanceUiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ activeGuidanceUiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ prompt.getId(),
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ activeGuidanceUiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ constrainBottomSheet(constraintSet, context);
+
+ // Constrain prompt above bottom sheet
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START);
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP);
+
+ // When the prompt is positioned above the bottom sheet rather than at the bottom of the screen,
+ // horizontal padding is added in non-wide mode to visually match standard Navigation SDK
+ // elements
+ // such as the turn card. In wide mode, the SDK automatically applies horizontal padding to the
+ // prompt, so this margin is only applied when in non-wide mode.
+ // We can use StyleValues.headerFooterSidePaddingDp() to visually align with the turn card.
+ if (!activeGuidanceUiState.isWideMode()) {
+ int promptSideMarginPx = dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context);
+ constraintSet.setMargin(prompt.getId(), ConstraintSet.START, promptSideMarginPx);
+ constraintSet.setMargin(prompt.getId(), ConstraintSet.END, promptSideMarginPx);
+ }
+
+ // Reposition end controls container above prompt
+ constrainEndControlsContainer(
+ constraintSet, context, activeGuidanceUiState, prompt.getId(), /* isWideMode= */ false);
+
+ return constraintSet;
+ }
+
+ private ConstraintSet buildActiveGuidanceWideModeConstraintSet(
+ Context context, ActiveGuidanceUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ constraintSet.create(halfGuidelineId, ConstraintSet.VERTICAL_GUIDELINE);
+ constraintSet.setGuidelinePercent(halfGuidelineId, 0.5f);
+
+ constraintSet.clear(uiState.getViewport().getId());
+ constraintSet.connect(
+ uiState.getViewport().getId(), ConstraintSet.START, halfGuidelineId, ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+
+ int sideMarginPx = dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context);
+
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.setMargin(uiState.getTurnCard().getId(), ConstraintSet.START, sideMarginPx);
+ constraintSet.clear(uiState.getTurnCard().getId(), ConstraintSet.END);
+ constraintSet.constrainWidth(uiState.getTurnCard().getId(), ConstraintSet.WRAP_CONTENT);
+
+ int turnCardId = uiState.getTurnCard().getId();
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.START, turnCardId, ConstraintSet.START);
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.END, turnCardId, ConstraintSet.END);
+ constraintSet.connect(
+ bottomSheetViewId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+
+ if (isBottomSheetExpanded) {
+ // Exactly overlap the turn card
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.TOP, turnCardId, ConstraintSet.TOP);
+ constraintSet.constrainHeight(bottomSheetViewId, ConstraintSet.MATCH_CONSTRAINT);
+ } else {
+ constraintSet.clear(bottomSheetViewId, ConstraintSet.TOP);
+ constraintSet.constrainHeight(
+ bottomSheetViewId, dpToPx(COLLAPSED_BOTTOM_SHEET_HEIGHT_DP, context));
+ }
+
+ constraintSet.clear(uiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ bottomSheetViewId,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.constrainWidth(uiState.getGoogleLogo().getId(), ConstraintSet.WRAP_CONTENT);
+ constraintSet.setHorizontalBias(uiState.getGoogleLogo().getId(), 0f);
+
+ constrainEndControlsContainer(
+ constraintSet, context, uiState, ConstraintSet.PARENT_ID, /* isWideMode= */ true);
+
+ return constraintSet;
+ }
+
+ private ConstraintSet buildActiveGuidanceWithPromptWideModeConstraintSet(
+ Context context, View prompt) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ constraintSet.create(halfGuidelineId, ConstraintSet.VERTICAL_GUIDELINE);
+ constraintSet.setGuidelinePercent(halfGuidelineId, 0.5f);
+
+ constraintSet.clear(activeGuidanceUiState.getViewport().getId());
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.START,
+ halfGuidelineId,
+ ConstraintSet.START);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+
+ int sideMarginPx = dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context);
+
+ constraintSet.connect(
+ activeGuidanceUiState.getTurnCard().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ activeGuidanceUiState.getTurnCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.setMargin(
+ activeGuidanceUiState.getTurnCard().getId(), ConstraintSet.START, sideMarginPx);
+ constraintSet.clear(activeGuidanceUiState.getTurnCard().getId(), ConstraintSet.END);
+ constraintSet.constrainWidth(
+ activeGuidanceUiState.getTurnCard().getId(), ConstraintSet.WRAP_CONTENT);
+
+ int turnCardId = activeGuidanceUiState.getTurnCard().getId();
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.START, turnCardId, ConstraintSet.START);
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.END, turnCardId, ConstraintSet.END);
+ constraintSet.connect(
+ bottomSheetViewId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+
+ if (isBottomSheetExpanded) {
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.TOP, turnCardId, ConstraintSet.TOP);
+ constraintSet.constrainHeight(bottomSheetViewId, ConstraintSet.MATCH_CONSTRAINT);
+ } else {
+ constraintSet.clear(bottomSheetViewId, ConstraintSet.TOP);
+ constraintSet.constrainHeight(
+ bottomSheetViewId, dpToPx(COLLAPSED_BOTTOM_SHEET_HEIGHT_DP, context));
+ }
+
+ constraintSet.connect(prompt.getId(), ConstraintSet.START, turnCardId, ConstraintSet.START);
+ constraintSet.connect(prompt.getId(), ConstraintSet.END, turnCardId, ConstraintSet.END);
+ constraintSet.constrainWidth(prompt.getId(), ConstraintSet.MATCH_CONSTRAINT);
+ if (isBottomSheetExpanded) {
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+ } else {
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP);
+ }
+
+ constraintSet.clear(activeGuidanceUiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ activeGuidanceUiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ bottomSheetViewId,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ activeGuidanceUiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ activeGuidanceUiState.getGoogleLogo().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.constrainWidth(
+ activeGuidanceUiState.getGoogleLogo().getId(), ConstraintSet.WRAP_CONTENT);
+ constraintSet.setHorizontalBias(activeGuidanceUiState.getGoogleLogo().getId(), 0f);
+
+ constrainEndControlsContainer(
+ constraintSet,
+ context,
+ activeGuidanceUiState,
+ ConstraintSet.PARENT_ID,
+ /* isWideMode= */ true);
+
+ return constraintSet;
+ }
+
+ private void updateBottomSheetHeightForPortrait(Context context) {
+ if (bottomSheetView != null) {
+ int targetHeight = getBottomSheetTargetHeight(context);
+ LayoutParams params = bottomSheetView.getLayoutParams();
+ if (params != null) {
+ params.height = targetHeight;
+ bottomSheetView.setLayoutParams(params);
+ }
+ }
+ }
+
+ @Override
+ public void onSizeChanged(NavigationUiParent parent, UiState state) {
+ if (state instanceof ActiveGuidanceUiState) {
+ activeGuidanceUiState = (ActiveGuidanceUiState) state;
+ }
+ if (isActiveGuidance) {
+ View localActivePrompt = activePrompt;
+ if (localActivePrompt != null) {
+ activeGuidanceWithPromptConstraintSet =
+ state.isWideMode()
+ ? buildActiveGuidanceWithPromptWideModeConstraintSet(
+ parent.getViewContext(), localActivePrompt)
+ : buildActiveGuidanceWithPromptConstraintSet(
+ parent.getViewContext(), localActivePrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ if (!state.isWideMode()) {
+ updateBottomSheetHeightForPortrait(parent.getViewContext());
+ }
+ } else {
+ if (state.isWideMode()) {
+ if (activeGuidanceUiState != null) {
+ activeGuidanceWideModeConstraintSet =
+ buildActiveGuidanceWideModeConstraintSet(
+ parent.getViewContext(), activeGuidanceUiState);
+ activeGuidanceWideModeConstraintSet.applyTo(layout);
+ }
+ } else {
+ activeGuidanceConstraintSet.applyTo(layout);
+ updateBottomSheetHeightForPortrait(parent.getViewContext());
+ }
+ }
+ } else {
+ if (navigationReadyConstraintSet != null) {
+ navigationReadyConstraintSet.applyTo(layout);
+ }
+ }
+ }
+
+ private void removeFromParentView(View view) {
+ if (view != null && view.getParent() != null) {
+ ((ViewGroup) view.getParent()).removeView(view);
+ }
+ }
+
+ private static int dpToPx(int dp, Context context) {
+ return (int)
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics());
+ }
+}
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLandscapeLayoutDelegateKt.kt b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLandscapeLayoutDelegateKt.kt
new file mode 100644
index 0000000..72129ec
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLandscapeLayoutDelegateKt.kt
@@ -0,0 +1,1080 @@
+/*
+ * 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.navigationapidemo.layoutdelegate
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.ColorStateList
+import android.content.res.Configuration
+import android.graphics.Color
+import android.graphics.Outline
+import android.graphics.drawable.GradientDrawable
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.view.ViewOutlineProvider
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.core.graphics.toColorInt
+import androidx.transition.TransitionManager
+import com.google.android.libraries.navigation.layoutcustomization.ActiveGuidanceUiState
+import com.google.android.libraries.navigation.layoutcustomization.AutoHidingVerticalLayout
+import com.google.android.libraries.navigation.layoutcustomization.NavigationLayoutDelegate
+import com.google.android.libraries.navigation.layoutcustomization.NavigationReadyUiState
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiParent
+import com.google.android.libraries.navigation.layoutcustomization.StyleValues
+import com.google.android.libraries.navigation.layoutcustomization.UiState
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+
+/**
+ * A sample implementation of [NavigationLayoutDelegate] in Kotlin that features an expandable
+ * bottom sheet to which custom content can be added. This sample also demonstrates placing a prompt
+ * above the bottom sheet rather than at the bottom of the screen, including adding rounded corners
+ * at the bottom to match those of the top.
+ */
+class BottomSheetLandscapeLayoutDelegateKt : NavigationLayoutDelegate() {
+
+ private val layoutId = View.generateViewId()
+ private val exampleCustomButtonId = View.generateViewId()
+ private val bottomSheetViewId = View.generateViewId()
+ private val endControlsContainerId = View.generateViewId()
+ private val halfGuidelineId = View.generateViewId()
+
+ private var layout: ConstraintLayout? = null
+ private var exampleCustomButton: View? = null
+ private var bottomSheetView: View? = null
+ private var endControlsContainer: AutoHidingVerticalLayout? = null
+
+ private var isBottomSheetExpanded = false
+ private var isActiveGuidance = false
+ private var activePrompt: View? = null
+
+ // We cache our ConstraintSet definitions to avoid cloning or rebuilding
+ // constraint configurations programmatically on every transition. This optimization
+ // keeps UI state switches (such as entering active guidance or popping up prompts) highly
+ // performant.
+ private var navigationReadyConstraintSet: ConstraintSet? = null
+ private var activeGuidanceConstraintSet: ConstraintSet? = null
+ private var activeGuidanceWideModeConstraintSet: ConstraintSet? = null
+ private var activeGuidanceWithPromptConstraintSet: ConstraintSet? = null
+
+ private var activeGuidanceUiState: ActiveGuidanceUiState? = null
+
+ override fun onEnterNavigationReady(
+ navigationUiParent: NavigationUiParent,
+ newState: NavigationReadyUiState?,
+ ) {
+ val context = navigationUiParent.viewContext
+ val layout =
+ this.layout ?: ConstraintLayout(context).apply { id = layoutId }.also { this.layout = it }
+ layout.removeAllViews()
+
+ // Implementation Tip: For simplicity, this sample instantiates views and constraints
+ // programmatically. In a production application, you can safely inflate standard XML
+ // layout templates to build your layout hierarchies and define base UI constraints.
+
+ if (newState != null) {
+ ensureRootLayoutCreated(layout, newState.viewport, newState.googleLogo)
+ }
+
+ // We use AutoHidingVerticalLayout to create an adaptive vertical button container that
+ // automatically hides or shows child views based on available screen height.
+ if (endControlsContainer == null) {
+ endControlsContainer =
+ AutoHidingVerticalLayout(context).apply {
+ id = endControlsContainerId
+ childSpacing = dpToPx(StyleValues.buttonVerticalPaddingDp(), context)
+ setPadding(0, 0, 0, 0)
+ }
+ }
+
+ endControlsContainer?.removeAllViews()
+
+ // Add the Navigation Ready UI buttons
+ newState?.navigationReadyButtons?.forEach { button ->
+ removeFromParentView(button.view)
+ val buttonLayoutParams = AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.type == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true
+ }
+ endControlsContainer?.addView(button.view, buttonLayoutParams)
+ }
+
+ if (endControlsContainer?.parent == null) {
+ val endControlsLayoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ layout.addView(endControlsContainer, endControlsLayoutParams)
+ }
+
+ // Build constraint set for Navigation Ready state
+ if (navigationReadyConstraintSet == null && newState != null) {
+ navigationReadyConstraintSet = buildNavigationReadyConstraintSet(newState)
+ }
+
+ // Apply the constraints
+ navigationReadyConstraintSet?.applyTo(layout)
+
+ navigationUiParent.removeNavigationLayout(layout)
+ navigationUiParent.setNavigationLayout(layout)
+ }
+
+ private fun ensureRootLayoutCreated(layout: ConstraintLayout, viewport: View, googleLogo: View) {
+ // Add the Viewport (REQUIRED):
+ // The viewport is an invisible bounding box used by Nav SDK to frame the vehicle
+ // chevron and the upcoming route line. We want to position this view such that it avoids
+ // being obscured by fully-opaque UI elements (like the turn card or the ETA card).
+ removeFromParentView(viewport)
+ val viewportLayoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+ layout.addView(viewport, viewportLayoutParams)
+
+ // Add the Google Logo / Re-center Button (REQUIRED):
+ // This view displays the Google Maps logo during guidance and may transition into a
+ // "Re-center" button if the user scrolls away from the vehicle chevron.
+ removeFromParentView(googleLogo)
+ val logoLayoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ layout.addView(googleLogo, logoLayoutParams)
+ }
+
+ private fun createExampleCustomButton(context: Context): View {
+ return FloatingActionButton(context).apply {
+ id = exampleCustomButtonId
+ backgroundTintList = ColorStateList.valueOf(CUSTOM_UI_ELEMENT_COLOR)
+ compatElevation = 0f
+ }
+ }
+
+ private fun createExampleCustomButtonLayoutParams(
+ context: Context
+ ): AutoHidingVerticalLayout.LayoutParams {
+ val sizePx = dpToPx(CUSTOM_BUTTON_SIZE_DP, context)
+ val layoutParams = AutoHidingVerticalLayout.LayoutParams(sizePx, sizePx)
+
+ // We use the StyleValues API margins to align custom elements with standard
+ // Navigation SDK UI elements.
+ val horizontalMarginPx = dpToPx(StyleValues.buttonHorizontalPaddingDp(), context)
+ val verticalMarginPx = dpToPx(StyleValues.buttonVerticalPaddingDp(), context)
+ layoutParams.setMargins(
+ horizontalMarginPx,
+ verticalMarginPx,
+ horizontalMarginPx,
+ verticalMarginPx,
+ )
+ return layoutParams
+ }
+
+ private fun createBottomSheetView(context: Context): View {
+ val sheetView =
+ LinearLayout(context).apply {
+ id = bottomSheetViewId
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER_HORIZONTAL
+ }
+
+ // We use the StyleValues API margins and corner radii to visually match
+ // or align custom elements (like this bottom sheet) with standard Navigation SDK UI elements
+ // (such as the turn card or ETA card).
+ val sheetBackground =
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(CUSTOM_UI_ELEMENT_COLOR)
+ val cornerRadiusPx = dpToPx(StyleValues.defaultCornerRadiusDp(), context).toFloat()
+ cornerRadius = cornerRadiusPx
+ }
+ sheetView.background = sheetBackground
+ sheetView.setOnClickListener { toggleBottomSheetState(context) }
+
+ // Create the handle icon
+ val handle = View(context)
+ val handleParams =
+ LinearLayout.LayoutParams(dpToPx(36, context), dpToPx(4, context)).apply {
+ topMargin = dpToPx(10, context)
+ bottomMargin = dpToPx(6, context)
+ }
+ handle.layoutParams = handleParams
+ val handleDrawable =
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(Color.WHITE)
+ cornerRadius = dpToPx(2, context).toFloat()
+ }
+ handle.background = handleDrawable
+ sheetView.addView(handle)
+
+ // Add a text view to demonstrate adding custom content
+ val textView =
+ TextView(context).apply {
+ text = "Application-specific UI here"
+ gravity = Gravity.CENTER
+ setTextColor(Color.WHITE)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
+ }
+ val textParams =
+ LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { topMargin = dpToPx(8, context) }
+ sheetView.addView(textView, textParams)
+
+ return sheetView
+ }
+
+ private fun getBottomSheetTargetHeight(context: Context): Int {
+ return if (isBottomSheetExpanded) {
+ val screenHeight = context.resources.displayMetrics.heightPixels
+ val isLandscape =
+ context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ if (isLandscape) {
+ screenHeight
+ } else {
+ (screenHeight * 0.50f).toInt()
+ }
+ } else {
+ dpToPx(COLLAPSED_BOTTOM_SHEET_HEIGHT_DP, context)
+ }
+ }
+
+ private fun createBottomSheetLayoutParams(context: Context): ConstraintLayout.LayoutParams {
+ val targetHeight = getBottomSheetTargetHeight(context)
+ return ConstraintLayout.LayoutParams(0, targetHeight)
+ }
+
+ private fun toggleBottomSheetState(context: Context) {
+ val view = bottomSheetView ?: return
+ val layout = this.layout ?: return
+ isBottomSheetExpanded = !isBottomSheetExpanded
+
+ val isLandscape =
+ context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ val activeGuidance = activeGuidanceUiState
+ val prompt = activePrompt
+ if (isLandscape && activeGuidance != null) {
+ val constraintSet =
+ if (prompt != null) {
+ buildActiveGuidanceWithPromptWideModeConstraintSet(context, prompt)
+ } else {
+ buildActiveGuidanceWideModeConstraintSet(context, activeGuidance)
+ }
+ TransitionManager.beginDelayedTransition(layout)
+ constraintSet.applyTo(layout)
+ } else {
+ val targetHeight = getBottomSheetTargetHeight(context)
+ val params = view.layoutParams
+ if (params != null) {
+ params.height = targetHeight
+ view.layoutParams = params
+ }
+ }
+ }
+
+ private fun buildNavigationReadyConstraintSet(uiState: NavigationReadyUiState): ConstraintSet {
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+
+ // Constrain viewport to the edges of its parent
+ val viewportId = uiState.viewport.id
+ constraintSet.connect(
+ viewportId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(viewportId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ constraintSet.connect(viewportId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ constraintSet.connect(
+ viewportId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ // Constrain the logo to the bottom start corner
+ val logoId = uiState.googleLogo.id
+ constraintSet.connect(
+ logoId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ constraintSet.connect(logoId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+
+ // Constrain end controls container to the bottom end corner
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.constrainHeight(endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT)
+ constraintSet.constrainDefaultHeight(
+ endControlsContainerId,
+ ConstraintSet.MATCH_CONSTRAINT_WRAP,
+ )
+ constraintSet.setVerticalBias(endControlsContainerId, 1.0f)
+
+ return constraintSet
+ }
+
+ override fun onLeaveNavigationReady(
+ navigationUiParent: NavigationUiParent,
+ oldState: NavigationReadyUiState?,
+ ) {
+ endControlsContainer?.removeAllViews()
+ oldState?.navigationReadyButtons?.forEach { button -> removeFromParentView(button.view) }
+ layout?.let {
+ it.removeAllViews()
+ navigationUiParent.removeNavigationLayout(it)
+ }
+ }
+
+ override fun onEnterActiveGuidance(
+ navigationUiParent: NavigationUiParent,
+ oldState: NavigationReadyUiState?,
+ newState: ActiveGuidanceUiState?,
+ ) {
+ activeGuidanceUiState = newState
+ isActiveGuidance = true
+ val context = navigationUiParent.viewContext
+ val layout =
+ this.layout ?: ConstraintLayout(context).apply { id = layoutId }.also { this.layout = it }
+ layout.removeAllViews()
+
+ if (newState != null) {
+ ensureRootLayoutCreated(layout, newState.viewport, newState.googleLogo)
+ }
+
+ if (endControlsContainer == null) {
+ endControlsContainer =
+ AutoHidingVerticalLayout(context).apply {
+ id = endControlsContainerId
+ childSpacing = dpToPx(StyleValues.buttonVerticalPaddingDp(), context)
+ setPadding(0, 0, 0, 0)
+ }
+ }
+ endControlsContainer?.removeAllViews()
+
+ // Add the turn card
+ newState?.turnCard?.let { turnCard ->
+ removeFromParentView(turnCard)
+ val turnCardLayoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ layout.addView(turnCard, turnCardLayoutParams)
+ }
+
+ // Add the custom bottom sheet
+ if (bottomSheetView == null) {
+ bottomSheetView = createBottomSheetView(context)
+ }
+ if (bottomSheetView?.parent == null) {
+ val bottomSheetLayoutParams = createBottomSheetLayoutParams(context)
+ layout.addView(bottomSheetView, bottomSheetLayoutParams)
+ }
+
+ // Remove the Navigation Ready UI buttons
+ oldState?.navigationReadyButtons?.forEach { button -> removeFromParentView(button.view) }
+
+ // Add the example button to its container
+ if (exampleCustomButton == null) {
+ exampleCustomButton = createExampleCustomButton(context)
+ }
+ removeFromParentView(exampleCustomButton)
+ val customButtonLayoutParams = createExampleCustomButtonLayoutParams(context)
+ endControlsContainer?.addView(exampleCustomButton, customButtonLayoutParams)
+
+ // By adding all buttons to the AutoHidingVerticalLayout, we can easily incorporate the latest
+ // set of buttons when upgrading without any code changes required
+ newState?.activeGuidanceButtons?.forEach { button ->
+ removeFromParentView(button.view)
+ val buttonLayoutParams = AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.type == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true
+ }
+ endControlsContainer?.addView(button.view, buttonLayoutParams)
+ }
+
+ if (endControlsContainer?.parent == null) {
+ val endControlsLayoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ layout.addView(endControlsContainer, endControlsLayoutParams)
+ }
+
+ // Build constraint set for Active Guidance state
+ if (activeGuidanceConstraintSet == null && newState != null) {
+ activeGuidanceConstraintSet = buildActiveGuidanceConstraintSet(context, newState)
+ }
+ if (activeGuidanceWideModeConstraintSet == null && newState != null) {
+ activeGuidanceWideModeConstraintSet =
+ buildActiveGuidanceWideModeConstraintSet(context, newState)
+ }
+
+ // Apply the constraints
+ if (newState != null && newState.isWideMode) {
+ activeGuidanceWideModeConstraintSet?.applyTo(layout)
+ } else {
+ activeGuidanceConstraintSet?.applyTo(layout)
+ }
+
+ navigationUiParent.removeNavigationLayout(layout)
+ navigationUiParent.setNavigationLayout(layout)
+ }
+
+ override fun onLeaveActiveGuidance(
+ navigationUiParent: NavigationUiParent,
+ oldState: ActiveGuidanceUiState?,
+ newState: NavigationReadyUiState?,
+ ) {
+ isActiveGuidance = false
+ // Remove Active Guidance UI elements
+ oldState?.turnCard?.let { removeFromParentView(it) }
+ removeFromParentView(bottomSheetView)
+ removeFromParentView(exampleCustomButton)
+ oldState?.activeGuidanceButtons?.forEach { removeFromParentView(it.view) }
+
+ // Add Navigation Ready UI buttons
+ newState?.navigationReadyButtons?.forEach { button ->
+ if (button.view.parent == null) {
+ val buttonLayoutParams = AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.type == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true
+ }
+ endControlsContainer?.addView(button.view, buttonLayoutParams)
+ }
+ }
+
+ if (navigationReadyConstraintSet != null && layout != null) {
+ navigationReadyConstraintSet?.applyTo(layout)
+ }
+ }
+
+ private fun buildActiveGuidanceConstraintSet(
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ): ConstraintSet {
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+
+ // Constrain turn card to top start corner
+ val turnCardId = uiState.turnCard.id
+ constraintSet.connect(turnCardId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ constraintSet.connect(
+ turnCardId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+
+ // Constrain viewport to top of bottom sheet
+ val viewportId = uiState.viewport.id
+ constraintSet.clear(viewportId)
+ constraintSet.connect(
+ viewportId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(viewportId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+
+ // Instead of constraining the viewport's top directly to the bottom of the turn card
+ // (which varies in height and would trigger jumpy camera framing updates), we position
+ // it relative to the top of the parent layout with a fixed margin representing the
+ // estimated turn card height.
+ constraintSet.setMargin(
+ viewportId,
+ ConstraintSet.TOP,
+ dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context),
+ )
+ constraintSet.connect(viewportId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ constraintSet.connect(viewportId, ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP)
+
+ // Constrain the logo to the top of the bottom sheet
+ val logoId = uiState.googleLogo.id
+ constraintSet.clear(logoId, ConstraintSet.BOTTOM)
+ constraintSet.connect(logoId, ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP)
+ constraintSet.connect(logoId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+
+ constrainBottomSheet(constraintSet, context)
+
+ // Constrain end controls container to top of bottom sheet
+ constrainEndControlsContainer(
+ constraintSet,
+ context,
+ uiState,
+ bottomSheetViewId,
+ isWideMode = false,
+ )
+
+ return constraintSet
+ }
+
+ private fun constrainBottomSheet(constraintSet: ConstraintSet, context: Context) {
+ constraintSet.connect(
+ bottomSheetViewId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(
+ bottomSheetViewId,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.connect(
+ bottomSheetViewId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ }
+
+ private fun constrainEndControlsContainer(
+ constraintSet: ConstraintSet,
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ bottomAnchorId: Int,
+ isWideMode: Boolean,
+ ) {
+ constraintSet.clear(endControlsContainerId)
+ if (isWideMode) {
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ dpToPx(8, context),
+ )
+ } else {
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.TOP,
+ uiState.turnCard.id,
+ ConstraintSet.BOTTOM,
+ dpToPx(8, context),
+ )
+ }
+ val marginPx: Int
+ val bottomPaddingPx: Int
+ if (bottomAnchorId == ConstraintSet.PARENT_ID) {
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ marginPx = 0
+ bottomPaddingPx = dpToPx(20, context)
+ } else {
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.BOTTOM,
+ bottomAnchorId,
+ ConstraintSet.TOP,
+ )
+ marginPx = dpToPx(BUTTONS_CONTAINER_BOTTOM_MARGIN_DP, context)
+ bottomPaddingPx = 0
+ }
+ constraintSet.setMargin(endControlsContainerId, ConstraintSet.BOTTOM, marginPx)
+
+ endControlsContainer?.setPadding(
+ endControlsContainer!!.paddingLeft,
+ endControlsContainer!!.paddingTop,
+ endControlsContainer!!.paddingRight,
+ bottomPaddingPx,
+ )
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.constrainHeight(endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT)
+ constraintSet.constrainDefaultHeight(
+ endControlsContainerId,
+ ConstraintSet.MATCH_CONSTRAINT_WRAP,
+ )
+ constraintSet.setVerticalBias(endControlsContainerId, 1.0f)
+ }
+
+ override fun onShowPrompt(navigationUiParent: NavigationUiParent, newPrompt: View) {
+ activePrompt = newPrompt
+ val context = navigationUiParent.viewContext
+ // By default, prompts in non-wide mode are positioned at the bottom of the screen with
+ // square bottom corners. When positioning the prompt above a custom bottom sheet, applying
+ // rounded bottom corners gives the prompt a clean, floating appearance that matches its top
+ // corners.
+ applyPromptRoundedBottomCorners(context, newPrompt)
+ layout?.addView(newPrompt)
+
+ // When a prompt is displayed above the bottom sheet, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ if (activeGuidanceUiState?.isWideMode == true) {
+ buildActiveGuidanceWithPromptWideModeConstraintSet(context, newPrompt)
+ } else {
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt)
+ }
+ activeGuidanceWithPromptConstraintSet?.applyTo(layout)
+ }
+
+ override fun onChangePrompt(
+ navigationUiParent: NavigationUiParent,
+ oldPrompt: View,
+ newPrompt: View,
+ ) {
+ activePrompt = newPrompt
+ val context = navigationUiParent.viewContext
+ applyPromptRoundedBottomCorners(context, newPrompt)
+
+ activeGuidanceWithPromptConstraintSet?.clear(oldPrompt.id)
+ layout?.removeView(oldPrompt)
+ layout?.addView(newPrompt)
+
+ // When a prompt is displayed above the bottom sheet, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ if (activeGuidanceUiState?.isWideMode == true) {
+ buildActiveGuidanceWithPromptWideModeConstraintSet(context, newPrompt)
+ } else {
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt)
+ }
+ activeGuidanceWithPromptConstraintSet?.applyTo(layout)
+ }
+
+ override fun onHidePrompt(navigationUiParent: NavigationUiParent, oldPrompt: View) {
+ activePrompt = null
+ activeGuidanceWithPromptConstraintSet?.clear(oldPrompt.id)
+ layout?.removeView(oldPrompt)
+ if (activeGuidanceUiState?.isWideMode == true) {
+ activeGuidanceWideModeConstraintSet?.applyTo(layout)
+ } else {
+ activeGuidanceConstraintSet?.applyTo(layout)
+ }
+ }
+
+ private fun buildActiveGuidanceWithPromptConstraintSet(
+ context: Context,
+ prompt: View,
+ ): ConstraintSet {
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+
+ val activeGuidance = activeGuidanceUiState ?: return constraintSet
+ val viewportId = activeGuidance.viewport.id
+ val logoId = activeGuidance.googleLogo.id
+ val promptId = prompt.id
+
+ // Constrain viewport to top of prompt
+ constraintSet.clear(viewportId)
+ constraintSet.connect(
+ viewportId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(viewportId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ constraintSet.setMargin(
+ viewportId,
+ ConstraintSet.TOP,
+ dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context),
+ )
+ constraintSet.connect(viewportId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ constraintSet.connect(viewportId, ConstraintSet.BOTTOM, promptId, ConstraintSet.TOP)
+
+ // Constrain logo to top of prompt
+ constraintSet.clear(logoId, ConstraintSet.BOTTOM)
+ constraintSet.connect(logoId, ConstraintSet.BOTTOM, promptId, ConstraintSet.TOP)
+ constraintSet.connect(logoId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+
+ constrainBottomSheet(constraintSet, context)
+
+ // Constrain prompt above bottom sheet
+ constraintSet.connect(
+ promptId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(promptId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ constraintSet.connect(promptId, ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP)
+
+ // When the prompt is positioned above the bottom sheet rather than at the bottom of the screen,
+ // horizontal padding is added in non-wide mode to visually match standard Navigation SDK
+ // elements
+ // such as the turn card. In wide mode, the SDK automatically applies horizontal padding to the
+ // prompt, so this margin is only applied when in non-wide mode.
+ // We can use StyleValues.headerFooterHorizontalPaddingDp() to visually align with the turn
+ // card.
+ if (!activeGuidance.isWideMode) {
+ val promptSideMarginPx = dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context)
+ constraintSet.setMargin(promptId, ConstraintSet.START, promptSideMarginPx)
+ constraintSet.setMargin(promptId, ConstraintSet.END, promptSideMarginPx)
+ }
+
+ // Reposition end controls container above prompt
+ constrainEndControlsContainer(
+ constraintSet,
+ context,
+ activeGuidance,
+ promptId,
+ isWideMode = false,
+ )
+
+ return constraintSet
+ }
+
+ private fun removeFromParentView(view: View?) {
+ (view?.parent as? ViewGroup)?.removeView(view)
+ }
+
+ private fun buildActiveGuidanceWideModeConstraintSet(
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ): ConstraintSet {
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+
+ constraintSet.create(halfGuidelineId, ConstraintSet.VERTICAL_GUIDELINE)
+ constraintSet.setGuidelinePercent(halfGuidelineId, 0.5f)
+
+ constraintSet.clear(uiState.viewport.id)
+ constraintSet.connect(
+ uiState.viewport.id,
+ ConstraintSet.START,
+ halfGuidelineId,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(
+ uiState.viewport.id,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ uiState.viewport.id,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.connect(
+ uiState.viewport.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ val sideMarginPx = dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context)
+
+ constraintSet.connect(
+ uiState.turnCard.id,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ uiState.turnCard.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.setMargin(uiState.turnCard.id, ConstraintSet.START, sideMarginPx)
+ constraintSet.clear(uiState.turnCard.id, ConstraintSet.END)
+ constraintSet.constrainWidth(uiState.turnCard.id, ConstraintSet.WRAP_CONTENT)
+
+ val turnCardId = uiState.turnCard.id
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.START, turnCardId, ConstraintSet.START)
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.END, turnCardId, ConstraintSet.END)
+ constraintSet.connect(
+ bottomSheetViewId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ if (isBottomSheetExpanded) {
+ // Exactly overlap the turn card
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.TOP, turnCardId, ConstraintSet.TOP)
+ constraintSet.constrainHeight(bottomSheetViewId, ConstraintSet.MATCH_CONSTRAINT)
+ } else {
+ constraintSet.clear(bottomSheetViewId, ConstraintSet.TOP)
+ constraintSet.constrainHeight(
+ bottomSheetViewId,
+ dpToPx(COLLAPSED_BOTTOM_SHEET_HEIGHT_DP, context),
+ )
+ }
+
+ constraintSet.clear(uiState.googleLogo.id, ConstraintSet.BOTTOM)
+ constraintSet.connect(
+ uiState.googleLogo.id,
+ ConstraintSet.BOTTOM,
+ bottomSheetViewId,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ uiState.googleLogo.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(
+ uiState.googleLogo.id,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.constrainWidth(uiState.googleLogo.id, ConstraintSet.WRAP_CONTENT)
+ constraintSet.setHorizontalBias(uiState.googleLogo.id, 0f)
+
+ constrainEndControlsContainer(
+ constraintSet,
+ context,
+ uiState,
+ ConstraintSet.PARENT_ID,
+ isWideMode = true,
+ )
+
+ return constraintSet
+ }
+
+ private fun buildActiveGuidanceWithPromptWideModeConstraintSet(
+ context: Context,
+ prompt: View,
+ ): ConstraintSet {
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+
+ constraintSet.create(halfGuidelineId, ConstraintSet.VERTICAL_GUIDELINE)
+ constraintSet.setGuidelinePercent(halfGuidelineId, 0.5f)
+
+ val activeGuidance = activeGuidanceUiState ?: return constraintSet
+
+ constraintSet.clear(activeGuidance.viewport.id)
+ constraintSet.connect(
+ activeGuidance.viewport.id,
+ ConstraintSet.START,
+ halfGuidelineId,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(
+ activeGuidance.viewport.id,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ activeGuidance.viewport.id,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.connect(
+ activeGuidance.viewport.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ val sideMarginPx = dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context)
+
+ constraintSet.connect(
+ activeGuidance.turnCard.id,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ activeGuidance.turnCard.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.setMargin(activeGuidance.turnCard.id, ConstraintSet.START, sideMarginPx)
+ constraintSet.clear(activeGuidance.turnCard.id, ConstraintSet.END)
+ constraintSet.constrainWidth(activeGuidance.turnCard.id, ConstraintSet.WRAP_CONTENT)
+
+ val turnCardId = activeGuidance.turnCard.id
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.START, turnCardId, ConstraintSet.START)
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.END, turnCardId, ConstraintSet.END)
+ constraintSet.connect(
+ bottomSheetViewId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ if (isBottomSheetExpanded) {
+ constraintSet.connect(bottomSheetViewId, ConstraintSet.TOP, turnCardId, ConstraintSet.TOP)
+ constraintSet.constrainHeight(bottomSheetViewId, ConstraintSet.MATCH_CONSTRAINT)
+ } else {
+ constraintSet.clear(bottomSheetViewId, ConstraintSet.TOP)
+ constraintSet.constrainHeight(
+ bottomSheetViewId,
+ dpToPx(COLLAPSED_BOTTOM_SHEET_HEIGHT_DP, context),
+ )
+ }
+
+ constraintSet.connect(prompt.id, ConstraintSet.START, turnCardId, ConstraintSet.START)
+ constraintSet.connect(prompt.id, ConstraintSet.END, turnCardId, ConstraintSet.END)
+ constraintSet.constrainWidth(prompt.id, ConstraintSet.MATCH_CONSTRAINT)
+ if (isBottomSheetExpanded) {
+ constraintSet.connect(
+ prompt.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ } else {
+ constraintSet.connect(prompt.id, ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP)
+ }
+
+ constraintSet.clear(activeGuidance.googleLogo.id, ConstraintSet.BOTTOM)
+ constraintSet.connect(
+ activeGuidance.googleLogo.id,
+ ConstraintSet.BOTTOM,
+ bottomSheetViewId,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ activeGuidance.googleLogo.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(
+ activeGuidance.googleLogo.id,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.constrainWidth(activeGuidance.googleLogo.id, ConstraintSet.WRAP_CONTENT)
+ constraintSet.setHorizontalBias(activeGuidance.googleLogo.id, 0f)
+
+ constrainEndControlsContainer(
+ constraintSet,
+ context,
+ activeGuidance,
+ ConstraintSet.PARENT_ID,
+ isWideMode = true,
+ )
+
+ return constraintSet
+ }
+
+ override fun onSizeChanged(parent: NavigationUiParent, state: UiState) {
+ val layout = layout ?: return
+ if (state is ActiveGuidanceUiState) {
+ activeGuidanceUiState = state
+ }
+ if (isActiveGuidance) {
+ val prompt = activePrompt
+ if (prompt != null) {
+ activeGuidanceWithPromptConstraintSet =
+ if (state.isWideMode) {
+ buildActiveGuidanceWithPromptWideModeConstraintSet(parent.viewContext, prompt)
+ } else {
+ buildActiveGuidanceWithPromptConstraintSet(parent.viewContext, prompt)
+ }
+ activeGuidanceWithPromptConstraintSet?.applyTo(layout)
+ } else {
+ if (state.isWideMode) {
+ val activeGuidance = activeGuidanceUiState
+ if (activeGuidance != null) {
+ val constraintSet =
+ buildActiveGuidanceWideModeConstraintSet(parent.viewContext, activeGuidance)
+ constraintSet.applyTo(layout)
+ }
+ } else {
+ activeGuidanceConstraintSet?.applyTo(layout)
+ }
+ }
+ } else {
+ navigationReadyConstraintSet?.applyTo(layout)
+ }
+ }
+
+ companion object {
+ private const val DEFAULT_TURN_CARD_HEIGHT_DP = 150
+ private const val CUSTOM_BUTTON_SIZE_DP = 56
+ private const val BUTTONS_CONTAINER_BOTTOM_MARGIN_DP = 8
+ private const val COLLAPSED_BOTTOM_SHEET_HEIGHT_DP = 110
+ private val CUSTOM_UI_ELEMENT_COLOR = "#FFA500".toColorInt()
+
+ private fun dpToPx(dp: Int, context: Context): Int {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp.toFloat(),
+ context.resources.displayMetrics,
+ )
+ .toInt()
+ }
+
+ private fun applyPromptRoundedBottomCorners(context: Context, prompt: View?) {
+ if (prompt == null) return
+
+ // We apply bottom rounded corners to the prompt view so that it matches the top rounded
+ // corners
+ // when positioned above the bottom sheet. We use the StyleValues.defaultCornerRadiusDp() API
+ // to retrieve the standard default corner radius. To avoid clipping the top shadow or top
+ // corners, we apply a custom ViewOutlineProvider that offsets the outline bound at the top.
+ val cornerRadius = dpToPx(StyleValues.defaultCornerRadiusDp(), context).toFloat()
+ val topOffsetPx = 0
+ val sidePaddingPx = 0
+
+ val shape =
+ GradientDrawable().apply {
+ this.shape = GradientDrawable.RECTANGLE
+ setColor(Color.WHITE)
+ this.cornerRadius = cornerRadius
+ }
+ prompt.background = shape
+
+ prompt.outlineProvider =
+ object : ViewOutlineProvider() {
+ override fun getOutline(view: View, outline: Outline) {
+ outline.setRoundRect(
+ sidePaddingPx,
+ topOffsetPx,
+ view.width - sidePaddingPx,
+ view.height,
+ cornerRadius,
+ )
+ }
+ }
+ prompt.clipToOutline = true
+ }
+ }
+}
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLayoutDelegate.java b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLayoutDelegate.java
new file mode 100644
index 0000000..a1501fa
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLayoutDelegate.java
@@ -0,0 +1,736 @@
+/*
+ * 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.navigationapidemo.layoutdelegate;
+
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.graphics.Outline;
+import android.graphics.drawable.GradientDrawable;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewOutlineProvider;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import androidx.annotation.ColorInt;
+import androidx.annotation.Nullable;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
+import com.google.android.libraries.navigation.layoutcustomization.ActiveGuidanceUiState;
+import com.google.android.libraries.navigation.layoutcustomization.AutoHidingVerticalLayout;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationLayoutDelegate;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationReadyUiState;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiParent;
+import com.google.android.libraries.navigation.layoutcustomization.StyleValues;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+/**
+ * A sample implementation of {@link NavigationLayoutDelegate} that features an expandable bottom
+ * sheet to which custom content can be added. In this sample, we demonstrate placing a prompt above
+ * the bottom sheet rather than at the bottom of the screen, including adding rounded corners at the
+ * bottom to match those of the top.
+ */
+public class BottomSheetLayoutDelegate extends NavigationLayoutDelegate {
+ private static final int CUSTOM_BUTTON_SIZE_DP = 56;
+
+ @ColorInt private static final int CUSTOM_UI_ELEMENT_COLOR = Color.parseColor("#FFA500");
+ private static final int COLLAPSED_BOTTOM_SHEET_HEIGHT_DP = 110;
+
+ private final int layoutId;
+ private final int exampleCustomButtonId;
+ private final int bottomSheetViewId;
+ private final int endControlsContainerId;
+
+ private ConstraintLayout layout;
+ private View exampleCustomButton;
+ private View bottomSheetView;
+ private AutoHidingVerticalLayout endControlsContainer;
+
+ private boolean isBottomSheetExpanded = false;
+
+ // We cache our ConstraintSet definitions to avoid cloning or rebuilding
+ // constraint configurations programmatically on every transition. This optimization
+ // keeps UI state switches (such as entering active guidance or popping up prompts) highly
+ // performant.
+ private ConstraintSet navigationReadyConstraintSet;
+ private ConstraintSet activeGuidanceConstraintSet;
+ private ConstraintSet activeGuidanceWithPromptConstraintSet;
+
+ private ActiveGuidanceUiState activeGuidanceUiState;
+
+ public BottomSheetLayoutDelegate() {
+ layoutId = View.generateViewId();
+ exampleCustomButtonId = View.generateViewId();
+ bottomSheetViewId = View.generateViewId();
+ endControlsContainerId = View.generateViewId();
+ }
+
+ @Override
+ public void onEnterNavigationReady(
+ NavigationUiParent navigationUiParent, NavigationReadyUiState newState) {
+ Context context = navigationUiParent.getViewContext();
+
+ // Implementation Tip: For simplicity, this sample instantiates views and constraints
+ // programmatically. In a production application, you can safely inflate standard XML
+ // layout templates to build your layout hierarchies and define base UI constraints.
+
+ if (layout == null) {
+ layout = new ConstraintLayout(context);
+ layout.setId(layoutId);
+ }
+ layout.removeAllViews();
+ if (newState != null) {
+ createRootLayout(newState.getViewport(), newState.getGoogleLogo());
+ }
+
+ // We use AutoHidingVerticalLayout to create an adaptive vertical button container that
+ // automatically hides or shows child views based on available screen height.
+ if (endControlsContainer == null) {
+ endControlsContainer = new AutoHidingVerticalLayout(context);
+ endControlsContainer.setId(endControlsContainerId);
+ endControlsContainer.setChildSpacing(dpToPx(StyleValues.buttonVerticalPaddingDp(), context));
+ }
+
+ endControlsContainer.removeAllViews();
+
+ // Add the Navigation Ready UI buttons
+ if (newState != null && newState.getNavigationReadyButtons() != null) {
+ for (NavigationUiButton button : newState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ AutoHidingVerticalLayout.LayoutParams buttonLayoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.getType() == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true;
+ }
+ endControlsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+ }
+
+ if (endControlsContainer.getParent() == null) {
+ LayoutParams endControlsLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(endControlsContainer, endControlsLayoutParams);
+ }
+
+ // Build constraint set for Navigation Ready state
+ if (navigationReadyConstraintSet == null) {
+ navigationReadyConstraintSet = buildNavigationReadyConstraintSet(newState);
+ }
+
+ // Apply the constraints
+ navigationReadyConstraintSet.applyTo(layout);
+
+ navigationUiParent.removeNavigationLayout(layout);
+ navigationUiParent.setNavigationLayout(layout);
+ }
+
+ private void createRootLayout(@Nullable View viewport, @Nullable View googleLogo) {
+ // Add the Viewport (REQUIRED):
+ // The viewport is an invisible bounding box used by Nav SDK to frame the vehicle
+ // chevron and the upcoming route line. We want to position this view such that it avoids
+ // being obscured by fully-opaque UI elements (like the turn card or the ETA card).
+ if (viewport != null) {
+ removeFromParentView(viewport);
+ LayoutParams viewportLayoutParams = new LayoutParams(MATCH_PARENT, MATCH_PARENT);
+ layout.addView(viewport, viewportLayoutParams);
+ }
+
+ // Add the Google Logo / Re-center Button (REQUIRED):
+ // This view displays the Google Maps logo during guidance and may transition into a
+ // "Re-center" button if the user scrolls away from the vehicle chevron.
+ if (googleLogo != null) {
+ removeFromParentView(googleLogo);
+ LayoutParams logoLayoutParams = new LayoutParams(MATCH_PARENT, WRAP_CONTENT);
+ layout.addView(googleLogo, logoLayoutParams);
+ }
+ }
+
+ private View createExampleCustomButton(Context context) {
+ FloatingActionButton button = new FloatingActionButton(context);
+ button.setId(exampleCustomButtonId);
+ button.setBackgroundTintList(ColorStateList.valueOf(CUSTOM_UI_ELEMENT_COLOR));
+ button.setCompatElevation(0f);
+ return button;
+ }
+
+ private AutoHidingVerticalLayout.LayoutParams createExampleCustomButtonLayoutParams(
+ Context context) {
+ int sizePx = dpToPx(CUSTOM_BUTTON_SIZE_DP, context);
+ AutoHidingVerticalLayout.LayoutParams layoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(sizePx, sizePx);
+
+ // We use the StyleValues API margins to align custom elements with standard
+ // Navigation SDK UI elements.
+ int horizontalMarginPx = dpToPx(StyleValues.buttonHorizontalPaddingDp(), context);
+ int verticalMarginPx = dpToPx(StyleValues.buttonVerticalPaddingDp(), context);
+ layoutParams.setMargins(
+ horizontalMarginPx, verticalMarginPx, horizontalMarginPx, verticalMarginPx);
+
+ return layoutParams;
+ }
+
+ @SuppressWarnings("SetTextI18n") // Sample layout delegate placeholder text is not translated.
+ private View createBottomSheetView(Context context) {
+ LinearLayout sheetView = new LinearLayout(context);
+ sheetView.setId(bottomSheetViewId);
+ sheetView.setOrientation(LinearLayout.VERTICAL);
+ sheetView.setGravity(Gravity.CENTER_HORIZONTAL);
+
+ // We use the StyleValues API margins and corner radii to visually match
+ // or align custom elements (like this bottom sheet) with standard Navigation SDK UI elements
+ // (such as the turn card or ETA card).
+ GradientDrawable sheetBackground = new GradientDrawable();
+ sheetBackground.setShape(GradientDrawable.RECTANGLE);
+ sheetBackground.setColor(CUSTOM_UI_ELEMENT_COLOR);
+ float cornerRadiusPx = dpToPx(StyleValues.defaultCornerRadiusDp(), context);
+ sheetBackground.setCornerRadius(cornerRadiusPx);
+ sheetView.setBackground(sheetBackground);
+
+ sheetView.setOnClickListener(v -> toggleBottomSheetState(context));
+
+ // Create the handle icon
+ View handle = new View(context);
+ LinearLayout.LayoutParams handleParams =
+ new LinearLayout.LayoutParams(dpToPx(36, context), dpToPx(4, context));
+ handleParams.topMargin = dpToPx(10, context);
+ handleParams.bottomMargin = dpToPx(6, context);
+ handle.setLayoutParams(handleParams);
+ GradientDrawable handleDrawable = new GradientDrawable();
+ handleDrawable.setShape(GradientDrawable.RECTANGLE);
+ handleDrawable.setColor(Color.WHITE);
+ handleDrawable.setCornerRadius(dpToPx(2, context));
+ handle.setBackground(handleDrawable);
+ sheetView.addView(handle);
+
+ // Add a text view to demonstrate adding custom content
+ TextView textView = new TextView(context);
+ textView.setText("Application-specific UI here");
+ textView.setGravity(Gravity.CENTER);
+ textView.setTextColor(Color.WHITE);
+ textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
+ LinearLayout.LayoutParams textParams =
+ new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ textParams.topMargin = dpToPx(8, context);
+ sheetView.addView(textView, textParams);
+
+ return sheetView;
+ }
+
+ private int getBottomSheetTargetHeight(Context context) {
+ if (isBottomSheetExpanded) {
+ int screenHeight = context.getResources().getDisplayMetrics().heightPixels;
+ return (int) (screenHeight * 0.50f);
+ } else {
+ return dpToPx(COLLAPSED_BOTTOM_SHEET_HEIGHT_DP, context);
+ }
+ }
+
+ private LayoutParams createBottomSheetLayoutParams(Context context) {
+ int targetHeight = getBottomSheetTargetHeight(context);
+ return new LayoutParams(0, targetHeight);
+ }
+
+ private void toggleBottomSheetState(Context context) {
+ if (bottomSheetView == null) {
+ return;
+ }
+ isBottomSheetExpanded = !isBottomSheetExpanded;
+ int targetHeight = getBottomSheetTargetHeight(context);
+ LayoutParams params = bottomSheetView.getLayoutParams();
+ if (params != null) {
+ params.height = targetHeight;
+ bottomSheetView.setLayoutParams(params);
+ }
+ }
+
+ private ConstraintSet buildNavigationReadyConstraintSet(NavigationReadyUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain viewport to the edges of its parent
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+
+ // Constrain the logo to the bottom start corner
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ // Constrain end controls container to the bottom end corner
+ constraintSet.connect(
+ endControlsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP);
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ endControlsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ constraintSet.constrainHeight(endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT);
+ constraintSet.constrainDefaultHeight(
+ endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT_WRAP);
+ constraintSet.setVerticalBias(endControlsContainerId, 1.0f);
+
+ return constraintSet;
+ }
+
+ @Override
+ public void onLeaveNavigationReady(
+ NavigationUiParent navigationUiParent, NavigationReadyUiState oldState) {
+ if (endControlsContainer != null) {
+ endControlsContainer.removeAllViews();
+ }
+ if (oldState != null && oldState.getNavigationReadyButtons() != null) {
+ for (NavigationUiButton button : oldState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ }
+ }
+ if (layout != null) {
+ layout.removeAllViews();
+ navigationUiParent.removeNavigationLayout(layout);
+ }
+ }
+
+ @Override
+ public void onEnterActiveGuidance(
+ NavigationUiParent navigationUiParent,
+ NavigationReadyUiState oldState,
+ ActiveGuidanceUiState newState) {
+ activeGuidanceUiState = newState;
+
+ Context context = navigationUiParent.getViewContext();
+ if (layout == null) {
+ layout = new ConstraintLayout(context);
+ layout.setId(layoutId);
+ }
+ layout.removeAllViews();
+ if (newState != null) {
+ createRootLayout(newState.getViewport(), newState.getGoogleLogo());
+ }
+
+ if (endControlsContainer == null) {
+ endControlsContainer = new AutoHidingVerticalLayout(context);
+ endControlsContainer.setId(endControlsContainerId);
+ endControlsContainer.setGravity(Gravity.BOTTOM | Gravity.END);
+ endControlsContainer.setChildSpacing(dpToPx(StyleValues.buttonVerticalPaddingDp(), context));
+ }
+
+ endControlsContainer.removeAllViews();
+
+ // Add the turn card
+ if (newState != null && newState.getTurnCard() != null) {
+ removeFromParentView(newState.getTurnCard());
+ LayoutParams turnCardLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(newState.getTurnCard(), turnCardLayoutParams);
+ }
+
+ // Add the custom bottom sheet
+ if (bottomSheetView == null) {
+ bottomSheetView = createBottomSheetView(context);
+ }
+ if (bottomSheetView.getParent() == null) {
+ LayoutParams bottomSheetLayoutParams = createBottomSheetLayoutParams(context);
+ layout.addView(bottomSheetView, bottomSheetLayoutParams);
+ }
+
+ // Remove the Navigation Ready UI buttons
+ if (oldState != null && oldState.getNavigationReadyButtons() != null) {
+ for (NavigationUiButton button : oldState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ }
+ }
+
+ // Add the example button to its container
+ if (exampleCustomButton == null) {
+ exampleCustomButton = createExampleCustomButton(context);
+ }
+ removeFromParentView(exampleCustomButton);
+ AutoHidingVerticalLayout.LayoutParams customButtonLayoutParams =
+ createExampleCustomButtonLayoutParams(context);
+ endControlsContainer.addView(exampleCustomButton, customButtonLayoutParams);
+
+ // By adding all buttons to the AutoHidingVerticalLayout, we can easily incorporate the latest
+ // set of buttons when upgrading without any code changes required
+ if (newState != null && newState.getActiveGuidanceButtons() != null) {
+ for (NavigationUiButton button : newState.getActiveGuidanceButtons()) {
+ removeFromParentView(button.getView());
+ AutoHidingVerticalLayout.LayoutParams buttonLayoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.getType() == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true;
+ }
+ endControlsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+ }
+
+ if (endControlsContainer.getParent() == null) {
+ LayoutParams endControlsLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(endControlsContainer, endControlsLayoutParams);
+ }
+
+ // Build constraint set for Active Guidance state
+ if (activeGuidanceConstraintSet == null) {
+ activeGuidanceConstraintSet = buildActiveGuidanceConstraintSet(context, newState);
+ }
+
+ // Apply the constraints
+ activeGuidanceConstraintSet.applyTo(layout);
+
+ navigationUiParent.removeNavigationLayout(layout);
+ navigationUiParent.setNavigationLayout(layout);
+ }
+
+ @Override
+ public void onLeaveActiveGuidance(
+ NavigationUiParent navigationUiParent,
+ ActiveGuidanceUiState oldState,
+ NavigationReadyUiState newState) {
+
+ // Remove Active Guidance UI elements
+ if (oldState != null && oldState.getTurnCard() != null) {
+ removeFromParentView(oldState.getTurnCard());
+ }
+ removeFromParentView(bottomSheetView);
+ removeFromParentView(exampleCustomButton);
+ if (oldState != null && oldState.getActiveGuidanceButtons() != null) {
+ for (NavigationUiButton button : oldState.getActiveGuidanceButtons()) {
+ removeFromParentView(button.getView());
+ }
+ }
+
+ // Add Navigation Ready UI buttons
+ if (newState != null && newState.getNavigationReadyButtons() != null) {
+ for (NavigationUiButton button : newState.getNavigationReadyButtons()) {
+ if (button.getView().getParent() == null) {
+ AutoHidingVerticalLayout.LayoutParams buttonLayoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.getType() == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true;
+ }
+ endControlsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+ }
+ }
+
+ if (navigationReadyConstraintSet != null && layout != null) {
+ navigationReadyConstraintSet.applyTo(layout);
+ }
+ }
+
+ private ConstraintSet buildActiveGuidanceConstraintSet(
+ Context context, ActiveGuidanceUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain turn card to top start corner
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ // Constrain viewport to top of bottom sheet
+ constraintSet.clear(uiState.getViewport().getId());
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+
+ // Instead of constraining the viewport's top directly to the bottom of the turn card
+ // (which varies in height and would trigger jumpy camera framing updates), we use a fixed
+ // nominal height to estimate the height of the turncard.
+ constraintSet.setMargin(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ dpToPx(StyleValues.headerNominalHeightDp(), context));
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(), ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP);
+
+ // Constrain the logo to the top of the bottom sheet
+ constraintSet.clear(uiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ bottomSheetViewId,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ constrainBottomSheet(constraintSet);
+
+ // Constrain end controls container to top of bottom sheet
+ constrainEndControlsContainer(constraintSet, context, uiState, bottomSheetViewId);
+
+ return constraintSet;
+ }
+
+ private void constrainBottomSheet(ConstraintSet constraintSet) {
+ constraintSet.connect(
+ bottomSheetViewId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START);
+ constraintSet.connect(
+ bottomSheetViewId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ constraintSet.connect(
+ bottomSheetViewId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+ }
+
+ private void constrainEndControlsContainer(
+ ConstraintSet constraintSet,
+ Context context,
+ ActiveGuidanceUiState uiState,
+ int bottomAnchorId) {
+ constraintSet.clear(endControlsContainerId);
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.TOP,
+ uiState.getTurnCard().getId(),
+ ConstraintSet.BOTTOM,
+ dpToPx(8, context));
+ constraintSet.connect(
+ endControlsContainerId, ConstraintSet.BOTTOM, bottomAnchorId, ConstraintSet.TOP);
+ constraintSet.connect(
+ endControlsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+
+ constraintSet.constrainHeight(endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT);
+ constraintSet.constrainDefaultHeight(
+ endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT_WRAP);
+ constraintSet.setVerticalBias(endControlsContainerId, 1.0f);
+ }
+
+ @Override
+ public void onShowPrompt(NavigationUiParent navigationUiParent, View newPrompt) {
+ Context context = navigationUiParent.getViewContext();
+ // By default, prompts in non-wide mode are positioned at the bottom of the screen with
+ // square bottom corners. When positioning the prompt above a custom bottom sheet, applying
+ // rounded bottom corners gives the prompt a clean, floating appearance that matches its top
+ // corners.
+ applyPromptRoundedBottomCorners(
+ context, newPrompt, activeGuidanceUiState != null && activeGuidanceUiState.isWideMode());
+ layout.addView(newPrompt);
+
+ // When a prompt is displayed above the bottom sheet, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onChangePrompt(
+ NavigationUiParent navigationUiParent, View oldPrompt, View newPrompt) {
+ Context context = navigationUiParent.getViewContext();
+ applyPromptRoundedBottomCorners(
+ context, newPrompt, activeGuidanceUiState != null && activeGuidanceUiState.isWideMode());
+
+ activeGuidanceWithPromptConstraintSet.clear(oldPrompt.getId());
+ layout.removeView(oldPrompt);
+ layout.addView(newPrompt);
+
+ // When a prompt is displayed above the bottom sheet, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onHidePrompt(NavigationUiParent navigationUiParent, View oldPrompt) {
+ activeGuidanceWithPromptConstraintSet.clear(oldPrompt.getId());
+ layout.removeView(oldPrompt);
+ activeGuidanceConstraintSet.applyTo(layout);
+ }
+
+ private static void applyPromptRoundedBottomCorners(
+ Context context, View prompt, boolean isWideMode) {
+ if (prompt == null) {
+ return;
+ }
+
+ // We apply bottom rounded corners to the prompt view so that it matches the top rounded corners
+ // when positioned above the bottom sheet. We use the StyleValues.defaultCornerRadiusDp() API
+ // to retrieve the standard default corner radius. To avoid clipping the top shadow or top
+ // corners, we apply a custom ViewOutlineProvider that offsets the outline bound at the top.
+ float cornerRadius = dpToPx(StyleValues.defaultCornerRadiusDp(), context);
+ int topOffsetPx = dpToPx(10, context);
+ int sidePaddingPx =
+ isWideMode ? dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context) : 0;
+
+ GradientDrawable shape = new GradientDrawable();
+ shape.setShape(GradientDrawable.RECTANGLE);
+ shape.setColor(Color.WHITE);
+ shape.setCornerRadius(cornerRadius);
+ prompt.setBackground(shape);
+
+ prompt.setOutlineProvider(
+ new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setRoundRect(
+ sidePaddingPx,
+ topOffsetPx,
+ view.getWidth() - sidePaddingPx,
+ view.getHeight(),
+ cornerRadius);
+ }
+ });
+ prompt.setClipToOutline(true);
+ }
+
+ private ConstraintSet buildActiveGuidanceWithPromptConstraintSet(Context context, View prompt) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain viewport to top of prompt
+ constraintSet.clear(activeGuidanceUiState.getViewport().getId());
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.setMargin(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ dpToPx(StyleValues.headerNominalHeightDp(), context));
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ prompt.getId(),
+ ConstraintSet.TOP);
+
+ // Constrain logo to top of prompt
+ constraintSet.clear(activeGuidanceUiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ activeGuidanceUiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ prompt.getId(),
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ activeGuidanceUiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ constrainBottomSheet(constraintSet);
+
+ // Constrain prompt above bottom sheet
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START);
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP);
+
+ // When the prompt is positioned above the bottom sheet rather than at the bottom of the screen,
+ // horizontal padding is added in non-wide mode to visually match standard Navigation SDK
+ // elements
+ // such as the turn card. In wide mode, the SDK automatically applies horizontal padding to the
+ // prompt, so this margin is only applied when in non-wide mode.
+ // We can use StyleValues.headerFooterSidePaddingDp() to visually align with the turn card.
+ if (!activeGuidanceUiState.isWideMode()) {
+ int promptSideMarginPx = dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context);
+ constraintSet.setMargin(prompt.getId(), ConstraintSet.START, promptSideMarginPx);
+ constraintSet.setMargin(prompt.getId(), ConstraintSet.END, promptSideMarginPx);
+ }
+
+ // Reposition end controls container above prompt
+ constrainEndControlsContainer(constraintSet, context, activeGuidanceUiState, prompt.getId());
+
+ return constraintSet;
+ }
+
+ private void removeFromParentView(View view) {
+ if (view != null && view.getParent() != null) {
+ ((ViewGroup) view.getParent()).removeView(view);
+ }
+ }
+
+ private static int dpToPx(int dp, Context context) {
+ return (int)
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics());
+ }
+}
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLayoutDelegateKt.kt b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLayoutDelegateKt.kt
new file mode 100644
index 0000000..ee2c122
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/BottomSheetLayoutDelegateKt.kt
@@ -0,0 +1,735 @@
+/*
+ * 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.navigationapidemo.layoutdelegate
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.Outline
+import android.graphics.drawable.GradientDrawable
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.view.ViewOutlineProvider
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import com.google.android.libraries.navigation.layoutcustomization.ActiveGuidanceUiState
+import com.google.android.libraries.navigation.layoutcustomization.AutoHidingVerticalLayout
+import com.google.android.libraries.navigation.layoutcustomization.NavigationLayoutDelegate
+import com.google.android.libraries.navigation.layoutcustomization.NavigationReadyUiState
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiParent
+import com.google.android.libraries.navigation.layoutcustomization.StyleValues
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+
+/**
+ * A sample implementation of [NavigationLayoutDelegate] in Kotlin that features an expandable
+ * bottom sheet to which custom content can be added. This sample also demonstrates placing a prompt
+ * above the bottom sheet rather than at the bottom of the screen, including adding rounded corners
+ * at the bottom to match those of the top.
+ */
+class BottomSheetLayoutDelegateKt : NavigationLayoutDelegate() {
+
+ private val layoutId = View.generateViewId()
+ private val exampleCustomButtonId = View.generateViewId()
+ private val bottomSheetViewId = View.generateViewId()
+ private val endControlsContainerId = View.generateViewId()
+
+ private var layout: ConstraintLayout? = null
+ private var exampleCustomButton: View? = null
+ private var bottomSheetView: View? = null
+ private var endControlsContainer: AutoHidingVerticalLayout? = null
+
+ private var isBottomSheetExpanded = false
+
+ // We cache our ConstraintSet definitions to avoid cloning or rebuilding
+ // constraint configurations programmatically on every transition. This optimization
+ // keeps UI state switches (such as entering active guidance or popping up prompts) highly
+ // performant.
+ private var navigationReadyConstraintSet: ConstraintSet? = null
+ private var activeGuidanceConstraintSet: ConstraintSet? = null
+ private var activeGuidanceWithPromptConstraintSet: ConstraintSet? = null
+
+ private var activeGuidanceUiState: ActiveGuidanceUiState? = null
+
+ override fun onEnterNavigationReady(
+ navigationUiParent: NavigationUiParent,
+ newState: NavigationReadyUiState?,
+ ) {
+ val context = navigationUiParent.viewContext
+ val layout =
+ this.layout ?: ConstraintLayout(context).apply { id = layoutId }.also { this.layout = it }
+ layout.removeAllViews()
+
+ // Implementation Tip: For simplicity, this sample instantiates views and constraints
+ // programmatically. In a production application, you can safely inflate standard XML
+ // layout templates to build your layout hierarchies and define base UI constraints.
+
+ ensureRootLayoutCreated(layout, newState?.viewport, newState?.googleLogo)
+
+ // We use AutoHidingVerticalLayout to create an adaptive vertical button container that
+ // automatically hides or shows child views based on available screen height.
+ val endControlsContainer = getOrCreateEndControlsContainer(context)
+
+ endControlsContainer.removeAllViews()
+
+ // Add the Navigation Ready UI buttons
+ newState?.navigationReadyButtons?.forEach { button ->
+ removeFromParentView(button.view)
+ val buttonLayoutParams = AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.type == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true
+ }
+ endControlsContainer.addView(button.view, buttonLayoutParams)
+ }
+
+ if (endControlsContainer.parent == null) {
+ val endControlsLayoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ layout.addView(endControlsContainer, endControlsLayoutParams)
+ }
+
+ // Build constraint set for Navigation Ready state
+ if (navigationReadyConstraintSet == null && newState != null) {
+ navigationReadyConstraintSet = buildNavigationReadyConstraintSet(layout, newState)
+ }
+
+ // Apply the constraints
+ navigationReadyConstraintSet?.applyTo(layout)
+
+ navigationUiParent.removeNavigationLayout(layout)
+ navigationUiParent.setNavigationLayout(layout)
+ }
+
+ private fun getOrCreateEndControlsContainer(context: Context): AutoHidingVerticalLayout {
+ return endControlsContainer
+ ?: AutoHidingVerticalLayout(context)
+ .apply {
+ id = endControlsContainerId
+ childSpacing = dpToPx(StyleValues.buttonVerticalPaddingDp(), context)
+ setPadding(0, 0, 0, 0)
+ }
+ .also { endControlsContainer = it }
+ }
+
+ private fun ensureRootLayoutCreated(
+ layout: ConstraintLayout,
+ viewport: View?,
+ googleLogo: View?,
+ ) {
+ // Add the Viewport (REQUIRED):
+ // The viewport is an invisible bounding box used by Nav SDK to frame the vehicle
+ // chevron and the upcoming route line. We want to position this view such that it avoids
+ // being obscured by fully-opaque UI elements (like the turn card or the ETA card).
+ if (viewport != null) {
+ removeFromParentView(viewport)
+ val viewportLayoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+ layout.addView(viewport, viewportLayoutParams)
+ }
+ // Add the Google Logo / Re-center Button (REQUIRED):
+ // This view displays the Google Maps logo during guidance and may transition into a
+ // "Re-center" button if the user scrolls away from the vehicle chevron.
+ if (googleLogo != null) {
+ removeFromParentView(googleLogo)
+ val logoLayoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ layout.addView(googleLogo, logoLayoutParams)
+ }
+ }
+
+ private fun createExampleCustomButton(context: Context): View {
+ return FloatingActionButton(context).apply {
+ id = exampleCustomButtonId
+ backgroundTintList = ColorStateList.valueOf(CUSTOM_UI_ELEMENT_COLOR)
+ compatElevation = 0f
+ }
+ }
+
+ private fun createExampleCustomButtonLayoutParams(
+ context: Context
+ ): AutoHidingVerticalLayout.LayoutParams {
+ val sizePx = dpToPx(CUSTOM_BUTTON_SIZE_DP, context)
+ val layoutParams = AutoHidingVerticalLayout.LayoutParams(sizePx, sizePx)
+
+ // We use the StyleValues API margins to align custom elements with standard
+ // Navigation SDK UI elements.
+ val horizontalMarginPx = dpToPx(StyleValues.buttonHorizontalPaddingDp(), context)
+ val verticalMarginPx = dpToPx(StyleValues.buttonVerticalPaddingDp(), context)
+ layoutParams.setMargins(
+ horizontalMarginPx,
+ verticalMarginPx,
+ horizontalMarginPx,
+ verticalMarginPx,
+ )
+ return layoutParams
+ }
+
+ private fun createBottomSheetView(context: Context): View {
+ val sheetView =
+ LinearLayout(context).apply {
+ id = bottomSheetViewId
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER_HORIZONTAL
+ }
+
+ // We use the StyleValues API margins and corner radii to visually match
+ // or align custom elements (like this bottom sheet) with standard Navigation SDK UI elements
+ // (such as the turn card or ETA card).
+ val sheetBackground =
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(CUSTOM_UI_ELEMENT_COLOR)
+ cornerRadius = dpToPx(StyleValues.defaultCornerRadiusDp(), context).toFloat()
+ }
+ sheetView.background = sheetBackground
+ sheetView.setOnClickListener { toggleBottomSheetState(context) }
+
+ // Create the handle icon
+ val handle = View(context)
+ val handleParams =
+ LinearLayout.LayoutParams(dpToPx(36, context), dpToPx(4, context)).apply {
+ topMargin = dpToPx(10, context)
+ bottomMargin = dpToPx(6, context)
+ }
+ handle.layoutParams = handleParams
+ val handleDrawable =
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(Color.WHITE)
+ cornerRadius = dpToPx(2, context).toFloat()
+ }
+ handle.background = handleDrawable
+ sheetView.addView(handle)
+
+ // Add a text view to demonstrate adding custom content
+ val textView =
+ TextView(context).apply {
+ text = "Application-specific UI here"
+ gravity = Gravity.CENTER
+ setTextColor(Color.WHITE)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
+ }
+ val textParams =
+ LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { topMargin = dpToPx(8, context) }
+ sheetView.addView(textView, textParams)
+
+ return sheetView
+ }
+
+ private fun getBottomSheetTargetHeight(context: Context): Int {
+ return if (isBottomSheetExpanded) {
+ val screenHeight = context.resources.displayMetrics.heightPixels
+ (screenHeight * 0.50f).toInt()
+ } else {
+ dpToPx(COLLAPSED_BOTTOM_SHEET_HEIGHT_DP, context)
+ }
+ }
+
+ private fun createBottomSheetLayoutParams(context: Context): ViewGroup.LayoutParams {
+ val targetHeight = getBottomSheetTargetHeight(context)
+ return ViewGroup.LayoutParams(0, targetHeight)
+ }
+
+ private fun toggleBottomSheetState(context: Context) {
+ val view = bottomSheetView ?: return
+ isBottomSheetExpanded = !isBottomSheetExpanded
+ val targetHeight = getBottomSheetTargetHeight(context)
+ val params = view.layoutParams
+ if (params != null) {
+ params.height = targetHeight
+ view.layoutParams = params
+ }
+ }
+
+ private fun buildNavigationReadyConstraintSet(
+ layout: ConstraintLayout,
+ uiState: NavigationReadyUiState,
+ ): ConstraintSet {
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+
+ // Constrain viewport to the edges of its parent
+ val viewportId = uiState.viewport.id
+ constraintSet.connect(
+ viewportId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(viewportId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ constraintSet.connect(viewportId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ constraintSet.connect(
+ viewportId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ // Constrain the logo to the bottom start corner
+ val logoId = uiState.googleLogo.id
+ constraintSet.connect(
+ logoId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ constraintSet.connect(logoId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+
+ // Constrain end controls container to the bottom end corner
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.constrainHeight(endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT)
+ constraintSet.constrainDefaultHeight(
+ endControlsContainerId,
+ ConstraintSet.MATCH_CONSTRAINT_WRAP,
+ )
+ constraintSet.setVerticalBias(endControlsContainerId, 1.0f)
+
+ return constraintSet
+ }
+
+ override fun onLeaveNavigationReady(
+ navigationUiParent: NavigationUiParent,
+ oldState: NavigationReadyUiState?,
+ ) {
+ val layout = this.layout ?: return
+ endControlsContainer?.removeAllViews()
+ oldState?.navigationReadyButtons?.forEach { button -> removeFromParentView(button.view) }
+ layout.removeAllViews()
+ navigationUiParent.removeNavigationLayout(layout)
+ }
+
+ override fun onEnterActiveGuidance(
+ navigationUiParent: NavigationUiParent,
+ oldState: NavigationReadyUiState?,
+ newState: ActiveGuidanceUiState?,
+ ) {
+ activeGuidanceUiState = newState
+ val context = navigationUiParent.viewContext
+ val layout =
+ this.layout ?: ConstraintLayout(context).apply { id = layoutId }.also { this.layout = it }
+ layout.removeAllViews()
+
+ ensureRootLayoutCreated(layout, newState?.viewport, newState?.googleLogo)
+
+ val endControlsContainer = getOrCreateEndControlsContainer(context)
+ endControlsContainer.removeAllViews()
+
+ // Add the turn card
+ newState?.turnCard?.let { turnCard ->
+ removeFromParentView(turnCard)
+ val turnCardLayoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ layout.addView(turnCard, turnCardLayoutParams)
+ }
+
+ // Add the custom bottom sheet
+ val bottomSheetView =
+ this.bottomSheetView ?: createBottomSheetView(context).also { this.bottomSheetView = it }
+ if (bottomSheetView.parent == null) {
+ val bottomSheetLayoutParams = createBottomSheetLayoutParams(context)
+ layout.addView(bottomSheetView, bottomSheetLayoutParams)
+ }
+
+ // Remove the Navigation Ready UI buttons
+ oldState?.navigationReadyButtons?.forEach { button -> removeFromParentView(button.view) }
+
+ // Add the example button to its container
+ val exampleCustomButton =
+ this.exampleCustomButton
+ ?: createExampleCustomButton(context).also { this.exampleCustomButton = it }
+ removeFromParentView(exampleCustomButton)
+ val customButtonLayoutParams = createExampleCustomButtonLayoutParams(context)
+ endControlsContainer.addView(exampleCustomButton, customButtonLayoutParams)
+
+ // By adding all buttons to the AutoHidingVerticalLayout, we can easily incorporate the latest
+ // set of buttons when upgrading without any code changes required
+ newState?.activeGuidanceButtons?.forEach { button ->
+ removeFromParentView(button.view)
+ val buttonLayoutParams = AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.type == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true
+ }
+ endControlsContainer.addView(button.view, buttonLayoutParams)
+ }
+
+ if (endControlsContainer.parent == null) {
+ val endControlsLayoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ layout.addView(endControlsContainer, endControlsLayoutParams)
+ }
+
+ // Build constraint set for Active Guidance state
+ if (activeGuidanceConstraintSet == null && newState != null) {
+ activeGuidanceConstraintSet = buildActiveGuidanceConstraintSet(layout, context, newState)
+ }
+
+ // Apply the constraints
+ activeGuidanceConstraintSet?.applyTo(layout)
+
+ navigationUiParent.removeNavigationLayout(layout)
+ navigationUiParent.setNavigationLayout(layout)
+ }
+
+ override fun onLeaveActiveGuidance(
+ navigationUiParent: NavigationUiParent,
+ oldState: ActiveGuidanceUiState?,
+ newState: NavigationReadyUiState?,
+ ) {
+ val layout = this.layout ?: return
+ // Remove Active Guidance UI elements
+ oldState?.turnCard?.let { removeFromParentView(it) }
+ removeFromParentView(bottomSheetView)
+ removeFromParentView(exampleCustomButton)
+ oldState?.activeGuidanceButtons?.forEach { removeFromParentView(it.view) }
+
+ // Add Navigation Ready UI buttons
+ val endControlsContainer = this.endControlsContainer
+ if (endControlsContainer != null) {
+ newState?.navigationReadyButtons?.forEach { button ->
+ if (button.view.parent == null) {
+ val buttonLayoutParams = AutoHidingVerticalLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.type == NavigationUiButton.ButtonKnownType.COMPASS) {
+ buttonLayoutParams.isHighPriority = true
+ }
+ endControlsContainer.addView(button.view, buttonLayoutParams)
+ }
+ }
+ }
+
+ navigationReadyConstraintSet?.applyTo(layout)
+ activeGuidanceUiState = null
+ }
+
+ private fun buildActiveGuidanceConstraintSet(
+ layout: ConstraintLayout,
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ): ConstraintSet {
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+
+ // Constrain turn card to top start corner
+ val turnCardId = uiState.turnCard.id
+ constraintSet.connect(turnCardId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ constraintSet.connect(
+ turnCardId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+
+ // Constrain viewport to top of bottom sheet
+ val viewportId = uiState.viewport.id
+ constraintSet.clear(viewportId)
+ constraintSet.connect(
+ viewportId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(viewportId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+
+ // Instead of constraining the viewport's top directly to the bottom of the turn card
+ // (which varies in height and would trigger jumpy camera framing updates), we position
+ // it relative to the top of the parent layout with a fixed margin representing the
+ // estimated turn card height.
+ constraintSet.setMargin(
+ viewportId,
+ ConstraintSet.TOP,
+ dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context),
+ )
+ constraintSet.connect(viewportId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ constraintSet.connect(viewportId, ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP)
+
+ // Constrain the logo to the top of the bottom sheet
+ val logoId = uiState.googleLogo.id
+ constraintSet.clear(logoId, ConstraintSet.BOTTOM)
+ constraintSet.connect(logoId, ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP)
+ constraintSet.connect(logoId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+
+ constrainBottomSheet(constraintSet, context)
+
+ // Constrain end controls container to top of bottom sheet
+ constrainEndControlsContainer(constraintSet, context, uiState, bottomSheetViewId)
+
+ return constraintSet
+ }
+
+ private fun constrainBottomSheet(constraintSet: ConstraintSet, context: Context) {
+ constraintSet.connect(
+ bottomSheetViewId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(
+ bottomSheetViewId,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+ constraintSet.connect(
+ bottomSheetViewId,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ }
+
+ private fun constrainEndControlsContainer(
+ constraintSet: ConstraintSet,
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ bottomAnchorId: Int,
+ ) {
+ constraintSet.clear(endControlsContainerId)
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.TOP,
+ uiState.turnCard.id,
+ ConstraintSet.BOTTOM,
+ dpToPx(8, context),
+ )
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.BOTTOM,
+ bottomAnchorId,
+ ConstraintSet.TOP,
+ )
+ constraintSet.connect(
+ endControlsContainerId,
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END,
+ )
+
+ constraintSet.constrainHeight(endControlsContainerId, ConstraintSet.MATCH_CONSTRAINT)
+ constraintSet.constrainDefaultHeight(
+ endControlsContainerId,
+ ConstraintSet.MATCH_CONSTRAINT_WRAP,
+ )
+ constraintSet.setVerticalBias(endControlsContainerId, 1.0f)
+ }
+
+ override fun onShowPrompt(navigationUiParent: NavigationUiParent, newPrompt: View) {
+ val uiState = activeGuidanceUiState ?: return
+ val context = navigationUiParent.viewContext
+ // By default, prompts in non-wide mode are positioned at the bottom of the screen with
+ // square bottom corners. When positioning the prompt above a custom bottom sheet, applying
+ // rounded bottom corners gives the prompt a clean, floating appearance that matches its top
+ // corners.
+ applyPromptRoundedBottomCorners(context, newPrompt, uiState.isWideMode)
+ val layout = this.layout ?: return
+ layout.addView(newPrompt)
+
+ // When a prompt is displayed above the bottom sheet, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ buildActiveGuidanceWithPromptConstraintSet(layout, context, newPrompt, uiState)
+ activeGuidanceWithPromptConstraintSet?.applyTo(layout)
+ }
+
+ override fun onChangePrompt(
+ navigationUiParent: NavigationUiParent,
+ oldPrompt: View,
+ newPrompt: View,
+ ) {
+ val uiState = activeGuidanceUiState ?: return
+ val context = navigationUiParent.viewContext
+ applyPromptRoundedBottomCorners(context, newPrompt, uiState.isWideMode)
+
+ val layout = this.layout ?: return
+ activeGuidanceWithPromptConstraintSet?.clear(oldPrompt.id)
+ layout.removeView(oldPrompt)
+ layout.addView(newPrompt)
+
+ // When a prompt is displayed above the bottom sheet, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ buildActiveGuidanceWithPromptConstraintSet(layout, context, newPrompt, uiState)
+ activeGuidanceWithPromptConstraintSet?.applyTo(layout)
+ }
+
+ override fun onHidePrompt(navigationUiParent: NavigationUiParent, oldPrompt: View) {
+ val layout = this.layout ?: return
+ activeGuidanceWithPromptConstraintSet?.clear(oldPrompt.id)
+ layout.removeView(oldPrompt)
+ activeGuidanceConstraintSet?.applyTo(layout)
+ }
+
+ private fun buildActiveGuidanceWithPromptConstraintSet(
+ layout: ConstraintLayout,
+ context: Context,
+ prompt: View,
+ uiState: ActiveGuidanceUiState,
+ ): ConstraintSet {
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+
+ val viewportId = uiState.viewport.id
+ val logoId = uiState.googleLogo.id
+ val promptId = prompt.id
+
+ // Constrain viewport to top of prompt
+ constraintSet.clear(viewportId)
+ constraintSet.connect(
+ viewportId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(viewportId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ constraintSet.setMargin(
+ viewportId,
+ ConstraintSet.TOP,
+ dpToPx(DEFAULT_TURN_CARD_HEIGHT_DP, context),
+ )
+ constraintSet.connect(viewportId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ constraintSet.connect(viewportId, ConstraintSet.BOTTOM, promptId, ConstraintSet.TOP)
+
+ // Constrain logo to top of prompt
+ constraintSet.clear(logoId, ConstraintSet.BOTTOM)
+ constraintSet.connect(logoId, ConstraintSet.BOTTOM, promptId, ConstraintSet.TOP)
+ constraintSet.connect(logoId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+
+ constrainBottomSheet(constraintSet, context)
+
+ // Constrain prompt above bottom sheet
+ constraintSet.connect(
+ promptId,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ constraintSet.connect(promptId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ constraintSet.connect(promptId, ConstraintSet.BOTTOM, bottomSheetViewId, ConstraintSet.TOP)
+
+ // When the prompt is positioned above the bottom sheet rather than at the bottom of the screen,
+ // horizontal padding is added in non-wide mode to visually match standard Navigation SDK
+ // elements
+ // such as the turn card. In wide mode, the SDK automatically applies horizontal padding to the
+ // prompt, so this margin is only applied when in non-wide mode.
+ // We can use StyleValues.headerFooterSidePaddingDp() to visually align with the turn card.
+ if (!uiState.isWideMode) {
+ val promptSideMarginPx = dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context)
+ constraintSet.setMargin(promptId, ConstraintSet.START, promptSideMarginPx)
+ constraintSet.setMargin(promptId, ConstraintSet.END, promptSideMarginPx)
+ }
+
+ // Reposition end controls container above prompt
+ constrainEndControlsContainer(
+ constraintSet,
+ context,
+ activeGuidanceUiState ?: return constraintSet,
+ promptId,
+ )
+
+ return constraintSet
+ }
+
+ private fun removeFromParentView(view: View?) {
+ (view?.parent as? ViewGroup)?.removeView(view)
+ }
+
+ companion object {
+ // The turn card's height varies dynamically based on the current maneuver.
+ // Constraining viewport heights to dynamic-height views causes the map camera
+ // to constantly adjust its zoom and focus, creating a jumpy user experience.
+ // We define a fixed estimate of the turn card's height to use as a stable top margin.
+ private const val DEFAULT_TURN_CARD_HEIGHT_DP = 150
+ private const val CUSTOM_BUTTON_SIZE_DP = 56
+
+ private val CUSTOM_UI_ELEMENT_COLOR = Color.parseColor("#FFA500")
+ private const val COLLAPSED_BOTTOM_SHEET_HEIGHT_DP = 110
+
+ private fun dpToPx(dp: Int, context: Context): Int {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp.toFloat(),
+ context.resources.displayMetrics,
+ )
+ .toInt()
+ }
+
+ private fun applyPromptRoundedBottomCorners(
+ context: Context,
+ prompt: View,
+ isWideMode: Boolean,
+ ) {
+ // We apply bottom rounded corners to the prompt view so that it matches the top rounded
+ // corners
+ // when positioned above the bottom sheet. We use the StyleValues.defaultCornerRadiusDp() API
+ // to retrieve the standard default corner radius. To avoid clipping the top shadow or top
+ // corners, we apply a custom ViewOutlineProvider that offsets the outline bound at the top.
+ val cornerRadiusPx = dpToPx(StyleValues.defaultCornerRadiusDp(), context).toFloat()
+ val topOffsetPx = 0
+ val sidePaddingPx =
+ if (isWideMode) dpToPx(StyleValues.headerFooterHorizontalPaddingDp(), context) else 0
+
+ val shape =
+ GradientDrawable().apply {
+ shape = GradientDrawable.RECTANGLE
+ setColor(Color.WHITE)
+ cornerRadius = cornerRadiusPx
+ }
+ prompt.background = shape
+
+ prompt.outlineProvider =
+ object : ViewOutlineProvider() {
+ override fun getOutline(view: View, outline: Outline) {
+ outline.setRoundRect(
+ sidePaddingPx,
+ topOffsetPx,
+ view.width - sidePaddingPx,
+ view.height,
+ cornerRadiusPx,
+ )
+ }
+ }
+ prompt.clipToOutline = true
+ }
+ }
+}
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/README.md b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/README.md
new file mode 100644
index 0000000..28c523f
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/README.md
@@ -0,0 +1 @@
+This directory contains sample layout delegate implementations demonstrating how to use the Navigation SDK Layout Customization API. These samples serve as reference implementations and functional starting points that developers can use or customize in their navigation applications.
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/StandardUiElementsLayoutDelegate.java b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/StandardUiElementsLayoutDelegate.java
new file mode 100644
index 0000000..1e6628a
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/StandardUiElementsLayoutDelegate.java
@@ -0,0 +1,473 @@
+/*
+ * 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.navigationapidemo.layoutdelegate;
+
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+import static androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.MATCH_CONSTRAINT;
+import static com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton.ButtonKnownType.COMPASS;
+
+import android.content.Context;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
+import com.google.android.libraries.navigation.layoutcustomization.ActiveGuidanceUiState;
+import com.google.android.libraries.navigation.layoutcustomization.AutoHidingVerticalLayout;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationLayoutDelegate;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationReadyUiState;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton;
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiParent;
+import com.google.android.libraries.navigation.layoutcustomization.StyleValues;
+
+/**
+ * A sample implementation of {@link NavigationLayoutDelegate} demonstrating a basic,
+ * portrait-optimized layout using {@link ConstraintLayout}.
+ *
+ * Understanding the Layout Delegate State Machine: Navigation SDK transitions through
+ * distinct states, each calling corresponding lifecycle methods on this delegate:
+ *
+ *
+ * - Navigation Ready: Initiated by {@link #onEnterNavigationReady}. We initialize the
+ * layout here and add non-guidance views, then pass it to {@link NavigationUiParent} as the
+ * navigation layout.
+ *
- Active Guidance (Turn-by-Turn Mode): Initiated by {@link #onEnterActiveGuidance}. We
+ * set up the layout for Active Guidance, adding elements such as the turn card and ETA card.
+ *
- Prompts: Prompts (e.g., incident alerts) may be triggered during Active Guidance
+ * mode and can be added to the layout via {@link #onShowPrompt}.
+ *
+ *
+ * This class caches its {@link ConstraintSet}s to ensure smooth transitions without needing to
+ * recreate or inflate layouts continuously.
+ */
+public class StandardUiElementsLayoutDelegate extends NavigationLayoutDelegate {
+ private final int layoutId;
+ private final int buttonsContainerId;
+
+ private ConstraintLayout layout;
+ private AutoHidingVerticalLayout buttonsContainer;
+
+ // We cache our ConstraintSet definitions to avoid cloning or rebuilding
+ // constraint configurations programmatically on every transition. This optimization
+ // keeps UI state switches (such as entering active guidance or popping up prompts) highly
+ // performant.
+ private ConstraintSet navigationReadyConstraintSet;
+ private ConstraintSet activeGuidanceConstraintSet;
+ private ConstraintSet activeGuidanceWithPromptConstraintSet;
+
+ private ActiveGuidanceUiState activeGuidanceUiState;
+
+ public StandardUiElementsLayoutDelegate() {
+ layoutId = View.generateViewId();
+ buttonsContainerId = View.generateViewId();
+ }
+
+ @Override
+ public void onEnterNavigationReady(
+ NavigationUiParent navigationUiParent, NavigationReadyUiState newState) {
+ Context context = navigationUiParent.getViewContext();
+
+ // Implementation Tip: For simplicity, this sample instantiates views and constraints
+ // programmatically. In a production application, you can safely inflate standard XML
+ // layout templates to build your layout hierarchies and define base UI constraints.
+
+ // Create the root layout
+ if (layout == null) {
+ layout = new ConstraintLayout(context);
+ LayoutParams layoutParams = new LayoutParams(MATCH_PARENT, MATCH_PARENT);
+ layout.setLayoutParams(layoutParams);
+ layout.setId(layoutId);
+ }
+
+ // Add the Viewport (REQUIRED):
+ // The viewport is an invisible bounding box used by Nav SDK to frame the vehicle
+ // chevron and the upcoming route line. We want to position this view such that it avoids
+ // being obscured by fully-opaque UI elements (like the turn card or the ETA card).
+ removeFromParentView(newState.getViewport());
+ LayoutParams viewportLayoutParams = new LayoutParams(MATCH_PARENT, MATCH_PARENT);
+ layout.addView(newState.getViewport(), viewportLayoutParams);
+
+ // Add the Google Logo / Re-center Button (REQUIRED):
+ // This view displays the Google logo during guidance and may transition into a
+ // "Re-center" button if the user scrolls away from the vehicle chevron. It must
+ // be added to the view hierarchy in all states.
+ removeFromParentView(newState.getGoogleLogo());
+ LayoutParams googleLogoLayoutParams = new LayoutParams(MATCH_PARENT, WRAP_CONTENT);
+ layout.addView(newState.getGoogleLogo(), googleLogoLayoutParams);
+
+ // Add the container for UI buttons
+ if (buttonsContainer == null) {
+ // We use AutoHidingVerticalLayout to create an adaptive vertical button container that
+ // automatically hides or shows child views based on available screen height.
+ buttonsContainer = new AutoHidingVerticalLayout(context);
+ buttonsContainer.setId(buttonsContainerId);
+ }
+ removeFromParentView(buttonsContainer);
+ LayoutParams buttonsContainerLayoutParams = new LayoutParams(WRAP_CONTENT, MATCH_CONSTRAINT);
+ layout.addView(buttonsContainer, buttonsContainerLayoutParams);
+
+ // Add UI buttons to the container
+ for (NavigationUiButton button : newState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ buttonsContainer.addView(button.getView());
+ }
+
+ // Build constraint set for Navigation Ready state
+ if (navigationReadyConstraintSet == null) {
+ navigationReadyConstraintSet = buildNavigationReadyConstraintSet(newState);
+ }
+
+ // Apply the constraints
+ navigationReadyConstraintSet.applyTo(layout);
+
+ // Set the layout in NavigationUiParent
+ navigationUiParent.removeNavigationLayout(layout);
+ navigationUiParent.setNavigationLayout(layout);
+ }
+
+ private ConstraintSet buildNavigationReadyConstraintSet(NavigationReadyUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain viewport to the edges of its parent
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+
+ // Constrain the logo to the bottom start corner
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ constrainButtonsToBottomEnd(constraintSet);
+
+ return constraintSet;
+ }
+
+ @Override
+ public void onLeaveNavigationReady(
+ NavigationUiParent navigationUiParent, NavigationReadyUiState oldState) {
+ buttonsContainer.removeAllViews();
+ layout.removeAllViews();
+ navigationUiParent.removeNavigationLayout(layout);
+ }
+
+ @Override
+ public void onEnterActiveGuidance(
+ NavigationUiParent navigationUiParent,
+ NavigationReadyUiState oldState,
+ ActiveGuidanceUiState newState) {
+ activeGuidanceUiState = newState;
+
+ Context context = navigationUiParent.getViewContext();
+
+ // Sizing Guideline: The turn card and ETA card are internally configured to adapt and size
+ // themselves dynamically based on the layout width (non-wideMode vs. wideMode). Forcing fixed
+ // widths or heights on these elements via layouts is unsupported. Always use WRAP_CONTENT to
+ // let the elements determine their optimal proportions.
+
+ // Add the turn card
+ removeFromParentView(newState.getTurnCard());
+ LayoutParams turnCardLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(newState.getTurnCard(), turnCardLayoutParams);
+
+ // Add the ETA card
+ removeFromParentView(newState.getEtaCard());
+ LayoutParams etaCardLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ layout.addView(newState.getEtaCard(), etaCardLayoutParams);
+
+ // Remove the Navigation Ready UI buttons
+ for (NavigationUiButton button : oldState.getNavigationReadyButtons()) {
+ removeFromParentView(button.getView());
+ }
+
+ // By adding all buttons to the AutoHidingVerticalLayout, we can easily incorporate the latest
+ // set of buttons when upgrading without any code changes required
+ for (NavigationUiButton button : newState.getActiveGuidanceButtons()) {
+ AutoHidingVerticalLayout.LayoutParams buttonLayoutParams =
+ new AutoHidingVerticalLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+
+ // Mark critical buttons (such as the compass) as high priority so they are the last to be
+ // hidden by AutoHidingVerticalLayout when layout space is limited.
+ if (button.getType() == COMPASS) {
+ buttonLayoutParams.isHighPriority = true;
+ }
+
+ removeFromParentView(button.getView());
+ buttonsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+
+ // Build constraint set for Active Guidance state
+ if (activeGuidanceConstraintSet == null) {
+ activeGuidanceConstraintSet = buildActiveGuidanceConstraintSet(context, newState);
+ }
+
+ // Apply the constraints
+ activeGuidanceConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onLeaveActiveGuidance(
+ NavigationUiParent navigationUiParent,
+ ActiveGuidanceUiState oldState,
+ NavigationReadyUiState newState) {
+
+ // Remove Active Guidance UI elements
+ removeFromParentView(oldState.getEtaCard());
+ removeFromParentView(oldState.getTurnCard());
+ buttonsContainer.removeAllViews();
+
+ // Add Navigation Ready UI buttons
+ for (NavigationUiButton button : newState.getNavigationReadyButtons()) {
+ LayoutParams buttonLayoutParams = new LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
+ buttonsContainer.addView(button.getView(), buttonLayoutParams);
+ }
+
+ navigationReadyConstraintSet.applyTo(layout);
+ }
+
+ private ConstraintSet buildActiveGuidanceConstraintSet(
+ Context context, ActiveGuidanceUiState uiState) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain turn card to top start corner
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.connect(
+ uiState.getTurnCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+
+ // Constrain viewport to top of ETA card
+ constraintSet.clear(uiState.getViewport().getId());
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+
+ // Instead of constraining the viewport's top directly to the bottom of the turn card
+ // (which varies in height and would trigger jumpy camera framing updates), we use a fixed
+ // nominal height to estimate the height of the turncard.
+ constraintSet.setMargin(
+ uiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ dpToPx(StyleValues.headerNominalHeightDp(), context));
+
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ uiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ uiState.getEtaCard().getId(),
+ ConstraintSet.TOP);
+
+ constraintSet.clear(uiState.getGoogleLogo().getId(), ConstraintSet.BOTTOM);
+ constrainLogoToTopOfEtaCard(uiState, constraintSet);
+
+ constrainEtaCardToBottomStart(uiState, constraintSet);
+
+ constrainButtonsToTopOfEtaCard(context, uiState, constraintSet);
+
+ return constraintSet;
+ }
+
+ private static void constrainEtaCardToBottomStart(
+ ActiveGuidanceUiState uiState, ConstraintSet constraintSet) {
+ constraintSet.connect(
+ uiState.getEtaCard().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ uiState.getEtaCard().getId(),
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM);
+ }
+
+ private static void constrainLogoToTopOfEtaCard(
+ ActiveGuidanceUiState uiState, ConstraintSet constraintSet) {
+ constraintSet.connect(
+ uiState.getGoogleLogo().getId(),
+ ConstraintSet.BOTTOM,
+ uiState.getEtaCard().getId(),
+ ConstraintSet.TOP);
+ }
+
+ private void constrainButtonsToBottomEnd(ConstraintSet constraintSet) {
+ constraintSet.clear(buttonsContainerId, ConstraintSet.BOTTOM);
+ constraintSet.clear(buttonsContainerId, ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ }
+
+ private void constrainButtonsToTopOfEtaCard(
+ Context context, ActiveGuidanceUiState uiState, ConstraintSet constraintSet) {
+ constraintSet.clear(buttonsContainerId, ConstraintSet.BOTTOM);
+ constraintSet.clear(buttonsContainerId, ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.BOTTOM, uiState.getEtaCard().getId(), ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP);
+ constraintSet.connect(
+ buttonsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END);
+ constraintSet.setMargin(
+ buttonsContainerId,
+ ConstraintSet.TOP,
+ dpToPx(StyleValues.headerNominalHeightDp(), context));
+ }
+
+ @Override
+ public void onShowPrompt(NavigationUiParent navigationUiParent, View newPrompt) {
+ Context context = navigationUiParent.getViewContext();
+
+ layout.addView(newPrompt);
+
+ // When a prompt is displayed at the bottom of the screen, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onChangePrompt(
+ NavigationUiParent navigationUiParent, View oldPrompt, View newPrompt) {
+ Context context = navigationUiParent.getViewContext();
+
+ activeGuidanceWithPromptConstraintSet.clear(oldPrompt.getId());
+
+ layout.removeView(oldPrompt);
+ layout.addView(newPrompt);
+
+ // When a prompt is displayed at the bottom of the screen, we update our active constraints so
+ // that the invisible Viewport sits entirely above the prompt. This automatically forces the
+ // Nav SDK camera to adjust its zoom and framing so that the route chevron is always visible to
+ // the driver.
+ activeGuidanceWithPromptConstraintSet =
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt);
+ activeGuidanceWithPromptConstraintSet.applyTo(layout);
+ }
+
+ @Override
+ public void onHidePrompt(NavigationUiParent navigationUiParent, View oldPrompt) {
+ activeGuidanceWithPromptConstraintSet.clear(oldPrompt.getId());
+
+ layout.removeView(oldPrompt);
+
+ activeGuidanceConstraintSet.applyTo(layout);
+ }
+
+ private ConstraintSet buildActiveGuidanceWithPromptConstraintSet(Context context, View prompt) {
+ ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(layout);
+
+ // Constrain viewport to top of prompt
+ constraintSet.clear(activeGuidanceUiState.getViewport().getId());
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.TOP);
+ constraintSet.setMargin(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.TOP,
+ dpToPx(StyleValues.headerNominalHeightDp(), context));
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.END,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.END);
+ constraintSet.connect(
+ activeGuidanceUiState.getViewport().getId(),
+ ConstraintSet.BOTTOM,
+ prompt.getId(),
+ ConstraintSet.TOP);
+
+ // Constrain prompt to bottom start corner
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START);
+ constraintSet.connect(
+ prompt.getId(), ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM);
+
+ return constraintSet;
+ }
+
+ private void removeFromParentView(View view) {
+ if (view != null && view.getParent() != null) {
+ ((ViewGroup) view.getParent()).removeView(view);
+ }
+ }
+
+ private static int dpToPx(int dp, Context context) {
+ return (int)
+ TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics());
+ }
+}
diff --git a/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/StandardUiElementsLayoutDelegateKt.kt b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/StandardUiElementsLayoutDelegateKt.kt
new file mode 100644
index 0000000..67b8d78
--- /dev/null
+++ b/navigation-sample/app/src/main/java/com/example/navigationapidemo/layoutdelegate/StandardUiElementsLayoutDelegateKt.kt
@@ -0,0 +1,376 @@
+/*
+ * 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.navigationapidemo.layoutdelegate
+
+import android.content.Context
+import android.util.TypedValue
+import android.view.View
+import android.view.ViewGroup
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import com.google.android.libraries.navigation.layoutcustomization.ActiveGuidanceUiState
+import com.google.android.libraries.navigation.layoutcustomization.AutoHidingVerticalLayout
+import com.google.android.libraries.navigation.layoutcustomization.NavigationLayoutDelegate
+import com.google.android.libraries.navigation.layoutcustomization.NavigationReadyUiState
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiButton.ButtonKnownType.COMPASS
+import com.google.android.libraries.navigation.layoutcustomization.NavigationUiParent
+import com.google.android.libraries.navigation.layoutcustomization.StyleValues.headerNominalHeightDp
+
+/** Kotlin equivalent of StandardUiElementsLayoutDelegate. */
+class StandardUiElementsLayoutDelegateKt : NavigationLayoutDelegate() {
+
+ private val layoutId = View.generateViewId()
+ private val buttonsContainerId = View.generateViewId()
+
+ private var layout: ConstraintLayout? = null
+ private var buttonsContainer: AutoHidingVerticalLayout? = null
+
+ private var navigationReadyConstraintSet: ConstraintSet? = null
+ private var activeGuidanceConstraintSet: ConstraintSet? = null
+ private var activeGuidanceWithPromptConstraintSet: ConstraintSet? = null
+
+ private var activeGuidanceUiState: ActiveGuidanceUiState? = null
+
+ override fun onEnterNavigationReady(
+ navigationUiParent: NavigationUiParent,
+ newState: NavigationReadyUiState,
+ ) {
+ val context = navigationUiParent.viewContext
+
+ var currentLayout = layout
+ if (currentLayout == null) {
+ currentLayout =
+ ConstraintLayout(context).apply {
+ layoutParams =
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ id = layoutId
+ }
+ layout = currentLayout
+ }
+
+ removeFromParentView(newState.viewport)
+ currentLayout.addView(
+ newState.viewport,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ),
+ )
+
+ removeFromParentView(newState.googleLogo)
+ currentLayout.addView(
+ newState.googleLogo,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+
+ var currentButtonsContainer = buttonsContainer
+ if (currentButtonsContainer == null) {
+ currentButtonsContainer = AutoHidingVerticalLayout(context).apply { id = buttonsContainerId }
+ buttonsContainer = currentButtonsContainer
+ }
+
+ removeFromParentView(currentButtonsContainer)
+ currentLayout.addView(
+ currentButtonsContainer,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.MATCH_CONSTRAINT,
+ ),
+ )
+
+ for (button in newState.navigationReadyButtons) {
+ removeFromParentView(button.view)
+ currentButtonsContainer.addView(button.view)
+ }
+
+ if (navigationReadyConstraintSet == null) {
+ navigationReadyConstraintSet = buildNavigationReadyConstraintSet(newState)
+ }
+ navigationReadyConstraintSet?.applyTo(currentLayout)
+
+ navigationUiParent.removeNavigationLayout(currentLayout)
+ navigationUiParent.setNavigationLayout(currentLayout)
+ }
+
+ private fun buildNavigationReadyConstraintSet(uiState: NavigationReadyUiState): ConstraintSet {
+ return ConstraintSet().apply {
+ clone(layout)
+
+ connect(
+ uiState.viewport.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ connect(uiState.viewport.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(uiState.viewport.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ connect(
+ uiState.viewport.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+
+ connect(
+ uiState.googleLogo.id,
+ ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.BOTTOM,
+ )
+ connect(
+ uiState.googleLogo.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+
+ constrainButtonsToBottomEnd()
+ }
+ }
+
+ override fun onLeaveNavigationReady(
+ navigationUiParent: NavigationUiParent,
+ oldState: NavigationReadyUiState,
+ ) {
+ buttonsContainer?.removeAllViews()
+ layout?.removeAllViews()
+ layout?.let { navigationUiParent.removeNavigationLayout(it) }
+ }
+
+ override fun onEnterActiveGuidance(
+ navigationUiParent: NavigationUiParent,
+ oldState: NavigationReadyUiState,
+ newState: ActiveGuidanceUiState,
+ ) {
+ activeGuidanceUiState = newState
+ val context = navigationUiParent.viewContext
+ val currentLayout = checkNotNull(layout) { "layout must be initialized" }
+ val currentButtonsContainer =
+ checkNotNull(buttonsContainer) { "buttonsContainer must be initialized" }
+
+ removeFromParentView(newState.turnCard)
+ currentLayout.addView(
+ newState.turnCard,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+
+ removeFromParentView(newState.etaCard)
+ currentLayout.addView(
+ newState.etaCard,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+
+ for (button in oldState.navigationReadyButtons) {
+ removeFromParentView(button.view)
+ }
+
+ for (button in newState.activeGuidanceButtons) {
+ val buttonLayoutParams =
+ AutoHidingVerticalLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ )
+ if (button.type == COMPASS) {
+ buttonLayoutParams.isHighPriority = true
+ }
+ removeFromParentView(button.view)
+ currentButtonsContainer.addView(button.view, buttonLayoutParams)
+ }
+
+ if (activeGuidanceConstraintSet == null) {
+ activeGuidanceConstraintSet = buildActiveGuidanceConstraintSet(context, newState)
+ }
+
+ activeGuidanceConstraintSet?.applyTo(currentLayout)
+ }
+
+ override fun onLeaveActiveGuidance(
+ navigationUiParent: NavigationUiParent,
+ oldState: ActiveGuidanceUiState,
+ newState: NavigationReadyUiState,
+ ) {
+ removeFromParentView(oldState.etaCard)
+ removeFromParentView(oldState.turnCard)
+ buttonsContainer?.removeAllViews()
+
+ val currentButtonsContainer =
+ checkNotNull(buttonsContainer) { "buttonsContainer must be initialized" }
+ for (button in newState.navigationReadyButtons) {
+ currentButtonsContainer.addView(
+ button.view,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+ }
+
+ navigationReadyConstraintSet?.applyTo(layout)
+ }
+
+ private fun buildActiveGuidanceConstraintSet(
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ): ConstraintSet {
+ return ConstraintSet().apply {
+ clone(layout)
+
+ connect(uiState.turnCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(
+ uiState.turnCard.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+
+ clear(uiState.viewport.id)
+ connect(
+ uiState.viewport.id,
+ ConstraintSet.START,
+ ConstraintSet.PARENT_ID,
+ ConstraintSet.START,
+ )
+ connect(uiState.viewport.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ setMargin(uiState.viewport.id, ConstraintSet.TOP, dpToPx(headerNominalHeightDp(), context))
+ connect(uiState.viewport.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ connect(uiState.viewport.id, ConstraintSet.BOTTOM, uiState.etaCard.id, ConstraintSet.TOP)
+
+ clear(uiState.googleLogo.id, ConstraintSet.BOTTOM)
+ constrainLogoToTopOfEtaCard(uiState)
+ constrainEtaCardToBottomStart(uiState)
+ constrainButtonsToTopOfEtaCard(context, uiState)
+ }
+ }
+
+ private fun ConstraintSet.constrainEtaCardToBottomStart(uiState: ActiveGuidanceUiState) {
+ connect(uiState.etaCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+ connect(uiState.etaCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
+ }
+
+ private fun ConstraintSet.constrainLogoToTopOfEtaCard(uiState: ActiveGuidanceUiState) {
+ connect(uiState.googleLogo.id, ConstraintSet.BOTTOM, uiState.etaCard.id, ConstraintSet.TOP)
+ }
+
+ private fun ConstraintSet.constrainButtonsToBottomEnd() {
+ clear(buttonsContainerId, ConstraintSet.BOTTOM)
+ clear(buttonsContainerId, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
+ connect(buttonsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ }
+
+ private fun ConstraintSet.constrainButtonsToTopOfEtaCard(
+ context: Context,
+ uiState: ActiveGuidanceUiState,
+ ) {
+ clear(buttonsContainerId, ConstraintSet.BOTTOM)
+ clear(buttonsContainerId, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.BOTTOM, uiState.etaCard.id, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ connect(buttonsContainerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ setMargin(buttonsContainerId, ConstraintSet.TOP, dpToPx(headerNominalHeightDp(), context))
+ }
+
+ override fun onShowPrompt(navigationUiParent: NavigationUiParent, newPrompt: View) {
+ val context = navigationUiParent.viewContext
+ layout?.addView(newPrompt)
+
+ if (activeGuidanceUiState != null) {
+ activeGuidanceWithPromptConstraintSet =
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt)
+ activeGuidanceWithPromptConstraintSet?.applyTo(layout)
+ }
+ }
+
+ override fun onChangePrompt(
+ navigationUiParent: NavigationUiParent,
+ oldPrompt: View,
+ newPrompt: View,
+ ) {
+ val context = navigationUiParent.viewContext
+ activeGuidanceWithPromptConstraintSet?.clear(oldPrompt.id)
+
+ val currentLayout = checkNotNull(layout) { "layout must be initialized" }
+ currentLayout.removeView(oldPrompt)
+ currentLayout.addView(newPrompt)
+
+ if (activeGuidanceUiState != null) {
+ activeGuidanceWithPromptConstraintSet =
+ buildActiveGuidanceWithPromptConstraintSet(context, newPrompt)
+ activeGuidanceWithPromptConstraintSet?.applyTo(currentLayout)
+ }
+ }
+
+ override fun onHidePrompt(navigationUiParent: NavigationUiParent, oldPrompt: View) {
+ activeGuidanceWithPromptConstraintSet?.clear(oldPrompt.id)
+ layout?.removeView(oldPrompt)
+
+ activeGuidanceConstraintSet?.applyTo(layout)
+ }
+
+ private fun buildActiveGuidanceWithPromptConstraintSet(
+ context: Context,
+ prompt: View,
+ ): ConstraintSet {
+ return ConstraintSet().apply {
+ clone(layout)
+
+ val state =
+ checkNotNull(activeGuidanceUiState) { "activeGuidanceUiState must be initialized" }
+
+ clear(state.viewport.id)
+ connect(state.viewport.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+ connect(state.viewport.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
+ setMargin(state.viewport.id, ConstraintSet.TOP, dpToPx(headerNominalHeightDp(), context))
+ connect(state.viewport.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
+ connect(state.viewport.id, ConstraintSet.BOTTOM, prompt.id, ConstraintSet.TOP)
+
+ clear(state.googleLogo.id, ConstraintSet.BOTTOM)
+ connect(state.googleLogo.id, ConstraintSet.BOTTOM, prompt.id, ConstraintSet.TOP)
+
+ connect(prompt.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
+ connect(prompt.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
+ }
+ }
+
+ private fun removeFromParentView(view: View?) {
+ if (view?.parent != null) {
+ (view.parent as ViewGroup).removeView(view)
+ }
+ }
+
+ private fun dpToPx(dp: Int, context: Context): Int {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp.toFloat(),
+ context.resources.displayMetrics,
+ )
+ .toInt()
+ }
+}
diff --git a/navigation-sample/build.gradle b/navigation-sample/build.gradle
index 29fd0fa..05026aa 100755
--- a/navigation-sample/build.gradle
+++ b/navigation-sample/build.gradle
@@ -22,7 +22,7 @@ buildscript {
google()
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.7.3'
+ classpath 'com.android.tools.build:gradle:8.13.2'
classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1"
// NOTE: Do not place your application dependencies here; they belong
@@ -31,7 +31,7 @@ buildscript {
}
plugins {
- id 'org.jetbrains.kotlin.android' version '2.1.10' apply false
+ id 'org.jetbrains.kotlin.android' version '2.3.0' apply false
}
allprojects {
diff --git a/navigation-sample/gradle/wrapper/gradle-wrapper.properties b/navigation-sample/gradle/wrapper/gradle-wrapper.properties
index df97d72..37f853b 100755
--- a/navigation-sample/gradle/wrapper/gradle-wrapper.properties
+++ b/navigation-sample/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME