Overview

0:03:00

Welcome 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.

Bible Paywall Preview

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:00

Let'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:

kotlin
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:

kotlin
// 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:00

The 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:

kotlin
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:

kotlin
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:

kotlin
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:

kotlin
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:00

Clouds 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:

kotlin
// 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:

kotlin
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:

kotlin
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:00

Golden 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:

kotlin
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:

kotlin
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:

kotlin
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:00

The 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:

kotlin
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:

kotlin
// 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:

kotlin
// 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:00

Now 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:

kotlin
@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:

kotlin
@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:

kotlin
@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:00

Congratulations! 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!