How to Build an Event Countdown App in SwiftUI
An Event Countdown app lets users track multiple upcoming dates — birthdays, vacations, product launches — with live ticking countdowns displayed as home-screen widgets and list cards. It's an ideal first shipping project: focused scope, no backend, real-time UI, and a clear upgrade path to pro features.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone or iPad to test WidgetKit — the Simulator has limited Lock Screen and home screen widget support
- An App Group identifier configured in your developer portal so both the app and widget extension can share the same SwiftData store
Architecture overview
The app uses a SwiftData model container shared between the main target and a WidgetKit extension via an App Group. CountdownEvent records are fetched with @Query in the main list and read directly from the shared store in the widget's TimelineProvider. A 1-second Timer publisher drives animated digit updates in each card, while the widget's timeline refreshes every 15 minutes or immediately when the user saves a new event.
CountdownApp/ ├── App/ │ └── CountdownApp.swift # @main + modelContainer(appGroup:) ├── Models/ │ └── CountdownEvent.swift # @Model — title, emoji, targetDate ├── Views/ │ ├── EventListView.swift # @Query list + Add sheet │ └── CountdownCard.swift # Timer + numericText animation ├── Widget/ │ └── CountdownWidget.swift # TimelineProvider + entry view └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a SwiftData @Model that stores each event and exposes a computed secondsRemaining property so every view reads from one source of truth.
import SwiftData
import Foundation
@Model
final class CountdownEvent {
var title: String
var emoji: String
var targetDate: Date
var colorHex: String
var createdAt: Date
init(title: String, emoji: String = "🎉",
targetDate: Date, colorHex: String = "#5E5CE6") {
self.title = title
self.emoji = emoji
self.targetDate = targetDate
self.colorHex = colorHex
self.createdAt = .now
}
var isPast: Bool { targetDate < .now }
var secondsRemaining: Int {
max(0, Int(targetDate.timeIntervalSinceNow))
}
}
2. Core UI — event list
Use @Query to fetch events sorted by target date and render each one as a CountdownCard, with swipe-to-delete wired directly to the model context.
import SwiftUI
import SwiftData
struct EventListView: View {
@Query(sort: \CountdownEvent.targetDate) var events: [CountdownEvent]
@Environment(\.modelContext) private var context
@State private var showAdd = false
var body: some View {
NavigationStack {
List {
ForEach(events) { event in
CountdownCard(event: event)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(.init(top: 6, leading: 16, bottom: 6, trailing: 16))
}
.onDelete { idx in idx.forEach { context.delete(events[$0]) } }
}
.listStyle(.plain)
.navigationTitle("Countdowns")
.toolbar { Button("Add", systemImage: "plus") { showAdd = true } }
.sheet(isPresented: $showAdd) { AddEventSheet() }
}
}
}
3. Multi-event live countdowns
Each card subscribes to a 1-second timer and uses .contentTransition(.numericText()) so digits animate smoothly on every tick — no third-party libraries needed.
struct CountdownCard: View {
let event: CountdownEvent
@State private var seconds = 0
private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("\(event.emoji) \(event.title)").font(.headline)
HStack(spacing: 20) {
unitView(seconds / 86400, "days")
unitView((seconds % 86400) / 3600, "hrs")
unitView((seconds % 3600) / 60, "min")
unitView(seconds % 60, "sec")
}
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.onReceive(ticker) { _ in withAnimation { seconds = event.secondsRemaining } }
.onAppear { seconds = event.secondsRemaining }
}
@ViewBuilder
private func unitView(_ value: Int, _ label: String) -> some View {
VStack(spacing: 2) {
Text("\(value)").font(.title2.monospacedDigit().bold())
.contentTransition(.numericText())
.animation(.easeInOut(duration: 0.3), value: value)
Text(label).font(.caption2).foregroundStyle(.secondary)
}
}
}
4. Privacy Manifest
Add PrivacyInfo.xcprivacy via File › New › Resource › App Privacy File to declare UserDefaults access — Apple requires this for any app whose binary, or whose extension (WidgetKit), reads UserDefaults.
<!-- PrivacyInfo.xcprivacy — add to both app and widget targets -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key><false/>
<key>NSPrivacyTrackingDomains</key><array/>
<key>NSPrivacyCollectedDataTypes</key><array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Widget can't read events. The widget extension is a separate process. Pass your App Group container URL to
modelContainer(for:)in both targets — without this, the widget reads an empty store silently. - "Days remaining" is off by one.
timeIntervalSinceNowreturns raw seconds, not calendar days. If your UI promises "3 days left" and the event is at midnight, useCalendar.current.dateComponents([.day], from: .now, to: targetDate).dayinstead. - Widget timeline never refreshes. Call
WidgetCenter.shared.reloadAllTimelines()immediately after saving or deleting an event, otherwise the widget can show stale data for up to 15 minutes. - App Store rejection: missing Privacy Manifest. Any binary using UserDefaults — including indirectly via WidgetKit's shared defaults — requires
PrivacyInfo.xcprivacywith reason codeCA92.1. Apple's automated checks catch this before human review and will reject the build outright. - Timer battery impact on older devices. One 1-second timer per visible card is fine; avoid instantiating timers for off-screen cells. If you support an always-on Lock Screen widget, use
TimelineView(.periodic(from:by:))there instead of Timer.
Adding monetization: One-time purchase
Implement a one-time unlock with StoreKit 2's Product.purchase() API. Define a non-consumable in-app purchase in App Store Connect (e.g. com.yourapp.countdown.pro), then gate premium features — unlimited events beyond three, custom colors, and Lock Screen widget families — behind a Transaction.currentEntitlement(for:) check at app launch. StoreKit 2 validates receipts server-side automatically so no backend is needed. Cache the entitlement state in UserDefaults for instant UI response, but always re-verify via StoreKit on launch to remain compliant with App Store guidelines and handle refunds gracefully.
Shipping this faster with Soarias
Soarias scaffolds the full project from a single prompt: it generates the CountdownEvent SwiftData model, wires App Group entitlements across both the app and widget targets, produces a working TimelineProvider with correct reload calls, and pre-fills PrivacyInfo.xcprivacy with the right reason codes. Fastlane lanes for screenshot capture and App Store Connect metadata submission are ready to run with one command.
For a beginner-complexity project like this, most solo developers save a full weekend. The WidgetKit App Group configuration — making both targets share one SwiftData store without silent failures — typically takes 2–4 hours to debug by hand. Soarias gets it right on the first scaffold, so you ship the feature instead of chasing entitlement mismatches.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free account lets you sideload the app on your own device via Xcode, but you need the $99/year Apple Developer Program to submit to TestFlight or the App Store. You also need a paid account to create App Group identifiers in the developer portal — required for the WidgetKit extension to share data with the main app.
How do I submit this to the App Store?
Archive the app in Xcode (Product › Archive), then upload via Xcode Organizer or xcrun altool. In App Store Connect, complete metadata, privacy nutrition labels, and screenshots — Apple requires 6.7″ and 5.5″ iPhone sizes at minimum. With a simple utility like this, review typically takes 24–48 hours after submission.
Can I show countdowns on the Lock Screen?
Yes. Add .accessoryCircular and .accessoryRectangular to your widget's supportedFamilies. Lock Screen widgets render in a reduced-color environment — mark accent elements with .widgetAccentable() and test on a real device, since the Simulator doesn't fully replicate Lock Screen rendering or tinting behavior.
Last reviewed: 2026-05-12 by the Soarias team.