Skip to content

Instantly share code, notes, and snippets.

@bmonjoie
Last active October 19, 2023 14:56
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save bmonjoie/8506040b2ea534eac931378348622725 to your computer and use it in GitHub Desktop.
Save bmonjoie/8506040b2ea534eac931378348622725 to your computer and use it in GitHub Desktop.
Compose Circular Reveal
import android.graphics.Path
import android.view.MotionEvent
import androidx.annotation.FloatRange
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.LayoutScopeMarker
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
@Composable
fun <T> CircularReveal(
targetState: T,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<Float> = tween(),
content: @Composable CircularRevealScope.(T) -> Unit
) {
val items = remember { mutableStateListOf<CircularRevealAnimationItem<T>>() }
val transitionState = remember { MutableTransitionState(targetState) }
val targetChanged = (targetState != transitionState.targetState)
var offset: Offset? by remember { mutableStateOf(null) }
transitionState.targetState = targetState
val transition = updateTransition(transitionState, label = "transition")
if (targetChanged || items.isEmpty()) {
// Only manipulate the list when the state is changed, or in the first run.
val keys = items.map { it.key }.run {
if (!contains(targetState)) {
toMutableList().also { it.add(targetState) }
} else {
this
}
}
items.clear()
keys.mapIndexedTo(items) { index, key ->
CircularRevealAnimationItem(key) {
val progress by transition.animateFloat(
transitionSpec = { animationSpec }, label = ""
) {
if (index == keys.size - 1) {
if (it == key) 1f else 0f
} else 1f
}
Box(Modifier.circularReveal(progress = progress, offset = offset)) {
with(CircularRevealScope) {
content(key)
}
}
}
}
} else if (transitionState.currentState == transitionState.targetState) {
// Remove all the intermediate items from the list once the animation is finished.
items.removeAll { it.key != transitionState.targetState }
}
Box(modifier.pointerInteropFilter {
offset = when (it.action) {
MotionEvent.ACTION_DOWN -> Offset(it.x, it.y)
else -> null
}
false
}) {
items.forEach {
key(it.key) {
it.content()
}
}
}
}
private data class CircularRevealAnimationItem<T>(
val key: T,
val content: @Composable () -> Unit
)
fun Modifier.circularReveal(@FloatRange(from = 0.0, to = 1.0) progress: Float, offset: Offset? = null) = then(
clip(CircularRevealShape(progress, offset))
)
private class CircularRevealShape(
@FloatRange(from = 0.0, to = 1.0) private val progress: Float,
private val offset: Offset? = null
) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
return Outline.Generic(Path().apply {
addCircle(
offset?.x ?: (size.width / 2f),
offset?.y ?: (size.height / 2f),
longestDistanceToACorner(size, offset) * progress,
Path.Direction.CW
)
}.asComposePath())
}
private fun longestDistanceToACorner(size: Size, offset: Offset?): Float {
if (offset == null) {
return hypot(size.width /2f, size.height / 2f)
}
val topLeft = hypot(offset.x, offset.y)
val topRight = hypot(size.width - offset.x, offset.y)
val bottomLeft = hypot(offset.x, size.height - offset.y)
val bottomRight = hypot(size.width - offset.x, size.height - offset.y)
return topLeft.coerceAtLeast(topRight).coerceAtLeast(bottomLeft).coerceAtLeast(bottomRight)
}
}
@LayoutScopeMarker
@Immutable
object CircularRevealScope
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import be.xzan.themerevealeffect.ui.theme.AppTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
var darkMode by remember { mutableStateOf(false) }
CircularReveal(targetState = darkMode, animationSpec = tween(500)) { localTheme ->
AppTheme(localTheme) {
Column(modifier = Modifier
.background(MaterialTheme.colors.background)
.padding(8.dp)
.fillMaxSize()
) {
Icon(if (localTheme) {
Icons.Default.Star
} else Icons.Default.Person,
"Toggle",
Modifier.clickable { darkMode = !darkMode },
tint = MaterialTheme.colors.onBackground)
Greeting("Android")
}
}
}
}
}
}
@Composable
fun Greeting(name: String) {
Surface(color = MaterialTheme.colors.background) {
Text(text = "Hello $name!")
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
AppTheme {
Greeting("Android")
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment