-
-
Save kibotu/996eab1b82237a94b2faedc5a90746e7 to your computer and use it in GitHub Desktop.
A circular reveal effect modifier for Jetpack Compose.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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