当我写我的最后一篇关于 Jetpack Compose 的文章时,我在那里指出 Jetpack Compose 缺少一些(在我看来是)基本组件,其中之一就是工具提示。

当时,没有内置的可组合组件来显示工具提示,网上有几种替代解决方案。那些解决方案的问题在于,一旦 Jetpack Compose 发布较新版本,那些解决方案可能会失效。所以这并不理想,社区希望未来某个时候会添加对工具提示的支持。

我很高兴地说,自从Compose Material 3 的 1.1.0 版本以来,我们现在有了内置的工具提示支持。👏

虽然这本身就很好,但自从那个版本发布以来已经过去了一年多。在后续版本中,与工具提示相关的 API 也发生了巨大变化。

如果你查看变更日志,你会看到公共和内部 API 如何发生变化。所以请记住,当你阅读这篇文章时,事情可能已经继续变化,因为与工具提示相关的一切仍然标记为注释ExperimentalMaterial3Api::class

❗️ 本文使用的 material 3 版本是 1.2.1,于 2024 年 3 月 6 日发布

工具提示类型

我们现在支持两种不同类型的工具提示:

  1. 普通工具提示

  2. 富媒体工具提示

简单工具提示

您可以使用第一种类型为图标按钮提供信息,否则这些信息不会很清晰。例如,您可以使用简单工具提示来指示用户图标按钮代表什么。

要向您的应用程序添加工具提示,您使用TooltipBox可组合组件。这个可组合组件接受几个参数:

fun TooltipBox(
    positionProvider: PopupPositionProvider,
    tooltip: @Composable TooltipScope.() -> Unit,
    state: TooltipState,
    modifier: Modifier = Modifier,
    focusable: Boolean = true,
    enableUserInput: Boolean = true,
    content: @Composable () -> Unit,
)

如果您之前使用过可组合组件,其中一些应该会很熟悉。在这里我会突出那些有特定用法的参数:

  • positionProvider – 是PopupPositionProvider类型,用于计算工具提示的位置。

  • tooltip – 这里你可以设计工具提示的UI外观。

  • state – 它持有与特定Tooltip实例相关联的状态。它暴露了显示/隐藏工具提示的方法,当实例化一个实例时,您可以声明工具提示是否应该是持久的(意味着它应该在用户执行工具提示外的点击动作之前一直显示在屏幕上)。

  • 内容 – 这是工具提示将在上方/下方显示的 UI。

以下是一个实例化 BasicTooltipBox 的示例,其中填充了所有相关参数:

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun BasicTooltip() {
    val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider()
    val tooltipState = rememberBasicTooltipState(isPersistent = false)

    BasicTooltipBox(positionProvider = tooltipPosition,
        tooltip =  { Text("Hello World") } ,
        state = tooltipState) {
        IconButton(onClick = { }) {
            Icon(imageVector = Icons.Filled.Favorite, 
                 contentDescription = "Your icon's description")
        }
    }
}

Jetpack Compose 有一个内置类叫做 TooltipDefaults。您可以使用这个类来帮助您实例化组成 TooltipBox 的参数。例如,您可以使用 TooltipDefaults.rememberPlainTooltipPositionProvider 来正确地将工具提示定位在锚元素的相关位置。

丰富工具提示

一个丰富的媒体工具提示比一个普通的工具提示占用更多的空间,可以用来提供更多关于图标按钮功能的上下文。当工具提示显示时,您可以在其中添加按钮和链接来提供进一步的解释或定义。

它的实例化方式与普通工具提示类似,在 TooltipBox 内部,但您使用的是 RichTooltip 可组合的。

TooltipBox(positionProvider = tooltipPosition,
        tooltip = {
                  RichTooltip(
                      title = { Text("RichTooltip") },
                      caretSize = caretSize,
                      action = {
                          TextButton(onClick = {
                              scope.launch {
                                  tooltipState.dismiss()
                                  tooltipState.onDispose()
                              }
                          }) {
                              Text("Dismiss")
                          }
                      }
                  ) {
                        Text("This is where a description would go.")
                  }
        },
        state = tooltipState) {
        IconButton(onClick = {
            /* 图标按钮的点击事件 */
        }) {
            Icon(imageVector = tooltipIcon,
                contentDescription = "Your icon's description",
                tint = iconColor)
        }
    }

关于 Rich 工具提示有几件事情需要注意:

  1. Rich 工具提示支持 caret。

  2. 您可以向提示框中添加一个动作(即按钮),以给用户提供一个了解更多信息的选择。

  3. 您可以添加逻辑来关闭提示框。

特殊情况

当您选择将提示框状态标记为持久时,这意味着一旦用户与显示提示框的UI交互,它将保持可见状态,直到用户在屏幕上其他地方按下。

如果您查看上述丰富的提示框示例,您可能会注意到我们在点击提示框后添加了一个按钮来关闭提示框。

当用户按下那个按钮时会发生一个问题。由于关闭动作是在提示框上执行的,如果用户想要在触发这个提示框的UI项目上执行另一个长按,提示框将不再显示。这意味着提示框的状态在关闭后是持久的。那么,我们应该如何解决这个问题呢?

为了“重置”提示框的状态,我们必须调用通过提示框状态暴露出的onDispose方法。一旦我们这样做,提示框的状态将被重置,当用户在UI项目上执行长按时,提示框将再次显示。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RichTooltip() {
    val tooltipPosition = TooltipDefaults.rememberRichTooltipPositionProvider()
    val tooltipState = rememberTooltipState(isPersistent = true)
    val scope = rememberCoroutineScope()

    TooltipBox(positionProvider = tooltipPosition,
        tooltip = {
                  RichTooltip(
                      title = { Text("RichTooltip") },
                      caretSize = TooltipDefaults.caretSize,
                      action = {
                          TextButton(onClick = {
                              scope.launch {
                                  tooltipState.dismiss()
                                  tooltipState.onDispose()  /// <---- 此地
                              }
                          }) {
                              Text("Dismiss")
                          }
                      }
                  ) {

                  }
        },
        state = tooltipState) {
        IconButton(onClick = {  }) {
            Icon(imageVector = Icons.Filled.Call, contentDescription = "Your icon's description")
        }
    }
}

另一个情况是,当用户点击工具提示外部区域以关闭工具提示时,工具提示状态不会重置。这种情况下,工具提示会在幕后调用关闭方法,并将工具提示状态设置为已关闭。长期按住UI元素以再次查看工具提示将不会有任何效果。

我们调用工具提示的 onDispose 方法的逻辑不会被触发,那么我们该如何重置工具提示的状态呢?

目前我还没有解决这个问题。这可能与工具提示的MutatorMutex有关。也许在未来的版本中,会有一个API来解决这个问题。我注意到,如果屏幕上有其他工具提示,并且它们被按下,这会重置之前点击的工具提示。

如果你想要查看这里展示的代码,你可以去这个GitHub仓库

如果你想要在应用程序中查看工具提示,你可以在这里找到它。

参考文献