ペイウォールは、アプリと収益をつなぐ重要な接点です。既成テンプレートを使えば素早く立ち上げられますが、ブランドのアイデンティティに合い、記憶に残るユーザー体験を生み出すカスタムペイウォールを作るべき理由も十分にあります。たとえば、昼と夜のテーマを切り替えるようにユーザーの状況に反応するアニメーション付きペイウォールは、静的テンプレートでは実現できない感情的なつながりを生み出せます。丁寧に作り込まれたペイウォールがもたらす心理的な効果は、単なる見た目の良さに留まりません。ユーザーに「このアプリは洗練されていて、プロフェッショナルで、投資する価値がある」と伝えるシグナルになります。

この記事では、Jetpack Composeで高度なアニメーション付きペイウォールを構築する方法を学びます。「Day & Night」ペイウォールの実装を深掘りし、各アニメーション手法をステップごとに解説します。さらに、Firebaseを使ってカスタムペイウォールのコンテンツを俊敏にA/Bテストする方法も学び、RevenueCatのPaywall Builderを選ぶほうが適しているケースも把握できます。読み終える頃には、コンバージョンにつながるカスタムペイウォールを「いつ」「どのように」作るべきかを、総合的に理解できるはずです。

カスタムペイウォールを作る:Day & Night の深掘り

ここでは、16秒サイクルで昼と夜のテーマが切り替わる、プロダクション品質のアニメーション付きペイウォールを見ていきます。この実装では、複数の高度なComposeアニメーション手法が調和して連携しています。ペイウォールは、太陽が昇って沈み、星がきらめきながら現れ、雲が空を流れ、UI要素が時間帯に合わせて色を変える——といった、動的に変化する没入感のある環境を作り出します。この連続的なサイクルは注意を引きつけ、静的なペイウォールでは実現できない感情的なエンゲージメントを生み出します。

アーキテクチャ概要

Day & Night ペイウォールは、関心事を明確に分離したレイヤードアーキテクチャを採用しています。土台となるのは DayNightBackground コンポーザブルで、空のグラデーション遷移、個別にきらめく挙動を持つプロシージャル生成の星空、空を弧を描いて移動する太陽と月、継続的に流れるパララックス雲レイヤー、そしてシーン全体を支えるランドスケープのグラデーションなど、すべての環境アニメーションを担当します。

このアニメーション背景の上に、コンテンツオーバーレイとして実際のペイウォール情報が配置されます。サイクルに応じて「GOOD MORNING」と「GOOD EVENING」がクロスフェードする挨拶文、昼夜の状態に合わせて色が変化するアニメーション付きチェックマークの機能リスト、アクセントカラーがアニメーションする価格表示、そして昼は温かみのあるゴールド、夜はクールなインディゴへと遷移するCTAボタンが含まれます。

このレイヤードアーキテクチャにはいくつもの利点があります。背景のアニメーションロジックがコンテンツ表示から切り離されているため、両者をそれぞれ独立して変更しやすくなります。背景は必要に応じて他のコンテキストで再利用でき、コンテンツレイヤーは複雑なアニメーションコードに触れることなく更新可能です。さらに、この分離によって、Composeが各レイヤーごとに再コンポーズを最適化できるため、パフォーマンスの向上にもつながります。

アニメーションサイクルのセットアップ

このペイウォール全体は、16秒かけて 0 から 1 までループする単一の cycleProgress値によって駆動されます。この長さは慎重に選ばれていて、遷移をじっくり味わえるだけの余裕がありつつ、急かされている感じはしません。一方で、一般的なペイウォールの閲覧時間の中でユーザーがサイクル全体を体験できるくらいには短くもあります。

1@Composable
2fun DayNightPaywallScreen(onDismiss: () -> Unit = {}) {
3    val paywallState = rememberPaywallState(onPurchaseSuccess = onDismiss)
4    val infiniteTransition = rememberInfiniteTransition(label = "daynight")
5
6    val cycleProgress by infiniteTransition.animateFloat(
7        initialValue = 0f,
8        targetValue = 1f,
9        animationSpec = infiniteRepeatable(
10            animation = tween(16000, easing = LinearEasing),
11            repeatMode = RepeatMode.Restart,
12        ),
13        label = "cycle",
14    )
15
16    val isDay = cycleProgress < 0.5f
17    // ...
18}

このパターンは、複雑なアニメーションにおいて重要です。単一の進捗値を「唯一の真実(source of truth)」として扱い、そこから他のすべてのアニメーションを派生させます。こうすることで完全な同期が保証され、アニメーションの挙動も理解しやすくなります。このペイウォールのように複数のアニメーションが連動する必要がある場合、ドライバーを1つにすることでタイミングのズレ(ドリフト)をなくし、要素同士の関係を明確にできます。

LinearEasing を選んでいるのも意図的です。時間の経過を表す連続的なサイクルでは、線形の進行が最も自然に感じられます。非線形のイージングにすると、日中のある時間帯が速く感じたり遅く感じたりしてしまい、このメタファーが崩れます。 RepeatMode.Restart によって、深夜から夜明けへとシームレスにループできるようにしています。

サイクルは2つのフェーズに分かれています。昼のフェーズは 0.0〜0.5 で、この間に太陽が昇り、空を横切って沈みます。夜のフェーズは 0.5〜1.0 で、同じ軌道を月が辿ります。この対称的な分割により、ユーザーは昼と夜の両モードを同じだけ体験でき、2つのビジュアル表現の露出を最大化できる、バランスの取れた体験になります。

アニメーション背景の実装

DayNightBackground composable が、環境全体のアニメーションをすべて担当します。ここがまさに「魔法」が起きる場所で、シンプルな進捗値を、生き生きと動くシーンへと変換します。各レイヤーを分解して、どのように連携して動いているのかを理解していきましょう。

手続き的な星の生成

星は一度だけ生成して remember で保持し、自然なばらつきが出るようにプロパティをランダム化します。手続き的アプローチを採ることで、セッションごとにユニークな星空が生成され、体験にさりげない多様性が加わります。

1data class NightStar(
2    val x: Float,
3    val y: Float,
4    val size: Float,
5    val twinklePhase: Float,
6    val twinkleSpeed: Float,
7)
8
9@Composable
10fun DayNightBackground(cycleProgress: Float, modifier: Modifier = Modifier) {
11    val stars = remember {
12        List(60) {
13            NightStar(
14                x = Random.nextFloat(),
15                y = Random.nextFloat() * 0.6f,  // Upper 60% of sky only
16                size = Random.nextFloat() * 2f + 1f,
17                twinklePhase = Random.nextFloat() * PI.toFloat() * 2f,
18                twinkleSpeed = Random.nextFloat() * 2f + 1f,
19            )
20        }
21    }
22    // ...
23}

各星はそれぞれ固有の「きらめき(twinkle)」の位相と速度を持ち、同じタイミングで一斉に点滅しないようになっています。この非同期化はリアリティにとって重要で、同期したきらめきは不自然で機械的に見えてしまいます。 y 座標は 0.6 で上限を設け、星が本来あるべき空の上部に収まるようにしています。これにより、画面下部の地形(ランドスケープ)領域に星が出現するのを防げます。

星の数を 60 個にすることで、見た目の密度とパフォーマンスのバランスを取っています。星が少なすぎると夜空がスカスカに見え、逆に大幅に増やすと低スペック端末で描画パフォーマンスに影響する可能性があります。サイズを 1〜3px の範囲(Random.nextFloat() * 2f + 1f)にすることで奥行き感が生まれ、小さい星ほど遠くにあるように見せられます。

remember ブロックにより、星はコンポジションごとに一度だけ生成され、ペイウォールがサイクルで変化しても一貫性が保たれます。これがないと、再コンポーズのたびに星が再生成されてしまい、カオスなチラつきが発生します。

フレーム精度のデルタタイム

フレームレートに依存しない滑らかなアニメーションのために、実際に経過した時間を追跡します。この手法はプロ品質のアニメーションにおける基本で、性能特性が異なるデバイス間でも一貫した挙動を保証します。

1var totalTime by remember { mutableFloatStateOf(0f) }
2var lastFrameTimeNanos by remember { mutableLongStateOf(0L) }
3
4LaunchedEffect(Unit) {
5    while (true) {
6        withFrameNanos { frameTimeNanos ->
7            val deltaTime = if (lastFrameTimeNanos == 0L) {
8                0.016f  // Assume 60fps for first frame
9            } else {
10                ((frameTimeNanos - lastFrameTimeNanos) / 1_000_000_000f)
11                    .coerceIn(0f, 0.1f)  // Cap to prevent jumps
12            }
13            lastFrameTimeNanos = frameTimeNanos
14            totalTime += deltaTime
15        }
16    }
17}

このアプローチには2つのメリットがあります。1つ目は、端末性能に関係なくアニメーションが滑らかに保たれることです。30fps で動く端末でも 120fps の端末でも、同じ速度でアニメーションが進行します。2つ目は、デバイス間でアニメーション速度が統一されることです。低価格帯の端末でもハイエンド端末でも、ユーザーは同じ体験を見られます。

coerceIn(0f, 0.1f) は、アプリがバックグラウンドから復帰したときのアニメーションの「飛び」を防ぎます。この上限がない場合、アプリがサスペンドされている間のデルタタイムが数秒になることがあり、雲の移動のような連続アニメーションで不自然なジャンプが発生します。デルタタイムを 0.1 秒(100ms)に制限することで、短時間の中断後でも滑らかに復帰できます。

ここで重要になるのが withFrameNanos 関数です。次のフレームまでサスペンドし、ナノ秒単位で正確なフレームタイムスタンプを提供します。長時間動作するアニメーションでタイミング誤差が蓄積し得る delay() のような代替手段よりも、精度が高いのが特長です。

マルチフェーズの空グラデーション

空は 8 つの明確な色フェーズを順に遷移し、夜明け → 昼 → 夕暮れ → 夜 → 夜明けへと戻る、現実感のあるサイクルを描きます。各フェーズでは 3 色のグラデーションを使い、空に奥行きを与えています。

1val sunriseColors = listOf(Color(0xFFFF8C42), Color(0xFFFFD700), Color(0xFFFFF4E0))
2val dayColors = listOf(Color(0xFF4A90D9), Color(0xFF87CEEB), Color(0xFFB8E0F0))
3val sunsetColors = listOf(Color(0xFFFF6B35), Color(0xFFFFAB5E), Color(0xFFFFD89E))
4val duskColors = listOf(Color(0xFF2C3E50), Color(0xFF34495E), Color(0xFF5D6D7E))
5val nightColors = listOf(Color(0xFF0D1B2A), Color(0xFF1B263B), Color(0xFF2C3E50))
6val dawnColors = listOf(Color(0xFF1A1A2E), Color(0xFF16213E), Color(0xFF1F4068))

色の選定は意図的で、実際の空の観察に基づいています。日の出では暖かみのあるオレンジやゴールドから始まり、グラデーション上部に向かって淡い色調へと移ろいます。昼はおなじみのスカイブルーを基調に、地平線に近づくにつれて明るくなります。夕焼けでは、より深いオレンジや赤で暖色が強調されます。薄暮では光が失われ、くすんだブルーグレーが現れます。夜は上部が濃いネイビーからほぼ黒に近づき、次のサイクルに向けて夜明けでは再びわずかな暖色の兆しが加わります。

フェーズ計算では、各ハーフサイクル内での進捗を正規化し、昼または夜のフェーズそれぞれで 0〜1 のローカル進捗値を作ります。

1val isDay = cycleProgress < 0.5f
2val dayProgress = if (isDay) cycleProgress * 2f else (cycleProgress - 0.5f) * 2f

この正規化により遷移ロジックが単純化され、全体サイクルのどの位置にあっても、各フェーズは一貫して 0〜1 の範囲で扱えます。cycleProgress が 0.5 未満のときが昼フェーズ、上半分(0.5〜1.0)が夜フェーズです。

その後、各フェーズは lerp (線形補間)を使って滑らかに遷移します。進捗パラメータに基づいて色同士をブレンドします。

1val skyColors = if (isDay) {
2    when {
3        dayProgress < 0.2f -> {
4            val t = dayProgress / 0.2f  // Normalize to 0-1 within this phase
5            listOf(
6                lerp(sunriseColors[0], dayColors[0], t),
7                lerp(sunriseColors[1], dayColors[1], t),
8                lerp(sunriseColors[2], dayColors[2], t),
9            )
10        }
11        dayProgress < 0.8f -> dayColors  // Hold steady during midday
12        else -> {
13            val t = (dayProgress - 0.8f) / 0.2f
14            listOf(
15                lerp(dayColors[0], sunsetColors[0], t),
16                lerp(dayColors[1], sunsetColors[1], t),
17                lerp(dayColors[2], sunsetColors[2], t),
18            )
19        }
20    }
21} else {
22    // Similar structure for night phases...
23}

この設計により、自然に感じられる遷移が実現します。各ハーフサイクルのうち、日の出・日の入りはそれぞれ 20% を占め、残り 60% は安定した昼/真夜中の状態が保たれます。このテンポは、地平線付近で遷移が比較的速く起こり、昼夜の大半は安定しているという現実の採光パターンを模しています。また、この 20/60/20 の配分により、ペイウォールを短時間しか見ないユーザーでも何らかの変化を目にしやすく、体験を動的に保てます。

フェード遷移を伴う星の瞬き

星は夜明けと夕暮れのタイミングでフェードイン/フェードアウトしつつ、夜の間はそれぞれが個別に瞬く必要があります。この「二重アニメーション」の要件は、エフェクトをきれいにレイヤー化する方法を示しています。

1val starAlpha = if (isDay) {
2    when {
3        dayProgress < 0.15f -> 1f - dayProgress * 6f     // Fade out at dawn
4        dayProgress > 0.85f -> (dayProgress - 0.85f) * 6f // Fade in at dusk
5        else -> 0f  // Invisible during day
6    }
7} else {
8    when {
9        dayProgress < 0.15f -> dayProgress * 6f           // Fade in after sunset
10        dayProgress > 0.85f -> 1f - (dayProgress - 0.85f) * 6f // Fade out before sunrise
11        else -> 1f  // Fully visible at night
12    }
13}
14
15if (starAlpha > 0f) {
16    for (star in stars) {
17        val twinkle = (sin(totalTime * star.twinkleSpeed + star.twinklePhase) + 1f) / 2f
18        val alpha = starAlpha * (0.4f + twinkle * 0.6f)  // Range: 40% to 100%
19        drawCircle(
20            color = Color.White.copy(alpha = alpha),
21            radius = star.size,
22            center = Offset(star.x * width, star.y * height),
23        )
24    }
25}

フェードのタイミングは、各フェーズの端に 15% のウィンドウを設けており、滑らかな遷移になります。倍率の 6(1/0.15 から導出)によって、フェードがそのウィンドウ内でちょうど完了するように調整されています。昼フェーズでは、時間の 70% は星が不可視(alpha 0)で、夜明けと夕暮れの遷移中にだけ星が現れます。

瞬き(twinkle)効果は、各星が持つ固有の位相でオフセットされたサイン波を使います。サイン関数は自然に -1〜1 の間を往復するため、 (sin(...) + 1f) / 2f で 0〜1 に正規化します。これに starAlpha を掛けることで全体のフェード効果を作り、さらに内側の計算 (0.4f + twinkle * 0.6f) によって星が完全に消えないようにしています。つまり、明るさは 40%〜100% の間で揺れ、実際の星のような大気による瞬きに近い見え方になります。

パフォーマンス最適化として、 if (starAlpha > 0f) により、昼の間(星が見えないとき)は星の描画ループ自体をスキップします。これにより、不要な計算を避けられます。

太陽と月の弧を描く軌道

太陽と月は、空の上を同じ弧のパスで移動し、説得力のある天体の動きを作り出します。ここで使っている三角関数によって、地平線から地平線へと自然に移る軌道になります。

1val sunMoonY = height * 0.35f  // Arc center Y position
2val arcRadius = width * 0.6f   // Arc radius
3val centerX = width / 2f
4
5if (isDay) {
6    val sunAngle = PI.toFloat() * (1f - dayProgress)  // PI to 0 as progress increases
7    val sunX = centerX + cos(sunAngle) * arcRadius
8    val sunY = sunMoonY - sin(sunAngle) * arcRadius * 0.5f + height * 0.1f
9
10    if (sunY < height * 0.7f) {  // Only draw when above horizon
11        // Draw sun...
12    }
13}

この三角関数は、パラメトリックな計算で滑らかな弧を作ります。 dayProgress = 0 のとき角度は PI (180度)なので、 cos(PI) = -1 により太陽は画面の左側に位置します。 dayProgress = 0.5 のとき角度は PI/2 (90度)となり、 cos(PI/2) = 0 かつ sin(PI/2) = 1 なので、太陽は画面上部の中央に配置されます。 dayProgress = 1 のとき角度は 0 になり、cos(0) = 1によって太陽は画面の右側に位置します。

Y成分に 0.5f の倍率を掛けることで、円弧ではなく楕円弧になり、空の高い位置まで上がりすぎない、より自然な「地平線から地平線」への軌道になります。さらに height * 0.1f のオフセットで弧全体を下方向にずらし、太陽と月が可視領域の下から昇って、また下へ沈むように見せることで、適切な地平線の演出を作っています。

また、 if (sunY < height * 0.7f) という地平線チェックによって、天体が地形(ランドスケープ)エリアの下にあるときは描画しないようにしています。このシンプルなカリングにより、日の出・日の入り(および月の出・月の入り)の切り替わりがきれいになり、太陽と月が自然に地平線から現れて沈むように見えます。

アニメーションする光線を備えた太陽の描画

太陽は、発光して“生きている”ように見せるために複数レイヤーで構成されています。各レイヤーには役割があり、それらが合わさって放射するような暖かさの印象を作ります。

1// Outer glow
2drawCircle(
3    brush = Brush.radialGradient(
4        colors = listOf(
5            Color(0xFFFFFFCC).copy(alpha = 0.4f),
6            Color(0xFFFFD700).copy(alpha = 0.2f),
7            Color.Transparent,
8        ),
9        center = Offset(sunX, sunY),
10        radius = 80f,
11    ),
12    radius = 80f,
13    center = Offset(sunX, sunY),
14)
15
16// Main sun body
17drawCircle(
18    brush = Brush.radialGradient(
19        colors = listOf(
20            Color(0xFFFFFFE0),
21            Color(0xFFFFD700),
22            Color(0xFFFFA500),
23        ),
24        center = Offset(sunX - 8f, sunY - 8f),  // Offset for 3D effect
25        radius = 35f,
26    ),
27    radius = 35f,
28    center = Offset(sunX, sunY),
29)
30
31// Animated rays
32for (i in 0 until 12) {
33    val rayAngle = (i * 30f + totalTime * 20f) * PI.toFloat() / 180f
34    val innerRadius = 40f
35    val outerRadius = 55f + sin(totalTime * 3f + i) * 5f  // Pulsing length
36    drawLine(
37        color = Color(0xFFFFD700).copy(alpha = 0.6f),
38        start = Offset(
39            sunX + cos(rayAngle) * innerRadius,
40            sunY + sin(rayAngle) * innerRadius,
41        ),
42        end = Offset(
43            sunX + cos(rayAngle) * outerRadius,
44            sunY + sin(rayAngle) * outerRadius,
45        ),
46        strokeWidth = 3f,
47        cap = StrokeCap.Round,
48    )
49}

外側のグロー(発光)には、透明へ向かってフェードする放射状グラデーションを使い、太陽の周りに柔らかなハロー(光の輪)を作ります。この大気的なグローが奥行きを与え、太陽がただの平坦な円として空間に浮いて見えるのを防ぎます。

太陽本体は、グラデーションの中心を少しオフセット(上と左へ8ピクセル)して、控えめな3D感を作っています。このオフセットにより片側がより明るく見え、太陽に厚みがあり、光が一定方向から当たっているかのように感じられます。中心の淡い黄色から金色を経て、縁のオレンジへと移る色の変化は、太陽の写真で見られる色の階調を再現しています。

光線(レイ)は2つの方法でアニメーションします。1つ目は、角度計算にある totalTime * 20f によってゆっくり回転し、スピンしているような効果を作ります。2つ目は、 sin(totalTime * 3f + i) * 5f によって各光線の長さがそれぞれ独立して伸縮し、 + i の位相オフセットで光線ごとに脈動のタイミングがずれるようにしています。この組み合わせにより、有機的で呼吸しているような質感が生まれ、太陽が“生きている”ように感じられます。

視差による雲の移動

雲は、互いに独立した移動速度によって奥行きを作ります。これは「パララックス(視差)」と呼ばれる手法で、2Dシーンに立体感を生み出すうえで基本となるものです。

1data class Cloud(val xOffset: Float, val y: Float, val scale: Float, val speed: Float)
2
3val clouds = remember {
4    List(5) {
5        Cloud(
6            xOffset = Random.nextFloat(),
7            y = Random.nextFloat() * 0.3f + 0.1f,  // Upper portion of sky
8            scale = Random.nextFloat() * 0.5f + 0.8f,
9            speed = Random.nextFloat() * 0.02f + 0.01f,
10        )
11    }
12}

5つの雲には、それぞれ異なるスケール(大きさ)と速度が設定されています。大きくて遅い雲は遠くにあるように見え、小さくて速い雲は手前にあるように感じられます。この変化により、平坦な2Dキャンバスであっても大気的な奥行きが生まれます。Y座標は画面の上部1/3(高さの0.1〜0.4)に制限されており、雲が本来あるべき空の領域に収まるようになっています。

各雲は、重なり合う円で描画され、横方向にループ(wrap)する移動を行います。

1val cloudAlpha = if (isDay) 0.9f else 0.15f  // Dim at night
2val cloudColor = if (isDay) Color.White else Color(0xFF555555)
3
4for (cloud in clouds) {
5    val cloudX = ((cloud.xOffset + totalTime * cloud.speed) % 1.4f - 0.2f) * width
6
7    drawCircle(
8        color = cloudColor.copy(alpha = cloudAlpha * 0.8f),
9        radius = 25f * cloud.scale,
10        center = Offset(cloudX, cloudY),
11    )
12    drawCircle(
13        color = cloudColor.copy(alpha = cloudAlpha),
14        radius = 35f * cloud.scale,
15        center = Offset(cloudX + 30f * cloud.scale, cloudY - 5f),
16    )
17    // Additional circles for cloud shape...
18}

% 1.4f - 0.2f というラップの式によって、雲は右側へ抜けたあと、左側から自然に再登場します。 1.4 の範囲(画面幅の140%)と -0.2 のオフセットを組み合わせることで、雲は画面の左端より少し外側で生成され、可視領域全体を横切り、右端を少し超えたところまで進んでからラップします。これにより、端で“ポップ”して見えることなく、滑らかで連続的な動きになります。

雲の色とアルファ(不透明度)は昼夜で変化します。昼の雲は不透明度90%の明るい白で、青空に対してはっきり見えます。夜の雲は不透明度15%の濃いグレーになり、星空を邪魔しない程度の控えめなシルエットとして見えるようになります。

サイクルに合わせてUI要素をアニメーションさせる

UIレイヤーは、 animateColorAsState を使って昼/夜の状態に応じてアニメーションします。animateColorAsState は、ターゲットの色が変わるたびに色同士を滑らかに補間します。

1val buttonColor by animateColorAsState(
2    targetValue = if (isDay) Color(0xFFFFB800) else Color(0xFF6366F1),
3    animationSpec = tween(800),
4    label = "buttonColor",
5)
6
7val buttonTextColor by animateColorAsState(
8    targetValue = if (isDay) Color.Black else Color.White,
9    animationSpec = tween(800),
10    label = "buttonTextColor",
11)
12
13val accentColor by animateColorAsState(
14    targetValue = if (isDay) Color(0xFFFFD700) else Color(0xFF8B9DC3),
15    animationSpec = tween(800),
16    label = "accentColor",
17)

800ms のdurationは、急ぎすぎない滑らかなトランジションを作ります。このタイミングは、ペイウォール全体のテンポに合わせて選ばれており、反応は十分に機敏に感じられつつ、ユーザーが変化を認識できて心地よい速さでもあります。色の選定も昼夜テーマを強化します。昼は暖かいゴールド系のトーンで日差しやエネルギーを想起させ、夜はクールなインディゴやシルバー系で落ち着きと洗練された印象を作ります。

label パラメータは、デバッグやツール連携で重要です。Android StudioのAnimation PreviewやComposeのデバッグツールを使うとき、これらのlabelによって可視化の中で「どのアニメーションがどれか」を識別しやすくなります。

挨拶テキストのクロスフェードアニメーション

挨拶テキストは、Crossfade を使って「GOOD MORNING」と「GOOD EVENING」を切り替えています。Crossfade は、コンテンツが切り替わる際の表示・非表示アニメーションを自動的に処理してくれます。

1Crossfade(
2    targetState = isDay,
3    animationSpec = tween(800),
4    label = "greeting",
5) { day ->
6    Text(
7        text = if (day) "GOOD MORNING" else "GOOD EVENING",
8        style = TextStyle(
9            color = Color.White.copy(alpha = 0.8f),
10            fontSize = 12.sp,
11            fontWeight = FontWeight.Bold,
12            letterSpacing = 4.sp,
13        ),
14    )
15}

Crossfade コンポーザブルは、古いコンテンツをフェードアウトしつつ、新しいコンテンツを同時にフェードインさせます。これにより、ジャンプやチラつきのない、滑らかな切り替えが実現します。 800ms のdurationは他のUIアニメーションと揃えられており、全体として一体感のある体験を生み出します。

この挨拶テキストは、単なる装飾以上の重要な役割を持っています。時間帯に合った挨拶は、アプリがユーザーの状況を理解し、それに反応しているように感じさせる小さな工夫です。アニメーションする環境表現と組み合わさることで、配慮が行き届いた、完成度の高い体験であるという印象をユーザーに与えます。

カスタムチェックマークの描画

機能リストの各項目には、 Canvas で描画したアニメーション付きのチェックマークが含まれており、アニメーションに反応するカスタムグラフィックの作り方を示しています。

1@Composable
2fun TimeFeatureItem(title: String, checkColor: Color, modifier: Modifier = Modifier) {
3    Row(
4        modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
5        verticalAlignment = Alignment.CenterVertically,
6    ) {
7        Box(
8            modifier = Modifier
9                .size(24.dp)
10                .clip(CircleShape)
11                .background(checkColor),  // Animated color from parent
12            contentAlignment = Alignment.Center,
13        ) {
14            Canvas(modifier = Modifier.size(12.dp)) {
15                val path = Path().apply {
16                    moveTo(size.width * 0.2f, size.height * 0.5f)
17                    lineTo(size.width * 0.4f, size.height * 0.7f)
18                    lineTo(size.width * 0.8f, size.height * 0.3f)
19                }
20                drawPath(
21                    path = path,
22                    color = Color.White,
23                    style = Stroke(width = 2f, cap = StrokeCap.Round, join = StrokeJoin.Round),
24                )
25            }
26        }
27
28        Spacer(modifier = Modifier.width(14.dp))
29
30        Text(
31            text = title,
32            style = TextStyle(
33                color = Color.White,
34                fontSize = 15.sp,
35                fontWeight = FontWeight.Medium,
36            ),
37        )
38    }
39}

アイコンではなく Canvas でチェックマークを描画することで、より精密にコントロールでき、アセットへの依存もなくせます。チェックマークのパスは相対座標(キャンバスサイズに対する割合)で定義されているため、どのサイズでも正しくスケールします。 StrokeCap.RoundStrokeJoin.Round によって、線の端や角が滑らかで、親しみやすい見た目になります。

背景色は親コンポーザブルから渡され、昼夜サイクルに合わせてアニメーションします。つまり、チェックマークの円は日中の暖かいゴールドから、夜の落ち着いたインディゴへと滑らかに移行し、ペイウォール全体のビジュアルと調和を保ちます。

購入のためのRevenueCat統合

このペイウォールは rememberPaywallState ヘルパーを通じてRevenueCatを統合しており、購入ロジックをすべてカプセル化して、UIレイヤーに対してクリーンなインターフェースを提供します。

1@Composable
2fun DayNightPaywallScreen(onDismiss: () -> Unit = {}) {
3    val paywallState = rememberPaywallState(onPurchaseSuccess = onDismiss)
4
5    // ...
6
7    Button(
8        onClick = { paywallState.purchase(PackageType.ANNUAL) },
9        modifier = Modifier.fillMaxWidth().height(56.dp),
10        colors = ButtonDefaults.buttonColors(containerColor = buttonColor),
11        shape = RoundedCornerShape(14.dp),
12    ) {
13        Text(
14            text = "Start Free Trial",
15            style = TextStyle(
16                color = buttonTextColor,
17                fontSize = 16.sp,
18                fontWeight = FontWeight.Bold,
19            ),
20        )
21    }
22
23    // ...
24
25    Text(
26        text = "Restore Purchases",
27        modifier = Modifier.clickable { paywallState.restorePurchases() },
28        // ...
29    )
30}

PaywallState クラスが購入に関するロジックをすべて内包することで、UIのコンポーザブルは表示(プレゼンテーション)に集中できます。

1class PaywallState(
2    private val scope: CoroutineScope,
3    private val onPurchaseSuccess: () -> Unit,
4    private val onPurchaseError: (String) -> Unit,
5    private val onPurchaseCancelled: () -> Unit,
6) {
7    var offering by mutableStateOf<OfferingInfo?>(null)
8    var isLoading by mutableStateOf(false)
9    var errorMessage by mutableStateOf<String?>(null)
10    var selectedPackage by mutableStateOf(PackageType.ANNUAL)
11
12    fun purchase(packageType: PackageType) {
13        scope.launch {
14            isLoading = true
15            when (val result = PurchaseHelper.purchase(packageType)) {
16                is PurchaseResult.Success -> onPurchaseSuccess()
17                is PurchaseResult.Error -> onPurchaseError(result.message)
18                is PurchaseResult.Cancelled -> onPurchaseCancelled()
19            }
20            isLoading = false
21        }
22    }
23
24    fun restorePurchases() {
25        scope.launch {
26            isLoading = true
27            when (val result = PurchaseHelper.restorePurchases()) {
28                is PurchaseResult.Success -> onPurchaseSuccess()
29                is PurchaseResult.Error -> onPurchaseError(result.message)
30                is PurchaseResult.Cancelled -> { /* No-op */ }
31            }
32            isLoading = false
33        }
34    }
35}

この関心の分離は、明確なアーキテクチャ原則に沿っています。つまり、UIコンポーネントは表示のみを担当し、ビジネスロジックは別クラスに置くべき、という考え方です。PaywallState はRevenueCatとのやり取り全般、ローディング状態、エラーハンドリングを管理し、UIが必要とするインターフェースだけを公開します。これにより、コードはテストしやすく、保守しやすく、変更もしやすくなります。購入ロジックの変更でUIコードを触る必要がなく、その逆も同様です。

アジャイルなA/BテストのためのFirebase活用

カスタムペイウォールは高い表現力とコントロールを提供しますが、異なるメッセージやオファーをテストするには、リモートで設定を切り替えられる仕組みが必要です。Firebase Realtime Database や Firestore を使えば、アプリのアップデートを行わずにペイウォールのコンテンツを反復改善できるようになります。

ペイウォールコンテンツにFirebaseを使う理由

RevenueCat は、サブスクリプションプロダクト、価格設定、アナリティクスの管理において非常に優れています。しかし、カスタムペイウォールを構築した場合、コンバージョンに影響する コンテンツレイヤー ― 見出し、サブ見出し、機能リスト、価格表示形式、CTA(コールトゥアクション)文言 ― をリモートで制御できる必要があります。これらの要素は実験によって最適化されることが多く、変更のたびにアプリストアの審査サイクルを待つのは、反復スピードを大きく落としてしまいます。

Firebase を使えば、アプリのアップデートなしでこれらの値を変更でき、迅速な実験が可能になります。朝にテストを立ち上げ、日中にデータを収集し、夜には勝ちパターンをロールアウトする──従来のアプリ更新では不可能なスピードです。この俊敏性は、どのメッセージがユーザーに響くのかを学んでいる マネタイズ初期フェーズ において、特に大きな価値を発揮します。

ブランド表現や高度なアニメーションを担う カスタムUI と、素早い反復改善を可能にする リモートコンテンツ を組み合わせることで、両方のメリットを享受できます。ペイウォールはユニークな見た目と体験を保ちつつ、テンプレート型ソリューションを使うチームと同じスピードでメッセージ最適化を行うことができます。

ペイウォールコンテンツのRemote Configを設定する

まず、Firestore にペイウォール設定の構造を定義します。この構造では、変更・検証したくなる可能性のある すべてのテキスト要素 を含めるようにします。

1data class PaywallConfig(
2    val headline: String = "Always On",
3    val subheadline: String = "Premium works day and night",
4    val features: List<String> = listOf(
5        "24/7 Access Anytime",
6        "Sync Across All Devices",
7        "Offline Mode Support",
8        "Smart Scheduling",
9        "Priority Notifications",
10    ),
11    val priceDisplay: String = "$39.99/year",
12    val priceSubtext: String = "Less than $3.50/month",
13    val ctaText: String = "Start Free Trial",
14    val variant: String = "control",
15)

デフォルト値には、主に2つの役割があります。1つ目は、ネットワークリクエストが失敗した場合の フォールバック として機能し、ペイウォールが常に妥当な内容で表示されるようにすること。2つ目は、想定するコンテンツを ドキュメント化 することです。各フィールドが何のためのものかが明確になり、運用や更新もしやすくなります。

また、 variant フィールドはアナリティクスにおいて重要です。設定そのものにバリアント識別子を含めておくことで、分析イベントに 常に正しい紐づけ(アトリビューション) を入れられるようになります。これは、A/Bテストでよくある「バリアントの割り当て」と「表示されたコンテンツ」がズレてしまう不具合の典型を防ぐことにもつながります。

ペイウォール表示時に設定を取得する

1class PaywallConfigRepository(
2    private val firestore: FirebaseFirestore
3) {
4    suspend fun getPaywallConfig(userId: String): PaywallConfig {
5        // Determine which variant this user should see
6        val variant = determineVariant(userId)
7
8        return firestore.collection("paywall_configs")
9            .document(variant)
10            .get()
11            .await()
12            .toObject<PaywallConfig>()
13            ?: PaywallConfig()  // Fallback to defaults
14    }
15
16    private fun determineVariant(userId: String): String {
17        // Simple hash-based assignment for consistent user experience
18        val hash = userId.hashCode().absoluteValue
19        return when (hash % 3) {
20            0 -> "control"
21            1 -> "variant_a"
22            else -> "variant_b"
23        }
24    }
25}

ハッシュベースでのバリアント割り当てにより、一貫性が保たれます。同じユーザーはセッションをまたいでも常に同じバリアント を見ることになり、これは正しいA/Bテストを行ううえで不可欠です。もし訪問のたびに異なるバリアントが表示されてしまうと、特定の施策がどの程度効果を持ったのかを正確に測定することができません。

また、 ?: PaywallConfig() のような null 合体演算子を使って デフォルト値にフォールバック することで、Firestore が利用できない場合でもペイウォールが必ず動作するようになります。このような防御的な設計により、ネットワーク障害が原因で購入フロー全体が止まってしまう事態を防ぐことができます。

ペイウォールに動的コンテンツを組み込む

ペイウォールが 設定(config)を受け取れるように修正 します。

1@Composable
2fun DayNightPaywallScreen(
3    config: PaywallConfig,
4    onDismiss: () -> Unit = {},
5) {
6    // Use config values instead of hardcoded strings
7    Text(
8        text = config.headline,
9        style = TextStyle(
10            color = Color.White,
11            fontSize = 38.sp,
12            fontWeight = FontWeight.Bold,
13        ),
14    )
15
16    Text(
17        text = config.subheadline,
18        style = TextStyle(
19            color = Color.White.copy(alpha = 0.7f),
20            fontSize = 14.sp,
21        ),
22    )
23
24    // Dynamic feature list
25    Column(
26        modifier = Modifier
27            .fillMaxWidth()
28            .clip(RoundedCornerShape(16.dp))
29            .background(Color.Black.copy(alpha = 0.3f))
30            .padding(16.dp),
31    ) {
32        config.features.forEach { feature ->
33            TimeFeatureItem(title = feature, checkColor = checkColor)
34        }
35    }
36
37    // Dynamic pricing
38    Text(text = config.priceDisplay, /* ... */)
39    Text(text = config.priceSubtext, /* ... */)
40
41    // Dynamic CTA
42    Button(onClick = { paywallState.purchase(PackageType.ANNUAL) }) {
43        Text(text = config.ctaText, /* ... */)
44    }
45}

ペイウォールを config オブジェクトでパラメータ化することで、無制限のバリエーションをサポートする柔軟な仕組みを構築できます。アニメーションやビジュアルデザインは一貫して(かつ差別化された状態で)保たれ、コンテンツはリモートから調整可能になります。この分離は、ユーザーが「形」と「内容」の両方に反応するという示唆を反映しています。形(デザイン)はブランド認知を形成し、内容(メッセージング)はコンバージョンを促進します。

コンバージョンイベントのトラッキング

A/B テストの結果を分析するために、バリアント識別子を含めたコンバージョンイベントをログに記録します。

1class PaywallAnalytics(
2    private val analytics: FirebaseAnalytics
3) {
4    fun logPaywallViewed(variant: String) {
5        analytics.logEvent("paywall_viewed") {
6            param("variant", variant)
7            param("paywall_type", "day_night")
8        }
9    }
10
11    fun logPurchaseStarted(variant: String, packageType: String) {
12        analytics.logEvent("purchase_started") {
13            param("variant", variant)
14            param("package_type", packageType)
15        }
16    }
17
18    fun logPurchaseCompleted(variant: String, packageType: String) {
19        analytics.logEvent("purchase_completed") {
20            param("variant", variant)
21            param("package_type", packageType)
22        }
23    }
24}

これら 3 つのイベントによってコンバージョンファネルが構築され、ユーザーがどの段階で離脱しているのかを分析できるようになります。各イベントをバリアントごとにフィルタリングすることで、施策間のパフォーマンスを比較できます。 paywall_type パラメータは、複数のペイウォールデザインを運用している場合に、デザインごとの分析を可能にします。

Firebase での結果分析

Firebase Analytics でファネル分析を作成し、 paywall_viewedpurchase_startedpurchase_completed という遷移を確認します。各ステップをバリアントごとにフィルタリングし、バリアント間でコンバージョン率を比較して勝ちパターンを特定します。

結果を解釈する際には、統計的有意性が重要であることを忘れないでください。特にサンプルサイズが小さい場合、コンバージョン率のわずかな差はノイズであり、意味のある差ではない可能性があります。Firebase のオーディエンス機能を使えば、セグメントごとの影響を把握できます。たとえば、新規ユーザーには効果的なバリアントが、リピーターには期待どおりに機能しない、といったケースもあり得ます。

ペイウォール A/B テストのベストプラクティス

A/B テストから実行可能な示唆を得るためには、いくつかの原則があります。まず、可能な限り一度にテストする変数は 1 つにしてください。見出し、機能リスト、CTA を同時に変更してしまうと、どの変更が結果に影響したのかを特定できません。どうしても複数の変更をまとめてテストする必要がある場合は、それらを1 つのバリアントとして扱い、個々の要素について結論を出さないようにします。

次に、一貫したユーザー割り当てを行い、同じユーザーが常に同じバリアントを見るようにしてください。これにより、訪問のたびに異なる体験をしてしまうことで生じるノイズを防げます。三つ目に、統計的有意性を得られる十分な期間テストを実施することが重要です。検出したい効果量にもよりますが、通常はバリアントごとに 1,000 件以上のコンバージョンが必要になります。

四つ目として、セグメント別のテストも検討してください。新規ユーザーと既存ユーザーではメッセージへの反応が異なることが多く、セグメント内でのテストによって、全体集計では見えない示唆が得られる場合があります。最後に、結果を記録し、アーカイブすることです。時間をかけて、自社のオーディエンスに何が有効かという組織的な知見が蓄積され、将来の実験に活かせるようになり、同じアイデアを再テストする必要も減っていきます。

RevenueCat Paywall Builder:カスタムを超えて

カスタムペイウォールは最大限の柔軟性を提供しますが、多くのケースにおいては RevenueCat’s Paywall Builder が非常に魅力的な選択肢になります。それぞれのアプローチがどのような場面で適しているかを理解することで、開発リソースをより効果的に配分できるようになります。

Paywall Builder が提供するもの

RevenueCat の Paywall Builder は、サーバードリブン UI の仕組みを採用したペイウォール構築ツールで、RevenueCat ダッシュボード上のビジュアルエディタを使って、コード変更なしでペイウォールをデザインできます。いったん作成したペイウォールは、アプリのアップデートを行うことなくリモートで更新でき、色、フォント、レイアウト、画像、コピーをいつでも変更可能です。組み込みの A/B テスト機能により、異なるデザインを統計的な厳密さをもってテストでき、トラフィックの自動割り当てや結果のトラッキングも行われます。

ローカリゼーション機能では、翻訳を一元管理でき、ユーザーのロケールに応じて自動的に適切な言語が配信されます。アナリティクスとの統合により、追加の計測実装なしでコンバージョンの自動トラッキングが可能です。また、テンプレートライブラリには実績のあるデザインが用意されており、それらをベースにカスタマイズすることもできます。

サーバードリブン UI の利点

Paywall Builder の最大の利点は、サーバードリブン UI にあります。アプリはサーバーから提供される設定内容をそのまま描画するため、ペイウォールの見た目をアプリのリリースサイクルから切り離すことができます。

1@Composable
2fun PaywallScreen(onDismiss: () -> Unit) {
3    PaywallDialog(
4        PaywallDialogOptions.Builder()
5            .setDismissRequest(onDismiss)
6            .build()
7    )
8}

この単一の composable によって、RevenueCat ダッシュボードで設定したあらゆるペイウォールデザインを描画できます。色、フォント、レイアウトの変更、画像やアイコンの差し替え、コピーや価格表示の修正、A/B テストの開始、勝ちパターンのロールアウトまで、いずれもアプリのアップデートは不要です。

運用面での影響は非常に大きいです。プロダクトチームはエンジニアリングに依存せずにペイウォールを改善・反復できるため、実験のサイクルを大幅に高速化できます。ペイウォール内容に関する不具合も、アプリストアの審査を待つことなく即座に修正可能です。また、季節キャンペーンなども、アプリ更新の調整を行わずに、正確なスケジュールで開始・終了できます。

Paywall Builder を選ぶべきタイミング

反復スピードが最優先される場合、Paywall Builder は多くのケースで最適な選択肢になります。コンバージョン最適化を目的として多数のバリエーションを素早くテストしたい場合、ビジュアルエディタと組み込みの A/B テスト機能によって、フィードバックループを大幅に短縮できます。プロダクトチームはエンジニアリングの関与なしにテストを作成・公開できるため、開発者は他の重要な作業に集中できます。

エンジニアリングリソースに制約がある場合も、Paywall Builder は有利です。カスタムペイウォールは初期実装だけでなく、その後の保守にも開発工数が必要になります。一方で Paywall Builder では、これらの作業をプロダクトチームやデザインチームに移譲でき、コードを書くことなくビジュアルエディタ上で対応できます。

高度なアニメーションが不要な場合も、Paywall Builder は十分に対応可能です。基本的なアニメーションやトランジションをサポートしており、複雑なカスタム演出を必要としないペイウォールであれば、テンプレートで事足ります。実際、多くの成功しているアプリは、凝ったモーションデザインよりも、明確なメッセージを重視したシンプルなペイウォールを採用しています。

また、クロスプラットフォームでの一貫性も重要な検討ポイントです。Paywall Builder は iOS、Android、Web で同じ見た目を一貫して描画できます。一方、カスタム実装ではプラットフォームごとに個別の開発が必要となり、エンジニアリング投資がその分増加します。

カスタムペイウォールが依然として有効なケース

ブランド表現が最優先される場合、カスタムペイウォールは今でも正しい選択肢です。独自のビジュアルアイデンティティやカスタムアニメーションに厳密に合わせる必要がある場合、テンプレートでは再現できません。ここまで見てきた「Day & Night」ペイウォールはその典型例で、プロシージャルに生成される星空、三角関数を用いた太陽/月の軌道、複数レイヤーが同期して動くアニメーションは、いずれもカスタムコードなしでは実現不可能です。

また、テンプレートでは対応できない独自のインタラクションが必要な場合も、カスタム実装が求められます。ゲーミフィケーションされたペイウォール、AR プレビュー、ジェスチャーベースのナビゲーション、あるいはプレミアム機能のミニデモなどは、いずれもカスタム実装が前提となります。こうしたインタラクティブな要素は、ユーザーが購入前に価値を体験できるため、コンバージョンに大きな影響を与えることがあります。

場合によっては、ペイウォールそのものがプロダクトの差別化要因になることもあります。たとえば、プレミアム体験のトーンを決定づける瞑想アプリの落ち着いたペイウォールのように、ペイウォール体験自体が「売り」になるケースでは、カスタム開発に投資する価値があります。第一印象は非常に重要であり、印象的なペイウォールは、プレミアムプランに対してユーザーが期待できる品質を強く印象づけるシグナルになります。

ハイブリッドアプローチ

多くの成功しているアプリは、これら2つのアプローチを戦略的に併用しています。初回のコンバージョンフローとなるメインのペイウォールにはカスタム実装を用い、ブランド表現や初めて課金に触れるユーザーに向けた印象的な体験を提供します。一方で、季節キャンペーン用のペイウォール、解約ユーザー向けのリカバリー施策、あるいは高速な反復が求められる実験用バリエーションについては、Paywall Builder が活用されます。これらのケースでは、カスタムデザインよりもスピードと柔軟性のほうが重要になるためです。設定画面内のアップセルや、機能制限時に表示されるプロンプトといったセカンダリな表示箇所でも、表示頻度が低く、簡単に調整できることが価値になるため、Paywall Builder が使われることが多くあります。

このハイブリッドアプローチにより、ブランド表現と反復スピードの両方を最大化できます。メインのペイウォールでは強い第一印象を作りつつ、Paywall Builder を使うことで、継続的な最適化やプロモーション施策に必要な柔軟性を確保できるのです。

結論

Jetpack Compose を使ってアニメーション付きのカスタムペイウォールを構築することで、テンプレート型のソリューションでは実現できないクリエイティブな表現が可能になります。今回紹介した Day & Night ペイウォールは、レイヤー化されたアニメーション、空のグラデーション、きらめく星、天体の軌道、パララックスで動く雲、そして同期した UI 要素を組み合わせることで、ユーザーの感情に訴えかける没入感のある体験を生み出しています。これらの手法は、コンバージョンの瞬間を単なる「取引」から「体験」へと変え、コンバージョン率とブランド認知の双方を高める可能性があります。

Firebase は、ペイウォールコンテンツをアジャイルに A/B テストするために必要なリモート設定レイヤーを提供します。見出し、機能説明、価格表示、CTA テキストを外部化することで、カスタムアニメーション体験を維持したまま、アプリ更新なしでメッセージングを反復改善できます。カスタムデザインとリモートコンテンツを組み合わせることで、ブランドの独自性と運用面での俊敏性の両方を手に入れることができます。

一方で、カスタムペイウォールが常に最適な選択肢とは限りません。RevenueCat の Paywall Builder は、サーバードリブン UI、組み込みの A/B テスト、高速な反復を可能にする機能を備えており、多くのケースではカスタム開発の利点を上回る価値を提供します。カスタムとテンプレートのどちらを選ぶべきかは、チームのリソース、反復スピードの要件、そしてブランド表現の重要度を踏まえて判断する必要があります。実際、多くの成功しているアプリは、主要な体験にはカスタムを、プロモーションやセカンダリな表示にはテンプレートを使うという形で、両者を併用しています。

RevenueCat の SDK 連携に関する完全なドキュメントについては、公式の RevenueCat ドキュメント を参照してください。また、本記事で紹介したソースコードの全体については、GitHub リポジトリ をご確認ください。