2

I am trying to implement a FlowLayout similar to what you would have in a UICollectionView.

I followed the guide from objc.io but modified it slightly to support custom spacing.

Even prior to my modifications, the issue is present.

Views < width of the layout flow fine. Views that are > the size of the layout do not.

see example issue here

Here is the code behind the layout:

public struct FlowLayout: Layout {
    private let spacing: CGFloat

    public init(spacing: CGFloat = 8) {
        self.spacing = spacing
    }

    public func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let containerWidth = proposal.replacingUnspecifiedDimensions().width
        let sizes = subviews.map {
            let dimensions = $0.sizeThatFits(.unspecified)
            return CGSize(width: min(dimensions.width, containerWidth), height: dimensions.height)
        }
        let layoutSizes = layout(sizes: sizes, spacing: spacing, containerWidth: containerWidth)
        return layoutSizes.size
    }

    public func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let sizes = subviews.map {
            let dimensions = $0.sizeThatFits(.unspecified)
            return CGSize(width: min(dimensions.width, bounds.width), height: dimensions.height)
        }
        let offsets = layout(sizes: sizes, spacing: spacing, containerWidth: bounds.width).offsets
        for (offset, subview) in zip(offsets, subviews) {
            subview.place(
                at: CGPoint(x: offset.x + bounds.minX, y: offset.y + bounds.minY),
                proposal: .unspecified
            )
        }
    }

    private func layout(
        sizes: [CGSize],
        spacing: CGFloat = 10,
        containerWidth: CGFloat
    ) -> (offsets: [CGPoint], size: CGSize) {
        var result: [CGPoint] = []
        var currentPosition: CGPoint = .zero
        var lineHeight: CGFloat = 0
        var maxX: CGFloat = 0
        for size in sizes {
            if currentPosition.x + size.width > containerWidth {
                currentPosition.x = 0
                currentPosition.y += lineHeight + spacing
                lineHeight = 0
            }

            result.append(currentPosition)
            currentPosition.x += size.width
            maxX = max(maxX, currentPosition.x)
            currentPosition.x += spacing
            lineHeight = max(lineHeight, size.height)
        }

        return (
            result,
            CGSize(width: maxX, height: currentPosition.y + lineHeight)
        )
    }
}

I am expecting the views with text that is larger than the container to expand vertically and wrap its contents.

What am I missing here? VStack and HStack apparently implement this protocol now, and this issue isn't present with them.

3
  • In the function sizeThatFits, you are passing .unspecified as ProposedViewSize to the subviews, so they return their ideal size. If the ideal size is too wide then you need to try again, passing a custom ProposedViewSize that is constrained to the maximum width. Commented Dec 18, 2023 at 22:10
  • See the documentation to ProposedViewSize: "Layout containers typically measure their subviews by proposing several sizes and looking at the responses. The container can use this information to decide how to allocate space among its subviews." Commented Dec 18, 2023 at 22:23
  • Thank you @BenzyNeez I didnt even realize there was an initializer for a view size proposal that took a size. Commented Dec 19, 2023 at 0:46

1 Answer 1

1

Thanks to @BenzyNeez for this suggestion:

The problem was I was using an .unspecified proposal when getting the sizes and placing the subviews.

The solution was to use a ProposedViewSize(width:height:) and specifying min(containerWidth, subviewWidth) for the width, and .infinity for the height.

Here is the updated code:

public struct FlowLayout: Layout {
    private let spacing: CGFloat

    public init(spacing: CGFloat = 8) {
        self.spacing = spacing
    }

    public func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let containerProposal = proposal.replacingUnspecifiedDimensions()
        let containerSize = CGSize(width: containerProposal.width, height: containerProposal.height)
        let sizes = subviewSizes(
            containerSize: CGSize(width: containerProposal.width, height: containerProposal.height),
            subviews: subviews
        )
        let size = layout(sizes: sizes, spacing: spacing, containerWidth: containerSize.width).size
        return size
    }

    public func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let sizes = subviewSizes(
            containerSize: CGSize(width: bounds.width, height: bounds.height),
            subviews: subviews
        )
        let offsets = layout(sizes: sizes, spacing: spacing, containerWidth: bounds.width).offsets
        for (offset, subview) in zip(offsets, subviews) {
            subview.place(
                at: CGPoint(x: offset.x + bounds.minX, y: offset.y + bounds.minY),
                proposal: .init(width: bounds.width, height: .infinity)
            )
        }
    }

    private func subviewSizes(containerSize: CGSize, subviews: Subviews) -> [CGSize] {
        subviews.map {
            let dimensions = $0.sizeThatFits(.init(width: containerSize.width, height: .infinity))
            return CGSize(
                width: min(dimensions.width, containerSize.width),
                height: dimensions.height
            )
        }
    }

    private func layout(
        sizes: [CGSize],
        spacing: CGFloat = 10,
        containerWidth: CGFloat
    ) -> (offsets: [CGPoint], size: CGSize) {
        var result: [CGPoint] = []
        var currentPosition: CGPoint = .zero
        var lineHeight: CGFloat = 0
        var maxX: CGFloat = 0
        for size in sizes {
            if currentPosition.x + size.width > containerWidth {
                currentPosition.x = 0
                currentPosition.y += lineHeight + spacing
                lineHeight = 0
            }

            result.append(currentPosition)
            currentPosition.x += size.width
            maxX = max(maxX, currentPosition.x)
            currentPosition.x += spacing
            lineHeight = max(lineHeight, size.height)
        }

        return (
            result,
            CGSize(width: maxX, height: currentPosition.y + lineHeight)
        )
    }
}
Sign up to request clarification or add additional context in comments.

1 Comment

Glad to see you got it working. I would suggest 2 simplifications: (1) In sizeThatFits, you can pass containerProposal directly as parameter containerSize to the function subviewSizes. (2) In subviewSizes, you don't need to use the min of the subview width and the container width any more. Now that you are providing a more specific proposal to the subview, the size that the subview delivers will not exceed the container width. So in fact, the result of calling $0.sizeThatFits can be inserted into the mapped array and the closure to the .map function reduces to 1 statement.

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.