Add manual rotation button for camera preview

New feature: Rotate preview independent of device orientation
- Rotate button in top control bar (next to Center Stage)
- Cycles through: 0° → 90° → 180° → 270° → 0°
- Yellow highlight when rotation is active
- Icon changes to show current rotation state

Ring light integration:
- Preview background color now matches ring light color
- Letterbox areas (from aspect ratio differences) show ring light
- Placeholder during permission request also uses ring light color

Visual feedback:
- rotate.right (outline) = no rotation
- rotate.right.fill = 90° right
- arrow.up.arrow.down = 180°
- rotate.left.fill = 270° (90° left)

Full accessibility support with labels and hints.
This commit is contained in:
Matt Bruce 2026-01-02 16:06:39 -06:00
parent 6fb3c4e212
commit d93625b2a4
3 changed files with 94 additions and 12 deletions

View File

@ -8,11 +8,15 @@ struct CameraPreview: UIViewRepresentable {
// These properties trigger view updates when they change
var isMirrorFlipped: Bool
var zoomFactor: Double
var manualRotationAngle: CGFloat
var ringLightColor: Color
init(viewModel: CameraViewModel, isMirrorFlipped: Bool, zoomFactor: Double) {
init(viewModel: CameraViewModel, isMirrorFlipped: Bool, zoomFactor: Double, manualRotationAngle: CGFloat = 0, ringLightColor: Color = .white) {
self.viewModel = viewModel
self.isMirrorFlipped = isMirrorFlipped
self.zoomFactor = zoomFactor
self.manualRotationAngle = manualRotationAngle
self.ringLightColor = ringLightColor
}
func makeUIView(context: Context) -> CameraPreviewUIView {
@ -28,6 +32,12 @@ struct CameraPreview: UIViewRepresentable {
}
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {
// Update background color to match ring light
uiView.backgroundColor = UIColor(ringLightColor)
// Update manual rotation
uiView.manualRotationAngle = manualRotationAngle
// Force layout update
uiView.setNeedsLayout()
uiView.layoutIfNeeded()
@ -94,6 +104,9 @@ class CameraPreviewUIView: UIView {
private weak var viewModel: CameraViewModel?
var previewLayer: AVCaptureVideoPreviewLayer?
/// Manual rotation offset from user (0, 90, 180, 270)
var manualRotationAngle: CGFloat = 0
override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
@ -192,25 +205,29 @@ class CameraPreviewUIView: UIView {
lastValidOrientation = deviceOrientation
}
// Calculate rotation angle (in degrees) for the preview layer
// Calculate base rotation angle (in degrees) for the preview layer
// For front camera in portrait: sensor is landscape, so rotate 90°
let rotationAngle: CGFloat
let baseRotationAngle: CGFloat
switch deviceOrientation {
case .portrait:
rotationAngle = 90
baseRotationAngle = 90
case .portraitUpsideDown:
rotationAngle = 270
baseRotationAngle = 270
case .landscapeLeft:
rotationAngle = 180
baseRotationAngle = 180
case .landscapeRight:
rotationAngle = 0
baseRotationAngle = 0
default:
rotationAngle = 90 // Default to portrait
baseRotationAngle = 90 // Default to portrait
}
// Add manual rotation offset and normalize to 0-360
let totalRotation = (baseRotationAngle + manualRotationAngle).truncatingRemainder(dividingBy: 360)
let finalRotation = totalRotation < 0 ? totalRotation + 360 : totalRotation
// Use modern rotation angle API (iOS 17+)
if connection.isVideoRotationAngleSupported(rotationAngle) {
connection.videoRotationAngle = rotationAngle
if connection.isVideoRotationAngleSupported(finalRotation) {
connection.videoRotationAngle = finalRotation
}
}
}

View File

@ -38,8 +38,24 @@ class CameraViewModel: NSObject {
/// Whether Center Stage is currently enabled
var isCenterStageEnabled = false
/// Manual rotation offset (0, 90, 180, 270 degrees)
/// Allows user to rotate preview independent of device orientation
var manualRotationAngle: CGFloat = 0
let settings = SettingsViewModel() // Shared config
// MARK: - Manual Rotation
/// Cycles through rotation angles: 0 90 180 270 0
func cycleManualRotation() {
manualRotationAngle = (manualRotationAngle + 90).truncatingRemainder(dividingBy: 360)
}
/// Resets manual rotation to match device orientation
func resetManualRotation() {
manualRotationAngle = 0
}
// MARK: - Screen Brightness Handling
/// Gets the current screen from any available window scene

View File

@ -115,16 +115,19 @@ struct ContentView: View {
CameraPreview(
viewModel: viewModel,
isMirrorFlipped: viewModel.settings.isMirrorFlipped,
zoomFactor: viewModel.settings.currentZoomFactor
zoomFactor: viewModel.settings.currentZoomFactor,
manualRotationAngle: viewModel.manualRotationAngle,
ringLightColor: viewModel.settings.lightColor
)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.padding(ringSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: viewModel.manualRotationAngle)
}
} else {
// Show placeholder while requesting permission
Rectangle()
.fill(.black)
.fill(viewModel.settings.lightColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.padding(ringSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
@ -176,6 +179,22 @@ struct ContentView: View {
.accessibilityHint(String(localized: "Keeps you centered in frame"))
}
// Rotate preview button
Button {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
viewModel.cycleManualRotation()
}
} label: {
Image(systemName: rotationIconName)
.font(.body)
.foregroundStyle(viewModel.manualRotationAngle != 0 ? .yellow : .white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Rotate preview"))
.accessibilityValue(rotationAccessibilityValue)
.accessibilityHint(String(localized: "Rotates the camera preview 90 degrees"))
Spacer()
// Grid toggle
@ -205,6 +224,36 @@ struct ContentView: View {
}
}
/// Icon name for rotation button based on current rotation
private var rotationIconName: String {
switch viewModel.manualRotationAngle {
case 90:
return "rotate.right.fill"
case 180:
return "arrow.up.arrow.down"
case 270:
return "rotate.left.fill"
default:
return "rotate.right"
}
}
/// Accessibility value for rotation button
private var rotationAccessibilityValue: String {
switch viewModel.manualRotationAngle {
case 0:
return String(localized: "No rotation")
case 90:
return String(localized: "Rotated 90 degrees right")
case 180:
return String(localized: "Rotated 180 degrees")
case 270:
return String(localized: "Rotated 90 degrees left")
default:
return String(localized: "Custom rotation")
}
}
// MARK: - Bottom Control Bar
private var bottomControlBar: some View {