Fix camera not showing - use MCamera as base with ring overlay

Issue: MCamera wasn't rendering when embedded inside ZStack with
background color in front of it.

Solution: Restructure view hierarchy:
- MCamera is now the base view (not inside a ZStack)
- Ring light effect is now an overlay using mask/blendMode
- Grid, controls, and toast are overlays on top

The ring light overlay uses:
1. Full-screen settings.lightColor
2. A mask with destinationOut blend mode
3. A RoundedRectangle cutout in the center

This allows MCamera to render properly while creating the ring
light border effect around the camera preview.
This commit is contained in:
Matt Bruce 2026-01-02 16:23:35 -06:00
parent b97590f1c1
commit e628897bff
2 changed files with 72 additions and 109 deletions

View File

@ -25,51 +25,49 @@ struct ContentView: View {
let maxRingSize = calculateMaxRingSize(for: geometry)
let effectiveRingSize = min(settings.ringSize, maxRingSize)
ZStack {
// MARK: - Ring Light Background
settings.lightColor
.ignoresSafeArea()
// MARK: - Camera with MijickCamera
MCamera()
.onImageCaptured { image, _ in
handleImageCaptured(image)
// Use MCamera as the base, with ring light as border
MCamera()
.onImageCaptured { image, _ in
handleImageCaptured(image)
}
.onVideoCaptured { url, _ in
handleVideoCaptured(url)
}
.ignoresSafeArea()
.overlay {
// Ring light border overlay
ringLightOverlay(ringSize: effectiveRingSize)
}
.overlay {
// Grid overlay
if settings.isGridVisible {
GridOverlay(isVisible: true)
.padding(effectiveRingSize)
.allowsHitTesting(false)
}
.onVideoCaptured { url, _ in
handleVideoCaptured(url)
}
.overlay {
// Top controls
VStack {
topControlBar
.padding(.top, effectiveRingSize + Design.Spacing.small)
Spacer()
}
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
.padding(effectiveRingSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: effectiveRingSize)
// MARK: - Grid Overlay
if settings.isGridVisible {
GridOverlay(isVisible: true)
.padding(effectiveRingSize)
.allowsHitTesting(false)
.padding(.horizontal, effectiveRingSize + Design.Spacing.small)
}
// MARK: - Top Controls Overlay
VStack {
topControlBar
.padding(.top, effectiveRingSize + Design.Spacing.small)
Spacer()
.overlay {
// Toast
if let message = toastMessage {
toastView(message: message)
}
}
.padding(.horizontal, effectiveRingSize + Design.Spacing.small)
// MARK: - Toast Notification
if let message = toastMessage {
toastView(message: message)
.onChange(of: geometry.size) { _, newSize in
let newMax = min(newSize.width, newSize.height) / 4
if settings.ringSize > newMax {
settings.ringSize = newMax
}
}
}
.onChange(of: geometry.size) { _, newSize in
let newMax = min(newSize.width, newSize.height) / 4
if settings.ringSize > newMax {
settings.ringSize = newMax
}
}
}
.ignoresSafeArea()
.onAppear {
checkCenterStageAvailability()
}
@ -90,6 +88,35 @@ struct ContentView: View {
min(geometry.size.width, geometry.size.height) / 4
}
// MARK: - Ring Light Overlay
/// Creates a ring light effect as a border around the camera
@ViewBuilder
private func ringLightOverlay(ringSize: CGFloat) -> some View {
// Use a rectangle with a large inner cutout to create the ring effect
GeometryReader { geo in
let innerRect = CGRect(
x: ringSize,
y: ringSize,
width: geo.size.width - (ringSize * 2),
height: geo.size.height - (ringSize * 2)
)
settings.lightColor
.mask(
Rectangle()
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.frame(width: innerRect.width, height: innerRect.height)
.blendMode(.destinationOut)
)
.compositingGroup()
)
.allowsHitTesting(false)
}
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
}
// MARK: - Top Control Bar
private var topControlBar: some View {
@ -141,20 +168,16 @@ struct ContentView: View {
// MARK: - Center Stage
/// Checks if Center Stage is available on this device
private func checkCenterStageAvailability() {
// Center Stage requires a compatible device (iPad with A12+ or some iPhones)
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
isCenterStageAvailable = false
return
}
// Check if the device supports Center Stage
isCenterStageAvailable = device.activeFormat.isCenterStageSupported
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
}
/// Toggles Center Stage on/off
private func toggleCenterStage() {
AVCaptureDevice.centerStageControlMode = .app
AVCaptureDevice.isCenterStageEnabled.toggle()

View File

@ -61,22 +61,10 @@
"comment" : "Display name for the \"Boomerang\" capture mode.",
"isCommentAutoGenerated" : true
},
"Camera Access Required" : {
"comment" : "A title displayed when camera access is denied.",
"isCommentAutoGenerated" : true
},
"Cancel" : {
"comment" : "The text for a button that dismisses the current view.",
"isCommentAutoGenerated" : true
},
"Capture boomerang" : {
"comment" : "Label for capturing a boomerang photo.",
"isCommentAutoGenerated" : true
},
"Capture mode: %@" : {
"comment" : "A label describing the currently selected capture mode. The placeholder is replaced with the actual mode name.",
"isCommentAutoGenerated" : true
},
"Captured boomerang" : {
"comment" : "A label describing a captured boomerang.",
"isCommentAutoGenerated" : true
@ -109,10 +97,6 @@
"comment" : "An accessibility label for the custom color button.",
"isCommentAutoGenerated" : true
},
"Custom rotation" : {
"comment" : "Accessibility value for the rotate preview button when the user has customized the rotation angle.",
"isCommentAutoGenerated" : true
},
"Debug mode: Purchase simulated!" : {
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
"isCommentAutoGenerated" : true
@ -168,10 +152,6 @@
"comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true
},
"No rotation" : {
"comment" : "Accessibility value for the rotation button when the preview is not rotated.",
"isCommentAutoGenerated" : true
},
"No Watermarks • Ad-Free" : {
"comment" : "Description of a benefit that comes with the Pro subscription.",
"isCommentAutoGenerated" : true
@ -184,8 +164,8 @@
"comment" : "A label describing a setting that is currently enabled.",
"isCommentAutoGenerated" : true
},
"Open Settings" : {
"comment" : "A button label that opens the device settings when tapped.",
"Open Source Licenses" : {
"comment" : "A heading displayed above a list of open source licenses used in the app.",
"isCommentAutoGenerated" : true
},
"Opens upgrade options" : {
@ -194,14 +174,6 @@
},
"Photo" : {
},
"Photo captured" : {
"comment" : "Accessibility label for a notification that is posted when a photo is captured.",
"isCommentAutoGenerated" : true
},
"Please enable camera access in Settings to use SelfieRingLight." : {
"comment" : "A message instructing the user to enable camera access in Settings to use SelfieRingLight.",
"isCommentAutoGenerated" : true
},
"Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.",
@ -239,26 +211,6 @@
"comment" : "The label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Rotate preview" : {
"comment" : "A button that rotates the camera preview by 90 degrees.",
"isCommentAutoGenerated" : true
},
"Rotated 90 degrees left" : {
"comment" : "Accessibility value for the \"Rotate preview\" button when the camera preview is rotated 90 degrees to the left.",
"isCommentAutoGenerated" : true
},
"Rotated 90 degrees right" : {
"comment" : "Accessibility value for the \"Rotate preview\" button when the camera preview is rotated 90 degrees to the right.",
"isCommentAutoGenerated" : true
},
"Rotated 180 degrees" : {
"comment" : "Accessibility value of the \"Rotate preview\" button when the preview is 180 degrees rotated.",
"isCommentAutoGenerated" : true
},
"Rotates the camera preview 90 degrees" : {
"comment" : "A hint that explains how to rotate the camera preview.",
"isCommentAutoGenerated" : true
},
"Saved to Photos" : {
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"isCommentAutoGenerated" : true
@ -306,14 +258,6 @@
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Start recording" : {
"comment" : "Label for the \"Start recording\" button in the bottom control bar when not recording a video.",
"isCommentAutoGenerated" : true
},
"Stop recording" : {
"comment" : "Label for the button that stops recording a video.",
"isCommentAutoGenerated" : true
},
"Subscribe to %@ for %@" : {
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
"isCommentAutoGenerated" : true,
@ -326,10 +270,6 @@
}
}
},
"Switch camera" : {
"comment" : "A button label that translates to \"Switch camera\".",
"isCommentAutoGenerated" : true
},
"Sync Now" : {
"comment" : "A button label that triggers a sync action.",
"isCommentAutoGenerated" : true
@ -349,8 +289,8 @@
"Syncing..." : {
},
"Take photo" : {
"comment" : "Label for the \"Take photo\" button in the bottom control bar when using the photo capture mode.",
"Third-party libraries used in this app" : {
"comment" : "A description of the third-party libraries used in this app.",
"isCommentAutoGenerated" : true
},
"Toggle grid" : {
@ -385,8 +325,8 @@
"comment" : "Display name for the \"Video\" capture mode.",
"isCommentAutoGenerated" : true
},
"Video saved" : {
"comment" : "Accessibility notification text when a video is successfully saved to the user's photo library.",
"View on GitHub" : {
"comment" : "A button label that says \"View on GitHub\".",
"isCommentAutoGenerated" : true
},
"Warm Amber" : {