התאמה אישית של פריסת הניווט

אנימציה לדוגמה שמציגה פריסת ניווט מותאמת אישית ב-Android

התאמה אישית של הפריסה מאפשרת לכם לשלוט בהיררכיית התצוגה ובמיקום המסך גם של תצוגות שסופקו על ידי Google וגם של תצוגות עסקיות מותאמות אישית משלכם.

במקום להוסיף תצוגות מותאמות אישית למקומות מוגדרים מראש בכותרת העליונה או התחתונה, כדאי להטמיע נציג פריסה כדי לנהל את הפריסה של כל המסך. במהלך מעברי מצב הניווט, נציג הפריסה מקבל את הרכיבים שסופקו על ידי Google, כמו כרטיס הפנייה, כרטיסיית פרטי זמן הגעה משוער ולחצנים. אפשר למקם את הרכיבים האלה לצד תוכן מותאם אישית משלכם באמצעות מערכות פריסה סטנדרטיות של Android כמו ConstraintLayout,‏ CoordinatorLayout או LinearLayout.

אתם יכולים להשתמש במסגרת הזו כדי למקם פרטי עסק – כמו סטטוס הזמנה או הוראות לאיסוף – בצורה מדויקת על המסך, וגם כדי למנוע חפיפה בין התצוגות.

איך עובדת התאמה אישית של פריסה

התאמה אישית של הפריסה מתבצעת באמצעות דפוס עיצוב של הקצאת הרשאות. במקום לצייר או למקם באופן אוטומטי רכיבי ממשק משתמש על המסך, ערכת ה-SDK מעבירה אותם ישירות למחלקה מותאמת אישית שאתם כותבים: נציג הפריסה.

כדי להתאים אישית את הפריסה, יוצרים מחלקה שמרחיבה את המחלקה המופשטת NavigationLayoutDelegate ומקצים מופע ל-NavigationView או ל-SupportNavigationFragment. בכל פעם שמתבצע מעבר בין מצבי הניווט – למשל, מעבר ממפת הבסיס להנחיות פעילות במסלול מפורט – ה-SDK מפעיל את שיטות הקריאה החוזרת בנציג ומספק את רכיבי ממשק המשתמש שזמינים למצב הספציפי הזה.

כדי לספק חוויית משתמש חלקה ומשולבת, המסגרת הזו יוצרת הפרדה ברורה בין האחריות של האפליקציה לבין האחריות של ה-SDK. אפשר להשתמש ב-layout delegate כדי להתאים אישית את הדברים הבאים:

  • יוצרים את היררכיית התצוגות: בוחרים בדיוק אילו רכיבים של Google ותצוגות עסקיות מותאמות אישית להוסיף למסך לכל מצב של ממשק המשתמש.
  • מיקום של כל רכיב: הגדרה של עוגנים מדויקים למסך, שוליים ומיקום הפריסה. אל תגדירו מגבלות מותאמות אישית של רוחב או גובה לרכיבי Google, כי רכיבי Google מחשבים את המימדים הפנימיים שלהם בעצמם.
  • הגדרת הגבולות של מפת הבסיס: הגדרת הגבולות הגלויים של מצלמת המפה באמצעות הקואורדינטות של רכיב אזור התצוגה.
  • הוספת שכבה למסך: קובעים אם התצוגות המותאמות אישית יופיעו מעל, מתחת או לצד אמצעי הבקרה המובנים של Google.

בינתיים, אי אפשר להתאים אישית את רכיבי Google הבאים באמצעות delegate הפריסה:

  • מאפייני רכיבים: הגודל והמאפיינים הפנימיים של רכיבים ש-Google מספקת, שה-SDK מחשב באופן אוטומטי.
  • תנאים להפעלת ההתראה: כשמוצגות התראות או הנחיות דינמיות על סמך נתוני מסלול בזמן אמת.

עקרונות ההטמעה

כשכותבים נציג פריסה, חשוב לזכור את הכללים הבאים כדי למנוע באגים בפריסה או קריסות בזמן ריצה:

  • ברירת המחדל היא מסך ריק: רכיבים שסופקו על ידי Google מופיעים רק אם הנציג מוסיף אותם באופן מפורש להיררכיית התצוגה וממקם אותם.
  • אין תמיכה בממשקי API מדור קודם: לא תהיה תמיכה במאפיינים ובשיטות ששולטים במודל הפריסה מדור קודם שמבוסס על מיקומי מודעות, והם עשויים שלא לפעול כמצופה כשנציג מותאם אישית פעיל.
  • אל תשנו את מבני התצוגה הפנימיים: אל תשתמשו בשיטות כמו findViewById() כדי לעבור בין היררכיות התצוגה של רכיבים ש-Google מספקת (כמו כרטיס הפנייה או כרטיסיית פרטי זמן הגעה משוער) או לשנות אותן. מכיוון שהיררכיות פנימיות של תצוגות הן פרטי הטמעה בסיסיים, הן עשויות להשתנות בין גרסאות SDK. שינוי שלהם עלול לגרום לשיבוש הפריסה במהלך עדכוני SDK עתידיים.

תאימות ל-API מדור קודם

כדי להבטיח התנהגות אמינה של פריסת הרכיבים כשמשתמשים בנציג פריסה בהתאמה אישית, מומלץ להימנע משימוש בממשקי ה-API הבאים מבוססי-המשבצות מדור קודם, שאנחנו מתכננים להוציא משימוש. כדי להעביר אפליקציה קיימת, צריך להחליף את השימוש בממשקי ה-API האלה בקוד בנציג פריסה בהתאמה אישית:

הצגת ממשקי API קודמים שמבוססים על משבצות

Legacy API החלפת נציג פריסה
setCustomControl(View, CustomControlPosition) אפשר להוסיף את התצוגות ישירות לConstraintLayout או לקבוצת תצוגות אחרת.
removeCustomControl(View) אפשר להסיר תצוגות ישירות מהיררכיית התצוגות.
setEtaCardEnabled(boolean) קוראים את התצוגה etaCard ב-onEnterActiveGuidance.
setHeaderEnabled(boolean) קוראים את התצוגה turnCard ב-onEnterActiveGuidance.
setReportIncidentButtonEnabled(boolean) מאתרים את הלחצן REPORTING ב-getActiveGuidanceButtons().
setTripProgressBarEnabled(boolean) קוראים את התצוגה tripProgressBar ב-onEnterActiveGuidance.
addOnNavigationUiChangedListener(...) להסתמך על קריאות חוזרות (callback) של שינוי המצב של NavigationLayoutDelegate.
removeOnNavigationUiChangedListener(...) אפשר לנהל את המעברים בין מצבי הפריסה ישירות באמצעות הנציג.
addPromptVisibilityChangedListener(...) אפשר להסתמך על קריאות חוזרות (callback) להנחיות של NavigationLayoutDelegate (כמו onShowPrompt()).
removePromptVisibilityChangedListener(...) אפשר לנהל את הלוגיקה של הצגת הפרומפטים ישירות באמצעות הנציג.
setCompassEnabled(boolean) מחפשים את המצפן ב-getNavigationReadyButtons() או ב-getActiveGuidanceButtons().

רשימת משימות ב-Essentials

כדי להטמיע את נציג הפריסה בהצלחה, צריך לפעול לפי השלבים והדרישות החיוניים הבאים:

  • מפעילים את הפונקציה delegate לפני שיוצרים את ממשק המשתמש: קוראים לפונקציה setLayoutDelegate() לפני ש-SDK מפעיל את ממשק המשתמש של הניווט. כדי לוודא את התזמון המדויק של ההגדרה במבנה האפליקציה, אפשר לעיין בדוגמאות הבאות להטמעת קוד. אתחול הנציג אחרי יצירת ממשק המשתמש מפעיל את ApiIllegalStateException.

    Kotlin

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

    Java

    // For SupportNavigationFragment
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        SupportNavigationFragment navFragment = (SupportNavigationFragment) getSupportFragmentManager().findFragmentById(R.id.nav_fragment);
        navFragment.setLayoutDelegate(new MyLayoutDelegate());
    }
    
    // For a programmatic NavigationView
    NavigationView navigationView = new NavigationView(context);
    navigationView.setLayoutDelegate(new MyLayoutDelegate());
    navigationView.onCreate(savedInstanceState);
            
  • יצירת מאגר פריסה משלכם: יצירת קבוצת תצוגות בהתאמה אישית – כמו ConstraintLayout – כדי להכיל את רכיבי ממשק המשתמש המותאמים אישית ואת התצוגות שסופקו על ידי Google.

  • צירוף תצוגות חובה: בכל מצבי הניווט, צריך להוסיף את הלוגו של Google ‏(getGoogleLogo()) ואת אזור התצוגה (getViewport()) למאגר פריסת הרכיבים. אם לא כוללים את שתי התצוגות האלה, מתרחשת שגיאת זמן ריצה ApiIllegalStateException והאפליקציה קורסת.

  • התחשבות במימדים קבועים: ה-SDK קובע את המימדים של כמה רכיבים שסופקו על ידי Google. אל תחיל מגבלות של רוחב או גובה מותאמים אישית על הרכיבים הבאים:

    • הפעלת הכרטיס
    • כרטיסיית פרטי זמן הגעה משוער
    • הודעות נכנסות
    • הלוגו של Google
    • ווידג'ט מהירות
  • מצרפים את פריסת הרכיבים לתצוגה: קוראים ל-navigationView.setNavigationLayout() בתוך הקריאות החוזרות (callback) של onEnterNavigationReady() ושל onEnterActiveGuidance() כדי לצרף את הקונטיינר להיררכיית תצוגת המפה.

  • ניקוי ביציאה מהמצב: קוראים ל-navigationView.removeNavigationLayout() ומסירים את התצוגות המותאמות אישית בתוך הקריאות החוזרות (callbacks) של onLeave כדי למנוע דליפות זיכרון ומצבי חפיפה בממשק המשתמש.

  • לא מומלץ להשתמש בממשקי API מיושנים של פריסות: אל תקראו לממשקי API מבוססי-משבצת שהוצאו משימוש כמו setCustomControl() או setHeaderEnabled() בזמן שפונקציית delegate בהתאמה אישית פעילה. יכול להיות ש-SDK יתעלם מהקריאות האלה מדור קודם אם מצורף נציג בהתאמה אישית.

מצבי ממשק משתמש ורכיבי Google

כשמתרחש מעבר בין מצבי ניווט, ה-SDK מעביר אובייקט UiState לקריאה בלבד אל פונקציית ה-callback של הנציג. האובייקט הזה כולל את דגלי ההגדרה של הפריסה הנוכחית עם רכיבי Google (למשל, מופעי View) שנדרשים לעיבוד המסך.

נציג הפריסה מנהל את היררכיית התצוגה בארבעה מצבי הפעלה. בתרשים המצב הבא אפשר לראות איך ה-SDK עובר בין מצבי ניווט ואילו שיטות של קריאה חוזרת הוא מפעיל בנציג:

תרשים של מעבר בין מצבי מחזור החיים של NavigationLayoutDelegate, שבו מוצגים המעברים בין המצבים Non-Navigation, ‏ Navigation Ready ו-Active Guidance.

הרכיבים הספציפיים של Google שמועברים לנציג תלויים בשלב הנוכחי של מחזור החיים של הניווט.

רכיבים נדרשים (כל מצבי הניווט)

בכל שלב של הניווט, אתם צריכים לכלול את רכיבי Google הבאים בהיררכיית התצוגה, למקם אותם ולשמור על הנראות שלהם:

  • לוגו Google (getGoogleLogo()): הרכיב הזה מציג את הלוגו של מפות Google, שחובה להשתמש בו. אם הלחצן Re-center (מרכוז מחדש) מופעל, הלוגו הופך אוטומטית ללחצן הזה כשהנהג מרחיק את התצוגה במפה מהרכב. כתוצאה מכך, המיקום של הלוגו קובע איפה יופיע הלחצן למרכוז מחדש. מומלץ למקם את הלוגו בפינה הימנית התחתונה של הפריסה כדי להתאים לציפיות של ממשק המשתמש הרגיל של המפה.

  • אזור התצוגה (getViewport()): View בלתי נראה שמגדיר את גבולות הפריים של המצלמה. המיקום של אזור התצוגה קובע בדיוק איפה ה-SDK ממקם את שברון הרכב במרכז ואיפה הוא מצייר את קו המסלול הפעיל. ממקמים את אזור התצוגה כך שיכסה את האזור הפתוח והלא מוסתר במסך, ומוודאים שהוא לא חופף לשכבות שקופות כמו גיליונות מותאמים אישית בחלק התחתון.

במהלך המצב 'מוכן לניווט', ממשק המשתמש נשאר מינימלי כדי שהמיקוד יהיה במפת הבסיס. אובייקט NavigationReadyUiState מספק גישה ל:

  • getNavigationReadyButtons(): רשימה של תצוגות של כפתורי פעולה צפים שהוגדרו למפת הבסיס (בדרך כלל רק לחצן המצפן). יכול להיות ש-Google תוסיף או תשנה את הסדר של לחצנים בגרסאות עתידיות של ה-SDK, לכן מומלץ לא להסתמך על אינדקסים קבועים של רשימות. במקום זאת, צריך לעבור על הרשימה, לקרוא את הסוג של כל לחצן על ידי השוואה בין getType() לבין ButtonKnownType.COMPASS, ולחלץ את התצוגה הפיזית על ידי קריאה ל-getView(). שימו לב: הכפתורים המדויקים ברשימה הזו שונים מאלה שזמינים במהלך הדרכה פעילה.

רכיבי הדרכה פעילים

כשמתחיל מסלול מפורט, ערכת ה-SDK פותחת את כל חבילת אמצעי הבקרה של הניווט. אובייקט ActiveGuidanceUiState מספק גישה לרכיבים הבאים של Google:

  • getTurnCard(): באנר הכותרת הראשי שמציג את ההוראות לביצוע התמרון הקרוב, מדידות המרחק והנחיות לגבי הנתיב. כדאי למקם אותו בחלק העליון של הפריסה כדי ליצור היררכיית ניווט מוכרת ולמקם את התצוגות המותאמות אישית סביבו.

  • getEtaCard(): באנר בכותרת התחתונה שבו מוצגים זמן ההגעה המשוער, הזמן שנותר עד לסיום הנסיעה והמרחק שנותר עד ליעד. אפשר למקם אותו לאורך הקצה התחתון של המסך, או לשלב את הקואורדינטות שלו בגיליונות מותאמים אישית לניהול משימות.

  • getTripProgressBar(): סרגל התקדמות אנכי שמראה לנהג כמה הוא התקדם במסלול הנוכחי. בשונה מפריסות מדור קודם שבהן המיקום הזה היה קבוע בקצה המפה, יש לכם חופש מוחלט להצמיד אותו לכל מקום – למשל, לאורך קצה צדדי של מאגר התגים המותאם אישית.

  • getSpeedWidget(): אמצעי בקרה צף למד המהירות ולמגבלות המהירות שמוצגות. בהתאם להגדרות ה-API ולזמינות הנתונים, התצוגה הזו משנה את הגודל שלה באופן דינמי בין ארבעה מצבים חזותיים בזמן הריצה (לא מוצג כלום, מוצגת רק המהירות הנוכחית, מוצג רק מגבלת המהירות או שמוצגים שני הערכים). הווידג'ט יכול לשנות את הגודל שלו בלי הודעה מוקדמת, ולכן תמיד צריך להגדיר אילוצים לתצוגות שמסביב כדי שהפריסה תותאם אוטומטית ותמנע חפיפות במרחב.

  • getActiveGuidanceButtons(): רשימה מורחבת של תצוגות של כפתורי פעולה צפים למצב ההנחיה הפעיל (בדרך כלל כוללת גם את המצפן וגם את הכפתור 'דיווח על אירוע'). בדומה למצב מוכן לניווט, אפשר לאתר ולחלץ תצוגות של לחצנים ספציפיים על ידי סינון הרשימה לפי ButtonKnownType (COMPASS או REPORTING) וחילוץ התצוגה באמצעות getView(). אחר כך אפשר למקם אותם באופן עצמאי או להשתמש בפריסה כמו AutoHidingLinearLayout כדי לסדר את המערך בבטחה בלי התנגשויות מרחביות.

רכיבים של הנחיות דינמיות

הנחיות, כמו התראות על אירועים או אזהרות ממצלמות בטיחות, מופעלות באופן עצמאי במהלך ההנחיה הפעילה.

כשמוכנה הנחיה להצגה, ה-SDK קורא ל-callback של onShowPrompt() במחלקה של הנציג ומעביר תצוגה של newPrompt. הנציג אחראי למיקום חלק של ההנחיה הזו מעל הפריסה (בדרך כלל היא מעוגנת לקצה התחתון של מאגר המפות).

ההודעה הנכנסת מכסה את החלק התחתון של המסך, ולכן צריך לעדכן את הפריסה כדי שההודעה לא תחפוף לחלון התצוגה, ללוגו של Google או ללחצנים שמוצבים בתחתית המסך.

טיפול בגודל המסך ובמצב מסך רחב

כדי לטפל בשינויים בממדי המפה ובכיוון המסך, נציג הפריסה משתמש בתכונות הבאות:

  • שינוי גודל התצוגה: הפריסה משתנה בכל פעם שהגודל הפיזי של המפה משתנה.

  • מצב רחב: המפה תשתנה לגרסה רחבה של הפריסה כשהיא תהיה רחבה מספיק.

תגובה לשינוי הגודל של התצוגה

בכל פעם שהמידות הפיזיות של מאגר המפות משתנות, ה-SDK מפעיל את הקריאה החוזרת onSizeChanged(). פריסות של מסך מפוצל, סרגלי פריסה וסיבוב המכשיר מפעילים בדרך כלל את הקריאה החוזרת onSizeChanged(). אפשר להשתמש בפונקציית הקריאה החוזרת הזו כדי לבצע התאמות רספונסיביות כלליות לממשק המשתמש המותאם אישית. אפשר להטמיע את התג onSizeChanged() כדי לשנות את המיקום של רכיבים מותאמים אישית בהתאם ליחס גובה-רוחב חדש של המסך, להחיל נקודות עצירה מותאמות אישית משלכם לרוחב או לגובה, ולזהות מתי אירוע של שינוי גודל מעביר את המצב הרחב כדי להחליף בבטחה בין וריאציות של פריסות.

מידע על מצב רחב

מצב רחב מופעל כשמאגר המפות רחב מספיק כדי להציג רכיבי ממשק משתמש זה לצד זה.

קוראים את isWideMode() הערך הבוליאני מאובייקט המצב כדי לשנות את המיקום של רכיבי ממשק המשתמש המותאמים אישית ולשמור על מרכז המפה פנוי לנהג. רכיבים ש-Google מספקת, כמו כרטיס התור וכרטיסיית פרטי זמן הגעה משוער, מתכווצים ומשנים את הצורה שלהם באופן אוטומטי במצב רחב. קריאת הערך הבוליאני הזה מבטיחה שהפריסה תותאם בדיוק ברגע שרכיבי Google מתעדכנים.

הנה כמה דוגמאות למיקום רכיבי הפריסה במצבים רגיל ורחב:

  • מצב פורטרט רגיל: כרטיס התור מוצג בחלק העליון של המסך וכרטיס זמן ההגעה המשוער מוצג בחלק התחתון.

  • מצב רחב: מעבירים את כרטיס הפנייה לצד ההתחלה של המסך ואת כרטיס ההגעה המשוערת לצד הסיום.

בדיקת המצב הרחב במהלך מעברים בין מצבים

אם הפריסה תומכת בגרסה של מצב רחב, צריך להעריך את isWideMode() הערך הבוליאני בתוך כל קריאה חוזרת (callback) של מעבר מצב, במקום להסתמך רק על onSizeChanged().

כדי לטפל בהפעלות ראשוניות של האפליקציה במצב לרוחב, צריך לבדוק את אובייקט המצב במהלך קריאות חוזרות (callback) כמו onEnterNavigationReady() ו-onEnterActiveGuidance(). הגישה הזו מגנה על הפריסה מפני אירועים במחזור החיים של מערכת Android – כמו יצירה מחדש של פעילות ברירת המחדל – שעוקפים לחלוטין את הקריאה החוזרת לשינוי הגודל, וכך מבטיחה שהפריסה הנכונה, רגילה או רחבה, תופעל ברגע שמתחיל מצב ניווט חדש.

התאמה לסגנון של Google

כדי לעזור לכם להתאים את הממשק המותאם אישית שלכם לקצב החזותי של Google, ה-SDK מספק את StyleValues מחלקת כלי העזר. אפשר לקרוא את הערכים האלה בפיקסלים שלא תלויים בדחיסות (dp) כדי להתאים את התצוגות בצורה מושלמת לרכיבי Google.

לדוגמה, אם רוצים להציב לחצן מותאם אישית בפינה העליונה של המסך, מול כרטיס התור, אפשר לקרוא ל-StyleValues.headerTopPaddingDp() ולהקצות את הערך שמוחזר בתור השוליים העליונים של הלחצן. כך מוודאים שהלחצן המותאם אישית יתיישר חזותית עם הקצה העליון של כרטיס הפנייה, כדי לשמור על סימטריה במסך.

הנה כמה מהערכים הזמינים של ריווח פנימי ומדדים:

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

דוגמה: הטמעה של פריסה מבוססת אילוצים

בדוגמה הבאה מוצג נציג פריסה בסיסי שמנהל מעברים בין מצבים באמצעות הגדרות תוכנתיות של ConstraintLayout ו-ConstraintSet.

בדוגמה הזו אילוצי התצוגה נוצרים בקוד, אבל אפשר גם להשתמש בנציגי פריסה כדי להרחיב פריסות XML רגילות של Android.

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