SwiftUI custom layout does not resize text views to fit container size

77 views Asked by At

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.

1

There are 1 answers

1
Xaxxus On

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