Search code examples
iosswiftuinslayoutconstraintswiftui-view

How do we constrain the layout of two Views to have the same position and size in SwiftUI?


I am a mildly experienced UIKit/AppKit developer, but I am just getting started using SwiftUI. In UIKit it is straight forward to constrain two UIView's to have the same size and position using interface builder or programmatically using NSLayoutConstraints. Using SwiftUI's declarative approach should make this easier, but I find myself having to manually determine the frame size and position so I feel I am missing something.

As an example, say I want to create the View hierarchy shown in figure below. The main goal is to create a transparent CanvasView instance that lies over the top of an ImageView whose size and placement has been tailored to display the image with the correct aspect ratio.

enter image description here

Currently I can accomplish this by wrapping the contents of the ZStack inside a GeometryReader and then do the layout math by hand and explicitly setting the frame size and position of both the ImageView and CanvasView

struct WidgetView: View {
    let uiimage = UIImage(named: "cateyes")!
    var body: some View {
        VStack(alignment: .leading) {
            HStack {
                Button("Left", action: {print("do left")})
                Spacer()
                Button("Right", action: {print("do right")})
            }
            .padding(.horizontal, 8)
            ZStack(alignment: .center) {
                GeometryReader { geom in
                    let aspect = uiimage.size.width / uiimage.size.height
                    let w = geom.size.width
                    let h = geom.size.width / aspect
                    let fits = h <= geom.size.height
                    let s = fits ? 1.0 : geom.size.height / h
                    let W = s*w
                    let H = s*h
                    let x0 = (geom.size.width - W)/2
                    ImageView(uiimage: uiimage)
                        .frame(width: W, height: H, alignment: .top)
                        .position(x: x0 + W/2, y: H/2)
                    CanvasView()
                        .frame(width: W, height: H, alignment: .top)
                        .position(x: x0 + W/2, y: H/2)
                }
            }
        }
    }
}

The ImageView simply contains a scaled view of the image that maintains its aspect ratio:

struct ImageView: View {
    var uiimage : UIImage!
    var body: some View {
        Image(uiImage: uiimage)
            .resizable()
            .aspectRatio(contentMode: .fit)
    }
}

The CanvasView in this example merely draws an X from the corners of its view

struct CanvasView: View {
    var body: some View {
        GeometryReader { geometry in
            let w = geometry.size.width
            let h = geometry.size.height
            Path { path in
                path.move(to: CGPoint(x: 0,y: 0))
                path.addLine(to: CGPoint(x: w, y: h))
                path.move(to: CGPoint(x: 0, y: h))
                path.addLine(to: CGPoint(x: w, y: 0))
            }
            .stroke(Color.black, lineWidth: 4)
        }
    }
}

This seems to work in this example, but I should not have to explicitly compute the layout. I am also concerned that the containing ZStack will not resize itself correctly when this is done. Is there a more direct way to constrain two views to have the same frame (and other possible constraints) like we do in UIKit?

enter image description here

You can find the above solution on github.


Solution

  • To pick up on my comments, I would suggest making the CanvasView an .overlay over the ImageView. An overlay automatically adopts the same frame as the view it is applied to (as does a background layer).

    Here is a revised version of your example to show it working. I also changed UIImage to Image and used a Canvas for drawing the X.

    struct WidgetView: View {
        let image = Image("cateyes")
        var body: some View {
            VStack(alignment: .leading) {
                HStack {
                    Button("Left", action: {print("do left")})
                    Spacer()
                    Button("Right", action: {print("do right")})
                }
                .padding(.horizontal, 8)
                ImageView(image: image)
                    .overlay { CanvasView() }
    
                Spacer()
            }
        }
    }
    
    struct ImageView: View {
        let image: Image
        var body: some View {
            image
                .resizable()
                .scaledToFit()
        }
    }
    
    struct CanvasView: View {
        var body: some View {
            Canvas { context, size in
                let w = size.width
                let h = size.height
                var path = Path()
                path.move(to: CGPoint(x: 0, y: 0))
                path.addLine(to: CGPoint(x: w, y: h))
                path.move(to: CGPoint(x: 0, y: h))
                path.addLine(to: CGPoint(x: w, y: 0))
                context.stroke(path, with: .color(.black), style: .init(lineWidth: 4))
            }
        }
    }
    

    Eyes