SwiftUI: Как заставить HStack оборачивать детей по нескольким строкам (например, в виде коллекции)?
Я пытаюсь воссоздать базовое поведение представления коллекции с помощью SwiftUI:
У меня есть несколько видов (например, фотографии), которые отображаются рядом друг с другом по горизонтали. Если для показа всех фотографий в одной строке недостаточно места, оставшиеся фотографии следует перенести на следующую строку (и).
Вот пример:
Похоже, можно использовать один VStack
с рядом HStack
элементы, каждый из которых содержит фотографии для одного ряда.
Я пытался с помощью GeometryReader
и перебирая представления фотографий для динамического создания такого макета, но он не будет компилироваться (Closure, содержащий объявление, не может использоваться со сборщиком функций 'ViewBuilder'). Можно ли динамически создавать представления и возвращать их?
Разъяснение:
Коробки / фотографии могут быть разной ширины (в отличие от классической "сетки"). Сложность в том, что мне нужно знать ширину текущего поля, чтобы решить, подходит ли оно к текущему ряду и нужно ли начинать новый ряд.
3 ответа
Вот как я решил это, используя 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]
)
}
}
}
Вы можете использовать это так:
struct ContentView: View {
let texts = ["a","lot","of","texts"]
var body: some View {
MultilineHStack(self.texts) {
Text($0)
}
}
}
Работает не только с Text
, но с любыми взглядами.
Я справился с чем-то, используя GeometryReader и ZStack, используя модификатор.position. Я использую метод взлома, чтобы получить ширину строк с помощью UIFont, но поскольку вы имеете дело с изображениями, ширина должна быть более доступной.
Представление ниже имеет переменные состояния для вертикального и горизонтального выравнивания, что позволяет вам начать с любого угла ZStack. Возможно, добавляет излишнюю сложность, но вы должны быть в состоянии приспособить это к вашим потребностям.
//
// 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"])
}
}
Это пересмотр ответа bzz. Описание структуры в разметке.
// MultilineHStackView.swift
// Everything Demonstrated
//
// Created by John Durcan on 10/08/2023.
//
import SwiftUI
/// `MultilineHStack` is a SwiftUI view that arranges its items horizontally, automatically wrapping to the next line when running out of horizontal space.
///
/// Items are arranged similarly to how words are laid out in a paragraph: horizontally until no more space is available, at which point it moves to the next line.
///
/// Usage:
/// ```
/// MultilineHStack(dataCollection) { item in
/// // SwiftUI View representing each item
/// }
/// ```
///
/// - Important: The data collection's element type should conform to the `Identifiable` protocol.
///
/// # Example
/// ```
/// struct IdentifiableString: Identifiable {
/// let id = UUID()
/// let value: String
/// }
///
/// let texts: [IdentifiableString] = [
/// IdentifiableString(value: "a"),
/// IdentifiableString(value: "lot"),
/// // ... other data items
/// ]
///
/// MultilineHStack(texts) { item in
/// Text(item.value)
/// }
/// ```
///
/// - Parameters:
/// - Data: The collection type of the data being displayed.
/// - Content: The SwiftUI view type for each item.
///
public struct MultilineHStack<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
struct SizePreferenceKey: PreferenceKey {
typealias Value = [CGSize]
static var defaultValue: Value { [] }
static func reduce(value: inout Value, nextValue: () -> Value) {
value.append(contentsOf: nextValue())
}
}
private let data: Data
private let content: (Data.Element) -> Content
@State private var sizes: [CGSize] = []
public init(_ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) {
self.data = data
self.content = content
}
public var body: some View {
GeometryReader { geometry in
ZStack(alignment: .topLeading) {
ForEach(0..<data.count, id: \.self) { index in
content(data[data.index(data.startIndex, offsetBy: index)])
.background(backgroundView())
.offset(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]
)
}
}
}
struct MultilineHStack_Previews: PreviewProvider {
struct IdentifiableString: Identifiable {
let id = UUID()
let value: String
}
static let texts = [
IdentifiableString(value: "a"),
IdentifiableString(value: "lot"),
IdentifiableString(value: "of"),
IdentifiableString(value: "textstextstextstextstextstexts"),
IdentifiableString(value: "Another one"),
IdentifiableString(value: "two")
]
static var previews: some View {
MultilineHStack(texts) { file in
Text(file.value)
.padding(6)
}
.frame(width: 250)
}
}