最近在用compose做一个WebView的阅读模式,突然想起这么一个动画效果,用作阅读页的过渡效果,在此记录一下,首先看一下效果。
实现原理
原理其实很简单,主要是通过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)
}