利用Popup将视图扩展到父级可组合边界之外
当我们对Jetpack Compose进行编程时, 我们可能没有意识到有一个限制是我们不能用普通的Jetpack Compose编程来实现的.
这个限制如下. 在任何组合视图中, 都不能从内部组合另一个比包含视图(父视图)更大或在视图之外的视图(如下图所示).
幸运的是, Jetpack Compose为我们提供了两个 Compose组件, 我们可以利用它们来实现这一目的.
注意: 这两个组件都不是用传统的Jetpack Compose编程方式创建的, 而是使用了
AbstractComposeView和WindowManager来创建的. 因此在两组件的内部, 它需要一些核心的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将与父容器的左上角对齐显示, 如下所示.
Popup对齐
要重新对齐Popup, 我们可以提供对齐参数, 例如:
Popup(
alignment = Alignment.Center,
)
我们可以设置Center、CenterStart、CenterEnd、TopCenter、TopStart(默认)、TopEnd、BottomCenter、BottomStart和BottomEnd.
当然, 这并不总是最理想的. 因此, 我们还可以提供一些偏移值, 例如:
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
当focusable为true时, Popup将接收IME事件和按键, 例如按下返回按钮时. 但它将禁止触摸Popup后面的任何内容.
如果我们将focusable = true与dismissOnBackPress设为true, 那么当用户点击后退按钮时, Popup将被取消(旧版Android手机).
DismissOnClickOutside
如前所述, 默认情况下, 当用户点击Popup外的区域时, Popup将被取消.
当我们将其切换为false时, Popup将不会被关闭.
安全策略
这是一个比较棘手的问题. 文档中没有明确提到它的功能. 相反, 我在一个问题中找到并了解了它.
安全策略有3种可能的值.
enum class SecureFlagPolicy {
Inherit, // Follow the parent, which is the default
SecureOn,
SecureOff
}
有了下面的说明, 一切都清楚了.
ExcludeFromSystemGesture
从Android Q开始, 用户可以从左侧轻扫返回, 而不是使用Android硬件返回按钮.
不过, 这可能会与应用程序手势相冲突, 例如, 如果我们有一个宽边栏的话. 一个很好的例子分享在这儿(在 Edge to Edge Brightness Slider 部分).
为了模拟这种情况, 我制作了一个大的Popup, 并将其打开或关闭.
当它处于打开状态时(默认), 如果我们尝试交换以下设置:
PopupProperties(
focusable = true,
dismissOnBackPress = false,
dismissOnClickOutside = false,
excludeFromSystemGesture = true,
)
如下面的 GIF 所示, 从左侧轻扫没有任何作用.
但是, 当我们将其关闭时:
PopupProperties(
focusable = true,
dismissOnBackPress = false,
dismissOnClickOutside = false,
excludeFromSystemGesture = false,
)
现在, 当你从左侧轻扫时,"点击返回"被触发(如图中的<号所示)
启用剪切功能
默认为true. 这意味着您永远不会意外地将Popup置于应用程序设备屏幕之外. 如果偏移值过大或Popup过大, 它就会缩小并固定在应用程序设备中, 如下图所示.
但如果我们不介意将Popup置于屏幕之外, 则可以将其设置为false.
UsePlatformDefaultWidth 使用平台默认宽度
在撰写本文时, 这仍是一项试验. 它与Clipping几乎相同.
下面是与Clipping一起使用时的结果
自定义位置
我们已经了解了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内容
}
}
你可以在这里获取到代码设计.
总结一下
Popup是一个Jetpack Compose组件, 它允许调用者的函数在调用者的视图边界之外显示一些内容.
我们可以自定义它的各种PopupProperties, 如如何取消、布局、与调用者Compose View的位置对齐等.
我们还可以自定义位置, 例如使其与窗口对齐, 而不是与调用者可组合函数对齐.
它有各种限制, 例如:
- 必须由外部控制(通过Popup外部的变量)来决定是否显示.
- 它是一个可组合函数, 因此我们不能在
onClick或enqueue中调用它, 也不能在"Coroutine Scope"中执行及时取消(例如, 将其作为Toast).