19.4 Compose 中 Popup 和 Dialog

2,777 阅读6分钟

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 函数源码可以分成三部分

  1. 创建 PopupLayout 对象,将 Compose UI 设置到 PopupLayout.content 中

  2. 使用 Effects Api 控制 PopupLayout 显示/消失 和位置更新

  3. 向当前 UI 中添加一个宽高都是 0  的 LayoutNode

PopupLayout 开启初始组合

PopupLayout

    fun show() {
        windowManager.addView(this, params)
    }

    fun dismiss() {
        ViewTreeLifecycleOwner.set(this, null)
        windowManager.removeViewImmediate(this)
    }
  1. PopupLayout 的显示是通过 WindowManager 向当前 DecorView 添加子 View 来实现。
  1. ViewGroup.addView() 添加子 View 时会执行    child.dispatchAttachedToWindow() 
  1. 触发 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 。作用有两个:

  1. 定位 Popup 函数父容器的位置,配合 PopupPositionProvider 计算出 PopupLayout 被添加到 Window 中的位置

  2. 监听父容器在  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))

        }
    }
}

Untitled.gif

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 应用显示的内容就会被隐藏。

59BFE4A8-512C-4E8A-86B9-26D36D79BC37.png

excludeFromSystemGesture

PopupLayout 中产生的手势是否排除在系统手势之外,默认 true ,既 PopupLayout 中产生的手势不能触发系统手势,例如 back 导航。

Untitled.gif

设置为 false

Untitled.gif

clippingEnabled

当 PopupLayout 显示的 Content 超出屏幕,是否将其放置到屏幕边缘。默认为 true

02072FEB-C48C-46CD-BE12-18AC11C2EE68.png

usePlatformDefaultWidth

是否使用平台默认的最大宽度作为 PopupLayout 的最大宽度,默认是 false PopupLayout 最大宽度是屏幕宽度 ,当为 true时 PopupLayout 最大宽度比屏幕宽度小

C2895CA4-E7B3-49E1-8B5E-56B26CA6E4C4.png

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 按照默认居中显示。