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

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

التعرّف على المشاهد

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

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

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

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

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

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

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

إنّ جوهر SceneStrategy هو طريقة calculateScene:

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

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

يكون SceneStrategyScope مسؤولاً عن الاحتفاظ بأي وسيطات اختيارية قد يحتاجها SceneStrategy، مثل معاودة الاتصال onBack.

توفّر SceneStrategy أيضًا دالة then infix ملائمة، ما يتيح لك ربط استراتيجيات متعددة معًا. يؤدي ذلك إلى إنشاء مسار مرن لاتّخاذ القرارات، حيث يمكن لكل استراتيجية محاولة حساب 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: Any,
    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() }
}

/**
 * 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().contentKey,
            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()
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.Content()
            }
        }
    }

    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.contentKey, secondEntry.contentKey)

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

عرض محتوى قائمة وتفاصيل في Material Adaptive Scene

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

لإنشاء SceneMaterial list-detailScene، اتّبِع الخطوات التالية:

  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