
使用版面配置自訂功能,控管 Google 提供的檢視畫面和您自己的自訂商家檢視畫面的檢視畫面階層和螢幕位置。
請實作版面配置委派,管理整個畫面的版面配置,而不是在預先定義的頁首或頁尾位置中加入自訂檢視區塊。在導覽狀態轉場期間,版面配置委派會收到 Google 提供的元件,例如轉彎資訊卡、預計到達時間資訊卡和按鈕。使用標準 Android 版面配置系統 (例如 ConstraintLayout、CoordinatorLayout 或 LinearLayout),將這些元素與自訂內容並列。
使用這個架構,在畫面上精確放置自訂商家資訊 (例如訂單狀態或取貨說明),同時避免檢視畫面重疊。
版面配置自訂功能的運作方式
版面配置自訂功能採用委派設計模式。SDK 不會在畫面上自動繪製或放置 UI 元件,而是直接將這些元件傳遞至您編寫的自訂類別 (即版面配置委派)。
如要自訂版面配置,請建立擴充抽象NavigationLayoutDelegate 類別的類別,並將執行個體指派給 NavigationView 或 SupportNavigationFragment。每當導覽狀態轉換時 (例如從基本地圖進入即時路線導覽),SDK 就會在委派項目上執行回呼方法,並提供該特定狀態可用的 UI 元件。
為提供流暢整合的使用者體驗,這個架構會在應用程式和 SDK 之間明確劃分責任。使用版面配置委派項目自訂下列項目:
- 建構檢視區塊階層:針對每個 UI 狀態,選擇要加入畫面的確切 Google 元件和自訂商家檢視區塊。
- 放置每個元素:設定精確的螢幕錨點、邊界和版面配置位置。請勿對 Google 元件套用自訂寬度或高度限制,因為 Google 元件會自行計算內部尺寸。
- 設定基本地圖的畫面:使用可視區域元件的座標,定義地圖攝影機的可見邊界。
- 分層顯示畫面:決定自訂檢視區塊要浮動顯示在 Google 內建控制項上方、下方或旁邊。
同時,您無法使用版面配置委派自訂下列 Google 元件:
- 元件尺寸:SDK 會自動計算 Google 提供元件的大小和內部尺寸。
- 觸發條件:根據即時路線資料顯示動態快訊或提示。
導入原則
撰寫版面配置委派時,請注意下列規則,以免發生版面配置錯誤或執行階段當機:
- 預設為空白畫面:只有在委派項目明確將 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() 中找出指南針。 |
基本檢查清單
請按照下列基本步驟和規定,順利實作版面配置委派:
-
在建立 UI 前初始化委派:在 SDK 初始化導覽 UI 前,呼叫
setLayoutDelegate()。請參閱下列程式碼導入範例,確認應用程式結構的確切設定時間。在 UI 建立後初始化委派,會觸發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),用來存放自訂 UI 元素和 Google 提供的檢視區塊。 -
附加必要檢視區塊:您必須在所有導覽狀態中,將 Google 標誌 (
getGoogleLogo()) 和檢視區塊 (getViewport()) 新增至版面配置容器。如果未同時納入這兩個檢視區塊,就會觸發執行階段ApiIllegalStateException,導致應用程式當機。 -
遵守固定尺寸:SDK 會決定多個 Google 提供元件的尺寸。請勿對下列元素套用自訂寬度 或高度限制:
- 翻轉資訊卡
- 預計抵達時間資訊卡
- 傳入提示
- Google 標誌
- 速度小工具
-
將版面配置附加至檢視區塊:在
onEnterNavigationReady()和onEnterActiveGuidance()回呼中呼叫navigationView.setNavigationLayout(),將容器附加至地圖檢視區塊階層。 -
在狀態結束時清理:在
onLeave回呼中呼叫navigationView.removeNavigationLayout()並移除自訂檢視畫面,避免發生記憶體洩漏和 UI 狀態重疊的問題。 -
避免使用舊版版面配置 API:自訂委派項目處於啟用狀態時,請勿呼叫已淘汰的版位式 API,例如
setCustomControl()或setHeaderEnabled()。如果附加自訂委派項目,SDK 可能會忽略這些舊版呼叫。
UI 狀態和 Google 元件
導覽狀態轉換時,SDK 會將唯讀的 UiState 物件傳遞至委派回呼。這個物件會將目前的版面配置設定旗標與您需要轉譯畫面的 Google 元件 (例如 View 執行個體) 組合在一起。
版面配置委派會管理四種作業狀態的檢視區塊階層。下圖顯示 SDK 如何在導覽狀態之間轉換,以及在委派項目上執行的回呼方法:
提供給委派對象的特定 Google 元件,取決於導覽生命週期的目前階段。
必要元件 (所有導覽狀態)
無論目前處於哪個導覽階段,您都必須在檢視區塊階層中加入、放置並保持下列 Google 元件的顯示狀態:
Google 標誌 (
getGoogleLogo()):這個元件會顯示強制性 Google 地圖標誌。如果啟用「重新置中」按鈕,當駕駛人捲動地圖,使車輛不在畫面中央時,標誌會自動變成這個按鈕。因此,標誌的位置會決定「重新置中」按鈕的顯示位置。建議將標誌放在版面配置的底部開頭 (左下角),以符合標準地圖 UI 的預期。可視區域 (
getViewport()):定義攝影機取景邊界的隱形View。可視區域的位置會決定 SDK 將車輛 V 形箭頭置中顯示的位置,以及繪製有效路線線條的位置。放置檢視區塊,使其涵蓋螢幕上未遭遮蔽的開放區域,並安全避開不透明的疊加層,例如自訂底部功能表。
適用於導覽的元件
在導覽就緒狀態期間,使用者介面會保持簡潔,讓使用者專注於基本地圖。您的 NavigationReadyUiState 物件可存取下列項目:
- getNavigationReadyButtons():為基本地圖設定的懸浮動作檢視區塊清單 (通常只有「指南針」按鈕)。因為 Google 可能會在日後發布的 SDK 版本中新增或重新排序按鈕,請避免依賴固定清單索引。請改為疊代清單、比較
getType()和ButtonKnownType.COMPASS讀取每個按鈕的類型,然後呼叫getView()擷取實體檢視區塊。請注意,這份清單中的按鈕與主動引導期間可用的按鈕不同。
啟用指引元件
開始提供即時路線導航時,SDK 會解鎖整套導航控制項。您的 ActiveGuidanceUiState 物件可存取下列 Google 元件:
getTurnCard():主要標題橫幅,顯示即將到來的轉彎方向、距離測量結果和車道指引。將其放在版面配置頂端,建立熟悉的導覽階層,並將自訂檢視畫面錨定在周圍。getEtaCard():顯示預計抵達時間、剩餘行程時間和目的地剩餘距離的頁尾橫幅。將其放置在螢幕底邊,或將座標整合至自訂工作管理表單。getTripProgressBar():垂直進度列,顯示駕駛人目前在路線上的行駛距離。與舊版版面配置不同,您可將這個元素錨定在任何位置,例如自訂容器的側邊緣,而不像舊版版面配置一樣,只能將這個元素錨定在地圖邊緣。getSpeedWidget():顯示速限和車速的浮動控制項。視 API 設定和資料可用性而定,這個檢視區塊會在執行階段動態調整大小,呈現四種視覺狀態 (不顯示任何內容、只顯示目前速度、只顯示速限,或同時顯示這兩項讀數)。由於小工具可能會在這些大小之間變換,恕不另行通知,請務必使用限制條件錨定周圍的檢視區塊,讓版面配置自動調整,避免空間重疊。
getActiveGuidanceButtons():作用中指引狀態的懸浮動作檢視區塊展開清單 (通常包括「指南針」和「事故回報」按鈕)。如同在導覽就緒狀態中,您可以透過依ButtonKnownType(COMPASS或REPORTING) 篩選清單,找出並擷取個別按鈕檢視區塊,然後使用getView()擷取檢視區塊。接著,您可以獨立放置這些揚聲器,也可以使用AutoHidingLinearLayout等配置,安全地堆疊陣列,不會發生空間衝突。
動態提示元件
在主動引導期間,系統會獨立觸發提示,例如事件警報或安全攝影機警告。
當提示準備好顯示時,SDK 會呼叫委派項目的 onShowPrompt() 回呼,並傳遞 newPrompt 檢視區塊。委派對象負責將這個提示順利放置在版面配置上 (通常會錨定在地圖容器的底邊)。
由於系統會將提示顯示在畫面底部,因此您必須更新版面配置,避免提示與檢視區塊、Google 標誌或任何底部對齊的按鈕重疊。
處理螢幕大小和寬螢幕模式
如要處理地圖尺寸和螢幕方向的變化,版面配置委派會使用下列功能:
檢視畫面大小調整:地圖的實際大小變更時,系統會調整版面配置。
寬螢幕模式:地圖夠寬時,會切換為寬螢幕版面配置變體。
回應檢視區塊大小調整
地圖容器的實體尺寸變更時,SDK 會執行onSizeChanged() 回呼。分割畫面配置、配置滑桿和裝置旋轉通常會觸發 onSizeChanged() 回呼。使用這個回呼,對自訂 UI 進行一般回應式調整。實作 onSizeChanged(),根據新的螢幕顯示比例重新放置自訂元素、套用自訂寬度或高度中斷點,並偵測大小調整事件何時會切換寬螢幕模式,以便安全地切換版面配置變體。
瞭解寬版顯示模式
當地圖容器寬度足夠並排顯示 UI 元件時,就會啟動寬螢幕模式。
從狀態物件讀取 isWideMode() 布林值,重新放置自訂 UI 元素,並確保地圖中央區域不會遮擋駕駛人視線。因為 Google 提供的元件 (例如轉彎資訊卡和預計到達時間資訊卡) 會在寬螢幕模式中自動縮小和重塑,讀取這個布林值可確保版面配置在 Google 元件更新的同一時間進行調整。
請參考下列範例,瞭解如何在標準和寬螢幕模式中放置版面配置元件:
標準直向模式:將轉彎資訊卡放在畫面頂端,預計抵達時間資訊卡放在底部。
寬螢幕模式:將轉彎資訊卡移至螢幕的開始側,將預計抵達時間資訊卡移至結束側。
在狀態轉換期間檢查寬版模式
如果版面配置支援寬螢幕模式變化版本,請評估每個狀態轉換回呼中的 isWideMode() 布林值,而不是只依賴 onSizeChanged()。
onEnterNavigationReady() 和 onEnterActiveGuidance() 等回呼期間檢查狀態物件,以便處理橫向模式下的初始應用程式啟動作業。這種做法可保護版面配置,避免因 Android 系統生命週期事件 (例如預設的 Activity 重新建立) 完全略過大小調整回呼,確保在新的導覽狀態開始時,系統會立即啟用正確的標準或寬版版面配置變體。
符合 Google 樣式
為協助自訂 UI 配合 Google 的視覺節奏,SDK 提供 StyleValues 公用程式類別。您可以讀取這些密度獨立像素 (dp) 值,將檢視區塊與 Google 元件完美對齊。
舉例來說,如要在畫面頂端角落放置自訂按鈕,與轉彎資訊卡相對,可以呼叫 StyleValues.headerTopPaddingDp(),並將傳回的值指派為按鈕的頂端邊界。這可確保自訂按鈕在視覺上與回合資訊卡的頂端對齊,維持螢幕對稱性。
可用的樣式邊框間距和測量值包括:
StyleValues.headerNominalHeightDp()StyleValues.headerTopPaddingDp()StyleValues.headerFooterSidePaddingDp()StyleValues.mapControlSidePaddingDp()StyleValues.buttonMapControlSidePaddingDp()
範例:根據限制條件實作版面配置
以下範例示範基本版面配置委派,可使用程式輔助 ConstraintLayout 和 ConstraintSet 定義管理狀態轉換。
雖然這個範例會在程式碼中建構檢視區塊限制,但版面配置委派也可以膨脹標準 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()); } }