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

«پیمایش ۳» سیستم قدرتمند و انعطاف‌پذیری را برای مدیریت جریان واسط کاربر برنامه شما ازطریق صحنه‌ها معرفی می‌کند. صحنه‌ها به شما امکان می‌دهند چیدمان‌های بسیار سفارشی‌سازی‌شده ایجاد کنید، با اندازه‌های مختلف صفحه‌نمایش سازگار شوید، و تجربه‌های چند پانلی پیچیده را به‌طور یکپارچه مدیریت کنید.

درک صحنه‌ها

در «پیمایش ۳»، 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 است ارائه کند.
  • ‫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 انتظار دارد فهرستی از نمونه‌های 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 جزئیات فهرست «ماتریال»، این مراحل را دنبال کنید:

  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.