Search code examples
iosswiftswiftuiuiviewuikit

How to correctly update `frame` in relation to `updateUIViewController`


I'm using this camera library in which the author wraps a AVCaptureVideoPreviewLayer inside a UIViewControllerRepresentable (link) to use it as a camera preview. Inside updateUIViewController, the author queues the frame update with DispatchQueue.main.async:

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        previewLayer.videoGravity = gravity
        if previewLayer.superlayer == nil {
            uiViewController.view.layer.addSublayer(previewLayer)
        }
        // print(uiViewController.view.bounds) -- point A
        DispatchQueue.main.async {
            // print(uiViewController.view.bounds) -- point B
            self.previewLayer.frame = uiViewController.view.bounds
        }
    }

There can be discrepancies between point A and point B, which makes sense since using DispatchQueue.main.async won't guarantee ordering, but I'm not entirely convinced this approach is correct. I want to be able to set the frame of this view using something like Preview().frame(width: 350, height: 500). However, the lack of ordering here means that depending on other parts of the app/views, the view.frame update happens at an unknown schedule. For example, the following:

import SwiftUI
import Aespa

@Observable
class ViewModel {
    @ObservationIgnored var aespaSession = Aespa.session(with: AespaOption(albumName: nil))
    @ObservationIgnored var preview: InteractivePreview {
        aespaSession.interactivePreview(
            gravity: .resizeAspectFill,
            option: InteractivePreviewOption()
        )
    }
    var show = false
}

struct ContentView: View {
    @State var viewModel = ViewModel()

    var body: some View {
        ZStack {
            VStack {
                Spacer()
                Button("Show Camera Preview") {
                    viewModel.show.toggle()
                }
            }
            .ignoresSafeArea()
            .zIndex(0)

            if viewModel.show {
                VStack {
                    viewModel.preview
                        .frame(width: 350, height: 500)
                    Spacer()
                }
                .zIndex(1)
            }
        }
    }
}

Prints:

Point A: (0.0, 0.0, 393.0, 852.0)
Point B: (0.0, 0.0, 350.0, 500.0)

But if you change the code to something else, perhaps a different background at .zIndex(0) (I won't show the exact code here since my app is non-trivial), it might print:

Point A: (0.0, 0.0, 393.0, 852.0)
Point B: (0.0, 0.0, 393.0, 852.0)

So my question is. What is the correct way to set view.frame? updateUIViewController is obviously being called in some deterministic order but not before the bound have "settled". I don't see a magical "updateViewOneLastTime" method?


Solution

  • It sounds like you are looking for viewDidLayoutSubviews, which is called whenever the view controller's view.bounds changes. Create a UIViewController subclass and override it.

    class PreviewViewController: UIViewController {
        var previewLayer: AVCaptureVideoPreviewLayer?
        
        override func viewDidLayoutSubviews() {
            previewLayer?.frame = view.frame
        }
    }
    

    Then change the methods in the UIViewControllerRepresentable implementation to use this subclass. Pass the preview layer to the view controller subclass where appropriate.

    func makeUIViewController(context: Context) -> PreviewViewController {
        let viewController = PreviewViewController()
        viewController.view.backgroundColor = .clear
        uiViewController.previewLayer = previewLayer
        
        return viewController
    }
    
    func updateUIViewController(_ uiViewController: PreviewViewController, context: Context) {
        previewLayer.videoGravity = gravity
        if previewLayer.superlayer == nil {
            uiViewController.view.layer.addSublayer(previewLayer)
        }
        uiViewController.previewLayer = previewLayer
    }
    
    func dismantleUIViewController(_ uiViewController: PreviewViewController, coordinator: ()) {
        previewLayer.removeFromSuperlayer()
    }