当另一个视图从 AVCaptureDevice API 获取新值时,SwiftUI Picker View 的奇怪行为

Weird behaviour of SwiftUI Picker View when the other view getting new value from AVCaptureDevice API

我是 SwiftUI 和 Combine 的新手。我想构建的是一个手动相机应用程序,只有 4 UI 个组件:

还添加了来自用户的隐私请求 into.Info.plist 文件以允许相机功能并保存到 Apple Photo App

为了更新数据并将其传递给 UI,我使用 CameraViewModelcurrentCameraSubjectcurrentCamera Publisher 来显示来自 [=19= 的新值] 并将其设置为 CameraViewModel.

我注意到 FocusPicker 的一个非常有趣的 behavior/bug 当我开始与它互动并获得新的焦点时它会不断回到开始位置并且当 OffsetView 是每次都得到一个新值。

但有趣的是,例如当 OffsetView 具有相同的值时 FocusPicker 正常。我不知道为什么会这样。请帮忙,为我修复真的很令人沮丧。

顺便说一下,它只能在真实设备上运行。

所有代码如下:

import SwiftUI

//@main
//struct WhosebugCamApp: App {
//    var cameraViewModel = CameraViewModel(focusLensPosition: 0)
//    let cameraController: CustomCameraController = CustomCameraController()
//
//    var body: some Scene {
//        WindowGroup {
//            ContentView(cameraViewModel: cameraViewModel, cameraController: cameraController)
//        }
//    }
//}

struct ContentView: View {
    
    @State private var didTapCapture = false
    @ObservedObject var cameraViewModel: CameraViewModel
    let cameraController: CustomCameraController
    
    var body: some View {
        
        VStack {
            ZStack {
                CameraPreviewRepresentable(didTapCapture: $didTapCapture, cameraViewModel: cameraViewModel, cameraController: cameraController)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                
                VStack {
                    FocusPicker(selectedFocus: $cameraViewModel.focusChoice)
                    
                    Text(String(format: "%.2f", cameraViewModel.focusLensPosition))
                        .foregroundColor(.red)
                        .font(.largeTitle)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .edgesIgnoringSafeArea(.all)
            
            Spacer()
            
            OffsetView(levelValue: cameraViewModel.exposureTargetOffset, height: 100)
                .frame(maxWidth: .infinity, alignment: .leading)
            
            CaptureButton(didTapCapture: $didTapCapture)
                .frame(width: 100, height: 100, alignment: .center)
                .padding(.bottom, 20)
        }
    }
}

struct CaptureButton: View {
    @Binding var didTapCapture : Bool
    
    var body: some View {
        Button {
            didTapCapture.toggle()
            
        } label: {
            Image(systemName: "photo")
                .font(.largeTitle)
                .padding(30)
                .background(Color.red)
                .foregroundColor(.white)
                .clipShape(Circle())
                .overlay(
                    Circle()
                        .stroke(Color.red)
                )
        }
    }
}

struct OffsetView: View {
    
    var levelValue: Float
    let height: CGFloat
    
    var body: some View {

        ZStack {
            Rectangle()
                .foregroundColor(.red)
                .frame(maxWidth: height / 2, maxHeight: height, alignment: .trailing)

            Rectangle()
                .foregroundColor(.orange)
                .frame(maxWidth: height / 2, maxHeight: height / 20, alignment: .trailing)
                .offset(x: 0, y: min(CGFloat(-levelValue) * height / 2, height / 2))
        }
    }
}

struct FocusPicker: View {
    
    @Binding var selectedFocus: FocusChoice
    
    var body: some View {
        
        Picker(selection: $selectedFocus, label: Text("")) {
            ForEach(0..<FocusChoice.allCases.count) {
                Text("\(FocusChoice.allCases[[=11=]].caption)")
                    .foregroundColor(.white)
                    .font(.subheadline)
                    .fontWeight(.medium)
                    .tag(FocusChoice.allCases[[=11=]])
            }
            .animation(.none)
            .background(Color.clear)
            .pickerStyle(WheelPickerStyle())
        }
        .frame(width: 60, height: 200)
        .border(Color.gray, width: 5)
        .clipped()
    }
}

import SwiftUI
import Combine
import AVFoundation

struct CameraPreviewRepresentable: UIViewControllerRepresentable {
    
    @Environment(\.presentationMode) var presentationMode
    @Binding var didTapCapture: Bool
    @ObservedObject var cameraViewModel: CameraViewModel
    
    let cameraController: CustomCameraController
    
    func makeUIViewController(context: Context) -> CustomCameraController {
        cameraController.delegate = context.coordinator
        
        return cameraController
    }
    
    func updateUIViewController(_ cameraViewController: CustomCameraController, context: Context) {
        
        if didTapCapture {
            cameraViewController.didTapRecord()
        }
        
        // checking if new value is differnt from the previous value
        if cameraViewModel.focusChoice.rawValue != cameraViewController.manualFocusValue {
            cameraViewController.manualFocusValue = cameraViewModel.focusChoice.rawValue
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self, cameraViewModel: cameraViewModel)
    }
    
    class Coordinator: NSObject, UINavigationControllerDelegate, AVCapturePhotoCaptureDelegate {
        let parent: CameraPreviewRepresentable
        var cameraViewModel: CameraViewModel
        
        var tokens = Set<AnyCancellable>()
        
        init(_ parent: CameraPreviewRepresentable, cameraViewModel: CameraViewModel) {
            self.parent = parent
            self.cameraViewModel = cameraViewModel
            super.init()
            
            // for showing focus lens position
            self.parent.cameraController.currentCamera
                    .filter { [=11=] != nil }
                    .flatMap { [=11=]!.publisher(for: \.lensPosition) }
                    .assign(to: \.focusLensPosition, on: cameraViewModel)
                    .store(in: &tokens)
            
            // for showing exposure offset
            self.parent.cameraController.currentCamera
                .filter { [=11=] != nil }
                .flatMap { [=11=]!.publisher(for: \.exposureTargetOffset) }
                .assign(to: \.exposureTargetOffset, on: cameraViewModel)
                .store(in: &tokens)
        }
        
        func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
            
            parent.didTapCapture = false
            
            if let imageData = photo.fileDataRepresentation(), let image = UIImage(data: imageData) {
                UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
            }
            
            parent.presentationMode.wrappedValue.dismiss()
        }
    }
}

import Combine
import AVFoundation

class CameraViewModel: ObservableObject {
    @Published var focusLensPosition: Float = 0
    @Published var exposureTargetOffset: Float = 0
    
    @Published var focusChoice: FocusChoice = .infinity
    
    private var tokens = Set<AnyCancellable>()

    init(focusLensPosition: Float) {
        self.focusLensPosition = focusLensPosition
    }
}

enum FocusChoice: Float, CaseIterable {
    case infinity = 1
    case ft_30 = 0.95
    case ft_15 = 0.9
    case ft_10 = 0.85
    case ft_7 = 0.8
    case ft_5 = 0.5
    case ft_4 = 0.7
    case ft_3_5 = 0.65
    case ft_3 = 0.6
    case auto = 0
}

extension FocusChoice {
    var caption: String {
        switch self {
        case .infinity: return "∞ft"
        case .ft_30: return "30"
        case .ft_15: return "15"
        case .ft_10: return "10"
        case .ft_7: return "7"
        case .ft_5: return "5"
        case .ft_4: return "4"
        case .ft_3_5: return "3.5"
        case .ft_3: return "3"
        case .auto: return "Auto"
        }
    }
}

import UIKit
import Combine
import AVFoundation

class CustomCameraController: UIViewController {
    
    var image: UIImage?
    
    var captureSession = AVCaptureSession()
    var backCamera: AVCaptureDevice?
    var frontCamera: AVCaptureDevice?
    lazy var currentCamera: AnyPublisher<AVCaptureDevice?, Never> = currentCameraSubject.eraseToAnyPublisher()
    var photoOutput: AVCapturePhotoOutput?
    var cameraPreviewLayer: AVCaptureVideoPreviewLayer?
    private var currentCameraSubject = CurrentValueSubject<AVCaptureDevice?, Never>(nil)
    
    var manualFocusValue: Float = 1 {
        didSet {
            guard manualFocusValue != 0 else {
                setAutoLensPosition()
                return
            }
            setFocusLensPosition(manualValue: manualFocusValue)
        }
    }
    
    //DELEGATE
    var delegate: AVCapturePhotoCaptureDelegate?
    
    func setFocusLensPosition(manualValue: Float) {
        do {
            try currentCameraSubject.value!.lockForConfiguration()
            currentCameraSubject.value!.focusMode = .locked
            currentCameraSubject.value!.setFocusModeLocked(lensPosition: manualValue, completionHandler: nil)
            currentCameraSubject.value!.unlockForConfiguration()
        } catch let error {
            print(error.localizedDescription)
        }
    }
    
    func setAutoLensPosition() {
        do {
            try currentCameraSubject.value!.lockForConfiguration()
            currentCameraSubject.value!.focusMode = .continuousAutoFocus
            currentCameraSubject.value!.unlockForConfiguration()
        } catch let error {
            print(error.localizedDescription)
        }
    }
    
    func didTapRecord() {
        
        let settings = AVCapturePhotoSettings()
        photoOutput?.capturePhoto(with: settings, delegate: delegate!)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
    }
    
    func setup() {
        
        setupCaptureSession()
        setupDevice()
        setupInputOutput()
        setupPreviewLayer()
        startRunningCaptureSession()
    }
    
    func setupCaptureSession() {
        captureSession.sessionPreset = .photo
    }
    
    func setupDevice() {
        let deviceDiscoverySession =
            AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera],
                                                                      mediaType: .video,
                                                                      position: .unspecified)
        for device in deviceDiscoverySession.devices {
            
            switch device.position {
            case .front:
                self.frontCamera = device
            case .back:
                self.backCamera = device
            default:
                break
            }
        }
        
        self.currentCameraSubject.send(self.backCamera)
    }
    
    func setupInputOutput() {
        do {
          let captureDeviceInput = try AVCaptureDeviceInput(device: currentCameraSubject.value!)
          captureSession.addInput(captureDeviceInput)
          photoOutput = AVCapturePhotoOutput()
          captureSession.addOutput(photoOutput!)
        } catch {
          print(error)
        }
         
      }
    
    func setupPreviewLayer() {
        
        self.cameraPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        self.cameraPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
        
        let deviceOrientation = UIDevice.current.orientation
        cameraPreviewLayer?.connection?.videoOrientation = AVCaptureVideoOrientation(rawValue: deviceOrientation.rawValue)!
        
        self.cameraPreviewLayer?.frame = self.view.frame
        self.view.layer.insertSublayer(cameraPreviewLayer!, at: 0)
    }
    
    func startRunningCaptureSession() {
        captureSession.startRunning()
    }
}

您的 ContentView 始终根据已发布的值进行更新。为了解决这个问题,我们首先从 ContentView 中的 ViewModel 中删除声明为 ObservedObject 并像这样声明它:

let cameraViewModel: CameraViewModel

现在我们会得到一些错误。对于 FocusView,只需使用 ProxyBinding。

FocusPicker(selectedFocus: Binding<FocusChoice>(
    get: {
        cameraViewModel.focusChoice
    },
    set: {
        cameraViewModel.focusChoice = [=11=]
    }
))

对于更新的文本,只需创建另一个视图。这里!我们使用 ObservedObject.

struct TextView: View {
    @ObservedObject var cameraViewModel: CameraViewModel
    
    var body: some View {
        Text(String(format: "%.2f", cameraViewModel.focusLensPosition))
            .foregroundColor(.red)
            .font(.largeTitle)
    }
}

OffsetView 也一样。在此处添加 ObservedObject

struct OffsetView: View {
    @ObservedObject var viewModel : CameraViewModel
    
    let height: CGFloat
    
    var body: some View {

        ZStack {
            Rectangle()
                .foregroundColor(.red)
                .frame(maxWidth: height / 2, maxHeight: height, alignment: .trailing)

            Rectangle()
                .foregroundColor(.orange)
                .frame(maxWidth: height / 2, maxHeight: height / 20, alignment: .trailing)
                .offset(x: 0, y: min(CGFloat(-viewModel.exposureTargetOffset) * height / 2, height / 2))
        }
    }
}

ContentView 将如下所示:

struct ContentView: View {
    
    @State private var didTapCapture = false
    let cameraViewModel: CameraViewModel
    let cameraController: CustomCameraController
    
    var body: some View {
        
        VStack {
            ZStack {
                CameraPreviewRepresentable(didTapCapture: $didTapCapture, cameraViewModel: cameraViewModel, cameraController: cameraController)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
                
                VStack {
                    FocusPicker(selectedFocus: Binding<FocusChoice>(
                        get: {
                            cameraViewModel.focusChoice
                        },
                        set: {
                            cameraViewModel.focusChoice = [=14=]
                        }
                    ))
                    
                    TextView(cameraViewModel: cameraViewModel)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .edgesIgnoringSafeArea(.all)
            
            Spacer()
            
            OffsetView(viewModel: cameraViewModel, height: 100)

                .frame(maxWidth: .infinity, alignment: .leading)
            
            CaptureButton(didTapCapture: $didTapCapture)
                .frame(width: 100, height: 100, alignment: .center)
                .padding(.bottom, 20)
        }
    }
}

因此,我们在 ContentView 中不再有任何 ObservedObject,我们的 Picker 工作正常。