Hi! Currently trying to incorporate an interaction with the camera in my TCA app using AVFoundation. So I've wrapped parts of it in a client that exposes some effects. I'm noticing some unreliable behaviour however. I notice that sometimes the AVCapturePhotoCaptureDelegate
doesn't seem to get called and therefore the app freezing. I'm guessing it's due to the way I've implemented it using TCA anyone with experience in AVFoundation and TCA who can spot my error maybe? Really hoping to find some pointers since I'm a bit stuck. I've based my implementation on what I found here SwiftUI 2.0 Building Custom Camera - Custom Camera View - AVFoundation and then tried to translate that to something TCA compatible :).
Short recap: camera output delegate method doesn't always get fired, not sure why, and this behaviour currently freezes my application.
Code:
I've wrapped all the AVFoundation stuff in a dependency called CameraClient
public struct CameraClient {
public var onAppear: Effect<DelegateEvent, Never>
public var onDisappear: Effect<Never, Never>
public var takePicture: Effect<DelegateEvent, Never>
public var startSession: Effect<Never, Never>
public var stopSession: Effect<Never, Never>
public enum DelegateEvent: Equatable {
case pictureTaken(Data)
case providePreviewLayer(CALayer)
}
}
which has the following live implementation:
extension CameraClient {
private static var session: AVCaptureSession?
private static var output: AVCapturePhotoOutput?
private static var delegate: Delegate?
private static func setUp() -> CALayer {
do {
let session = AVCaptureSession()
Self.session = session
session.beginConfiguration()
let device = AVCaptureDevice.default(.builtInDualCamera, for: .video, position: .back)
let input = try AVCaptureDeviceInput(device: device!)
if session.canAddInput(input) {
session.addInput(input)
}
let output = AVCapturePhotoOutput()
Self.output = output
if session.canAddOutput(output) {
session.addOutput(output)
}
session.commitConfiguration()
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
return previewLayer
} catch {
#warning("Better error handling")
fatalError(error.localizedDescription)
}
}
public static let live = Self(
onAppear: .run { subscriber in
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
let previewLayer = setUp()
subscriber.send(.providePreviewLayer(previewLayer))
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { status in
if status {
let previewLayer = setUp()
subscriber.send(.providePreviewLayer(previewLayer))
} else {
//alert?
}
}
case .restricted:
// alert?
print("restricted access")
case .denied:
// alert
print("access denied")
@unknown default:
// ?
print("unknown value")
}
subscriber.send(completion: .finished)
return AnyCancellable { }
},
onDisappear: .run { subscriber in
Self.delegate = nil
Self.output = nil
Self.session = nil
subscriber.send(completion: .finished)
return AnyCancellable { }
},
takePicture: .run { subscriber in
let delegate = Delegate(subscriber: subscriber)
Self.delegate = delegate
Self.output?.capturePhoto(
with: AVCapturePhotoSettings(),
delegate: delegate
)
return AnyCancellable { }
},
startSession: .run { subscriber in
session?.startRunning()
subscriber.send(completion: .finished)
return AnyCancellable { }
},
stopSession: .run { subscriber in
session?.stopRunning()
subscriber.send(completion: .finished)
return AnyCancellable { }
}
)
private class Delegate: NSObject, AVCapturePhotoCaptureDelegate {
let subscriber: Effect<DelegateEvent, Never>.Subscriber
init(subscriber: Effect<DelegateEvent, Never>.Subscriber) {
self.subscriber = subscriber
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
if let error = error {
print(error)
return
}
print("Pic taken")
guard let imageData = photo.fileDataRepresentation() else {
return
}
subscriber.send(.pictureTaken(imageData))
subscriber.send(completion: .finished)
}
}
}
And this gets implemented in a camera view which contains the following state, actions, environment and reducer:
public struct CameraState: Equatable {
public var isTaken = false
public var picData = Data(count: 0)
public var image: UIImage?
public var previewLayer: CALayer?
public init() { }
}
public enum CameraAction: Equatable {
case onAppear
case onDisappear
case cameraClient(CameraClient.DelegateEvent)
case takePictureButtonTapped
case retakePictureButtonTapped
case saveButtonTapped
}
public struct CameraEnvironment {
public var mainQueue: AnySchedulerOf<DispatchQueue>
public var backgroundQueue: AnySchedulerOf<DispatchQueue>
public var cameraClient: CameraClient
public init(
mainQueue: AnySchedulerOf<DispatchQueue>,
backgroundQueue: AnySchedulerOf<DispatchQueue>,
cameraClient: CameraClient
) {
self.mainQueue = mainQueue
self.backgroundQueue = backgroundQueue
self.cameraClient = cameraClient
}
}
public let cameraReducer = Reducer<CameraState, CameraAction, CameraEnvironment> { state, action, environment in
switch action {
case .onAppear:
return Effect.concatenate(
environment.cameraClient.onAppear,
environment.cameraClient.startSession
.subscribe(on: environment.backgroundQueue)
.receive(on: environment.mainQueue)
.fireAndForget()
).map { .cameraClient($0) }
case .onDisappear:
return environment.cameraClient.onDisappear
.fireAndForget()
case let .cameraClient(.providePreviewLayer(layer)):
state.previewLayer = layer
return .none
case let .cameraClient(.pictureTaken(data)):
state.picData = data
state.isTaken.toggle()
return .none
case .takePictureButtonTapped:
return Effect.concatenate(
environment.cameraClient.takePicture,
environment.cameraClient.stopSession
.subscribe(on: environment.backgroundQueue)
.receive(on: environment.mainQueue)
.fireAndForget()
)
.map { .cameraClient($0) }
case .retakePictureButtonTapped:
state.isTaken.toggle()
return environment.cameraClient.startSession
.subscribe(on: environment.backgroundQueue)
.receive(on: environment.mainQueue)
.fireAndForget()
case .saveButtonTapped:
print("moving to next thing!")
return .none
}
}.debug()
And the CameraView
itself:
public struct CameraView: View {
public let store: Store<CameraState, CameraAction>
public init(store: Store<CameraState, CameraAction>) {
self.store = store
}
public var body: some View {
WithViewStore(self.store) { viewStore in
ZStack {
IfLetStore(
self.store.scope(
state: { $0.previewLayer }
),
then: { store in
CameraPreview(store: store)
.ignoresSafeArea(.all, edges: .all)
},
else: Color.red
.ignoresSafeArea(.all, edges: .all)
)
VStack {
Spacer()
HStack {
if viewStore.isTaken {
Button(action: { viewStore.send(.saveButtonTapped) }) {
Text("Save")
.foregroundColor(.black)
.fontWeight(.semibold)
.padding(.vertical, 10)
.padding(.horizontal, 20)
.background(Color.white)
.clipShape(Capsule())
}
.padding(.leading)
Spacer()
Button(action: { viewStore.send(.retakePictureButtonTapped) }) {
Image(systemName: "arrow.triangle.2.circlepath.camera")
.foregroundColor(.black)
.padding()
.background(Color.white)
.clipShape(Circle())
}
.padding(.trailing, 10)
} else {
Button(action: { viewStore.send(.takePictureButtonTapped) }) {
ShutterButton()
}
}
}.frame(height: 75)
}
}
.onAppear { viewStore.send(.onAppear) }
.onDisappear { viewStore.send(.onDisappear) }
}
}
}
struct CameraPreview: UIViewRepresentable {
private let store: Store<CALayer, CameraAction>
private let viewStore: ViewStore<CALayer, CameraAction>
public init(store: Store<CALayer, CameraAction>) {
self.store = store
self.viewStore = ViewStore(self.store)
}
public func makeUIView(context: Context) -> UIView {
let view = UIView(frame: UIScreen.main.bounds)
let previewLayer = viewStore.state
previewLayer.frame = view.frame
view.layer.addSublayer(previewLayer)
return view
}
public func updateUIView(_ uiView: UIView, context: Context) {
}
}
struct ShutterButton: View {
var body: some View {
ZStack {
Circle()
.fill(Color.white)
.frame(width: 60, height: 60)
Circle()
.stroke(Color.white, lineWidth: 5)
.frame(width: 75, height: 75)
}
}
}
Edit:
I've also made the following test to test some of my reducer logic.
func testTakingPicture() throws {
let caLayer = CALayer()
let mainQueue = DispatchQueue.testScheduler
let backgroundQueue = DispatchQueue.testScheduler
let store = TestStore(
initialState: CameraState(),
reducer: cameraReducer,
environment: CameraEnvironment(
mainQueue: mainQueue.eraseToAnyScheduler(),
backgroundQueue: backgroundQueue.eraseToAnyScheduler(),
cameraClient: CameraClient(
onAppear: .run { subscriber in
subscriber.send(.providePreviewLayer(caLayer))
subscriber.send(completion: .finished)
return AnyCancellable { }
},
onDisappear: .run { subscriber in
subscriber.send(completion: .finished)
return AnyCancellable { }
},
takePicture: .run { subscriber in
subscriber.send(.pictureTaken(Data()))
subscriber.send(completion: .finished)
return AnyCancellable { }
},
startSession: .run { subscriber in
subscriber.send(completion: .finished)
return AnyCancellable { }
},
stopSession: .run { subscriber in
subscriber.send(completion: .finished)
return AnyCancellable { }
}
)
)
)
store.assert(
.send(.onAppear),
.receive(
.cameraClient(.providePreviewLayer(caLayer)),
{ $0.previewLayer = caLayer }
),
.send(.takePictureButtonTapped),
.receive(
.cameraClient(.pictureTaken(Data())),
{
$0.picData = Data()
$0.isTaken = true
}
),
.send(
.retakePictureButtonTapped,
{ $0.isTaken = false }
),
.do { backgroundQueue.advance() },
.do { mainQueue.advance() }
)
}
Edit2: Updated code with what seems to be a solution! :)