1

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
        })
    }
}
0

2 Answers 2

1

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.

Sign up to request clarification or add additional context in comments.

1 Comment

Answer updated with a working example of sizeThatFits
0

I haven't managed to find the perfect solution but at least I have been able to make it scroll. The issue is that to enable scrolling in a ScrollView the Views inside must exceed the size of it. What I've done is using a combination of GeometryReader and listening to changes to Windows resiszing in order to assing a kind of ideal minWidth to the VStack inside the ScrollView. I also needed to simulate many tokens in order to test it.

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

/// New properties for size
@State private var size: CGSize = .zero
@State private var scrollViewMinHeight: CGFloat = .zero

var body: some View {
    /// Change it as you need
    let minIncreaseCoeff: CGFloat = 0.006
    GeometryReader {
        let size = $0.size
        VStack(alignment: .center) {
            HStack(alignment: .center) {
                TextField("Enter mail Ids..", text: $inputData)
                    .onSubmit {
                        resultData.append(TokenData(email: inputData))
                        inputData = ""
                    }
                Button {
                    self.resultData.removeAll()
                } label: {
                    Text("Clear")
                }
            }
            Divider()
            ScrollView {
                /// Wrap the layout in a VStack
                VStack(alignment: .leading, spacing: 5) {
                    layout {
                        ForEach(resultData.indices, id: \.self) { index in
                            TokenView(dataSource: $resultData, tokenData: self.$resultData[index])
                        }
                    }
                }
                /// Add a minHeight to make it scroll
                .frame(minHeight: self.scrollViewMinHeight)
                .frame(maxHeight: .infinity)
                .padding(.top)
            }
        }
        .onAppear(perform: {
            let window = NSApplication.shared.windows.first
            window?.level = .floating
            self.size = size
            /// The for loop is for testing purposes only
            Task.detached(priority: .userInitiated) {
                for i in 0...1000 {
                    resultData.append(.init(email: "email\(i)"))
                }
                /// This line is needed to init the scrollViewMinHeight
                self.scrollViewMinHeight = size.height * (CGFloat(resultData.count) * minIncreaseCoeff)
            }
        })
        /// Listen to window resizes
        .onChange(of: size) { oldSize, newSize in
            withAnimation {
                self.size = newSize
                if oldSize.width > newSize.width {
                    self.scrollViewMinHeight += (oldSize.width - newSize.width) * (CGFloat(resultData.count) * minIncreaseCoeff)
                } else {
                    if (scrollViewMinHeight - ((newSize.width - oldSize.width) * (CGFloat(resultData.count) * minIncreaseCoeff))) >= .zero {
                        self.scrollViewMinHeight -= (newSize.width - oldSize.width) * (CGFloat(resultData.count) * (minIncreaseCoeff))
                    }
                }
            }
        }
    }
}

}

I also changed the sizeThatFits function to this to avoid the crash:

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    return CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0)
}

But you can also follow Benzy Neez advice there.

I'm using a State variable to make the VStack exceed the ScrollView size in order to enable the scrolling. When you resize the window I increse or decrease that variable in order to adjust to the new size based on the item count.

Now, I want to say that I'm not very experienced with Layout and SizeThatFits but I wanted to share my effort for this cause.

You will see that this solution is not perfect and needs fine tuning but, at least it is scrollable now.

Let me know your thoughts!

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.