10

I'm trying to recreate basic collection view behavior with SwiftUI:

I have a number of views (e.g. photos) that are shown next to each other horizontally. When there is not enough space to show all photos on the same line, the remaining photos should wrap to the next line(s).

Here's an example:

landscape version portrait

It looks like one could use one VStack with a number of HStack elements, each containing the photos for one row.

I tried using GeometryReader and iterating over the photo views to dynamically create such a layout, but it won't compile (Closure containing a declaration cannot be used with function builder 'ViewBuilder'). Is it possible to dynamically create views and return them?

Clarification:

The boxes/photos can be of different width (unlike a classical "grid"). The tricky part is that I have to know the width of the current box in order to decide if it fits on the current row of if I have to start a new row.

Mark
  • 5,965
  • 1
  • 37
  • 77
  • You can use package https://github.com/Q-Mobile/QGrid, either by including the Package in your project, or just as an inspiration for your own solution. – kontiki Aug 15 '19 at 13:14
  • Thanks for the link. It looks promising, but is not quite what I need. In my case, the width of the boxes varies, i.e. there could be 2 on the first row and 3 on the next. What makes this difficult is that I need to know the width of the current box to determine if it will fit on the current row or if I have to start a new row. – Mark Aug 15 '19 at 13:28
  • I have written some articles about view preferences that can be useful (check my profile for a link to my blog if you need that information). – kontiki Aug 15 '19 at 13:31

2 Answers2

8

Here is how I solved this using PreferenceKeys.

public struct MultilineHStack: View {
    struct SizePreferenceKey: PreferenceKey {
        typealias Value = [CGSize]
        static var defaultValue: Value = []
        static func reduce(value: inout Value, nextValue: () -> Value) {
            value.append(contentsOf: nextValue())
        }
    }

    private let items: [AnyView]
    @State private var sizes: [CGSize] = []

    public init<Data: RandomAccessCollection,  Content: View>(_ data: Data, @ViewBuilder content: (Data.Element) -> Content) {
          self.items = data.map { AnyView(content($0)) }
    }

    public var body: some View {
        GeometryReader {geometry in
            ZStack(alignment: .topLeading) {
                ForEach(0..<self.items.count) { index in
                    self.items[index].background(self.backgroundView()).offset(self.getOffset(at: index, geometry: geometry))
                }
            }
        }.onPreferenceChange(SizePreferenceKey.self) {
                self.sizes = $0
        }
    }

    private func getOffset(at index: Int, geometry: GeometryProxy) -> CGSize {
        guard index < sizes.endIndex else {return .zero}
        let frame = sizes[index]
        var (x,y,maxHeight) = sizes[..<index].reduce((CGFloat.zero,CGFloat.zero,CGFloat.zero)) {
            var (x,y,maxHeight) = $0
            x += $1.width
            if x > geometry.size.width {
                x = $1.width
                y += maxHeight
                maxHeight = 0
            }
            maxHeight = max(maxHeight, $1.height)
            return (x,y,maxHeight)
        }
        if x + frame.width > geometry.size.width {
            x = 0
            y += maxHeight
        }
        return .init(width: x, height: y)
    }

    private func backgroundView() -> some View {
        GeometryReader { geometry in
            Rectangle()
                .fill(Color.clear)
                .preference(
                    key: SizePreferenceKey.self,
                    value: [geometry.frame(in: CoordinateSpace.global).size]
                )
        }
    }
}

You can use it like this:

struct ContentView: View {
    let texts = ["a","lot","of","texts"]
    var body: some View {
        MultilineHStack(self.texts) {
            Text($0)
        }
    }
}

It works not only with Text, but with any views.

bzz
  • 5,368
  • 22
  • 25
  • Would be better with position instead of offset, in order for the views around the MultilineHStack to position correctly instead of above the elements. Great stuff though. – Daniel Mar 11 '21 at 17:51
  • In the end, this answer did not work for me since it was positioning elements around it wrong (even using position instead of offset) and I ended up creating a GitHub project ( [WrappingHStack](https://github.com/dkk/WrappingHStack)) to make it easier in the future – Daniel Mar 21 '21 at 18:21
5

I managed something using GeometryReader and the ZStack by using the .position modifier. I'm using a hack method to get String Widths using a UIFont, but as you are dealing with Images, the width should be more readily accessible.

The view below has state variables for Vertical and Horizontal alignment, letting you start from any corner of the ZStack. Probably adds undue complexity, but you should be able to adapt this to your needs.

//
//  WrapStack.swift
//  MusicBook
//
//  Created by Mike Stoddard on 8/26/19.
//  Copyright © 2019 Mike Stoddard. All rights reserved.
//

import SwiftUI

extension String {
    func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [.font: font], context: nil)

        return ceil(boundingBox.height)
    }

    func width(withConstrainedHeight height: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height)
        let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [.font: font], context: nil)

        return ceil(boundingBox.width)
    }
}


struct WrapStack: View {
    var strings: [String]

    @State var borderColor = Color.red
    @State var verticalAlignment = VerticalAlignment.top
    @State var horizontalAlignment = HorizontalAlignment.leading

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                ForEach(self.strings.indices, id: \.self) {idx in
                    Text(self.strings[idx])
                        .position(self.nextPosition(
                            index: idx,
                            bucketRect: geometry.frame(in: .local)))
                }   //end GeometryReader
            }   //end ForEach
        }   //end ZStack
        .overlay(Rectangle().stroke(self.borderColor))
    }   //end body

    func nextPosition(index: Int,
                      bucketRect: CGRect) -> CGPoint {
        let ssfont = UIFont.systemFont(ofSize: UIFont.systemFontSize)
        let initX = (self.horizontalAlignment == .trailing) ? bucketRect.size.width : CGFloat(0)
        let initY = (self.verticalAlignment == .bottom) ? bucketRect.size.height : CGFloat(0)
        let dirX = (self.horizontalAlignment == .trailing) ? CGFloat(-1) : CGFloat(1)
        let dirY = (self.verticalAlignment == .bottom) ? CGFloat(-1) : CGFloat(1)

        let internalPad = 10   //fudge factor

        var runningX = initX
        var runningY = initY
        let fontHeight = "TEST".height(withConstrainedWidth: 30, font: ssfont)

        if index > 0 {
            for i in 0...index-1 {
                let w = self.strings[i].width(
                    withConstrainedHeight: fontHeight,
                    font: ssfont) + CGFloat(internalPad)
                if dirX <= 0 {
                    if (runningX - w) <= 0 {
                        runningX = initX - w
                        runningY = runningY + dirY * fontHeight
                    } else {
                        runningX -= w
                    }
                } else {
                    if (runningX + w) >= bucketRect.size.width {
                        runningX = initX + w
                        runningY = runningY + dirY * fontHeight
                    } else {
                        runningX += w
                    }   //end check if overflow
                }   //end check direction of flow
            }   //end for loop
        }   //end check if not the first one

        let w = self.strings[index].width(
            withConstrainedHeight: fontHeight,
            font: ssfont) + CGFloat(internalPad)

        if dirX <= 0 {
            if (runningX - w) <= 0 {
                runningX = initX
                runningY = runningY + dirY * fontHeight
            }
        } else {
            if (runningX +  w) >= bucketRect.size.width {
                runningX = initX
                runningY = runningY + dirY * fontHeight
            }  //end check if overflow
        }   //end check direction of flow

        //At this point runnoingX and runningY are pointing at the
        //corner of the spot at which to put this tag.  So...
        //
        return CGPoint(
            x: runningX + dirX * w/2,
            y: runningY + dirY * fontHeight/2)
    }

}   //end struct WrapStack

struct WrapStack_Previews: PreviewProvider {
    static var previews: some View {
        WrapStack(strings: ["One, ", "Two, ", "Three, ", "Four, ", "Five, ", "Six, ", "Seven, ", "Eight, ", "Nine, ", "Ten, ", "Eleven, ", "Twelve, ", "Thirteen, ", "Fourteen, ", "Fifteen, ", "Sixteen"])
    }
}
justgus
  • 61
  • 3