最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

ios - SwiftUI align custom subviews - Stack Overflow

programmeradmin2浏览0评论

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 == pact ? -(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()
}

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 == pact ? -(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()
}

Share Improve this question edited Mar 10 at 21:32 HangarRash 15.1k5 gold badges20 silver badges55 bronze badges asked Mar 6 at 9:34 Deepak SharmaDeepak Sharma 6,68110 gold badges67 silver badges162 bronze badges 6
  • Why did you add the VStack as an overlay of Color.clear instead to Color.blue? It would make a lot more sense if it were an overlay of Color.blue. – Sweeper Commented Mar 6 at 9:48
  • Well, I wanted the two views (overlay view which contains all buttons and controls, and the camera preview) to be decoupled. I can add all controls and gestures on the overlay view and the camera preview can be separate. Further, some controls can extend beyond the camera preview so adding them to Color.clear may make sense. – Deepak Sharma Commented Mar 6 at 13:52
  • 1 From a code maintenance point of view, they are decoupled either way. You can have totally separate CameraPreview and CameraControls views that does not depend on each other. How they are structured in CameraUI is irrelevant to how coupled they are. – Sweeper Commented Mar 6 at 13:57
  • "Some controls can extend beyond the camera preview" so in reality you don't necessarily need a VStack and HStack? In that case I would put each camera control as a separate background on the Color.clear with custom alignment:s. – Sweeper Commented Mar 6 at 14:02
  • 1 @DeepakSharma Are the buttons supposed to appear on top of the blue camera preview or below? And in landscape mode, are they supposed to be on top of the blue preview or to the right of the blue preview and vertical (so Button 1 on top, Button 2 bellow)? – Andrei G. Commented Mar 6 at 19:07
 |  Show 1 more comment

1 Answer 1

Reset to default 1

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 == pact
    }
    
    //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()
                }
            }
        }
        .persistentSystemOverlays(.hidden)
        .statusBarHidden()
        .frame(maxWidth: .infinity, maxHeight: .infinity,  alignment: isLandscapeOrientation ? .leading : .top)
                .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

    }
    
    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 == pact
    }
    
    //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
            }
}

UPDATE 2:

Moved the .ignoresSafeAreas() in CameraUIPreview to be the last modifier after the .frame modifier, for better compatibility with older devices and smaller screen sizes.

发布评论

评论列表(0)

  1. 暂无评论