├── App ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon-1024.png │ │ ├── AppIcon-60@2x.png │ │ ├── AppIcon-60@3x.png │ │ ├── AppIcon-76@2x.png │ │ ├── AppIcon-83.5@2x.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── ViewfinderView.swift ├── ThumbnailView.swift ├── CameraApp.swift ├── PhotoItemView.swift ├── PhotoLibrary.swift ├── PhotoAssetCollection.swift ├── PhotoView.swift ├── PhotoAsset.swift ├── PhotoCollectionView.swift ├── CachedImageManager.swift ├── CameraView.swift ├── DataModel.swift ├── PhotoCollection.swift └── Camera.swift ├── images-for-readme ├── low-energy.png ├── new-arch.png └── orig-arch.png ├── .swiftpm └── playgrounds │ ├── DocumentThumbnail.png │ ├── version.plist │ ├── Workspace.plist │ ├── contentInfo.plist │ ├── DocumentThumbnail.plist │ └── CachedManifest.plist ├── .xcodesamplecode.plist ├── Package.swift └── README.md /App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /images-for-readme/low-energy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/images-for-readme/low-energy.png -------------------------------------------------------------------------------- /images-for-readme/new-arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/images-for-readme/new-arch.png -------------------------------------------------------------------------------- /images-for-readme/orig-arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/images-for-readme/orig-arch.png -------------------------------------------------------------------------------- /.swiftpm/playgrounds/DocumentThumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/.swiftpm/playgrounds/DocumentThumbnail.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/App/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/App/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danwood/CapturingPhotos/HEAD/App/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png -------------------------------------------------------------------------------- /.xcodesamplecode.plist: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.swiftpm/playgrounds/version.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemMintColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.swiftpm/playgrounds/Workspace.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppSettings 6 | 7 | appIconPlaceholderGlyphName 8 | camera 9 | appSettingsVersion 10 | 1 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.swiftpm/playgrounds/contentInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | contentVersion 6 | 1.0.0 7 | contentIdentifier 8 | com.apple.swiftplaygroundscontent.capturingphotos 9 | guideVersion 10 | 2.0.0 11 | 12 | 13 | -------------------------------------------------------------------------------- /.swiftpm/playgrounds/DocumentThumbnail.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DocumentThumbnailConfiguration 6 | 7 | accentColorHash 8 | 9 | XrZ/n4QJucP3OXNWM8vfkhITk9DhO9D0ZLGypqFa0tw= 10 | 11 | appIconHash 12 | 13 | Gq8OnZH5tvqx0d14iUMLkeCSdmbYiCB4f/dAWyvwNHM= 14 | 15 | thumbnailIsPrerendered 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /App/ViewfinderView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import SwiftUI 6 | 7 | struct ViewfinderView: View { 8 | let image: Image? 9 | 10 | var body: some View { 11 | GeometryReader { geometry in 12 | if let image = image { 13 | image 14 | .resizable() 15 | .scaledToFill() 16 | .frame(width: geometry.size.width, height: geometry.size.height) 17 | } 18 | } 19 | } 20 | } 21 | 22 | struct ViewfinderView_Previews: PreviewProvider { 23 | static var previews: some View { 24 | ViewfinderView(image: Image(systemName: "pencil")) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /App/ThumbnailView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import SwiftUI 6 | 7 | struct ThumbnailView: View { 8 | var image: Image? 9 | 10 | var body: some View { 11 | ZStack { 12 | Color.white 13 | if let image = image { 14 | image 15 | .resizable() 16 | .scaledToFill() 17 | } 18 | } 19 | .frame(width: 41, height: 41) 20 | .cornerRadius(11) 21 | } 22 | } 23 | 24 | struct ThumbnailView_Previews: PreviewProvider { 25 | static let previewImage = Image(systemName: "photo.fill") 26 | static var previews: some View { 27 | ThumbnailView(image: previewImage) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /App/CameraApp.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import SwiftUI 6 | 7 | @main 8 | struct CameraApp: App { 9 | 10 | init() { 11 | UINavigationBar.applyCustomAppearance() 12 | } 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | CameraView() 17 | } 18 | } 19 | } 20 | 21 | fileprivate extension UINavigationBar { 22 | 23 | static func applyCustomAppearance() { 24 | let appearance = UINavigationBarAppearance() 25 | appearance.backgroundEffect = UIBlurEffect(style: .systemUltraThinMaterial) 26 | UINavigationBar.appearance().standardAppearance = appearance 27 | UINavigationBar.appearance().compactAppearance = appearance 28 | UINavigationBar.appearance().scrollEdgeAppearance = appearance 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /App/PhotoItemView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import SwiftUI 6 | import Photos 7 | 8 | struct PhotoItemView: View { 9 | var asset: PhotoAsset 10 | var cache: CachedImageManager? 11 | var imageSize: CGSize 12 | 13 | @State private var image: Image? 14 | @State private var imageRequestID: PHImageRequestID? 15 | 16 | var body: some View { 17 | 18 | Group { 19 | if let image = image { 20 | image 21 | .resizable() 22 | .scaledToFill() 23 | } else { 24 | ProgressView() 25 | .scaleEffect(0.5) 26 | } 27 | } 28 | .task { 29 | guard image == nil, let cache = cache else { return } 30 | imageRequestID = await cache.requestImage(for: asset, targetSize: imageSize) { result in 31 | Task { 32 | if let result = result { 33 | self.image = result.image 34 | } 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | 3 | // WARNING: 4 | // This file is automatically generated. 5 | // Do not edit it by hand because the contents will be replaced. 6 | 7 | import PackageDescription 8 | import AppleProductTypes 9 | 10 | let package = Package( 11 | name: "Capturing Photos", 12 | platforms: [ 13 | .iOS("16.0") 14 | ], 15 | products: [ 16 | .iOSApplication( 17 | name: "Capturing Photos", 18 | targets: ["App"], 19 | displayVersion: "1.0", 20 | bundleVersion: "1", 21 | appIcon: .asset("AppIcon"), 22 | supportedDeviceFamilies: [ 23 | .pad, 24 | .phone 25 | ], 26 | supportedInterfaceOrientations: [ 27 | 28 | ], 29 | capabilities: [ 30 | .camera(purposeString: "This sample app uses the camera."), 31 | .photoLibrary(purposeString: "This sample app uses your photo library.") 32 | ] 33 | ) 34 | ], 35 | targets: [ 36 | .executableTarget( 37 | name: "App", 38 | path: "App" 39 | ) 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /App/PhotoLibrary.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import Photos 6 | import os.log 7 | 8 | class PhotoLibrary { 9 | 10 | static func checkAuthorization() async -> Bool { 11 | switch PHPhotoLibrary.authorizationStatus(for: .readWrite) { 12 | case .authorized: 13 | logger.debug("Photo library access authorized.") 14 | return true 15 | case .notDetermined: 16 | logger.debug("Photo library access not determined.") 17 | return await PHPhotoLibrary.requestAuthorization(for: .readWrite) == .authorized 18 | case .denied: 19 | logger.debug("Photo library access denied.") 20 | return false 21 | case .limited: 22 | logger.debug("Photo library access limited.") 23 | return false 24 | case .restricted: 25 | logger.debug("Photo library access restricted.") 26 | return false 27 | @unknown default: 28 | return false 29 | } 30 | } 31 | } 32 | 33 | fileprivate let logger = Logger(subsystem: "com.apple.swiftplaygroundscontent.capturingphotos", category: "PhotoLibrary") 34 | 35 | -------------------------------------------------------------------------------- /App/PhotoAssetCollection.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import Photos 6 | 7 | class PhotoAssetCollection: RandomAccessCollection { 8 | private(set) var fetchResult: PHFetchResult 9 | private var iteratorIndex: Int = 0 10 | 11 | private var cache = [Int : PhotoAsset]() 12 | 13 | var startIndex: Int { 0 } 14 | var endIndex: Int { fetchResult.count } 15 | 16 | init(_ fetchResult: PHFetchResult) { 17 | self.fetchResult = fetchResult 18 | } 19 | 20 | subscript(position: Int) -> PhotoAsset { 21 | if let asset = cache[position] { 22 | return asset 23 | } 24 | let asset = PhotoAsset(phAsset: fetchResult.object(at: position), index: position) 25 | cache[position] = asset 26 | return asset 27 | } 28 | 29 | var phAssets: [PHAsset] { 30 | var assets = [PHAsset]() 31 | fetchResult.enumerateObjects { (object, count, stop) in 32 | assets.append(object) 33 | } 34 | return assets 35 | } 36 | } 37 | 38 | extension PhotoAssetCollection: Sequence, IteratorProtocol { 39 | 40 | func next() -> PhotoAsset? { 41 | if iteratorIndex >= count { 42 | return nil 43 | } 44 | 45 | defer { 46 | iteratorIndex += 1 47 | } 48 | 49 | return self[iteratorIndex] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.swiftpm/playgrounds/CachedManifest.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CachedManifest 6 | 7 | manifestData 8 | 9 | eyJkZXBlbmRlbmNpZXMiOltdLCJuYW1lIjoiQ2FtZXJhQ2hhbGxlbmdlIiwi 10 | cGFja2FnZUtpbmQiOiJyb290IiwicGxhdGZvcm1zIjpbeyJvcHRpb25zIjpb 11 | XSwicGxhdGZvcm1OYW1lIjoiaW9zIiwidmVyc2lvbiI6IjE1LjIifV0sInBy 12 | b2R1Y3RzIjpbeyJuYW1lIjoiQ2FtZXJhQ2hhbGxlbmdlIiwic2V0dGluZ3Mi 13 | Olt7InRlYW1JZGVudGlmaWVyIjpbIkE5M0E1Q00yNzgiXX0seyJkaXNwbGF5 14 | VmVyc2lvbiI6WyIxLjAiXX0seyJidW5kbGVWZXJzaW9uIjpbIjEiXX0seyJp 15 | T1NBcHBJbmZvIjpbeyJhY2NlbnRDb2xvckFzc2V0TmFtZSI6IkFjY2VudENv 16 | bG9yIiwiY2FwYWJpbGl0aWVzIjpbXSwiaWNvbkFzc2V0TmFtZSI6IkFwcElj 17 | b24iLCJzdXBwb3J0ZWREZXZpY2VGYW1pbGllcyI6WyJwYWQiLCJwaG9uZSJd 18 | LCJzdXBwb3J0ZWRJbnRlcmZhY2VPcmllbnRhdGlvbnMiOltdfV19XSwidGFy 19 | Z2V0cyI6WyJDYW1lcmFDaGFsbGVuZ2UiXSwidHlwZSI6eyJleGVjdXRhYmxl 20 | IjpudWxsfX1dLCJ0YXJnZXRNYXAiOnsiQ2FtZXJhQ2hhbGxlbmdlIjp7ImRl 21 | cGVuZGVuY2llcyI6W10sImV4Y2x1ZGUiOltdLCJuYW1lIjoiQ2FtZXJhQ2hh 22 | bGxlbmdlIiwicGF0aCI6IkNhbWVyYUNoYWxsZW5nZSIsInJlc291cmNlcyI6 23 | W10sInNldHRpbmdzIjpbXSwidHlwZSI6ImV4ZWN1dGFibGUifX0sInRhcmdl 24 | dHMiOlt7ImRlcGVuZGVuY2llcyI6W10sImV4Y2x1ZGUiOltdLCJuYW1lIjoi 25 | Q2FtZXJhQ2hhbGxlbmdlIiwicGF0aCI6IkNhbWVyYUNoYWxsZW5nZSIsInJl 26 | c291cmNlcyI6W10sInNldHRpbmdzIjpbXSwidHlwZSI6ImV4ZWN1dGFibGUi 27 | fV0sInRvb2xzVmVyc2lvbiI6eyJfdmVyc2lvbiI6IjUuNS4wIn19 28 | 29 | manifestHash 30 | 31 | qFneFbEjeSybThjfR3/k5oPCGR9AOXklt/3aWOxg4FI= 32 | 33 | schemaVersion 34 | 3 35 | swiftPMVersionString 36 | 5.5.0 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "AppIcon-60@2x.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "60x60" 38 | }, 39 | { 40 | "filename" : "AppIcon-60@3x.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "idiom" : "ipad", 47 | "scale" : "1x", 48 | "size" : "20x20" 49 | }, 50 | { 51 | "idiom" : "ipad", 52 | "scale" : "2x", 53 | "size" : "20x20" 54 | }, 55 | { 56 | "idiom" : "ipad", 57 | "scale" : "1x", 58 | "size" : "29x29" 59 | }, 60 | { 61 | "idiom" : "ipad", 62 | "scale" : "2x", 63 | "size" : "29x29" 64 | }, 65 | { 66 | "idiom" : "ipad", 67 | "scale" : "1x", 68 | "size" : "40x40" 69 | }, 70 | { 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "40x40" 74 | }, 75 | { 76 | "idiom" : "ipad", 77 | "scale" : "1x", 78 | "size" : "76x76" 79 | }, 80 | { 81 | "filename" : "AppIcon-76@2x.png", 82 | "idiom" : "ipad", 83 | "scale" : "2x", 84 | "size" : "76x76" 85 | }, 86 | { 87 | "filename" : "AppIcon-83.5@2x.png", 88 | "idiom" : "ipad", 89 | "scale" : "2x", 90 | "size" : "83.5x83.5" 91 | }, 92 | { 93 | "filename" : "AppIcon-1024.png", 94 | "idiom" : "ios-marketing", 95 | "scale" : "1x", 96 | "size" : "1024x1024" 97 | } 98 | ], 99 | "info" : { 100 | "author" : "xcode", 101 | "version" : 1 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /App/PhotoView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import SwiftUI 6 | import Photos 7 | 8 | struct PhotoView: View { 9 | var asset: PhotoAsset 10 | var cache: CachedImageManager? 11 | @State private var image: Image? 12 | @State private var imageRequestID: PHImageRequestID? 13 | @Environment(\.dismiss) var dismiss 14 | private let imageSize = CGSize(width: 1024, height: 1024) 15 | 16 | var body: some View { 17 | Group { 18 | if let image = image { 19 | image 20 | .resizable() 21 | .scaledToFit() 22 | .accessibilityLabel(asset.accessibilityLabel) 23 | } else { 24 | ProgressView() 25 | } 26 | } 27 | .frame(maxWidth: .infinity, maxHeight: .infinity) 28 | .ignoresSafeArea() 29 | .background(Color.secondary) 30 | .navigationTitle("Photo") 31 | .navigationBarTitleDisplayMode(.inline) 32 | .overlay(alignment: .bottom) { 33 | buttonsView() 34 | .offset(x: 0, y: -50) 35 | } 36 | .task { 37 | guard image == nil, let cache = cache else { return } 38 | imageRequestID = await cache.requestImage(for: asset, targetSize: imageSize) { result in 39 | Task { 40 | if let result = result { 41 | self.image = result.image 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | private func buttonsView() -> some View { 49 | HStack(spacing: 60) { 50 | 51 | Button { 52 | Task { 53 | await asset.setIsFavorite(!asset.isFavorite) 54 | } 55 | } label: { 56 | Label("Favorite", systemImage: asset.isFavorite ? "heart.fill" : "heart") 57 | .font(.system(size: 24)) 58 | } 59 | 60 | Button { 61 | Task { 62 | await asset.delete() 63 | await MainActor.run { 64 | dismiss() 65 | } 66 | } 67 | } label: { 68 | Label("Delete", systemImage: "trash") 69 | .font(.system(size: 24)) 70 | } 71 | } 72 | .buttonStyle(.plain) 73 | .labelStyle(.iconOnly) 74 | .padding(EdgeInsets(top: 20, leading: 30, bottom: 20, trailing: 30)) 75 | .background(Color.secondary.colorInvert()) 76 | .cornerRadius(15) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /App/PhotoAsset.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import Photos 6 | import os.log 7 | 8 | struct PhotoAsset: Identifiable { 9 | var id: String { identifier } 10 | var identifier: String = UUID().uuidString 11 | var index: Int? 12 | var phAsset: PHAsset? 13 | 14 | typealias MediaType = PHAssetMediaType 15 | 16 | var isFavorite: Bool { 17 | phAsset?.isFavorite ?? false 18 | } 19 | 20 | var mediaType: MediaType { 21 | phAsset?.mediaType ?? .unknown 22 | } 23 | 24 | var accessibilityLabel: String { 25 | "Photo\(isFavorite ? ", Favorite" : "")" 26 | } 27 | 28 | init(phAsset: PHAsset, index: Int?) { 29 | self.phAsset = phAsset 30 | self.index = index 31 | self.identifier = phAsset.localIdentifier 32 | } 33 | 34 | init(identifier: String) { 35 | self.identifier = identifier 36 | let fetchedAssets = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil) 37 | self.phAsset = fetchedAssets.firstObject 38 | } 39 | 40 | func setIsFavorite(_ isFavorite: Bool) async { 41 | guard let phAsset = phAsset else { return } 42 | Task { 43 | do { 44 | try await PHPhotoLibrary.shared().performChanges { 45 | let request = PHAssetChangeRequest(for: phAsset) 46 | request.isFavorite = isFavorite 47 | } 48 | } catch (let error) { 49 | logger.error("Failed to change isFavorite: \(error.localizedDescription)") 50 | } 51 | } 52 | } 53 | 54 | func delete() async { 55 | guard let phAsset = phAsset else { return } 56 | do { 57 | try await PHPhotoLibrary.shared().performChanges { 58 | PHAssetChangeRequest.deleteAssets([phAsset] as NSArray) 59 | } 60 | logger.debug("PhotoAsset asset deleted: \(index ?? -1)") 61 | } catch (let error) { 62 | logger.error("Failed to delete photo: \(error.localizedDescription)") 63 | } 64 | } 65 | } 66 | 67 | extension PhotoAsset: Equatable { 68 | static func ==(lhs: PhotoAsset, rhs: PhotoAsset) -> Bool { 69 | (lhs.identifier == rhs.identifier) && (lhs.isFavorite == rhs.isFavorite) 70 | } 71 | } 72 | 73 | extension PhotoAsset: Hashable { 74 | func hash(into hasher: inout Hasher) { 75 | hasher.combine(identifier) 76 | } 77 | } 78 | 79 | extension PHObject: Identifiable { 80 | public var id: String { localIdentifier } 81 | } 82 | 83 | fileprivate let logger = Logger(subsystem: "com.apple.swiftplaygroundscontent.capturingphotos", category: "PhotoAsset") 84 | 85 | -------------------------------------------------------------------------------- /App/PhotoCollectionView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import SwiftUI 6 | import os.log 7 | 8 | struct PhotoCollectionView: View { 9 | @ObservedObject var photoCollection : PhotoCollection 10 | 11 | @Environment(\.displayScale) private var displayScale 12 | 13 | private static let itemSpacing = 12.0 14 | private static let itemCornerRadius = 15.0 15 | private static let itemSize = CGSize(width: 90, height: 90) 16 | 17 | private var imageSize: CGSize { 18 | return CGSize(width: Self.itemSize.width * min(displayScale, 2), height: Self.itemSize.height * min(displayScale, 2)) 19 | } 20 | 21 | private let columns = [ 22 | GridItem(.adaptive(minimum: itemSize.width, maximum: itemSize.height), spacing: itemSpacing) 23 | ] 24 | 25 | var body: some View { 26 | ScrollView { 27 | LazyVGrid(columns: columns, spacing: Self.itemSpacing) { 28 | ForEach(photoCollection.photoAssets) { asset in 29 | NavigationLink { 30 | PhotoView(asset: asset, cache: photoCollection.cache) 31 | } label: { 32 | photoItemView(asset: asset) 33 | } 34 | .buttonStyle(.borderless) 35 | .accessibilityLabel(asset.accessibilityLabel) 36 | } 37 | } 38 | .padding([.vertical], Self.itemSpacing) 39 | } 40 | .navigationTitle(photoCollection.albumName ?? "Gallery") 41 | .navigationBarTitleDisplayMode(.inline) 42 | .statusBar(hidden: false) 43 | } 44 | 45 | private func photoItemView(asset: PhotoAsset) -> some View { 46 | PhotoItemView(asset: asset, cache: photoCollection.cache, imageSize: imageSize) 47 | .frame(width: Self.itemSize.width, height: Self.itemSize.height) 48 | .clipped() 49 | .cornerRadius(Self.itemCornerRadius) 50 | .overlay(alignment: .bottomLeading) { 51 | if asset.isFavorite { 52 | Image(systemName: "heart.fill") 53 | .foregroundColor(.white) 54 | .shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 1) 55 | .font(.callout) 56 | .offset(x: 4, y: -4) 57 | } 58 | } 59 | .onAppear { 60 | Task { 61 | await photoCollection.cache.startCaching(for: [asset], targetSize: imageSize) 62 | } 63 | } 64 | .onDisappear { 65 | Task { 66 | await photoCollection.cache.stopCaching(for: [asset], targetSize: imageSize) 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /App/CachedImageManager.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import UIKit 6 | import Photos 7 | import SwiftUI 8 | import os.log 9 | 10 | actor CachedImageManager { 11 | 12 | private let imageManager = PHCachingImageManager() 13 | 14 | private var imageContentMode = PHImageContentMode.aspectFit 15 | 16 | enum CachedImageManagerError: LocalizedError { 17 | case error(Error) 18 | case cancelled 19 | case failed 20 | } 21 | 22 | private var cachedAssetIdentifiers = [String : Bool]() 23 | 24 | private lazy var requestOptions: PHImageRequestOptions = { 25 | let options = PHImageRequestOptions() 26 | options.deliveryMode = .opportunistic 27 | return options 28 | }() 29 | 30 | init() { 31 | imageManager.allowsCachingHighQualityImages = false 32 | } 33 | 34 | var cachedImageCount: Int { 35 | cachedAssetIdentifiers.keys.count 36 | } 37 | 38 | func startCaching(for assets: [PhotoAsset], targetSize: CGSize) { 39 | let phAssets = assets.compactMap { $0.phAsset } 40 | phAssets.forEach { 41 | cachedAssetIdentifiers[$0.localIdentifier] = true 42 | } 43 | imageManager.startCachingImages(for: phAssets, targetSize: targetSize, contentMode: imageContentMode, options: requestOptions) 44 | } 45 | 46 | func stopCaching(for assets: [PhotoAsset], targetSize: CGSize) { 47 | let phAssets = assets.compactMap { $0.phAsset } 48 | phAssets.forEach { 49 | cachedAssetIdentifiers.removeValue(forKey: $0.localIdentifier) 50 | } 51 | imageManager.stopCachingImages(for: phAssets, targetSize: targetSize, contentMode: imageContentMode, options: requestOptions) 52 | } 53 | 54 | func stopCaching() { 55 | imageManager.stopCachingImagesForAllAssets() 56 | } 57 | 58 | @discardableResult 59 | func requestImage(for asset: PhotoAsset, targetSize: CGSize, completion: @escaping ((image: Image?, isLowerQuality: Bool)?) -> Void) -> PHImageRequestID? { 60 | guard let phAsset = asset.phAsset else { 61 | completion(nil) 62 | return nil 63 | } 64 | 65 | let requestID = imageManager.requestImage(for: phAsset, targetSize: targetSize, contentMode: imageContentMode, options: requestOptions) { image, info in 66 | if let error = info?[PHImageErrorKey] as? Error { 67 | logger.error("CachedImageManager requestImage error: \(error.localizedDescription)") 68 | completion(nil) 69 | } else if let cancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, cancelled { 70 | logger.debug("CachedImageManager request canceled") 71 | completion(nil) 72 | } else if let image = image { 73 | let isLowerQualityImage = (info?[PHImageResultIsDegradedKey] as? NSNumber)?.boolValue ?? false 74 | let result = (image: Image(uiImage: image), isLowerQuality: isLowerQualityImage) 75 | completion(result) 76 | } else { 77 | completion(nil) 78 | } 79 | } 80 | return requestID 81 | } 82 | 83 | func cancelImageRequest(for requestID: PHImageRequestID) { 84 | imageManager.cancelImageRequest(requestID) 85 | } 86 | } 87 | 88 | fileprivate let logger = Logger(subsystem: "com.apple.swiftplaygroundscontent.capturingphotos", category: "CachedImageManager") 89 | 90 | -------------------------------------------------------------------------------- /App/CameraView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import SwiftUI 6 | 7 | struct CameraView: View { 8 | @StateObject private var model = DataModel() 9 | 10 | private static let barHeightFactor = 0.15 11 | 12 | 13 | var body: some View { 14 | 15 | NavigationStack { 16 | GeometryReader { geometry in 17 | ViewfinderView(image: model.viewfinderImage ) 18 | .overlay(alignment: .top) { 19 | Color.black 20 | .opacity(0.75) 21 | .frame(height: geometry.size.height * Self.barHeightFactor) 22 | } 23 | .overlay(alignment: .bottom) { 24 | buttonsView() 25 | .frame(height: geometry.size.height * Self.barHeightFactor) 26 | .background(.black.opacity(0.75)) 27 | } 28 | .overlay(alignment: .center) { 29 | Color.clear 30 | .frame(height: geometry.size.height * (1 - (Self.barHeightFactor * 2))) 31 | .accessibilityElement() 32 | .accessibilityLabel("View Finder") 33 | .accessibilityAddTraits([.isImage]) 34 | } 35 | .background(.black) 36 | } 37 | .task { 38 | await model.camera.start() 39 | await model.loadPhotos() 40 | await model.loadThumbnail() 41 | } 42 | .navigationTitle("Camera") 43 | .navigationBarTitleDisplayMode(.inline) 44 | .navigationBarHidden(true) 45 | .ignoresSafeArea() 46 | .statusBar(hidden: true) 47 | } 48 | } 49 | 50 | private func buttonsView() -> some View { 51 | HStack(spacing: 60) { 52 | 53 | Spacer() 54 | 55 | NavigationLink { 56 | PhotoCollectionView(photoCollection: model.photoCollection) 57 | .onAppear { 58 | model.camera.isPreviewPaused = true 59 | } 60 | .onDisappear { 61 | model.camera.isPreviewPaused = false 62 | } 63 | } label: { 64 | Label { 65 | Text("Gallery") 66 | } icon: { 67 | ThumbnailView(image: model.thumbnailImage) 68 | } 69 | } 70 | 71 | Button { 72 | model.camera.takePhoto() 73 | } label: { 74 | Label { 75 | Text("Take Photo") 76 | } icon: { 77 | ZStack { 78 | Circle() 79 | .strokeBorder(.white, lineWidth: 3) 80 | .frame(width: 62, height: 62) 81 | Circle() 82 | .fill(.white) 83 | .frame(width: 50, height: 50) 84 | } 85 | } 86 | } 87 | 88 | Button { 89 | model.camera.switchCaptureDevice() 90 | } label: { 91 | Label("Switch Camera", systemImage: "arrow.triangle.2.circlepath") 92 | .font(.system(size: 36, weight: .bold)) 93 | .foregroundColor(.white) 94 | } 95 | 96 | Spacer() 97 | 98 | } 99 | .buttonStyle(.plain) 100 | .labelStyle(.iconOnly) 101 | .padding() 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /App/DataModel.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import AVFoundation 6 | import SwiftUI 7 | import os.log 8 | 9 | final class DataModel: ObservableObject { 10 | let camera = Camera() 11 | let photoCollection = PhotoCollection(smartAlbum: .smartAlbumUserLibrary) 12 | 13 | @Published var viewfinderImage: Image? 14 | @Published var thumbnailImage: Image? 15 | 16 | var isPhotosLoaded = false 17 | 18 | init() { 19 | Task { 20 | await handleCameraPreviews() 21 | } 22 | 23 | Task { 24 | await handleCameraPhotos() 25 | } 26 | } 27 | 28 | func handleCameraPreviews() async { 29 | let context = CIContext(options: [.cacheIntermediates: false, 30 | .name: "handleCameraPreviews"]) 31 | let imageStream = camera.previewStream 32 | .map { $0.image(ciContext: context) } 33 | 34 | for await image in imageStream { 35 | Task { @MainActor in 36 | viewfinderImage = image 37 | } 38 | } 39 | } 40 | 41 | func handleCameraPhotos() async { 42 | let unpackedPhotoStream = camera.photoStream 43 | .compactMap { self.unpackPhoto($0) } 44 | 45 | for await photoData in unpackedPhotoStream { 46 | Task { @MainActor in 47 | thumbnailImage = photoData.thumbnailImage 48 | } 49 | savePhoto(imageData: photoData.imageData) 50 | } 51 | } 52 | 53 | private func unpackPhoto(_ photo: AVCapturePhoto) -> PhotoData? { 54 | guard let imageData = photo.fileDataRepresentation() else { return nil } 55 | 56 | guard let previewCGImage = photo.previewCGImageRepresentation(), 57 | let metadataOrientation = photo.metadata[String(kCGImagePropertyOrientation)] as? UInt32, 58 | let cgImageOrientation = CGImagePropertyOrientation(rawValue: metadataOrientation) else { return nil } 59 | let imageOrientation = Image.Orientation(cgImageOrientation) 60 | let thumbnailImage = Image(decorative: previewCGImage, scale: 1, orientation: imageOrientation) 61 | 62 | let photoDimensions = photo.resolvedSettings.photoDimensions 63 | let imageSize = (width: Int(photoDimensions.width), height: Int(photoDimensions.height)) 64 | let previewDimensions = photo.resolvedSettings.previewDimensions 65 | let thumbnailSize = (width: Int(previewDimensions.width), height: Int(previewDimensions.height)) 66 | 67 | return PhotoData(thumbnailImage: thumbnailImage, thumbnailSize: thumbnailSize, imageData: imageData, imageSize: imageSize) 68 | } 69 | 70 | func savePhoto(imageData: Data) { 71 | Task { 72 | do { 73 | try await photoCollection.addImage(imageData) 74 | logger.debug("Added image data to photo collection.") 75 | } catch let error { 76 | logger.error("Failed to add image to photo collection: \(error.localizedDescription)") 77 | } 78 | } 79 | } 80 | 81 | func loadPhotos() async { 82 | guard !isPhotosLoaded else { return } 83 | 84 | let authorized = await PhotoLibrary.checkAuthorization() 85 | guard authorized else { 86 | logger.error("Photo library access was not authorized.") 87 | return 88 | } 89 | 90 | Task { 91 | do { 92 | try await self.photoCollection.load() 93 | await self.loadThumbnail() 94 | } catch let error { 95 | logger.error("Failed to load photo collection: \(error.localizedDescription)") 96 | } 97 | self.isPhotosLoaded = true 98 | } 99 | } 100 | 101 | func loadThumbnail() async { 102 | guard let asset = photoCollection.photoAssets.first else { return } 103 | await photoCollection.cache.requestImage(for: asset, targetSize: CGSize(width: 256, height: 256)) { result in 104 | if let result = result { 105 | Task { @MainActor in 106 | self.thumbnailImage = result.image 107 | } 108 | } 109 | } 110 | } 111 | } 112 | 113 | fileprivate struct PhotoData { 114 | var thumbnailImage: Image 115 | var thumbnailSize: (width: Int, height: Int) 116 | var imageData: Data 117 | var imageSize: (width: Int, height: Int) 118 | } 119 | 120 | fileprivate extension CIImage { 121 | func image(ciContext: CIContext) -> Image? { 122 | guard let cgImage = ciContext.createCGImage(self, from: self.extent) else { return nil } 123 | return Image(decorative: cgImage, scale: 1, orientation: .up) 124 | } 125 | } 126 | 127 | fileprivate extension Image.Orientation { 128 | 129 | init(_ cgImageOrientation: CGImagePropertyOrientation) { 130 | switch cgImageOrientation { 131 | case .up: self = .up 132 | case .upMirrored: self = .upMirrored 133 | case .down: self = .down 134 | case .downMirrored: self = .downMirrored 135 | case .left: self = .left 136 | case .leftMirrored: self = .leftMirrored 137 | case .right: self = .right 138 | case .rightMirrored: self = .rightMirrored 139 | } 140 | } 141 | } 142 | 143 | fileprivate let logger = Logger(subsystem: "com.apple.swiftplaygroundscontent.capturingphotos", category: "DataModel") 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Writing an iPhone Camera App with SwiftUI 2 | 3 | > [!NOTE] 4 | > Based on [this Apple Sample Code](https://developer.apple.com/tutorials/sample-apps/capturingphotos-camerapreview). This README is based on article originally published at [https://www.linkedin.com/pulse/writing-iphone-camera-app-swiftui-dan-wood-xru4c](https://www.linkedin.com/pulse/writing-iphone-camera-app-swiftui-dan-wood-xru4c). 5 | 6 | Over the last several years, I've worked on several iPhone projects that make use of the built-in camera. Developers who have written similar apps have probably started with some Apple sample code, such as [AVCam: Building a camera app](https://developer.apple.com/documentation/avfoundation/capture_setup/avcam_building_a_camera_app?trk=article-ssr-frontend-pulse_little-text-block). 7 | 8 | This sample code has been updated through the years as new frameworks became available. The version currently online, updated just after WWDC24, ostensibly requires iOS 18 (beta) to run, though I have found that it will also deploy to iOS 17 with only a minor change to project settings. 9 | 10 | As I've moved almost all development to SwiftUI in favor of the older UIKit and AppKit frameworks, the lack of a "native" SwiftUI approach has bugged me. Even the latest update to this sample code, in its README, implies that the only way to create a camera preview is to build a `UIViewRepresentable`, wrapping the `UIView` that holds an `AVCaptureVideoPreviewLayer`. 11 | 12 | But it turns out there is a way to build a camera app with a native SwiftUI view rather than a wrapped `UIView`! A couple of weeks ago, I stumbled upon [Capturing and Displaying Photos: Previewing the Camera Output](https://developer.apple.com/tutorials/sample-apps/capturingphotos-camerapreview?trk=article-ssr-frontend-pulse_little-text-block). According to the [WayBack Machine](https://wayback-api.archive.org/?trk=article-ssr-frontend-pulse_little-text-block), this code was first published in May 2022! And yet, none of the developers I mentioned this to last week at the [One More Thing](http://omt-conf.com/?trk=article-ssr-frontend-pulse_little-text-block) conference were aware of this code and the technique it espouses. 13 | 14 | The sample code is fairly straightforward, but I did find some room for improvement and simplification. **Never assume that Apple's sample code is 100% perfect or uses all best development practices!** So let's see how Apple's code works, then analyze it. 15 | 16 | Apple Sample Code Architecture 17 | ----------- 18 | 19 | ![3 main objects: Camera, Data Model, Viewfinder View](images-for-readme/orig-arch.png) 20 | 21 | # Architecture for Apple's Sample Code for Previewing the Camera Output 22 | 23 | Stripping away the code for saving the photos, the capturing architecture that remains is quite simple: 24 | 25 | * The `Camera` object sets up all of the AVFoundation inputs and outputs. It implements the `AVCaptureVideoDataOutputSampleBufferDelegate` protocol to process each frame that comes in from the camera. With each frame, it creates a `CIImage` from the sample buffer's pixel buffer. Each of these images is added to an `AsyncStream`. 26 | * The `DataModel`'s job — along with saving captured photos when the shutter is pressed — is to wait for each image in the above stream (using `for await`), convert it to a SwiftUI `Image` object, and update the value of its `viewfinderImage`, a `@Published` property. 27 | * The `ViewFinderView` will repaint each time the `viewfinderImage` is updated, thanks to SwiftUI's magic. 28 | 29 | That's it! By keeping all of the AVFoundation code encapsulated in the `Camera` object, and making use of async streams, the `ViewfinderView` works while avoiding the convoluted `UIViewRepresentable` technique. 30 | 31 | Performance 32 | ----------- 33 | 34 | I worried when I saw this code that it might not be performant enough to use in shipping code. I did some quick tests using an iPhone SE — not the most powerful of Apple's current lineup! — and found that it didn't break a sweat. The performance of this sample code was almost as good as the "AVCam" camera sample code which uses the `UIViewRepresentable` technique. The "Energy Impact" meter in the pure SwiftUI code was quite comfortable in the "low" zone. The `UIViewRepresentable` code's energy impact was often "none," so if you are trying to squeeze out *every last bit of battery life** from your code, especially if you already have working code, you can skip the rest of this article and stick with your legacy code. (In my current project, I was creating a `CIImage` for each frame anyhow, in order to run through some `CoreML` and `Vision` processing, so it was worth it for me to change and simplify my codebase.) 35 | 36 | ![meter showing low energy impact](images-for-readme/low-energy.png) 37 | 38 | Room for Improvement 39 | -------------------- 40 | 41 | I found several opportunities to tighten up the sample code. If you wish to use this `UIView`-less technique, you may want to consider these for your code. 42 | 43 | * Don't create a new `CIContext` with every frame. Apple's code converts each `CIImage` instance into a SwiftUI `Image` by allocating a new `CIContext`, then building a `CGImage`, then building a SwiftUI `Image`. This is happening with every frame from the camera! Instead, create a *single** CIContext and use that for *all* of the conversions. 44 | * For the `CIContext`, be sure to allocate it with the right parameters. In a [WWDC 2020 talk](https://developer.apple.com/videos/play/wwdc2020/10008/?trk=article-ssr-frontend-pulse_little-text-block), the presenter said "Most important is to tell the context not to cache intermediates. With video, every frame is different than the one before it, so disabling this cache will lower memory usage." So be sure to set `.cacheIntermediates` to false. 45 | * The `ViewfinderView` has a `@Binding` to the image that is passed in as a projected value with the `$` prefix. The image, however, is *not modified in the view*. This should be a simple `let` parameter directly passed in! 46 | * In my code, I moved up the ownership of the `Camera` object, out of the `DataModel`, and into the parent view. I haven't yet tried mocking a camera for testing, but if I do, it will be easier to use dependency injection. 47 | * Depending on the direction you go with your code, you may want to change the `DataModel` from an `ObservableObject` subclass to an `@Observable`. [This might improve performance.](https://www.avanderlee.com/swiftui/observable-macro-performance-increase-observableobject/?trk=article-ssr-frontend-pulse_little-text-block) 48 | * Even better, you may be able to remove the `ViewModel` class altogether. If all it is doing is serving as a bridge between the async stream and the current frame, you could just set up a task in a SwiftUI View which awaits each new frame and updates a `@State` variable holding the current frame (`viewfinderImage`) to trigger a refresh. 49 | * There isn't any disadvantage to creating a SwiftUI `Image` object as a property to be updated. Still, if your code is like mine and needs to process each bitmap frame, it may be easier to change your stream to vend lower-level image objects, closer to the source data, such as `CVPixelBuffer`, `CGImage`, or `CIImage`. (Any of these are suitable input to a Vision image request for CoreML processing or text recognition.) It's easy to construct a SwiftUI `Image` as needed for display, but since that is an opaque type — only usable by SwiftUI — you can't go in the other direction. 50 | * The sample code doesn't specify the video settings for the video output in your camera capture session, and fortunately, it seems to default to `kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange`. It's important to use a [performant video pixel format](https://developer.apple.com/documentation/technotes/tn3121-selecting-a-pixel-format-for-an-avcapturevideodataoutput?trk=article-ssr-frontend-pulse_little-text-block). I'd rather not leave that to chance in my code so I'd recommend setting the format explicitly. 51 | 52 | New Architecture 53 | ---------------- 54 | 55 | ![3 main objects: Camera, Content View, Viewfinder View](images-for-readme/new-arch.png) 56 | 57 | Possible new architecture for previewing the camera with a SwiftUI view 58 | 59 | I am more comfortable with the modified architecture; it's a bit more straightforward than Apple's sample code. I hope you agree, and I hope you can make use of these insights in your projects! 60 | 61 | -------------------------------------------------------------------------------- /App/PhotoCollection.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import Photos 6 | import os.log 7 | 8 | class PhotoCollection: NSObject, ObservableObject { 9 | 10 | @Published var photoAssets: PhotoAssetCollection = PhotoAssetCollection(PHFetchResult()) 11 | 12 | var identifier: String? { 13 | assetCollection?.localIdentifier 14 | } 15 | 16 | var albumName: String? 17 | 18 | var smartAlbumType: PHAssetCollectionSubtype? 19 | 20 | let cache = CachedImageManager() 21 | 22 | private var assetCollection: PHAssetCollection? 23 | 24 | private var createAlbumIfNotFound = false 25 | 26 | enum PhotoCollectionError: LocalizedError { 27 | case missingAssetCollection 28 | case missingAlbumName 29 | case missingLocalIdentifier 30 | case unableToFindAlbum(String) 31 | case unableToLoadSmartAlbum(PHAssetCollectionSubtype) 32 | case addImageError(Error) 33 | case createAlbumError(Error) 34 | case removeAllError(Error) 35 | } 36 | 37 | init(albumNamed albumName: String, createIfNotFound: Bool = false) { 38 | self.albumName = albumName 39 | self.createAlbumIfNotFound = createIfNotFound 40 | super.init() 41 | } 42 | 43 | init?(albumWithIdentifier identifier: String) { 44 | guard let assetCollection = PhotoCollection.getAlbum(identifier: identifier) else { 45 | logger.error("Photo album not found for identifier: \(identifier)") 46 | return nil 47 | } 48 | logger.log("Loaded photo album with identifier: \(identifier)") 49 | self.assetCollection = assetCollection 50 | super.init() 51 | Task { 52 | await refreshPhotoAssets() 53 | } 54 | } 55 | 56 | init(smartAlbum smartAlbumType: PHAssetCollectionSubtype) { 57 | self.smartAlbumType = smartAlbumType 58 | super.init() 59 | } 60 | 61 | deinit { 62 | PHPhotoLibrary.shared().unregisterChangeObserver(self) 63 | } 64 | 65 | func load() async throws { 66 | 67 | PHPhotoLibrary.shared().register(self) 68 | 69 | if let smartAlbumType = smartAlbumType { 70 | if let assetCollection = PhotoCollection.getSmartAlbum(subtype: smartAlbumType) { 71 | logger.log("Loaded smart album of type: \(smartAlbumType.rawValue)") 72 | self.assetCollection = assetCollection 73 | await refreshPhotoAssets() 74 | return 75 | } else { 76 | logger.error("Unable to load smart album of type: : \(smartAlbumType.rawValue)") 77 | throw PhotoCollectionError.unableToLoadSmartAlbum(smartAlbumType) 78 | } 79 | } 80 | 81 | guard let name = albumName, !name.isEmpty else { 82 | logger.error("Unable to load an album without a name.") 83 | throw PhotoCollectionError.missingAlbumName 84 | } 85 | 86 | if let assetCollection = PhotoCollection.getAlbum(named: name) { 87 | logger.log("Loaded photo album named: \(name)") 88 | self.assetCollection = assetCollection 89 | await refreshPhotoAssets() 90 | return 91 | } 92 | 93 | guard createAlbumIfNotFound else { 94 | logger.error("Unable to find photo album named: \(name)") 95 | throw PhotoCollectionError.unableToFindAlbum(name) 96 | } 97 | 98 | logger.log("Creating photo album named: \(name)") 99 | 100 | if let assetCollection = try? await PhotoCollection.createAlbum(named: name) { 101 | self.assetCollection = assetCollection 102 | await refreshPhotoAssets() 103 | } 104 | } 105 | 106 | func addImage(_ imageData: Data) async throws { 107 | guard let assetCollection = self.assetCollection else { 108 | throw PhotoCollectionError.missingAssetCollection 109 | } 110 | 111 | do { 112 | try await PHPhotoLibrary.shared().performChanges { 113 | 114 | let creationRequest = PHAssetCreationRequest.forAsset() 115 | if let assetPlaceholder = creationRequest.placeholderForCreatedAsset { 116 | creationRequest.addResource(with: .photo, data: imageData, options: nil) 117 | 118 | if let albumChangeRequest = PHAssetCollectionChangeRequest(for: assetCollection), assetCollection.canPerform(.addContent) { 119 | let fastEnumeration = NSArray(array: [assetPlaceholder]) 120 | albumChangeRequest.addAssets(fastEnumeration) 121 | } 122 | } 123 | } 124 | 125 | await refreshPhotoAssets() 126 | 127 | } catch let error { 128 | logger.error("Error adding image to photo library: \(error.localizedDescription)") 129 | throw PhotoCollectionError.addImageError(error) 130 | } 131 | } 132 | 133 | func removeAsset(_ asset: PhotoAsset) async throws { 134 | guard let assetCollection = self.assetCollection else { 135 | throw PhotoCollectionError.missingAssetCollection 136 | } 137 | 138 | do { 139 | try await PHPhotoLibrary.shared().performChanges { 140 | if let albumChangeRequest = PHAssetCollectionChangeRequest(for: assetCollection) { 141 | albumChangeRequest.removeAssets([asset as Any] as NSArray) 142 | } 143 | } 144 | 145 | await refreshPhotoAssets() 146 | 147 | } catch let error { 148 | logger.error("Error removing all photos from the album: \(error.localizedDescription)") 149 | throw PhotoCollectionError.removeAllError(error) 150 | } 151 | } 152 | 153 | func removeAll() async throws { 154 | guard let assetCollection = self.assetCollection else { 155 | throw PhotoCollectionError.missingAssetCollection 156 | } 157 | 158 | do { 159 | try await PHPhotoLibrary.shared().performChanges { 160 | if let albumChangeRequest = PHAssetCollectionChangeRequest(for: assetCollection), 161 | let assets = (PHAsset.fetchAssets(in: assetCollection, options: nil) as AnyObject?) as! PHFetchResult? { 162 | albumChangeRequest.removeAssets(assets) 163 | } 164 | } 165 | 166 | await refreshPhotoAssets() 167 | 168 | } catch let error { 169 | logger.error("Error removing all photos from the album: \(error.localizedDescription)") 170 | throw PhotoCollectionError.removeAllError(error) 171 | } 172 | } 173 | 174 | private func refreshPhotoAssets(_ fetchResult: PHFetchResult? = nil) async { 175 | 176 | var newFetchResult = fetchResult 177 | 178 | if newFetchResult == nil { 179 | let fetchOptions = PHFetchOptions() 180 | fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] 181 | if let assetCollection = self.assetCollection, let fetchResult = (PHAsset.fetchAssets(in: assetCollection, options: fetchOptions) as AnyObject?) as? PHFetchResult { 182 | newFetchResult = fetchResult 183 | } 184 | } 185 | 186 | if let newFetchResult = newFetchResult { 187 | await MainActor.run { 188 | photoAssets = PhotoAssetCollection(newFetchResult) 189 | logger.debug("PhotoCollection photoAssets refreshed: \(self.photoAssets.count)") 190 | } 191 | } 192 | } 193 | 194 | private static func getAlbum(identifier: String) -> PHAssetCollection? { 195 | let fetchOptions = PHFetchOptions() 196 | let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [identifier], options: fetchOptions) 197 | return collections.firstObject 198 | } 199 | 200 | private static func getAlbum(named name: String) -> PHAssetCollection? { 201 | let fetchOptions = PHFetchOptions() 202 | fetchOptions.predicate = NSPredicate(format: "title = %@", name) 203 | let collections = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: fetchOptions) 204 | return collections.firstObject 205 | } 206 | 207 | private static func getSmartAlbum(subtype: PHAssetCollectionSubtype) -> PHAssetCollection? { 208 | let fetchOptions = PHFetchOptions() 209 | let collections = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: subtype, options: fetchOptions) 210 | return collections.firstObject 211 | } 212 | 213 | private static func createAlbum(named name: String) async throws -> PHAssetCollection? { 214 | var collectionPlaceholder: PHObjectPlaceholder? 215 | do { 216 | try await PHPhotoLibrary.shared().performChanges { 217 | let createAlbumRequest = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: name) 218 | collectionPlaceholder = createAlbumRequest.placeholderForCreatedAssetCollection 219 | } 220 | } catch let error { 221 | logger.error("Error creating album in photo library: \(error.localizedDescription)") 222 | throw PhotoCollectionError.createAlbumError(error) 223 | } 224 | logger.log("Created photo album named: \(name)") 225 | guard let collectionIdentifier = collectionPlaceholder?.localIdentifier else { 226 | throw PhotoCollectionError.missingLocalIdentifier 227 | } 228 | let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [collectionIdentifier], options: nil) 229 | return collections.firstObject 230 | } 231 | } 232 | 233 | extension PhotoCollection: PHPhotoLibraryChangeObserver { 234 | 235 | func photoLibraryDidChange(_ changeInstance: PHChange) { 236 | Task { @MainActor in 237 | guard let changes = changeInstance.changeDetails(for: self.photoAssets.fetchResult) else { return } 238 | await self.refreshPhotoAssets(changes.fetchResultAfterChanges) 239 | } 240 | } 241 | } 242 | 243 | fileprivate let logger = Logger(subsystem: "com.apple.swiftplaygroundscontent.capturingphotos", category: "PhotoCollection") 244 | -------------------------------------------------------------------------------- /App/Camera.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See the License.txt file for this sample’s licensing information. 3 | */ 4 | 5 | import AVFoundation 6 | import CoreImage 7 | import UIKit 8 | import os.log 9 | 10 | class Camera: NSObject { 11 | private let captureSession = AVCaptureSession() 12 | private var isCaptureSessionConfigured = false 13 | private var deviceInput: AVCaptureDeviceInput? 14 | private var photoOutput: AVCapturePhotoOutput? 15 | private var videoOutput: AVCaptureVideoDataOutput? 16 | private var sessionQueue: DispatchQueue! 17 | 18 | private var allCaptureDevices: [AVCaptureDevice] { 19 | AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInTrueDepthCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInWideAngleCamera, .builtInDualWideCamera], mediaType: .video, position: .unspecified).devices 20 | } 21 | 22 | private var frontCaptureDevices: [AVCaptureDevice] { 23 | allCaptureDevices 24 | .filter { $0.position == .front } 25 | } 26 | 27 | private var backCaptureDevices: [AVCaptureDevice] { 28 | allCaptureDevices 29 | .filter { $0.position == .back } 30 | } 31 | 32 | private var captureDevices: [AVCaptureDevice] { 33 | var devices = [AVCaptureDevice]() 34 | #if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst)) 35 | devices += allCaptureDevices 36 | #else 37 | if let backDevice = backCaptureDevices.first { 38 | devices += [backDevice] 39 | } 40 | if let frontDevice = frontCaptureDevices.first { 41 | devices += [frontDevice] 42 | } 43 | #endif 44 | return devices 45 | } 46 | 47 | private var availableCaptureDevices: [AVCaptureDevice] { 48 | captureDevices 49 | .filter( { $0.isConnected } ) 50 | .filter( { !$0.isSuspended } ) 51 | } 52 | 53 | private var captureDevice: AVCaptureDevice? { 54 | didSet { 55 | guard let captureDevice = captureDevice else { return } 56 | logger.debug("Using capture device: \(captureDevice.localizedName)") 57 | sessionQueue.async { 58 | self.updateSessionForCaptureDevice(captureDevice) 59 | } 60 | } 61 | } 62 | 63 | var isRunning: Bool { 64 | captureSession.isRunning 65 | } 66 | 67 | var isUsingFrontCaptureDevice: Bool { 68 | guard let captureDevice = captureDevice else { return false } 69 | return frontCaptureDevices.contains(captureDevice) 70 | } 71 | 72 | var isUsingBackCaptureDevice: Bool { 73 | guard let captureDevice = captureDevice else { return false } 74 | return backCaptureDevices.contains(captureDevice) 75 | } 76 | 77 | private var addToPhotoStream: ((AVCapturePhoto) -> Void)? 78 | 79 | private var addToPreviewStream: ((CIImage) -> Void)? 80 | 81 | var isPreviewPaused = false 82 | 83 | lazy var previewStream: AsyncStream = { 84 | AsyncStream { continuation in 85 | addToPreviewStream = { ciImage in 86 | if !self.isPreviewPaused { 87 | continuation.yield(ciImage) 88 | } 89 | } 90 | } 91 | }() 92 | 93 | lazy var photoStream: AsyncStream = { 94 | AsyncStream { continuation in 95 | addToPhotoStream = { photo in 96 | continuation.yield(photo) 97 | } 98 | } 99 | }() 100 | 101 | override init() { 102 | super.init() 103 | initialize() 104 | } 105 | 106 | private func initialize() { 107 | sessionQueue = DispatchQueue(label: "session queue") 108 | 109 | captureDevice = availableCaptureDevices.first ?? AVCaptureDevice.default(for: .video) 110 | 111 | UIDevice.current.beginGeneratingDeviceOrientationNotifications() 112 | NotificationCenter.default.addObserver(self, selector: #selector(updateForDeviceOrientation), name: UIDevice.orientationDidChangeNotification, object: nil) 113 | } 114 | 115 | private func configureCaptureSession(completionHandler: (_ success: Bool) -> Void) { 116 | 117 | var success = false 118 | 119 | self.captureSession.beginConfiguration() 120 | 121 | defer { 122 | self.captureSession.commitConfiguration() 123 | completionHandler(success) 124 | } 125 | 126 | guard 127 | let captureDevice = captureDevice, 128 | let deviceInput = try? AVCaptureDeviceInput(device: captureDevice) 129 | else { 130 | logger.error("Failed to obtain video input.") 131 | return 132 | } 133 | 134 | let photoOutput = AVCapturePhotoOutput() 135 | 136 | captureSession.sessionPreset = AVCaptureSession.Preset.photo 137 | 138 | let videoOutput = AVCaptureVideoDataOutput() 139 | videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "VideoDataOutputQueue")) 140 | 141 | videoOutput.videoSettings = [ 142 | String(kCVPixelBufferPixelFormatTypeKey) : Int(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange) 143 | ] 144 | 145 | videoOutput.alwaysDiscardsLateVideoFrames = true 146 | 147 | guard captureSession.canAddInput(deviceInput) else { 148 | logger.error("Unable to add device input to capture session.") 149 | return 150 | } 151 | guard captureSession.canAddOutput(photoOutput) else { 152 | logger.error("Unable to add photo output to capture session.") 153 | return 154 | } 155 | guard captureSession.canAddOutput(videoOutput) else { 156 | logger.error("Unable to add video output to capture session.") 157 | return 158 | } 159 | 160 | captureSession.addInput(deviceInput) 161 | captureSession.addOutput(photoOutput) 162 | captureSession.addOutput(videoOutput) 163 | 164 | self.deviceInput = deviceInput 165 | self.photoOutput = photoOutput 166 | self.videoOutput = videoOutput 167 | 168 | photoOutput.isHighResolutionCaptureEnabled = true 169 | photoOutput.maxPhotoQualityPrioritization = .quality 170 | 171 | updateVideoOutputConnection() 172 | 173 | isCaptureSessionConfigured = true 174 | 175 | success = true 176 | } 177 | 178 | private func checkAuthorization() async -> Bool { 179 | switch AVCaptureDevice.authorizationStatus(for: .video) { 180 | case .authorized: 181 | logger.debug("Camera access authorized.") 182 | return true 183 | case .notDetermined: 184 | logger.debug("Camera access not determined.") 185 | sessionQueue.suspend() 186 | let status = await AVCaptureDevice.requestAccess(for: .video) 187 | sessionQueue.resume() 188 | return status 189 | case .denied: 190 | logger.debug("Camera access denied.") 191 | return false 192 | case .restricted: 193 | logger.debug("Camera library access restricted.") 194 | return false 195 | @unknown default: 196 | return false 197 | } 198 | } 199 | 200 | private func deviceInputFor(device: AVCaptureDevice?) -> AVCaptureDeviceInput? { 201 | guard let validDevice = device else { return nil } 202 | do { 203 | return try AVCaptureDeviceInput(device: validDevice) 204 | } catch let error { 205 | logger.error("Error getting capture device input: \(error.localizedDescription)") 206 | return nil 207 | } 208 | } 209 | 210 | private func updateSessionForCaptureDevice(_ captureDevice: AVCaptureDevice) { 211 | guard isCaptureSessionConfigured else { return } 212 | 213 | captureSession.beginConfiguration() 214 | defer { captureSession.commitConfiguration() } 215 | 216 | for input in captureSession.inputs { 217 | if let deviceInput = input as? AVCaptureDeviceInput { 218 | captureSession.removeInput(deviceInput) 219 | } 220 | } 221 | 222 | if let deviceInput = deviceInputFor(device: captureDevice) { 223 | if !captureSession.inputs.contains(deviceInput), captureSession.canAddInput(deviceInput) { 224 | captureSession.addInput(deviceInput) 225 | } 226 | } 227 | 228 | updateVideoOutputConnection() 229 | } 230 | 231 | private func updateVideoOutputConnection() { 232 | if let videoOutput = videoOutput, let videoOutputConnection = videoOutput.connection(with: .video) { 233 | if videoOutputConnection.isVideoMirroringSupported { 234 | videoOutputConnection.isVideoMirrored = isUsingFrontCaptureDevice 235 | } 236 | } 237 | } 238 | 239 | func start() async { 240 | let authorized = await checkAuthorization() 241 | guard authorized else { 242 | logger.error("Camera access was not authorized.") 243 | return 244 | } 245 | 246 | if isCaptureSessionConfigured { 247 | if !captureSession.isRunning { 248 | sessionQueue.async { [self] in 249 | self.captureSession.startRunning() 250 | } 251 | } 252 | return 253 | } 254 | 255 | sessionQueue.async { [self] in 256 | self.configureCaptureSession { success in 257 | guard success else { return } 258 | self.captureSession.startRunning() 259 | } 260 | } 261 | } 262 | 263 | func stop() { 264 | guard isCaptureSessionConfigured else { return } 265 | 266 | if captureSession.isRunning { 267 | sessionQueue.async { 268 | self.captureSession.stopRunning() 269 | } 270 | } 271 | } 272 | 273 | func switchCaptureDevice() { 274 | if let captureDevice = captureDevice, let index = availableCaptureDevices.firstIndex(of: captureDevice) { 275 | let nextIndex = (index + 1) % availableCaptureDevices.count 276 | self.captureDevice = availableCaptureDevices[nextIndex] 277 | } else { 278 | self.captureDevice = AVCaptureDevice.default(for: .video) 279 | } 280 | } 281 | 282 | private var deviceOrientation: UIDeviceOrientation { 283 | var orientation = UIDevice.current.orientation 284 | if orientation == UIDeviceOrientation.unknown { 285 | orientation = UIScreen.main.orientation 286 | } 287 | return orientation 288 | } 289 | 290 | @objc 291 | func updateForDeviceOrientation() { 292 | //TODO: Figure out if we need this for anything. 293 | } 294 | 295 | private func videoOrientationFor(_ deviceOrientation: UIDeviceOrientation) -> AVCaptureVideoOrientation? { 296 | switch deviceOrientation { 297 | case .portrait: return AVCaptureVideoOrientation.portrait 298 | case .portraitUpsideDown: return AVCaptureVideoOrientation.portraitUpsideDown 299 | case .landscapeLeft: return AVCaptureVideoOrientation.landscapeRight 300 | case .landscapeRight: return AVCaptureVideoOrientation.landscapeLeft 301 | default: return nil 302 | } 303 | } 304 | 305 | func takePhoto() { 306 | guard let photoOutput = self.photoOutput else { return } 307 | 308 | sessionQueue.async { 309 | 310 | var photoSettings = AVCapturePhotoSettings() 311 | 312 | if photoOutput.availablePhotoCodecTypes.contains(.hevc) { 313 | photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) 314 | } 315 | 316 | let isFlashAvailable = self.deviceInput?.device.isFlashAvailable ?? false 317 | photoSettings.flashMode = isFlashAvailable ? .auto : .off 318 | photoSettings.isHighResolutionPhotoEnabled = true 319 | if let previewPhotoPixelFormatType = photoSettings.availablePreviewPhotoPixelFormatTypes.first { 320 | photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: previewPhotoPixelFormatType] 321 | } 322 | photoSettings.photoQualityPrioritization = .balanced 323 | 324 | if let photoOutputVideoConnection = photoOutput.connection(with: .video) { 325 | if photoOutputVideoConnection.isVideoOrientationSupported, 326 | let videoOrientation = self.videoOrientationFor(self.deviceOrientation) { 327 | photoOutputVideoConnection.videoOrientation = videoOrientation 328 | } 329 | } 330 | 331 | photoOutput.capturePhoto(with: photoSettings, delegate: self) 332 | } 333 | } 334 | } 335 | 336 | extension Camera: AVCapturePhotoCaptureDelegate { 337 | 338 | func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { 339 | 340 | if let error = error { 341 | logger.error("Error capturing photo: \(error.localizedDescription)") 342 | return 343 | } 344 | 345 | addToPhotoStream?(photo) 346 | } 347 | } 348 | 349 | extension Camera: AVCaptureVideoDataOutputSampleBufferDelegate { 350 | 351 | func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { 352 | guard let pixelBuffer = sampleBuffer.imageBuffer else { return } 353 | 354 | if connection.isVideoOrientationSupported, 355 | let videoOrientation = videoOrientationFor(deviceOrientation) { 356 | connection.videoOrientation = videoOrientation 357 | } 358 | 359 | addToPreviewStream?(CIImage(cvPixelBuffer: pixelBuffer)) 360 | } 361 | } 362 | 363 | fileprivate extension UIScreen { 364 | 365 | var orientation: UIDeviceOrientation { 366 | let point = coordinateSpace.convert(CGPoint.zero, to: fixedCoordinateSpace) 367 | if point == CGPoint.zero { 368 | return .portrait 369 | } else if point.x != 0 && point.y != 0 { 370 | return .portraitUpsideDown 371 | } else if point.x == 0 && point.y != 0 { 372 | return .landscapeRight //.landscapeLeft 373 | } else if point.x != 0 && point.y == 0 { 374 | return .landscapeLeft //.landscapeRight 375 | } else { 376 | return .unknown 377 | } 378 | } 379 | } 380 | 381 | fileprivate let logger = Logger(subsystem: "com.apple.swiftplaygroundscontent.capturingphotos", category: "Camera") 382 | 383 | --------------------------------------------------------------------------------