How to Create a Custom Animated Hole Effect in Jetpack Compose

Kappdev
5 min readSep 9, 2024

--

Welcome 🙋

In this article, we’ll explore how to create a Custom Hole Effect in Jetpack Compose, featuring a customizable center origin. We’ll also cover how to animate this effect to bring it to life

Excited? 🤩 Let’s dive in 🚀👇

Created by Kappdev

Circular Hole Shape

We start by defining a custom shape class, CircularHoleShape, which implements the Shape interface. We’ll then use this class to clip the view:

class CircularHoleShape(
private val progress: Float,
private val center: TransformOrigin = TransformOrigin.Center
) : Shape {

override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
// Implementation...
}
}

Parameters

progress 👉 A value between 0 and 1 that determines the size of the hole.

center 👉 The origin point from which the hole is expanded.

Distance Utility

Before implementing the createOutline function, let's define a utility function to calculate the distance between two points:

private infix fun Offset.distanceTo(other: Offset): Float {
return hypot(other.x - this.x, other.y - this.y)
}

💡 The hypot function calculates the straight-line distance between two points using their x and y coordinate differences.

We’ll use this function to find the longest distance to the corner, which will be used as the target radius for the circle:

Created by Kappdev

Create Outline Implementation

Now, we can implement the createOutline function:

override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val rect = size.toRect() // Convert size to a rectangle

if (progress == 0f) {
return Outline.Rectangle(rect) // Return the full rectangle if no hole
}

// Calculate the center of the hole based on the pivot
val holeCenter = Offset(center.pivotFractionX * size.width, center.pivotFractionY * size.height)

// Determine the maximum distance from the hole center to any corner of the rectangle
val targetRadius = maxOf(
holeCenter distanceTo rect.topLeft,
holeCenter distanceTo rect.topRight,
holeCenter distanceTo rect.bottomLeft,
holeCenter distanceTo rect.bottomRight,
)

val holeRadius = targetRadius * progress // Scale radius based on progress

// Create a path for the full rectangle
val viewPath = Path().apply {
addRect(rect)
}

// Create a path for the circular hole
val holePath = Path().apply {
addOval(Rect(holeCenter, holeRadius))
}

// Subtract the hole path from the view path to create the hole effect
val path = Path().apply {
op(viewPath, holePath, PathOperation.Difference)
}

return Outline.Generic(path) // Return the resulting shape outline
}

Hole Modifier

To simplify the clipping process and make it more convenient to apply a hole effect to any composable, let’s create a custom Modifier:

fun Modifier.hole(
progress: Float,
center: TransformOrigin = TransformOrigin.Center
) = this.clip(CircularHoleShape(progress, center))

Hole Animation Modifier

While clipping a view using the hole Modifier can be useful, we can extend it further by applying automated animation:

@Composable
fun Modifier.holeAnimation(
// Controls whether the content is visible (true) or hidden by the hole (false)
isVisible: Boolean,
center: TransformOrigin = TransformOrigin.Center,
animationSpec: AnimationSpec<Float> = tween(700),
onFinish: ((isVisible: Boolean) -> Unit)? = null
): Modifier {
// Animate the progress based on visibility
val progress by animateFloatAsState(
targetValue = if (isVisible) 0f else 1f,
animationSpec = animationSpec,
label = "HoleProgress",
finishedListener = { currentValue ->
// Invoke the onFinish callback when the animation completes
onFinish?.invoke(currentValue == 0f)
}
)
// Apply the hole effect
return this.hole(progress, center)
}

Congratulations 🥳! We’ve successfully built it 👏. You can find the full code on GitHub Gist 🧑‍💻. Let’s explore the usage 👇

Example of Usage 💁‍♂️

Let’s explore examples for both manual and animated hole effects:

Manual Hole

// Progress state for the hole effect
var progress by remember { mutableFloatStateOf(0f) }

Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Image with a hole effect, centered by default
Image(
painter = painterResource(R.drawable.img_dog),
modifier = Modifier
.height(200.dp)
.hole(progress),
contentDescription = "DogImage"
)

// Image with a hole effect, custom origin
Image(
painter = painterResource(R.drawable.img_dog),
modifier = Modifier
.height(200.dp)
.hole(progress, TransformOrigin(0.2f, 0.3f)),
contentDescription = "DogImage"
)

// Another image with a different custom origin
Image(
painter = painterResource(R.drawable.img_dog),
modifier = Modifier
.height(200.dp)
.hole(progress, TransformOrigin(0.6f, 0.8f)),
contentDescription = "DogImage"
)

// Slider to control hole progress
Slider(
value = progress,
onValueChange = { progress = it },
modifier = Modifier.width(300.dp)
)
}

Output:

Tap Origin Utility

To detect the tap origin and use it to animate the hole, define a utility Modifier:

fun Modifier.detectTapWithOrigin(
onTap: (TransformOrigin) -> Unit
) = this.pointerInput(Unit) {
detectTapGestures { clickOffset ->
// Calculate the origin based on the tap location as a fraction of the total size
val origin = TransformOrigin(
pivotFractionX = clickOffset.x / size.width,
pivotFractionY = clickOffset.y / size.height,
)
// Trigger the onTap callback with the calculated origin
onTap(origin)
}
}

Animated Hole

// State to hold the center of the hole effect
var holeCenter by remember { mutableStateOf(TransformOrigin.Center) }

// State to control the visibility of the hole effect
var isVisible by remember { mutableStateOf(true) }

Image(
painter = painterResource(R.drawable.img_dog),
modifier = Modifier
.fillMaxWidth()
.detectTapWithOrigin { tapOrigin ->
// Update hole center and toggle visibility on tap
holeCenter = tapOrigin
isVisible = !isVisible
}
.holeAnimation(isVisible, holeCenter), // Apply the hole animation based on state
contentDescription = "DogImage"
)

Output:

You might also like 👇

Thank you for reading this article! ❤️ If you found it enjoyable and valuable, show your appreciation by clapping 👏 and following Kappdev for more exciting articles 😊

--

--

Kappdev

💡 Curious Explorer 🧭 Kotlin and Compose enthusiast 👨‍💻 Passionate about self-development and growth ❤️‍🔥 Push your boundaries 🚀