
使用版面配置自訂功能,控制 Google 提供的檢視畫面和自訂業務檢視畫面的檢視區塊階層和畫面位置。
請勿將自訂檢視畫面新增至預先定義的標頭或頁尾版位,而是實作版面配置委派,管理整個畫面的版面配置。在導航狀態轉換期間,版面配置委派會收到 Google 提供的元件,例如轉彎資訊卡、預計到達時間資訊卡和按鈕。使用標準 Android 版面配置系統 (例如 ConstraintLayout、CoordinatorLayout 或 LinearLayout),將這些元素與自訂內容並排。
使用這個架構,即可在螢幕上精確定位自訂業務資訊 (例如訂單狀態或取貨操作說明),同時避免檢視畫面重疊。
版面配置自訂功能的運作方式
版面配置自訂功能採用委派設計模式。SDK 不會在畫面上自動繪製或定位 UI 元件,而是直接將元件傳遞至您編寫的自訂類別:版面配置委派。
如要自訂版面配置,請建立用於擴充抽象NavigationLayoutDelegate 類別的類別,並將例項指派給 NavigationView 或 SupportNavigationFragment。每當導航狀態轉換時 (例如從基本地圖切換至即時路線指引),SDK 就會執行委派物件的回呼方法,並提供該特定狀態可用的 UI 元件。
為提供流暢、整合的使用者體驗,這個架構明確區分應用程式和 SDK 的責任。使用版面配置委派來自訂下列項目:
- 建構檢視區塊階層:選擇要為每個 UI 狀態新增至畫面的 Google 元件和自訂業務檢視區塊。
- 定位每個元素:設定精確的螢幕錨點、邊界和版面配置位置。請避免對 Google 元件套用自訂寬度或高度限制,因為 Google 元件會自行計算內部尺寸。
- 設定基本地圖的框架:使用可視區域元件的座標,定義地圖攝影機的可視邊界。
- 分層顯示畫面:決定自訂檢視畫面要浮動顯示在 Google 內建控制項的上方、下方或旁邊。
同時,您無法使用版面配置委派來自訂下列 Google 元件:
- 元件尺寸:Google 提供的元件大小和內部尺寸,由 SDK 自動計算。
- 觸發條件:根據即時路線資料,顯示動態快訊或提示。
導入原則
編寫版面配置委派時,請遵守下列規則,避免版面配置錯誤或執行階段當機:
- 預設為空白畫面:只有在委派明確將 Google 提供的元件加入檢視區塊階層並定位時,這些元件才會顯示。
- 不支援舊版 API:系統不支援控制舊版版位版面配置模型的屬性和方法,且在自訂委派代表啟用時,可能不會正常運作。
- 請勿修改內部檢視區塊結構:請勿使用
findViewById()等方法,周遊或修改 Google 提供元件 (例如轉彎資訊卡或預計到達時間資訊卡) 的檢視區塊階層。由於這些內部檢視區塊階層是基礎實作細節,因此可能會隨著 SDK 版本而變更。如果修改這些 ID,可能會導致版面配置在日後的 SDK 更新中中斷。
舊版 API 相容性
使用自訂版面配置委派代表時,為確保版面配置行為可靠,請避免使用下列以版位為基礎的舊版 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() 中找出指南針。 |
Essentials 檢查清單
請按照下列基本步驟和規定,成功導入版面配置委派:
-
在建立 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(),將容器附加至地圖檢視區塊階層。 -
在狀態結束時清除:呼叫
navigationView.removeNavigationLayout(),並移除onLeave回呼中的自訂檢視畫面,避免記憶體流失和重疊的使用者介面狀態。 -
避免使用舊版版面配置 API:自訂委派代表處於啟用狀態時,請勿呼叫已淘汰的版位 API,例如
setCustomControl()或setHeaderEnabled()。附加自訂委派代表時,SDK 可能會忽略這些舊版通話。
UI 狀態和 Google 元件
導航狀態轉換時,SDK 會將唯讀的 UiState 物件傳遞至委派回呼。這個物件會將目前的版面配置設定旗標,與轉譯畫面所需的 Google 元件 (例如 View 執行個體) 綁定。
版面配置委派代表會管理四種作業狀態的檢視區塊階層。下列狀態圖說明 SDK 如何在導航狀態之間轉換,以及在委派物件上執行的回呼方法:
提供給委派代表的特定 Google 元件,取決於導航生命週期的目前階段。
必要元件 (所有導覽狀態)
無論目前的導航階段為何,您都必須在檢視區塊階層中加入、定位及保留下列 Google 元件:
Google 標誌 (
getGoogleLogo()):這個元件會顯示必要的 Google 地圖標誌。如果啟用「重新置中」按鈕,當駕駛人捲動地圖,使車輛移出畫面時,標誌會自動轉換為這個按鈕。因此,標誌的位置會決定「重新置中」按鈕顯示的位置。建議將標誌放在版面配置的底部開頭 (左下角),以符合標準地圖 UI 預期。可視區域 (
getViewport()):定義攝影機取景邊界的隱形View。SDK 會根據可視區域的位置,精確決定車輛 V 形符號的中心點,並繪製有效路線。調整可視區域的位置,使其覆蓋螢幕上未遮蔽的開放區域,安全避開自訂底部功能表等不透明疊加層。
個導覽就緒元件
在導航就緒狀態下,UI 會保持精簡,讓使用者專注於基本地圖。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. */ 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()); } }
如要查看完整的程式碼範例,或探索其他版面配置委派實作,請參閱 GitHub 上的版面配置委派範例。您也可以在 GitHub 上查看這個範例的個別來源檔案: