ایجاد چیدمان‌های سفارشی بااستفاده از «صحنه‌ها»

‫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. If so, how should I arrange those entries into the Scene? Once a SceneStrategy commits to handling the entries, it takes on the responsibility of constructing a Scene and defining how the specified NavEntrys will be displayed within that 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 منتظر فهرستی از نمونه‌های 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)
        )
}

مثال: چیدمان تفصیلی-فهرستی پایه (Scene و استراتژی سفارشی)

این مثال نشان می‌دهد که چگونه می‌توان یک طرح‌بندی فهرست-جزئیات ایجاد کرد که براساس دو شرط فعال می‌شود:

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

If you don't want to create your own list-detail scene, you can use the Material list-detail scene, which comes with sensible details and the support for placeholders, as showcased in the next section.

نمایش محتوای تفصیلی-فهرستی در «صحنه تطبیقی 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")
                        }
                    }
                )
            }
        }
    }
}

شکل ۱. نمونه محتوای درحال اجرا در «صحنه» فهرست-جزئیات Material.