Dostosowywanie układu nawigacji

Przykładowa animacja przedstawiająca dostosowany układ nawigacji na Androidzie

Dostosuj układ, aby kontrolować hierarchię widoków i umiejscowienie na ekranie zarówno widoków udostępnianych przez Google, jak i własnych widoków biznesowych.

Zamiast dodawać widoki niestandardowe do predefiniowanych miejsc na nagłówku lub stopce, zaimplementuj delegata układu, aby zarządzać układem całego ekranu. Podczas przejść między stanami nawigacji delegat układu otrzymuje komponenty udostępniane przez Google, takie jak karta skrętu, karta szacowanego czasu dotarcia i przyciski. Umieść te elementy obok własnych treści niestandardowych za pomocą standardowych systemów układu Androida, takich jak ConstraintLayout, CoordinatorLayout lub LinearLayout.

Użyj tego frameworka, aby precyzyjnie umieścić na ekranie niestandardowe informacje biznesowe, takie jak stan zamówienia lub instrukcje odbioru, i zapobiec nakładaniu się widoków.

Jak działa dostosowywanie układu

Dostosowywanie układu korzysta z wzorca projektowego delegowania. Zamiast automatycznie rysować lub umieszczać komponenty interfejsu na ekranie, pakiet SDK przekazuje je bezpośrednio do klasy niestandardowej, którą napiszesz – delegata układu.

Aby dostosować układ, utwórz klasę, która rozszerza abstrakcyjną klasę NavigationLayoutDelegate, i przypisz instancję do NavigationView lub SupportNavigationFragment. Za każdym razem, gdy nastąpi przejście między stanami nawigacji, np. przejście z mapy podstawowej do aktywnego prowadzenia krok po kroku, pakiet SDK wykonuje metody wywołania zwrotnego w delegacie i udostępnia komponenty interfejsu dostępne w danym stanie.

Aby zapewnić spójną i zintegrowaną obsługę, ten framework wyraźnie rozdziela obowiązki między aplikację a pakiet SDK. Za pomocą delegata układu możesz dostosować te elementy:

  • Tworzenie hierarchii widoków: wybierz dokładnie, które komponenty Google i widoki biznesowe niestandardowe mają być dodawane do ekranu w każdym stanie interfejsu.
  • Umieszczanie każdego elementu: ustaw precyzyjne punkty zaczepienia, marginesy i umiejscowienie układu na ekranie. Unikaj stosowania niestandardowych ograniczeń szerokości lub wysokości do komponentów Google, ponieważ komponenty Google obliczają własne wymiary wewnętrzne.
  • Kadrowanie mapy podstawowej: zdefiniuj widoczne granice kamery mapy za pomocą współrzędnych komponentu widocznego obszaru.
  • Warstwy na ekranie: zdecyduj, czy widoki niestandardowe mają być wyświetlane nad, pod czy obok wbudowanych elementów sterujących Google.

Tymczasem za pomocą delegata układu nie można dostosować tych komponentów Google:

  • Wymiary komponentów: rozmiar i wymiary wewnętrzne komponentów udostępnianych przez Google, które pakiet SDK oblicza automatycznie.
  • Warunki wyzwalania: kiedy na podstawie danych o trasie w czasie rzeczywistym pojawiają się dynamiczne alerty lub prośby.

Zasady implementacji

Podczas pisania delegata układu pamiętaj o tych zasadach, aby zapobiec błędom układu lub awariom w czasie działania:

  • Domyślnie ekran jest pusty: komponenty udostępniane przez Google pojawiają się tylko wtedy, gdy delegat wyraźnie doda je do hierarchii widoków i je umieści.
  • Starsze interfejsy API nie są obsługiwane: właściwości i metody, które kontrolują starszy model układu oparty na miejscach, nie będą obsługiwane i mogą nie działać zgodnie z oczekiwaniami, gdy aktywny jest delegat niestandardowy.
  • Nie modyfikuj wewnętrznych struktur widoków: nie używaj metod takich jak findViewById() do przechodzenia przez hierarchie widoków komponentów udostępnianych przez Google (np. karty skrętu lub karty szacowanego czasu dotarcia na miejsce) ani do ich modyfikowania. Ponieważ te wewnętrzne hierarchie widoków są szczegółami implementacji, mogą się zmieniać w kolejnych wersjach pakietu SDK. Ich modyfikowanie może spowodować uszkodzenie układu podczas przyszłych aktualizacji pakietu SDK.

Zgodność ze starszymi interfejsami API

Aby zapewnić niezawodne działanie układu podczas korzystania z delegata układu niestandardowego, unikaj używania tych starszych interfejsów API opartych na miejscach, które mają zostać wycofane: Aby przeprowadzić migrację istniejącej aplikacji, zastąp użycie tych interfejsów API kodem w delegacie układu niestandardowego:

Pokaż starsze interfejsy API oparte na miejscach

Starszy interfejs API Zamiennik delegata układu
setCustomControl(View, CustomControlPosition) Dodaj widoki bezpośrednio do ConstraintLayout lub innej grupy widoków.
removeCustomControl(View) Usuń widoki bezpośrednio z hierarchii widoków.
setEtaCardEnabled(boolean) Odczytaj widok etaCard w onEnterActiveGuidance.
setHeaderEnabled(boolean) Odczytaj widok turnCard w onEnterActiveGuidance.
setReportIncidentButtonEnabled(boolean) Znajdź przycisk REPORTING w getActiveGuidanceButtons().
setTripProgressBarEnabled(boolean) Odczytaj widok tripProgressBar w onEnterActiveGuidance.
addOnNavigationUiChangedListener(...) Polegaj na wywołaniach zwrotnych przejścia między stanami NavigationLayoutDelegate.
removeOnNavigationUiChangedListener(...) Zarządzaj przejściami między stanami układu bezpośrednio za pomocą delegata.
addPromptVisibilityChangedListener(...) Polegaj na wywołaniach zwrotnych prośby NavigationLayoutDelegate (np. onShowPrompt()).
removePromptVisibilityChangedListener(...) Zarządzaj logiką wyświetlania prośby bezpośrednio za pomocą delegata.
setCompassEnabled(boolean) Znajdź kompas w getNavigationReadyButtons() lub getActiveGuidanceButtons().

Lista kontrolna najważniejszych elementów

Aby prawidłowo zaimplementować delegata układu, wykonaj te podstawowe czynności i spełnij te wymagania:

  • Zainicjuj delegata przed utworzeniem interfejsu: wywołaj setLayoutDelegate() zanim pakiet SDK zainicjuje interfejs nawigacji. Aby sprawdzić dokładny czas konfiguracji struktury aplikacji, zapoznaj się z tymi przykładami implementacji kodu. Zainicjowanie delegata po utworzeniu interfejsu wywoła 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);
            
  • Utwórz własny kontener układu: utwórz niestandardową grupę widoków, np. ConstraintLayout, aby pomieścić niestandardowe elementy interfejsu i widoki udostępniane przez Google.

  • Dołącz widoki obowiązkowe: w każdym stanie nawigacji musisz dodać logo Google (getGoogleLogo()) i widoczny obszar (getViewport()) do kontenera układu. Jeśli nie uwzględnisz obu tych widoków w czasie działania aplikacji zostanie wywołany wyjątek ApiIllegalStateException i aplikacja ulegnie awarii.

  • Przestrzegaj stałych wymiarów: pakiet SDK określa wymiary kilku komponentów udostępnianych przez Google. Nie stosuj niestandardowych ograniczeń szerokości ani wysokości do tych elementów:

    • Karta skrętu
    • Karta szacowanego czasu dotarcia
    • Przychodzące prośby
    • Logo Google
    • Widżet prędkości
  • Dołącz układ do widoku: wywołaj navigationView.setNavigationLayout() w wywołaniach zwrotnych onEnterNavigationReady() i onEnterActiveGuidance(), aby dołączyć kontener do hierarchii widoków mapy.

  • Zwalnianie miejsca po wyjściu ze stanu: wywołaj navigationView.removeNavigationLayout() i usuń widoki niestandardowe w wywołaniach zwrotnych onLeave, aby zapobiec wyciekom pamięci i nakładaniu się stanów interfejsu.

  • Unikaj starszych interfejsów API układu: gdy aktywny jest delegat niestandardowy, nie wywołuj wycofanych interfejsów API opartych na miejscach, takich jak setCustomControl() lub setHeaderEnabled(). Gdy dołączony jest delegat niestandardowy, pakiet SDK może ignorować te starsze wywołania.

Stany interfejsu i komponenty Google

Gdy nastąpi przejście między stanami nawigacji, pakiet SDK przekazuje do wywołania zwrotnego delegata obiekt UiState tylko do odczytu. Ten obiekt łączy bieżące flagi konfiguracji układu z komponentami Google (np. instancjami View), których potrzebujesz do renderowania ekranu.

Delegat układu zarządza hierarchią widoków w 4 stanach operacyjnych. Ten diagram stanu pokazuje, jak pakiet SDK przechodzi między stanami nawigacji i które metody wywołania zwrotnego wykonuje w delegacie:

Diagram przejść między stanami cyklu życia NavigationLayoutDelegate pokazujący przejścia między stanami Non-Navigation, Navigation Ready i Active Guidance.

Konkretne komponenty Google udostępniane delegatowi zależą od bieżącej fazy cyklu życia nawigacji.

Wymagane komponenty (wszystkie stany nawigacji)

Niezależnie od bieżącej fazy nawigacji musisz uwzględnić, umieścić i utrzymywać widoczność tych komponentów Google w hierarchii widoków:

  • Logo Google (getGoogleLogo()): ten komponent wyświetla obowiązkowe logo Map Google. Jeśli włączony jest przycisk Wyśrodkuj , logo automatycznie przekształca się w ten przycisk, gdy kierowca przewinie mapę z dala od pojazdu. Dlatego umiejscowienie logo decyduje o tym, gdzie pojawi się przycisk Wyśrodkuj. Zalecamy umieszczenie logo w lewym dolnym rogu układu, aby pasowało do standardowego interfejsu mapy.

  • Widoczny obszar (getViewport()): niewidoczny View, który definiuje granice kadrowania kamery. Umiejscowienie widocznego obszaru decyduje o tym, gdzie pakiet SDK wyśrodkuje strzałkę pojazdu i narysuje aktywną linię trasy. Umieść widoczny obszar tak, aby obejmował otwarty, niezasłonięty obszar ekranu, bezpiecznie unikając nieprzezroczystych nakładek, takich jak niestandardowe arkusze dolne.

W stanie gotowości do nawigacji interfejs jest minimalny, aby skupić się na mapie podstawowej. Obiekt NavigationReadyUiState zapewnia dostęp do tych elementów:

  • getNavigationReadyButtons(): lista pływających widoków działań skonfigurowanych dla mapy bazowej (zwykle tylko przycisk Kompas). Ponieważ Google może dodawać lub zmieniać kolejność przycisków w przyszłych wersjach pakietu SDK, unikaj polegania na stałych indeksach listy. Zamiast tego iteruj po liście, odczytaj typ każdego przycisku, porównując getType() z ButtonKnownType.COMPASS, i wyodrębnij widok fizyczny, wywołując getView(). Pamiętaj, że dokładne przyciski na tej liście różnią się od tych, które są dostępne podczas aktywnego prowadzenia.

Komponenty aktywnego prowadzenia

Gdy rozpocznie się prowadzenie krok po kroku, pakiet SDK odblokuje pełny zestaw elementów sterujących nawigacją. Obiekt ActiveGuidanceUiState zapewnia dostęp do tych komponentów Google:

  • getTurnCard(): główny baner nagłówka wyświetlający wskazówki dotyczące nadchodzącego manewru, pomiary odległości i asystent pasa ruchu. Umieść go u góry układu, aby utworzyć znaną hierarchię nawigacji i zakotwiczyć wokół niego widoki niestandardowe.

  • getEtaCard(): baner stopki wyświetlający szacowany czas dotarcia, pozostały czas podróży i pozostałą odległość do celu. Umieść go wzdłuż dolnej krawędzi ekranu lub zintegruj jego współrzędne z niestandardowymi arkuszami zarządzania zadaniami.

  • getTripProgressBar(): pionowy pasek postępu, który pokazuje kierowcy, jak daleko przejechał po bieżącej trasie. W przeciwieństwie do starszych układów, które były ściśle przypisane do krawędzi mapy, masz pełną swobodę zakotwiczenia go w dowolnym miejscu – na przykład wzdłuż bocznej krawędzi kontenera niestandardowego.

  • getSpeedWidget(): pływający element sterujący prędkościomierzem i obowiązującymi ograniczeniami prędkości. W zależności od ustawień interfejsu API i dostępności danych ten widok dynamicznie zmienia rozmiar między 4 stanami wizualnymi w czasie działania (wyświetlając nic, tylko bieżącą prędkość, tylko ograniczenie prędkości lub oba odczyty). Ponieważ widżet może zmieniać rozmiar bez powiadomienia, zawsze zakotwiczaj otaczające widoki za pomocą ograniczeń, aby układ automatycznie dostosowywał się do zapobiegania nakładaniu się przestrzeni.

  • getActiveGuidanceButtons(): rozwinięta lista pływających widoków działań w stanie aktywnego prowadzenia (zwykle obejmująca zarówno przycisk Kompas, jak i przycisk Zgłoś incydent). Podobnie jak w stanie gotowości do nawigacji, możesz znaleźć i wyodrębnić poszczególne widoki przycisków, filtrując listę według ButtonKnownType (COMPASS lub REPORTING) i wyodrębniając widok za pomocą getView(). Następnie możesz je umieścić niezależnie lub użyć układu takiego jak AutoHidingLinearLayout, aby bezpiecznie ułożyć tablicę bez konfliktów przestrzennych.

Komponenty dynamicznych próśb

Prośby, takie jak alerty o incydentach lub ostrzeżenia o kamerach bezpieczeństwa, są wyzwalane niezależnie podczas aktywnego prowadzenia.

Gdy prośba jest gotowa do wyświetlenia, pakiet SDK wywołuje wywołanie zwrotne delegata onShowPrompt() i przekazuje widok newPrompt. Delegat jest odpowiedzialny za płynne umieszczenie tej prośby nad układem (zwykle zakotwiczonym do dolnej krawędzi kontenera mapy).

Ponieważ przychodząca prośba obejmuje dolną część ekranu, musisz zaktualizować układ, aby zapobiec nakładaniu się prośby na widoczny obszar, logo Google lub przyciski wyrównane do dołu.

Obsługa rozmiaru ekranu i trybu szerokiego

Aby obsługiwać zmiany wymiarów mapy i orientacji ekranu, delegat układu korzysta z tych funkcji:

  • Zmiana rozmiaru widoku: dostosowuje układ za każdym razem, gdy zmienia się fizyczny rozmiar mapy.

  • Tryb szeroki: przełącza się na wariant układu w formacie szerokim, gdy mapa jest wystarczająco szeroka.

Reagowanie na zmianę rozmiaru widoku

Za każdym razem, gdy zmieniają się fizyczne wymiary kontenera mapy, pakiet SDK wykonuje wywołanie zwrotne onSizeChanged(). Układy z podzielonym ekranem, suwaki układu i obracanie urządzenia zwykle wywołują wywołanie zwrotne onSizeChanged(). Użyj tego wywołania zwrotnego, aby wprowadzić ogólne zmiany w interfejsie niestandardowym. Zaimplementuj onSizeChanged(), aby zmienić położenie elementów niestandardowych w przypadku nowego współczynnika proporcji ekranu, zastosować własne niestandardowe punkty przerwania szerokości lub wysokości oraz wykryć, kiedy zdarzenie zmiany rozmiaru przełącza tryb szeroki, aby bezpiecznie przełączać warianty układu.

Informacje o trybie szerokim

Tryb szeroki aktywuje się, gdy kontener mapy jest wystarczająco szeroki, aby wyświetlać komponenty interfejsu obok siebie.

Odczytaj wartość logiczną isWideMode() z obiektu stanu, aby zmienić położenie niestandardowych elementów interfejsu i zachować środek mapy wolny dla kierowcy. Ponieważ komponenty udostępniane przez Google, takie jak karta skrętu i karta szacowanego czasu dotarcia na miejsce, automatycznie zmniejszają się i zmieniają kształt w trybie szerokim, odczytanie tej wartości logicznej zapewnia, że układ dostosuje się dokładnie w tym samym momencie, w którym zaktualizują się komponenty Google.

Oto kilka przykładów umieszczania komponentów układu w trybie standardowym i szerokim:

  • Standardowy tryb pionowy: umieść kartę skrętu u góry ekranu, a kartę szacowanego czasu dotarcia u dołu.

  • Tryb szeroki: przenieś kartę skrętu na początek ekranu, a kartę szacowanego czasu dotarcia na koniec.

Sprawdzanie trybu szerokiego podczas przejść między stanami

Jeśli układ obsługuje wariant trybu szerokiego, oceń wartość logiczną isWideMode() w każdym wywołaniu zwrotnym przejścia między stanami, zamiast polegać tylko na onSizeChanged().

Sprawdź obiekt stanu podczas wywołań zwrotnych, takich jak onEnterNavigationReady() i onEnterActiveGuidance(), aby obsługiwać początkowe uruchomienia aplikacji w trybie poziomym. To podejście chroni układ przed zdarzeniami cyklu życia systemu Android, takimi jak domyślne ponowne tworzenie aktywności, które całkowicie pomijają wywołanie zwrotne zmiany rozmiaru, dzięki czemu prawidłowy wariant układu standardowego lub szerokiego aktywuje się w momencie rozpoczęcia nowego stanu nawigacji.

Dopasowywanie do stylu Google

Aby pomóc w dopasowaniu interfejsu niestandardowego do wizualnego rytmu Google, pakiet SDK udostępnia klasę narzędziową StyleValues. Możesz odczytać te wartości w pikselach niezależnych od gęstości (dp), aby idealnie wyrównać widoki z komponentami Google.

Jeśli na przykład chcesz umieścić przycisk niestandardowy w prawym górnym rogu ekranu naprzeciwko karty skrętu, możesz wywołać StyleValues.headerTopPaddingDp() i przypisać zwróconą wartość jako górny margines przycisku. Dzięki temu przycisk niestandardowy będzie wizualnie wyrównany z górną krawędzią karty skrętu, co zapewni symetrię ekranu.

Dostępne dopełnienia i pomiary stylu obejmują:

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

Przykład: implementacja układu opartego na ograniczeniach

Ten przykład pokazuje podstawowego delegata układu, który zarządza przejściami między stanami za pomocą programowych definicji ConstraintLayout i ConstraintSet.

Chociaż w tym przykładzie ograniczenia widoków są tworzone w kodzie, delegaci układu mogą też rozszerzać standardowe układy XML Androida.

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