Skip to content

Permission Primer Screen

The screen shown before the system asks for a permission: what it is for, what the person gets, and one button that leads to the system's question.

Permission Primer Screen, playing
Download .swift
Origin
Original work
Found by
permission · pre-permission · primer · notifications · onboarding · allow · privacy · tracking · permiso · pantalla previa · notificaciones · bienvenida · permitir · privacidad · seguimiento · solicitar permiso

The screen an app shows before the system asks for a permission: what it is for, what the person gets, and one button that leads to the system's question.

Ask for the permission in onContinue. Pass skipTitle as nil where the only way on has to be the system's question, as Apple asks before the tracking prompt. With Reduce Motion on, the rings stand still and the benefits appear together.

struct PermissionPrimerScreenDemo: View {
private enum Answer {
case allowed, later
}
@State private var answer: Answer?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
if let answer {
// Only for this demo: what came of the choice, and a way back to the screen.
ContentUnavailableView {
Label(answer == .allowed ? "The system would ask now" : "Maybe later", systemImage: answer == .allowed ? "bell.badge.fill" : "moon.zzz.fill")
} description: {
Text(answer == .allowed ? "This is where your app requests the permission." : "Ask again when the person reaches something that needs it.")
} actions: {
Button("Start over") { self.answer = nil }
.buttonStyle(.borderedProminent)
}
.transition(.opacity)
} else {
PermissionPrimerScreen(
systemImage: "bell.badge.fill",
title: "Stay in the loop",
message: "Turn on notifications and we only write when it matters.",
benefits: [
PermissionPrimerScreenBenefit(systemImage: "shippingbox.fill", title: "Order updates", text: "Know the moment your order ships and arrives."),
PermissionPrimerScreenBenefit(systemImage: "tag.fill", title: "Price drops", text: "Hear first when something you saved gets cheaper."),
PermissionPrimerScreenBenefit(systemImage: "moon.fill", title: "Quiet by default", text: "No marketing, and nothing at night."),
],
tint: .indigo,
onContinue: { answer = .allowed },
onSkip: { answer = .later }
)
.transition(.opacity)
}
}
.animation(reduceMotion ? nil : .easeInOut(duration: 0.3), value: answer)
}
}
ParameterTypeDefault
systemImageStringRequired
titleLocalizedStringResourceRequired
messageLocalizedStringResourceRequired
benefits[PermissionPrimerScreenBenefit]Required
continueTitleLocalizedStringResource"Continue"
skipTitleLocalizedStringResource?"Not Now"
tintColor.blue
onContinue() -> VoidRequired
onSkip() -> Void{}

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

PermissionPrimerScreen.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct PermissionPrimerScreenBenefit: Identifiable {
let systemImage: String
let title: LocalizedStringResource
let text: LocalizedStringResource
var id: String { systemImage }
}
/// The screen an app shows before the system asks for a permission: what it is for, what the
/// person gets, and one button that leads to the system's question.
///
/// Ask for the permission in `onContinue`. Pass `skipTitle` as nil where the only way on has
/// to be the system's question, as Apple asks before the tracking prompt. With Reduce Motion
/// on, the rings stand still and the benefits appear together.
struct PermissionPrimerScreen: View {
let systemImage: String
let title: LocalizedStringResource
let message: LocalizedStringResource
let benefits: [PermissionPrimerScreenBenefit]
var continueTitle: LocalizedStringResource = "Continue"
var skipTitle: LocalizedStringResource? = "Not Now"
var tint: Color = .blue
let onContinue: () -> Void
var onSkip: () -> Void = {}
@State private var hasAppeared = false
@ScaledMetric(relativeTo: .largeTitle) private var heroSize = 96.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let minimumTarget: CGFloat = 44
var body: some View {
ScrollView {
VStack(spacing: 28) {
hero
VStack(spacing: 10) {
Text(title)
.font(.largeTitle.bold())
.accessibilityAddTraits(.isHeader)
Text(message)
.font(.body)
.foregroundStyle(.secondary)
}
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 20) {
ForEach(Array(benefits.enumerated()), id: \.element.id) { index, benefit in
row(benefit)
.opacity(hasAppeared ? 1 : 0)
.offset(y: hasAppeared || reduceMotion ? 0 : 18)
.animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.5, bounce: 0.2).delay(0.2 + 0.12 * Double(index)), value: hasAppeared)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 28)
.padding(.top, 36)
.padding(.bottom, 16)
}
.scrollBounceBehavior(.basedOnSize)
.safeAreaInset(edge: .bottom) { buttons }
.onAppear { hasAppeared = true }
}
private var hero: some View {
Image(systemName: systemImage)
.font(.system(size: heroSize * 0.44, weight: .semibold))
.foregroundStyle(PermissionPrimerScreenContrastingForeground(background: tint))
.frame(width: heroSize, height: heroSize)
.background(tint.gradient, in: .circle)
.background {
if !reduceMotion {
ForEach(0..<2, id: \.self) { ring in
Circle()
.stroke(tint, lineWidth: 2)
.phaseAnimator([false, true]) { content, isWide in
content
.scaleEffect(isWide ? 1.7 : 1)
.opacity(isWide ? 0 : 0.5)
} animation: { isWide in
isWide ? .easeOut(duration: 2).delay(Double(ring) * 0.7) : .linear(duration: 0.01).delay(Double(1 - ring) * 0.7)
}
}
}
}
.padding(.vertical, heroSize * 0.3)
.accessibilityHidden(true)
}
private func row(_ benefit: PermissionPrimerScreenBenefit) -> some View {
HStack(alignment: .top, spacing: 16) {
Image(systemName: benefit.systemImage)
.font(.title2)
.foregroundStyle(tint)
.frame(width: 36)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(benefit.title)
.font(.headline)
Text(benefit.text)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .combine)
}
private var buttons: some View {
VStack(spacing: 4) {
Button(action: onContinue) {
Text(continueTitle)
.font(.headline)
.foregroundStyle(PermissionPrimerScreenContrastingForeground(background: tint))
.frame(maxWidth: .infinity, minHeight: 54)
.background(tint.gradient, in: .rect(cornerRadius: 16))
}
.buttonStyle(.plain)
if let skipTitle {
Button(action: onSkip) {
Text(skipTitle)
.font(.subheadline.weight(.medium))
.frame(maxWidth: .infinity, minHeight: minimumTarget)
.contentShape(.rect)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 24)
.padding(.top, 12)
.padding(.bottom, 8)
.background(.bar)
}
}
/// White or black, whichever reads better on `background`. Black only on a
/// light color, so the system's own colors keep white.
nonisolated struct PermissionPrimerScreenContrastingForeground: ShapeStyle {
let background: Color
func resolve(in environment: EnvironmentValues) -> Color {
let color = background.resolve(in: environment)
let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue
return luminance > 0.5 ? .black : .white
}
}
// MARK: - Demo
struct PermissionPrimerScreenDemo: View {
private enum Answer {
case allowed, later
}
@State private var answer: Answer?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
if let answer {
// Only for this demo: what came of the choice, and a way back to the screen.
ContentUnavailableView {
Label(answer == .allowed ? "The system would ask now" : "Maybe later", systemImage: answer == .allowed ? "bell.badge.fill" : "moon.zzz.fill")
} description: {
Text(answer == .allowed ? "This is where your app requests the permission." : "Ask again when the person reaches something that needs it.")
} actions: {
Button("Start over") { self.answer = nil }
.buttonStyle(.borderedProminent)
}
.transition(.opacity)
} else {
PermissionPrimerScreen(
systemImage: "bell.badge.fill",
title: "Stay in the loop",
message: "Turn on notifications and we only write when it matters.",
benefits: [
PermissionPrimerScreenBenefit(systemImage: "shippingbox.fill", title: "Order updates", text: "Know the moment your order ships and arrives."),
PermissionPrimerScreenBenefit(systemImage: "tag.fill", title: "Price drops", text: "Hear first when something you saved gets cheaper."),
PermissionPrimerScreenBenefit(systemImage: "moon.fill", title: "Quiet by default", text: "No marketing, and nothing at night."),
],
tint: .indigo,
onContinue: { answer = .allowed },
onSkip: { answer = .later }
)
.transition(.opacity)
}
}
.animation(reduceMotion ? nil : .easeInOut(duration: 0.3), value: answer)
}
}
#Preview {
PermissionPrimerScreenDemo()
}