自定义导航布局

示例动画:显示 Android 上自定义的导航布局

您可以使用布局自定义功能来控制 Google 提供的视图和您自己的自定义业务视图的视图层次结构和屏幕放置位置。

您可以实现布局委托,以管理整个屏幕的布局,而不是将自定义视图添加到预定义的页眉或页脚槽中。在导航状态转换期间,您的布局委托会接收 Google 提供的组件,例如转弯卡片、预计到达时间卡片和按钮。您可以使用标准 Android 布局系统(例如 ConstraintLayoutCoordinatorLayoutLinearLayout)将这些元素与您自己的自定义内容并排放置。

您可以使用此框架在屏幕上精确定位自定义商家信息(例如订单状态或取货说明),同时防止视图重叠。

布局自定义的工作原理

布局自定义使用委托设计模式。SDK 不会自动在屏幕上绘制或放置界面组件,而是直接将它们传递给您编写的自定义类:布局委托。

如需自定义布局,请创建一个扩展抽象 NavigationLayoutDelegate 类的类,并将实例分配给 NavigationViewSupportNavigationFragment。每当导航状态转换时(例如从基本地图进入主动精细导航),SDK 都会对委托执行回调方法,并提供适用于该特定状态的界面组件。

为了提供顺畅的集成用户体验,此框架在应用和 SDK 之间建立了明确的责任分离。您可以使用布局委托自定义以下内容:

  • 构建视图层次结构: 准确选择要为每个界面状态添加到屏幕中的 Google 组件和自定义业务视图。
  • 放置每个元素: 设置精确的屏幕锚点、边距和布局放置位置。避免对 Google 组件应用自定义宽度或高度约束,因为 Google 组件会计算自己的内部尺寸。
  • 为基本地图设置框架: 使用视口组件的坐标定义地图镜头的可见边界。
  • 分层显示屏幕: 确定自定义视图是浮动在 Google 内置控件上方、下方还是旁边。

同时,您无法使用布局委托自定义以下 Google 组件:

  • 组件尺寸: Google 提供的组件的大小和内部尺寸,由 SDK 自动计算。
  • 触发条件: 何时根据实时路线数据显示动态提醒或提示。

实现原则

编写布局委托时,请谨记以下规则,以防止布局 bug 或运行时崩溃:

  • 默认情况下,屏幕为空: 只有当您的委托明确将 Google 提供的组件添加到视图层次结构并放置它们时,这些组件才会显示。
  • 不支持旧版 API: 控制 旧版基于槽的布局模型的属性和方法将不受支持,并且在自定义委托处于活动状态时可能无法按 预期运行。
  • 请勿修改内部视图结构: 请勿使用 findViewById() 等方法遍历或修改 Google 提供的组件(例如转弯卡片或预计到达时间卡片)的视图层次结构。由于这些内部视图层次结构是底层实现细节,因此可能会在不同的 SDK 版本中发生变化。修改它们可能会导致您的布局在未来的 SDK 更新期间中断。

旧版 API 兼容性

为了在使用自定义布局委托时获得可靠的布局行为,请避免使用以下计划弃用的旧版基于槽的 API。如需迁移现有应用,请将您对这些 API 的使用替换为自定义布局委托中的代码:

显示旧版基于槽的 API

旧版 API 布局委托替换项
setCustomControl(View, CustomControlPosition) 将视图直接添加到 ConstraintLayout 或其他视图组。
removeCustomControl(View) 直接从视图层次结构中移除视图。
setEtaCardEnabled(boolean) onEnterActiveGuidance 中读取 etaCard 视图。
setHeaderEnabled(boolean) onEnterActiveGuidance 中读取 turnCard 视图。
setReportIncidentButtonEnabled(boolean) getActiveGuidanceButtons() 中找到 REPORTING 按钮。
setTripProgressBarEnabled(boolean) onEnterActiveGuidance 中读取 tripProgressBar 视图。
addOnNavigationUiChangedListener(...) 依赖于 NavigationLayoutDelegate 状态转换回调。
removeOnNavigationUiChangedListener(...) 使用委托直接管理布局状态转换。
addPromptVisibilityChangedListener(...) 依赖于 NavigationLayoutDelegate 提示回调(例如 onShowPrompt())。
removePromptVisibilityChangedListener(...) 使用委托直接管理提示显示逻辑。
setCompassEnabled(boolean) getNavigationReadyButtons()getActiveGuidanceButtons() 中找到指南针。

要素核对清单

请按照以下基本步骤和要求成功实现布局委托:

  • 在创建界面之前初始化委托: 在 SDK 初始化导航界面之前调用 setLayoutDelegate()。请参阅以下代码实现示例,以验证应用结构的准确 设置时间。如果在界面创建 初始化委托,系统会触发 ApiIllegalStateException

    Kotlin

    // For SupportNavigationFragment
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val navFragment = supportFragmentManager.findFragmentById(R.id.nav_fragment) as SupportNavigationFragment
        navFragment.setLayoutDelegate(MyLayoutDelegate())
    }
    
    // For a programmatic NavigationView
    val navigationView = NavigationView(context)
    navigationView.setLayoutDelegate(MyLayoutDelegate())
    navigationView.onCreate(savedInstanceState)
            

    Java

    // For SupportNavigationFragment
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        SupportNavigationFragment navFragment = (SupportNavigationFragment) getSupportFragmentManager().findFragmentById(R.id.nav_fragment);
        navFragment.setLayoutDelegate(new MyLayoutDelegate());
    }
    
    // For a programmatic NavigationView
    NavigationView navigationView = new NavigationView(context);
    navigationView.setLayoutDelegate(new MyLayoutDelegate());
    navigationView.onCreate(savedInstanceState);
            
  • 构建自己的布局容器: 创建自定义视图 组(例如 ConstraintLayout),以容纳自定义界面 元素和 Google 提供的视图。

  • 附加强制性视图: 您必须在所有导航状态下将 Google 徽标 (getGoogleLogo()) 和视口 (getViewport()) 添加到布局容器中。如果未能同时添加这两个视图 ,系统会触发运行时 ApiIllegalStateException 并导致应用崩溃。

  • 遵守固定尺寸: SDK 会确定多个 Google 提供的组件的 尺寸。请勿对以下元素应用自定义宽度 或高度约束:

    • 转弯卡片
    • 预计到达时间卡片
    • 即将收到的提示
    • Google 徽标
    • 速度 widget
  • 将布局附加到视图: 在 navigationView.setNavigationLayout()onEnterNavigationReady() 回调中调用 onEnterActiveGuidance(),以将容器附加到 地图视图层次结构。

  • 在状态退出时进行清理: 调用 navigationView.removeNavigationLayout() 并在您的 onLeave 回调中移除自定义 视图,以防止内存泄漏和 界面状态重叠。

  • 避免使用旧版布局 API: 在自定义委托处于活动状态时,请勿调用已废弃的 基于槽的 API,例如 setCustomControl()setHeaderEnabled()。当附加自定义委托时,SDK 可能会忽略这些旧版调用。

界面状态和 Google 组件

发生导航状态转换时,SDK 会将只读 UiState 对象传递给委托回调。此对象会将当前布局配置标志与您需要渲染屏幕的 Google 组件(例如 View 实例)捆绑在一起。

布局委托管理四种操作状态下的视图层次结构。 下图说明了 SDK 如何在导航状态之间转换,以及它在委托上执行的回调方法:

NavigationLayoutDelegate 生命周期状态转换图,显示了“非导航”“导航就绪”和“有效引导”状态之间的转换。

提供给委托的特定 Google 组件取决于导航生命周期的当前阶段。

必需组件(所有导航状态)

无论当前处于哪个导航阶段,您都必须在视图层次结构中添加、放置并保持以下 Google 组件可见:

  • Google 徽标 (getGoogleLogo()): 此组件会显示强制性的 Google 地图徽标。如果启用了重新居中 按钮,当驾驶员将地图滚动到远离车辆的位置时,徽标会自动转换为此按钮。因此,徽标的放置位置决定了重新居中 按钮的显示位置。建议将徽标放置在布局的底部起始(左下角)位置,以符合标准地图界面的预期。

  • 视口 (getViewport()): 一个不可见的 View,用于定义相机的取景边界。视口的放置位置决定了 SDK 将车辆箭头居中放置的确切位置,以及绘制活动路线的确切位置。放置视口时,请确保它覆盖屏幕的开放、未被遮挡的区域,并安全地避开不透明的叠加层(例如自定义底部工作表)。

在导航就绪状态下,界面保持最小化,以便将焦点放在基本地图上。NavigationReadyUiState 对象提供对以下内容的访问权限:

  • getNavigationReadyButtons(): 为基本地图配置的悬浮操作视图列表(通常仅包含罗盘按钮)。由于 Google 可能会在未来的 SDK 版本中添加或重新排序按钮,因此请避免依赖于固定的列表索引。相反,请遍历列表,通过将 getType()ButtonKnownType.COMPASS 进行比较来读取每个按钮的类型,并通过调用 getView() 提取物理视图。请注意,此列表中的确切按钮与主动精细导航期间可用的按钮不同。

主动精细导航组件

当精细导航开始时,SDK 会解锁全套导航控件。ActiveGuidanceUiState 对象提供对以下 Google 组件的访问权限:

  • getTurnCard(): 主要标题横幅,用于显示即将到来的操作方向、距离测量值和车道导航。将其放置在布局顶部,以建立熟悉的导航层次结构,并围绕其锚定自定义视图。

  • getEtaCard(): 页脚横幅,用于显示预计到达时间、剩余行程时间和到达目的地的剩余距离。 将其放置在屏幕底部边缘,或将其坐标与自定义任务管理工作表集成。

  • getTripProgressBar(): 一个垂直进度条,用于向驾驶员显示他们沿当前路线行驶的距离。与将此严格固定到地图边缘的旧版布局不同,您可以完全自由地将其锚定到任何位置,例如沿自定义容器的侧边缘。

  • getSpeedWidget(): 一个浮动控件,用于显示车速表和已发布的限速。根据您的 API 设置和数据可用性,此视图会在运行时动态调整大小,在四种视觉状态之间切换(不显示任何内容、仅显示当前速度、仅显示限速或同时显示两个读数)。由于 widget 可能会在这些大小之间切换,恕不另行通知,因此请始终使用约束条件锚定周围的视图,以便布局自动适应,防止空间重叠。

  • getActiveGuidanceButtons(): 主动精细导航状态的浮动操作视图的扩展列表(通常包括指南针按钮和事件报告按钮)。与导航就绪状态一样,您可以通过按 ButtonKnownTypeCOMPASSREPORTING)过滤列表并使用 getView() 提取视图,来查找和提取各个按钮视图。然后,您可以单独放置它们,也可以使用 AutoHidingLinearLayout 等布局安全地堆叠数组,而不会发生空间冲突。

动态提示组件

在主动精细导航期间,提示(例如事件提醒或安全摄像头警告)会独立触发。

当提示准备好显示时,SDK 会调用委托的 onShowPrompt() 回调并传递 newPrompt 视图。您的委托负责在布局上顺畅地放置此提示(通常锚定到地图容器的底部边缘)。

由于即将收到的提示会覆盖屏幕的下半部分,因此您必须更新布局,以防止提示与视口、Google 徽标或任何底部对齐的按钮重叠。

处理屏幕尺寸和宽屏模式

为了处理地图尺寸和屏幕方向的变化,布局委托使用以下功能:

  • 视图调整大小: 每当地图的物理尺寸发生变化时,都会调整布局。

  • 宽屏模式: 当地图足够宽时,切换到宽格式布局变体。

响应视图调整大小

每当地图容器的物理尺寸发生变化时,SDK 都会执行 onSizeChanged() 回调。分屏布局、布局滑块和设备旋转通常会触发 onSizeChanged() 回调。您可以使用此回调对自定义界面进行常规响应式调整。实现 onSizeChanged() 以针对新的屏幕宽高比重新放置自定义元素,应用您自己的自定义宽度或高度断点,并检测调整大小事件何时切换宽屏模式以安全地交换布局变体。

了解宽屏模式

当地图容器足够宽以并排显示界面组件时,宽屏模式会激活。

从状态对象中读取 isWideMode() 布尔值,以重新放置自定义界面元素,并保持地图中心对驾驶员清晰可见。由于 Google 提供的组件(例如转弯卡片和预计到达时间卡片)在宽屏模式下会自动缩小和调整形状,因此读取此布尔值可确保布局在 Google 组件更新的同时进行调整。

请考虑以下示例,了解如何在标准模式和宽屏模式下放置布局组件:

  • 标准纵向模式: 将转弯卡片放置在屏幕顶部,将预计到达时间卡片放置在底部。

  • 宽屏模式: 将转弯卡片移至屏幕的起始侧,将预计到达时间卡片移至结束侧。

在状态转换期间检查宽屏模式

如果您的布局支持宽屏模式变体,请在每个状态转换回调中评估 isWideMode() 布尔值,而不是仅依赖于 onSizeChanged()

onEnterNavigationReady()onEnterActiveGuidance() 等回调期间检查状态对象,以处理横屏模式下的初始应用启动。此方法可保护您的布局免受 Android 系统生命周期事件(例如默认 activity 重新创建)的影响,这些事件会完全绕过调整大小回调,确保在新的导航状态开始时激活正确的标准或宽屏布局变体。

与 Google 样式对齐

为了帮助您的自定义界面与 Google 的视觉节奏保持一致,SDK 提供了 StyleValues 实用程序类。您可以读取这些值(以密度无关像素 [dp] 为单位),使视图与 Google 组件完美对齐。

例如,如果您想在屏幕顶部与转弯卡片相对的角落放置一个自定义按钮,可以调用 StyleValues.headerTopPaddingDp() 并将返回的值分配为按钮的顶部边距。这可确保自定义按钮在视觉上与转弯卡片的顶部边缘对齐,从而保持屏幕对称性。

可用的样式内边距和测量值包括:

  • StyleValues.headerNominalHeightDp()
  • StyleValues.headerTopPaddingDp()
  • StyleValues.headerFooterSidePaddingDp()
  • StyleValues.mapControlSidePaddingDp()
  • StyleValues.buttonMapControlSidePaddingDp()

示例:基于约束的布局实现

以下示例演示了一个基本的布局委托,该委托使用程序化 ConstraintLayoutConstraintSet 定义来管理状态转换。

虽然此示例在代码中构建了视图约束,但布局委托也可以扩充标准 Android XML 布局。

Kotlin

/*
 * 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.
 */

@file:Suppress("PackageName")

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()
  }
}

    

Java

/*
 * 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}.
 *
 * <p><b>Understanding the Layout Delegate State Machine:</b> Navigation SDK transitions through
 * distinct states, each calling corresponding lifecycle methods on this delegate:
 *
 * <ul>
 *   <li><b>Navigation Ready:</b> Initiated by {@link #onEnterNavigationReady}. We initialize the
 *       layout here and add non-guidance views, then pass it to {@link NavigationUiParent} as the
 *       navigation layout.
 *   <li><b>Active Guidance (Turn-by-Turn Mode):</b> Initiated by {@link #onEnterActiveGuidance}. We
 *       set up the layout for Active Guidance, adding elements such as the turn card and ETA card.
 *   <li><b>Prompts:</b> Prompts (e.g., incident alerts) may be triggered during Active Guidance
 *       mode and can be added to the layout via {@link #onShowPrompt}.
 * </ul>
 *
 * 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());
  }
}