轻松实现 Jetpack Compose 中的 Snackbar

331 阅读4分钟

轻松实现 Jetpack Compose 中的 Snackbar

受近期关于 Jetpack Compose 的讨论及其Snackbar组件的启发, 我想分享一种我认为易于使用且在其他项目中高度可复用的方法. 我们将探讨一种实现方案, 使Snackbar不仅可以在 Compose UI 树内部显示, 还可以在外部显示, 例如从 ViewModel 中调用.

问题是什么?

要在 Compose 应用中显示 Snackbar, 你需要几个组件:

  • 一个 Scaffold 组件用于渲染 SnackbarHost
  • 一个 SnackbarHost, 负责Snackbar的 UI 呈现
  • 一个 SnackbarHostState, 用于管理Snackbar的显示, 隐藏和关闭

最基本的设置大致如下:

@Composable
fun App() {
    val host = remember { SnackbarHostState() }
    Scaffold(snackbarHost = { SnackbarHost(hostState = host) }) {
        // rest of content
    }
}

我们可以调用 host 状态实例上的 showSnackbar 方法来排队一个新的 Snackbar. 这很简单, 只要你能够访问渲染的 SnackbarHost 中使用的 host 即可. 但如果我们需要在 UI 树的深处访问它呢? 或者如果我们想从 ViewModel 中显示一个带有撤销操作的确认消息?

Show me the code!

我将详细解释实现过程, 但如果你想立即查看代码, 它已发布在GitHub gist.

拆解实现

让我们从实现必要的组件开始, 以达到最终目标.

Snackbar操作

Snackbar除了显示消息外, 还可以包含操作. 让我们定义一个 data class 来表示这种结构:

data class SnackbarAction(val title: String, val onActionPress: () -> Unit)

Controller

显示Snackbar的核心功能将由我们的 SnackbarController 类处理. 该类需要一个 SnackbarHostState 来排队 Snackbar, 并使用 CoroutineScope 来管理它们, 因为 SnackbarHostState 上的 showSnackbar 方法是一个suspend函数.

SnackbarController 将提供一个名为 showMessage 的方法, 该方法启动一个协程来显示 Snackbar:


@Immutable
class SnackbarController(
    private val host: SnackbarHostState,
    private val scope: CoroutineScope,
) {
    fun showMessage(
        message: String,
        action: SnackbarAction? = null,
        duration: SnackbarDuration = SnackbarDuration.Short,
    ) {
        scope.launch {
            /**
             * note: uncomment this line if you want snackbar to be displayed immediately,
             * rather than being enqueued and waiting [duration] * current_queue_size
             */
            // host.currentSnackbarData?.dismiss()
            val result =
                host.showSnackbar(
                    message = message,
                    actionLabel = action?.title,
                    duration = duration
                )

            if (result == SnackbarResult.ActionPerformed) {
                action?.onActionPress?.invoke()
            }
        }
    }
}

在 Composable 树中访问 SnackbarController

为了在 Composable 树中访问 SnackbarController, 我们将使用CompositionLocal. 这种方法避免了需要通过多个 Composable 层传递相同参数以到达目标位置的属性钻取问题.

首先, 定义一个自定义的 CompositionLocal:

val LocalSnackbarController = staticCompositionLocalOf {
    SnackbarController(
        host = SnackbarHostState(),
        scope = CoroutineScope(EmptyCoroutineContext)
    )
}

接下来, 创建一个 SnackbarControllerProvider Composable , 通过 CompositionLocal 提供 SnackbarController. 该 Composable 接受一个参数 @Composable content, 该参数将接收 SnackbarHostState 作为其参数.

@Composable
fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) {
    val snackHostState = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()
    val snackController = remember(scope) {
        SnackbarController(snackHostState, scope)
    }

    CompositionLocalProvider(LocalSnackbarController provides snackController) {
        content(
            snackHostState
        )
    }
}

最后, 通过在 SnackbarController 中添加一个属性, 以熟悉的方式通过 SnackbarController.current 访问其在组合中的实例, 从而提升开发者体验:

@Immutable
class SnackbarController(
    private val host: SnackbarHostState,
    private val scope: CoroutineScope,
) {
    companion object {
        val current
            @Composable
            @ReadOnlyComposable
            get() = LocalSnackbarController.current
    }

    // omitted for brevity
}

将所有内容连接起来

现在是时候将所有部分整合在一起了. 我们通过将 SnackbarControllerProvider 放置在视图层级中的 Scaffold 之上, 以便将其 SnackbarHostState 实例传递给它:

// assuming this is top of composable tree
@Composable
fun App() {
    SnackbarControllerProvider { host ->
        Scaffold(snackbarHost = { SnackbarHost(hostState = host) }) {
            // rest of content
        }
    }
}

在 Compose 上下文中使用

借助 SnackbarControllerProvider 提供的 Controller, 我们可以在 Composable 树的任何位置访问它:

@Composable
fun MyContent() {
    val controller = SnackbarController.current

    Button(onClick = {
        controller.showMessage("World!")
    }) {
        Text("Hello")
    }
}

在 Compose 上下文外使用

要在 Compose 层级外显示 Snackbar, 我们需要一种与 Compose 层级内的 SnackbarController 通信的方式. 一种方法是使用 Kotlin 的ChannelsSnackbarController 发送消息以触发Snackbar显示.

首先, 创建一个容量无限的Channel

data class SnackbarChannelMessage(
    val message: String,
    val action: SnackbarAction?,
    val duration: SnackbarDuration = SnackbarDuration.Short,
)

val channel = Channel<SnackbarChannelMessage>(capacity = Int.MAX_VALUE)

接下来, 我们需要监听Channel中的传入消息. 我们可以在 SnackbarControllerProvider 中使用 DisposableEffect 实现这一点

@Composable
fun SnackbarControllerProvider(content: @Composable (snackbarHost: SnackbarHostState) -> Unit) {
    val snackHostState = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()
    val snackController = remember(scope) { SnackbarController(snackHostState, scope) }

    DisposableEffect(snackController, scope) {
        val job = scope.launch {
            for (payload in channel) {
                snackController.showMessage(
                    message = payload.message,
                    duration = payload.duration,
                    action = payload.action
                )
            }
        }

        onDispose {
            job.cancel()
        }
    }

    // omitted for brevity
}

最后, 在 SnackbarController 的伴生对象中添加一个函数, 用于向Channel发送消息:

@Immutable
class SnackbarController(
    private val host: SnackbarHostState,
    private val scope: CoroutineScope,
) {
    companion object {
        // omitted for brevity

        fun showMessage(
            message: String,
            action: SnackbarAction? = null,
            duration: SnackbarDuration = SnackbarDuration.Short,
        ) {
            channel.trySend(
                SnackbarChannelMessage(
                    message = message,
                    duration = duration,
                    action = action
                )
            )
        }
    }
    // omitted for brevity
}

设置完成后, 我们现在可以从 Compose 树外部(如 ViewModel)显示 Snackbar:

// method on ViewModel
fun deleteItem(itemId: Long) {
    repo.deleteItem(itemId)

    val action = SnackbarAction(
        title = "Undo",
        onActionPress = { repo.undoDelete(itemId) }
    )
    SnackbarController.showMessage(message = "Item deleted", action = action)
}

总结一下

希望这篇文章对简化Snackbar的使用有所帮助. 该实现可以进一步扩展, 但目前这样已经足够.

好吧, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy coding! Stay GOLDEN!