Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI: How to have HStack wrap children along multiple lines (like a collection view)?

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.

like image 757
Mark Avatar asked Aug 15 '19 13:08

Mark


People also ask

How many views can HStack SwiftUI have?

HStack can contain up to 10 static views, if you need more static views, you can nest HStack inside another HStack or Group to nest views inside.

What is HStack and VStack in SwiftUI?

Using stacks in SwiftUI allows you to arrange multiple views into a single organized view with certain properties. You can use 3 kinds of stacks with SwiftUI: VStack, a vertical stack, which shows views in a top-to-bottom list. HStack, a horizontal stack, which shows views in a left-to-right list.

What is a HStack?

A view that arranges its subviews in a horizontal line.

How to declare a stack in SwiftUI?

A stack is declared by embedding child views into a stack view within the SwiftUI View file. In the following view, for example, three Image views have been embedded within an HStack:

How does an hstack display the text within its children?

By default, an HStack will attempt to display the text within its Text view children on a single line. Take, for example, the following HStack declaration containing an Image view and two Text views:

How do I add space between views in SwiftUI stack layout?

To add space between views, SwiftUI includes the Spacer component. When used in a stack layout, the spacer will flexibly expand and contract along the axis of the containing stack (in other words either horizontally or vertically) to provide a gap between views positioned on either side, for example:

What is SwiftUI and how to use it?

As is to be expected, SwiftUI includes a wide range of user interface components to be used when developing an app such as button, label, slider and toggle views.


2 Answers

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.

like image 167
bzz Avatar answered Oct 05 '22 10:10

bzz


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"])
    }
}
like image 31
justgus Avatar answered Oct 05 '22 10:10

justgus