Features.Vote - Build profitable features from user feedback | Product Hunt
SwiftUI tutorial · iOS 16+

Show a "What's New" Screen on App Update in SwiftUI

Greet returning users with your latest release notes — but only after they actually update. Here's the native @AppStorage pattern, with full code and the gotchas that trip people up.

"Shout out to FeaturesVote! Integration was done in under a minute"

Alexandre Negrel,

Founder at Prisme Analytics

A good "What's New" screen turns a silent update into a moment of delight — and quietly boosts feature adoption. The trick is timing: it should appear after an update, never on a fresh install, and never on every launch. The whole thing is about 60 lines of SwiftUI.

We'll build it in four steps, then look at how to avoid editing code for every single release.

1

Read the app's current version

Apple stores your marketing version in CFBundleShortVersionString. Read it once via a small Bundle extension so you can compare it on every launch.

import SwiftUI // The current marketing version, e.g. "2.1" from your target settings extension Bundle { var appVersion: String { infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" } }
2

Define what's new this release

Model each highlight as a simple Identifiable struct and keep the list in one place. You'll update this array each time you ship a release worth announcing.

struct WhatsNewItem: Identifiable { let id = UUID() let systemImage: String let title: String let description: String } enum WhatsNew { // Bump these every release you want to announce. static let items: [WhatsNewItem] = [ .init(systemImage: "sparkles", title: "A fresh new design", description: "We rebuilt the home screen to be faster and easier to scan."), .init(systemImage: "bell.badge", title: "Smarter notifications", description: "Only get pinged for the things you actually care about."), .init(systemImage: "lock.shield", title: "Privacy improvements", description: "Your data now stays on-device by default."), ] }
3

Build the What's New view

A title, a scrollable list of highlights with SF Symbols, and a single Continue button. Keep it native, light, and skippable.

struct WhatsNewView: View { let onDismiss: () -> Void var body: some View { VStack(spacing: 0) { Text("What's New") .font(.largeTitle.bold()) .padding(.top, 48) .padding(.bottom, 24) ScrollView { VStack(alignment: .leading, spacing: 24) { ForEach(WhatsNew.items) { item in HStack(alignment: .top, spacing: 16) { Image(systemName: item.systemImage) .font(.title2) .foregroundStyle(.tint) .frame(width: 36) VStack(alignment: .leading, spacing: 4) { Text(item.title).font(.headline) Text(item.description) .font(.subheadline) .foregroundStyle(.secondary) } } } } .padding(.horizontal, 28) } Button(action: onDismiss) { Text("Continue") .font(.headline) .frame(maxWidth: .infinity) .padding() } .buttonStyle(.borderedProminent) .padding(24) } } }
4

Show it only after an update

Use @AppStorage to remember the last version the user saw. Present the sheet when the stored version differs from the current one — and crucially, skip it on a fresh install so you don't interrupt first-run onboarding.

struct ContentView: View { // Persists across launches. Empty string means "never seen". @AppStorage("lastSeenWhatsNewVersion") private var lastSeenVersion = "" @State private var showWhatsNew = false var body: some View { HomeView() .sheet(isPresented: $showWhatsNew) { WhatsNewView { // Mark this version as seen and close. lastSeenVersion = Bundle.main.appVersion showWhatsNew = false } } .onAppear { let current = Bundle.main.appVersion // Skip a brand-new install (lastSeenVersion empty), // show only when the user UPDATED to a new version. if !lastSeenVersion.isEmpty, lastSeenVersion != current { showWhatsNew = true } // First launch ever: record the version, don't interrupt. if lastSeenVersion.isEmpty { lastSeenVersion = current } } } }

That's the whole pattern — a version check, a sheet, and one @AppStorage flag.

Three gotchas to avoid

Don't show it on first install

A brand-new user has no 'previous version', so a naive check (stored != current) fires on first launch. Record the version silently on first run and only present on a genuine version change.

Version vs build number

Compare CFBundleShortVersionString (the marketing version, e.g. 2.1) — not CFBundleVersion (the build number, e.g. 2.1.345), which changes on every TestFlight build and would pop the sheet constantly.

Hard-coded copy gets stale

Every release you have to edit the items array, ship a new binary, and wait for review. Release notes that live in your app bundle can't be fixed or localized without another submission.

The shortcut: a changelog you don't hard-code

The hand-rolled version works, but every release means editing the items array and shipping a new binary. Features.Vote gives you a native SwiftUI ChangelogView that renders the releases you publish from a dashboard — fix a typo or add a release without another App Review.

import SwiftUI import FeaturesVote struct ContentView: View { var body: some View { // A native changelog fed from the releases you publish — // no hard-coded items to maintain on every ship. FeaturesVote.ChangelogView() } } // One-time setup (e.g. in your App init): // FeaturesVote.configure(with: "your-project-slug")

Frequently Asked Questions

Still not convinced?

Here's a full price comparison with all top competitors

Okay, okay! Sign me up!

Start building the right features today ⚡️