π‘οΈ PermissionKit
ν΅ν© κΆν κ΄λ¦¬λ‘ μ¬μ©μ νλΌμ΄λ²μλ₯Ό μ‘΄μ€νλ μ± λ§λ€κΈ°
β¨ PermissionKitμ΄λ?
PermissionKitμ iOS 18μμ λμ λ ν΅ν© κΆν κ΄λ¦¬ νλ μμν¬μ λλ€. μΉ΄λ©λΌ, λ§μ΄ν¬, μμΉ, μλ¦Ό, μ°λ½μ² λ± λ€μν μμ€ν κΆνμ νλμ μΌκ΄λ APIλ‘ κ΄λ¦¬ν μ μμ΅λλ€. κΆν μνλ₯Ό μ€μκ°μΌλ‘ μΆμ νκ³ , μ¬μ©μκ° κ±°λΆν κ²½μ° μ€μ μ±μΌλ‘μ λ₯λ§ν¬λ₯Ό μ 곡νλ©°, κΆν μμ² μ μ λ§₯λ½ μ€λͺ μ νμνμ¬ μ¬μ©μ κ²½νμ κ°μ ν©λλ€. νλΌμ΄λ²μλ₯Ό μ΅μ°μ μΌλ‘ νλ νλμ μΈ κΆν κ΄λ¦¬ μ루μ μ λλ€.
π― 1. κΈ°λ³Έ μ€μ
PermissionKitμ μ€μ νκ³ Info.plistμ κΆν μ€λͺ μ μΆκ°ν©λλ€.
<!-- μΉ΄λ©λΌ --> <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>
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. κΆν μμ²
μ¬μ©μμκ² κΆνμ μμ²νκ³ κ²°κ³Όλ₯Ό μ²λ¦¬ν©λλ€.
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
μ¬μ©μ μΉνμ μΈ κΆν μμ² νλ©΄μ λ§λλλ€.
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. μ¨λ³΄λ© ν΅ν©
μ± μ²« μ€ν μ κΆνμ μ€λͺ νκ³ μμ²νλ μ¨λ³΄λ© νλ©΄μ λ§λλλ€.
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. μ€μκ° κΆν λͺ¨λν°λ§
μ±μ΄ μ€ν μ€μΌ λ κΆν λ³κ²½μ κ°μ§νκ³ λμν©λλ€.
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 κ°μ΄λλΌμΈ
- λͺ νν μ΄μ : κΆνμ΄ μ νμνμ§ μ¬μ©μκ° μ΄ν΄ν μ μλλ‘ μ€λͺ
- μ μ ν νμ΄λ°: κΈ°λ₯μ μ¬μ©νλ €λ μμ μ κΆν μμ²
- μ°μν μ ν: κΆνμ΄ μμ΄λ μ±μ λ€λ₯Έ κΈ°λ₯μ μ¬μ© κ°λ₯νλλ‘
- μ¬μμ² κΈμ§: κ±°λΆλ κΆνμ λ°λ³΅μ μΌλ‘ μμ²νμ§ μμ
- μ€μ μλ΄: κ±°λΆ μ μ€μ μ±μΌλ‘ μ΄λνλ λͺ νν κ²½λ‘ μ 곡
π― μ€μ νμ©
- μμ μ±: μ°λ½μ², μΉ΄λ©λΌ, μλ¦Ό κΆν ν΅ν© κ΄λ¦¬
- ν¬μ€ μ±: HealthKit, μμΉ, μλ¦Ό κΆν μ¨λ³΄λ©
- ν¬ν μ±: μ¬μ§ λΌμ΄λΈλ¬λ¦¬ λΆλΆ κΆν νμ©
- λ©μμ§ μ±: λ§μ΄ν¬, μΉ΄λ©λΌ μ€μκ° κΆν νμΈ
- λ΄λΉκ²μ΄μ μ±: μμΉ κΆν μν λͺ¨λν°λ§