Overview
0:03:00Welcome to this codelab on building a divine Bible app paywall with heavenly animations using Jetpack Compose.
In this codelab, you will learn how to create a serene, spiritually-themed paywall screen. The paywall includes golden light rays emanating from above, soft drifting clouds, twinkling stars in the sky, rising golden particles, animated flying doves with wing flapping, and a beautifully rendered shiny cross with radiant glow effects.
By the end of this codelab, you'll have a peaceful, inspiring paywall that creates a sense of divine presence, perfect for Bible apps, prayer journals, or meditation applications.
Prerequisites
Before starting this codelab, you should have basic knowledge of Kotlin and Jetpack Compose. You'll need Android Studio or IntelliJ IDEA with Compose support installed. Familiarity with Canvas drawing and trigonometric functions will be helpful, as we'll use them extensively for the visual effects.
What you'll build
A complete Bible-themed paywall screen with multiple layered animations that create a heavenly atmosphere. The design uses warm golden colors and soft glows to evoke a sense of peace and spirituality.
Project Setup
0:05:00Let's start by setting up the basic structure for our Bible paywall. Create a new Kotlin file called BiblePaywallScreen.kt and add the necessary imports and data classes.
Required Imports
Add all the necessary imports for Canvas drawing, animations, and Compose UI components. We'll need trigonometric functions from kotlin.math for calculating angles and positions of our visual elements:
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.*
import kotlin.random.Random
private const val TAU = 2f * PI.toFloat()Why define TAU? TAU (2π) represents a full circle in radians. While PI represents half a circle, TAU makes circular calculations more intuitive. When we want to distribute elements evenly around a circle or create rotating animations, using TAU simplifies the math considerably.
Data Classes for Visual Elements
Our animation system uses five different types of visual elements. Each has its own data class to store position, movement, and appearance properties:
// Heavenly light ray emanating from above
data class HeavenlyRay(
val angle: Float, // Direction angle in radians
val width: Float, // Thickness of the ray
val length: Float, // How far the ray extends
val speed: Float, // Pulsing animation speed
val phase: Float, // Animation offset for variety
val alpha: Float, // Base transparency
)
// Floating dove with animated wings
data class DoveParticle(
var x: Float, // Horizontal position
var y: Float, // Vertical position
val size: Float, // Size of the dove
var wingPhase: Float, // Wing flap animation phase
val speed: Float, // Horizontal movement speed
val amplitude: Float, // Vertical bobbing range
val phaseOffset: Float,// Offset for bobbing animation
)
// Twinkling star in the sky
data class HeavenlyStar(
val x: Float, // Fixed horizontal position
val y: Float, // Fixed vertical position
val size: Float, // Star size
val twinkleSpeed: Float,// How fast it twinkles
val phase: Float, // Animation offset
)
// Rising golden particle
data class GoldenParticle(
var x: Float, // Horizontal position (sways)
var y: Float, // Vertical position (rises up)
var vy: Float, // Vertical velocity (negative = upward)
val size: Float, // Particle size
val alpha: Float, // Transparency
var swayPhase: Float, // Horizontal sway animation
val swaySpeed: Float, // Sway animation speed
)
// Soft cloud layer
data class HeavenlyCloud(
val x: Float, // Initial horizontal position
val y: Float, // Vertical position
val width: Float, // Cloud width
val height: Float, // Cloud height
val alpha: Float, // Transparency
val driftSpeed: Float, // Horizontal drift speed
)Why use var for some properties? Properties that change during animation (like position) are declared as var, while fixed properties (like size or speed constants) use val. This makes it clear which values are mutable and helps prevent accidental modifications to properties that should remain constant.
Light Rays & Heavenly Glow
0:08:00The light rays create the foundation of our divine atmosphere. These golden beams emanate from the top center of the screen, creating the impression of heavenly light shining down. We'll use trigonometry to calculate the ray positions and gradients for smooth fading.
Divine Color Palette
First, let's define our color palette. These warm, golden tones create a sense of divine warmth and spirituality:
class BibleAnimationSystem {
private val lightRays = mutableListOf<HeavenlyRay>()
private val random = Random
private var initialized = false
// Divine color palette
private val goldenLight = Color(0xFFFFD700) // Pure gold
private val warmGold = Color(0xFFFFC857) // Warm golden tone
private val divineWhite = Color(0xFFFFFAF0) // Off-white with warmth
private val heavenlyBlue = Color(0xFF87CEEB) // Sky blue
private val softPurple = Color(0xFFE6E6FA) // Lavender
}Color choice matters: The golden colors (0xFFFFD700, 0xFFFFC857) evoke warmth and divinity across many cultures. The off-white (0xFFFFFAF0, also known as "Floral White") adds softness without the harshness of pure white, creating a more peaceful atmosphere.
Initializing Light Rays
We create 12 light rays that spread out from a central point at the top of the screen. The rays are positioned using angles calculated from the vertical center line:
fun initialize(width: Float, height: Float) {
if (initialized) return
initialized = true
// Create light rays emanating from top center
for (i in 0 until 12) {
// Start from straight down (-PI/2) and spread out
val baseAngle = -PI.toFloat() / 2 + (i - 5.5f) * 0.12f
lightRays.add(
HeavenlyRay(
angle = baseAngle,
width = random.nextFloat() * 30f + 20f,
length = height * 0.9f,
speed = random.nextFloat() * 0.3f + 0.2f,
phase = random.nextFloat() * TAU,
alpha = random.nextFloat() * 0.15f + 0.08f,
),
)
}
}Understanding the angle calculation: The expression -PI/2 + (i - 5.5f) * 0.12f works as follows. First, -PI/2 points straight down (in Compose's coordinate system, 0 radians points right, and negative PI/2 points down). Then (i - 5.5f) creates values from -5.5 to +5.5 for our 12 rays, centering them around zero. Finally, multiplying by 0.12f (about 7 degrees) spreads the rays apart, creating a fan effect that covers roughly 84 degrees total.
Drawing the Heavenly Glow
The background glow uses a radial gradient centered at the top of the screen. This creates the effect of light emanating from above:
private fun drawHeavenlyGlow(
drawScope: DrawScope,
width: Float,
height: Float,
time: Float
) {
// Pulsing effect: oscillates between 0.9 and 1.0
val pulse = sin(time * 0.5f) * 0.1f + 0.9f
// Divine light from above
drawScope.drawRect(
brush = Brush.radialGradient(
colors = listOf(
goldenLight.copy(alpha = 0.25f * pulse),
warmGold.copy(alpha = 0.15f * pulse),
Color.Transparent,
),
center = Offset(width / 2, 0f), // Top center
radius = height * 0.8f,
),
size = Size(width, height),
)
}The pulse calculation: sin(time * 0.5f) * 0.1f + 0.9f creates a smooth oscillation. The sin() function returns values between -1 and +1. Multiplying by 0.1 reduces this to -0.1 to +0.1, and adding 0.9 shifts the range to 0.8 to 1.0. This creates a subtle breathing effect where the glow slowly brightens and dims, adding life to the static gradient.
Drawing Light Rays
Each light ray is drawn as a triangular path that emanates from the top center and widens as it extends downward:
private fun drawLightRay(
drawScope: DrawScope,
ray: HeavenlyRay,
time: Float,
width: Float,
height: Float,
) {
// Pulsing intensity
val pulse = sin(time * ray.speed + ray.phase) * 0.4f + 0.6f
val originX = width / 2
val originY = -20f // Slightly above screen
// Calculate ray endpoint using trigonometry
val endX = originX + cos(ray.angle) * ray.length
val endY = originY + sin(ray.angle) * ray.length
// Calculate perpendicular vector for ray width
val perpX = -sin(ray.angle) * ray.width * pulse
val perpY = cos(ray.angle) * ray.width * pulse
// Build triangular path
val path = Path().apply {
moveTo(originX - perpX * 0.1f, originY - perpY * 0.1f)
lineTo(originX + perpX * 0.1f, originY + perpY * 0.1f)
lineTo(endX + perpX, endY + perpY)
lineTo(endX - perpX, endY - perpY)
close()
}
drawScope.drawPath(
path = path,
brush = Brush.linearGradient(
colors = listOf(
goldenLight.copy(alpha = ray.alpha * pulse),
warmGold.copy(alpha = ray.alpha * pulse * 0.5f),
Color.Transparent,
),
start = Offset(originX, originY),
end = Offset(endX, endY),
),
)
}Perpendicular vector calculation: To make the ray have width, we need a vector perpendicular to its direction. If the ray direction is (cos(angle), sin(angle)), the perpendicular is (-sin(angle), cos(angle)). This is a 90-degree rotation. We multiply this by the ray width to get offset points for the ray edges.
The path shape: The path creates a trapezoid that is narrow at the origin (top center) and wide at the end. The factor of 0.1 at the origin makes it start as nearly a point, creating the classic "light beam" shape. The linear gradient makes it fade from bright at the source to transparent at the end.
Clouds & Twinkling Stars
0:07:00Clouds and stars add depth and atmosphere to our heavenly scene. The clouds drift slowly across the sky, while stars twinkle with varying intensities. These elements create a sense of peaceful motion without being distracting.
Initializing Clouds and Stars
Add these initialization blocks to your initialize function:
// Create stars in the upper half of the screen
for (i in 0 until 25) {
stars.add(
HeavenlyStar(
x = random.nextFloat() * width,
y = random.nextFloat() * height * 0.5f, // Only in top half
size = random.nextFloat() * 3f + 1f,
twinkleSpeed = random.nextFloat() * 2f + 1f,
phase = random.nextFloat() * TAU,
),
)
}
// Create clouds near the top of the screen
for (i in 0 until 6) {
clouds.add(
HeavenlyCloud(
// Start position can be off-screen for seamless looping
x = random.nextFloat() * width * 1.5f - width * 0.25f,
y = random.nextFloat() * height * 0.15f, // Near top
width = random.nextFloat() * 150f + 100f,
height = random.nextFloat() * 40f + 30f,
alpha = random.nextFloat() * 0.15f + 0.05f, // Very subtle
driftSpeed = random.nextFloat() * 10f + 5f,
),
)
}Star placement strategy: Stars are only placed in the top 50% of the screen (height * 0.5f) because that's where the "sky" portion of our scene is. Placing them lower would conflict with the UI elements and feel unnatural.
Cloud position range: The expression random.nextFloat() * width * 1.5f - width * 0.25f creates a range from -25% to +125% of the screen width. This allows clouds to start off-screen so they can drift on naturally rather than suddenly appearing.
Drawing Clouds
Clouds are drawn using multiple overlapping circles with radial gradients, creating a soft, fluffy appearance:
private fun drawCloud(
drawScope: DrawScope,
cloud: HeavenlyCloud,
time: Float,
screenWidth: Float,
) {
// Calculate current position with looping
val x = (cloud.x + time * cloud.driftSpeed) %
(screenWidth + cloud.width * 2) - cloud.width
// Subtle pulsing based on position and time
val pulse = sin(time * 0.3f + cloud.x * 0.01f) * 0.3f + 0.7f
val cloudColor = divineWhite.copy(alpha = cloud.alpha * pulse)
// Draw 5 overlapping circles to form cloud shape
for (i in 0 until 5) {
val offsetX = (i - 2) * cloud.width * 0.2f
val offsetY = sin(i * 1.2f) * cloud.height * 0.2f
val circleSize = cloud.height * (0.8f + sin(i * 0.8f) * 0.3f)
drawScope.drawCircle(
brush = Brush.radialGradient(
colors = listOf(
cloudColor,
cloudColor.copy(alpha = cloudColor.alpha * 0.5f),
Color.Transparent,
),
center = Offset(x + offsetX, cloud.y + offsetY),
radius = circleSize,
),
radius = circleSize,
center = Offset(x + offsetX, cloud.y + offsetY),
)
}
}Looping animation explained: The modulo operation % (screenWidth + cloud.width * 2) creates seamless looping. When the cloud drifts past the right edge, it wraps around to start from the left again. The extra cloud.width * 2 ensures the cloud is fully off-screen before wrapping, preventing visible "jumps".
Building cloud shape: Five circles with varying positions and sizes create an organic cloud shape. The sin(i * 1.2f) for Y offset and sin(i * 0.8f) for size variation create irregular, natural-looking bumps. Using different multipliers (1.2 vs 0.8) prevents the pattern from being too regular.
Drawing Twinkling Stars
Stars use a 4-pointed shape created by two perpendicular lines, with a glow effect behind them:
private fun drawStar(
drawScope: DrawScope,
star: HeavenlyStar,
time: Float
) {
// Twinkle effect: smoothly varies between 0 and 1
val twinkle = sin(time * star.twinkleSpeed + star.phase) * 0.5f + 0.5f
val size = star.size * (0.5f + twinkle * 0.5f)
// Draw soft glow behind the star
drawScope.drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = twinkle * 0.9f),
goldenLight.copy(alpha = twinkle * 0.5f),
Color.Transparent,
),
center = Offset(star.x, star.y),
radius = size * 3f,
),
radius = size * 3f,
center = Offset(star.x, star.y),
)
// Draw 4-pointed star shape with two lines
val rayLength = size * 2f
// Horizontal ray
drawScope.drawLine(
color = Color.White.copy(alpha = twinkle * 0.8f),
start = Offset(star.x - rayLength, star.y),
end = Offset(star.x + rayLength, star.y),
strokeWidth = 1f,
)
// Vertical ray
drawScope.drawLine(
color = Color.White.copy(alpha = twinkle * 0.8f),
start = Offset(star.x, star.y - rayLength),
end = Offset(star.x, star.y + rayLength),
strokeWidth = 1f,
)
}Twinkle normalization: The formula sin(...) * 0.5f + 0.5f converts the sine wave from range (-1, 1) to range (0, 1). This ensures the star never completely disappears (which would look unnatural) and reaches full brightness at its peak.
Size variation with twinkle: The expression star.size * (0.5f + twinkle * 0.5f) makes the star's size oscillate between 50% and 100% of its base size in sync with the brightness. This creates a more realistic twinkling effect where the star appears to pulse rather than just changing opacity.
Golden Particles & Doves
0:08:00Golden particles rise upward like prayers or blessings, while doves fly gracefully across the screen. These moving elements add life and spiritual symbolism to our paywall.
Spawning and Updating Particles
The particle system continuously spawns new particles at the bottom and removes them when they leave the screen:
fun update(deltaTime: Float, width: Float, height: Float) {
// Spawn new particles with 15% probability each frame
if (random.nextFloat() < 0.15f && particles.size < 40) {
particles.add(
GoldenParticle(
x = random.nextFloat() * width,
y = height + 20f, // Start below screen
vy = -(random.nextFloat() * 40f + 30f), // Negative = upward
size = random.nextFloat() * 4f + 2f,
alpha = random.nextFloat() * 0.6f + 0.3f,
swayPhase = random.nextFloat() * TAU,
swaySpeed = random.nextFloat() * 2f + 1f,
),
)
}
// Update existing particles
val iterator = particles.iterator()
while (iterator.hasNext()) {
val particle = iterator.next()
// Move upward
particle.y += particle.vy * deltaTime
// Gentle horizontal sway
particle.x += sin(particle.swayPhase) * 0.5f
particle.swayPhase += particle.swaySpeed * deltaTime
// Remove when above screen
if (particle.y < -20f) {
iterator.remove()
}
}
}Spawn rate control: The 15% probability (random.nextFloat() < 0.15f) combined with a maximum of 40 particles creates a steady but not overwhelming stream of particles. At 60fps, this spawns roughly 9 particles per second on average, but the randomness prevents a mechanical, predictable pattern.
Horizontal sway mechanics: The sine-based sway sin(particle.swayPhase) * 0.5f creates gentle side-to-side motion. Each particle has its own swayPhase that increments at swaySpeed, so particles sway independently rather than in unison. The 0.5 multiplier keeps the movement subtle.
Drawing Golden Particles
Particles are drawn as glowing orbs with a white-to-gold gradient:
private fun drawGoldenParticle(
drawScope: DrawScope,
particle: GoldenParticle,
time: Float
) {
// Fast twinkle for sparkle effect
val twinkle = sin(time * 3f + particle.swayPhase) * 0.3f + 0.7f
drawScope.drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = particle.alpha * twinkle),
goldenLight.copy(alpha = particle.alpha * twinkle * 0.6f),
Color.Transparent,
),
center = Offset(particle.x, particle.y),
radius = particle.size * 3f,
),
radius = particle.size * 3f,
center = Offset(particle.x, particle.y),
)
}Twinkle speed choice: The multiplier of 3 in sin(time * 3f + ...) makes particles twinkle about 3 times per second. This is faster than the stars' twinkle, giving particles a more energetic, sparkly appearance that suggests motion and life.
Drawing Doves
Doves are the most complex element, composed of multiple shapes representing body, head, wings, and tail:
private fun drawDove(
drawScope: DrawScope,
dove: DoveParticle,
time: Float,
screenWidth: Float
) {
// Horizontal position with looping
val x = (dove.x + time * dove.speed) %
(screenWidth + dove.size * 4) - dove.size * 2
// Gentle vertical bobbing
val y = dove.y + sin(time * 1.5f + dove.phaseOffset) * dove.amplitude
// Wing flap animation (fast oscillation)
val wingFlap = sin(time * 8f + dove.wingPhase) * 0.4f
val doveColor = divineWhite.copy(alpha = 0.9f)
// Body (oval)
drawScope.drawOval(
color = doveColor,
topLeft = Offset(x - dove.size * 0.4f, y - dove.size * 0.15f),
size = Size(dove.size * 0.8f, dove.size * 0.3f),
)
// Head (circle, slightly forward and up)
drawScope.drawCircle(
color = doveColor,
radius = dove.size * 0.15f,
center = Offset(x + dove.size * 0.35f, y - dove.size * 0.05f),
)
// Left wing (curved path)
val leftWingPath = Path().apply {
moveTo(x - dove.size * 0.1f, y)
quadraticTo(
x - dove.size * 0.5f, // Control point X
y - dove.size * (0.5f + wingFlap), // Control point Y (animated)
x - dove.size * 0.3f, // End X
y - dove.size * 0.1f, // End Y
)
}
drawScope.drawPath(
leftWingPath,
color = doveColor,
style = Stroke(width = dove.size * 0.08f, cap = StrokeCap.Round),
)
// Soft glow around dove
drawScope.drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = 0.3f),
Color.Transparent,
),
center = Offset(x, y),
radius = dove.size,
),
radius = dove.size,
center = Offset(x, y),
)
}Wing flap animation: The expression sin(time * 8f + dove.wingPhase) creates wing movement at about 8 cycles per second, which mimics real bird wing beats. The wingFlap value modifies the control point of the quadratic curve, making the wing arc higher or lower as it "flaps".
Quadratic curves for wings: The quadraticTo function draws a smooth curve from the current position to an endpoint, bending toward a control point. By animating the control point's Y coordinate with wingFlap, we create a convincing wing motion without complex animation code.
Shiny Cross Effect
0:10:00The shiny cross is the centerpiece of our heavenly scene. It features multiple layers of glow, rotating light rays, a shimmering golden surface, and sparkles at key points. This creates a truly radiant, divine presence.
Multi-Layer Glow Effect
The cross is surrounded by three concentric glow layers that pulse in intensity:
private fun drawShinyCross(
drawScope: DrawScope,
cx: Float, // Center X
cy: Float, // Center Y
time: Float
) {
// Animation values
val pulse = sin(time * 1.5f) * 0.1f + 1f // Size pulse: 0.9 to 1.1
val glow = sin(time * 2f) * 0.3f + 0.7f // Glow intensity: 0.4 to 1.0
val shimmer = sin(time * 4f) * 0.5f + 0.5f // Shimmer: 0 to 1
// Cross dimensions (all scaled by pulse)
val crossWidth = 12f * pulse
val crossHeight = 90f * pulse
val crossArmWidth = 65f * pulse
val crossArmHeight = 12f * pulse
// Layer 1: Outermost divine radiance (180px radius)
drawScope.drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = 0.15f * glow),
goldenLight.copy(alpha = 0.1f * glow),
Color.Transparent,
),
center = Offset(cx, cy),
radius = 180f * pulse,
),
radius = 180f * pulse,
center = Offset(cx, cy),
)
// Layer 2: Middle radiance (120px radius)
drawScope.drawCircle(
brush = Brush.radialGradient(
colors = listOf(
goldenLight.copy(alpha = 0.35f * glow),
warmGold.copy(alpha = 0.2f * glow),
Color.Transparent,
),
center = Offset(cx, cy),
radius = 120f * pulse,
),
radius = 120f * pulse,
center = Offset(cx, cy),
)
// Layer 3: Inner bright glow (70px radius)
drawScope.drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = 0.6f * glow),
goldenLight.copy(alpha = 0.4f * glow),
Color.Transparent,
),
center = Offset(cx, cy),
radius = 70f * pulse,
),
radius = 70f * pulse,
center = Offset(cx, cy),
)
}Three animation speeds: Using different speeds (1.5, 2, and 4) for pulse, glow, and shimmer prevents the animations from synchronizing, creating a more organic, living appearance. If all animations used the same speed, the effect would look mechanical and repetitive.
Layered glow technique: Three concentric circles with decreasing radius and increasing opacity create a realistic glow falloff. The outermost layer (180px) is very subtle, the middle layer (120px) adds warmth, and the innermost (70px) provides bright intensity near the cross itself.
Rotating Light Rays
Twelve rays emanate from the cross center, slowly rotating to create a dynamic halo effect:
// Shining rays emanating from cross
val rayCount = 12
for (i in 0 until rayCount) {
// Calculate angle with slow rotation
val angle = (i.toFloat() / rayCount) * TAU + time * 0.3f
// Ray length varies with time for shimmer effect
val rayLength = (60f + sin(time * 3f + i * 0.5f) * 20f) * pulse
// Ray opacity also varies
val rayAlpha = (0.4f + sin(time * 2f + i * 0.8f) * 0.2f) * glow
// Calculate start point (25px from center)
val startX = cx + cos(angle) * 25f
val startY = cy + sin(angle) * 25f
// Calculate end point
val endX = cx + cos(angle) * rayLength
val endY = cy + sin(angle) * rayLength
drawScope.drawLine(
brush = Brush.linearGradient(
colors = listOf(
Color.White.copy(alpha = rayAlpha),
goldenLight.copy(alpha = rayAlpha * 0.5f),
Color.Transparent,
),
start = Offset(startX, startY),
end = Offset(endX, endY),
),
start = Offset(startX, startY),
end = Offset(endX, endY),
strokeWidth = 3f * pulse,
cap = StrokeCap.Round,
)
}Distributing rays evenly: The expression (i.toFloat() / rayCount) * TAU divides a full circle (TAU = 2π) into 12 equal parts, placing rays 30 degrees apart. Adding time * 0.3f makes all rays rotate together at a slow, majestic pace.
Individual ray variation: Each ray has slightly different length and opacity variations using i * 0.5f and i * 0.8f offsets. This prevents all rays from pulsing in unison, creating a more natural, shimmering appearance.
Drawing the Cross Shape
The cross itself is drawn using rounded rectangles with a gradient fill and highlight edges:
// Vertical beam of the cross
val verticalPath = Path().apply {
addRoundRect(
RoundRect(
left = cx - crossWidth / 2,
top = cy - crossHeight / 2,
right = cx + crossWidth / 2,
bottom = cy + crossHeight / 2,
cornerRadius = CornerRadius(crossWidth / 2), // Fully rounded ends
),
)
}
// Horizontal beam (positioned higher on the vertical)
val crossArmY = cy - 18f * pulse // Offset from center
val horizontalPath = Path().apply {
addRoundRect(
RoundRect(
left = cx - crossArmWidth / 2,
top = crossArmY - crossArmHeight / 2,
right = cx + crossArmWidth / 2,
bottom = crossArmY + crossArmHeight / 2,
cornerRadius = CornerRadius(crossArmHeight / 2),
),
)
}
// Shimmering gradient for the cross surface
val crossGradient = Brush.linearGradient(
colors = listOf(
Color.White,
Color(0xFFFFF8DC), // Cornsilk
goldenLight,
Color(0xFFFFF8DC),
Color.White,
),
start = Offset(cx - crossArmWidth / 2, cy - crossHeight / 2),
end = Offset(cx + crossArmWidth / 2, cy + crossHeight / 2),
)
// Draw filled cross
drawScope.drawPath(verticalPath, crossGradient)
drawScope.drawPath(horizontalPath, crossGradient)
// Draw bright edge highlight
val highlightAlpha = 0.7f + shimmer * 0.3f
drawScope.drawPath(
verticalPath,
color = Color.White.copy(alpha = highlightAlpha),
style = Stroke(width = 2f),
)
drawScope.drawPath(
horizontalPath,
color = Color.White.copy(alpha = highlightAlpha),
style = Stroke(width = 2f),
)Cross proportions: A traditional Christian cross has the horizontal beam positioned in the upper portion of the vertical beam. The 18-pixel offset (cy - 18f * pulse) places the arms about 20% up from center, matching the expected proportions.
Multi-stop gradient: The 5-color gradient (white → cornsilk → gold → cornsilk → white) creates a metallic, polished appearance. The light colors at edges and warm gold in the center simulate how light reflects off a curved golden surface.
Complete Paywall Screen
0:08:00Now let's assemble all the pieces into a complete paywall screen. We'll create the animated background composable, plan selection options with custom styling, and the main screen layout.
BibleBackground Composable
This composable manages the animation loop and renders all background elements:
@Composable
fun BibleBackground(modifier: Modifier = Modifier) {
val system = remember { BibleAnimationSystem() }
var lastFrameTimeNanos by remember { mutableLongStateOf(0L) }
var totalTime by remember { mutableFloatStateOf(0f) }
var canvasSize by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(Unit) {
while (true) {
withFrameNanos { frameTimeNanos ->
// Calculate delta time in seconds
val deltaTime = if (lastFrameTimeNanos == 0L) {
0.016f // Assume 60fps for first frame
} else {
((frameTimeNanos - lastFrameTimeNanos) / 1_000_000_000f)
.coerceIn(0f, 0.1f) // Clamp to prevent huge jumps
}
lastFrameTimeNanos = frameTimeNanos
totalTime += deltaTime
if (canvasSize.width > 0 && canvasSize.height > 0) {
system.initialize(canvasSize.width, canvasSize.height)
system.update(deltaTime, canvasSize.width, canvasSize.height)
}
}
}
}
Canvas(modifier = modifier.fillMaxSize()) {
canvasSize = size
system.draw(this, totalTime, size.width, size.height)
}
}Frame timing explained: The withFrameNanos function provides the current frame's timestamp in nanoseconds. We divide by 1 billion (1_000_000_000f) to convert to seconds. The coerceIn(0f, 0.1f) prevents animation jumps if the app was paused or suspended, limiting delta time to a maximum of 100ms.
Why track canvasSize separately: The Canvas size isn't available until the composable is laid out. By storing it in state and checking it's valid before initializing, we ensure the animation system has correct dimensions to work with.
Plan Selection Option
Each subscription plan is displayed with a custom cross-themed checkbox:
@Composable
fun BiblePlanOption(
title: String,
price: String,
period: String,
badge: String? = null,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(
if (isSelected) Color(0xFF2A1810).copy(alpha = 0.9f)
else Color(0xFF1A0F08).copy(alpha = 0.7f),
)
.clickable(onClick = onClick)
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Cross-shaped selection indicator
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
if (isSelected) {
Canvas(modifier = Modifier.size(20.dp)) {
// Golden circle background
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xFFFFD700), Color(0xFFFFC857)),
),
radius = size.width / 2,
)
// Small cross inside
val crossColor = Color(0xFF1A0F08)
drawLine(crossColor, Offset(size.width/2, size.height*0.25f),
Offset(size.width/2, size.height*0.75f), 2.5f, StrokeCap.Round)
drawLine(crossColor, Offset(size.width*0.3f, size.height*0.4f),
Offset(size.width*0.7f, size.height*0.4f), 2.5f, StrokeCap.Round)
}
} else {
Box(modifier = Modifier.size(20.dp).clip(CircleShape)
.background(Color(0xFFFFD700).copy(alpha = 0.2f)))
}
}
Text(text = title, style = TextStyle(
color = Color(0xFFFFFAF0), fontSize = 16.sp, fontWeight = FontWeight.Bold))
}
Column(horizontalAlignment = Alignment.End) {
Text(text = price, style = TextStyle(
color = Color(0xFFFFD700), fontSize = 18.sp, fontWeight = FontWeight.Bold))
Text(text = period, style = TextStyle(
color = Color(0xFFFFFAF0).copy(alpha = 0.6f), fontSize = 12.sp))
}
}
}
}Visual selection feedback: The selected state uses a brighter background (0x2A1810 vs 0x1A0F08) and shows a golden circle with a dark cross inside. The unselected state shows just a faint golden circle outline, maintaining visual consistency while clearly indicating state.
Main Paywall Screen
Finally, the complete screen composable brings everything together:
@Composable
fun BiblePaywallScreen(onDismiss: () -> Unit = {}) {
var selectedPlan by remember { mutableStateOf("yearly") }
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color(0xFF1A0F08), // Deep brown-black
Color(0xFF2A1810), // Warm dark brown
Color(0xFF3D2517), // Medium brown
Color(0xFF1A0F08), // Back to deep
),
),
),
) {
// Animated background layer
BibleBackground()
// Content layer
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(80.dp))
Text(text = "WALK IN FAITH", style = TextStyle(
color = Color(0xFFFFD700).copy(alpha = 0.9f),
fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp))
Text(text = "Bible Pro", style = TextStyle(
fontSize = 36.sp, fontWeight = FontWeight.Bold,
brush = Brush.linearGradient(listOf(
Color(0xFFFFD700), Color(0xFFFFC857), Color(0xFFFFE4B5)))))
Spacer(modifier = Modifier.weight(1f))
// Plan options
BiblePlanOption(title = "Lifetime", price = "$79.99",
period = "one-time", badge = "BLESSED",
isSelected = selectedPlan == "lifetime",
onClick = { selectedPlan = "lifetime" })
Spacer(modifier = Modifier.height(10.dp))
BiblePlanOption(title = "Yearly", price = "$29.99",
period = "per year", isSelected = selectedPlan == "yearly",
onClick = { selectedPlan = "yearly" })
Spacer(modifier = Modifier.weight(1f))
// Subscribe button
Button(
onClick = { /* Handle purchase */ },
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
) {
Box(
modifier = Modifier.fillMaxSize().background(
brush = Brush.linearGradient(listOf(
Color(0xFFFFD700), Color(0xFFFFC857), Color(0xFFFFE4B5))),
shape = RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) {
Text(text = "Begin Your Journey", style = TextStyle(
color = Color(0xFF1A0F08), fontSize = 16.sp, fontWeight = FontWeight.Bold))
}
}
}
}
}Layer ordering: The BibleBackground() is placed first in the Box, so it renders behind the content Column. Both fill the entire screen, with the content floating on top of the animated background.
Conclusion
0:02:00Congratulations! You've built a beautiful divine Bible paywall with heavenly animations.
What you've learned
Throughout this codelab, you've learned how to create light ray effects using trigonometry and Path objects. You built soft, drifting clouds using multiple overlapping circles with radial gradients. You implemented twinkling stars with smooth oscillating animations. You created a particle system for rising golden orbs with horizontal sway. You drew animated doves with wing flapping using quadratic bezier curves. You built an elaborate shiny cross effect with multiple glow layers, rotating rays, and sparkle points. Finally, you composed all these elements into a cohesive, spiritually-themed paywall screen.
Next steps
To take this paywall to production, consider integrating with RevenueCat for actual purchase handling. You can customize the scripture quote and messaging for your specific audience. Consider adding subtle ambient audio like soft chimes or choir sounds. You might also experiment with different color palettes for various themes such as sunrise, sunset, or night sky variations.
Source Code
The complete source code for this codelab is available on GitHub: compose-paywall-animations
Resources
For further learning, check out the RevenueCat Documentation for subscription management, the Jetpack Compose Animation Guide for advanced animation techniques, and the Compose Canvas Drawing documentation for custom graphics.
Peace be with you on your coding journey!