🌐 KO

πŸ›‘οΈ PermissionKit

⭐ Difficulty: ⭐⭐ ⏱️ Est. Time: 1h πŸ“‚ iOS 26

Build privacy-respecting apps with unified permission management

iOS 18+πŸ†• 2024

✨ PermissionKit is?

PermissionKit is a unified permission management framework introduced in iOS 18. It manages various system permissions β€” camera, microphone, location, notifications, contacts β€” through a single consistent API. It tracks permission status in real-time, provides deep links to Settings when denied, and displays contextual explanations before requesting permissions to improve UX. A modern, privacy-first permission management solution.

πŸ’‘ Key Features: Unified Permission API Β· Real-Time Status Tracking Β· Settings Deep Link Β· Contextual UI Β· Partial Permissions Β· Provisional Access Β· Permission Groups Β· SwiftUI Integration

🎯 1. Basic Setup

Set up PermissionKit and add permission descriptions to Info.plist.

Info.plist β€” Add Permission Descriptions
<!-- 카메라 -->
<key>NSCameraUsageDescription</key>
<string>사진을 μ΄¬μ˜ν•˜κ³  ν”„λ‘œν•„ 이미지λ₯Ό μ„€μ •ν•˜λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- 사진 라이브러리 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>사진을 μ €μž₯ν•˜κ³  λΆˆλŸ¬μ˜€λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- μœ„μΉ˜ (μ‚¬μš© 쀑) -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>μ£Όλ³€ μž₯μ†Œλ₯Ό κ²€μƒ‰ν•˜κ³  날씨 정보λ₯Ό μ œκ³΅ν•˜λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- μœ„μΉ˜ (항상) -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>μœ„μΉ˜ 기반 μ•Œλ¦Όμ„ λ³΄λ‚΄λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- 마이크 -->
<key>NSMicrophoneUsageDescription</key>
<string>μŒμ„± λ©”μ‹œμ§€λ₯Ό λ…ΉμŒν•˜λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- μ—°λ½μ²˜ -->
<key>NSContactsUsageDescription</key>
<string>친ꡬλ₯Ό μ°Ύκ³  μ΄ˆλŒ€ν•˜λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>

<!-- μ•Œλ¦Ό -->
<key>NSUserNotificationsUsageDescription</key>
<string>μ€‘μš”ν•œ μ—…λ°μ΄νŠΈλ₯Ό μ•Œλ €λ“œλ¦¬λŠ”λ° μ‚¬μš©λ©λ‹ˆλ‹€.</string>
PermissionManager.swift β€” Permission Manager
import SwiftUI
import AVFoundation
import CoreLocation
import UserNotifications
import Contacts
import Photos

// κΆŒν•œ νƒ€μž…
enum PermissionType: String, CaseIterable, Identifiable {
    case camera = "카메라"
    case photoLibrary = "사진"
    case location = "μœ„μΉ˜"
    case microphone = "마이크"
    case contacts = "μ—°λ½μ²˜"
    case notifications = "μ•Œλ¦Ό"

    var id: String { rawValue }

    var icon: String {
        switch self {
        case .camera: return "camera.fill"
        case .photoLibrary: return "photo.fill"
        case .location: return "location.fill"
        case .microphone: return "mic.fill"
        case .contacts: return "person.2.fill"
        case .notifications: return "bell.fill"
        }
    }

    var description: String {
        switch self {
        case .camera: return "사진과 λΉ„λ””μ˜€λ₯Ό μ΄¬μ˜ν•˜λŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .photoLibrary: return "사진을 μ €μž₯ν•˜κ³  λΆˆλŸ¬μ˜€λŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .location: return "μ£Όλ³€ μž₯μ†Œλ₯Ό μ°ΎλŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .microphone: return "μŒμ„±μ„ λ…ΉμŒν•˜λŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .contacts: return "친ꡬλ₯Ό μ°ΎλŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        case .notifications: return "μ€‘μš”ν•œ μ•Œλ¦Όμ„ λ°›λŠ”λ° ν•„μš”ν•©λ‹ˆλ‹€"
        }
    }
}

// κΆŒν•œ μƒνƒœ
enum PermissionStatus {
    case notDetermined
    case authorized
    case denied
    case restricted
    case limited // λΆ€λΆ„ κΆŒν•œ (사진 λ“±)

    var color: Color {
        switch self {
        case .notDetermined: return .gray
        case .authorized: return .green
        case .denied: return .red
        case .restricted: return .orange
        case .limited: return .yellow
        }
    }

    var text: String {
        switch self {
        case .notDetermined: return "λ―Έμ„€μ •"
        case .authorized: return "ν—ˆμš©λ¨"
        case .denied: return "거뢀됨"
        case .restricted: return "μ œν•œλ¨"
        case .limited: return "μ œν•œμ  ν—ˆμš©"
        }
    }
}

// 톡합 κΆŒν•œ κ΄€λ¦¬μž
@Observable
class PermissionManager {
    var permissions: [PermissionType: PermissionStatus] = [:]

    init() {
        // 초기 μƒνƒœ 확인
        Task {
            await checkAllPermissions()
        }
    }

    // λͺ¨λ“  κΆŒν•œ μƒνƒœ 확인
    func checkAllPermissions() async {
        for type in PermissionType.allCases {
            let status = await checkPermission(type)
            await MainActor.run {
                permissions[type] = status
            }
        }
    }

    // κ°œλ³„ κΆŒν•œ 확인
    func checkPermission(_ type: PermissionType) async -> PermissionStatus {
        switch type {
        case .camera:
            return checkCameraPermission()

        case .photoLibrary:
            return checkPhotoLibraryPermission()

        case .location:
            return checkLocationPermission()

        case .microphone:
            return checkMicrophonePermission()

        case .contacts:
            return await checkContactsPermission()

        case .notifications:
            return await checkNotificationPermission()
        }
    }

    // 카메라 κΆŒν•œ
    func checkCameraPermission() -> PermissionStatus {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .notDetermined: return .notDetermined
        case .authorized: return .authorized
        case .denied: return .denied
        case .restricted: return .restricted
        @unknown default: return .notDetermined
        }
    }

    // 사진 κΆŒν•œ
    func checkPhotoLibraryPermission() -> PermissionStatus {
        switch PHPhotoLibrary.authorizationStatus(for: .readWrite) {
        case .notDetermined: return .notDetermined
        case .authorized: return .authorized
        case .denied: return .denied
        case .restricted: return .restricted
        case .limited: return .limited
        @unknown default: return .notDetermined
        }
    }

    // μœ„μΉ˜ κΆŒν•œ
    func checkLocationPermission() -> PermissionStatus {
        let status = CLLocationManager().authorizationStatus

        switch status {
        case .notDetermined: return .notDetermined
        case .authorizedAlways, .authorizedWhenInUse: return .authorized
        case .denied: return .denied
        case .restricted: return .restricted
        @unknown default: return .notDetermined
        }
    }

    // 마이크 κΆŒν•œ
    func checkMicrophonePermission() -> PermissionStatus {
        switch AVAudioSession.sharedInstance().recordPermission {
        case .undetermined: return .notDetermined
        case .granted: return .authorized
        case .denied: return .denied
        @unknown default: return .notDetermined
        }
    }

    // μ—°λ½μ²˜ κΆŒν•œ
    func checkContactsPermission() async -> PermissionStatus {
        switch CNContactStore.authorizationStatus(for: .contacts) {
        case .notDetermined: return .notDetermined
        case .authorized: return .authorized
        case .denied: return .denied
        case .restricted: return .restricted
        @unknown default: return .notDetermined
        }
    }

    // μ•Œλ¦Ό κΆŒν•œ
    func checkNotificationPermission() async -> PermissionStatus {
        let settings = await UNUserNotificationCenter.current().notificationSettings()

        switch settings.authorizationStatus {
        case .notDetermined: return .notDetermined
        case .authorized, .provisional, .ephemeral: return .authorized
        case .denied: return .denied
        @unknown default: return .notDetermined
        }
    }
}

πŸ” 2. κΆŒν•œ μš”μ²­

μ‚¬μš©μžμ—κ²Œ κΆŒν•œμ„ μš”μ²­ν•˜κ³  κ²°κ³Ό handling.

PermissionRequest.swift β€” Permission Request
import SwiftUI

extension PermissionManager {
    // κΆŒν•œ μš”μ²­
    func requestPermission(_ type: PermissionType) async -> PermissionStatus {
        switch type {
        case .camera:
            return await requestCameraPermission()

        case .photoLibrary:
            return await requestPhotoLibraryPermission()

        case .location:
            // μœ„μΉ˜λŠ” CLLocationManager 델리게이트 ν•„μš”
            return .notDetermined

        case .microphone:
            return await requestMicrophonePermission()

        case .contacts:
            return await requestContactsPermission()

        case .notifications:
            return await requestNotificationPermission()
        }
    }

    func requestCameraPermission() async -> PermissionStatus {
        let granted = await AVCaptureDevice.requestAccess(for: .video)
        return granted ? .authorized : .denied
    }

    func requestPhotoLibraryPermission() async -> PermissionStatus {
        let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite)

        switch status {
        case .authorized: return .authorized
        case .limited: return .limited
        case .denied: return .denied
        case .restricted: return .restricted
        default: return .notDetermined
        }
    }

    func requestMicrophonePermission() async -> PermissionStatus {
        let granted = await AVAudioSession.sharedInstance().requestRecordPermission()
        return granted ? .authorized : .denied
    }

    func requestContactsPermission() async -> PermissionStatus {
        do {
            let granted = try await CNContactStore().requestAccess(for: .contacts)
            return granted ? .authorized : .denied
        } catch {
            return .denied
        }
    }

    func requestNotificationPermission() async -> PermissionStatus {
        do {
            let granted = try await UNUserNotificationCenter.current()
                .requestAuthorization(options: [.alert, .badge, .sound])
            return granted ? .authorized : .denied
        } catch {
            return .denied
        }
    }

    // μ„€μ • μ•±μœΌλ‘œ 이동
    func openSettings() {
        guard let settingsURL = URL(string: UIApplication.openSettingsURLString)
        else { return }

        UIApplication.shared.open(settingsURL)
    }
}

πŸ“± 3. κΆŒν•œ UI

μ‚¬μš©μž μΉœν™”μ μΈ κΆŒν•œ μš”μ²­ 화면을 λ§Œλ“­.

PermissionView.swift β€” κΆŒν•œ ν™”λ©΄
import SwiftUI

struct PermissionListView: View {
    @State private var manager = PermissionManager()

    var body: some View {
        NavigationStack {
            List(PermissionType.allCases) { type in
                PermissionRow(
                    type: type,
                    status: manager.permissions[type] ?? .notDetermined,
                    onRequest: {
                        Task {
                            let newStatus = await manager.requestPermission(type)
                            manager.permissions[type] = newStatus
                        }
                    },
                    onOpenSettings: {
                        manager.openSettings()
                    }
                )
            }
            .navigationTitle("κΆŒν•œ 관리")
            .task {
                await manager.checkAllPermissions()
            }
        }
    }
}

struct PermissionRow: View {
    let type: PermissionType
    let status: PermissionStatus
    let onRequest: () -> Void
    let onOpenSettings: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Image(systemName: type.icon)
                    .font(.title2)
                    .foregroundStyle(.blue)
                    .frame(width: 40)

                VStack(alignment: .leading, spacing: 4) {
                    Text(type.rawValue)
                        .font(.headline)

                    Text(type.description)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                Spacer()

                // μƒνƒœ λ°°μ§€
                Text(status.text)
                    .font(.caption)
                    .fontWeight(.semibold)
                    .foregroundStyle(.white)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
                    .background(status.color)
                    .cornerRadius(8)
            }

            // μ•‘μ…˜ λ²„νŠΌ
            switch status {
            case .notDetermined:
                Button {
                    onRequest()
                } label: {
                    Text("κΆŒν•œ ν—ˆμš©")
                        .frame(maxWidth: .infinity)
                        .padding(8)
                        .background(Color.blue)
                        .foregroundStyle(.white)
                        .cornerRadius(8)
                }
                .buttonStyle(.plain)

            case .denied:
                VStack(spacing: 8) {
                    Text("κΆŒν•œμ΄ κ±°λΆ€λ˜μ—ˆμŠ΅λ‹ˆλ‹€")
                        .font(.caption)
                        .foregroundStyle(.red)

                    Button {
                        onOpenSettings()
                    } label: {
                        Label("μ„€μ •μ—μ„œ ν—ˆμš©ν•˜κΈ°", systemImage: "gear")
                            .frame(maxWidth: .infinity)
                            .padding(8)
                            .background(Color.gray.opacity(0.2))
                            .foregroundStyle(.primary)
                            .cornerRadius(8)
                    }
                    .buttonStyle(.plain)
                }

            case .limited:
                Button {
                    onRequest()
                } label: {
                    Text("전체 μ•‘μ„ΈμŠ€ ν—ˆμš©")
                        .frame(maxWidth: .infinity)
                        .padding(8)
                        .background(Color.orange)
                        .foregroundStyle(.white)
                        .cornerRadius(8)
                }
                .buttonStyle(.plain)

            case .authorized:
                HStack {
                    Image(systemName: "checkmark.circle.fill")
                        .foregroundStyle(.green)
                    Text("κΆŒν•œμ΄ ν—ˆμš©λ˜μ—ˆμŠ΅λ‹ˆλ‹€")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

            case .restricted:
                Text("κΈ°κΈ° 섀정에 μ˜ν•΄ μ œν•œλ˜μ—ˆμŠ΅λ‹ˆλ‹€")
                    .font(.caption)
                    .foregroundStyle(.orange)
            }
        }
        .padding(.vertical, 8)
    }
}

🎭 4. μ˜¨λ³΄λ”© 톡합

Build an onboarding screen that explains and requests permissions on first launch.

PermissionOnboardingView.swift β€” Onboarding
import SwiftUI

struct PermissionOnboardingView: View {
    @State private var manager = PermissionManager()
    @State private var currentStep = 0
    @Environment(\.dismiss) var dismiss

    let steps: [PermissionType] = [.camera, .photoLibrary, .location, .notifications]

    var body: some View {
        VStack(spacing: 32) {
            // μ§„ν–‰ ν‘œμ‹œ
            HStack(spacing: 8) {
                ForEach(0..<4, id: \.self) { index in
                    Capsule()
                        .fill(index <= currentStep ? Color.blue : Color.gray.opacity(0.3))
                        .frame(height: 4)
                }
            }
            .padding(.horizontal)

            Spacer()

            // ν˜„μž¬ 단계 μ½˜ν…μΈ 
            if currentStep < steps.count {
                PermissionStepView(type: steps[currentStep])
            } else {
                CompletionView()
            }

            Spacer()

            // μ•‘μ…˜ λ²„νŠΌ
            VStack(spacing: 12) {
                if currentStep < steps.count {
                    Button {
                        Task {
                            let type = steps[currentStep]
                            let status = await manager.requestPermission(type)
                            manager.permissions[type] = status
                            currentStep += 1
                        }
                    } label: {
                        Text("ν—ˆμš©")
                            .font(.headline)
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color.blue)
                            .foregroundStyle(.white)
                            .cornerRadius(12)
                    }

                    Button {
                        currentStep += 1
                    } label: {
                        Text("λ‚˜μ€‘μ—")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                } else {
                    Button {
                        dismiss()
                    } label: {
                        Text("μ‹œμž‘ν•˜κΈ°")
                            .font(.headline)
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color.blue)
                            .foregroundStyle(.white)
                            .cornerRadius(12)
                    }
                }
            }
            .padding()
        }
    }
}

struct PermissionStepView: View {
    let type: PermissionType

    var body: some View {
        VStack(spacing: 24) {
            // μ•„μ΄μ½˜
            Image(systemName: type.icon)
                .font(.system(size: 80))
                .foregroundStyle(.blue)
                .symbolEffect(.bounce)

            // 제λͺ©κ³Ό μ„€λͺ…
            VStack(spacing: 8) {
                Text(type.rawValue)
                    .font(.title)
                    .fontWeight(.bold)

                Text(type.description)
                    .font(.body)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
            }
            .padding(.horizontal)

            // μ‚¬μš© μ˜ˆμ‹œ
            VStack(alignment: .leading, spacing: 12) {
                Text("이런 μš©λ„λ‘œ μ‚¬μš©λ©λ‹ˆλ‹€:")
                    .font(.headline)

                VStack(alignment: .leading, spacing: 8) {
                    usageExample("ν”„λ‘œν•„ 사진 촬영")
                    usageExample("λ¬Έμ„œ μŠ€μΊ”")
                    usageExample("AR κΈ°λŠ₯ μ‚¬μš©")
                }
            }
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.gray.opacity(0.1))
            .cornerRadius(12)
            .padding(.horizontal)
        }
    }

    func usageExample(_ text: String) -> some View {
        HStack {
            Image(systemName: "checkmark.circle.fill")
                .foregroundStyle(.green)
            Text(text)
                .font(.subheadline)
        }
    }
}

struct CompletionView: View {
    var body: some View {
        VStack(spacing: 24) {
            Image(systemName: "checkmark.circle.fill")
                .font(.system(size: 80))
                .foregroundStyle(.green)

            VStack(spacing: 8) {
                Text("λͺ¨λ‘ μ™„λ£Œ!")
                    .font(.title)
                    .fontWeight(.bold)

                Text("이제 μ•±μ˜ λͺ¨λ“  κΈ°λŠ₯을 μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€")
                    .font(.body)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

πŸ”„ 5. μ‹€μ‹œκ°„ κΆŒν•œ λͺ¨λ‹ˆν„°λ§

Detect and respond to permission changes while the app is running.

PermissionMonitor.swift β€” Real-Time Monitoring
import SwiftUI
import Combine

@Observable
class PermissionMonitor {
    var cameraAvailable = false
    var locationAvailable = false
    var showPermissionAlert = false
    var alertMessage = ""

    private var cancellables = Set<AnyCancellable>()

    init() {
        startMonitoring()
    }

    func startMonitoring() {
        // μ•± ν¬κ·ΈλΌμš΄λ“œ μ§„μž… μ‹œ κΆŒν•œ μž¬ν™•μΈ
        NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
            .sink { _ in
                Task {
                    await self.checkPermissions()
                }
            }
            .store(in: &cancellables)
    }

    func checkPermissions() async {
        // 카메라 κΆŒν•œ 확인
        let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video)
        let cameraWasAvailable = cameraAvailable
        cameraAvailable = cameraStatus == .authorized

        // κΆŒν•œμ΄ μ·¨μ†Œλœ 경우
        if cameraWasAvailable && !cameraAvailable {
            await showAlert("카메라 κΆŒν•œμ΄ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μ„€μ •μ—μ„œ λ‹€μ‹œ ν—ˆμš©ν•΄μ£Όμ„Έμš”.")
        }

        // μœ„μΉ˜ κΆŒν•œ 확인
        let locationStatus = CLLocationManager().authorizationStatus
        let locationWasAvailable = locationAvailable
        locationAvailable = locationStatus == .authorizedWhenInUse || locationStatus == .authorizedAlways

        if locationWasAvailable && !locationAvailable {
            await showAlert("μœ„μΉ˜ κΆŒν•œμ΄ μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€. μ„€μ •μ—μ„œ λ‹€μ‹œ ν—ˆμš©ν•΄μ£Όμ„Έμš”.")
        }
    }

    func showAlert(_ message: String) async {
        await MainActor.run {
            alertMessage = message
            showPermissionAlert = true
        }
    }
}

// μ‚¬μš© 예제
struct CameraView: View {
    @State private var monitor = PermissionMonitor()

    var body: some View {
        VStack {
            if monitor.cameraAvailable {
                Text("카메라 μ‚¬μš© κ°€λŠ₯")
                // 카메라 UI
            } else {
                ContentUnavailableView {
                    Label("카메라 κΆŒν•œ ν•„μš”", systemImage: "camera.fill")
                } description: {
                    Text("μ„€μ •μ—μ„œ 카메라 κΆŒν•œμ„ ν—ˆμš©ν•΄μ£Όμ„Έμš”")
                } actions: {
                    Button("μ„€μ • μ—΄κΈ°") {
                        PermissionManager().openSettings()
                    }
                }
            }
        }
        .alert("κΆŒν•œ λ³€κ²½", isPresented: $monitor.showPermissionAlert) {
            Button("μ„€μ • μ—΄κΈ°") {
                PermissionManager().openSettings()
            }
            Button("확인", role: .cancel) { }
        } message: {
            Text(monitor.alertMessage)
        }
        .task {
            await monitor.checkPermissions()
        }
    }
}

πŸ’‘ HIG Guidelines

🎯 Practical Usage

πŸ“š Learn More

⚑️ 팁: PermissionKit puts user privacy first. Always explain context before requesting permissions, and design your app to work properly even when denied. Leverage partial permissions (e.g., selected photos only) to increase user trust. Re-check permission status whenever the app returns to foreground for a consistent experience.

πŸ“Ž Apple Official Resources

πŸ“˜ Documentation 🎬 WWDC Sessions