├── .gitignore ├── DemoApp ├── DemoApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── DemoAppApp.swift │ └── ContentView.swift ├── DemoApp.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── project.pbxproj ├── DemoAppUITests │ ├── DemoAppUITestsLaunchTests.swift │ └── DemoAppUITests.swift └── DemoAppTests │ └── DemoAppTests.swift ├── Tests ├── LinuxMain.swift └── Camera-SwiftUITests │ ├── XCTestManifests.swift │ └── Camera_SwiftUITests.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources └── Camera-SwiftUI │ ├── Service │ ├── CameraService+Enums.swift │ ├── CameraService+Extensions.swift │ ├── ImageResizer.swift │ ├── PhotoCaptureProcessor.swift │ └── CameraService.swift │ └── View │ └── CameraPreview.swift ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import Camera_SwiftUITests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += Camera_SwiftUITests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DemoApp/DemoApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/Camera-SwiftUITests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(Camera_SwiftUITests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/DemoAppApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoAppApp.swift 3 | // DemoApp 4 | // 5 | // Created by Rolando Rodriguez on 8/25/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct DemoAppApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | CameraView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /DemoApp/DemoApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/Camera-SwiftUITests/Camera_SwiftUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Camera_SwiftUI 3 | 4 | final class Camera_SwiftUITests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | } 10 | 11 | static var allTests = [ 12 | ("testExample", testExample), 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Camera-SwiftUI/Service/CameraService+Enums.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraService+Enums.swift 3 | // Campus 4 | // 5 | // Created by Rolando Rodriguez on 1/11/20. 6 | // Copyright © 2020 Rolando Rodriguez. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: CameraService Enums 12 | extension CameraService { 13 | enum LivePhotoMode { 14 | case on 15 | case off 16 | } 17 | 18 | enum DepthDataDeliveryMode { 19 | case on 20 | case off 21 | } 22 | 23 | enum PortraitEffectsMatteDeliveryMode { 24 | case on 25 | case off 26 | } 27 | 28 | enum SessionSetupResult { 29 | case success 30 | case notAuthorized 31 | case configurationFailed 32 | } 33 | 34 | enum CaptureMode: Int { 35 | case photo = 0 36 | case movie = 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /DemoApp/DemoAppUITests/DemoAppUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoAppUITestsLaunchTests.swift 3 | // DemoAppUITests 4 | // 5 | // Created by Rolando Rodriguez on 8/25/22. 6 | // 7 | 8 | import XCTest 9 | 10 | class DemoAppUITestsLaunchTests: XCTestCase { 11 | 12 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 13 | true 14 | } 15 | 16 | override func setUpWithError() throws { 17 | continueAfterFailure = false 18 | } 19 | 20 | func testLaunch() throws { 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Insert steps here to perform after app launch but before taking a screenshot, 25 | // such as logging into a test account or navigating somewhere in the app 26 | 27 | let attachment = XCTAttachment(screenshot: app.screenshot()) 28 | attachment.name = "Launch Screen" 29 | attachment.lifetime = .keepAlways 30 | add(attachment) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Camera-SwiftUI", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v11) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "Camera-SwiftUI", 16 | targets: ["Camera-SwiftUI"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "Camera-SwiftUI", 27 | dependencies: []), 28 | .testTarget( 29 | name: "Camera-SwiftUITests", 30 | dependencies: ["Camera-SwiftUI"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /DemoApp/DemoAppTests/DemoAppTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoAppTests.swift 3 | // DemoAppTests 4 | // 5 | // Created by Rolando Rodriguez on 8/25/22. 6 | // 7 | 8 | import XCTest 9 | @testable import DemoApp 10 | 11 | class DemoAppTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | // Any test you write for XCTest can be annotated as throws and async. 25 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 26 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /DemoApp/DemoAppUITests/DemoAppUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoAppUITests.swift 3 | // DemoAppUITests 4 | // 5 | // Created by Rolando Rodriguez on 8/25/22. 6 | // 7 | 8 | import XCTest 9 | 10 | class DemoAppUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Camera-SwiftUI/Service/CameraService+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraService+Extensions.swift 3 | // Campus 4 | // 5 | // Created by Rolando Rodriguez on 1/11/20. 6 | // Copyright © 2020 Rolando Rodriguez. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | import AVFoundation 12 | 13 | extension AVCaptureVideoOrientation { 14 | init?(deviceOrientation: UIDeviceOrientation) { 15 | switch deviceOrientation { 16 | case .portrait: self = .portrait 17 | case .portraitUpsideDown: self = .portraitUpsideDown 18 | case .landscapeLeft: self = .landscapeRight 19 | case .landscapeRight: self = .landscapeLeft 20 | default: return nil 21 | } 22 | } 23 | 24 | init?(interfaceOrientation: UIInterfaceOrientation) { 25 | switch interfaceOrientation { 26 | case .portrait: self = .portrait 27 | case .portraitUpsideDown: self = .portraitUpsideDown 28 | case .landscapeLeft: self = .landscapeLeft 29 | case .landscapeRight: self = .landscapeRight 30 | default: return nil 31 | } 32 | } 33 | } 34 | 35 | extension AVCaptureDevice.DiscoverySession { 36 | var uniqueDevicePositionsCount: Int { 37 | 38 | var uniqueDevicePositions = [AVCaptureDevice.Position]() 39 | 40 | for device in devices where !uniqueDevicePositions.contains(device.position) { 41 | uniqueDevicePositions.append(device.position) 42 | } 43 | 44 | return uniqueDevicePositions.count 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Camera-SwiftUI 2 | 3 | SwiftUI has proven to be a really awesome new framework to build and design apps in a quick and reliable way. Nonetheless, it is still in its infancy and Apple needs to add more support for other framework integrations as it did recently with the new Sign In With Apple button in SwiftUI 2.0. In this project we'll learn how to integrate AVFoundation's AVCameraSession with SwiftUI so we can create reusable, extendable modern components for apps that need to use our device's cameras. 4 | 5 | ![alt text](https://cdn-images-1.medium.com/max/2600/1*dQ6PJ2f9GfIO3iuKDNZxqA.png) 6 | This is the package developed and used in the [Effortless SwiftUI-- Camera](https://rorodriguez116.medium.com/effortless-swiftui-camera-d7a74abde37e) tutorial. 7 | 8 | 9 | # Features 10 | - Camera feed. 11 | - Tap to focus. 12 | - Drag to zoom in/out. 13 | - Thumbnail preview. 14 | - Save capture to photo library. 15 | - Switch between front and back facing cameras. 16 | - Set flash on/off 17 | 18 | # Try it out! 19 | You can download the whole example project from [here](https://github.com/rorodriguez116/SwiftCamera). If you'd like to read the article for this project click [here](https://rorodriguez116.medium.com/effortless-swiftui-camera-d7a74abde37e) 20 | 21 | # Installation 22 | This package is and will only be available trough Swift Package Manager. You can add this pacakage as a dependency in Xcode 12. 23 | 24 | Use the following link when Xcode requests the package's repo url: 25 | 26 | https://github.com/rorodriguez116/Camera-SwiftUI.git 27 | 28 | License 29 | ---- 30 | 31 | MIT 32 | 33 | -------------------------------------------------------------------------------- /Sources/Camera-SwiftUI/Service/ImageResizer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageResizer.swift 3 | // Campus 4 | // 5 | // Created by Rolando Rodriguez on 12/21/19. 6 | // Copyright © 2019 Rolando Rodriguez. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | public enum ImageResizingError: Error { 13 | case cannotRetrieveFromURL 14 | case cannotRetrieveFromData 15 | } 16 | 17 | public struct ImageResizer { 18 | public var targetWidth: CGFloat 19 | 20 | public init(targetWidth: CGFloat) { 21 | self.targetWidth = targetWidth 22 | } 23 | 24 | public func resize(at url: URL) -> UIImage? { 25 | guard let image = UIImage(contentsOfFile: url.path) else { 26 | return nil 27 | } 28 | 29 | return self.resize(image: image) 30 | } 31 | 32 | public func resize(image: UIImage) -> UIImage { 33 | let originalSize = image.size 34 | let targetSize = CGSize(width: targetWidth, height: targetWidth*originalSize.height/originalSize.width) 35 | let renderer = UIGraphicsImageRenderer(size: targetSize) 36 | return renderer.image { (context) in 37 | image.draw(in: CGRect(origin: .zero, size: targetSize)) 38 | } 39 | } 40 | 41 | public func resize(data: Data) -> UIImage? { 42 | guard let image = UIImage(data: data) else {return nil} 43 | return resize(image: image ) 44 | } 45 | } 46 | 47 | public struct MemorySizer { 48 | public static func size(of data: Data) -> String { 49 | let bcf = ByteCountFormatter() 50 | bcf.allowedUnits = [.useMB] // optional: restricts the units to MB only 51 | bcf.countStyle = .file 52 | let string = bcf.string(fromByteCount: Int64(data.count)) 53 | return string 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/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 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "2x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "83.5x83.5" 82 | }, 83 | { 84 | "idiom" : "ios-marketing", 85 | "scale" : "1x", 86 | "size" : "1024x1024" 87 | } 88 | ], 89 | "info" : { 90 | "author" : "xcode", 91 | "version" : 1 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/Camera-SwiftUI/View/CameraPreview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraPreview.swift 3 | // Campus 4 | // 5 | // Created by Rolando Rodriguez on 12/17/19. 6 | // Copyright © 2019 Rolando Rodriguez. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AVFoundation 11 | import SwiftUI 12 | 13 | public struct CameraPreview: UIViewRepresentable { 14 | public class VideoPreviewView: UIView { 15 | public override class var layerClass: AnyClass { 16 | AVCaptureVideoPreviewLayer.self 17 | } 18 | 19 | var videoPreviewLayer: AVCaptureVideoPreviewLayer { 20 | return layer as! AVCaptureVideoPreviewLayer 21 | } 22 | 23 | let focusView: UIView = { 24 | let focusView = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) 25 | focusView.layer.borderColor = UIColor.white.cgColor 26 | focusView.layer.borderWidth = 1.5 27 | focusView.layer.cornerRadius = 25 28 | focusView.layer.opacity = 0 29 | focusView.backgroundColor = .clear 30 | return focusView 31 | }() 32 | 33 | @objc func focusAndExposeTap(gestureRecognizer: UITapGestureRecognizer) { 34 | let layerPoint = gestureRecognizer.location(in: gestureRecognizer.view) 35 | let devicePoint = videoPreviewLayer.captureDevicePointConverted(fromLayerPoint: layerPoint) 36 | 37 | let focusCircleDiam: CGFloat = 50 38 | let shiftedLayerPoint = CGPoint(x: layerPoint.x - (focusCircleDiam / 2), 39 | y: layerPoint.y - (focusCircleDiam / 2)) 40 | 41 | focusView.layer.frame = CGRect(origin: shiftedLayerPoint, size: CGSize(width: focusCircleDiam, height: focusCircleDiam)) 42 | 43 | NotificationCenter.default.post(.init(name: .init("UserDidRequestNewFocusPoint"), object: nil, userInfo: ["devicePoint": devicePoint] as [AnyHashable: Any])) 44 | 45 | UIView.animate(withDuration: 0.3, animations: { 46 | self.focusView.layer.opacity = 1 47 | }) { (completed) in 48 | if completed { 49 | UIView.animate(withDuration: 0.3) { 50 | self.focusView.layer.opacity = 0 51 | } 52 | } 53 | } 54 | } 55 | 56 | public override func layoutSubviews() { 57 | super.layoutSubviews() 58 | 59 | self.layer.addSublayer(focusView.layer) 60 | 61 | let gRecognizer = UITapGestureRecognizer(target: self, action: #selector(VideoPreviewView.focusAndExposeTap(gestureRecognizer:))) 62 | self.addGestureRecognizer(gRecognizer) 63 | } 64 | } 65 | 66 | public let session: AVCaptureSession 67 | 68 | public init(session: AVCaptureSession) { 69 | self.session = session 70 | } 71 | 72 | public func makeUIView(context: Context) -> VideoPreviewView { 73 | let viewFinder = VideoPreviewView() 74 | viewFinder.backgroundColor = .black 75 | viewFinder.videoPreviewLayer.cornerRadius = 0 76 | viewFinder.videoPreviewLayer.session = session 77 | viewFinder.videoPreviewLayer.connection?.videoOrientation = .portrait 78 | return viewFinder 79 | } 80 | 81 | public func updateUIView(_ uiView: VideoPreviewView, context: Context) { 82 | 83 | } 84 | } 85 | 86 | struct CameraPreview_Previews: PreviewProvider { 87 | static var previews: some View { 88 | CameraPreview(session: AVCaptureSession()) 89 | .frame(height: 300) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Camera-SwiftUI/Service/PhotoCaptureProcessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoCaptureProcessor.swift 3 | // abseil 4 | // 5 | // Created by Rolando Rodriguez on 1/11/20. 6 | // 7 | 8 | import Foundation 9 | import Photos 10 | 11 | class PhotoCaptureProcessor: NSObject { 12 | private(set) var requestedPhotoSettings: AVCapturePhotoSettings 13 | 14 | private let willCapturePhotoAnimation: () -> Void 15 | 16 | lazy var context = CIContext() 17 | 18 | private let completionHandler: (PhotoCaptureProcessor) -> Void 19 | 20 | private let photoProcessingHandler: (Bool) -> Void 21 | 22 | var photoData: Data? 23 | 24 | private var maxPhotoProcessingTime: CMTime? 25 | 26 | init(with requestedPhotoSettings: AVCapturePhotoSettings, 27 | willCapturePhotoAnimation: @escaping () -> Void, 28 | completionHandler: @escaping (PhotoCaptureProcessor) -> Void, 29 | photoProcessingHandler: @escaping (Bool) -> Void) { 30 | self.requestedPhotoSettings = requestedPhotoSettings 31 | self.willCapturePhotoAnimation = willCapturePhotoAnimation 32 | self.completionHandler = completionHandler 33 | self.photoProcessingHandler = photoProcessingHandler 34 | } 35 | } 36 | 37 | extension PhotoCaptureProcessor: AVCapturePhotoCaptureDelegate { 38 | /* 39 | This extension adopts all of the AVCapturePhotoCaptureDelegate protocol methods. 40 | */ 41 | 42 | /// - Tag: WillBeginCapture 43 | func photoOutput(_ output: AVCapturePhotoOutput, willBeginCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings) { 44 | maxPhotoProcessingTime = resolvedSettings.photoProcessingTimeRange.start + resolvedSettings.photoProcessingTimeRange.duration 45 | } 46 | 47 | /// - Tag: WillCapturePhoto 48 | func photoOutput(_ output: AVCapturePhotoOutput, willCapturePhotoFor resolvedSettings: AVCaptureResolvedPhotoSettings) { 49 | DispatchQueue.main.async { 50 | self.willCapturePhotoAnimation() 51 | } 52 | 53 | guard let maxPhotoProcessingTime = maxPhotoProcessingTime else { 54 | return 55 | } 56 | 57 | // Show a spinner if processing time exceeds one second. 58 | let oneSecond = CMTime(seconds: 2, preferredTimescale: 1) 59 | if maxPhotoProcessingTime > oneSecond { 60 | DispatchQueue.main.async { 61 | self.photoProcessingHandler(true) 62 | } 63 | } 64 | } 65 | 66 | /// - Tag: DidFinishProcessingPhoto 67 | func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { 68 | 69 | DispatchQueue.main.async { 70 | self.photoProcessingHandler(false) 71 | } 72 | 73 | if let error = error { 74 | print("Error capturing photo: \(error)") 75 | } else { 76 | photoData = photo.fileDataRepresentation() 77 | 78 | } 79 | } 80 | 81 | fileprivate func saveToPhotoLibrary(_ photoData: Data) { 82 | // MARK: Saves capture to photo library 83 | 84 | PHPhotoLibrary.requestAuthorization { status in 85 | if status == .authorized { 86 | PHPhotoLibrary.shared().performChanges({ 87 | let options = PHAssetResourceCreationOptions() 88 | let creationRequest = PHAssetCreationRequest.forAsset() 89 | options.uniformTypeIdentifier = self.requestedPhotoSettings.processedFileType.map { $0.rawValue } 90 | creationRequest.addResource(with: .photo, data: photoData, options: options) 91 | 92 | 93 | }, completionHandler: { _, error in 94 | if let error = error { 95 | print("Error occurred while saving photo to photo library: \(error)") 96 | } 97 | 98 | DispatchQueue.main.async { 99 | self.completionHandler(self) 100 | } 101 | } 102 | ) 103 | } else { 104 | DispatchQueue.main.async { 105 | self.completionHandler(self) 106 | } 107 | } 108 | } 109 | } 110 | 111 | /// - Tag: DidFinishCapture 112 | func photoOutput(_ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, error: Error?) { 113 | if let error = error { 114 | print("Error capturing photo: \(error)") 115 | DispatchQueue.main.async { 116 | self.completionHandler(self) 117 | } 118 | return 119 | } 120 | 121 | DispatchQueue.main.async { 122 | self.completionHandler(self) 123 | } 124 | 125 | // self.saveToPhotoLibrary(photoData) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /DemoApp/DemoApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // DemoApp 4 | // 5 | // Created by Rolando Rodriguez on 8/25/22. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import Camera_SwiftUI 11 | import AVFoundation 12 | 13 | final class CameraModel: ObservableObject { 14 | private let service = CameraService() 15 | 16 | @Published var photo: Photo! 17 | 18 | @Published var showAlertError = false 19 | 20 | @Published var isFlashOn = false 21 | 22 | @Published var willCapturePhoto = false 23 | 24 | var alertError: AlertError! 25 | 26 | var session: AVCaptureSession 27 | 28 | private var subscriptions = Set() 29 | 30 | init() { 31 | self.session = service.session 32 | 33 | service.$photo.sink { [weak self] (photo) in 34 | guard let pic = photo else { return } 35 | self?.photo = pic 36 | } 37 | .store(in: &self.subscriptions) 38 | 39 | service.$shouldShowAlertView.sink { [weak self] (val) in 40 | self?.alertError = self?.service.alertError 41 | self?.showAlertError = val 42 | } 43 | .store(in: &self.subscriptions) 44 | 45 | service.$flashMode.sink { [weak self] (mode) in 46 | self?.isFlashOn = mode == .on 47 | } 48 | .store(in: &self.subscriptions) 49 | 50 | service.$willCapturePhoto.sink { [weak self] (val) in 51 | self?.willCapturePhoto = val 52 | } 53 | .store(in: &self.subscriptions) 54 | } 55 | 56 | func configure() { 57 | service.checkForPermissions() 58 | service.configure() 59 | } 60 | 61 | func capturePhoto() { 62 | service.capturePhoto() 63 | } 64 | 65 | func flipCamera() { 66 | service.changeCamera() 67 | } 68 | 69 | func zoom(with factor: CGFloat) { 70 | service.set(zoom: factor) 71 | } 72 | 73 | func switchFlash() { 74 | service.flashMode = service.flashMode == .on ? .off : .on 75 | } 76 | } 77 | 78 | struct CameraView: View { 79 | @StateObject var model = CameraModel() 80 | 81 | @State var currentZoomFactor: CGFloat = 1.0 82 | 83 | var captureButton: some View { 84 | Button(action: { 85 | model.capturePhoto() 86 | }, label: { 87 | Circle() 88 | .foregroundColor(.white) 89 | .frame(width: 80, height: 80, alignment: .center) 90 | .overlay( 91 | Circle() 92 | .stroke(Color.black.opacity(0.8), lineWidth: 2) 93 | .frame(width: 65, height: 65, alignment: .center) 94 | ) 95 | }) 96 | } 97 | 98 | var capturedPhotoThumbnail: some View { 99 | Group { 100 | if model.photo != nil { 101 | Image(uiImage: model.photo.image!) 102 | .resizable() 103 | .aspectRatio(contentMode: .fill) 104 | .frame(width: 60, height: 60) 105 | .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 106 | .animation(.spring()) 107 | 108 | } else { 109 | RoundedRectangle(cornerRadius: 10) 110 | .frame(width: 60, height: 60, alignment: .center) 111 | .foregroundColor(.black) 112 | } 113 | } 114 | } 115 | 116 | var flipCameraButton: some View { 117 | Button(action: { 118 | model.flipCamera() 119 | }, label: { 120 | Circle() 121 | .foregroundColor(Color.gray.opacity(0.2)) 122 | .frame(width: 45, height: 45, alignment: .center) 123 | .overlay( 124 | Image(systemName: "camera.rotate.fill") 125 | .foregroundColor(.white)) 126 | }) 127 | } 128 | 129 | var body: some View { 130 | NavigationView { 131 | GeometryReader { reader in 132 | ZStack { 133 | Color.black.edgesIgnoringSafeArea(.all) 134 | 135 | VStack { 136 | Button(action: { 137 | model.switchFlash() 138 | }, label: { 139 | Image(systemName: model.isFlashOn ? "bolt.fill" : "bolt.slash.fill") 140 | .font(.system(size: 20, weight: .medium, design: .default)) 141 | }) 142 | .accentColor(model.isFlashOn ? .yellow : .white) 143 | 144 | CameraPreview(session: model.session) 145 | .gesture( 146 | DragGesture().onChanged({ (val) in 147 | // Only accept vertical drag 148 | if abs(val.translation.height) > abs(val.translation.width) { 149 | // Get the percentage of vertical screen space covered by drag 150 | let percentage: CGFloat = -(val.translation.height / reader.size.height) 151 | // Calculate new zoom factor 152 | let calc = currentZoomFactor + percentage 153 | // Limit zoom factor to a maximum of 5x and a minimum of 1x 154 | let zoomFactor: CGFloat = min(max(calc, 1), 5) 155 | // Store the newly calculated zoom factor 156 | currentZoomFactor = zoomFactor 157 | // Sets the zoom factor to the capture device session 158 | model.zoom(with: zoomFactor) 159 | } 160 | }) 161 | ) 162 | .onAppear { 163 | model.configure() 164 | } 165 | .alert(isPresented: $model.showAlertError, content: { 166 | Alert(title: Text(model.alertError.title), message: Text(model.alertError.message), dismissButton: .default(Text(model.alertError.primaryButtonTitle), action: { 167 | model.alertError.primaryAction?() 168 | })) 169 | }) 170 | .overlay( 171 | Group { 172 | if model.willCapturePhoto { 173 | Color.black 174 | } 175 | } 176 | ) 177 | .animation(.easeInOut) 178 | 179 | 180 | HStack { 181 | NavigationLink(destination: Text("Detail photo")) { 182 | capturedPhotoThumbnail 183 | } 184 | 185 | Spacer() 186 | 187 | captureButton 188 | 189 | Spacer() 190 | 191 | flipCameraButton 192 | 193 | } 194 | .padding(.horizontal, 20) 195 | } 196 | } 197 | } 198 | } 199 | } 200 | } 201 | 202 | struct ContentView_Previews: PreviewProvider { 203 | static var previews: some View { 204 | CameraView() 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /DemoApp/DemoApp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | A9E714B928B7892F00A273A7 /* DemoAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E714B828B7892F00A273A7 /* DemoAppApp.swift */; }; 11 | A9E714BB28B7892F00A273A7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E714BA28B7892F00A273A7 /* ContentView.swift */; }; 12 | A9E714BD28B7893100A273A7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9E714BC28B7893100A273A7 /* Assets.xcassets */; }; 13 | A9E714C028B7893100A273A7 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9E714BF28B7893100A273A7 /* Preview Assets.xcassets */; }; 14 | A9E714CA28B7893100A273A7 /* DemoAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E714C928B7893100A273A7 /* DemoAppTests.swift */; }; 15 | A9E714D428B7893100A273A7 /* DemoAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E714D328B7893100A273A7 /* DemoAppUITests.swift */; }; 16 | A9E714D628B7893100A273A7 /* DemoAppUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E714D528B7893100A273A7 /* DemoAppUITestsLaunchTests.swift */; }; 17 | A9E714E628B789A100A273A7 /* Camera-SwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = A9E714E528B789A100A273A7 /* Camera-SwiftUI */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXContainerItemProxy section */ 21 | A9E714C628B7893100A273A7 /* PBXContainerItemProxy */ = { 22 | isa = PBXContainerItemProxy; 23 | containerPortal = A9E714AD28B7892F00A273A7 /* Project object */; 24 | proxyType = 1; 25 | remoteGlobalIDString = A9E714B428B7892F00A273A7; 26 | remoteInfo = DemoApp; 27 | }; 28 | A9E714D028B7893100A273A7 /* PBXContainerItemProxy */ = { 29 | isa = PBXContainerItemProxy; 30 | containerPortal = A9E714AD28B7892F00A273A7 /* Project object */; 31 | proxyType = 1; 32 | remoteGlobalIDString = A9E714B428B7892F00A273A7; 33 | remoteInfo = DemoApp; 34 | }; 35 | /* End PBXContainerItemProxy section */ 36 | 37 | /* Begin PBXFileReference section */ 38 | A9E714B528B7892F00A273A7 /* DemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 39 | A9E714B828B7892F00A273A7 /* DemoAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAppApp.swift; sourceTree = ""; }; 40 | A9E714BA28B7892F00A273A7 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 41 | A9E714BC28B7893100A273A7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 42 | A9E714BF28B7893100A273A7 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 43 | A9E714C528B7893100A273A7 /* DemoAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DemoAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 44 | A9E714C928B7893100A273A7 /* DemoAppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAppTests.swift; sourceTree = ""; }; 45 | A9E714CF28B7893100A273A7 /* DemoAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DemoAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 46 | A9E714D328B7893100A273A7 /* DemoAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAppUITests.swift; sourceTree = ""; }; 47 | A9E714D528B7893100A273A7 /* DemoAppUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoAppUITestsLaunchTests.swift; sourceTree = ""; }; 48 | A9E714E328B7898200A273A7 /* Camera-SwiftUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "Camera-SwiftUI"; path = ..; sourceTree = ""; }; 49 | /* End PBXFileReference section */ 50 | 51 | /* Begin PBXFrameworksBuildPhase section */ 52 | A9E714B228B7892F00A273A7 /* Frameworks */ = { 53 | isa = PBXFrameworksBuildPhase; 54 | buildActionMask = 2147483647; 55 | files = ( 56 | A9E714E628B789A100A273A7 /* Camera-SwiftUI in Frameworks */, 57 | ); 58 | runOnlyForDeploymentPostprocessing = 0; 59 | }; 60 | A9E714C228B7893100A273A7 /* Frameworks */ = { 61 | isa = PBXFrameworksBuildPhase; 62 | buildActionMask = 2147483647; 63 | files = ( 64 | ); 65 | runOnlyForDeploymentPostprocessing = 0; 66 | }; 67 | A9E714CC28B7893100A273A7 /* Frameworks */ = { 68 | isa = PBXFrameworksBuildPhase; 69 | buildActionMask = 2147483647; 70 | files = ( 71 | ); 72 | runOnlyForDeploymentPostprocessing = 0; 73 | }; 74 | /* End PBXFrameworksBuildPhase section */ 75 | 76 | /* Begin PBXGroup section */ 77 | A9E714AC28B7892F00A273A7 = { 78 | isa = PBXGroup; 79 | children = ( 80 | A9E714E228B7898200A273A7 /* Packages */, 81 | A9E714B728B7892F00A273A7 /* DemoApp */, 82 | A9E714C828B7893100A273A7 /* DemoAppTests */, 83 | A9E714D228B7893100A273A7 /* DemoAppUITests */, 84 | A9E714B628B7892F00A273A7 /* Products */, 85 | A9E714E428B789A100A273A7 /* Frameworks */, 86 | ); 87 | sourceTree = ""; 88 | }; 89 | A9E714B628B7892F00A273A7 /* Products */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | A9E714B528B7892F00A273A7 /* DemoApp.app */, 93 | A9E714C528B7893100A273A7 /* DemoAppTests.xctest */, 94 | A9E714CF28B7893100A273A7 /* DemoAppUITests.xctest */, 95 | ); 96 | name = Products; 97 | sourceTree = ""; 98 | }; 99 | A9E714B728B7892F00A273A7 /* DemoApp */ = { 100 | isa = PBXGroup; 101 | children = ( 102 | A9E714B828B7892F00A273A7 /* DemoAppApp.swift */, 103 | A9E714BA28B7892F00A273A7 /* ContentView.swift */, 104 | A9E714BC28B7893100A273A7 /* Assets.xcassets */, 105 | A9E714BE28B7893100A273A7 /* Preview Content */, 106 | ); 107 | path = DemoApp; 108 | sourceTree = ""; 109 | }; 110 | A9E714BE28B7893100A273A7 /* Preview Content */ = { 111 | isa = PBXGroup; 112 | children = ( 113 | A9E714BF28B7893100A273A7 /* Preview Assets.xcassets */, 114 | ); 115 | path = "Preview Content"; 116 | sourceTree = ""; 117 | }; 118 | A9E714C828B7893100A273A7 /* DemoAppTests */ = { 119 | isa = PBXGroup; 120 | children = ( 121 | A9E714C928B7893100A273A7 /* DemoAppTests.swift */, 122 | ); 123 | path = DemoAppTests; 124 | sourceTree = ""; 125 | }; 126 | A9E714D228B7893100A273A7 /* DemoAppUITests */ = { 127 | isa = PBXGroup; 128 | children = ( 129 | A9E714D328B7893100A273A7 /* DemoAppUITests.swift */, 130 | A9E714D528B7893100A273A7 /* DemoAppUITestsLaunchTests.swift */, 131 | ); 132 | path = DemoAppUITests; 133 | sourceTree = ""; 134 | }; 135 | A9E714E228B7898200A273A7 /* Packages */ = { 136 | isa = PBXGroup; 137 | children = ( 138 | A9E714E328B7898200A273A7 /* Camera-SwiftUI */, 139 | ); 140 | name = Packages; 141 | sourceTree = ""; 142 | }; 143 | A9E714E428B789A100A273A7 /* Frameworks */ = { 144 | isa = PBXGroup; 145 | children = ( 146 | ); 147 | name = Frameworks; 148 | sourceTree = ""; 149 | }; 150 | /* End PBXGroup section */ 151 | 152 | /* Begin PBXNativeTarget section */ 153 | A9E714B428B7892F00A273A7 /* DemoApp */ = { 154 | isa = PBXNativeTarget; 155 | buildConfigurationList = A9E714D928B7893100A273A7 /* Build configuration list for PBXNativeTarget "DemoApp" */; 156 | buildPhases = ( 157 | A9E714B128B7892F00A273A7 /* Sources */, 158 | A9E714B228B7892F00A273A7 /* Frameworks */, 159 | A9E714B328B7892F00A273A7 /* Resources */, 160 | ); 161 | buildRules = ( 162 | ); 163 | dependencies = ( 164 | ); 165 | name = DemoApp; 166 | packageProductDependencies = ( 167 | A9E714E528B789A100A273A7 /* Camera-SwiftUI */, 168 | ); 169 | productName = DemoApp; 170 | productReference = A9E714B528B7892F00A273A7 /* DemoApp.app */; 171 | productType = "com.apple.product-type.application"; 172 | }; 173 | A9E714C428B7893100A273A7 /* DemoAppTests */ = { 174 | isa = PBXNativeTarget; 175 | buildConfigurationList = A9E714DC28B7893100A273A7 /* Build configuration list for PBXNativeTarget "DemoAppTests" */; 176 | buildPhases = ( 177 | A9E714C128B7893100A273A7 /* Sources */, 178 | A9E714C228B7893100A273A7 /* Frameworks */, 179 | A9E714C328B7893100A273A7 /* Resources */, 180 | ); 181 | buildRules = ( 182 | ); 183 | dependencies = ( 184 | A9E714C728B7893100A273A7 /* PBXTargetDependency */, 185 | ); 186 | name = DemoAppTests; 187 | productName = DemoAppTests; 188 | productReference = A9E714C528B7893100A273A7 /* DemoAppTests.xctest */; 189 | productType = "com.apple.product-type.bundle.unit-test"; 190 | }; 191 | A9E714CE28B7893100A273A7 /* DemoAppUITests */ = { 192 | isa = PBXNativeTarget; 193 | buildConfigurationList = A9E714DF28B7893100A273A7 /* Build configuration list for PBXNativeTarget "DemoAppUITests" */; 194 | buildPhases = ( 195 | A9E714CB28B7893100A273A7 /* Sources */, 196 | A9E714CC28B7893100A273A7 /* Frameworks */, 197 | A9E714CD28B7893100A273A7 /* Resources */, 198 | ); 199 | buildRules = ( 200 | ); 201 | dependencies = ( 202 | A9E714D128B7893100A273A7 /* PBXTargetDependency */, 203 | ); 204 | name = DemoAppUITests; 205 | productName = DemoAppUITests; 206 | productReference = A9E714CF28B7893100A273A7 /* DemoAppUITests.xctest */; 207 | productType = "com.apple.product-type.bundle.ui-testing"; 208 | }; 209 | /* End PBXNativeTarget section */ 210 | 211 | /* Begin PBXProject section */ 212 | A9E714AD28B7892F00A273A7 /* Project object */ = { 213 | isa = PBXProject; 214 | attributes = { 215 | BuildIndependentTargetsInParallel = 1; 216 | LastSwiftUpdateCheck = 1330; 217 | LastUpgradeCheck = 1330; 218 | TargetAttributes = { 219 | A9E714B428B7892F00A273A7 = { 220 | CreatedOnToolsVersion = 13.3.1; 221 | }; 222 | A9E714C428B7893100A273A7 = { 223 | CreatedOnToolsVersion = 13.3.1; 224 | TestTargetID = A9E714B428B7892F00A273A7; 225 | }; 226 | A9E714CE28B7893100A273A7 = { 227 | CreatedOnToolsVersion = 13.3.1; 228 | TestTargetID = A9E714B428B7892F00A273A7; 229 | }; 230 | }; 231 | }; 232 | buildConfigurationList = A9E714B028B7892F00A273A7 /* Build configuration list for PBXProject "DemoApp" */; 233 | compatibilityVersion = "Xcode 13.0"; 234 | developmentRegion = en; 235 | hasScannedForEncodings = 0; 236 | knownRegions = ( 237 | en, 238 | Base, 239 | ); 240 | mainGroup = A9E714AC28B7892F00A273A7; 241 | productRefGroup = A9E714B628B7892F00A273A7 /* Products */; 242 | projectDirPath = ""; 243 | projectRoot = ""; 244 | targets = ( 245 | A9E714B428B7892F00A273A7 /* DemoApp */, 246 | A9E714C428B7893100A273A7 /* DemoAppTests */, 247 | A9E714CE28B7893100A273A7 /* DemoAppUITests */, 248 | ); 249 | }; 250 | /* End PBXProject section */ 251 | 252 | /* Begin PBXResourcesBuildPhase section */ 253 | A9E714B328B7892F00A273A7 /* Resources */ = { 254 | isa = PBXResourcesBuildPhase; 255 | buildActionMask = 2147483647; 256 | files = ( 257 | A9E714C028B7893100A273A7 /* Preview Assets.xcassets in Resources */, 258 | A9E714BD28B7893100A273A7 /* Assets.xcassets in Resources */, 259 | ); 260 | runOnlyForDeploymentPostprocessing = 0; 261 | }; 262 | A9E714C328B7893100A273A7 /* Resources */ = { 263 | isa = PBXResourcesBuildPhase; 264 | buildActionMask = 2147483647; 265 | files = ( 266 | ); 267 | runOnlyForDeploymentPostprocessing = 0; 268 | }; 269 | A9E714CD28B7893100A273A7 /* Resources */ = { 270 | isa = PBXResourcesBuildPhase; 271 | buildActionMask = 2147483647; 272 | files = ( 273 | ); 274 | runOnlyForDeploymentPostprocessing = 0; 275 | }; 276 | /* End PBXResourcesBuildPhase section */ 277 | 278 | /* Begin PBXSourcesBuildPhase section */ 279 | A9E714B128B7892F00A273A7 /* Sources */ = { 280 | isa = PBXSourcesBuildPhase; 281 | buildActionMask = 2147483647; 282 | files = ( 283 | A9E714BB28B7892F00A273A7 /* ContentView.swift in Sources */, 284 | A9E714B928B7892F00A273A7 /* DemoAppApp.swift in Sources */, 285 | ); 286 | runOnlyForDeploymentPostprocessing = 0; 287 | }; 288 | A9E714C128B7893100A273A7 /* Sources */ = { 289 | isa = PBXSourcesBuildPhase; 290 | buildActionMask = 2147483647; 291 | files = ( 292 | A9E714CA28B7893100A273A7 /* DemoAppTests.swift in Sources */, 293 | ); 294 | runOnlyForDeploymentPostprocessing = 0; 295 | }; 296 | A9E714CB28B7893100A273A7 /* Sources */ = { 297 | isa = PBXSourcesBuildPhase; 298 | buildActionMask = 2147483647; 299 | files = ( 300 | A9E714D428B7893100A273A7 /* DemoAppUITests.swift in Sources */, 301 | A9E714D628B7893100A273A7 /* DemoAppUITestsLaunchTests.swift in Sources */, 302 | ); 303 | runOnlyForDeploymentPostprocessing = 0; 304 | }; 305 | /* End PBXSourcesBuildPhase section */ 306 | 307 | /* Begin PBXTargetDependency section */ 308 | A9E714C728B7893100A273A7 /* PBXTargetDependency */ = { 309 | isa = PBXTargetDependency; 310 | target = A9E714B428B7892F00A273A7 /* DemoApp */; 311 | targetProxy = A9E714C628B7893100A273A7 /* PBXContainerItemProxy */; 312 | }; 313 | A9E714D128B7893100A273A7 /* PBXTargetDependency */ = { 314 | isa = PBXTargetDependency; 315 | target = A9E714B428B7892F00A273A7 /* DemoApp */; 316 | targetProxy = A9E714D028B7893100A273A7 /* PBXContainerItemProxy */; 317 | }; 318 | /* End PBXTargetDependency section */ 319 | 320 | /* Begin XCBuildConfiguration section */ 321 | A9E714D728B7893100A273A7 /* Debug */ = { 322 | isa = XCBuildConfiguration; 323 | buildSettings = { 324 | ALWAYS_SEARCH_USER_PATHS = NO; 325 | CLANG_ANALYZER_NONNULL = YES; 326 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 327 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 328 | CLANG_ENABLE_MODULES = YES; 329 | CLANG_ENABLE_OBJC_ARC = YES; 330 | CLANG_ENABLE_OBJC_WEAK = YES; 331 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 332 | CLANG_WARN_BOOL_CONVERSION = YES; 333 | CLANG_WARN_COMMA = YES; 334 | CLANG_WARN_CONSTANT_CONVERSION = YES; 335 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 336 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 337 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 338 | CLANG_WARN_EMPTY_BODY = YES; 339 | CLANG_WARN_ENUM_CONVERSION = YES; 340 | CLANG_WARN_INFINITE_RECURSION = YES; 341 | CLANG_WARN_INT_CONVERSION = YES; 342 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 343 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 344 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 345 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 346 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 347 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 348 | CLANG_WARN_STRICT_PROTOTYPES = YES; 349 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 350 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 351 | CLANG_WARN_UNREACHABLE_CODE = YES; 352 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 353 | COPY_PHASE_STRIP = NO; 354 | DEBUG_INFORMATION_FORMAT = dwarf; 355 | ENABLE_STRICT_OBJC_MSGSEND = YES; 356 | ENABLE_TESTABILITY = YES; 357 | GCC_C_LANGUAGE_STANDARD = gnu11; 358 | GCC_DYNAMIC_NO_PIC = NO; 359 | GCC_NO_COMMON_BLOCKS = YES; 360 | GCC_OPTIMIZATION_LEVEL = 0; 361 | GCC_PREPROCESSOR_DEFINITIONS = ( 362 | "DEBUG=1", 363 | "$(inherited)", 364 | ); 365 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 366 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 367 | GCC_WARN_UNDECLARED_SELECTOR = YES; 368 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 369 | GCC_WARN_UNUSED_FUNCTION = YES; 370 | GCC_WARN_UNUSED_VARIABLE = YES; 371 | IPHONEOS_DEPLOYMENT_TARGET = 15.4; 372 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 373 | MTL_FAST_MATH = YES; 374 | ONLY_ACTIVE_ARCH = YES; 375 | SDKROOT = iphoneos; 376 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 377 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 378 | }; 379 | name = Debug; 380 | }; 381 | A9E714D828B7893100A273A7 /* Release */ = { 382 | isa = XCBuildConfiguration; 383 | buildSettings = { 384 | ALWAYS_SEARCH_USER_PATHS = NO; 385 | CLANG_ANALYZER_NONNULL = YES; 386 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 387 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 388 | CLANG_ENABLE_MODULES = YES; 389 | CLANG_ENABLE_OBJC_ARC = YES; 390 | CLANG_ENABLE_OBJC_WEAK = YES; 391 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 392 | CLANG_WARN_BOOL_CONVERSION = YES; 393 | CLANG_WARN_COMMA = YES; 394 | CLANG_WARN_CONSTANT_CONVERSION = YES; 395 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 396 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 397 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 398 | CLANG_WARN_EMPTY_BODY = YES; 399 | CLANG_WARN_ENUM_CONVERSION = YES; 400 | CLANG_WARN_INFINITE_RECURSION = YES; 401 | CLANG_WARN_INT_CONVERSION = YES; 402 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 403 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 404 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 405 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 406 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 407 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 408 | CLANG_WARN_STRICT_PROTOTYPES = YES; 409 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 410 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 411 | CLANG_WARN_UNREACHABLE_CODE = YES; 412 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 413 | COPY_PHASE_STRIP = NO; 414 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 415 | ENABLE_NS_ASSERTIONS = NO; 416 | ENABLE_STRICT_OBJC_MSGSEND = YES; 417 | GCC_C_LANGUAGE_STANDARD = gnu11; 418 | GCC_NO_COMMON_BLOCKS = YES; 419 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 420 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 421 | GCC_WARN_UNDECLARED_SELECTOR = YES; 422 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 423 | GCC_WARN_UNUSED_FUNCTION = YES; 424 | GCC_WARN_UNUSED_VARIABLE = YES; 425 | IPHONEOS_DEPLOYMENT_TARGET = 15.4; 426 | MTL_ENABLE_DEBUG_INFO = NO; 427 | MTL_FAST_MATH = YES; 428 | SDKROOT = iphoneos; 429 | SWIFT_COMPILATION_MODE = wholemodule; 430 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 431 | VALIDATE_PRODUCT = YES; 432 | }; 433 | name = Release; 434 | }; 435 | A9E714DA28B7893100A273A7 /* Debug */ = { 436 | isa = XCBuildConfiguration; 437 | buildSettings = { 438 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 439 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 440 | CODE_SIGN_STYLE = Automatic; 441 | CURRENT_PROJECT_VERSION = 1; 442 | DEVELOPMENT_ASSET_PATHS = "\"DemoApp/Preview Content\""; 443 | DEVELOPMENT_TEAM = YAFLRP6JUR; 444 | ENABLE_PREVIEWS = YES; 445 | GENERATE_INFOPLIST_FILE = YES; 446 | INFOPLIST_KEY_NSCameraUsageDescription = "DemoApp needs to access your camera to take pictures."; 447 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 448 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 449 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 450 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 451 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 452 | LD_RUNPATH_SEARCH_PATHS = ( 453 | "$(inherited)", 454 | "@executable_path/Frameworks", 455 | ); 456 | MARKETING_VERSION = 1.0; 457 | PRODUCT_BUNDLE_IDENTIFIER = "com.rry.cemera-swiftui.demo"; 458 | PRODUCT_NAME = "$(TARGET_NAME)"; 459 | SWIFT_EMIT_LOC_STRINGS = YES; 460 | SWIFT_VERSION = 5.0; 461 | TARGETED_DEVICE_FAMILY = "1,2"; 462 | }; 463 | name = Debug; 464 | }; 465 | A9E714DB28B7893100A273A7 /* Release */ = { 466 | isa = XCBuildConfiguration; 467 | buildSettings = { 468 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 469 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 470 | CODE_SIGN_STYLE = Automatic; 471 | CURRENT_PROJECT_VERSION = 1; 472 | DEVELOPMENT_ASSET_PATHS = "\"DemoApp/Preview Content\""; 473 | DEVELOPMENT_TEAM = YAFLRP6JUR; 474 | ENABLE_PREVIEWS = YES; 475 | GENERATE_INFOPLIST_FILE = YES; 476 | INFOPLIST_KEY_NSCameraUsageDescription = "DemoApp needs to access your camera to take pictures."; 477 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 478 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 479 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 480 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 481 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 482 | LD_RUNPATH_SEARCH_PATHS = ( 483 | "$(inherited)", 484 | "@executable_path/Frameworks", 485 | ); 486 | MARKETING_VERSION = 1.0; 487 | PRODUCT_BUNDLE_IDENTIFIER = "com.rry.cemera-swiftui.demo"; 488 | PRODUCT_NAME = "$(TARGET_NAME)"; 489 | SWIFT_EMIT_LOC_STRINGS = YES; 490 | SWIFT_VERSION = 5.0; 491 | TARGETED_DEVICE_FAMILY = "1,2"; 492 | }; 493 | name = Release; 494 | }; 495 | A9E714DD28B7893100A273A7 /* Debug */ = { 496 | isa = XCBuildConfiguration; 497 | buildSettings = { 498 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 499 | BUNDLE_LOADER = "$(TEST_HOST)"; 500 | CODE_SIGN_STYLE = Automatic; 501 | CURRENT_PROJECT_VERSION = 1; 502 | DEVELOPMENT_TEAM = YAFLRP6JUR; 503 | GENERATE_INFOPLIST_FILE = YES; 504 | IPHONEOS_DEPLOYMENT_TARGET = 15.4; 505 | MARKETING_VERSION = 1.0; 506 | PRODUCT_BUNDLE_IDENTIFIER = rry.com.DemoAppTests; 507 | PRODUCT_NAME = "$(TARGET_NAME)"; 508 | SWIFT_EMIT_LOC_STRINGS = NO; 509 | SWIFT_VERSION = 5.0; 510 | TARGETED_DEVICE_FAMILY = "1,2"; 511 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DemoApp.app/DemoApp"; 512 | }; 513 | name = Debug; 514 | }; 515 | A9E714DE28B7893100A273A7 /* Release */ = { 516 | isa = XCBuildConfiguration; 517 | buildSettings = { 518 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 519 | BUNDLE_LOADER = "$(TEST_HOST)"; 520 | CODE_SIGN_STYLE = Automatic; 521 | CURRENT_PROJECT_VERSION = 1; 522 | DEVELOPMENT_TEAM = YAFLRP6JUR; 523 | GENERATE_INFOPLIST_FILE = YES; 524 | IPHONEOS_DEPLOYMENT_TARGET = 15.4; 525 | MARKETING_VERSION = 1.0; 526 | PRODUCT_BUNDLE_IDENTIFIER = rry.com.DemoAppTests; 527 | PRODUCT_NAME = "$(TARGET_NAME)"; 528 | SWIFT_EMIT_LOC_STRINGS = NO; 529 | SWIFT_VERSION = 5.0; 530 | TARGETED_DEVICE_FAMILY = "1,2"; 531 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DemoApp.app/DemoApp"; 532 | }; 533 | name = Release; 534 | }; 535 | A9E714E028B7893100A273A7 /* Debug */ = { 536 | isa = XCBuildConfiguration; 537 | buildSettings = { 538 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 539 | CODE_SIGN_STYLE = Automatic; 540 | CURRENT_PROJECT_VERSION = 1; 541 | DEVELOPMENT_TEAM = YAFLRP6JUR; 542 | GENERATE_INFOPLIST_FILE = YES; 543 | MARKETING_VERSION = 1.0; 544 | PRODUCT_BUNDLE_IDENTIFIER = rry.com.DemoAppUITests; 545 | PRODUCT_NAME = "$(TARGET_NAME)"; 546 | SWIFT_EMIT_LOC_STRINGS = NO; 547 | SWIFT_VERSION = 5.0; 548 | TARGETED_DEVICE_FAMILY = "1,2"; 549 | TEST_TARGET_NAME = DemoApp; 550 | }; 551 | name = Debug; 552 | }; 553 | A9E714E128B7893100A273A7 /* Release */ = { 554 | isa = XCBuildConfiguration; 555 | buildSettings = { 556 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 557 | CODE_SIGN_STYLE = Automatic; 558 | CURRENT_PROJECT_VERSION = 1; 559 | DEVELOPMENT_TEAM = YAFLRP6JUR; 560 | GENERATE_INFOPLIST_FILE = YES; 561 | MARKETING_VERSION = 1.0; 562 | PRODUCT_BUNDLE_IDENTIFIER = rry.com.DemoAppUITests; 563 | PRODUCT_NAME = "$(TARGET_NAME)"; 564 | SWIFT_EMIT_LOC_STRINGS = NO; 565 | SWIFT_VERSION = 5.0; 566 | TARGETED_DEVICE_FAMILY = "1,2"; 567 | TEST_TARGET_NAME = DemoApp; 568 | }; 569 | name = Release; 570 | }; 571 | /* End XCBuildConfiguration section */ 572 | 573 | /* Begin XCConfigurationList section */ 574 | A9E714B028B7892F00A273A7 /* Build configuration list for PBXProject "DemoApp" */ = { 575 | isa = XCConfigurationList; 576 | buildConfigurations = ( 577 | A9E714D728B7893100A273A7 /* Debug */, 578 | A9E714D828B7893100A273A7 /* Release */, 579 | ); 580 | defaultConfigurationIsVisible = 0; 581 | defaultConfigurationName = Release; 582 | }; 583 | A9E714D928B7893100A273A7 /* Build configuration list for PBXNativeTarget "DemoApp" */ = { 584 | isa = XCConfigurationList; 585 | buildConfigurations = ( 586 | A9E714DA28B7893100A273A7 /* Debug */, 587 | A9E714DB28B7893100A273A7 /* Release */, 588 | ); 589 | defaultConfigurationIsVisible = 0; 590 | defaultConfigurationName = Release; 591 | }; 592 | A9E714DC28B7893100A273A7 /* Build configuration list for PBXNativeTarget "DemoAppTests" */ = { 593 | isa = XCConfigurationList; 594 | buildConfigurations = ( 595 | A9E714DD28B7893100A273A7 /* Debug */, 596 | A9E714DE28B7893100A273A7 /* Release */, 597 | ); 598 | defaultConfigurationIsVisible = 0; 599 | defaultConfigurationName = Release; 600 | }; 601 | A9E714DF28B7893100A273A7 /* Build configuration list for PBXNativeTarget "DemoAppUITests" */ = { 602 | isa = XCConfigurationList; 603 | buildConfigurations = ( 604 | A9E714E028B7893100A273A7 /* Debug */, 605 | A9E714E128B7893100A273A7 /* Release */, 606 | ); 607 | defaultConfigurationIsVisible = 0; 608 | defaultConfigurationName = Release; 609 | }; 610 | /* End XCConfigurationList section */ 611 | 612 | /* Begin XCSwiftPackageProductDependency section */ 613 | A9E714E528B789A100A273A7 /* Camera-SwiftUI */ = { 614 | isa = XCSwiftPackageProductDependency; 615 | productName = "Camera-SwiftUI"; 616 | }; 617 | /* End XCSwiftPackageProductDependency section */ 618 | }; 619 | rootObject = A9E714AD28B7892F00A273A7 /* Project object */; 620 | } 621 | -------------------------------------------------------------------------------- /Sources/Camera-SwiftUI/Service/CameraService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraService.swift 3 | // Campus 4 | // 5 | // Created by Rolando Rodriguez on 12/20/19. 6 | // Copyright © 2018 Rolando Rodriguez. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import AVFoundation 12 | import Photos 13 | import UIKit 14 | 15 | // MARK: Class Camera Service, handles setup of AVFoundation needed for a basic camera app. 16 | public struct Photo: Identifiable, Equatable { 17 | public var id: String 18 | public var originalData: Data 19 | 20 | public init(id: String = UUID().uuidString, originalData: Data) { 21 | self.id = id 22 | self.originalData = originalData 23 | } 24 | } 25 | 26 | public struct AlertError { 27 | public var title: String = "" 28 | public var message: String = "" 29 | public var primaryButtonTitle = "Accept" 30 | public var secondaryButtonTitle: String? 31 | public var primaryAction: (() -> ())? 32 | public var secondaryAction: (() -> ())? 33 | 34 | public init(title: String = "", message: String = "", primaryButtonTitle: String = "Accept", secondaryButtonTitle: String? = nil, primaryAction: (() -> ())? = nil, secondaryAction: (() -> ())? = nil) { 35 | self.title = title 36 | self.message = message 37 | self.primaryAction = primaryAction 38 | self.primaryButtonTitle = primaryButtonTitle 39 | self.secondaryAction = secondaryAction 40 | } 41 | } 42 | 43 | extension Photo { 44 | public var compressedData: Data? { 45 | ImageResizer(targetWidth: 800).resize(data: originalData)?.jpegData(compressionQuality: 0.5) 46 | } 47 | public var thumbnailData: Data? { 48 | ImageResizer(targetWidth: 100).resize(data: originalData)?.jpegData(compressionQuality: 0.5) 49 | } 50 | public var thumbnailImage: UIImage? { 51 | guard let data = thumbnailData else { return nil } 52 | return UIImage(data: data) 53 | } 54 | public var image: UIImage? { 55 | guard let data = compressedData else { return nil } 56 | return UIImage(data: data) 57 | } 58 | } 59 | 60 | public class CameraService: NSObject, Identifiable { 61 | typealias PhotoCaptureSessionID = String 62 | 63 | // MARK: Observed Properties UI must react to 64 | 65 | @Published public var flashMode: AVCaptureDevice.FlashMode = .off 66 | @Published public var shouldShowAlertView = false 67 | @Published public var shouldShowSpinner = false 68 | 69 | @Published public var willCapturePhoto = false 70 | @Published public var isCameraButtonDisabled = false 71 | @Published public var isCameraUnavailable = false 72 | @Published public var photo: Photo? 73 | 74 | // MARK: Alert properties 75 | public var alertError: AlertError = AlertError() 76 | 77 | // MARK: Session Management Properties 78 | 79 | public let session = AVCaptureSession() 80 | 81 | var isSessionRunning = false 82 | var isConfigured = false 83 | var setupResult: SessionSetupResult = .success 84 | 85 | // Communicate with the session and other session objects on this queue. 86 | let sessionQueue = DispatchQueue(label: "session queue") 87 | 88 | @objc dynamic var videoDeviceInput: AVCaptureDeviceInput! 89 | 90 | // MARK: Device Configuration Properties 91 | let videoDeviceDiscoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .builtInDualCamera, .builtInTrueDepthCamera], mediaType: .video, position: .unspecified) 92 | 93 | // MARK: Capturing Photos 94 | 95 | let photoOutput = AVCapturePhotoOutput() 96 | 97 | var inProgressPhotoCaptureDelegates = [Int64: PhotoCaptureProcessor]() 98 | 99 | // MARK: KVO and Notifications Properties 100 | 101 | var keyValueObservations = [NSKeyValueObservation]() 102 | 103 | // MARK: Init 104 | 105 | override public init() { 106 | super.init() 107 | 108 | // Disable the UI. Enable the UI later, if and only if the session starts running. 109 | DispatchQueue.main.async { 110 | self.isCameraButtonDisabled = true 111 | self.isCameraUnavailable = true 112 | } 113 | } 114 | 115 | public func configure() { 116 | if !self.isSessionRunning && !self.isConfigured { 117 | /* 118 | Setup the capture session. 119 | In general, it's not safe to mutate an AVCaptureSession or any of its 120 | inputs, outputs, or connections from multiple threads at the same time. 121 | 122 | Don't perform these tasks on the main queue because 123 | AVCaptureSession.startRunning() is a blocking call, which can 124 | take a long time. Dispatch session setup to the sessionQueue, so 125 | that the main queue isn't blocked, which keeps the UI responsive. 126 | */ 127 | sessionQueue.async { 128 | self.configureSession() 129 | } 130 | } 131 | } 132 | 133 | // MARK: Checks for permisions, setup obeservers and starts running session 134 | public func checkForPermissions() { 135 | /* 136 | Check the video authorization status. Video access is required and audio 137 | access is optional. If the user denies audio access, AVCam won't 138 | record audio during movie recording. 139 | */ 140 | switch AVCaptureDevice.authorizationStatus(for: .video) { 141 | case .authorized: 142 | // The user has previously granted access to the camera. 143 | break 144 | case .notDetermined: 145 | /* 146 | The user has not yet been presented with the option to grant 147 | video access. Suspend the session queue to delay session 148 | setup until the access request has completed. 149 | 150 | Note that audio access will be implicitly requested when we 151 | create an AVCaptureDeviceInput for audio during session setup. 152 | */ 153 | sessionQueue.suspend() 154 | AVCaptureDevice.requestAccess(for: .video, completionHandler: { granted in 155 | if !granted { 156 | self.setupResult = .notAuthorized 157 | } 158 | self.sessionQueue.resume() 159 | }) 160 | 161 | default: 162 | // The user has previously denied access. 163 | setupResult = .notAuthorized 164 | 165 | DispatchQueue.main.async { 166 | self.alertError = AlertError(title: "Camera Access", message: "Campus no tiene permiso para usar la cámara, por favor cambia la configruación de privacidad", primaryButtonTitle: "Configuración", secondaryButtonTitle: nil, primaryAction: { 167 | UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, 168 | options: [:], completionHandler: nil) 169 | 170 | }, secondaryAction: nil) 171 | self.shouldShowAlertView = true 172 | self.isCameraUnavailable = true 173 | self.isCameraButtonDisabled = true 174 | } 175 | } 176 | } 177 | 178 | // MARK: Session Managment 179 | 180 | // Call this on the session queue. 181 | /// - Tag: ConfigureSession 182 | private func configureSession() { 183 | if setupResult != .success { 184 | return 185 | } 186 | 187 | session.beginConfiguration() 188 | 189 | /* 190 | Do not create an AVCaptureMovieFileOutput when setting up the session because 191 | Live Photo is not supported when AVCaptureMovieFileOutput is added to the session. 192 | */ 193 | session.sessionPreset = .photo 194 | 195 | // Add video input. 196 | do { 197 | var defaultVideoDevice: AVCaptureDevice? 198 | 199 | if let backCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) { 200 | // If a rear dual camera is not available, default to the rear wide angle camera. 201 | defaultVideoDevice = backCameraDevice 202 | } else if let frontCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) { 203 | // If the rear wide angle camera isn't available, default to the front wide angle camera. 204 | defaultVideoDevice = frontCameraDevice 205 | } 206 | 207 | guard let videoDevice = defaultVideoDevice else { 208 | print("Default video device is unavailable.") 209 | setupResult = .configurationFailed 210 | session.commitConfiguration() 211 | return 212 | } 213 | 214 | let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) 215 | 216 | if session.canAddInput(videoDeviceInput) { 217 | session.addInput(videoDeviceInput) 218 | self.videoDeviceInput = videoDeviceInput 219 | 220 | } else { 221 | print("Couldn't add video device input to the session.") 222 | setupResult = .configurationFailed 223 | session.commitConfiguration() 224 | return 225 | } 226 | } catch { 227 | print("Couldn't create video device input: \(error)") 228 | setupResult = .configurationFailed 229 | session.commitConfiguration() 230 | return 231 | } 232 | 233 | // Add the photo output. 234 | if session.canAddOutput(photoOutput) { 235 | session.addOutput(photoOutput) 236 | 237 | photoOutput.isHighResolutionCaptureEnabled = true 238 | photoOutput.maxPhotoQualityPrioritization = .quality 239 | 240 | } else { 241 | print("Could not add photo output to the session") 242 | setupResult = .configurationFailed 243 | session.commitConfiguration() 244 | return 245 | } 246 | 247 | session.commitConfiguration() 248 | self.isConfigured = true 249 | 250 | self.start() 251 | } 252 | 253 | private func resumeInterruptedSession() { 254 | sessionQueue.async { 255 | /* 256 | The session might fail to start running, for example, if a phone or FaceTime call is still 257 | using audio or video. This failure is communicated by the session posting a 258 | runtime error notification. To avoid repeatedly failing to start the session, 259 | only try to restart the session in the error handler if you aren't 260 | trying to resume the session. 261 | */ 262 | self.session.startRunning() 263 | self.isSessionRunning = self.session.isRunning 264 | if !self.session.isRunning { 265 | DispatchQueue.main.async { 266 | self.alertError = AlertError(title: "Camera Error", message: "Unable to resume camera", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil) 267 | self.shouldShowAlertView = true 268 | self.isCameraUnavailable = true 269 | self.isCameraButtonDisabled = true 270 | } 271 | } else { 272 | DispatchQueue.main.async { 273 | self.isCameraUnavailable = false 274 | self.isCameraButtonDisabled = false 275 | } 276 | } 277 | } 278 | } 279 | 280 | // MARK: Device Configuration 281 | 282 | /// - Tag: ChangeCamera 283 | public func changeCamera() { 284 | // MARK: Here disable all camera operation related buttons due to configuration is due upon and must not be interrupted 285 | DispatchQueue.main.async { 286 | self.isCameraButtonDisabled = true 287 | } 288 | // 289 | 290 | sessionQueue.async { 291 | let currentVideoDevice = self.videoDeviceInput.device 292 | let currentPosition = currentVideoDevice.position 293 | 294 | let preferredPosition: AVCaptureDevice.Position 295 | let preferredDeviceType: AVCaptureDevice.DeviceType 296 | 297 | switch currentPosition { 298 | case .unspecified, .front: 299 | preferredPosition = .back 300 | preferredDeviceType = .builtInWideAngleCamera 301 | 302 | case .back: 303 | preferredPosition = .front 304 | preferredDeviceType = .builtInWideAngleCamera 305 | 306 | @unknown default: 307 | print("Unknown capture position. Defaulting to back, dual-camera.") 308 | preferredPosition = .back 309 | preferredDeviceType = .builtInWideAngleCamera 310 | } 311 | let devices = self.videoDeviceDiscoverySession.devices 312 | var newVideoDevice: AVCaptureDevice? = nil 313 | 314 | // First, seek a device with both the preferred position and device type. Otherwise, seek a device with only the preferred position. 315 | if let device = devices.first(where: { $0.position == preferredPosition && $0.deviceType == preferredDeviceType }) { 316 | newVideoDevice = device 317 | } else if let device = devices.first(where: { $0.position == preferredPosition }) { 318 | newVideoDevice = device 319 | } 320 | 321 | if let videoDevice = newVideoDevice { 322 | do { 323 | let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) 324 | 325 | self.session.beginConfiguration() 326 | 327 | // Remove the existing device input first, because AVCaptureSession doesn't support 328 | // simultaneous use of the rear and front cameras. 329 | self.session.removeInput(self.videoDeviceInput) 330 | 331 | if self.session.canAddInput(videoDeviceInput) { 332 | NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: currentVideoDevice) 333 | NotificationCenter.default.addObserver(self, selector: #selector(self.subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: videoDeviceInput.device) 334 | 335 | self.session.addInput(videoDeviceInput) 336 | self.videoDeviceInput = videoDeviceInput 337 | } else { 338 | self.session.addInput(self.videoDeviceInput) 339 | } 340 | 341 | if let connection = self.photoOutput.connection(with: .video) { 342 | if connection.isVideoStabilizationSupported { 343 | connection.preferredVideoStabilizationMode = .auto 344 | } 345 | } 346 | 347 | self.photoOutput.maxPhotoQualityPrioritization = .quality 348 | 349 | self.session.commitConfiguration() 350 | } catch { 351 | print("Error occurred while creating video device input: \(error)") 352 | } 353 | } 354 | 355 | DispatchQueue.main.async { 356 | // MARK: Here enable all camera operation related buttons due to succesfull setup 357 | self.isCameraButtonDisabled = false 358 | } 359 | } 360 | } 361 | 362 | public func focus(with focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode, at devicePoint: CGPoint, monitorSubjectAreaChange: Bool) { 363 | sessionQueue.async { 364 | guard let device = self.videoDeviceInput?.device else { return } 365 | do { 366 | try device.lockForConfiguration() 367 | 368 | /* 369 | Setting (focus/exposure)PointOfInterest alone does not initiate a (focus/exposure) operation. 370 | Call set(Focus/Exposure)Mode() to apply the new point of interest. 371 | */ 372 | if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) { 373 | device.focusPointOfInterest = devicePoint 374 | device.focusMode = focusMode 375 | } 376 | 377 | if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) { 378 | device.exposurePointOfInterest = devicePoint 379 | device.exposureMode = exposureMode 380 | } 381 | 382 | device.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange 383 | device.unlockForConfiguration() 384 | } catch { 385 | print("Could not lock device for configuration: \(error)") 386 | } 387 | } 388 | } 389 | 390 | 391 | public func focus(at focusPoint: CGPoint){ 392 | let device = self.videoDeviceInput.device 393 | do { 394 | try device.lockForConfiguration() 395 | if device.isFocusPointOfInterestSupported { 396 | device.focusPointOfInterest = focusPoint 397 | device.exposurePointOfInterest = focusPoint 398 | device.exposureMode = .continuousAutoExposure 399 | device.focusMode = .continuousAutoFocus 400 | device.unlockForConfiguration() 401 | } 402 | } 403 | catch { 404 | print(error.localizedDescription) 405 | } 406 | } 407 | 408 | @objc public func stop(completion: (() -> ())? = nil) { 409 | sessionQueue.async { 410 | if self.isSessionRunning { 411 | if self.setupResult == .success { 412 | self.session.stopRunning() 413 | self.isSessionRunning = self.session.isRunning 414 | print("CAMERA STOPPED") 415 | self.removeObservers() 416 | 417 | if !self.session.isRunning { 418 | DispatchQueue.main.async { 419 | self.isCameraButtonDisabled = true 420 | self.isCameraUnavailable = true 421 | completion?() 422 | } 423 | } 424 | } 425 | } 426 | } 427 | } 428 | 429 | @objc public func start() { 430 | sessionQueue.async { 431 | if !self.isSessionRunning && self.isConfigured { 432 | switch self.setupResult { 433 | case .success: 434 | // Only setup observers and start the session if setup succeeded. 435 | self.addObservers() 436 | self.session.startRunning() 437 | print("CAMERA RUNNING") 438 | self.isSessionRunning = self.session.isRunning 439 | 440 | if self.session.isRunning { 441 | DispatchQueue.main.async { 442 | self.isCameraButtonDisabled = false 443 | self.isCameraUnavailable = false 444 | } 445 | } 446 | 447 | case .notAuthorized: 448 | print("Application not authorized to use camera") 449 | DispatchQueue.main.async { 450 | self.isCameraButtonDisabled = true 451 | self.isCameraUnavailable = true 452 | } 453 | 454 | case .configurationFailed: 455 | DispatchQueue.main.async { 456 | self.alertError = AlertError(title: "Camera Error", message: "Camera configuration failed. Either your device camera is not available or other application is using it", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil) 457 | self.shouldShowAlertView = true 458 | self.isCameraButtonDisabled = true 459 | self.isCameraUnavailable = true 460 | } 461 | } 462 | } 463 | } 464 | } 465 | 466 | public func set(zoom: CGFloat){ 467 | let factor = zoom < 1 ? 1 : zoom 468 | let device = self.videoDeviceInput.device 469 | 470 | do { 471 | try device.lockForConfiguration() 472 | device.videoZoomFactor = factor 473 | device.unlockForConfiguration() 474 | } 475 | catch { 476 | print(error.localizedDescription) 477 | } 478 | } 479 | 480 | // MARK: Capture Photo 481 | 482 | /// - Tag: CapturePhoto 483 | public func capturePhoto() { 484 | /* 485 | Retrieve the video preview layer's video orientation on the main queue before 486 | entering the session queue. This to ensures that UI elements are accessed on 487 | the main thread and session configuration is done on the session queue. 488 | */ 489 | 490 | if self.setupResult != .configurationFailed { 491 | let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait 492 | self.isCameraButtonDisabled = true 493 | 494 | sessionQueue.async { 495 | if let photoOutputConnection = self.photoOutput.connection(with: .video) { 496 | photoOutputConnection.videoOrientation = videoPreviewLayerOrientation 497 | } 498 | var photoSettings = AVCapturePhotoSettings() 499 | 500 | // Capture HEIF photos when supported. Enable according to user settings and high-resolution photos. 501 | if self.photoOutput.availablePhotoCodecTypes.contains(.hevc) { 502 | photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) 503 | } 504 | 505 | if self.videoDeviceInput.device.isFlashAvailable { 506 | photoSettings.flashMode = self.flashMode 507 | } 508 | 509 | photoSettings.isHighResolutionPhotoEnabled = true 510 | if !photoSettings.__availablePreviewPhotoPixelFormatTypes.isEmpty { 511 | photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: photoSettings.__availablePreviewPhotoPixelFormatTypes.first!] 512 | } 513 | 514 | photoSettings.photoQualityPrioritization = .speed 515 | 516 | let photoCaptureProcessor = PhotoCaptureProcessor(with: photoSettings, willCapturePhotoAnimation: { 517 | // Flash the screen to signal that AVCam took a photo. 518 | DispatchQueue.main.async { 519 | self.willCapturePhoto.toggle() 520 | self.willCapturePhoto.toggle() 521 | } 522 | }, completionHandler: { (photoCaptureProcessor) in 523 | // When the capture is complete, remove a reference to the photo capture delegate so it can be deallocated. 524 | if let data = photoCaptureProcessor.photoData { 525 | self.photo = Photo(originalData: data) 526 | print("passing photo") 527 | } else { 528 | print("No photo data") 529 | } 530 | 531 | self.isCameraButtonDisabled = false 532 | 533 | self.sessionQueue.async { 534 | self.inProgressPhotoCaptureDelegates[photoCaptureProcessor.requestedPhotoSettings.uniqueID] = nil 535 | } 536 | }, photoProcessingHandler: { animate in 537 | // Animates a spinner while photo is processing 538 | if animate { 539 | self.shouldShowSpinner = true 540 | } else { 541 | self.shouldShowSpinner = false 542 | } 543 | }) 544 | 545 | // The photo output holds a weak reference to the photo capture delegate and stores it in an array to maintain a strong reference. 546 | self.inProgressPhotoCaptureDelegates[photoCaptureProcessor.requestedPhotoSettings.uniqueID] = photoCaptureProcessor 547 | self.photoOutput.capturePhoto(with: photoSettings, delegate: photoCaptureProcessor) 548 | } 549 | } 550 | } 551 | 552 | 553 | // MARK: KVO & Observers 554 | 555 | /// - Tag: ObserveInterruption 556 | private func addObservers() { 557 | let systemPressureStateObservation = observe(\.videoDeviceInput.device.systemPressureState, options: .new) { _, change in 558 | guard let systemPressureState = change.newValue else { return } 559 | self.setRecommendedFrameRateRangeForPressureState(systemPressureState: systemPressureState) 560 | } 561 | keyValueObservations.append(systemPressureStateObservation) 562 | 563 | // NotificationCenter.default.addObserver(self, selector: #selector(self.onOrientationChange), name: UIDevice.orientationDidChangeNotification, object: nil) 564 | 565 | NotificationCenter.default.addObserver(self, 566 | selector: #selector(subjectAreaDidChange), 567 | name: .AVCaptureDeviceSubjectAreaDidChange, 568 | object: videoDeviceInput.device) 569 | 570 | NotificationCenter.default.addObserver(self, selector: #selector(uiRequestedNewFocusArea), name: .init(rawValue: "UserDidRequestNewFocusPoint"), object: nil) 571 | 572 | NotificationCenter.default.addObserver(self, 573 | selector: #selector(sessionRuntimeError), 574 | name: .AVCaptureSessionRuntimeError, 575 | object: session) 576 | 577 | /* 578 | A session can only run when the app is full screen. It will be interrupted 579 | in a multi-app layout, introduced in iOS 9, see also the documentation of 580 | AVCaptureSessionInterruptionReason. Add observers to handle these session 581 | interruptions and show a preview is paused message. See the documentation 582 | of AVCaptureSessionWasInterruptedNotification for other interruption reasons. 583 | */ 584 | NotificationCenter.default.addObserver(self, 585 | selector: #selector(sessionWasInterrupted), 586 | name: .AVCaptureSessionWasInterrupted, 587 | object: session) 588 | NotificationCenter.default.addObserver(self, 589 | selector: #selector(sessionInterruptionEnded), 590 | name: .AVCaptureSessionInterruptionEnded, 591 | object: session) 592 | } 593 | 594 | private func removeObservers() { 595 | NotificationCenter.default.removeObserver(self) 596 | 597 | for keyValueObservation in keyValueObservations { 598 | keyValueObservation.invalidate() 599 | } 600 | keyValueObservations.removeAll() 601 | } 602 | 603 | @objc private func uiRequestedNewFocusArea(notification: NSNotification) { 604 | guard let userInfo = notification.userInfo as? [String: Any], let devicePoint = userInfo["devicePoint"] as? CGPoint else { return } 605 | self.focus(at: devicePoint) 606 | } 607 | 608 | @objc 609 | private func subjectAreaDidChange(notification: NSNotification) { 610 | let devicePoint = CGPoint(x: 0.5, y: 0.5) 611 | focus(with: .continuousAutoFocus, exposureMode: .continuousAutoExposure, at: devicePoint, monitorSubjectAreaChange: false) 612 | } 613 | 614 | /// - Tag: HandleRuntimeError 615 | @objc 616 | private func sessionRuntimeError(notification: NSNotification) { 617 | guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return } 618 | 619 | print("Capture session runtime error: \(error)") 620 | // If media services were reset, and the last start succeeded, restart the session. 621 | if error.code == .mediaServicesWereReset { 622 | sessionQueue.async { 623 | if self.isSessionRunning { 624 | self.session.startRunning() 625 | self.isSessionRunning = self.session.isRunning 626 | } else { 627 | DispatchQueue.main.async { 628 | // self.resumeButton.isHidden = false 629 | } 630 | } 631 | } 632 | } else { 633 | // resumeButton.isHidden = false 634 | } 635 | } 636 | 637 | /// - Tag: HandleSystemPressure 638 | private func setRecommendedFrameRateRangeForPressureState(systemPressureState: AVCaptureDevice.SystemPressureState) { 639 | /* 640 | The frame rates used here are only for demonstration purposes. 641 | Your frame rate throttling may be different depending on your app's camera configuration. 642 | */ 643 | let pressureLevel = systemPressureState.level 644 | if pressureLevel == .serious || pressureLevel == .critical { 645 | do { 646 | try self.videoDeviceInput.device.lockForConfiguration() 647 | print("WARNING: Reached elevated system pressure level: \(pressureLevel). Throttling frame rate.") 648 | self.videoDeviceInput.device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 20) 649 | self.videoDeviceInput.device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 15) 650 | self.videoDeviceInput.device.unlockForConfiguration() 651 | } catch { 652 | print("Could not lock device for configuration: \(error)") 653 | } 654 | } else if pressureLevel == .shutdown { 655 | print("Session stopped running due to shutdown system pressure level.") 656 | } 657 | } 658 | 659 | /// - Tag: HandleInterruption 660 | @objc 661 | private func sessionWasInterrupted(notification: NSNotification) { 662 | /* 663 | In some scenarios you want to enable the user to resume the session. 664 | For example, if music playback is initiated from Control Center while 665 | using Campus, then the user can let Campus resume 666 | the session running, which will stop music playback. Note that stopping 667 | music playback in Control Center will not automatically resume the session. 668 | Also note that it's not always possible to resume, see `resumeInterruptedSession(_:)`. 669 | */ 670 | DispatchQueue.main.async { 671 | self.isCameraUnavailable = true 672 | } 673 | 674 | if let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?, 675 | let reasonIntegerValue = userInfoValue.integerValue, 676 | let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) { 677 | print("Capture session was interrupted with reason \(reason)") 678 | 679 | if reason == .audioDeviceInUseByAnotherClient || reason == .videoDeviceInUseByAnotherClient { 680 | print("Session stopped running due to video devies in use by another client.") 681 | } else if reason == .videoDeviceNotAvailableWithMultipleForegroundApps { 682 | // Fade-in a label to inform the user that the camera is unavailable. 683 | print("Session stopped running due to video devies is not available with multiple foreground apps.") 684 | } else if reason == .videoDeviceNotAvailableDueToSystemPressure { 685 | print("Session stopped running due to shutdown system pressure level.") 686 | } 687 | } 688 | } 689 | 690 | @objc 691 | private func sessionInterruptionEnded(notification: NSNotification) { 692 | print("Capture session interruption ended") 693 | DispatchQueue.main.async { 694 | self.isCameraUnavailable = false 695 | } 696 | } 697 | } 698 | --------------------------------------------------------------------------------