swiftuiios16swiftui-layout

Multiple Layouts in SwiftUI ScrollView overlap


I've implemented a left aligned flow layout using the new iOS 16 Layout protocol, and I'm using it to add two lists of items to a ScrollView as follows:

let people = ["Albert", "Bernard", "Clarence", "Desmond", "Ethelbert", "Frederick", "Graeme", "Hortense", "Inigo"]
let places = ["Adelaide", "Birmingham", "Chester", "Dar es Salaam", "East Lothian"]

struct ContentView: View {
    
    var body: some View {
        ScrollView(.vertical) {
            LeftAlignedFlowLayout {
                ForEach(people, id: \.self) { name in
                    NameView(name: name, colour: .red)
                }
            }
            LeftAlignedFlowLayout {
                ForEach(places, id: \.self) { name in
                    NameView(name: name, colour: .green)
                }
            }
        }
        .padding()
    }
}


struct NameView: View {
    let name: String
    let colour: Color
    
    var body: some View {
        Text(name)
            .font(.body)
            .padding(.vertical, 6)
            .padding(.horizontal, 12)
            .background(Capsule().fill(colour))
            .foregroundColor(.black)
    }
}

struct LeftAlignedFlowLayout: Layout {
    
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let height = calculateRects(width: proposal.width ?? 0, subviews: subviews).last?.maxY ?? 0
        return CGSize(width: proposal.width ?? 0, height: height)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        
        calculateRects(width: bounds.width, subviews: subviews).enumerated().forEach { index, rect in
            let sizeProposal = ProposedViewSize(rect.size)
            subviews[index].place(at: rect.origin, proposal: sizeProposal)
        }
    }
    
    func calculateRects(width: CGFloat, subviews: Subviews) -> [CGRect] {
        
        var nextPosition = CGPoint.zero
        return subviews.indices.map { index in
            
            let size = subviews[index].sizeThatFits(.unspecified)
            
            var nextHSpacing: CGFloat = 0
            var previousVSpacing: CGFloat = 0
            
            if index > subviews.startIndex {
                let previousIndex = index.advanced(by: -1)
                previousVSpacing = subviews[previousIndex].spacing.distance(to: subviews[index].spacing, along: .vertical)
            }
            
            if index < subviews.endIndex.advanced(by: -1) {
                let nextIndex = index.advanced(by: 1)
                nextHSpacing = subviews[index].spacing.distance(to: subviews[nextIndex].spacing, along: .horizontal)
            }
            
            if nextPosition.x + nextHSpacing + size.width > width {
                nextPosition.x = 0
                nextPosition.y += size.height + previousVSpacing
            }
            
            let thisPosition = nextPosition
            print(thisPosition)
            nextPosition.x += nextHSpacing + size.width
            return CGRect(origin: thisPosition, size: size)
        }
    }
}

The LeftAlignedFlowLayout works as expected, returning the correct heights and positioning the subviews correctly, but the two layouts are overlapping:

enter image description here

I've tried embedding the two LeftAlignedFlowLayout in a VStack, with the same result.

If I add another View between the two layouts, e.g.

LeftAlignedFlowLayout {
...           
}
Text("Hello")
LeftAlignedFlowLayout {
...
}

I get the following result:

enter image description here

which seems to show that the correct size is being returned for the layout.

Any thoughts as to how to resolve this issue?


Solution

  • Your calculateRects() is always starting the layout at CGPoint.zero when it should be starting at bounds.origin. Since calculateRects() doesn't have access to the bounds, pass the desired starting origin as an additional parameter to calculateRects(). In sizeThatFits(), just pass CGPoint.zero as the origin, and in placeSubviews(), pass bounds.origin as the origin:

    struct LeftAlignedFlowLayout: Layout {
        
        func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
            let height = calculateRects(origin: CGPoint.zero, width: proposal.width ?? 0, subviews: subviews).last?.maxY ?? 0
            return CGSize(width: proposal.width ?? 0, height: height)
        }
        
        func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
            
            calculateRects(origin: bounds.origin, width: bounds.width, subviews: subviews).enumerated().forEach { index, rect in
                let sizeProposal = ProposedViewSize(rect.size)
                subviews[index].place(at: rect.origin, proposal: sizeProposal)
            }
        }
        
        func calculateRects(origin: CGPoint, width: CGFloat, subviews: Subviews) -> [CGRect] {
            
            var nextPosition = origin // was CGPoint.zero
            return subviews.indices.map { index in
                
                let size = subviews[index].sizeThatFits(.unspecified)
                
                var nextHSpacing: CGFloat = 0
                var previousVSpacing: CGFloat = 0
                
                if index > subviews.startIndex {
                    let previousIndex = index.advanced(by: -1)
                    previousVSpacing = subviews[previousIndex].spacing.distance(to: subviews[index].spacing, along: .vertical)
                }
                
                if index < subviews.endIndex.advanced(by: -1) {
                    let nextIndex = index.advanced(by: 1)
                    nextHSpacing = subviews[index].spacing.distance(to: subviews[nextIndex].spacing, along: .horizontal)
                }
                
                if nextPosition.x + nextHSpacing + size.width > width {
                    nextPosition.x = 0
                    nextPosition.y += size.height + previousVSpacing
                }
                
                let thisPosition = nextPosition
                print(thisPosition)
                nextPosition.x += nextHSpacing + size.width
                return CGRect(origin: thisPosition, size: size)
            }
        }
    }