How to Implement an App Rating Prompt in SwiftUI
Use the @Environment(\.requestReview) action introduced in iOS 16 / StoreKit 2 and call it after a meaningful moment in your app — iOS handles throttling automatically and caps prompts at three per 365 days.
import StoreKit
import SwiftUI
struct ContentView: View {
@Environment(\.requestReview) private var requestReview
var body: some View {
Button("Complete Task") {
// ... your logic ...
requestReview()
}
}
}
Full implementation
The cleanest pattern is to track a session-based counter (e.g. completed tasks) in @AppStorage and fire the review prompt when the user hits a milestone for the first time. The environment's requestReview action is scene-aware and automatically respects Apple's per-year cap — you don't need to add extra guards, but you do need to pick meaningful trigger moments so iOS doesn't suppress your request before users ever see it.
import StoreKit
import SwiftUI
struct TaskListView: View {
@Environment(\.requestReview) private var requestReview
// Persisted across launches
@AppStorage("completedTaskCount") private var completedTaskCount = 0
@AppStorage("lastReviewRequestCount") private var lastReviewRequestCount = 0
@State private var tasks: [String] = ["Buy groceries", "Read chapter 3", "Go for a run"]
@State private var completedTasks: Set = []
// Milestones at which we try to request a review
private let reviewMilestones: Set = [3, 10, 25]
var body: some View {
NavigationStack {
List(tasks, id: \.self) { task in
HStack {
Image(systemName: completedTasks.contains(task) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(completedTasks.contains(task) ? .green : .secondary)
.accessibilityLabel(completedTasks.contains(task) ? "Completed" : "Not completed")
Text(task)
.strikethrough(completedTasks.contains(task))
.foregroundStyle(completedTasks.contains(task) ? .secondary : .primary)
}
.contentShape(Rectangle())
.onTapGesture {
completeTask(task)
}
.accessibilityAddTraits(.isButton)
.accessibilityHint("Double-tap to mark as complete")
}
.navigationTitle("My Tasks")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Text("\(completedTaskCount) done")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
private func completeTask(_ task: String) {
guard !completedTasks.contains(task) else { return }
completedTasks.insert(task)
completedTaskCount += 1
maybeRequestReview()
}
private func maybeRequestReview() {
// Only fire at milestones we haven't triggered a review for yet
guard reviewMilestones.contains(completedTaskCount),
completedTaskCount > lastReviewRequestCount else { return }
lastReviewRequestCount = completedTaskCount
// Give the UI a moment to settle before the system dialog appears
Task { @MainActor in
try? await Task.sleep(for: .seconds(1))
requestReview()
}
}
}
#Preview {
TaskListView()
}
How it works
-
@Environment(\.requestReview)— This environment key, provided by StoreKit, gives you aRequestReviewActionthat is bound to the activeUIWindowScene. Calling it invokesSKStoreReviewController.requestReview(in:)under the hood, targeting the correct scene automatically. -
Milestone gating with
@AppStorage—completedTaskCountpersists across launches viaUserDefaults. ThereviewMilestonesset defines the exact counts (3, 10, 25) at which a prompt attempt is made — high enough that users have experienced real value before being asked. -
No double-firing guard —
lastReviewRequestCount— By storing the count at which the last prompt was attempted, themaybeRequestReviewfunction skips future calls at the same count (e.g. if the user marks and unmarks the same task repeatedly). -
One-second delay with
Task.sleep— The review sheet is a system-level modal. Triggering it mid-animation can cause visual glitches. The one-second async sleep lets any ongoing list transitions complete before the dialog appears. -
System throttling — Apple enforces a hard limit of three prompts per 365-day rolling window per app. Even if your code calls
requestReview()more often, the system silently no-ops after that cap. In development builds the prompt always appears — use a device with a real App Store account for realistic testing.
Variants
Trigger after a minimum number of app launches
Some apps prefer session-count triggers over action-count triggers. Store a launch counter in @AppStorage and call requestReview() on the .onAppear of your root view after enough sessions.
import StoreKit
import SwiftUI
struct RootView: View {
@Environment(\.requestReview) private var requestReview
@AppStorage("appLaunchCount") private var appLaunchCount = 0
var body: some View {
MainTabView()
.onAppear {
appLaunchCount += 1
if appLaunchCount == 5 {
Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
requestReview()
}
}
}
}
}
Linking to the App Store review page manually
For settings screens where users want to leave a review on their own terms, deep-link to the App Store write-a-review URL: https://apps.apple.com/app/idYOUR_APP_ID?action=write-review. Open it with UIApplication.shared.open(url) or SwiftUI's Link view — this bypasses the system throttle and is always allowed.
Common pitfalls
-
Calling
requestReview()at app launch — Apple's guidelines prohibit prompting users who have not yet experienced your app's value. Prompts triggered during onboarding or on the very first launch are likely to be suppressed and may lead to App Review rejection. -
Using the old
SKStoreReviewController.requestReview()(no scene) — The static method without a scene parameter is deprecated as of iOS 14. Always use the environment action (@Environment(\.requestReview)) or pass an explicitUIWindowScenetorequestReview(in:). In multi-scene iPadOS apps the deprecated form may show on the wrong scene. - Expecting the prompt in simulator or TestFlight — The system dialog renders in the simulator and in development builds but will not count toward the App Store or affect production analytics. TestFlight builds also see a special variant of the dialog that counts separately. Always test on a device signed into a real App Store account with the production build.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement an app rating prompt in SwiftUI for iOS 17+. Use SKStoreReviewController via @Environment(\.requestReview). Trigger the prompt after the user completes 3, 10, and 25 tasks, persisting the count with @AppStorage. Make it accessible (VoiceOver labels on all interactive elements). Add a #Preview with realistic sample data.
In Soarias, drop this prompt into the Build phase after your core task-completion flow is scaffolded — the agent will wire up the milestone logic and @AppStorage keys without touching unrelated screens.
Related
FAQ
Does this work on iOS 16?
Yes — @Environment(\.requestReview) was introduced in iOS 16.0 alongside StoreKit 2. The code in this guide targets iOS 17+ because that's where SwiftUI's #Preview macro and NavigationStack are fully stable, but the core requestReview action itself works on iOS 16 without modification.
Can I force the prompt to appear during testing without hitting the 3-per-year cap?
During development and in the iOS Simulator the prompt always appears and does not consume one of your three annual attempts. For production testing on a real device, navigate to Settings → App Store → Ratings & Reviews — there is no way to reset the cap manually, but TestFlight builds use a separate counter so you can test freely there. Alternatively, mock the behavior with a custom environment value in previews so you can verify your trigger logic without calling the real API.
What is the UIKit equivalent?
In UIKit you call SKStoreReviewController.requestReview(in: windowScene) where windowScene is your active UIWindowScene — retrieve it via UIApplication.shared.connectedScenes. The SwiftUI environment action is simply a wrapper around this that resolves the scene automatically, so the underlying behavior is identical.
Last reviewed: 2026-05-11 by the Soarias team.