A physics-based circular reveal animation system for Jetpack Compose
An experimental Android project exploring advanced animation techniques with custom modifiers and spring-driven physics
CircularRevealDemo.mp4
chatcircularReveal.mp4
This project demonstrates a sophisticated circular reveal animation system built from scratch for Jetpack Compose. UI elements can reveal with fluid physics-based animations from any origin point, with directional item animations and customizable spring configurations.
Built as a personal exploration of:
- Advanced Jetpack Compose animation APIs and custom modifier creation
- Physics-based UI transitions with spring dynamics
- Complex geometry calculations for position-based animations
- State management in animated UI systems
- Material Design motion principles and implementation
- 9 Reveal Origins - Animate from any corner, center, or edge position with precise control
- Physics-Based Item Animations - Spring-driven entrance effects with scale, translation, and rotation
- Touch-Based Reveals - Dynamic reveal animations starting from exact touch coordinates
- Element-Based Reveals - Animations triggered from specific UI element positions
- Staggered Animation System - Directional item entrance with configurable delays and physics
- Custom Animation Specs - Full control over spring physics, damping, and stiffness
- Custom Compose Modifiers -
Modifier.circularReveal()andModifier.animateItemReveal()for reusable animations - Geometry Math System - Coordinate transformations and radius calculations for precise reveals
- Spring Physics Engine - Custom physics configurations with realistic bounce and damping
- State-Driven Animations - Reactive animation system with proper state management
- Performance Optimization - Layout removal strategies and memory-efficient rendering
└── composereveal/ # Core animation system
└── src/main/java/com/example/circularreveal/composereveal/
├── animation/
│ ├── RevealOrigin.kt # 9 reveal position definitions
│ ├── ItemPhysicsSpec.kt # Physics presets and configuration
│ └── CircularRevealSpec.kt # Animation specification
├── modifiers/
│ ├── circularReveal.kt # Core reveal modifier implementation
│ ├── circularRevealFromTouch.kt # Touch-based reveal extensions
│ └── animateItemReveal.kt # Item physics animation system
└── util/
└── AnimationUtils.kt # Geometry calculations and utilities
@Composable
fun MyScreen() {
var isVisible by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.circularReveal(
visible = isVisible,
origin = RevealOrigin.TopStart,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
)
.background(MaterialTheme.colorScheme.primary)
) {
Text("Content revealed with physics!")
}
}@Composable
fun TouchRevealExample() {
var isVisible by remember { mutableStateOf(false) }
var touchPosition by remember { mutableStateOf(Offset.Zero) }
var containerSize by remember { mutableStateOf(Size.Zero) }
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { containerSize = it.size.toSize() }
.pointerInput(Unit) {
detectTapGestures { offset ->
touchPosition = offset
isVisible = !isVisible
}
}
.circularRevealFromTouch(
visible = isVisible,
touchPosition = touchPosition,
containerSize = containerSize
)
.background(MaterialTheme.colorScheme.secondary)
)
}@Composable
fun PhysicsGridExample() {
var isRevealed by remember { mutableStateOf(false) }
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
items(9) { index ->
Card(
modifier = Modifier
.animateItemReveal(
parentRevealed = rememberUpdatedState(isRevealed),
itemIndex = index,
revealOrigin = RevealOrigin.BottomCenter,
physicsSpec = ItemPhysicsSpec.StrongBounce,
staggerDelayMs = 100L
)
) {
Text("Item ${index + 1}")
}
}
}
}- Custom spring configurations with realistic damping and stiffness values
- Directional translation calculations based on reveal origin
- Coordinated scale, alpha, translation, and rotation animations
- Non-linear interpolation curves for natural movement
// Complex coordinate transformations for position-based reveals
internal fun Offset.mapToSize(size: Size): Offset {
return Offset(
x = this.x * size.width,
y = this.y * size.height
)
}
internal fun calculateMaxRadius(normalizedOrigin: Offset, size: Size): Float {
val x = if (normalizedOrigin.x > 0.5f) normalizedOrigin.x else (1f - normalizedOrigin.x)
val y = if (normalizedOrigin.y > 0.5f) normalizedOrigin.y else (1f - normalizedOrigin.y)
return sqrt((x * size.width).pow(2) + (y * size.height).pow(2))
}- Reactive animation states with proper lifecycle management
- Complex UI state coordination across multiple animated elements
- Performance optimization with conditional layout removal
- Thread-safe state updates with proper synchronization
// Reusable modifier system with clean API design
fun Modifier.circularReveal(
visible: Boolean,
origin: RevealOrigin = RevealOrigin.Center,
animationSpec: AnimationSpec<Float> = defaultSpring(),
hideWhenInvisible: Boolean = true
): Modifier = composed {
val transition = updateTransition(visible, label = "CircularReveal")
val progress = transition.animateFloat(
label = "RevealProgress",
transitionSpec = { animationSpec }
) { isVisible -> if (isVisible) 1f else 0f }
circularRevealWithProgress(progress, origin.toOffset(), hideWhenInvisible)
}