I have the following SwiftUI code where I wish to present a 16:9 camera preview in landscape mode & 9:16 preview in portrait mode. I also want a custom alignment for the subviews as follows:
The camera preview (prototyped as Color.blue
) in the code below to be aligned to the leading edge of safe area insets (+/- few points if I desire).
The VStack/HStack
overlay frame to align and match with Camera preview.
Need inputs on how to fix the code below to proceed in this direction. I tried to fetch the size of the two views using onGeometryChange
but I seem to be getting incorrect size for the top (Color.clear
) view it seems. Returned size is transposed.
struct CameraUI: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State var cameraViewSize:CGSize = CGSize.zero
@State var viewSize:CGSize = CGSize.zero
var body: some View {
Color.clear
.ignoresSafeArea()
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newValue in
viewSize = newValue
}
.background {
Color.blue
.ignoresSafeArea()
.aspectRatio(verticalSizeClass == .regular ? 9.0/16.0 : 16.0/9.0, contentMode: .fit)
.offset(x:verticalSizeClass == .compact ? -(viewSize.width - cameraViewSize.width)/2 : 0)
//Need to inset with leading edge of Safe Area
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { newValue in
cameraViewSize = newValue
print("View size \(viewSize.width), Camera View size \(cameraViewSize.width)")
}
}
.persistentSystemOverlays(.hidden)
.overlay {
/* Need to have this VStack/HStack aligned with the Color.blue view */
VStack {
Spacer()
HStack {
Button("Button1") {
}
Spacer()
Button("Button2") {
}
}
}
}
}
}
#Preview {
CameraUI()
}
You don't need any GeometryReaders to achieve this layout. You can do it simply with some overlays and orientation-based stacks.
For the buttons to be aligned with the blue camera preview, they just need to be in the same stack. The only difference is whether it's an HStack
or a VStack
based on orientation.
I kept it simple by checking the verticalSizeClass
, but it could also be done using ViewThatFits
maybe, although it may become unnecessarily complicated.
if isLandscapeOrientation {
HStack(spacing: 0) {
//Camera preview
cameraPreview
//Controls
CameraUIControls()
}
}
else {
VStack(spacing: 0) {
//Camera preview
cameraPreview
//Controls
CameraUIControls()
}
}
The buttons are also similarly arranged in a horizontal or vertical stack based on orientation. If needed, their container could be a ScrollView
to accommodate more buttons. (UPDATE: the code below now reflects this).
Depending on the orientation, you need to ignore the appropriate safe areas, so the camera preview can fill the space nicely:
.ignoresSafeArea(.container, edges: isLandscapeOrientation ? [.leading, .vertical] : [.top])
Additional elements can be added over the blue preview as overlays (see the grid lines and the close button as examples in the code below).
import SwiftUI
struct CameraUIRootView: View {
//State values
@State private var showCameraPreview = true
//Body
var body: some View {
VStack {
Button {
withAnimation {
showCameraPreview.toggle()
}
} label: {
Label("Take picture", systemImage: "camera.fill")
}
.buttonStyle(.borderedProminent)
}
.fullScreenCover(isPresented: $showCameraPreview) {
CameraUIPreview()
}
}
}
struct CameraUIPreview: View {
//Environment values
@Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.dismiss) var dismiss
//Helper computed property for detecting landscape orientation
private var isLandscapeOrientation: Bool {
verticalSizeClass == .compact
}
//State values
@State private var showGridLines = false
//Body
var body: some View {
Group {
if isLandscapeOrientation {
HStack(spacing: 0) {
//Camera preview
cameraPreview
//Controls
CameraUIControls()
}
}
else {
VStack(spacing: 0) {
//Camera preview
cameraPreview
//Controls
CameraUIControls()
}
}
}
.ignoresSafeArea(.container, edges: isLandscapeOrientation ? [.leading, .vertical] : [.top]) // <- Optional: use [.bottom] if you don't want to push the preview into the top safe area or leave blank [], which may cause issues with respecing the aspect ratio depending on device
.persistentSystemOverlays(.hidden)
.statusBarHidden()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isLandscapeOrientation ? .leading : .top)
}
private var cameraPreview: some View {
Color.blue
.aspectRatio(isLandscapeOrientation ? 16.0/9.0 : 9.0/16.0, contentMode: .fit)
//Close preview button
.overlay(alignment: .topTrailing) {
Button {
withAnimation {
// showCameraPreview.toggle()
dismiss()
}
} label: {
Text("Close")
}
.tint(.white)
.padding(30)
}
// Shutter button
.overlay(alignment: isLandscapeOrientation ? .trailing : .bottom) {
Button {
showGridLines.toggle()
} label: {
Image(systemName: "camera")
.imageScale(.large)
.padding()
}
.tint(.white)
.frame(width: 80, height: 80)
.background(.white.gradient.opacity(0.4), in: Circle())
.padding()
}
//Gridlines overlay
.overlay {
CameraUIGridLines()
}
}
}
struct CameraUIControls: View {
//Environment values
@Environment(\.verticalSizeClass) var verticalSizeClass
//Helper computed property for detecting landscape orientation
private var isLandscapeOrientation: Bool {
verticalSizeClass == .compact
}
//Body
var body: some View {
Group {
if isLandscapeOrientation {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
controls
}
}
}
else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
controls
}
}
}
}
.contentMargins(16)
// .padding()
}
@ViewBuilder
private var controls: some View {
Group {
//Flash button
Button {
//...
} label: {
Image(systemName: "bolt.fill")
.padding()
}
// Spacer()
//Gridlines button
Button {
//...
} label: {
Image(systemName: "grid")
.padding()
}
// Spacer()
//Macro mode button
Button {
//...
} label: {
Image(systemName: "camera.macro")
.padding()
}
// Spacer()
//Metering mode button
Button {
//...
} label: {
Image(systemName: "camera.metering.center.weighted")
.padding()
}
// Spacer()
//Flip camera button
Button {
//...
} label: {
Image(systemName: "camera.rotate")
.padding()
}
}
.background(.gray.gradient.opacity(0.2), in: Circle())
.tint(.primary)
.containerRelativeFrame(isLandscapeOrientation ? .vertical : .horizontal, count: 5, spacing: 0)
}
}
struct CameraUIGridLines: View {
//Body
var body: some View {
ZStack {
HStack {
gridLines
}
VStack {
gridLines
}
}
}
private var gridLines: some View {
Group {
Spacer()
divider
Spacer()
divider
Spacer()
}
}
private var divider: some View {
Divider()
//Divider color
.overlay {
Color.white
}
}
}
//Preview
#Preview("Root view") {
CameraUIRootView()
}
#Preview("Camera preview") {
CameraUIPreview()
}
#Preview("Controls") {
CameraUIControls()
}
UPDATE:
If you use a Landscape Right orientation (where the FaceID sensor area is on the left) and you want to respect the leading/left safe area, change .leading
to .trailing
in .ignoresSafeArea()
of CameraUIPreview
:
// ... in CameraUIPreview
.ignoresSafeArea(.container, edges: isLandscapeOrientation ? [.vertical, .trailing] : [.top])
This allows the buttons to the right of the preview to push into the available trailing safe area if needed, without having to change contentMode
to .fill
.
Changed also the divider in CameraUIGridLines
to use an .overlay{}
instead of .background()
, with the curly brackets initializer that respects the (already set) safe areas:
private var divider: some View {
Divider()
//Divider color
.overlay {
Color.white
}
}