I was doing an app which had several "camera" buttons each one dedicated to taking/storing/reviewing/deleting an image associated with a variable URL but what should have been a simple no brainer turned out to be a programming nightmare.
To cut a long story short there is a bug in the sheet handling wherebye even tho you have separate instance for each button the camera/picker cylcles sequentially thru the stack of instances for any action finally always placing the image in the first URL. Working with myself debugging, all major AIs (Grok, Claude, Gemini and Perplexity) after 4 x 12hr+ days we finally managed to crack a solution. What follows is Groks interpretation (note it misses the earlier problem of instance cycling!!) ...
You can follow the discussion here: https://x.com/i/grok/share/KHeaUPladURmbFq5qy9W506er
but be warned its long a detailed but if you are having problems then read ...
**Bug Report: Race Conditions with UIImagePickerController in SwiftUI Sheet **
Environment:
- SwiftUI, iOS 17.7.5
- Device: iPad Pro (12.9-inch, 2nd generation)
- Xcode Version: [Insert your Xcode version]
- Date: March 30, 2025
**Issue 1: Multiple Instances of UIImagePickerController Spawned After Dismissal ** Description:
When using a UIImagePickerController wrapped in a UIViewControllerRepresentable and presented via a SwiftUI .sheet, selecting "Use Photo" resulted in multiple unintended instances of the picker being initialized and presented. The console logs showed repeated "Camera initialized" and "Camera sheet appeared" messages (e.g., multiple <UIImagePickerController: 0x...> instances) after the initial dismissal, despite the sheet being dismissed programmatically.
Reproduction Steps:
- Create a SwiftUI view with a button that sets a @State variable showCamera to true.
- Present a UIImagePickerController via .sheet(isPresented: $showCamera).
- Update a @Binding variable (e.g., photoLocation: URL?) in imagePickerController(_:didFinishPickingMediaWithInfo:) after saving the image.
- Dismiss the picker with picker.dismiss(animated: true) and presentationMode.wrappedValue.dismiss().
- Observe that updating the @Binding variable triggers a view re-render, causing the .sheet to re-present multiple times before finally staying dismissed.
Root Cause: A race condition occurred between the view update (triggered by changing photoLocation) and the dismissal of the picker. During the re-render, showCamera remained true momentarily, causing the .sheet modifier to re-evaluate and spawn new picker instances before the onDismiss closure could reset showCamera to false.
The fix involved delaying the @Binding update (photoLocation) until after the picker and sheet were fully dismissed, ensuring showCamera was reset to false before the view re-rendered:
-
Introduced an onPhotoPicked: (URL) -> Void closure to decouple the photoLocation update from the dismissal timing.
-
Modified the coordinator to call onPhotoPicked and reset showCamera before initiating dismissal:swift
Issue 2: Single Unintended Picker Reopen After Initial Fix Description:
After addressing the multiple-instance issue, a single unintended reopen of the picker persisted. The logs showed one additional "Camera initialized" and "Camera sheet appeared" after "Use Photo," before the final dismissal.
Reproduction Steps:
Reproduction Steps:
Use the initial fix with onPhotoPicked and delayed photoLocation update. Take a photo and select "Use Photo." Observe one extra picker instance appearing briefly before dismissal completes.
Root Cause: The @Binding update (photoLocation) was still occurring too early in the dismissal sequence. Although delayed until after picker.dismiss, the view re-render happened while showCamera was still true during the dismissal animation, causing the .sheet to re-present once before onDismiss reset showCamera.
Resolution: The fix ensured showCamera was set to false before the picker dismissal animation began, preventing the .sheet from re-evaluating during the transition: Moved the dismissCamera() call (which sets showCamera to false) into the onPhotoPicked callback, executed before picker.dismiss:
CameraView( photoLocation: $photoLocation, storeDirectory: storeDirectory, onPhotoPicked: { url in print("Photo picked callback for \(id), setting photoLocation: \(url)") self.photoLocation = url self.cameraState.dismissCamera() // Sets showCamera to false first } )
Kept the dismissal sequence in the coordinator:
DispatchQueue.main.async { self.parent.onPhotoPicked(fileURL) picker.dismiss(animated: true) { self.parent.presentationMode.wrappedValue.dismiss() } }
This synchronized the state change with the dismissal, ensuring showCamera was false before the view re-rendered, eliminating the single reopen.
Request:
Could the SwiftUI team clarify if this behavior is expected, or consider improving the .sheet modifier to better handle state transitions during UIKit controller dismissal? A more robust bridge between SwiftUI’s declarative state and UIKit’s imperative lifecycle could prevent such race conditions.