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:
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.
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.
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.
A view that arranges its subviews in a horizontal line.
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:
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:
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:
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.
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.
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"])
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With