إنشاء تنسيقات مخصّصة باستخدام "المشاهد"

يوفّر نظام التنقّل 3 نظامًا قويًا ومرنًا لإدارة تدفق واجهة المستخدم في تطبيقك من خلال المشاهد. تتيح لك "المشاهد" إنشاء تخطيطات مخصّصة للغاية والتأقلم مع أحجام الشاشات المختلفة وإدارة التجارب المعقدة المتعدّدة الأقسام بسلاسة.

فهم "المَشاهد"

في Navigation 3، Scene هي الوحدة الأساسية التي تعرِض مثيلًا واحدًا أو أكثر من NavEntry. يمكنك اعتبار Scene حالة مرئية أو قسمًا محددًا في واجهة المستخدم يمكن أن يحتوي على عرض المحتوى من ملفاتك في الخلفية ويدير هذا العرض.

يتم تحديد كل مثيل منScene بشكل فريد من خلالkey وفئةScene نفسها. هذا المعرّف الفريد مهمّ جدًا لأنّه يشغّل الرسوم المتحركة على المستوى الأعلى عند تغيُّر Scene.

تتضمّن واجهة Scene السمات التالية:

  • key: Any: معرّف فريد لهذه النسخة المحدّدة من Scene. يضمن هذا المفتاح، إلى جانب فئة Scene، التميز، وذلك لأغراض الرسوم المتحركة في المقام الأول.
  • entries: List<NavEntry<T>>: هذه قائمة بعناصر NavEntry التي يكون Scene مسؤولاً عن عرضها. من المهمّ الإشارة إلى أنّه إذا تم عرض NavEntry نفسه في عدّة Scenes أثناء انتقال (مثلاً، في انتقال Scene عنصر مشترَك)، لن يتم عرض محتواه إلا من خلال Scene المستهدَف الأخير الذي يعرضه.
  • previousEntries: List<NavEntry<T>>: تحدِّد هذه السمة NavEntry التي ستظهر في حال تنفيذ إجراء "رجوع" من Scene الحالي. من الضروري احتساب الحالة التوقّعية المناسبة للرجوع، مما يسمح لـ NavDisplay بالتوقّع والانتقال إلى الحالة السابقة الصحيحة، والتي قد تكون مشهدًا يتضمّن فئة و/أو مفتاحًا مختلفَين.
  • content: @Composable () -> Unit: هذه هي الدالة القابلة للتجميع التي يتم فيها تحديد كيفية عرض Scene لعنصر entries وأي عناصر واجهة مستخدم مجاورة خاصة بهذا Scene.

فهم استراتيجيات المشهد

SceneStrategy هي الآلية التي تحدّد كيفية ترتيب قائمة معيّنة من NavEntry من الحزمة الخلفية ونقلها إلى Scene. بشكل أساسي، عند عرض إدخالات الحزمة الخلفية الحالية، يطرح SceneStrategy سؤالَين رئيسيَّين على نفسه:

  1. هل يمكنني إنشاء Scene من هذه الإدخالات؟ إذا اتّضح لـ SceneStrategy أنّه يمكنه التعامل مع NavEntry المحدّدة وإنشاء Scene ذي معنى (مثل مربّع حوار أو تنسيق متعدد الأقسام)، يتابع المعالجة. بخلاف ذلك، يتم عرض null، ما يمنح الاستراتيجيات الأخرى فرصة لإنشاء Scene.
  2. إذا كان الأمر كذلك، كيف يمكنني ترتيب هذه الإدخالات في Scene?؟ بعد أن يقرّ SceneStrategy بمعالجة الإدخالات، يتحمّل مسؤولية إنشاء Scene وتحديد كيفية عرض NavEntrys المحدّدة ضمن هذا Scene.

يتمثل جوهر SceneStrategy في طريقة calculateScene:

@Composable
public fun calculateScene(
    entries: List<NavEntry<T>>,
    onBack: (count: Int) -> Unit,
): Scene<T>?

تأخذ هذه الطريقة List<NavEntry<T>> الحالية من الحزمة الخلفية وonBack للرجوع إليه. من المفترض أن يعرض الرمز Scene<T> إذا كان بإمكانه إنشاء Scene<T> بنجاح من الإدخالات المقدَّمة، أو null إذا لم يكن بإمكانه ذلك.

توفّر SceneStrategy أيضًا دالة then داخلية ملائمة، ما يتيح لك ربط استراتيجيات متعددة معًا. يؤدّي ذلك إلى إنشاء مسار flexible لاتخاذ القرار يمكن فيه لكل استراتيجية محاولة احتساب Scene، وإذا لم تتمكّن من ذلك، يتم تفويضها إلى الاستراتيجية التالية في السلسلة.

كيفية عمل "المشاهد" واستراتيجيات المشاهد معًا

NavDisplay هو العنصر القابل للتجميع المركزي الذي يراقب الحزمة الخلفية وي يستخدِم SceneStrategy لتحديد Scene المناسب وعرضه.

تتوقّع المَعلمة NavDisplay's sceneStrategy مَعلمة SceneStrategy التي تُمثّل المَعلمة المسؤولة عن احتساب Scene المراد عرضها. إذا لم يتم احتساب أيّ Scene بواسطة الاستراتيجية المقدّمة (أو سلسلة الاستراتيجيات)، يعودNavDisplay تلقائيًا إلى استخدام SinglePaneSceneStrategy تلقائيًا.

في ما يلي تفاصيل التفاعل:

  • عند إضافة مفاتيح أو إزالتها من الحزمة الخلفية (على سبيل المثال، باستخدام backStack.add() أو backStack.removeLastOrNull())، يلاحظ NavDisplay هذه التغييرات.
  • تُرسِل دالة NavDisplay القائمة الحالية من NavEntrys (المشتقة من مفاتيح السلسلة الخلفية) إلى طريقة SceneStrategy's calculateScene التي تم ضبطها.
  • إذا أرجعت SceneStrategy Scene بنجاح، NavDisplay ثم يعرض content هذا Scene. يدير NavDisplay أيضًا الرسوم المتحرّكة والرجوع التوقّعي استنادًا إلى سمات Scene.

مثال: تنسيق لوحة واحدة (السلوك التلقائي)

أبسط تنسيق مخصّص يمكنك الحصول عليه هو عرض لوحة واحدة، وهو السلوك التلقائي إذا لم تكن هناك SceneStrategy أخرى لها الأولوية.

data class SinglePaneScene<T : Any>(
    override val key: T,
    val entry: NavEntry<T>,
    override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(entry)
    override val content: @Composable () -> Unit = { entry.content.invoke(entry.key) }
}

/**
 * A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
 * list.
 */
public class SinglePaneSceneStrategy<T : Any> : SceneStrategy<T> {
    @Composable
    override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T> =
        SinglePaneScene(
            key = entries.last().key,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

مثال على تنسيق أساسي ثنائي اللوحة (مشهد واستراتيجية مخصّصان)

يوضّح هذا المثال كيفية إنشاء تنسيق بسيط من لوحتَين يتم تفعيله استنادًا إلى شرطَين:

  1. عرض النافذة كافٍ لعرض لوحتَين (أي WIDTH_DP_MEDIUM_LOWER_BOUND على الأقل).
  2. يوضّح العنصران العلويان في الحزمة الخلفية بشكل صريح توافقهما على العرض في تنسيق ذي مربّعَين باستخدام بيانات وصفية محدّدة.

المقتطف التالي هو رمز المصدر المجمّع لـ TwoPaneScene.kt و TwoPaneSceneStrategy.kt:

// --- TwoPaneScene ---
/**
 * A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split.
 */
class TwoPaneScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val firstEntry: NavEntry<T>,
    val secondEntry: NavEntry<T>
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.5f)) {
                firstEntry.content.invoke(firstEntry.key)
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.content.invoke(secondEntry.key)
            }
        }
    }

    companion object {
        internal const val TWO_PANE_KEY = "TwoPane"
        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * in a two-pane layout.
         */
        fun twoPane() = mapOf(TWO_PANE_KEY to true)
    }
}

// --- TwoPaneSceneStrategy ---
/**
 * A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough
 * and the top two back stack entries declare support for two-pane display.
 */
class TwoPaneSceneStrategy<T : Any> : SceneStrategy<T> {
    @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3WindowSizeClassApi::class)
    @Composable
    override fun calculateScene(
        entries: List<NavEntry<T>>,
        onBack: (Int) -> Unit
    ): Scene<T>? {

        val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

        // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes.
        // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp).
        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val lastTwoEntries = entries.takeLast(2)

        // Condition 2: Only return a Scene if there are two entries, and both have declared
        // they can be displayed in a two pane scene.
        return if (lastTwoEntries.size == 2 &&
            lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }
        ) {
            val firstEntry = lastTwoEntries.first()
            val secondEntry = lastTwoEntries.last()

            // The scene key must uniquely represent the state of the scene.
            val sceneKey = Pair(firstEntry.key, secondEntry.key)

            TwoPaneScene(
                key = sceneKey,
                // Where we go back to is a UX decision. In this case, we only remove the top
                // entry from the back stack, despite displaying two entries in this scene.
                // This is because in this app we only ever add one entry to the
                // back stack at a time. It would therefore be confusing to the user to add one
                // when navigating forward, but remove two when navigating back.
                previousEntries = entries.dropLast(1),
                firstEntry = firstEntry,
                secondEntry = secondEntry
            )
        } else {
            null
        }
    }
}

لاستخدام هذه السمة TwoPaneSceneStrategy في NavDisplay، عدِّل طلبات entryProvider لتضمين البيانات الوصفية TwoPaneScene.twoPane() للملف الشخصي التي تريد عرضها في تنسيق ذي لوحتَين. بعد ذلك، أدخِل TwoPaneSceneStrategy() كقيمة sceneStrategy، مع الاعتماد على القيمة التلقائية للرجوع إليها في سيناريوهات اللوحة الواحدة:

// Define your navigation keys
@Serializable
data object ProductList : NavKey
@Serializable
data class ProductDetail(val id: String) : NavKey

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ProductList)

    NavDisplay(
        backStack = backStack,
        entryProvider = entryProvider {
            entry<ProductList>(
                // Mark this entry as eligible for two-pane display
                metadata = TwoPaneScene.twoPane()
            ) { key ->
                Column {
                    Text("Product List")
                    Button(onClick = { backStack.add(ProductDetail("ABC")) }) {
                        Text("View Details for ABC (Two-Pane Eligible)")
                    }
                }
            }

            entry<ProductDetail>(
                // Mark this entry as eligible for two-pane display
                metadata = TwoPaneScene.twoPane()
            ) { key ->
                Text("Product Detail: ${key.id} (Two-Pane Eligible)")
            }
            // ... other entries ...
        },
        // Simply provide your custom strategy. NavDisplay will fall back to SinglePaneSceneStrategy automatically.
        sceneStrategy = TwoPaneSceneStrategy<Any>(),
        onBack = { count ->
            repeat(count) {
                if (backStack.isNotEmpty()) {
                    backStack.removeLastOrNull()
                }
            }
        }
    )
}

عرض محتوى تفاصيل القائمة في "مشهد متوافق مع واجهة المستخدم المادية"

بالنسبة إلى حالة استخدام تفاصيل القائمة، يقدّم العنصر androidx.compose.material3.adaptive:adaptive-navigation3 ListDetailSceneStrategy الذي ينشئ Scene تفاصيل قائمة. يعالج هذا Scene تلقائيًا الترتيبات المعقدة للقوائم المتعدّدة (القائمة والقائمة التفصيلية والقوائم الإضافية) ويُعدّلها استنادًا إلى حجم النافذة وحالة الجهاز.

لإنشاء تفاصيل قائمة المواد Scene، اتّبِع الخطوات التالية:

  1. إضافة التبعية: أدرِج ملف androidx.compose.material3.adaptive:adaptive-navigation3 في ملف build.gradle.kts الخاص بمشروعك.
  2. تحديد الإدخالات باستخدام البيانات الوصفية ListDetailSceneStrategy: استخدِم listPane(), detailPane() وextraPane() لوضع علامة على NavEntrys لأجل عرض اللوحة المناسب. يتيح لك مساعد listPane() أيضًا تحديد detailPlaceholder عندما لا يكون هناك عنصر محدّد.
  3. استخدِم rememberListDetailSceneStrategy(): توفّر هذه الدالة القابلة للتجميع ListDetailSceneStrategy تم ضبطه مسبقًا ويمكن استخدامه من قِبل NavDisplay.

المقتطف التالي هو نموذج Activity يوضّح استخدام ListDetailSceneStrategy:

@Serializable
object ProductList : NavKey

@Serializable
data class ProductDetail(val id: String) : NavKey

@Serializable
data object Profile : NavKey

class MaterialListDetailActivity : ComponentActivity() {

    @OptIn(ExperimentalMaterial3AdaptiveApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Scaffold { paddingValues ->
                val backStack = rememberNavBackStack(ProductList)
                val listDetailStrategy = rememberListDetailSceneStrategy<Any>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
                    sceneStrategy = listDetailStrategy,
                    entryProvider = entryProvider {
                        entry<ProductList>(
                            metadata = ListDetailSceneStrategy.listPane(
                                detailPlaceholder = {
                                    ContentYellow("Choose a product from the list")
                                }
                            )
                        ) {
                            ContentRed("Welcome to Nav3") {
                                Button(onClick = {
                                    backStack.add(ProductDetail("ABC"))
                                }) {
                                    Text("View product")
                                }
                            }
                        }
                        entry<ProductDetail>(
                            metadata = ListDetailSceneStrategy.detailPane()
                        ) { product ->
                            ContentBlue("Product ${product.id} ", Modifier.background(PastelBlue)) {
                                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                                    Button(onClick = {
                                        backStack.add(Profile)
                                    }) {
                                        Text("View profile")
                                    }
                                }
                            }
                        }
                        entry<Profile>(
                            metadata = ListDetailSceneStrategy.extraPane()
                        ) {
                            ContentGreen("Profile")
                        }
                    }
                )
            }
        }
    }
}

الشكل 1. مثال على محتوى يتم تشغيله في بيئة Material قائمة التفاصيل