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 🚀👇
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 theirx
andy
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:
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 😊