How to Create Smooth Cubic Bézier Curves in Jetpack Compose | Creating Smooth Card Views

Cubic Bézier Curves in Jetpack Compose

1. Overview

Want to make your dessert-themed app’s UI stand out? Custom shapes in Jetpack Compose can elevate your design, especially for showcasing treats like ice cream. 

As developers, we often ask: “How can we craft smooth, curvy shapes for UI elements?” Cubic Bézier curves enable precise, elegant shapes for components like cards, aligning with your app’s brand identity

In this article, we’ll walk through creating a custom-shaped card for an ice cream app using Jetpack Compose, featuring smooth Bézier curves to captivate users from the first glance.


2. What are Cubic Bézier Curves?

A cubic Bézier curve is a smooth, parametric curve defined by a start point, an end point, and two control points that shape its curvature, ideal for creating custom UI elements in Jetpack Compose.


3. Why Use Cubic Bézier Curves?

Cubic Bézier curves offer flexibility to create unique, brand-aligned shapes for your app’s UI, enabling:

  • Smooth, custom shapes for cards and images.
  • Precise clipping with elegant, curvy edges.
  • Scalable designs using percentage-based coordinates.

4. Key Features Table

Feature Description
 Custom Card Shape Fully curved card using cubic Bézier curves.
 Top-Curved Image Image clipped with Bézier curves on the top edge.
 Flexible Design Percentage-based coordinates for scalable shapes.

5. Implementation Steps

This guide walks you through creating an ice cream app UI with a custom-shaped card using cubic Bézier curves.

Each step includes a simple code snippet and explanation to help beginners build the UI step by step, leading to the full implementation.

Learn more about this shape: Wikipedia

5.1 Define Full Curved Card Shape

Create a custom shape with cubic Bézier curves for all edges of a card, using percentage-based coordinates for flexibility.


class CubicBezierCardShape : Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path().apply {
            val width = size.width
            val height = size.height
            // Start at top-left curve
            moveTo(x = width.times(.02f), y = height.times(.25f))
            // Top-left curve control points
            val topLeftControlPoint1 = Offset(width.times(.02f), height.times(.03f))
            val topLeftControlPoint2 = Offset(width.times(.03f), height.times(.02f))
            // Draw top-left curve
            cubicTo(
                x1 = topLeftControlPoint1.x,
                y1 = topLeftControlPoint1.y,
                x2 = topLeftControlPoint2.x,
                y2 = topLeftControlPoint2.y,
                x3 = width.times(.25f),
                y3 = height.times(.02f)
            )
            // Top straight line
            lineTo(x = width.times(.75f), y = height.times(.02f))
            // Top-right curve control points
            val topRightControlPoint1 = Offset(width.times(.97f), height.times(.02f))
            val topRightControlPoint2 = Offset(width.times(.98f), height.times(.03f))
            // Draw top-right curve
            cubicTo(
                x1 = topRightControlPoint1.x,
                y1 = topRightControlPoint1.y,
                x2 = topRightControlPoint2.x,
                y2 = topRightControlPoint2.y,
                x3 = width.times(.98f),
                y3 = height.times(.25f)
            )
            // Right straight line
            lineTo(x = width.times(.98f), y = height.times(.75f))
            // Bottom-right curve control points
            val bottomRightControlPoint1 = Offset(width.times(.97f), height.times(.98f))
            val bottomRightControlPoint2 = Offset(width.times(.98f), height.times(.97f))
            // Draw bottom-right curve
            cubicTo(
                x1 = bottomRightControlPoint1.x,
                y1 = bottomRightControlPoint1.y,
                x2 = bottomRightControlPoint2.x,
                y2 = bottomRightControlPoint2.y,
                x3 = width.times(.75f),
                y3 = height.times(.98f)
            )
            // Bottom straight line
            lineTo(x = width.times(.25f), y = height.times(.98f))
            // Bottom-left curve control points
            val bottomLeftControlPoint1 = Offset(width.times(.03f), height.times(.98f))
            val bottomLeftControlPoint2 = Offset(width.times(.02f), height.times(.97f))
            // Draw bottom-left curve
            cubicTo(
                x1 = bottomLeftControlPoint1.x,
                y1 = bottomLeftControlPoint1.y,
                x2 = bottomLeftControlPoint2.x,
                y2 = bottomLeftControlPoint2.y,
                x3 = width.times(.02f),
                y3 = height.times(.75f)
            )
            // Close path
            close()
        }
        return Outline.Generic(path)
    }
}
    

Explanation:

The CubicBezierCardShape implements the Shape interface, using cubicTo to draw smooth curves for all four edges of a card.

It starts at a point (2% width, 25% height), uses two control points per curve to shape the path, and connects segments with straight lines, ensuring scalability with percentage-based coordinates.


UI design inspiration:
This curves originally developed by French engineer Pierre Bézier in the 1960s for designing car bodies.
Now a day's, Native platforms like iOS and popular apps like Swiggy use Bézier curves extensively to craft sleek, natural shapes that make UI elements feel polished and intuitive. These curves go beyond basic rounded corners, helping your app stand out with a unique and memorable brand feel. 

5.2 Define Top-Curved Image Shape

Create a shape with cubic Bézier curves for the top edge and straight lines for the sides and bottom, ideal for clipping images.


class TopCubicBezierImageShape : Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path().apply {
            val width = size.width
            val height = size.height
            // Start at top-left curve
            moveTo(x = width.times(.02f), y = height.times(.25f))
            // Top-left curve control points
            val topLeftControlPoint1 = Offset(width.times(.02f), height.times(.03f))
            val topLeftControlPoint2 = Offset(width.times(.03f), height.times(.02f))
            // Draw top-left curve
            cubicTo(
                x1 = topLeftControlPoint1.x,
                y1 = topLeftControlPoint1.y,
                x2 = topLeftControlPoint2.x,
                y2 = topLeftControlPoint2.y,
                x3 = width.times(.25f),
                y3 = height.times(.02f)
            )
            // Top straight line
            lineTo(x = width.times(.75f), y = height.times(.02f))
            // Top-right curve control points
            val topRightControlPoint1 = Offset(width.times(.97f), height.times(.02f))
            val topRightControlPoint2 = Offset(width.times(.98f), height.times(.03f))
            // Draw top-right curve
            cubicTo(
                x1 = topRightControlPoint1.x,
                y1 = topRightControlPoint1.y,
                x2 = topRightControlPoint2.x,
                y2 = topRightControlPoint2.y,
                x3 = width.times(.98f),
                y3 = height.times(.25f)
            )
            // Straight right side
            lineTo(x = width.times(.98f), y = height)
            // Straight bottom
            lineTo(x = width.times(.02f), y = height)
            // Straight left side
            lineTo(x = width.times(.02f), y = height.times(.25f))
            // Close path
            close()
        }
        return Outline.Generic(path)
    }
}
    

Explanation:

The TopCubicBezierImageShape defines a shape with smooth Bézier curves on the top edge, using two control points per curve, and straight lines for the sides and bottom to create a clean base for images, with percentage-based coordinates for scalability.

5.3 Create Ice Cream Screen

Build the main screen with a card using the custom shapes, an image, and text content, styled with dynamic theme colors.


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IceCreamScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.background) // Dynamic background
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            shape = CubicBezierCardShape(),
            elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.surfaceVariant // Dynamic surface color
            )
        ) {
            Column(
                modifier = Modifier
                    .background(MaterialTheme.colorScheme.surface) // Dynamic surface background
            ) {
                // Display ice cream image
                Image(
                    painter = painterResource(id = R.drawable.sample_image),
                    contentDescription = "Ice Cream Image",
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(300.dp)
                        .clip(TopCubicBezierImageShape()),
                    contentScale = ContentScale.Crop
                )
                // Text content container
                Column(
                    modifier = Modifier
                        .padding(36.dp)
                ) {
                    Spacer(modifier = Modifier.height(12.dp))
                    // Ice cream title
                    Text(
                        text = "Thai Spring Lemon Ice Cream",
                        style = MaterialTheme.typography.titleLarge.copy(
                            fontWeight = FontWeight.Bold,
                            fontSize = 20.sp,
                            color = MaterialTheme.colorScheme.onSurface // Dynamic text color
                        ),
                        modifier = Modifier.align(Alignment.Start)
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    // Ice cream description
                    Text(
                        text = "Indulge in the tasty and sweet lemon flavor, with a refreshing green twist from Thai Spring.",
                        style = MaterialTheme.typography.bodyMedium.copy(
                            fontSize = 14.sp,
                            color = MaterialTheme.colorScheme.onSurfaceVariant // Dynamic text color
                        ),
                        modifier = Modifier.align(Alignment.Start)
                    )
                }
            }
        }
    }
}
    

Explanation: The IceCreamScreen composable creates a Card with CubicBezierCardShape for a fully curved shape, clips an image with TopCubicBezierImageShape for a curved top edge, and displays a bold title and description using dynamic theme colors for light/dark mode compatibility.

6. Understanding cubicTo() with Interactive Demo

To make cubicTo() easier to understand, explore this interactive tool to visualize and manipulate Cubic Bézier curves: https://cubic-bezier.com/

7. Full Source Code

Below is the complete source code for the ice cream app UI, combining all steps into a single implementation, including preview composables for testing.

7.1 MainActivity.kt


package com.android.uix.new
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.android.uix.R
import com.android.uix.ui.theme.ComposeUIXTheme
// Custom shape for card with curved edges
class CubicBezierCardShape : Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path().apply {
            val width = size.width
            val height = size.height
            // Start at top-left curve
            moveTo(x = width.times(.02f), y = height.times(.25f))
            // Top-left curve control points
            val topLeftControlPoint1 = Offset(width.times(.02f), height.times(.03f))
            val topLeftControlPoint2 = Offset(width.times(.03f), height.times(.02f))
            // Draw top-left curve
            cubicTo(
                x1 = topLeftControlPoint1.x,
                y1 = topLeftControlPoint1.y,
                x2 = topLeftControlPoint2.x,
                y2 = topLeftControlPoint2.y,
                x3 = width.times(.25f),
                y3 = height.times(.02f)
            )
            // Top straight line
            lineTo(x = width.times(.75f), y = height.times(.02f))
            // Top-right curve control points
            val topRightControlPoint1 = Offset(width.times(.97f), height.times(.02f))
            val topRightControlPoint2 = Offset(width.times(.98f), height.times(.03f))
            // Draw top-right curve
            cubicTo(
                x1 = topRightControlPoint1.x,
                y1 = topRightControlPoint1.y,
                x2 = topRightControlPoint2.x,
                y2 = topRightControlPoint2.y,
                x3 = width.times(.98f),
                y3 = height.times(.25f)
            )
            // Right straight line
            lineTo(x = width.times(.98f), y = height.times(.75f))
            // Bottom-right curve control points
            val bottomRightControlPoint1 = Offset(width.times(.97f), height.times(.98f))
            val bottomRightControlPoint2 = Offset(width.times(.98f), height.times(.97f))
            // Draw bottom-right curve
            cubicTo(
                x1 = bottomRightControlPoint1.x,
                y1 = bottomRightControlPoint1.y,
                x2 = bottomRightControlPoint2.x,
                y2 = bottomRightControlPoint2.y,
                x3 = width.times(.75f),
                y3 = height.times(.98f)
            )
            // Bottom straight line
            lineTo(x = width.times(.25f), y = height.times(.98f))
            // Bottom-left curve control points
            val bottomLeftControlPoint1 = Offset(width.times(.03f), height.times(.98f))
            val bottomLeftControlPoint2 = Offset(width.times(.02f), height.times(.97f))
            // Draw bottom-left curve
            cubicTo(
                x1 = bottomLeftControlPoint1.x,
                y1 = bottomLeftControlPoint1.y,
                x2 = bottomLeftControlPoint2.x,
                y2 = bottomLeftControlPoint2.y,
                x3 = width.times(.02f),
                y3 = height.times(.75f)
            )
            // Close path
            close()
        }
        return Outline.Generic(path)
    }
}
// Custom shape for image with curved top and straight bottom
class TopCubicBezierImageShape : Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path().apply {
            val width = size.width
            val height = size.height
            // Start at top-left curve
            moveTo(x = width.times(.02f), y = height.times(.25f))
            // Top-left curve control points
            val topLeftControlPoint1 = Offset(width.times(.02f), height.times(.03f))
            val topLeftControlPoint2 = Offset(width.times(.03f), height.times(.02f))
            // Draw top-left curve
            cubicTo(
                x1 = topLeftControlPoint1.x,
                y1 = topLeftControlPoint1.y,
                x2 = topLeftControlPoint2.x,
                y2 = topLeftControlPoint2.y,
                x3 = width.times(.25f),
                y3 = height.times(.02f)
            )
            // Top straight line
            lineTo(x = width.times(.75f), y = height.times(.02f))
            // Top-right curve control points
            val topRightControlPoint1 = Offset(width.times(.97f), height.times(.02f))
            val topRightControlPoint2 = Offset(width.times(.98f), height.times(.03f))
            // Draw top-right curve
            cubicTo(
                x1 = topRightControlPoint1.x,
                y1 = topRightControlPoint1.y,
                x2 = topRightControlPoint2.x,
                y2 = topRightControlPoint2.y,
                x3 = width.times(.98f),
                y3 = height.times(.25f)
            )
            // Straight right side
            lineTo(x = width.times(.98f), y = height)
            // Straight bottom
            lineTo(x = width.times(.02f), y = height)
            // Straight left side
            lineTo(x = width.times(.02f), y = height.times(.25f))
            // Close path
            close()
        }
        return Outline.Generic(path)
    }
}
// Main composable for ice cream card
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IceCreamScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.background) // Dynamic background
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            shape = CubicBezierCardShape(),
            elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.surfaceVariant // Dynamic surface color
            )
        ) {
            Column(
                modifier = Modifier
                    .background(MaterialTheme.colorScheme.surface) // Dynamic surface background
            ) {
                // Display ice cream image
                Image(
                    painter = painterResource(id = R.drawable.sample_image),
                    contentDescription = "Ice Cream Image",
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(300.dp)
                        .clip(TopCubicBezierImageShape()),
                    contentScale = ContentScale.Crop
                )
                // Text content container
                Column(
                    modifier = Modifier
                        .padding(36.dp)
                ) {
                    Spacer(modifier = Modifier.height(12.dp))
                    // Ice cream title
                    Text(
                        text = "Thai Spring Lemon Ice Cream",
                        style = MaterialTheme.typography.titleLarge.copy(
                            fontWeight = FontWeight.Bold,
                            fontSize = 20.sp,
                            color = MaterialTheme.colorScheme.onSurface // Dynamic text color
                        ),
                        modifier = Modifier.align(Alignment.Start)
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    // Ice cream description
                    Text(
                        text = "Indulge in the tasty and sweet lemon flavor, with a refreshing green twist from Thai Spring.",
                        style = MaterialTheme.typography.bodyMedium.copy(
                            fontSize = 14.sp,
                            color = MaterialTheme.colorScheme.onSurfaceVariant // Dynamic text color
                        ),
                        modifier = Modifier.align(Alignment.Start)
                    )
                }
            }
        }
    }
}
// Light theme preview
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable
fun IceCreamScreenLightPreview() {
    ComposeUIXTheme(darkTheme = false) {
        IceCreamScreen()
    }
}
// Dark theme preview
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun IceCreamScreenDarkPreview() {
    ComposeUIXTheme(darkTheme = true) {
        IceCreamScreen()
    }
}
    

8. Tips for Improvement

This implementation is a single-page demo for simplicity, but here are tips to enhance it for production:

  • Separation of Concerns: Split shapes and composables into separate files (e.g., Shapes.kt, IceCreamScreen.kt) for better modularity and maintainability.
  • Dynamic Image Loading: Use a library like Coil to load images dynamically instead of the static R.drawable.sample_image.
  • Theme Integration: Extend ComposeUIXTheme with custom typography and shapes to reinforce the dessert app’s brand identity.
  • Reusable Shapes: Parameterize control points in CubicBezierCardShape and TopCubicBezierImageShape to reuse across different components.
  • Organize Previews: Move preview composables to a separate file to keep the main code focused on production logic.
  • Enhanced Theme Support: Test additional theme variations (e.g., high-contrast mode) to ensure accessibility.

9. Community Feedback

Do you have suggestions to improve this ice cream app UI or the Jetpack Compose code using cubic Bézier curves? Share your ideas, optimizations, or alternative approaches in the comments. We’ll update this guide with your feedback!

Post a Comment

Previous Post Next Post