Compose 中 Popup 和 Dialog 基于 PopupLayout 和 DialogLayout 实现,它俩是 AbstractComposeView 的实现类。AbstractComposeView 的另一个实现类 ComposeView 前面的章节已经分析过了。
Popup 函数
@Composable
fun Popup(
popupPositionProvider: PopupPositionProvider,
onDismissRequest: (() -> Unit)? = null,
properties: PopupProperties = PopupProperties(),
content: @Composable () -> Unit
) {
val view = LocalView.current
val density = LocalDensity.current
val testTag = LocalPopupTestTag.current
val layoutDirection = LocalLayoutDirection.current
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
val popupId = rememberSaveable { UUID.randomUUID() }
//创建 PopupLayout 对象,继承自 AbstractComposeView 的 View 对象
val popupLayout = remember {
PopupLayout(
onDismissRequest = onDismissRequest,
properties = properties,
testTag = testTag,
composeView = view,
density = density,
initialPositionProvider = popupPositionProvider,
popupId = popupId
).apply {
//设置 PopupLayout.content 属性
setContent(parentComposition) {
//就一个 Box ,帧布局
SimpleStack(
Modifier
.semantics { this.popup() }
.onSizeChanged {
popupContentSize = it
updatePosition()
}
.alpha(if (canCalculatePosition) 1f else 0f)
) {
currentContent()
}
}
}
}
//-----------------------------------------
DisposableEffect(popupLayout) {
popupLayout.show() //显示
popupLayout.updateParameters(
onDismissRequest = onDismissRequest,
properties = properties,
testTag = testTag,
layoutDirection = layoutDirection
)
onDispose {
popupLayout.disposeComposition()
//消失
popupLayout.dismiss()
}
}
//更新参数
SideEffect {
popupLayout.updateParameters(
onDismissRequest = onDismissRequest,
properties = properties,
testTag = testTag,
layoutDirection = layoutDirection
)
}
//更新位置
DisposableEffect(popupPositionProvider) {
popupLayout.positionProvider = popupPositionProvider
popupLayout.updatePosition()
onDispose {}
}
//更新位置
LaunchedEffect(popupLayout) {
while (isActive) {
withInfiniteAnimationFrameNanos {}
popupLayout.pollForLocationOnScreenChange()
}
}
//-----------------------------------------
Layout(
content = {},
modifier = Modifier
.onGloballyPositioned { childCoordinates ->
val parentCoordinates = childCoordinates.parentLayoutCoordinates!!
//更新位置
popupLayout.updateParentLayoutCoordinates(parentCoordinates)
}
) { _, _ ->
popupLayout.parentLayoutDirection = layoutDirection
layout(0, 0) {}
}
}
Popup 函数源码可以分成三部分
-
创建 PopupLayout 对象,将 Compose UI 设置到 PopupLayout.content 中
-
使用 Effects Api 控制 PopupLayout 显示/消失 和位置更新
-
向当前 UI 中添加一个宽高都是 0 的 LayoutNode
PopupLayout 开启初始组合
PopupLayout
fun show() {
windowManager.addView(this, params)
}
fun dismiss() {
ViewTreeLifecycleOwner.set(this, null)
windowManager.removeViewImmediate(this)
}
- PopupLayout 的显示是通过 WindowManager 向当前 DecorView 添加子 View 来实现。
- ViewGroup.addView() 添加子 View 时会执行 child.dispatchAttachedToWindow()
- 触发 AbstractComposeView.onAttachedToWindow() 执行 ensureCompositionCreated() 开启初始组合
接下来的流程我们就不陌生了,但是不一样,具体情况我们下一章分析。
PopupLayout 显示/消失
从 DisposableEffect(popupLayout) 可以看出 PopupLayout 的显示/消失是由 Popup 函数来控制。
Popup 函数进入当前组合(调用 Popup 函数所在的 Composition)时显示 ,退出当前组合时消失 。
Popup 函数中的 Layout
Layout(
content = {},
modifier = Modifier
.onGloballyPositioned { childCoordinates ->
entCoordinates = childCoordinates.parentLayoutCoordinates!!
//PopupLayout 计算位置时以 parentCoordinates 作为锚点,
popupLayout.updateParentLayoutCoordinates(parentCoordinates)
}
) { _, _ ->
popupLayout.parentLayoutDirection = layoutDirectio
layout(0, 0) {} //宽高都是 0
}
这个 Layout 它的 content 是空实现,宽高也是 0 。作用有两个:
-
定位 Popup 函数父容器的位置,配合 PopupPositionProvider 计算出 PopupLayout 被添加到 Window 中的位置
-
监听父容器在 Window 中位置变化,变化时更新 PopupLayout 的位置
使用 Popup 函数时需要传入 PopupPositionProvider (后面详细介绍) 来计算 PopupLayout 被添加到 Window 中的位置,PopupPositionProvider 在计算时是以父容器为锚点的。
下面的例子分别在 Column 和 Box 显示 Popup
@Composable
fun PopupDemo() {
var showPopup by remember { mutableStateOf(false) }
var alignment by remember { mutableStateOf(Alignment.Center) }
Column(modifier = Modifier.fillMaxSize()) {
Text(text= "In Column:")
Column(modifier = Modifier.fillMaxWidth().height(200.dp))
{
if (showPopup) {
Popup(alignment = alignment) {
Box(modifier = Modifier.size(100.dp).background(Color.LightGray)){
Text(text = "Popup Content", modifier = Modifier.align(Alignment.Center))
}
}
}
Box(modifier = Modifier.fillMaxWidth().height(50.dp).background(Color.Red))
Box(modifier = Modifier.fillMaxWidth().height(50.dp).background(Color.Yellow))
Box(modifier = Modifier.fillMaxWidth().height(50.dp).background(Color.Blue))
Box(modifier = Modifier.fillMaxWidth().height(50.dp).background(Color.Cyan))
}
Row{
Button(onClick = { showPopup = !showPopup }) { Text(text = "Switch Popup") }
Button(onClick = {
alignment = if (alignment == Alignment.Center) Alignment.BottomEnd else Alignment.Center
}) { Text(text = "Change Alignment") }
}
Text(text= "In Box:")
Box(modifier = Modifier.fillMaxWidth().height(200.dp))
{
if (showPopup) {
Popup(alignment = alignment) {
Box(modifier = Modifier.size(100.dp).background(Color.LightGray)){
Text(text = "Popup Content")
}
}
}
Box(modifier = Modifier.fillMaxWidth().height(50.dp).background(Color.Red))
Box(modifier = Modifier.fillMaxWidth().height(50.dp).offset(0.dp,50.dp).background(Color.Yellow))
Box(modifier = Modifier.fillMaxWidth().height(50.dp).offset(0.dp,100.dp).background(Color.Blue)
Box(modifier = Modifier.fillMaxWidth().height(50.dp).offset(0.dp,150.dp).background(Color.Cyan))
}
}
}
Popup 函数参数解析
//常用
@Composable
fun Popup(
alignment: Alignment = Alignment.TopStart,//默认左上对齐
offset: IntOffset = IntOffset(0, 0),//对齐后的位置偏移
onDismissRequest: (() -> Unit)? = null,//请求消失的回调,请求是重点
properties: PopupProperties = PopupProperties(),//设置 Popup 属性,后面详细说
content: @Composable () -> Unit //弹出的内容
)
//上面的函数在内部根据 alignment 生成 popupPositionProvider 调用下面的函数
@Composable
fun Popup(
//提供 Popup content 在父容器中的位置
popupPositionProvider: PopupPositionProvider,
onDismissRequest: (() -> Unit)? = null,
properties: PopupProperties = PopupProperties(),
content: @Composable () -> Uit
)
content
PopupLayout 显示的 Compose UI。
onDismissRequest
PopupLayout 不能控制本身的显示/消失,所以 PopupLayout 中检测到需要消失时只能通过 onDismissRequest() 回调在 Popup 函数所在的组合中执行消失逻辑 (将 Popup 函数移出组合)。
PopupPositionProvider
以 Popup 函数中 Layout 父容器为锚点计算出 PopupLayout 在 Window 中的位置。
interface PopupPositionProvider {
fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSiz
): IntOffset
}
PopupPositionProvider#calculatePosition() 在 PupLayout#updatePosition() 调用来计算 PopupLayout 显示的位置,源码如下
fun updatePosition() {
//parentBounds 的赋值在上面 Layout Modifier#onGloballyPositioned
val parentBounds = parentBounds ?: return
val popupContentSize = popupContentSize ?: return
val windowSize = previousWindowVisibleFrame.let {
popupLayoutHelper.getWindowVisibleDisplayFrame(composeView, it)
val bounds = it.toIntBounds()
IntSize(width = bounds.width, height = bounds.height)
}
//计算位置
val popupPosition = positionProvider.calculatePosition(
parentBounds, //** 锚点
windowSize,
parentLayoutDirection,
popupContentSize
)
//WindowManager.LayoutParams
//设置 offsetX ,offsetY
params.x = popupPosition.x
params.y = popupPosition.y
if (properties.excludeFromSystemGesture) {
popupLayoutHelper.setGestureExclusionRects(this, windowSize.width, windowSize.height)
}
//使用 windowManager 更新 PopupLayout 在 Window 中的 LayoutParams
popupLayoutHelper.updateViewLayout(windowManager, this, params)
}
一般我们不会直接使用这个参数,而是使用 aligment 和 offset 代替 PositionProvider
@Composable
fun Popup(
alignment: Alignment = Alignment.TopStart,
offset: IntOffset = IntOffset(0, 0),
onDismissRequest: (() -> Unit)? = null,
properties: PopupProperties = PopupProperties(),
content: @Composable () -> Unit
) {
//转换成 Compose 已经实现的 PositionProvider
val popupPositioner = remember(alignment, offset) {
AlignmentOffsetPositionProvider(
alignment,
offset
)
}
Popup(
popupPositionProvider = popupPositioner,
onDismissRequest = onDismissRequest,
properties = properties
content = content
)
}
当 AlignmentOffsetPositionProvider 不满足需求时,自定义时一定要记得每个参数具体代表什么。
PopupProperties
设置 PopupLayout 的属性,
class PopupProperties @ExperimentalComposeUiApi constructor(
val focusable: Boolean = false,
val dismissOnBackPress: Boolean = true,
val dismissOnClickOutside: Boolean = true,
val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
val excludeFromSystemGesture: Boolean = true,
val clippingEnabled: Boolean = true,
val usePlatformDefaultWidth: Boolean = false
)
focusable
是否可以捕获 IME 和 按键事件,并不是否可以获取焦点。即便 focusable 是 false PopupLayout 中的 Button 也可以响应点击事件,但 TextField 仅可以获取焦点并不能响应输入事件。
dismissOnBackPress
当 back 键按下时触发 onDismissRequest 回调,前提是 focusable 为 true 时生效
dismissOnClickOutside
点击 PopupLayout 外部时触发 onDismissRequest 回调。PopupLayout 大小就是 content 的大小,有兴趣的可以去查看 PopupLayout 源码。
securePolicy
设置 PopupLayout 所属 Window 隐私策略
enum class SecureFlagPolicy {
Inherit,//跟当前 Window 隐私策略一致
SecureOn,//PopupLayout 添加 WindowManager.LayoutParams.FLAG_SECURE
SecureOff//删除 WindowManager.LayoutParams.FLAG_SECUR
}
默认情况 Window 是没有 FLAG_SECURE 这个 Flag 的 , 设置 SecureOn 后 Home 键回到桌面再看 Recent Task 应用显示的内容就会被隐藏。
excludeFromSystemGesture
PopupLayout 中产生的手势是否排除在系统手势之外,默认 true ,既 PopupLayout 中产生的手势不能触发系统手势,例如 back 导航。
设置为 false
clippingEnabled
当 PopupLayout 显示的 Content 超出屏幕,是否将其放置到屏幕边缘。默认为 true
usePlatformDefaultWidth
是否使用平台默认的最大宽度作为 PopupLayout 的最大宽度,默认是 false PopupLayout 最大宽度是屏幕宽度 ,当为 true时 PopupLayout 最大宽度比屏幕宽度小
Dialog 函数
Dialog 函数不是直接使用 DialogLayout 实现的,函数内部生成的是 DialogWrapper 对象。
@Composable
fun Dialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
content: @Composable () -> Unit
) {
val dialog = remember(view, density) { DialogWrapper() }
}
@OptIn(ExperimentalComposeUiApi::class)
private class DialogWrapper(
private var onDismissRequest: () -> Unit,
private var properties: DialogProperties,
private val composeView: View,
layoutDirection: LayoutDirection,
density: Density,
dialogId: UUID
) : ComponentDialog(){
init {
dialogLayout = DialogLayout(context, window)
setContentView(dialogLayout)
}
fun setContent(parentComposition: CompositionContext, children: @Composable () -> Unit) {
dialogLayout.setContent(parentComposition, children)
}
}
DialogWrapper 将 Dialog 和 DialogLayout 包装在一起, 在 init 时生成 DialogLayout 对象后设置给 Dialog 的 contentView。Compose UI 再设置给 DialogLayout 。
DialogWrapper 继承原生Android 中的 ComponentDialog,所以 Dialog 函数是在现有的 Window 上添加了一个 Window。
DialogLayout 继承 AbstractComposeView ,显示 Dialog 函数 content 参数的 Compose UI。
@Composable
fun Dialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
content: @Composable () -> Unit
)
class DialogProperties @ExperimentalComposeUiApi constructor(
val dismissOnBackPress: Boolean = true,
val dismissOnClickOutside: Boolean = true,
val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
val usePlatformDefaultWidth: Boolean = true,
decorFitsSystemWindows: Boolean = true
)
Dialog(@UiContext @NonNull Context context, @StyleRes int themeResId,
boolean createContextThemeWrapper) {
w.setGravity(Gravity.CENTER);//默认居中
}
Dialog 函数没有设置显示位置的 PositionProvider 参数 ,它不需要监听父容器的位置所以在 Dialog 函数的源码中没有占位的 Layout 。
Dialog 没有设置显示位置相关的实现,所以 Dialog 按照默认居中显示。