.sheet or .fullScreenSheet Looping when presenting Image Picker on visionOS

I am having issues with my app on visionOS. It works fine on iOS. The app is presenting a ImagePicker, I had tried converting to PhotoPicker and the behavior did not change.

The relevant code is in the EditGreetingCardView -

//
//  Created by Michael Rowe on 1/2/24.
//

import AVKit
import SwiftData
import SwiftUI

struct EditGreetingCardView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    
    @Query(sort: \EventType.eventName) private var events: [EventType]
    
    var greetingCard: GreetingCard?
    private var editorTitle: String { greetingCard == nil ? "Add Greeting Card" : "Edit Greeting Card" }
    
    @State var frontImageSelected: Image? = Image("frontImage")
    @State var sourceType: UIImagePickerController.SourceType = .photoLibrary
    @State var frontPhoto = false
    @State var captureFrontImage = false
    var eventTypePassed: EventType?
    @State private var eventType: EventType?
    @State private var cardName = ""
    @State private var cardManufacturer = ""
    @State private var cardURL = ""
    @State private var cardUIImage: UIImage?
    @State private var cameraNotAuthorized = false
    @State private var isCameraPresented = false
    @State private var newEvent = false
    
    @AppStorage("walkthrough") var walkthrough = 1
    
    init(eventTypePassed: EventType?) {
        if let eventTypePassed {
            _eventType = .init(initialValue: eventTypePassed)
        }
    }
    
    init(greetingCard: GreetingCard?) {
        self.greetingCard = greetingCard
        _eventType = .init(initialValue: greetingCard?.eventType)
    }
    
    var body: some View {
        NavigationStack {
            Form {
                Section("Occasion") {
                    Picker("Select Occasion", selection: $eventType) {
                        Text("Unknown Occasion")
                            .tag(Optional<EventType>.none) //basically added empty tag and it solve the case
                        
                        if events.isEmpty == false {
                            Divider()
                            
                            ForEach(events) { event in
                                Text(event.eventName)
                                    .tag(Optional(event))
                            }
                        }
                    }
                }
                .foregroundColor(Color("AccentColor"))

                Section("Card details") {
                }
                .foregroundColor(Color("AccentColor"))
                
                Section("Card Image") {
                    HStack(alignment: .center){
                        Spacer()
                        ZStack {
                            Image(uiImage: cardUIImage ?? UIImage(named: "frontImage")!)
                                .resizable()
                                .aspectRatio(contentMode: .fit)
                                .shadow(radius: 10 )
                            Image(systemName: "camera.fill")
                                .foregroundColor(.white)
                                .font(.largeTitle)
                                .shadow(radius: 10)
                                .frame(width: 200)
                                .onTapGesture { self.frontPhoto = true }
                                .actionSheet(isPresented: $frontPhoto) { () -> ActionSheet in
#if !os(visionOS)
                                    ActionSheet(
                                        title: Text("Choose mode"),
                                        message: Text("Select one."),
                                        buttons: [
                                            ActionSheet.Button.default(Text("Camera"), action: {
                                                checkCameraAuthorization()
                                                self.captureFrontImage.toggle()
                                                self.sourceType = .camera
                                            }),
                                            
                                            ActionSheet.Button.default(Text("Photo Library"), action: {
                                                self.captureFrontImage.toggle()
                                                self.sourceType = .photoLibrary
                                            }),
                                            
                                            ActionSheet.Button.cancel()
                                        ]
                                    )
#else
                                    ActionSheet(
                                        title: Text("Choose mode"),
                                        message: Text("Select one."),
                                        buttons: [
                                            ActionSheet.Button.default(Text("Photo Library"), action: {
                                                self.captureFrontImage.toggle()
                                                self.sourceType = .photoLibrary }),
                                            ActionSheet.Button.cancel()
                                        ]
                                    )
#endif
                                }
                                .fullScreenCover(isPresented: $captureFrontImage) {
                                    #if !os(visionOS)
                                    ImagePicker(
                                        sourceType: sourceType,
                                        image: $frontImageSelected)
                                    .interactiveDismissDisabled(true)
                                    #else
                                    ImagePicker(
                                        image: $frontImageSelected)
                                    .interactiveDismissDisabled(true)
                                    #endif
                                }
                            
                        }
                        .frame(width: 250, height: 250)
                        Spacer()
                    }
                }
            }
            .alert(isPresented: $cameraNotAuthorized) {
                Alert(
                    title: Text("Unable to access the Camera"),
                    message: Text("To enable access, go to Settings > Privacy > Camera and turn on Camera access for this app."),
                    primaryButton: .default(Text("Settings")) {
                        openSettings()
                    }
                    ,
                    secondaryButton: .cancel()
                )
            }
            
            .toolbar {
            }
            
            .onAppear {
            }
            .onChange(of: frontImageSelected) { oldValue, newValue in
                cardUIImage = newValue?.asUIImage()
            }
        }
    }
}

Hi Michael, without being able to run the full app, of course I can only speculate. But I can see some patterns that are known to cause infinite loops in view constructions like these.

  • Passing in values to try to initialize @State values. Almost always, @State should be private and not initialized in the intializer. That can be the cause of true/false fluctuation like this. Try making all your @State variables private or passing them in as bindings instead (owned by the parent view)

  • Capturing self i.e. the EditGreetingCardView (or self's instance variables which implicitly capture self) in ViewBuilders to actionSheet can cause more view invalidation than necessary. Try factoring these out into separate views and passing the binding in directly. Like so:

import SwiftUI

struct ContentView: View {
    @State private var presented = false
    @State private var otherBinding = 3
    var body: some View {
        Color.green
            .sheet(isPresented: $presented) { [$otherBinding] in
                SheetView(count: $otherBinding)
            }
    }
}

struct SheetView: View {
    @Binding var count: Int
    var body: some View {
        Text("\(count)")
    }
}

This can prevent the view builder provided to sheet from invalidating when EditGreetingCardView invalidates.

If you adopt this and none of these approaches work, then a reduced/runnable example in a Feedback might help us diagnose this. As is, I can't say for certain if this is or isn't a platform bug.

Could it be an issue with the ImagePicker?

import SwiftUI
import PhotosUI

struct ImagePicker: UIViewControllerRepresentable {
    @Binding var image: Image?

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIViewController(context: Context) -> PHPickerViewController {
        var config = PHPickerConfiguration()
        config.filter = .images // Only allow image selection
        config.selectionLimit = 1 // Allow only one image

        let picker = PHPickerViewController(configuration: config)
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
        // No updates needed
    }

    class Coordinator: NSObject, PHPickerViewControllerDelegate {
        var parent: ImagePicker

        init(_ parent: ImagePicker) {
            self.parent = parent
        }

        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            picker.dismiss(animated: true)

            guard let provider = results.first?.itemProvider else { return }
            if provider.canLoadObject(ofClass: UIImage.self) {
                provider.loadObject(ofClass: UIImage.self) { image, _ in
                    DispatchQueue.main.async { [self] in
                        if let uiImage = image as? UIImage {
                            parent.image = Image(uiImage: uiImage)
                        }
                    }
                }
            }
        }
    }
}

As it is the view that loops from the fullScreenCover

.sheet or .fullScreenSheet Looping when presenting Image Picker on visionOS
 
 
Q