20 写一个可以在 Compose 外控制的弹层组件

582 阅读4分钟

18章开始学习 Compose 中的弹层组件,Snackbar 和 NavigationDrawer 这两个是插槽形式实现的,Popup 和 Dialog 基于 AbstractComposeView 显示的内容在子组合中,但控制和显示还是在原来的组合中。前两个布局是组件中固定的,后面两个使用时都要先在 @Composable 函数中写好,然后再利用 State 来控制。

像这样的

@Composable
fun PopupDemo() {
    var showPopup by remember { mutableStateOf(false) }
    Column(modifier = Modifier.fillMaxWidth().height(200.dp))
    {
        if (showPopup) {
            Popup(alignment = Alignment.Center) {
                Box(modifier = Modifier.size(100.dp).background(Color.LightGray)) {
                    Text(text = "Popup Content")
                }
            }
        }
        Box(modifier = Modifier.fillMaxWidth().height(100.dp).background(Color.Red))
        Box(modifier = Modifier.fillMaxWidth().height(100.dp).background(Color.Yellow))
        Button(onClick = { showPopup = !showPopup }) { Text(text = "Switch Popup") }
    }
}

利用前面几章学习的内容

AbstractComposeView:

组件需要一个跟 Popup 类似的 Layout ,通过 windowManager add/remove view 实现显示隐藏并且在 Layout 中显示弹出的 Compose UI 。

CompositionContext:

组件中能够使用项目中的主题

remember() + RememberOberver

保证一个 window 中只有一个这样的 Layout 实例 (显示不同 UI 时 给它 set 不同的 content,不需要重复生成)

自动销毁生成的 Layout 实例

来自定义一个可以这样在 Compose 中控制显示隐藏的组件

HUD.showLoading(modal = false)
HUD.dismiss()

Untitled.gif

这个组件主要是为了加深 19 章内容的理解,实现一种外部控制 Compose 的方式,不要拿来直接使用,没测试过!!! 不要拿来直接使用,没测试过!!! 不要拿来直接使用,没测试过!!!

组件核心

实现显示/隐藏功能,和自动管理功能。

HudLayout

模仿 PopupLayout 实现显示隐藏、设置 content 功能

interface HudLayout {
    val composeViewWindowToken: IBinder
    val isShowing: State<Boolean>

    fun setContent(parent: CompositionContext? = null, content: @Composable () -> Unit)

    fun show()
    fun dismiss()
    fun dispose()

    fun resetLayoutParams()
    fun setIsFocusable(isFocusable: Boolean)
    fun setLayoutGravity(@GravityInt gravity: Int)
    fun setLayoutOffset(offset: IntOffset)
}


internal class HudLayoutImpl(
    composeView: View,
    layoutId: UUID,
) : AbstractComposeView(composeView.context), HudLayout {

    override val composeViewWindowToken: IBinder = composeView.applicationWindowToken

    init {
        ViewTreeLifecycleOwner.set(this, ViewTreeLifecycleOwner.get(composeView))
        ViewTreeViewModelStoreOwner.set(this, ViewTreeViewModelStoreOwner.get(composeView))
        setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
        setTag(R.id.compose_view_saveable_id_tag, "HudLayout:$layoutId")
    }


    private var _showing = mutableStateOf(false)

    override val isShowing: State<Boolean>
        get() = _showing

    private val windowManager =
        composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager

    private var windowParams = createLayoutParams()

    private var content: @Composable () -> Unit by mutableStateOf({})

    override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
        private set

    @Composable
    override fun Content() {
        content()
    }

    override fun setContent(parent: CompositionContext?, content: @Composable () -> Unit) {

        parent?.let {
            setParentCompositionContext(it)
        }

        shouldCreateCompositionOnAttachedToWindow = true

        this.content = content

        if (isAttachedToWindow) {
            createComposition()
        }
    }

    override fun show() {
        if (_showing.value) {
            dismiss()
        }
        _showing.value = true
        windowManager.addView(this, windowParams)

    }

    override fun dismiss() {
        if (!_showing.value) return
        _showing.value = false
        disposeComposition()
        windowManager.removeViewImmediate(this)
    }

    override fun dispose() {
        disposeComposition()
        ViewTreeLifecycleOwner.set(this, null)
        ViewTreeViewModelStoreOwner.set(this, null)
        setViewTreeSavedStateRegistryOwner(null)
        if (_showing.value) {
            windowManager.removeViewImmediate(this)
        }
    }

    override fun resetLayoutParams() {
        windowParams = createLayoutParams()
        updateWindowParams()
    }

    private fun createLayoutParams(): WindowManager.LayoutParams {
        return WindowManager.LayoutParams().apply {
            gravity = Gravity.CENTER

            type = WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL
            token = composeViewWindowToken

            width = WindowManager.LayoutParams.WRAP_CONTENT
            height = WindowManager.LayoutParams.WRAP_CONTENT
            format = PixelFormat.TRANSLUCENT
        }
    }

    override fun setIsFocusable(isFocusable: Boolean) {
        windowParams.flags = if (!isFocusable) {
            windowParams.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
        } else {
            windowParams.flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
        }
        updateWindowParams()
    }

    override fun setLayoutGravity(@GravityInt gravity: Int) {
        windowParams.gravity = gravity
        updateWindowParams()
    }

    override fun setLayoutOffset(offset: IntOffset) {
        windowParams.x = offset.x
        windowParams.y = offset.y
        updateWindowParams()
    }

    private fun updateWindowParams(){
        if(isAttachedToWindow){
            windowManager.updateViewLayout(this,windowParams)
        }
    }

}

抽出 HudLayout 接口,将显示无关的功能放到 warpper 中

LayoutWrapper

internal class LayoutWrapper(
    private val layout: HudLayoutImpl,
    private val parentContext: CompositionContext,
    val coroutineScope: CoroutineScope, // toast 功能使用的协程 scope
) : RememberObserver,HudLayout by layout{

    override fun setContent(parent: CompositionContext?,content: @Composable () -> Unit) {
        layout.setContent(parentContext,content)
    }
    
    //利用 RememberObserver 实现自动 dispose
    override fun onAbandoned() {
        dispose()
    }

    override fun onForgotten() {
        dispose()
    }

    override fun onRemembered() {}
}

HudLayoutManager

管理 Layout , 使用当前 window 中的 layout 来显示弹层

internal object HudLayoutManager {

    private val layouts = mutableListOf<LayoutWrapper>()
    private var currentLayout: HudLayout? = null
    
    fun show(
        isFocusable: Boolean = true,
        @GravityInt gravity: Int = Gravity.CENTER,
        offset: IntOffset = IntOffset.Zero,
        content: @Composable (State<Boolean>) -> Unit
    ) {
        currentLayout?.let {
            if (it.isShowing.value) {
                it.dismiss()
            }
            it.setContent{
                content(it.isShowing)
            }
            it.resetLayoutParams()
            it.setIsFocusable(isFocusable)
            it.setLayoutGravity(gravity)
            it.setLayoutOffset(offset)
            it.show()
        }
    }

    fun toast(
        @GravityInt gravity: Int = Gravity.CENTER,
        offset: IntOffset = IntOffset.Zero,
        @IntRange(0, 1) duration: Int = 0,
        content: @Composable (State<Boolean>) -> Unit
    ) {
        show(false,gravity,offset,content)
        (currentLayout as LayoutWrapper?)?.let {
            it.coroutineScope.launch {
                val delay = if (duration == 0) 500L else 1_000L
                delay(delay)
                dismiss()
            }
        }
    }

    fun dismiss() {
        currentLayout?.dismiss()
    }
	//判断 view 所在的 window 中是否需要创建新的 Layout
    fun needNewLayout(view: View): Boolean {
        val token = view.applicationWindowToken
        return layouts.find { it.composeViewWindowToken == token } == null
    }

    fun newLayout(
        composeView: View,
        layoutID: UUID,
        parentContext: CompositionContext,
        coroutineScope: CoroutineScope
    ): LayoutWrapper {

        val layout = HudLayoutImpl(composeView, layoutID)
        val wrapper = LayoutWrapper(layout, parentContext, coroutineScope)
      	//监听 composeView lifecycle 自动在 manager 中添加删除layout
        composeView.findViewTreeLifecycleOwner()!!.lifecycle.addObserver(object :
            LifecycleEventObserver {
            override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
                if (event == Lifecycle.Event.ON_RESUME) {
                    currentLayout = wrapper
                } else if (event == Lifecycle.Event.ON_DESTROY) {
                    layouts.remove(wrapper)
                    if (currentLayout == wrapper) {
                        currentLayout = null
                    }
                }
            }
        })

        layouts.add(wrapper)
        return wrapper
    }
}

实现共用主题和 LayoutWrapper 的自动 dispose()

定义 remember 方法并在项目主题中使用

@Composable
fun rememberHudLayout(){
    val composeView = LocalView.current
  //防止同一个 window 重复生成 layout
    if (HudLayoutManager.needNewLayout(composeView)){
        val parentContext = rememberCompositionContext()
        val layoutId = remember{ UUID.randomUUID()}
        val coroutineScope = rememberCoroutineScope()
        remember(parentContext,layoutId) {
            HudLayoutManager.newLayout(composeView,layoutId,parentContext,coroutineScope)
        }
    }
}

修改项目中的主题

@Composable
fun ComposeHudTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb()
            ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme
        }
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,

    ){
      	//放到 MaterialTheme 中,CompositionLocal 通过 CompositionContext 
      	//传递给 Layout 中的子组合
        rememberHudLayout() 
        content()
    }
}
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeHudTheme {
                App()
            }
        }
    }
}

Activity setContent 中使用 ComposeHudTheme ,调用 rememberHudLayout() 会自动生成一个 layout 添加到 manager 中。

onDestroy() 时 layout 自动从 manager 中移除。

Activity 中的父组合 dispose 时 ,LayoutWrapper 通过 RememberObserver 接口自动 dispose。

提供对外使用的 Api

核心中除了大部分都是 internal 修饰的,定义组件对外API

HudComposables

组件默认实现的 ui

@Composable
internal fun LoadingHud(visible: Boolean ) { //想实现显示/隐藏动画,没成功 T_T
    val bgColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)

    val infiniteTransition = rememberInfiniteTransition()

    val rotate by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(300),
            repeatMode = RepeatMode.Restart
        )
    )

    Surface(
        color = bgColor,
        shape = RoundedCornerShape(4.dp),
    ) {
        Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
            Image(
                painter = painterResource(id = R.drawable.loading),
                contentDescription = "loading",
                modifier = Modifier.graphicsLayer {
                    rotationZ = rotate
                }
            )
        }
    }
}

@Composable
internal fun ToastHud(msg:String){
    val bgColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)

    Surface(
        color = bgColor,
        shape = RoundedCornerShape(4.dp)
    ) {
        Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
            Text(text = msg, modifier = Modifier.align(Alignment.Center), textAlign = TextAlign.Center)
        }
    }
}

Hud

object HUD {

    fun showLoading(modal:Boolean = true) {
        popup(isFocusable = modal) {
            LoadingHud(it.value)
        }
    }

    fun toastMessage(
        message: String,
        @IntRange(0, 1)duration: Int = Toast.LENGTH_SHORT,

    ) {
        toast(duration = duration) {
            ToastHud(msg = message)
        }
    }

    fun popup(
        isFocusable: Boolean = true,
        @GravityInt gravity: Int = Gravity.CENTER,
        offset: IntOffset = IntOffset.Zero,
        content: @Composable (State<Boolean>) -> Unit
    ) {
        HudLayoutManager.show(isFocusable, gravity, offset, content)
    }

    fun toast(
        @GravityInt gravity: Int = Gravity.CENTER,
        offset: IntOffset = IntOffset.Zero,
        @IntRange(0, 1) duration: Int = Toast.LENGTH_SHORT,
        content: @Composable (State<Boolean>) -> Unit,

    ) {
        HudLayoutManager.toast(gravity,offset,duration,content)
    }

    fun dismiss(){
        HudLayoutManager.dismiss()
    }
}

使用组件的项目中就可以这样调用啦

HUD.toastMessage("Message", Toast.LENGTH_LONG)
HUD.showLoading(modal = false)
HUD.dismiss()

还可以用 popup 或 toast 显示隐藏自定义的 Compose UI

不足:

没有显示隐藏动画

没处理 back 事件和 touch 事件

没有处理显示位置

没有测试功能肯定有 BUG

等等

分享一下思路  源码