Compose实现圆形扩散(Circular Reveal)过渡动画

211 阅读4分钟

最近在用compose做一个WebView的阅读模式,突然想起这么一个动画效果,用作阅读页的过渡效果,在此记录一下,首先看一下效果。

Screen_recording_20241231_151503.gif

实现原理

原理其实很简单,主要是通过Modifier的drawWithCache实现,在drawContent之前,使用clipPath裁剪成圆形。有过自定义view的经历应该对clipPath都不陌生,clipPath可以将绘制区域裁剪成各种形状,下面看一看代码实现。

fun Modifier.circularReveal(
    //动画进度,取值在0-1f
    progress: State<Float>,
    //开始偏移量,取值0-1f,初始值0.5f,即画布中心点
    pivot: Offset = Offset(0.5f, 0.5f)
): Modifier {
    return drawWithCache {
        val path = Path()

        //计算中心点
        val center = calculateCenter(pivot, size)
        //计算扩散圆形半径
        val radius = calculateRadius(pivot, size)

        path.addOval(Rect(center, radius * progress.value))

        onDrawWithContent {
            clipPath(path) { this@onDrawWithContent.drawContent() }
        }
    }
}

private fun calculateCenter(pivot: Offset, size: Size) = with(pivot) {
    Offset(x * size.width, y * size.height)
}

//函数的目的是确保圆形裁剪的半径足够大,能够完全覆盖 Composable 的内容,即使中心点不在 Composable 的正中央,通过计算中心点到 Composable 四个角中最远那个角的距离来实现这一点
private fun calculateRadius(pivot: Offset, size: Size) = with(pivot) {
    val x = (if (x > 0.5f) x else 1 - x) * size.width
    val y = (if (y > 0.5f) y else 1 - y) * size.height

    //使用勾股定理,计算出中心点到 Composable 四个角 中最远那个角的距离,作为圆形裁剪的半径
    sqrt(x * x + y * y)
}

上面定义了一个Modifier的扩展函数,下面就可以愉快的使用扩散动画了。不得不说compose某些方面还是很有优势,一些功能实现起来比自定义view简单多了。

使用方法

@Composable
fun HtmlReaderScreen(contentFlow: StateFlow<HtmlContent>, onDismissRequest: () -> Unit) {
    val content by contentFlow.collectAsStateWithLifecycle()

    val animatable = remember { Animatable(0f) }

    val coroutineScope = rememberCoroutineScope()

    BackHandler(true) {
        coroutineScope.launch {
            //结束动画
            animatable.animateTo(0f, animationSpec = tween(600))
            //等待动画结束
            onDismissRequest()
        }
    }

    LaunchedEffect(content) {
        if (!content.isEmpty) {
            //出现动画
            animatable.animateTo(1f, animationSpec = tween(600))
        }
    }

    //这里注意,背景设置的先后顺序,background必须放到circularReveal之后,不明白的可以去了解一下Modifier操作符先后顺序对compose的影响
    Box(
        modifier = Modifier
            .fillMaxSize()
            .circularReveal(animatable.asState())
            .background(MaterialTheme.colorScheme.background),
        contentAlignment = Alignment.Center
    ) {
        if (!content.isEmpty) {
            WebView(
                state = rememberWebViewStateWithHTMLData(content.html),
                captureBackPresses = false,
                factory = DefaultWebViewFactory,
                modifier = Modifier.fillMaxSize()
            )
        }
    }
}

@Compose
fun BrowserScreen(viewModel: BrowserViewModel) {
    //.....
    
    var readMode by remember{ mutableStateOf(false) } 
    Button(
        onClick = {
            readMode = true
        }
    ) {
        Text(text = "阅读模式")
    }
    
    if (viewModel.readMode) {
        SurfaceWindow(
            properties = SurfaceWindowDefaults.properties(
                captureBackPresses = true
            ),
            onDismissRequest = {
                readMode = false
            },
        ) {
            HtmlReaderScreen(viewModel.htmlContent) {
                it.dismiss()
            }
        }
    }
}

用法大致就是这样了,代码都有注释,就不赘述了。下面再贴一下SurfaceWindow的相关代码,这个是我根据dialog原理,封装的一个悬浮窗组件,感兴趣的也可以看一下。

data class SurfaceWindowState(
    internal val transition: ContentTransition?,
    private val onDismissRequest: () -> Unit
) : ContentDismissal {

    var isVisible by mutableStateOf(false)
        private set

    internal fun animateShow() {
        isVisible = true
    }

    override fun dismiss() {
        if (transition != null) {
            isVisible = false
        } else {
            onDismissRequest()
        }
    }

}

@Composable
fun SurfaceWindow(
    modifier: Modifier = Modifier,
    onDismissRequest: () -> Unit = {},
    properties: SurfaceWindowProperties = SurfaceWindowDefaults.properties(),
    shape: Shape = RectangleShape,
    containerColor: Color = SurfaceWindowDefaults.ContainerColor,
    contentColor: Color = contentColorFor(containerColor),
    tonalElevation: Dp = 0.dp,
    contentWindowInsets: WindowInsets = SurfaceWindowDefaults.windowInsets,
    contentTransition: () -> ContentTransition? = { null },
    content: @Composable BoxScope.(ContentDismissal) -> Unit,
) {
    val state = remember(contentTransition, onDismissRequest) {
        SurfaceWindowState(contentTransition(), onDismissRequest)
    }

    val wrappedContent: @Composable () -> Unit = {
        Surface(
            modifier = modifier
                .fillMaxSize()
                .imePadding(),
            shape = shape,
            color = containerColor,
            contentColor = contentColor,
            tonalElevation = tonalElevation,
        ) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .then(
                        if (properties.fullscreen) Modifier.windowInsetsPadding(
                            contentWindowInsets
                        ) else Modifier
                    )
            ) {
                content(state)
            }
        }
    }

    BasicSurfaceWindow(
        onDismissRequest = state::dismiss,
        properties = properties,
    ) {
        if (state.transition != null) {
            LaunchedEffect(Unit) {
                state.animateShow()
            }
            AnimatedVisibility(
                visible = state.isVisible,
                enter = state.transition.enter,
                exit = state.transition.exit,
                modifier = Modifier.fillMaxSize()
            ) {
                wrappedContent()

                DisposableEffect(Unit) {
                    onDispose(onDismissRequest)
                }
            }
        } else {
            wrappedContent()
        }
    }
}

@Composable
fun BasicSurfaceWindow(
    onDismissRequest: () -> Unit = {},
    properties: SurfaceWindowProperties = SurfaceWindowDefaults.properties(),
    content: @Composable () -> Unit,
) {
    val view = LocalView.current
    val parentComposition = rememberCompositionContext()
    val currentContent by rememberUpdatedState(content)
    val layoutDirection = LocalLayoutDirection.current

    val surfaceWindow = remember {
        SurfaceWindow(
            properties = properties,
            onDismissRequest = onDismissRequest,
            parentView = view,
        ).apply {
            setContent(
                parent = parentComposition,
                content = currentContent
            )
        }
    }

    DisposableEffect(surfaceWindow) {
        surfaceWindow.show()
        surfaceWindow.superSetLayoutDirection(layoutDirection)
        onDispose {
            surfaceWindow.disposeComposition()
            surfaceWindow.dismiss()
        }
    }
}

@SuppressLint("ViewConstructor")
private class SurfaceWindow(
    private val properties: SurfaceWindowProperties,
    private var onDismissRequest: () -> Unit,
    private val parentView: View,
) : FrameLayout(parentView.context), OnBackPressedDispatcherOwner {

    private var backCallback: Any? = null

    private val composeView = ComposeView(parentView.context)

    private val lifecycleOwner = parentView.findViewTreeLifecycleOwner()
    override val lifecycle: Lifecycle
        get() = requireNotNull(lifecycleOwner?.lifecycle)

    override val onBackPressedDispatcher: OnBackPressedDispatcher = OnBackPressedDispatcher()

    init {
        id = android.R.id.content
        // Set up view owners
        setViewTreeLifecycleOwner(lifecycleOwner)
        setViewTreeViewModelStoreOwner(parentView.findViewTreeViewModelStoreOwner())
        setViewTreeSavedStateRegistryOwner(parentView.findViewTreeSavedStateRegistryOwner())
        setViewTreeOnBackPressedDispatcherOwner(this)

        // Enable children to draw their shadow by not clipping them
        clipChildren = false

        addView(composeView)
    }

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

    private val params = WindowManager.LayoutParams().apply {
        val (screenWidth, screenHeight) = if (properties.softInputNeeded
            && Build.VERSION.SDK_INT < Build.VERSION_CODES.R
        ) {
            context.getScreenSize(false)
        } else {
            context.getScreenSize(properties.fullscreen)
        }

        width = screenWidth
        height = screenHeight

        format = PixelFormat.TRANSLUCENT
        gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL

        when (properties.windowType) {
            SurfaceWindowType.Application -> {
                type = WindowManager.LayoutParams.TYPE_APPLICATION
                // Get the Window token from current activity
                token = parentView.context.activity?.window?.attributes?.token
            }

            SurfaceWindowType.ApplicationSubPanel -> {
                type = WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL
                // Get the Window token from the parent view
                token = parentView.run { applicationWindowToken ?: windowToken }
            }

            SurfaceWindowType.ApplicationPanel -> {
                type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
                // Get the Window token from the parent view
                token = parentView.run { applicationWindowToken ?: windowToken }
            }

            SurfaceWindowType.ApplicationAttachedDialog -> {
                type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG
                // Get the Window token from the parent view
                token = parentView.run { applicationWindowToken ?: windowToken }
            }

            SurfaceWindowType.ApplicationOverlay -> {
                type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
                } else {
                    WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY
                }
            }

            SurfaceWindowType.AccessibilityOverlay -> {
                type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
            }
        }

        // Security flag
        val secureFlagEnabled = properties.securePolicy
            .shouldApplySecureFlag(parentView.isFlagSecureEnabled())
        flags = if (secureFlagEnabled) {
            flags or WindowManager.LayoutParams.FLAG_SECURE
        } else {
            flags and WindowManager.LayoutParams.FLAG_SECURE.inv()
        }

        if (properties.fullscreen && (!properties.softInputNeeded || Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)) {
            flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
        }

        flags = flags or (
                WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
                        or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
                        or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
                )

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
        } else if (properties.softInputNeeded) {
            @Suppress("DEPRECATION")
            softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
        }
    }

    fun setContent(
        parent: CompositionContext? = null,
        content: @Composable () -> Unit
    ) {
        parent?.let { composeView.setParentCompositionContext(it) }
        composeView.setContent(content)
    }

    fun disposeComposition() {
        composeView.disposeComposition()
    }

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

    fun dismiss() {
        setViewTreeLifecycleOwner(null)
        setViewTreeViewModelStoreOwner(null)
        setViewTreeSavedStateRegistryOwner(null)
        windowManager.removeViewImmediate(this)
    }

    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
        if (event.keyCode == KeyEvent.KEYCODE_BACK && properties.captureBackPresses) {
            if (keyDispatcherState == null) {
                return super.dispatchKeyEvent(event)
            }
            if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
                val state = keyDispatcherState
                state?.startTracking(event, this)
                return true
            } else if (event.action == KeyEvent.ACTION_UP) {
                val state = keyDispatcherState
                if (state != null && state.isTracking(event) && !event.isCanceled) {
                    onBackPressed()
                    return true
                }
            }
        }
        return super.dispatchKeyEvent(event)
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()

        maybeRegisterBackCallback()
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()

        maybeUnregisterBackCallback()
    }

    private fun maybeRegisterBackCallback() {
        if (!properties.captureBackPresses || Build.VERSION.SDK_INT < 33) {
            return
        }
        if (backCallback == null) {
            backCallback = Api33Impl.createBackCallback(::onBackPressed)
        }
        Api33Impl.maybeRegisterBackCallback(
            this,
            backCallback
        )
    }

    private fun maybeUnregisterBackCallback() {
        if (Build.VERSION.SDK_INT >= 33) {
            Api33Impl.maybeUnregisterBackCallback(
                this,
                backCallback
            )
        }
        backCallback = null
    }

    private fun onBackPressed() {
        if (onBackPressedDispatcher.hasEnabledCallbacks()) {
            onBackPressedDispatcher.onBackPressed()
            return
        }

        onDismissRequest()
    }

    // Sets the "real" layout direction for our content that we obtain from the parent composition.
    fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
        val direction = when (layoutDirection) {
            LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
            LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
        }
        super.setLayoutDirection(direction)
    }


    @RequiresApi(33)
    private object Api33Impl {
        @JvmStatic
        @DoNotInline
        fun createBackCallback(onDismissRequest: () -> Unit) =
            OnBackInvokedCallback(onDismissRequest)

        @JvmStatic
        @DoNotInline
        fun maybeRegisterBackCallback(view: View, backCallback: Any?) {
            if (backCallback is OnBackInvokedCallback) {
                view.findOnBackInvokedDispatcher()?.registerOnBackInvokedCallback(
                    OnBackInvokedDispatcher.PRIORITY_OVERLAY,
                    backCallback
                )
            }
        }

        @JvmStatic
        @DoNotInline
        fun maybeUnregisterBackCallback(view: View, backCallback: Any?) {
            if (backCallback is OnBackInvokedCallback) {
                view.findOnBackInvokedDispatcher()?.unregisterOnBackInvokedCallback(backCallback)
            }
        }
    }

}

object SurfaceWindowDefaults {

    val ContainerColor: Color = Color.Transparent

    val windowInsets: WindowInsets
        @Composable get() = WindowInsets.systemBars

    @Composable
    fun properties(
        windowType: SurfaceWindowType = LocalSurfaceWindowType.current,
        securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
        fullscreen: Boolean = true,
        softInputNeeded: Boolean = false,
        captureBackPresses: Boolean = false
    ) = SurfaceWindowProperties(
        windowType = windowType,
        securePolicy = securePolicy,
        fullscreen = fullscreen,
        softInputNeeded = softInputNeeded,
        captureBackPresses = captureBackPresses
    )

}

data class SurfaceWindowProperties(
    val windowType: SurfaceWindowType,
    val securePolicy: SecureFlagPolicy,
    val fullscreen: Boolean,
    val softInputNeeded: Boolean,
    val captureBackPresses: Boolean
)

data class ContentTransition(
    val enter: EnterTransition,
    val exit: ExitTransition
) {

    companion object {
        infix fun EnterTransition.and(exit: ExitTransition) = ContentTransition(this, exit)
    }

}

enum class SurfaceWindowType {
    Application,
    ApplicationSubPanel,
    ApplicationPanel,
    ApplicationAttachedDialog,
    ApplicationOverlay,
    AccessibilityOverlay
}

val LocalSurfaceWindowType = staticCompositionLocalOf { SurfaceWindowType.ApplicationPanel }

@Composable
fun ProvideSurfaceWindowType(type: SurfaceWindowType, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalSurfaceWindowType provides type) {
        content()
    }
}

// Taken from AndroidPopup.android.kt
private fun View.isFlagSecureEnabled(): Boolean {
    val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
    if (windowParams != null) {
        return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
    }
    return false
}

// Taken from AndroidPopup.android.kt
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
    return when (this) {
        SecureFlagPolicy.SecureOff -> false
        SecureFlagPolicy.SecureOn -> true
        SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
    }
}

private fun Context.getScreenSize(fullscreen: Boolean): IntSize {
    if (!fullscreen) {
        val dm = resources.displayMetrics
        return IntSize(dm.widthPixels, dm.heightPixels)
    }

    val windowManager = ContextCompat.getSystemService(this, WindowManager::class.java)
    if (windowManager != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        windowManager.maximumWindowMetrics.bounds.run {
            return IntSize(width(), height())
        }
    }
    val screenSize = Point()
    val display = ContextCompat.getDisplayOrDefault(this)
    display.getRealSize(screenSize)
    return IntSize(screenSize.x, screenSize.y)
}