تخصيص تصميم شريط التنقّل

صورة متحرّكة تعرض مثالاً على تخطيط مخصّص للتنقّل على Android

يمكنك استخدام ميزة تخصيص التنسيق للتحكّم في التسلسل الهرمي للعرض وموضع الشاشة لكلّ من طرق العرض التي توفّرها Google وطرق العرض المخصّصة لمؤسستك.

بدلاً من إضافة طرق عرض مخصّصة إلى خانات رأس الصفحة أو تذييلها المحدّدة مسبقًا، يمكنك تنفيذ مفوّض تنسيق لإدارة التنسيق للشاشة بأكملها. أثناء عمليات الانتقال بين حالات التنقّل، يتلقّى مفوّض التنسيق المكوّنات التي توفّرها Google، مثل بطاقة المنعطف وبطاقة الوقت المقدّر للوصول والأزرار. يمكنك وضع هذه العناصر بجانب المحتوى المخصّص باستخدام أنظمة التنسيق العادية على Android، مثل ConstraintLayout أو CoordinatorLayout أو LinearLayout.

استخدِم هذا الإطار لوضع معلومات المؤسسة المخصّصة، مثل حالة الطلب أو تعليمات الاستلام، بدقة على الشاشة مع منع تداخل طرق العرض.

طريقة عمل ميزة تخصيص التنسيق

تستخدِم ميزة تخصيص التنسيق نمط تصميم التفويض. بدلاً من رسم مكوّنات واجهة المستخدم أو وضعها تلقائيًا على الشاشة، تنقل حزمة تطوير البرامج (SDK) هذه المكوّنات مباشرةً إلى فئة مخصّصة تكتبها أنت: مفوّض التنسيق.

لتخصيص التنسيق، أنشِئ فئة توسِّع الفئة المجرّدة NavigationLayoutDelegate وعيِّن نموذجًا لـ NavigationView أو SupportNavigationFragment. عندما تنتقل حالة التنقّل، مثلاً من الخريطة الأساسية إلى إرشادات اتّجاهات مفصّلة نشطة، تنفِّذ حزمة تطوير البرامج (SDK) طرق معاودة الاتصال على المفوّض وتوفّر مكوّنات واجهة المستخدم المتاحة لتلك الحالة المحدّدة.

لتوفير تجربة مستخدم سلسة ومتكاملة، يحدّد هذا الإطار فصلاً واضحًا للمسؤولية بين تطبيقك وحزمة تطوير البرامج (SDK). استخدِم مفوّض التنسيق لتخصيص ما يلي:

  • إنشاء التسلسل الهرمي للعرض: اختَر بالضبط مكوّنات Google وطرق العرض المخصّصة للمؤسسة التي تريد إضافتها إلى الشاشة لكل حالة من حالات واجهة المستخدم.
  • وضع كل عنصر: اضبط نقاط الارتكاز الدقيقة على الشاشة والمسافات الهامشية وموضع التنسيق. تجنَّب تطبيق قيود مخصّصة على العرض أو الارتفاع على مكوّنات Google لأنّها تحسب أبعادها الداخلية.
  • تأطير الخريطة الأساسية: حدِّد الحدود المرئية لكاميرا الخريطة باستخدام إحداثيات مكوّن إطار العرض.
  • ترتيب الشاشة: حدِّد ما إذا كانت طرق العرض المخصّصة تظهر فوق عناصر التحكّم المضمّنة من Google أو تحتها أو بجانبها.

في الوقت نفسه، لا يمكنك تخصيص مكوّنات Google التالية باستخدام مفوّض التنسيق:

  • أبعاد المكوّن: حجم المكوّنات التي توفّرها Google وأبعادها الداخلية، والتي تحسبها حزمة تطوير البرامج (SDK) تلقائيًا.
  • شروط التفعيل: عندما تظهر التنبيهات أو الطلبات الديناميكية استنادًا إلى بيانات المسار في الوقت الفعلي.

مبادئ التنفيذ

عند كتابة مفوّض تنسيق، ضَع القواعد التالية في الاعتبار لمنع حدوث أخطاء في التنسيق أو أعطال أثناء التشغيل:

  • الإعداد التلقائي هو شاشة فارغة: لا تظهر المكوّنات التي توفّرها Google إلا إذا أضافها المفوّض بشكلٍ صريح إلى التسلسل الهرمي للعرض ووضعها.
  • واجهات برمجة التطبيقات القديمة غير متوافقة: لن يتم دعم الخصائص والطرق التي تتحكّم في نموذج التنسيق القديم المستند إلى الخانات، وقد لا تعمل على النحو المتوقّع عندما يكون مفوّض مخصّص نشطًا.
  • لا تعدِّل هياكل العرض الداخلية: لا تستخدِم طرقًا مثل findViewById() لاجتياز التسلسلات الهرمية للعرض أو تعديلها في المكوّنات التي توفّرها Google (مثل بطاقة المنعطف أو بطاقة الوقت المقدّر للوصول). بما أنّ هذه التسلسلات الهرمية للعرض الداخلية هي تفاصيل التنفيذ الأساسية، يمكن أن تتغيّر في إصدارات حزمة تطوير البرامج (SDK). ويمكن أن يؤدي تعديلها إلى تعطُّل التنسيق أثناء تحديثات حزمة تطوير البرامج (SDK) المستقبلية.

التوافق مع واجهة برمجة التطبيقات القديمة

للحصول على سلوك تنسيق موثوق به عند استخدام مفوّض تنسيق مخصّص، تجنَّب استخدام واجهات برمجة التطبيقات القديمة المستندة إلى الخانات التالية، والتي من المقرر إيقافها نهائيًا. لنقل تطبيق حالي، استبدِل استخدامك لواجهات برمجة التطبيقات هذه بالرمز في مفوّض التنسيق المخصّص:

عرض واجهات برمجة التطبيقات القديمة المستندة إلى الخانات

واجهة برمجة التطبيقات القديمة بديل مفوّض التنسيق
setCustomControl(View, CustomControlPosition) أضِف طرق العرض مباشرةً إلى ConstraintLayout أو مجموعة طرق العرض الأخرى.
removeCustomControl(View) أزِل طرق العرض مباشرةً من هيكلية طرق العرض.
setEtaCardEnabled(boolean) اقرأ طريقة عرض etaCard في onEnterActiveGuidance.
setHeaderEnabled(boolean) اقرأ طريقة عرض turnCard في onEnterActiveGuidance.
setReportIncidentButtonEnabled(boolean) حدِّد مكان الزر REPORTING في getActiveGuidanceButtons().
setTripProgressBarEnabled(boolean) اقرأ طريقة عرض tripProgressBar في onEnterActiveGuidance.
addOnNavigationUiChangedListener(...) اعتمد على عمليات معاودة الاتصال للانتقال بين حالات NavigationLayoutDelegate.
removeOnNavigationUiChangedListener(...) أدِر عمليات الانتقال بين حالات التنسيق مباشرةً باستخدام المفوّض.
addPromptVisibilityChangedListener(...) اعتمد على عمليات معاودة الاتصال للطلبات في NavigationLayoutDelegate (مثل onShowPrompt()).
removePromptVisibilityChangedListener(...) أدِر منطق عرض الطلبات مباشرةً باستخدام المفوّض.
setCompassEnabled(boolean) حدِّد مكان البوصلة في getNavigationReadyButtons() أو getActiveGuidanceButtons().

قائمة التحقّق من المتطلبات الأساسية

اتّبِع هذه الخطوات والمتطلبات الأساسية لتنفيذ مفوّض التنسيق بنجاح:

  • تهيئة المفوّض قبل إنشاء واجهة المستخدم: استخدِم 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)
            

    جافا

    // 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() داخل عمليات معاودة الاتصال onEnterNavigationReady() و onEnterActiveGuidance() لإرفاق الحاوية بالتسلسل الهرمي لعرض الخريطة.

  • تنظيف البيانات عند الخروج من الحالة: استخدِم navigationView.removeNavigationLayout() وأزِل طرق العرض المخصّصة داخل عمليات معاودة الاتصال onLeave لمنع تسرّب الذاكرة وتداخل حالات واجهة المستخدم.

  • تجنُّب واجهات برمجة التطبيقات القديمة للتنسيق: لا تستخدِم واجهات برمجة التطبيقات القديمة المستندة إلى الخانات، مثل setCustomControl() أو setHeaderEnabled()، عندما يكون مفوّض مخصّص نشطًا. قد تتجاهل حزمة تطوير البرامج (SDK) هذه الاستدعاءات القديمة عند إرفاق مفوّض مخصّص.

حالات واجهة المستخدم ومكوّنات Google

عند حدوث انتقال بين حالات التنقّل، تنقل حزمة تطوير البرامج (SDK) عنصر UiState للقراءة فقط إلى عملية معاودة الاتصال للمفوّض. يجمع هذا العنصر علامات إعدادات التنسيق الحالية مع مكوّنات Google (مثل نماذج View) التي تحتاج إليها لعرض الشاشة.

يدير مفوّض التصميم هيكلية طرق العرض في أربع حالات تشغيلية. يوضّح مخطط الحالة التالي كيفية انتقال حزمة تطوير البرامج (SDK) بين حالات التنقّل وطرق معاودة الاتصال التي تنفّذها على المفوّض:

مخطّط انتقال حالة دورة حياة NavigationLayoutDelegate الذي يعرض عمليات الانتقال بين حالات "غير التنقّل" و"التنقّل جاهز" و"الإرشاد النشط"

تعتمد مكوّنات Google المحدّدة التي يتم توفيرها للمفوّض على المرحلة الحالية من دورة حياة التنقّل.

المكوّنات المطلوبة (جميع حالات التنقّل)

بغض النظر عن مرحلة التنقّل الحالية، يجب تضمين مكوّنات Google التالية ووضعها وإبقاؤها مرئية في هيكلية طرق العرض:

  • شعار Google (getGoogleLogo()): يعرض هذا المكوّن شعار "خرائط Google" الإلزامي. إذا كان الزر إعادة التوسيط مفعّلاً، يتحوّل الشعار تلقائيًا إلى هذا الزر عندما يمرّر السائق الخريطة بعيدًا عن سيارته. نتيجةً لذلك، يحدّد موضع الشعار مكان ظهور الزر إعادة التوسيط. ننصحك بوضع الشعار في أسفل يمين التنسيق لتتطابق مع التوقعات العادية لواجهة مستخدم الخريطة.

  • إطار العرض (getViewport()): هو View غير مرئي يحدّد حدود تأطير الكاميرا. يحدّد موضع إطار العرض المكان الذي توسِّط فيه حزمة تطوير البرامج (SDK) علامة السيارة وتُرسم خط المسار النشط. ضَع إطار العرض بحيث يغطّي المنطقة المفتوحة غير المحجوبة من الشاشة، مع تجنُّب التراكبات غير الشفافة بأمان، مثل الأوراق السفلية المخصّصة.

أثناء حالة "التنقّل جاهز"، تظل واجهة المستخدم في الحد الأدنى للحفاظ على التركيز على الخريطة الأساسية. يتيح لك عنصر NavigationReadyUiState الوصول إلى ما يلي:

  • getNavigationReadyButtons(): قائمة بطرق عرض الإجراءات الرئيسية التي تم ضبطها للخريطة الأساسية (عادةً ما تكون زر البوصلة فقط). بما أنّ Google قد تضيف أزرارًا أو تعيد ترتيبها في إصدارات حزمة تطوير البرامج (SDK) المستقبلية، تجنَّب الاعتماد على فهارس القوائم الثابتة. بدلاً من ذلك، انتقِل خلال القائمة واقرأ نوع كل زر من خلال مقارنة getType() بـ ButtonKnownType.COMPASS، واستخرِج طريقة العرض الفعلية باستخدام getView(). تجدر الإشارة إلى أنّ الأزرار المحدّدة في هذه القائمة تختلف عن الأزرار المتاحة أثناء الإرشادات النشطة.

مكوّنات الإرشادات النشطة

عند بدء الإرشادات المفصّلة، تتيح حزمة تطوير البرامج (SDK) المجموعة الكاملة من عناصر التحكّم في التنقّل. يتيح لك عنصر ActiveGuidanceUiState الوصول إلى مكوّنات Google التالية:

  • getTurnCard(): بانر الرأس الأساسي الذي يعرض اتجاهات المناورة القادمة وقياسات المسافة وتوجيه خط السير. ضَع هذا البانر في أعلى التنسيق لإنشاء تسلسل هرمي مألوف للتنقّل وارتكاز طرق العرض المخصّصة حوله.

  • getEtaCard(): بانر التذييل الذي يعرض الوقت المقدّر للوصول والوقت المتبقي للرحلة والمسافة المتبقية إلى الوجهة. ضَع هذا البانر على طول الحافة السفلية للشاشة، أو ادمِج إحداثياته مع أوراق إدارة المهام المخصّصة.

  • getTripProgressBar(): شريط تقدّم عمودي يعرض للسائق المسافة التي قطعها على طول المسار الحالي. على عكس التنسيقات القديمة التي كانت تثبِّت هذا الشريط على حافة الخريطة، يمكنك تثبيته في أي مكان، مثلاً على طول حافة جانبية من الحاوية المخصّصة.

  • getSpeedWidget(): عنصر تحكّم عائم لمقياس السرعة والحدود القصوى للسرعة المحدّدة. استنادًا إلى إعدادات واجهة برمجة التطبيقات وتوفّر البيانات، يتم تغيير حجم طريقة العرض هذه بشكلٍ ديناميكي بين أربع حالات مرئية أثناء التشغيل (لا يتم عرض أي شيء أو السرعة الحالية فقط أو الحد الأقصى للسرعة فقط أو كلتا القراءتَين). بما أنّ الأداة يمكن أن تنتقل بين هذه الأحجام بدون إشعار، ثبِّت دائمًا طرق العرض المحيطة باستخدام القيود لكي يتكيّف التنسيق تلقائيًا لمنع التداخلات المكانية.

  • getActiveGuidanceButtons(): قائمة موسّعة بطرق عرض الإجراءات الرئيسية لحالة الإرشادات النشطة (عادةً ما تتضمّن كلاً من زر البوصلة وزر الإبلاغ عن حادث). كما هو الحال في حالة "التنقّل جاهز"، يمكنك تحديد مكان طرق عرض الأزرار الفردية واستخراجها من خلال فلترة القائمة حسب ButtonKnownType (COMPASS أو REPORTING) واستخراج طريقة العرض باستخدام getView(). يمكنك بعد ذلك وضعها بشكلٍ مستقل أو استخدام تنسيق مثل AutoHidingLinearLayout لتجميع الصفيف بأمان بدون حدوث تعارضات مكانية.

مكوّنات الطلبات الديناميكية

تظهر الطلبات، مثل تنبيهات الحوادث أو تحذيرات كاميرات السلامة، بشكلٍ مستقل أثناء الإرشادات النشطة.

عندما يكون الطلب جاهزًا للعرض، تستدعي حزمة تطوير البرامج (SDK) عملية معاودة الاتصال onShowPrompt() للمفوّض وتمرِّر طريقة عرض newPrompt. يكون المفوّض مسؤولاً عن وضع هذا الطلب بسلاسة فوق التنسيق (عادةً ما يكون مرتبطًا بالحافة السفلية لحاوية الخريطة).

بما أنّ الطلب الوارد يغطّي الجزء السفلي من الشاشة، يجب تعديل التنسيق لمنع تداخل الطلب مع إطار العرض أو شعار Google أو أي أزرار محاذية للأسفل.

التعامل مع حجم الشاشة والوضع العريض

للتعامل مع التغييرات في أبعاد الخريطة واتجاه الشاشة، يستخدِم مفوّض التنسيق الميزات التالية:

  • تغيير حجم طريقة العرض: يتم تعديل التنسيق كلما تغيّر الحجم الفعلي للخريطة.

  • الوضع العريض: يتم التبديل إلى تنسيق عريض عندما تكون الخريطة عريضة بما يكفي.

الاستجابة لتغيير حجم طريقة العرض

كلما تغيّرت الأبعاد الفعلية لحاوية الخريطة، تنفِّذ حزمة تطوير البرامج (SDK) عملية معاودة الاتصال onSizeChanged(). تؤدي عادةً تنسيقات الشاشة المقسّمة ومنزلقات التنسيق وتدوير الجهاز إلى تفعيل عملية معاودة الاتصال onSizeChanged(). استخدِم عملية معاودة الاتصال هذه لإجراء تعديلات عامة سريعة الاستجابة على واجهة المستخدم المخصّصة. استخدِم onSizeChanged() لإعادة وضع العناصر المخصّصة لنسبة عرض إلى ارتفاع جديدة للشاشة، وتطبيق نقاط التوقف المخصّصة للعرض أو الارتفاع، واكتشاف متى يؤدي حدث تغيير الحجم إلى تفعيل الوضع العريض للتبديل بأمان بين تنسيقات مختلفة.

فهم الوضع العريض

يتم تفعيل الوضع العريض عندما تكون حاوية الخريطة عريضة بما يكفي لعرض مكوّنات واجهة المستخدم جنبًا إلى جنب.

اقرأ القيمة المنطقية isWideMode() من عنصر الحالة لإعادة وضع عناصر واجهة المستخدم المخصّصة وإبقاء مركز الخريطة واضحًا للسائق. بما أنّ المكوّنات التي توفّرها Google، مثل بطاقة المنعطف وبطاقة الوقت المقدّر للوصول، تتقلّص تلقائيًا وتتغيّر شكلها في الوضع العريض، يضمن قراءة هذه القيمة المنطقية تكيُّف التنسيق في اللحظة نفسها التي يتم فيها تعديل مكوّنات Google.

إليك بعض الأمثلة على وضع مكوّنات التنسيق في الوضعَين العادي والعريض:

  • الوضع العادي العمودي: ضَع بطاقة المنعطف في أعلى الشاشة وبطاقة الوقت المقدّر للوصول في أسفلها.

  • الوضع العريض: انقل بطاقة المنعطف إلى الجانب الأيمن من الشاشة وبطاقة الوقت المقدّر للوصول إلى الجانب الأيسر.

التحقّق من الوضع العريض أثناء عمليات الانتقال بين الحالات

إذا كان التنسيق يتيح استخدام وضع عريض، قيِّم القيمة المنطقية isWideMode() داخل كل عملية معاودة اتصال للانتقال بين الحالات، بدلاً من الاعتماد على onSizeChanged() فقط.

تحقَّق من عنصر الحالة أثناء عمليات معاودة الاتصال، مثل onEnterNavigationReady() وonEnterActiveGuidance()، للتعامل مع عمليات تشغيل التطبيق الأولية في الوضع الأفقي. يحمي هذا النهج التنسيق من أحداث دورة حياة نظام Android، مثل إعادة إنشاء النشاط التلقائية، التي تتجاوز عملية معاودة الاتصال لتغيير الحجم بالكامل، ما يضمن تفعيل التنسيق العادي أو العريض الصحيح في اللحظة التي تبدأ فيها حالة تنقّل جديدة.

المحاذاة مع تصميم Google

لمساعدة واجهة المستخدم المخصّصة على مطابقة الإيقاع المرئي لـ Google، توفّر حزمة تطوير البرامج (SDK) فئة الأداة StyleValues. يمكنك قراءة هذه القيم بوحدات بكسل مستقلة الكثافة لمحاذاة طرق العرض تمامًا مع مكوّنات 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()
  }
}

    

جافا

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