Skip to content
Permission Primer preview
An animated render of the SwiftUI source on this page.

Permission Primer

From syxUI — written for both platforms, not translated between them.

The explainer you show before the system permission dialog, so you only ask once.

Onboarding · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • onboarding
  • permission
  • notifications
  • primer
  • pre-prompt
  • animated

The actual source

PermissionPrimer.swift
// Permission Primer · syxUI · https://syxui.dev/components/permission-primer
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// The screen you show *before* the system permission alert.
///
/// The system alert can only be shown once. Asking cold burns it, so this
/// explains the trade first and only calls through when the answer is likely
/// to be yes — `onAllow` is where you invoke the real request.
struct PermissionPrimer: View {
    var symbol: String = "bell.badge.fill"
    var title: String = "Stay in the loop"
    var message: String = "Get a nudge when a build finishes or someone replies. No marketing, ever."
    var reasons: [String] = [
        "Only for things you started",
        "Turn any type off individually",
        "Nothing is shared with anyone",
    ]
    var allowTitle: String = "Allow notifications"
    var denyTitle: String = "Not now"
    var accent: Color = Color(red: 0.36, green: 0.42, blue: 0.98)

    var onAllow: () -> Void = {}
    var onDeny: () -> Void = {}
    /// Drive the entrance externally, 0...1.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var animated: Double = 0

    private var t: Double { progress ?? animated }

    var body: some View {
        VStack(spacing: 0) {
            // The explanation scrolls; the buttons stay pinned. Long
            // translations and large Dynamic Type sizes both make this list
            // taller than the screen, and the action must never be pushed out
            // of reach.
            ScrollView {
                explanation
            }
            .scrollBounceBehavior(.basedOnSize)
            .scrollIndicators(.hidden)

            VStack(spacing: 4) {
                Button(action: onAllow) {
                    Text(allowTitle)
                        .font(.system(size: 16.5, weight: .semibold))
                        .foregroundStyle(.white)
                        .frame(maxWidth: .infinity)
                        .frame(height: 52)
                        .background(.black, in: Capsule())
                }
                .buttonStyle(.plain)

                Button(action: onDeny) {
                    Text(denyTitle)
                        .font(.system(size: 15))
                        .foregroundStyle(.secondary)
                        .frame(maxWidth: .infinity)
                        .frame(height: 44)
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
            }
            .opacity(phase(0.6))
            .padding(.top, 14)
        }
        .padding(26)
        .frame(width: 320, height: 460)
        .onAppear(perform: start)
    }

    private var explanation: some View {
        VStack(spacing: 0) {
            glyph
                .padding(.bottom, 22)

            Text(title)
                .font(.system(size: 22, weight: .bold))
                .multilineTextAlignment(.center)
                .opacity(phase(0.15))
                .offset(y: (1 - phase(0.15)) * 12)

            Text(message)
                .font(.system(size: 14.5))
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
                .fixedSize(horizontal: false, vertical: true)
                .padding(.top, 7)
                .opacity(phase(0.25))
                .offset(y: (1 - phase(0.25)) * 12)

            VStack(alignment: .leading, spacing: 10) {
                ForEach(Array(reasons.enumerated()), id: \.offset) { index, reason in
                    // Each reason arrives a beat after the one above it.
                    let local = phase(0.35 + Double(index) * 0.08)

                    HStack(spacing: 9) {
                        Image(systemName: "checkmark")
                            .font(.system(size: 10, weight: .bold))
                            .foregroundStyle(accent)
                            .frame(width: 18, height: 18)
                            .background(accent.opacity(0.12), in: Circle())
                        Text(reason)
                            .font(.system(size: 13.5))
                            .foregroundStyle(.primary)
                    }
                    .opacity(local)
                    .offset(x: (1 - local) * -10)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.top, 22)
            .padding(.horizontal, 4)
        }
    }

    private var glyph: some View {
        ZStack {
            Circle()
                .fill(accent.opacity(0.10))
                .frame(width: 96, height: 96)
                .scaleEffect(0.7 + 0.3 * phase(0))
            Image(systemName: symbol)
                .font(.system(size: 38))
                .foregroundStyle(accent)
                .scaleEffect(0.6 + 0.4 * phase(0.05))
        }
        .opacity(phase(0))
    }

    /// Eased sub-phase starting at `from`, so elements stagger off one value.
    private func phase(_ from: Double) -> Double {
        let raw = min(max((t - from) / 0.35, 0), 1)
        return 1 - pow(1 - raw, 3)
    }

    private func start() {
        guard progress == nil else { return }
        guard !reduceMotion else { animated = 1; return }
        withAnimation(.linear(duration: 1.1)) { animated = 1 }
    }
}

#Preview {
    PermissionPrimer(progress: 1)
        .padding(20)
}
iOS 17 · No dependencies

SwiftUI note. Call the real UNUserNotificationCenter request inside onAllow, and record the denial in onDeny so you do not show this again next launch. The system alert only appears once per install — this is what protects that one shot.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27