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

تقدّم مكتبة 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 بتوقّع الحالة السابقة الصحيحة والانتقال إليها، والتي قد تكون `Scene` بفئة أو مفتاح أو كليهما مختلفَين.
  • content: @Composable () -> Unit: هذه هي الدالة القابلة للإنشاء التي تحدّد كيف يعرض Scene entries وأي عناصر واجهة مستخدم محيطة خاصة بهذا Scene.
  • metadata: Map<String, Any>: تقدّم هذه السمة معلومات خاصة بالمشهد إلى مكوّنات المكتبة الأخرى ، مثل NavDisplay. تعرض هذه السمة تلقائيًا metadata لآخر NavEntry في entries.

تنفيذ الدالتَين equals وhashCode في المشاهد المخصّصة

يجب أن تنفّذ عمليات تنفيذ Scene المخصّصة الدالتَين equals وhashCode بشكلٍ صحيح لدعم ما يلي:

  • عمليات الانتقال بين حالات المشهد: في كل مرة يتم فيها حساب مشهد من خلال قائمة استراتيجيات المشهد، يتحقّق NavDisplay من مدى تطابق المشهد الجديد والمشهد السابق. إذا رصد تغييرًا، سيعدّل SeekableTransition المستخدَم للانتقال بين المشاهد، ما يؤثر بدوره في حالة مراحل نشاط المشاهد.
  • إدارة الطبقات الخارجية: بالنسبة إلى OverlayScene (مثل مربّعات الحوار)، NavDisplay يستخدم كائن المشهد نفسه كـ key لتتبُّع مراحل نشاط الطبقات الخارجية والرسوم المتحركة للخروج.

تأكَّد من تضمين جميع الخصائص التي تحدّد محتوى المشهد، مثل key وentries وpreviousEntries، في عمليات تنفيذ equals وhashCode. بوجهٍ عام، تجنَّب تضمين عمليات معاودة الاتصال (مثل onBack) في تنفيذ هاتَين الدالتَين لأنّهما يمكنهما تغيير المثيلات بدون تغيير حالة المشهد المنطقية.

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

SceneStrategy هي الآلية التي تحدّد كيفية ترتيب قائمة معيّنة من NavEntry من ذاكرة التصفّح السابقة والانتقال بها إلى 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.

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

NavDisplay هي الدالة القابلة للإنشاء المركزية التي تراقب ذاكرة التصفّح السابقة و تستخدم واحدة أو أكثر من SceneStrategy لتحديد Scene المناسب وعرضه.

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

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

  • عند إضافة مفاتيح إلى ذاكرة التصفّح السابقة أو إزالتها منها (مثلاً، باستخدام backStack.add() أو backStack.removeLastOrNull())، يراقب NavDisplay هذه التغييرات.
  • يمرّر NavDisplay القائمة الحالية من NavEntry (المستمدة من مفاتيح ذاكرة التصفّح السابقة) إلى sceneStrategies التي تم ضبطها بالترتيب، ويستدعي calculateScene على كل منها إلى أن يتم عرض Scene.
  • عندما تعرض 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> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? =
        SinglePaneScene(
            key = entries.last().contentKey,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

مثال: تنسيق أساسي يتضمّن قائمة وتفاصيل (مشهد واستراتيجية مخصّصان)

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

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

مقتطف الرمز البرمجي التالي هو رمز المصدر لـ ListDetailScene.kt ويحتوي على كل من ListDetailScene وListDetailSceneStrategy:

// --- ListDetailScene ---
/**
 * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
 *
 */
data class ListDetailScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val listEntry: NavEntry<T>,
    val detailEntry: NavEntry<T>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(listEntry, detailEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.4f)) {
                listEntry.Content()
            }
            Column(modifier = Modifier.weight(0.6f)) {
                detailEntry.Content()
            }
        }
    }
}

@Composable
fun <T : Any> rememberListDetailSceneStrategy(): ListDetailSceneStrategy<T> {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    return remember(windowSizeClass) {
        ListDetailSceneStrategy(windowSizeClass)
    }
}

// --- ListDetailSceneStrategy ---
/**
 * A [SceneStrategy] that returns a [ListDetailScene] if the window is wide enough, the last item
 * is the backstack is a detail, and before it, at any point in the backstack is a list.
 */
class ListDetailSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {

    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {

        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val detailEntry =
            entries.lastOrNull()?.takeIf { it.metadata.contains(DetailKey) } ?: return null
        val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: return null

        // We use the list's contentKey to uniquely identify the scene.
        // This allows the detail panes to be displayed instantly through recomposition, rather than
        // having NavDisplay animate the whole scene out when the selected detail item changes.
        val sceneKey = listEntry.contentKey

        return ListDetailScene(
            key = sceneKey,
            previousEntries = entries.dropLast(1),
            listEntry = listEntry,
            detailEntry = detailEntry
        )
    }

    object ListKey : NavMetadataKey<Boolean>
    object DetailKey : NavMetadataKey<Boolean>
    companion object {

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun listPane() = metadata {
            put(ListKey, true)
        }

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = metadata {
            put(DetailKey, true)
        }
    }
}

لاستخدام ListDetailSceneStrategy في NavDisplay، عدِّل طلبات entryProvider لتضمين بيانات ListDetailScene.listPane() الوصفية للإدخال الذي تنوي عرضه كتنسيق قائمة، وListDetailScene.detailPane() للإدخال الذي تريد عرضه كتنسيق تفاصيل. بعد ذلك، قدِّم ListDetailSceneStrategy() كـ sceneStrategy، مع الاعتماد على السلوك الاحتياطي التلقائي لسيناريوهات الجزء الواحد:

// Define your navigation keys
@Serializable
data object ConversationList : NavKey

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

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ConversationList)
    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        sceneStrategies = listOf(listDetailStrategy),
        entryProvider = entryProvider {
            entry<ConversationList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                Column(modifier = Modifier.fillMaxSize()) {
                    Text(text = "I'm a Conversation List")
                    Button(onClick = { backStack.addDetail(ConversationDetail("123")) }) {
                        Text(text = "Open detail")
                    }
                }
            }
            entry<ConversationDetail>(
                metadata = ListDetailSceneStrategy.detailPane()
            ) {
                Text(text = "I'm a Conversation Detail")
            }
        }
    )
}

private fun NavBackStack<NavKey>.addDetail(detailRoute: ConversationDetail) {

    // Remove any existing detail routes, then add the new detail route
    removeIf { it is ConversationDetail }
    add(detailRoute)
}

إذا كنت لا تريد إنشاء مشهد يتضمّن قائمة وتفاصيل خاص بك، يمكنك استخدام مشهد القائمة والتفاصيل في Material، الذي يتضمّن تفاصيل منطقية وإمكانية استخدام العناصر النائبة، كما هو موضّح في القسم التالي.

عرض محتوى يتضمّن قائمة وتفاصيل في مشهد تكيّفي في Material

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

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

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

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategies = listOf(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 : مثال على المحتوى الذي يتم عرضه في `Scene` يتضمّن قائمة وتفاصيل في Material