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:
parent
6fb3c4e212
commit
d93625b2a4
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user