對話場景裝飾器食譜

這項食譜說明在對話方塊中顯示場景的方法。舉例來說,當應用程式處於展開視窗時,這項功能可用於將清單詳細資料設定 UI 顯示為疊加層,否則會顯示為全螢幕頁面。

運作方式

如要在對話方塊中顯示場景,請執行下列操作:

  1. 建立對話方塊 SceneDecorationStrategy:這個套件會定義 DialogSceneDecoratorStrategy,以及用來在對話方塊中顯示輸入內容 SceneDialogDecoratorScene。定義對話方塊場景裝飾策略時,請考量下列事項:
    1. 何時可以在對話方塊中顯示場景?在這個食譜中,只有在視窗寬度至少展開時,場景才會顯示在對話方塊中。
    2. 哪些中繼資料應決定場景是否可顯示在對話方塊中?在本食譜中,如果場景中的第一個項目含有 DialogSceneMetadataKey 中繼資料,系統就會在對話方塊中顯示該場景。視用途而定,您可能更適合採用其他方法,例如規定場景中的所有項目都必須具有相同的中繼資料。
    3. 對話方塊的關閉行為為何?在本食譜中,點選對話方塊外部會移除場景中的所有項目,而返回手勢或按鈕一次只會移除一個項目。食譜會透過 DialogDecoratorSceneConfiguration 類別提供這項行為的設定。
  2. 使用對話方塊 SceneDecorationStrategy:如要使用場景裝飾器策略,請將其做為 sceneDecoratorStrategies 參數的一部分傳遞至 NavDisplay
    1. 注意:由於 NavDisplay 不會裝飾 OverlayScene 執行個體,您可能需要注意對話方塊場景裝飾策略在 sceneDecoratorStrategies 清單中的位置。如果使用這個屬性裝飾場景,系統就不會再依據場景裝飾策略裝飾場景。
    2. 注意:使用 contentKey 導入 onDismissAll 來識別要關閉的項目時,請注意 contentKey 預設會使用 key.toString()。本食譜使用 backStack.removeAll { it.toString() in contentKeys },適用於簡單的物件鍵,但如果鍵較複雜或實作自訂 contentKey,可能就需要調整。
package com.example.nav3recipes.dialogscenedecorator

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentYellow
import com.example.nav3recipes.scenes.listdetail.ListDetailScene
import com.example.nav3recipes.scenes.listdetail.rememberListDetailSceneStrategy
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable

@Serializable
private data object Main : NavKey

@Serializable
private data object SettingsList : NavKey

@Serializable
private data object SettingsDetail : NavKey

class DialogSceneDecoratorActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setEdgeToEdgeConfig()

        setContent {
            val backStack = rememberNavBackStack(Main)
            val listDetailSceneStrategy = rememberListDetailSceneStrategy<NavKey>()

            val dialogSceneDecoratorStrategy = rememberDialogSceneDecoratorStrategy<NavKey>(
                onDismissAll = { entriesToDismiss ->
                    // Caution: This relies on the default behavior of NavEntry using key.toString()
                    // to define its contentKey property.
                    entriesToDismiss.forEach { entry ->
                        backStack
                            .indexOfLast { it.toString() == entry.contentKey }
                            .takeIf { it >= 0 }
                            ?.let { backStack.removeAt(it) }
                    }
                }
            )

            SharedTransitionLayout {
                NavDisplay(
                    backStack = backStack,
                    onBack = { backStack.removeLastOrNull() },
                    sceneStrategies = listOf(listDetailSceneStrategy),
                    sceneDecoratorStrategies = listOf(dialogSceneDecoratorStrategy),
                    sharedTransitionScope = this,
                    entryProvider = entryProvider {
                        entry<Main> {
                            ContentGreen("Welcome to Nav3") {
                                Button(onClick = dropUnlessResumed {
                                    backStack.add(SettingsList)
                                }) {
                                    Text("Click to open settings")
                                }
                            }
                        }
                        entry<SettingsList>(
                            metadata = DialogSceneDecoratorStrategy.sceneDialog(
                                DialogDecoratorSceneConfiguration(
                                    backDismissalBehavior = DismissalBehavior.Single
                                )
                            ) + ListDetailScene.listPane()
                        ) {
                            ContentBlue(
                                title = "Settings List",
                            ) {
                                Button(onClick = dropUnlessResumed {
                                    if (backStack.last() !is SettingsDetail) {
                                        backStack.add(SettingsDetail)
                                    }
                                }) {
                                    Text("Open detail")
                                }
                            }
                        }
                        entry<SettingsDetail>(
                            metadata = ListDetailScene.detailPane()
                        ) {
                            ContentYellow("Settings Detail")
                        }
                    }
                )
            }
        }
    }
}
package com.example.nav3recipes.dialogscenedecorator

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavMetadataKey
import androidx.navigation3.runtime.get
import androidx.navigation3.runtime.metadata
import androidx.navigation3.scene.OverlayScene
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneDecoratorStrategy
import androidx.navigation3.scene.SceneDecoratorStrategyScope
import androidx.window.core.layout.WindowSizeClass

fun DialogProperties.copy(
    dismissOnBackPress: Boolean = this.dismissOnBackPress,
    dismissOnClickOutside: Boolean = this.dismissOnClickOutside,
    securePolicy: SecureFlagPolicy = this.securePolicy,
    usePlatformDefaultWidth: Boolean = this.usePlatformDefaultWidth,
    decorFitsSystemWindows: Boolean = this.decorFitsSystemWindows,
    windowTitle: String = this.windowTitle
): DialogProperties = DialogProperties(
    dismissOnBackPress = dismissOnBackPress,
    dismissOnClickOutside = dismissOnClickOutside,
    securePolicy = securePolicy,
    usePlatformDefaultWidth = usePlatformDefaultWidth,
    decorFitsSystemWindows = decorFitsSystemWindows,
    windowTitle = windowTitle
)

enum class DismissalBehavior {
    All, Single
}

/**
 * Configuration for the [DialogDecoratorScene].
 *
 * @property dialogProperties The [DialogProperties] used to configure the dialog.
 * @property backDismissalBehavior Whether all entries in the scene should be dismissed when the
 * back button is pressed. This configuration only applies if the
 * [DialogProperties.dismissOnBackPress] is set to `true`.
 *
 */
class DialogDecoratorSceneConfiguration(
    val dialogProperties: DialogProperties = DialogProperties(),
    val backDismissalBehavior: DismissalBehavior = DismissalBehavior.Single,
    val shape: Shape = RoundedCornerShape(16.dp),
) {
    fun toDialogProperties(): DialogProperties {
        // If the desired behavior is to not dismiss all, the DialogProperties passed to the Dialog
        // needs to be configured to not handle the back press itself.
        if (dialogProperties.dismissOnBackPress && backDismissalBehavior != DismissalBehavior.All) {
            return dialogProperties.copy(dismissOnBackPress = false)
        }

        return dialogProperties
    }

    fun shouldDismissSingleOnBackPress() =
        dialogProperties.dismissOnBackPress && backDismissalBehavior == DismissalBehavior.Single

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other == null || this::class != other::class) return false
        other as DialogDecoratorSceneConfiguration
        return dialogProperties == other.dialogProperties &&
                backDismissalBehavior == other.backDismissalBehavior &&
                shape == other.shape
    }

    override fun hashCode(): Int {
        return dialogProperties.hashCode() * 31 +
                backDismissalBehavior.hashCode() * 31 +
                shape.hashCode() * 31
    }
}

/**
 * [DialogDecoratorScene] is an [OverlayScene] used by [DialogSceneDecoratorStrategy] to present
 * another [Scene] within a [Dialog].
 *
 * @property scene The [Scene] to be displayed within the dialog.
 * @property overlaidEntries The [NavEntry]s that are overlaid by the dialog.
 * @property dialogDecoratorSceneConfiguration The [DialogDecoratorSceneConfiguration] used to
 * configure the dialog scene.
 * @property onBack The callback to be invoked when a back event should be handled
 * @property onDismissAll The callback to be invoked when the entire dialog stack should be dismissed
 **/
data class DialogDecoratorScene<T : Any>(
    private val scene: Scene<T>,
    override val overlaidEntries: List<NavEntry<T>>,
    private val dialogDecoratorSceneConfiguration: DialogDecoratorSceneConfiguration,
    private val onBack: () -> Unit,
    private val onDismissAll: () -> Unit
) : OverlayScene<T>, Scene<T> by scene {

    override val content: @Composable () -> Unit = {
        Dialog(
            onDismissRequest = onDismissAll,
            properties = dialogDecoratorSceneConfiguration.toDialogProperties()
        ) {
            Surface(
                shape = dialogDecoratorSceneConfiguration.shape
            ) {
                scene.content()
            }

            // Because back events are dispatched to the currently focused window, this back handler
            // must be contained within the dialog's content to receive the events.
            BackHandler(dialogDecoratorSceneConfiguration.shouldDismissSingleOnBackPress()) {
                onBack()
            }
        }
    }
}

@Composable
fun <T : Any> rememberDialogSceneDecoratorStrategy(
    windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass,
    onDismissAll: ((List<NavEntry<T>>) -> Unit)
): DialogSceneDecoratorStrategy<T> = remember(windowSizeClass, onDismissAll) {
    DialogSceneDecoratorStrategy(windowSizeClass, onDismissAll = onDismissAll)
}

/**
 * A [SceneDecoratorStrategy] that wraps a [Scene] in a [DialogDecoratorScene] if the first
 * [NavEntry] within it has been marked with the [DialogSceneMetadataKey] and the window width
 * is at least [WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND].
 *
 * If you only need to display a single [NavEntry] in a [Dialog], using
 * [androidx.navigation3.scene.DialogSceneStrategy] instead may be preferable.
 *
 * @property windowSizeClass The current [WindowSizeClass] used to determine if dialogs should be used.
 * @property windowWidthDpBreakpoint the width in dp at or above which a dialog should be displayed.
 * @property onDismissAll callback invoked to dismiss all dialog entries, receives the entries that
 * are currently in the dialog.
 */
class DialogSceneDecoratorStrategy<T : Any>(
    private val windowSizeClass: WindowSizeClass,
    private val windowWidthDpBreakpoint: Int = WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND,
    private val onDismissAll: (List<NavEntry<T>>) -> Unit
) : SceneDecoratorStrategy<T> {

    override fun SceneDecoratorStrategyScope<T>.decorateScene(scene: Scene<T>): Scene<T> {
        if (!windowSizeClass.isWidthAtLeastBreakpoint(windowWidthDpBreakpoint)) return scene

        val dialogDecoratorSceneConfiguration =
            scene.entries.firstOrNull()?.metadata[DialogSceneMetadataKey] ?: return scene

        // This is critical to ensure that the scenes rendered beneath the dialog don't contain
        // any entries that are in the dialog.
        val overlaidEntries = scene.previousEntries.dropLastWhile { it in scene.entries }

        return DialogDecoratorScene(
            scene,
            overlaidEntries,
            dialogDecoratorSceneConfiguration,
            onBack,
            onDismissAll = { onDismissAll.invoke(scene.entries) })
    }

    companion object {
        object DialogSceneMetadataKey : NavMetadataKey<DialogDecoratorSceneConfiguration>

        fun sceneDialog(dialogDecoratorSceneConfiguration: DialogDecoratorSceneConfiguration = DialogDecoratorSceneConfiguration()): Map<String, Any> =
            metadata {
                put(DialogSceneMetadataKey, dialogDecoratorSceneConfiguration)
            }
    }
}