Skip to content

Trial Timeline Paywall

A paywall that shows what happens on each day of the free trial, with the plans and the button to start it.

Trial Timeline Paywall, playing
Download .swift
Origin
Inspired by Blinkist's free trial timeline, written from scratch
Found by
paywall · free trial · trial timeline · subscription · pricing · upgrade · in-app purchase · paywall · prueba gratis · línea de tiempo · suscripción · precios · mejorar plan · compra dentro de la app

A paywall that shows what happens on each day of the free trial before asking to start it.

It draws the plans and the timeline and reports the taps: buying and restoring are up to you, with StoreKit or whatever you use. The reminder step is a promise, so schedule that notification, or set reminderLead to 0 to leave the step out.

struct TrialTimelinePaywallDemo: View {
private let plans = [
TrialTimelinePaywallPlan(id: "yearly", title: "Yearly", price: "$39.99 a year", badge: "Save 44%", trialDays: 7),
TrialTimelinePaywallPlan(id: "monthly", title: "Monthly", price: "$5.99 a month"),
]
@State private var selection = "yearly"
@State private var isPurchasing = false
var body: some View {
TrialTimelinePaywall(plans: plans, selection: $selection, isPurchasing: isPurchasing) { _ in
Task {
isPurchasing = true
try? await Task.sleep(for: .seconds(1.5))
isPurchasing = false
}
} onRestore: {
}
.tint(.indigo)
}
}
ParameterTypeDefaultDescription
titleLocalizedStringResource"How your free trial works"
plans[TrialTimelinePaywallPlan]Required
selectionBinding<TrialTimelinePaywallPlan.ID>Required
reminderLeadInt2How many days before the trial ends the reminder is sent.
startDateNot writtenDate.now
isPurchasingBoolfalse
onContinue(TrialTimelinePaywallPlan) -> VoidRequired
onRestore() -> VoidRequired

Copy this code into your project, or download the file.

TrialTimelinePaywall.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum TrialTimelinePaywallLogic {
/// The days of a trial worth showing, counted from today: today, the day of the reminder and
/// the day the trial ends. A trial too short for a reminder has none, and no trial is just today.
static func days(trialDays: Int, reminderLead: Int = 2) -> [Int] {
guard trialDays > 0 else { return [0] }
let reminder = trialDays - reminderLead
return reminderLead > 0 && reminder > 0 ? [0, reminder, trialDays] : [0, trialDays]
}
static func date(afterDays days: Int, from start: Date, calendar: Calendar = .current) -> Date {
calendar.date(byAdding: .day, value: days, to: start) ?? start
}
}
struct TrialTimelinePaywallPlan: Identifiable, Equatable {
var id: String
var title: LocalizedStringResource
/// The price as it should be read, such as StoreKit's `displayPrice` and its period.
var price: String
var badge: LocalizedStringResource?
/// How long its free trial lasts. Zero when it has none.
var trialDays = 0
}
/// A paywall that shows what happens on each day of the free trial before asking to start it.
///
/// It draws the plans and the timeline and reports the taps: buying and restoring are up to you,
/// with StoreKit or whatever you use. The reminder step is a promise, so schedule that
/// notification, or set `reminderLead` to 0 to leave the step out.
struct TrialTimelinePaywall: View {
var title: LocalizedStringResource = "How your free trial works"
let plans: [TrialTimelinePaywallPlan]
@Binding var selection: TrialTimelinePaywallPlan.ID
/// How many days before the trial ends the reminder is sent.
var reminderLead = 2
var startDate = Date.now
var isPurchasing = false
var onContinue: (TrialTimelinePaywallPlan) -> Void
var onRestore: () -> Void
private let margin: CGFloat = 24
private let cornerRadius: CGFloat = 16
private let lineWidth: CGFloat = 6
@State private var hasAppeared = false
@ScaledMetric(relativeTo: .headline) private var markSize = 34.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var plan: TrialTimelinePaywallPlan? {
plans.first { $0.id == selection } ?? plans.first
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
Text(title)
.font(.largeTitle.bold())
.accessibilityAddTraits(.isHeader)
if let plan {
timeline(for: plan)
}
VStack(spacing: 10) {
ForEach(plans) { plan in
card(for: plan)
}
}
}
.padding(.horizontal, margin)
.padding(.top, 32)
.padding(.bottom, margin)
.frame(maxWidth: 520, alignment: .leading)
.frame(maxWidth: .infinity)
}
.scrollBounceBehavior(.basedOnSize)
.safeAreaInset(edge: .bottom) {
if let plan {
footer(for: plan)
}
}
.animation(reduceMotion ? nil : .snappy, value: selection)
.onAppear { hasAppeared = true }
}
// MARK: Timeline
private func timeline(for plan: TrialTimelinePaywallPlan) -> some View {
let days = TrialTimelinePaywallLogic.days(trialDays: plan.trialDays, reminderLead: reminderLead)
return VStack(alignment: .leading, spacing: 0) {
ForEach(Array(days.enumerated()), id: \.element) { index, day in
step(day: day, plan: plan, isLast: index == days.count - 1)
.opacity(hasAppeared ? 1 : 0)
.animation(reduceMotion ? nil : .easeOut(duration: 0.4).delay(Double(index) * 0.15), value: hasAppeared)
.transition(.opacity)
}
}
}
private func step(day: Int, plan: TrialTimelinePaywallPlan, isLast: Bool) -> some View {
let isCharge = day == plan.trialDays
return HStack(alignment: .top, spacing: 16) {
Image(systemName: day == 0 && !isCharge ? "lock.open.fill" : isCharge ? "star.fill" : "bell.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.frame(width: markSize, height: markSize)
.background(.tint, in: .circle)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline) {
Text(day == 0 ? "Today" : "Day \(day)")
.font(.headline)
if day > 0 {
Text(TrialTimelinePaywallLogic.date(afterDays: day, from: startDate), format: .dateTime.month(.abbreviated).day())
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Text(text(day: day, plan: plan))
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 6)
.padding(.bottom, isLast ? 0 : 22)
}
.background(alignment: .topLeading) {
// The line that joins this step to the next one. After the last step it fades away.
Capsule()
.fill(isLast ? AnyShapeStyle(LinearGradient(colors: [.accentColor.opacity(0.3), .clear], startPoint: .top, endPoint: .bottom)) : AnyShapeStyle(.tint.opacity(0.3)))
.frame(width: lineWidth)
.frame(maxHeight: isLast ? markSize * 2 : .infinity, alignment: .top)
.padding(.leading, (markSize - lineWidth) / 2)
.padding(.top, markSize / 2)
.accessibilityHidden(true)
}
.padding(.bottom, isLast ? markSize : 0)
.accessibilityElement(children: .combine)
}
private func text(day: Int, plan: TrialTimelinePaywallPlan) -> String {
if day == plan.trialDays {
return plan.trialDays > 0
? "Your subscription starts and you are charged \(plan.price). Cancel any time before."
: "Your subscription starts and you are charged \(plan.price)."
}
return day == 0
? "You get full access to everything, free."
: "We remind you that your trial is about to end."
}
// MARK: Plans
private func card(for plan: TrialTimelinePaywallPlan) -> some View {
let isSelected = plan.id == self.plan?.id
return Button {
selection = plan.id
} label: {
HStack(spacing: 12) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(plan.title)
.font(.headline)
if let badge = plan.badge {
Text(badge)
.font(.caption.weight(.semibold))
.foregroundStyle(.tint)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.tint.opacity(0.15), in: .capsule)
}
}
Text(plan.trialDays > 0 ? "\(plan.trialDays) days free, then \(plan.price)" : plan.price)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.fill.quaternary, in: .rect(cornerRadius: cornerRadius))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(.tint, lineWidth: isSelected ? 2 : 0)
}
.contentShape(.rect(cornerRadius: cornerRadius))
}
.buttonStyle(.plain)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private func footer(for plan: TrialTimelinePaywallPlan) -> some View {
VStack(spacing: 10) {
Button {
onContinue(plan)
} label: {
Text(plan.trialDays > 0 ? "Start \(plan.trialDays)-Day Free Trial" : "Subscribe")
.font(.headline)
.opacity(isPurchasing ? 0 : 1)
.frame(maxWidth: .infinity)
.overlay {
if isPurchasing {
ProgressView()
.controlSize(.regular)
.tint(.white)
}
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
// Not `disabled`, which would take the color away from under the spinner.
.allowsHitTesting(!isPurchasing)
.accessibilityValue(isPurchasing ? "In progress" : "")
Button(action: onRestore) {
// A line of text is too short to hit. The padding widens what answers to a
// touch, and the same padding taken back keeps the layout as it was.
Text("Restore Purchases")
.padding(.vertical, 14)
.contentShape(.rect)
.padding(.vertical, -14)
}
.font(.footnote)
.disabled(isPurchasing)
}
.padding(.horizontal, margin)
.padding(.vertical, 12)
.frame(maxWidth: 520)
.frame(maxWidth: .infinity)
.background(.bar)
}
}
// MARK: - Demo
struct TrialTimelinePaywallDemo: View {
private let plans = [
TrialTimelinePaywallPlan(id: "yearly", title: "Yearly", price: "$39.99 a year", badge: "Save 44%", trialDays: 7),
TrialTimelinePaywallPlan(id: "monthly", title: "Monthly", price: "$5.99 a month"),
]
@State private var selection = "yearly"
@State private var isPurchasing = false
var body: some View {
TrialTimelinePaywall(plans: plans, selection: $selection, isPurchasing: isPurchasing) { _ in
Task {
isPurchasing = true
try? await Task.sleep(for: .seconds(1.5))
isPurchasing = false
}
} onRestore: {
}
.tint(.indigo)
}
}
#Preview {
TrialTimelinePaywallDemo()
}