├── 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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------