Search code examples
iosswiftui

SwiftUI align custom subviews


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:

  1. 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).

  2. 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()
}


Solution

  • 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).

    Complete code:

    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()
    }
    

    enter image description here

    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.

    enter image description here

    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
                }
    }