Jetpack Compose Popup 高级编程

3,885 阅读5分钟

利用Popup将视图扩展到父级可组合边界之外

当我们对Jetpack Compose进行编程时, 我们可能没有意识到有一个限制是我们不能用普通的Jetpack Compose编程来实现的.

这个限制如下. 在任何组合视图中, 都不能从内部组合另一个比包含视图(父视图)更大或在视图之外的视图(如下图所示).

1_h7mkrmsvLvr61AymIvu-xw.webp

幸运的是, Jetpack Compose为我们提供了两个 Compose组件, 我们可以利用它们来实现这一目的.

注意: 这两个组件都不是用传统的Jetpack Compose编程方式创建的, 而是使用了AbstractComposeViewWindowManager来创建的. 因此在两组件的内部, 它需要一些核心的Android框架开发知识.

本文的重点是分享有关Popup的所有内容, 并提供图解, 以便读者轻松掌握. 通过本文, 你还可能了解到其他一些有趣的细节, 如屏幕安全和禁用手势用法.

让我们一起来看看吧.

Popup基础

显示Popup

默认情况下, 我们可以使用:

Popup { 
    //  显示在Popup里面的Composable内容
}

但是, 如果我们像这样调用它, Popup就会一直显示.

要使其只在点击按钮时显示, 我们需要将其封装在一些控制逻辑中:

var popupControl by remember { mutableStateOf(false) }
 TextButton(onClick = { popupControl = true } ) {
 Text("Open normal popup")
 }

if (popupControl) { 
    Popup {
   // 显示在Popup里面的Composable内容
 }
} 

默认情况下, Popup将与父容器的左上角对齐显示, 如下所示.

1_jpGDSCT0yj7bk1I2Bf-tPA.gif

Popup对齐

要重新对齐Popup, 我们可以提供对齐参数, 例如:

Popup(
    alignment = Alignment.Center,
)

我们可以设置CenterCenterStartCenterEndTopCenterTopStart(默认)、TopEndBottomCenterBottomStartBottomEnd.

当然, 这并不总是最理想的. 因此, 我们还可以提供一些偏移值, 例如:

Popup(
    alignment = Alignment.CenterStart,
    offset = IntOffset(0, 700),
)

Popup onDismissRequest

默认情况下, 当我们在弹出内容之外按下按钮时, Popup将被取消. 但是, 如果您按照以下步骤操作, Popup将永远不会被关闭:

var popupControl by remember { mutableStateOf(false) }
 TextButton(onClick = { popupControl = true } ) {
 Text("Open normal popup")
 }

if (popupControl) { 
    Popup(
        alignment = Alignment.CenterStart,
        offset = IntOffset(0, 700),
    )  {
   // 显示在Popup里面的Composable内容
 }
} 

这并不是因为它没有被取消. 事实上, 它已经被取消, 但由于popupControl仍然是true, 当它被重新组合时, Popup会再次出现(使我们感觉不到它已被取消).

为了解决这个问题, 我们需要通知Popup在Popup被取消时将popupControl设置为false. 我们可以使用onDismissRequest参数来实现, 如下所示:

var popupControl by remember { mutableStateOf(false) }
 TextButton(onClick = { popupControl = true } ) {
 Text("Open normal popup")
 }

if (popupControl) { 
    Popup(
        alignment = Alignment.CenterStart,
        offset = IntOffset(0, 700),
 onDismissRequest = { popupControl = false }, 
    )  {
   // 显示在Popup里面的Composable内容
 }
} 

Popup属性

我们已经学习了Popup的基础知识, 下面我们来学习Popup的高级功能--Popup的PopupProperties参数.

class PopupProperties(
    focusable: Boolean = false,
    dismissOnBackPress: Boolean = true,
    dismissOnClickOutside: Boolean = true,
    securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
    excludeFromSystemGesture: Boolean = true,
    clippingEnabled: Boolean = true,    @get:ExperimentalComposeUiApi //At the time of writing
    val usePlatformDefaultWidth: Boolean = false
)

上述参数是默认提供的, 但我们可以覆盖它. 下面我将逐一说明.

Focusable和DismissOnBackPress

focusabletrue时, Popup将接收IME事件和按键, 例如按下返回按钮时. 但它将禁止触摸Popup后面的任何内容.

1_YVgQ9tQhzKopHQeWGLXelA.webp

如果我们将focusable = truedismissOnBackPress设为true, 那么当用户点击后退按钮时, Popup将被取消(旧版Android手机).

DismissOnClickOutside

如前所述, 默认情况下, 当用户点击Popup外的区域时, Popup将被取消.

当我们将其切换为false时, Popup将不会被关闭.

安全策略

这是一个比较棘手的问题. 文档中没有明确提到它的功能. 相反, 我在一个问题中找到并了解了它.

安全策略有3种可能的值.

enum class SecureFlagPolicy {
 Inherit, // Follow the parent, which is the default
    SecureOn,
 SecureOff
}

有了下面的说明, 一切都清楚了.

1_fzpb8CeEM6UYL1rBTp8AYg.webp

ExcludeFromSystemGesture

从Android Q开始, 用户可以从左侧轻扫返回, 而不是使用Android硬件返回按钮.

不过, 这可能会与应用程序手势相冲突, 例如, 如果我们有一个宽边栏的话. 一个很好的例子分享在这儿(在 Edge to Edge Brightness Slider 部分).

为了模拟这种情况, 我制作了一个大的Popup, 并将其打开或关闭.

当它处于打开状态时(默认), 如果我们尝试交换以下设置:

PopupProperties(
    focusable = true,
    dismissOnBackPress = false,
    dismissOnClickOutside = false,
 excludeFromSystemGesture = true, 
)

如下面的 GIF 所示, 从左侧轻扫没有任何作用.

1_8keelwDUflGe_U2LT9_7-g.gif

但是, 当我们将其关闭时:

PopupProperties(
    focusable = true,
    dismissOnBackPress = false,
    dismissOnClickOutside = false,
 excludeFromSystemGesture = false, 
)

现在, 当你从左侧轻扫时,"点击返回"被触发(如图中的<号所示)

1_lIPOkC6ucBMDt0yUOWWAPg.gif

启用剪切功能

默认为true. 这意味着您永远不会意外地将Popup置于应用程序设备屏幕之外. 如果偏移值过大或Popup过大, 它就会缩小并固定在应用程序设备中, 如下图所示.

但如果我们不介意将Popup置于屏幕之外, 则可以将其设置为false.

1_tyLGuir47r1bnVfUHwwwsA.webp

UsePlatformDefaultWidth 使用平台默认宽度

在撰写本文时, 这仍是一项试验. 它与Clipping几乎相同.

下面是与Clipping一起使用时的结果

1_Lvg5gpRRmeCHS2o875C8LQ.webp

自定义位置

我们已经了解了PopupProperty. 我们还知道, 在普通Popup中, 我们可以通过与父可组合视图对齐来调整位置.

但是, 如果我们想让Popup不依赖于父可组合视图, 该怎么做呢?

好消息是, 有另一种Popup API可以让我们提供PopupPositionProvider.

@Composable
fun Popup(
 popupPositionProvider: PopupPositionProvider, 
    onDismissRequest: (() -> Unit)? = null,
    properties: PopupProperties = PopupProperties(),
    content: @Composable () -> Unit
) {

PopupPositionProvider

这是一个用于计算位置的接口, 用户可以据此进行自定义.

@Immutable
interface PopupPositionProvider {
 fun calculatePosition(
        anchorBounds: IntRect,
        windowSize: IntSize,
        layoutDirection: LayoutDirection,
        popupContentSize: IntSize
    ): IntOffset
}

在我的例子中, 我希望自定义的Popup位置始终位于窗口的中心.

因此, 我可以很容易地创建如下所示的位置, 其中也可以设置相对于窗口中心位置的OffSet.

class WindowCenterOffsetPositionProvider(
    private val x: Int = 0,
    private val y: Int = 0
) : PopupPositionProvider {
    override fun calculatePosition(
        anchorBounds: IntRect,
        windowSize: IntSize,
        layoutDirection: LayoutDirection,
        popupContentSize: IntSize
    ): IntOffset {
        return IntOffset(
            (windowSize.width - popupContentSize.width) / 2 + x,
            (windowSize.height - popupContentSize.height) / 2 + y
        )
    }
}

现在, 我们只需将其分配给Popup, 如下所示. 这样它就会有相应的行为:

var popupControl by remember { mutableStateOf(false) }
 TextButton(onClick = { popupControl = true } ) {
 Text("Open normal popup")
 }

if (popupControl) { 
    Popup(
 popupPositionProvider = 
           WindowCenterOffsetPositionProvider(),
        onDismissRequest = { popupControl = false },
    )  {
   // 显示在Popup里面的Composable内容
 }
} 

1_Gd9kg-HENkrcawasnU1IZw.gif

你可以在这里获取到代码设计.

总结一下

Popup是一个Jetpack Compose组件, 它允许调用者的函数在调用者的视图边界之外显示一些内容.

我们可以自定义它的各种PopupProperties, 如如何取消、布局、与调用者Compose View的位置对齐等.

我们还可以自定义位置, 例如使其与窗口对齐, 而不是与调用者可组合函数对齐.

它有各种限制, 例如:

  1. 必须由外部控制(通过Popup外部的变量)来决定是否显示.
  2. 它是一个可组合函数, 因此我们不能在onClickenqueue中调用它, 也不能在"Coroutine Scope"中执行及时取消(例如, 将其作为Toast).