Skip to content

Instantly share code, notes, and snippets.

@kibotu
Forked from darvld/CircularReveal.kt
Last active December 22, 2023 13:21
Show Gist options
  • Save kibotu/996eab1b82237a94b2faedc5a90746e7 to your computer and use it in GitHub Desktop.
Save kibotu/996eab1b82237a94b2faedc5a90746e7 to your computer and use it in GitHub Desktop.
A circular reveal effect modifier for Jetpack Compose.
/**
* A modifier that clips the composable content using a circular reveal animation. The circle will
* expand or shrink whenever [isVisible] changes.
*
* For more control over the transition, consider using this method's variant which allows passing
* a [State] object to control the progress of the reveal animation.
*
* By default, the circle is centered in the content. However, custom positions can be specified using
* [revealFrom]. The specified offsets should range from 0 (left/top) to 1 (right/bottom).
*
* @param isVisible Determines whether content is visible or not. If true, circle expands; if false, it shrinks.
* @param revealFrom Custom position from which to start the circular reveal. Default is center of content.
* @param durationMillis Duration of animation in milliseconds. Default is 250ms.
* @param easing Easing function used for animation. Default is EaseInOutSine.
* @param size Size state of component being revealed. Default size is (0, 0).
*/
fun Modifier.circularReveal(
isVisible: Boolean,
revealFrom: Offset = Offset(0.5f, 0.5f),
durationMillis: Int = 250,
easing: Easing = EaseInOutSine,
size: MutableState<IntSize> = mutableStateOf(IntSize(0, 0))
): Modifier =
onGloballyPositioned {
size.value = it.size
}.composed(
factory = {
val animationProgress: State<Float> = animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = durationMillis, easing = easing),
label = ""
)
circularReveal(animationProgress, revealFrom / size.value.toSize())
},
inspectorInfo = debugInspectorInfo {
name = "circularReveal"
properties["visible"] = isVisible
properties["revealFrom"] = revealFrom
properties["durationMillis"] = durationMillis
}
)
/**
* A modifier that applies a circular reveal animation to the composable content using a transition progress state.
* The radius of the circle used for clipping will be calculated based on the transition progress.
*
* @param transitionProgress The state of progress for the transition. This determines the radius of the circle used for clipping.
* @param revealFrom The position from which to start the circular reveal. Default is center of content.
*/
private fun Modifier.circularReveal(
transitionProgress: State<Float>,
revealFrom: Offset = Offset(0.5f, 0.5f)
): Modifier = drawWithCache {
val path = Path()
val center = revealFrom * size
val radius = calculateRadius(revealFrom, size)
path.addOval(Rect(center, radius * transitionProgress.value))
onDrawWithContent {
clipPath(path) { [email protected]() }
}
}
operator fun Offset.times(size: Size): Offset = Offset(x * size.width, y * size.height)
operator fun Offset.div(size: Size): Offset {
val dx = if (size.width == 0f) x else x / size.width
val dy = if (size.height == 0f) y else y / size.height
return Offset(dx, dy)
}
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
val x = (if (x > 0.5f) x else 1 - x) * size.width
val y = (if (y > 0.5f) y else 1 - y) * size.height
sqrt(x * x + y * y)
}
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun DraggableCircle(
modifier: Modifier = Modifier,
onTap: (Offset) -> Unit
) {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
val position = remember { mutableStateOf(Offset(0f, 0f)) }
Box(
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.onGloballyPositioned {
position.value = it.positionInRoot()
}
.pointerInput(Unit) {
detectTapGestures { onTap(it + position.value) }
}
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
.clip(RoundedCornerShape(25.dp))
.then(modifier)
)
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.check24.profis.partner.shared.material3.theme.AppTheme
@Composable
private fun RevealTest() {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
) {
val isVisible = remember { mutableStateOf(false) }
val revealFrom = remember { mutableStateOf(Offset(0f, 0f)) }
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.error)
.fillMaxWidth()
.height(150.dp)
)
Box(
modifier = Modifier
.circularReveal(
isVisible = isVisible.value,
revealFrom = revealFrom.value
)
.background(MaterialTheme.colorScheme.primary)
.fillMaxWidth()
.height(300.dp)
) {
}
DraggableCircle(
modifier = Modifier
.background(MaterialTheme.colorScheme.tertiary)
.size(50.dp)
.align(Alignment.CenterEnd)
) {
revealFrom.value = it
isVisible.value = !isVisible.value
}
}
}
@Preview(showBackground = true)
@Composable
private fun RevealTestPreview() {
AppTheme {
RevealTest()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment