Search code examples
swiftuiswiftui-scrollviewswiftui-layout

Scrolling Inside custom layout in SwiftUI


I would like to add vertical scrolling to my custom layout. I don't know how to do it. I have added my code below for reference. Thanks in Advance.

struct TokenData {
    let email: String
}

let gradientColor = LinearGradient(colors: [Color(red: 0/255, green: 204/255, blue: 172/255), Color(red: 1/255, green: 99/255, blue: 240/255)], startPoint: .top, endPoint: .bottom)

struct TokenView: View {
    @Binding var dataSource: [TokenData]
    @Binding var tokenData: TokenData
    var body: some View {
        HStack {
            Text(tokenData.email)
                .foregroundStyle(.white)
            Button(action: {
                if let index = self.dataSource.firstIndex(where:  { $0.email == tokenData.email }) {
                    self.dataSource.remove(at: index)
                } else {
                    assertionFailure("Should not come here.. ")
                }
            }, label: {
                Image(systemName: "multiply")
                    .renderingMode(.template)
            })
            .buttonStyle(.plain)
            .foregroundStyle(.white)
        }
        .padding(3)
        .background(gradientColor)
        .clipShape(RoundedRectangle(cornerRadius: 4))
        
    }
}

struct TokenFieldView: View {
    @State var resultData: [TokenData] = []
    @State var inputData: String = ""
    @State var layout: AnyLayout = AnyLayout(CustomTokenFieldLayout())

    var body: some View {
        VStack(alignment: .center) {
            HStack(alignment: .center) {
                TextField("Enter mail Ids..", text: $inputData)
                    .onSubmit {
                        resultData.append(TokenData(email: inputData))
                    }
                Button {
                    self.resultData.removeAll()
                } label: {
                    Text("clear")
                }
            }
            Divider()
            ScrollView(.vertical) {
                layout {
                    ForEach(resultData.indices, id: \.self) { index in
                        TokenView(dataSource: $resultData, tokenData: self.$resultData[index])
                    }
                }
            }
        }
        .onAppear(perform: {
            let window = NSApplication.shared.windows.first
            window?.level = .floating
        })
    }
}

struct CustomTokenFieldLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        return CGSize(width: proposal.width!, height: proposal.height!)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        guard !subviews.isEmpty else {
            return
        }
        
        var point = bounds.origin
        let hSpacing: Double = 5
        let vSpacing: Double = 5
        point.x += hSpacing
        point.y += vSpacing
        for subview in subviews {
            if (point.x + subview.dimensions(in: .unspecified).width + hSpacing) > bounds.width {
                point.y += (subview.dimensions(in: .unspecified).height + vSpacing)
                point.x = hSpacing
            }
            subview.place(at: point, anchor: .leading, proposal: proposal)
            point.x += (subview.dimensions(in: .unspecified).width + hSpacing)
        }
    }
}

Sample Image of my UI

When I try to add my custom layout inside scrollview, I get zero proposalview.width and height in Layout definition. Could anyone please explain why that is happening?

struct TokenFieldView: View {
    @State var resultData: [TokenData] = []
    @State var inputData: String = ""
    @State var layout: AnyLayout = AnyLayout(CustomTokenFieldLayout())

    var body: some View {
        VStack(alignment: .center) {
            HStack(alignment: .center) {
                TextField("Enter mail Ids..", text: $inputData)
                    .onSubmit {
                        resultData.append(TokenData(email: inputData))
                    }
                Button {
                    self.resultData.removeAll()
                } label: {
                    Text("clear")
                }
            }
            Divider()
            ScrollView {
                layout {
                    ForEach(resultData.indices, id: \.self) { index in
                        TokenView(dataSource: $resultData, tokenData: self.$resultData[index])
                    }
                }
            }
        }
        .onAppear(perform: {
            let window = NSApplication.shared.windows.first
            window?.level = .floating
        })
    }
}

Solution

  • When I tried your example code, it crashed in CustomTokenFieldLayout.sizeThatFits, because it was trying to unwrap an undefined optional.

    Try changing the implementation of sizeThatFits to the following:

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        proposal.replacingUnspecifiedDimensions()
    }
    

    This stops it crashing, but it is still only returning the proposal it is given, so it is not calculating the actual size that really fits. This prevents the ScrollView from scrolling properly over the full height of its contents.

    To fix, the function sizeThatfits needs to iterate over the subviews and work out the real size that fits, as explained by the documentation.

    Looking at placeSubviews, I assume you are trying to achieve a flow layout, where as many items are shown on a row as possible. I see that you are also adding leading and trailing padding to the rows and to the overall height.

    The current implementation of placeSubviews is a bit flawed, for the following reasons:

    1. If the first item is wider than the available width, it adds an empty row at the start.
    2. When it moves to a new row, it adds the height of the latest subview, instead of adding the maximum height of the subviews that form the existing row.
    3. The subviews are being placed with anchor: .leading, which means that half their height will be above the position they are placed at.
    4. The subviews are being supplied with the size proposal for the container, instead of a size proposal that is specific to the subview.

    Addressing these issues:

    • The first point is not so serious if you are not expecting the items in the container to exceed the available width.
    • The second point also doesn't matter if you are expecting all subviews to have the same height.
    • The third point can be fixed by omitting the anchor, so that the default anchor of .topLeading is used instead:
    subview.place(at: point, proposal: proposal)
    
    • The fourth point could be fixed by basing the size proposal on the dimensions delivered by the subview. But in many cases, it probably works to supply the container proposal.

    So based on the way that placeSubviews is working, here is an example implementation of sizeThatFits that computes the size appropriately:

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let hSpacing: CGFloat = 5
        let vSpacing: CGFloat = 5
        var rowWidth = CGFloat.zero
        var rowHeight = CGFloat.zero
        var totalWidth = CGFloat.zero
        var totalHeight = CGFloat.zero
        let horizontalPadding = hSpacing + hSpacing
    
        for subview in subviews {
            let subviewSize = subview.sizeThatFits(proposal)
            let addToExistingRow: Bool
            if let containerWidth = proposal.width {
                let availableWidth = containerWidth - horizontalPadding
                addToExistingRow = rowWidth == 0 || (rowWidth + hSpacing + subviewSize.width) <= availableWidth
            } else {
                addToExistingRow = true
            }
            if addToExistingRow {
                rowWidth += rowWidth > 0 ? hSpacing : 0
                rowHeight = max(rowHeight, subviewSize.height)
            } else {
                rowWidth = 0
                if rowHeight > 0 {
                    totalHeight += totalHeight > 0 ? vSpacing : 0
                    totalHeight += rowHeight
                }
                rowHeight = subviewSize.height
            }
            rowWidth += subviewSize.width
            totalWidth = max(totalWidth, rowWidth)
        }
        if rowWidth > 0 {
            totalHeight += totalHeight > 0 ? vSpacing : 0
            totalHeight += rowHeight
        }
        return CGSize(width: totalWidth + horizontalPadding, height: totalHeight + vSpacing + vSpacing)
    }
    

    This scrolls fine:

    Animation

    One other suggestion: it might be better to apply external padding to the layout container, instead of it having internal padding at the sides and at top and bottom. This way, the size proposals that the layout receives will represent the actual space available and the layout functions do not need to be concerned with padding.