概要

0:03:00

このコードラボへようこそ。ここでは、天上のアニメーションを備えた神聖な聖書アプリのペイウォールを Jetpack Compose で構築します。

このコードラボでは、静謐でスピリチュアルなテーマのペイウォール画面の作り方を学びます。上空から降り注ぐ金色の光の筋、柔らかく流れる雲、空にまたたく星、立ち上る金色のパーティクル、羽ばたきながら飛ぶ鳩のアニメーション、そして輝きを放つ美しく描画された十字架を、このペイウォールに盛り込みます。

このコードラボを終えるころには、神々しい存在感を醸し出す、穏やかで心に響くペイウォールが完成します。聖書アプリや祈りのジャーナル、瞑想アプリにぴったりの仕上がりです。

Bible Paywall Preview

前提条件

このコードラボを始める前に、Kotlin と Jetpack Compose の基礎知識が必要です。Compose をサポートした Android Studio または IntelliJ IDEA をインストールしておいてください。ビジュアルエフェクトでは Canvas による描画と三角関数を多用するため、これらに慣れていると理解がスムーズになります。

作成するもの

複数のアニメーションレイヤーを重ね、天上の雰囲気を演出する、聖書テーマの完成したペイウォール画面です。暖かな金色と柔らかな輝きを基調に、平安とスピリチュアルさを感じさせるデザインに仕上げます。

プロジェクトのセットアップ

0:05:00

まずは聖書ペイウォールの基本構造をセットアップしましょう。BiblePaywallScreen.kt という名前で新しい Kotlin ファイルを作成し、必要な import とデータクラスを追加します。

必要な import

Canvas 描画、アニメーション、Compose UI コンポーネントに必要な import をすべて追加します。ビジュアル要素の角度や位置を計算するため、kotlin.math の三角関数を利用します。

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()

なぜ TAU を定義するのか? TAU(2π)はラジアンで表した円一周分を意味します。PI は円の半分ですが、TAU を使うと円周の計算がより直感的になります。要素を円周上に均等に配置したり、回転アニメーションを作ったりするとき、TAU を使えば計算がかなりシンプルになります。

ビジュアル要素のためのデータクラス

このアニメーションシステムでは、5 種類のビジュアル要素を使います。それぞれに、位置・動き・見た目のプロパティを保持する専用のデータクラスを用意します。

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
)

なぜ一部のプロパティに var を使うのか? アニメーション中に変化するプロパティ(位置など)は var で宣言し、固定のプロパティ(サイズや速度の定数など)は val を使います。こうすると、どの値が可変かがひと目でわかり、一定のままであるべきプロパティを誤って変更してしまうのを防げます。

光の筋と天上の輝き

0:08:00

光の筋は、神々しい雰囲気の土台になります。この金色の光線は画面上部の中央から広がり、天上の光が降り注ぐ印象を作り出します。三角関数で光線の位置を計算し、なめらかにフェードさせるためのグラデーションを適用します。

神聖なカラーパレット

まずはカラーパレットを定義しましょう。この暖かな金色のトーンが、神々しい温もりとスピリチュアルさを演出します。

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
}

色選びは重要です: 金色(0xFFFFD700、0xFFFFC857)は、多くの文化圏で温もりと神聖さを連想させます。オフホワイト(0xFFFFFAF0、別名「Floral White」)は、純白のような鋭さがなく柔らかさを添え、より穏やかな雰囲気を生み出します。

光の筋を初期化する

画面上部の中心点から放射状に広がる 12 本の光の筋を作成します。光の筋は、垂直の中心線を基準に計算した角度で配置します。

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,
      ),
    )
  }
}

角度の計算を理解する: -PI/2 + (i - 5.5f) * 0.12f という式は次のように動きます。まず -PI/2 は真下を指します(Compose の座標系では 0 ラジアンが右方向、-PI/2 が下方向です)。次に (i - 5.5f) が 12 本の光線に対して -5.5 から +5.5 の値を生成し、ゼロを中心に配置します。最後に 0.12f(約 7 度)を掛けることで光線どうしを広げ、合計およそ 84 度をカバーする扇状のエフェクトを作り出します。

天上の輝きを描く

背景の輝きには、画面上部を中心とした放射状グラデーションを使います。これにより、上空から光が放たれているような効果が生まれます。

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),
  )
}

pulse の計算: sin(time * 0.5f) * 0.1f + 0.9f はなめらかな振動を生み出します。sin() 関数は -1 から +1 の値を返します。0.1 を掛けると範囲が -0.1 から +0.1 に縮まり、0.9 を足すと 0.8 から 1.0 に移ります。これにより、輝きがゆっくり明るくなったり暗くなったりする、控えめな呼吸のような効果が生まれ、静的なグラデーションに生命感が加わります。

光の筋を描く

各光線は、画面上部の中心から放たれ、下に伸びるにつれて幅が広がる三角形のパスとして描きます。

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),
    ),
  )
}

垂直ベクトルの計算: 光線に幅を持たせるには、その向きに直交するベクトルが必要です。光線の向きが (cos(angle), sin(angle)) なら、直交ベクトルは (-sin(angle), cos(angle)) になります。これは 90 度の回転です。これに光線の幅を掛けて、光線の縁のオフセット点を求めます。

パスの形: このパスは、起点(上部中央)で細く、末端で広い台形を作ります。起点の 0.1 という係数によってほぼ点として始まり、光線らしい形になります。線形グラデーションによって、光源で明るく、末端に向かって透明になっていきます。

雲とまたたく星

0:07:00

雲と星は、天上のシーンに奥行きと雰囲気を加えます。雲は空をゆっくり流れ、星はさまざまな強さでまたたきます。これらの要素は、目障りにならない程度に穏やかな動きを生み出します。

雲と星を初期化する

次の初期化ブロックを initialize 関数に追加します。

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,
    ),
  )
}

星の配置戦略: 星は画面上部 50%(height * 0.5f)にのみ配置します。このシーンでは、そこが「空」の部分だからです。それより下に配置すると UI 要素と重なり、不自然に感じられます。

雲の位置の範囲: random.nextFloat() * width * 1.5f - width * 0.25f という式は、画面幅の -25% から +125% までの範囲を生成します。これにより雲が画面外から始まるため、突然現れるのではなく自然に流れ込んでこられます。

雲を描く

雲は、放射状グラデーションを施した複数の円を重ねて描き、柔らかくふわりとした見た目にします。

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),
    )
  }
}

ループアニメーションの仕組み: 剰余演算 % (screenWidth + cloud.width * 2) がシームレスなループを生み出します。雲が右端を通り過ぎると、ふたたび左端から始まるように折り返します。cloud.width * 2 の余裕を持たせることで、折り返す前に雲が完全に画面外に出るため、目に見える「飛び」が起きません。

雲の形を作る: 位置とサイズがそれぞれ異なる 5 つの円を使い、有機的な雲の形を作ります。Y オフセットの sin(i * 1.2f) とサイズ変化の sin(i * 0.8f) が、不規則で自然に見えるふくらみを生みます。異なる係数(1.2 と 0.8)を使うことで、パターンが規則的になりすぎるのを防ぎます。

またたく星を描く

星は 2 本の直交する線で作る 4 方向の形にし、その背後に輝きのエフェクトを加えます。

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 の正規化: sin(...) * 0.5f + 0.5f という式は、サイン波を (-1, 1) の範囲から (0, 1) の範囲へ変換します。これにより星が完全に消えてしまう(不自然に見えます)ことがなく、ピーク時には最大の明るさに達します。

twinkle に合わせたサイズ変化: star.size * (0.5f + twinkle * 0.5f) という式は、明るさに同期して星のサイズを基本サイズの 50% から 100% の間で振動させます。これにより、単に透明度が変わるだけでなく、星が脈打つように見える、よりリアルなまたたき効果が生まれます。

金色のパーティクルと鳩

0:08:00

金色のパーティクルは、祈りや祝福のように立ち上り、鳩は画面を優雅に横切っていきます。これらの動く要素が、ペイウォールに生命感とスピリチュアルな象徴性を添えます。

パーティクルの生成と更新

パーティクルシステムは、画面下部で新しいパーティクルを絶えず生成し、画面外に出たら取り除きます。

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()
    }
  }
}

生成レートの制御: 15% の確率(random.nextFloat() < 0.15f)と最大 40 個という上限を組み合わせることで、途切れないが過剰にはならないパーティクルの流れを作ります。60fps では平均で毎秒およそ 9 個のパーティクルが生成されますが、ランダム性のおかげで機械的で予測可能なパターンにはなりません。

横揺れの仕組み: サインを使った揺れ sin(particle.swayPhase) * 0.5f が、穏やかな左右の動きを生みます。各パーティクルは自身の swayPhase を持ち、それが swaySpeed で増えていくため、パーティクルは一斉にではなく個別に揺れます。0.5 の係数によって動きが控えめに保たれます。

金色のパーティクルを描く

パーティクルは、白から金へのグラデーションを持つ、光るオーブとして描きます。

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),
  )
}

またたき速度の選び方: sin(time * 3f + ...) の係数 3 によって、パーティクルは 1 秒あたりおよそ 3 回またたきます。これは星のまたたきより速く、動きと生命感を感じさせる、より躍動的できらめいた見た目になります。

鳩を描く

鳩はもっとも複雑な要素で、胴体・頭・翼・尾を表す複数の図形で構成します。

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),
  )
}

羽ばたきのアニメーション: sin(time * 8f + dove.wingPhase) という式は、1 秒あたりおよそ 8 サイクルで翼を動かし、実際の鳥の羽ばたきを模します。wingFlap の値が二次曲線の制御点を変化させ、翼が「羽ばたく」につれて弧を高くしたり低くしたりします。

翼のための二次曲線: quadraticTo 関数は、現在位置から終点へ、制御点に向かって曲がるなめらかな曲線を描きます。制御点の Y 座標を wingFlap でアニメーションさせることで、複雑なアニメーションコードなしに説得力のある翼の動きを作り出せます。

輝く十字架のエフェクト

0:10:00

輝く十字架は、この天上のシーンの主役です。複数の輝きのレイヤー、回転する光線、きらめく金色の表面、要所のスパークルを備えています。これにより、真に光り輝く神々しい存在感が生まれます。

多層の輝きエフェクト

十字架は、強さが脈動する 3 つの同心円状の輝きレイヤーに囲まれています。

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),
  )
}

3 つのアニメーション速度: pulse、glow、shimmer にそれぞれ異なる速度(1.5、2、4)を使うことで、アニメーションが同期するのを防ぎ、より有機的で生き生きとした見た目になります。すべてのアニメーションが同じ速度だと、機械的で単調な効果に見えてしまいます。

輝きの重ねがけ技法: 半径が小さくなるにつれ不透明度が増す 3 つの同心円が、リアルな輝きの減衰を生み出します。最外層(180px)はごく控えめで、中間層(120px)が温もりを加え、最内層(70px)が十字架のすぐ近くで明るい強さをもたらします。

回転する光線

12 本の光線が十字架の中心から放たれ、ゆっくり回転してダイナミックな後光の効果を作り出します。

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,
  )
}

光線を均等に分散する: (i.toFloat() / rayCount) * TAU という式は、円一周(TAU = 2π)を 12 等分し、光線を 30 度おきに配置します。time * 0.3f を足すことで、すべての光線がそろって、ゆっくりと荘厳なペースで回転します。

光線ごとの変化: 各光線は、i * 0.5fi * 0.8f のオフセットを使って、長さと不透明度をわずかに変えます。これにより、すべての光線が一斉に脈動するのを防ぎ、より自然できらめく見た目になります。

十字架の形を描く

十字架そのものは、グラデーションで塗りつぶし、ハイライトの縁を付けた角丸長方形で描きます。

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),
)

十字架の比率: 伝統的なキリスト教の十字架では、横棒が縦棒の上寄りに配置されます。18 ピクセルのオフセット(cy - 18f * pulse)が横棒を中心から約 20% 上に置き、期待される比率に合わせます。

多段グラデーション: 5 色のグラデーション(白 → コーンシルク → 金 → コーンシルク → 白)が、金属的で磨き上げられた見た目を生み出します。縁の明るい色と中央の暖かい金色が、湾曲した金色の表面での光の反射を再現します。

完成したペイウォール画面

0:08:00

いよいよ、すべてのパーツを組み合わせて完成したペイウォール画面を作り上げましょう。アニメーション背景の Composable、独自スタイルのプラン選択オプション、そしてメイン画面のレイアウトを作成します。

BibleBackground Composable

この Composable はアニメーションループを管理し、すべての背景要素を描画します。

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)
  }
}

フレームのタイミングについて: withFrameNanos 関数は現在のフレームのタイムスタンプをナノ秒で返します。これを 10 億(1_000_000_000f)で割って秒に変換します。coerceIn(0f, 0.1f) は、アプリが一時停止または中断されていた場合のアニメーションの飛びを防ぎ、デルタタイムを最大 100ms に制限します。

canvasSize を別途追跡する理由: Canvas のサイズは Composable がレイアウトされるまで利用できません。それを state に保存し、初期化前に有効かを確認することで、アニメーションシステムが正しい寸法を使えるようにします。

プラン選択オプション

各サブスクリプションプランは、十字架をあしらった独自のチェックボックスとともに表示します。

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))
      }
    }
  }
}

視覚的な選択フィードバック: 選択状態では、より明るい背景(0x2A1810、非選択時は 0x1A0F08)を使い、内側に暗い十字架を配した金色の円を表示します。非選択状態では、うっすらとした金色の円の輪郭だけを表示し、状態を明確に示しつつ視覚的な一貫性を保ちます。

メインのペイウォール画面

最後に、完成した画面の Composable がすべてをひとつにまとめます。

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))
        }
      }
    }
  }
}

レイヤーの重ね順: BibleBackground() は Box の最初に配置されるため、コンテンツの Column の背後に描画されます。両方とも画面全体を占め、コンテンツがアニメーション背景の上に浮かびます。

まとめ

0:02:00

おめでとうございます! 天上のアニメーションを備えた、美しく神聖な聖書ペイウォールが完成しました。

学んだこと

このコードラボを通して、三角関数と Path オブジェクトを使った光の筋のエフェクトの作り方を学びました。放射状グラデーションを施した複数の円を重ねて、柔らかく流れる雲を作りました。なめらかに振動するアニメーションで、またたく星を実装しました。横揺れしながら立ち上る金色のオーブのために、パーティクルシステムを作成しました。二次ベジェ曲線を使って、羽ばたきながら飛ぶ鳩のアニメーションを描きました。複数の輝きレイヤー、回転する光線、スパークルの要所を備えた、手の込んだ輝く十字架のエフェクトを作り上げました。そして最後に、これらすべての要素を、まとまりのあるスピリチュアルなテーマのペイウォール画面へと組み上げました。

次のステップ

このペイウォールを本番に持っていくには、実際の購入処理のために RevenueCat との連携を検討してください。ターゲットとする利用者層に合わせて、聖句の引用やメッセージをカスタマイズできます。柔らかなチャイムや聖歌のような、控えめな環境音を加えるのもよいでしょう。日の出、日没、夜空など、さまざまなテーマに合わせて別のカラーパレットを試してみるのもお勧めです。

ソースコード

このコードラボの完全なソースコードは GitHub で公開しています: compose-paywall-animations

リソース

さらに学びたい方は、サブスクリプション管理については RevenueCat ドキュメントを、高度なアニメーション技法については Jetpack Compose アニメーションガイドを、カスタムグラフィックスについては Compose Canvas 描画のドキュメントをご覧ください。

あなたのコーディングの旅に、平安がありますように!