```html SwiftUI: How to Implement App Rating Prompt (iOS 17+, 2026)
Soarias SwiftUI Guides

How to Implement an App Rating Prompt in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: SKStoreReviewController Updated: May 11, 2026
TL;DR

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

  1. @Environment(\.requestReview) — This environment key, provided by StoreKit, gives you a RequestReviewAction that is bound to the active UIWindowScene. Calling it invokes SKStoreReviewController.requestReview(in:) under the hood, targeting the correct scene automatically.
  2. Milestone gating with @AppStoragecompletedTaskCount persists across launches via UserDefaults. The reviewMilestones set 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.
  3. No double-firing guard — lastReviewRequestCount — By storing the count at which the last prompt was attempted, the maybeRequestReview function skips future calls at the same count (e.g. if the user marks and unmarks the same task repeatedly).
  4. 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.
  5. 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

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.

```