Approach For ERD App (Displaying Connected Boxes)

I want to make an ERD (entity relation diagram) iPad app but am really struggling with the positioning and feel like I’m taking the wrong approach.

Apple makes it so awkward to manually position elements as it doesn’t change the frame and all the calculations are off that I’m convinced I’m doing this wrong.

I created two custom Views, one called the EntityView for displaying an entity box containing child attribute boxes, and the other being RelationView which draws a CGPath between two CGPoints which I’ll use to draw a line between two entities.

I’m trying to achieve something that looks like this:

But due to positioning issues am currently seeing this:

I’ll post my code below. Please let me know if I’m using a wrong approach and if there’s anything I can do to make this work.

ContentView (Displays the two EntityView and connecting RelationView on main app page

struct ContentView: View {
    @State var entities = [EntityView(), EntityView()]
    var body: some View {
        ScrollView {
            ZStack {
                entities[0]
                    .position(x: entities[0].width, y: entities[0].height)
                
                entities[1]
                    .position(x: 600, y: 700)
                
                RelationView(startPoint: CGPoint(x: entities[0].width, y: entities[0].height), endPoint: CGPoint(x: 600, y: 700))
                    .position(x:entities[0].width, y: entities[0].height)
            }
        }
    }
}

EntityView (Displays a hard-coded entity with 3 attributes)

struct EntityView: View, Identifiable {
    let id = UUID()
    let blueColor = Color(red: 0/255, green: 48/255, blue: 87/255)
    let entityTitle = "Customer"
    let attributes = ["Attribute 1", "Attribute 2", "Attribute 3"]
    let width:Double
    let height:Double
    
    init() {
        let widestString = getWidthOfWidestString(stringArray: attributes + [entityTitle])
        self.width = max(widestString, 200)
        self.height = Double(40 + attributes.count * 30 + 6)
    }
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 6)
                .frame(width: (width) + 2, height: CGFloat(self.height))
                .foregroundColor(blueColor)
            
            RoundedRectangle(cornerRadius: 5)
                .frame(width: width, height: CGFloat(40 + attributes.count * 30))
                .foregroundColor(.white)
            
            VStack(spacing: 0) {
                // MARK: Entity
                ZStack {
                    Rectangle()
                        .fill(blueColor)
                    Text(entityTitle)
                        .foregroundColor(.white)
                }
                .frame(height: 40)
                
                // MARK: Attributes
                ForEach(attributes, id:\.self) { attribute in
                    ZStack {
                        Rectangle()
                            .foregroundColor(Color(uiColor: .lightGray))
                        Text(attribute)
                            .foregroundColor(.white)
                    }
                    .frame(height: 30)
                }
                .padding(.top, 1)
            }
            .frame(width: width)
            .cornerRadius(5)
        }
    }
}

RelationView (Displays CGPath between two points)

struct RelationView: View {
    let startPoint:CGPoint
    let endPoint:CGPoint
    
    var body: some View {
        let minSize = getMinSize(startPoint: startPoint, endPoint: endPoint)
        let relativeStartPoint = CGPoint(x: startPoint.x - minSize.width, y: startPoint.y - minSize.height)
        let relativeEndPoint = CGPoint(x: endPoint.x - minSize.width, y: endPoint.y - minSize.height)
        
        Path({ path in
            path.addLines([
                relativeStartPoint,
                CGPoint(x: (relativeStartPoint.x + relativeEndPoint.x) / 2, y: relativeStartPoint.y),
                CGPoint(x: (relativeStartPoint.x + relativeEndPoint.x) / 2, y: relativeEndPoint.y),
                relativeEndPoint
            ])
        })
            .stroke()
            .frame(width: abs(endPoint.x - startPoint.x), height: abs(endPoint.y - startPoint.y))
    }
}

func getMinSize(startPoint:CGPoint, endPoint:CGPoint) -> CGSize {
    return CGSize(width: min(startPoint.x, endPoint.x), height: min(startPoint.y, endPoint.y))
}