SwiftUI HStack с оболочкой
Возможно ли, что синие теги (которые в настоящее время усечены) отображаются полностью, а затем автоматически выполняется разрыв строки?
NavigationLink(destination: GameListView()) {
VStack(alignment: .leading, spacing: 5){
// Name der Sammlung:
Text(collection.name)
.font(.headline)
// Optional: Für welche Konsolen bzw. Plattformen:
HStack(alignment: .top, spacing: 10){
ForEach(collection.platforms, id: \.self) { platform in
Text(platform)
.padding(.all, 5)
.font(.caption)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
.lineLimit(1)
}
}
}
.padding(.vertical, 10)
}
ht tps:https://stackru.com/images/60f17dc9533988c3557a27ac6306ad0dd2f9f2d4.png
Также в синих тегах не должно быть разрывов строк:
ht tps:https://stackru.com/images/26e64df5e3fd988277ca2449b29a797c2eb66433.png
Вот как это должно выглядеть в итоге: ht tps:https://stackru.com/images/e8ff829482e5567cca22da3fb06d6a4ac3b0022c.png
12 ответов
Вот несколько подходов, как это можно сделать с помощью alignmentGuide(s). Он упрощен, чтобы избежать многих постов кода, но надеюсь, что это полезно.
Обновление: в моем ответе на SwiftUI HStack также есть обновленный и улучшенный вариант решения ниже с переносом и динамической высотой
Вот результат:
А вот и полный демонстрационный код (ориентация поддерживается автоматически):
import SwiftUI
struct TestWrappedLayout: View {
@State var platforms = ["Ninetendo", "XBox", "PlayStation", "PlayStation 2", "PlayStation 3", "PlayStation 4"]
var body: some View {
GeometryReader { geometry in
self.generateContent(in: geometry)
}
}
private func generateContent(in g: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(self.platforms, id: \.self) { platform in
self.item(for: platform)
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width)
{
width = 0
height -= d.height
}
let result = width
if platform == self.platforms.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if platform == self.platforms.last! {
height = 0 // last item
}
return result
})
}
}
}
func item(for text: String) -> some View {
Text(text)
.padding(.all, 5)
.font(.body)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
}
struct TestWrappedLayout_Previews: PreviewProvider {
static var previews: some View {
TestWrappedLayout()
}
}
Для меня ни один из ответов не помог. Либо потому, что у меня были разные типы элементов, либо потому, что элементы вокруг были неправильно расположены. Поэтому в итоге я реализовал свой собственный WrappingHStack, который можно использовать очень похоже на HStack. Вы можете найти его на GitHub: WrappingHStack.
Вот улучшение ответа Тимми . Он резервирует все доступное горизонтальное пространство, исправляя ошибку, из-за которой, если бы в контейнере-обертке было меньше полной строки элементов, он резервировал бы такой объем пространства, который приводил к смещению пользовательского интерфейса, а также исправлял ошибку, которая приводила к переполнению содержимого в некоторые крайние случаи:
private struct WrappingHStack: Layout {
// inspired by: https://stackoverflow.com/a/75672314
private var horizontalSpacing: CGFloat
private var verticalSpacing: CGFloat
public init(horizontalSpacing: CGFloat, verticalSpacing: CGFloat? = nil) {
self.horizontalSpacing = horizontalSpacing
self.verticalSpacing = verticalSpacing ?? horizontalSpacing
}
public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
guard !subviews.isEmpty else { return .zero }
let height = subviews.map { $0.sizeThatFits(proposal).height }.max() ?? 0
var rowWidths = [CGFloat]()
var currentRowWidth: CGFloat = 0
subviews.forEach { subview in
if currentRowWidth + horizontalSpacing + subview.sizeThatFits(proposal).width >= proposal.width ?? 0 {
rowWidths.append(currentRowWidth)
currentRowWidth = subview.sizeThatFits(proposal).width
} else {
currentRowWidth += horizontalSpacing + subview.sizeThatFits(proposal).width
}
}
rowWidths.append(currentRowWidth)
let rowCount = CGFloat(rowWidths.count)
return CGSize(width: max(rowWidths.max() ?? 0, proposal.width ?? 0), height: rowCount * height + (rowCount - 1) * verticalSpacing)
}
public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let height = subviews.map { $0.dimensions(in: proposal).height }.max() ?? 0
guard !subviews.isEmpty else { return }
var x = bounds.minX
var y = height / 2 + bounds.minY
subviews.forEach { subview in
x += subview.dimensions(in: proposal).width / 2
if x + subview.dimensions(in: proposal).width / 2 > bounds.maxX {
x = bounds.minX + subview.dimensions(in: proposal).width / 2
y += height + verticalSpacing
}
subview.place(
at: CGPoint(x: x, y: y),
anchor: .center,
proposal: ProposedViewSize(
width: subview.dimensions(in: proposal).width,
height: subview.dimensions(in: proposal).height
)
)
x += subview.dimensions(in: proposal).width / 2 + horizontalSpacing
}
}
}
Вы можете использовать его следующим образом:
struct MyView: View {
var body: some View {
WrappingHStack(horizontalSpacing: 5) {
Text("Hello")
Text("Hello again")
Text("Hello")
Text("Hello again")
Text("Hello")
Text("Hello again")
Text("Hello")
Text("Hello again")
Text("Hello")
Text("Hello again")
Text("Hello")
Text("Hello again")
}
}
}
И эффект выглядит так:
Я уже успел создать то, что вам нужно.
Я использовал
HStack
в
VStack
.
Вы проходите через
geometryProxy
который используется для определения максимальной ширины строки. Я пошел с передачей этого, чтобы его можно было использовать в scrollView
Я обернул представления SwiftUI в UIHostingController, чтобы получить размер для каждого дочернего элемента.
Затем я перебираю представления, добавляя их в строку, пока она не достигнет максимальной ширины, и в этом случае я начинаю добавлять в новую строку.
Это всего лишь этап инициализации и заключительный этап, объединяющий и выводящий строки в VStack.
struct WrappedHStack<Content: View>: View {
private let content: [Content]
private let spacing: CGFloat = 8
private let geometry: GeometryProxy
init(geometry: GeometryProxy, content: [Content]) {
self.content = content
self.geometry = geometry
}
var body: some View {
let rowBuilder = RowBuilder(spacing: spacing,
containerWidth: geometry.size.width)
let rowViews = rowBuilder.generateRows(views: content)
let finalView = ForEach(rowViews.indices) { rowViews[$0] }
VStack(alignment: .center, spacing: 8) {
finalView
}.frame(width: geometry.size.width)
}
}
extension WrappedHStack {
init<Data, ID: Hashable>(geometry: GeometryProxy, @ViewBuilder content: () -> ForEach<Data, ID, Content>) {
let views = content()
self.geometry = geometry
self.content = views.data.map(views.content)
}
init(geometry: GeometryProxy, content: () -> [Content]) {
self.geometry = geometry
self.content = content()
}
}
Здесь происходит волшебство
extension WrappedHStack {
struct RowBuilder {
private var spacing: CGFloat
private var containerWidth: CGFloat
init(spacing: CGFloat, containerWidth: CGFloat) {
self.spacing = spacing
self.containerWidth = containerWidth
}
func generateRows<Content: View>(views: [Content]) -> [AnyView] {
var rows = [AnyView]()
var currentRowViews = [AnyView]()
var currentRowWidth: CGFloat = 0
for (view) in views {
let viewWidth = view.getSize().width
if currentRowWidth + viewWidth > containerWidth {
rows.append(createRow(for: currentRowViews))
currentRowViews = []
currentRowWidth = 0
}
currentRowViews.append(view.erasedToAnyView())
currentRowWidth += viewWidth + spacing
}
rows.append(createRow(for: currentRowViews))
return rows
}
private func createRow(for views: [AnyView]) -> AnyView {
HStack(alignment: .center, spacing: spacing) {
ForEach(views.indices) { views[$0] }
}
.erasedToAnyView()
}
}
}
и вот расширения, которые я использовал
extension View {
func erasedToAnyView() -> AnyView {
AnyView(self)
}
func getSize() -> CGSize {
UIHostingController(rootView: self).view.intrinsicContentSize
}
}
Вы можете увидеть полный код с некоторыми примерами здесь: https://gist.github.com/kanesbetas/63e719cb96e644d31bf027194bf4ccdb
Вот версия, использующая SwiftUILayout
протокол:
public struct OverflowGrid: Layout {
private var horizontalSpacing: CGFloat
private var vericalSpacing: CGFloat
public init(horizontalSpacing: CGFloat, vericalSpacing: CGFloat? = nil) {
self.horizontalSpacing = horizontalSpacing
self.vericalSpacing = vericalSpacing ?? horizontalSpacing
}
public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let height = subviews.max(by: {$0.dimensions(in: proposal).height > $1.dimensions(in: proposal).height})?.dimensions(in: proposal).height ?? 0
var rows = [CGFloat]()
subviews.indices.forEach { index in
let rowIndex = rows.count - 1
let subViewWidth = subviews[index].dimensions(in: proposal).width
guard !rows.isEmpty else {
rows.append(subViewWidth)
return
}
let newWidth = rows[rowIndex] + subViewWidth + horizontalSpacing
if newWidth < proposal.width ?? 0 {
rows[rowIndex] += (rows[rowIndex] > 0 ? horizontalSpacing: 0) + subViewWidth
}else {
rows.append(subViewWidth)
}
}
let count = CGFloat(rows.count)
return CGSize(width: rows.max() ?? 0, height: count * height + (count - 1) * vericalSpacing)
}
public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let height = subviews.max(by: {$0.dimensions(in: proposal).height > $1.dimensions(in: proposal).height})?.dimensions(in: proposal).height ?? 0
guard !subviews.isEmpty else {return}
var x = bounds.minX
var y = height/2 + bounds.minY
subviews.indices.forEach { index in
let subView = subviews[index]
x += subView.dimensions(in: proposal).width/2
subviews[index].place(at: CGPoint(x: x, y: y), anchor: .center, proposal: ProposedViewSize(width: subView.dimensions(in: proposal).width, height: subView.dimensions(in: proposal).height))
x += horizontalSpacing + subView.dimensions(in: proposal).width/2
if x > bounds.width {
x = bounds.minX
y += height + vericalSpacing
}
}
}
}
iOS 16 имеет новыйLayout
протокол, который идеально подходит для этой задачи. Я написал библиотеку с переносом строк. Он может обрабатывать различные типы подпредставлений и направляющих значений выравнивания.
Основываясь на реализации Тимми, я считаю, что исправил несколько (буквальных) крайних случаев:
public struct OverflowGrid: Layout {
private var horizontalSpacing: CGFloat
private var verticalSpacing: CGFloat
public init(horizontalSpacing: CGFloat, verticalSpacing: CGFloat? = nil) {
self.horizontalSpacing = horizontalSpacing
self.verticalSpacing = verticalSpacing ?? horizontalSpacing
}
private struct RowSize {
let width: CGFloat
let height: CGFloat
static let empty = RowSize(width: CGFloat(0), height: CGFloat(0))
}
public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let rows = generateRowSizes(subviews: subviews, proposal: proposal)
let rowCount = CGFloat(rows.count)
let combinedHeights: CGFloat = rows.reduce(CGFloat(0)) { acc, rowSize in
return acc + rowSize.height
}
let calculatedSize = CGSize(width: rows.max(by: { $0.width < $1.width })?.width ?? 0,
height: combinedHeights + ((rowCount - 1) * verticalSpacing))
return calculatedSize
}
private func generateRowSizes(subviews: Subviews, proposal: ProposedViewSize) -> [RowSize] {
var rows = [RowSize.empty]
for subview in subviews {
let rowIndex = rows.count - 1
let currentRowSize = rows.last ?? .empty
let subviewSize = subview.dimensions(in: proposal)
let subviewWidth = subviewSize.width
let subviewHeight = subviewSize.height
// Prevent creating infinite rows if the proposed width is smaller than any subview width.
if currentRowSize.width == 0 {
rows[rowIndex] = RowSize(width: subviewWidth,
height: max(currentRowSize.height, subviewHeight))
} else {
let currentRowWidth = currentRowSize.width
let newWidth = currentRowWidth + horizontalSpacing + subviewWidth
let viewWillFit = newWidth <= (proposal.width ?? 0)
if viewWillFit {
rows[rowIndex] = RowSize(width: newWidth, height: max(currentRowSize.height, subviewHeight))
} else {
rows.append(RowSize(width: subviewWidth, height: subviewHeight))
}
}
}
return rows
}
public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let rows = generateRowSizes(subviews: subviews, proposal: proposal)
var rowIndex = 0
let boundsMinX = bounds.minX
let boundsEnd = boundsMinX + bounds.width
var x = boundsMinX
var y = bounds.minY
for subview in subviews {
let subviewSize = subview.dimensions(in: proposal)
let isFirstElementInRow = x == boundsMinX
var elementStart = isFirstElementInRow ? x : x + horizontalSpacing
var elementEnd = elementStart + subviewSize.width
if elementEnd > boundsEnd && !isFirstElementInRow {
elementStart = boundsMinX
elementEnd = elementStart + subviewSize.width
x = boundsMinX
y += rows[rowIndex].height + verticalSpacing
rowIndex += 1
}
let subviewCenter = CGPoint(x: elementStart + subviewSize.width / 2, y: y + subviewSize.height / 2)
subview.place(
at: subviewCenter,
anchor: .center,
proposal: ProposedViewSize(width: subviewSize.width,
height: subviewSize.height))
x = elementEnd
}
}
}
У меня есть что-то вроде этого кода (довольно длинный). В простых сценариях он работает нормально, но при глубоком вложении с помощью считывателей геометрии он плохо передает свой размер.
Было бы неплохо, если бы эти представления обертывали и текли, как Text(), расширяя содержимое родительского представления, но, кажется, явно установили свою высоту из родительского представления.
https://gist.github.com/michzio/a0b23ee43a88cbc95f65277070167e29
Вот самая важная часть кода (без предварительных и тестовых данных)
[![private func flow(in geometry: GeometryProxy) -> some View {
print("Card geometry: \(geometry.size.width) \(geometry.size.height)")
return ZStack(alignment: .topLeading) {
//Color.clear
ForEach(data, id: self.dataId) { element in
self.content(element)
.geometryPreference(tag: element\[keyPath: self.dataId\])
/*
.alignmentGuide(.leading) { d in
print("Element: w: \(d.width), h: \(d.height)")
if (abs(width - d.width) > geometry.size.width)
{
width = 0
height -= d.height
}
let result = width
if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
width = 0 //last item
} else {
width -= d.width
}
return result
}
.alignmentGuide(.top) { d in
let result = height
if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
height = 0 // last item
}
return result
}*/
.alignmentGuide(.top) { d in
self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.y ?? 0
}
.alignmentGuide(.leading) { d in
self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.x ?? 0
}
}
}
.background(Color.pink)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
//.animation(self.loaded ? .linear(duration: 1) : nil)
.onPreferenceChange(_GeometryPreferenceKey.self, perform: { preferences in
DispatchQueue.main.async {
let (alignmentGuides, totalHeight) = self.calculateAlignmentGuides(preferences: preferences, geometry: geometry)
self.alignmentGuides = alignmentGuides
self.totalHeight = totalHeight
self.availableWidth = geometry.size.width
}
})
}
func calculateAlignmentGuides(preferences: \[_GeometryPreference\], geometry: GeometryProxy) -> (\[AnyHashable: CGPoint\], CGFloat) {
var alignmentGuides = \[AnyHashable: CGPoint\]()
var width: CGFloat = 0
var height: CGFloat = 0
var rowHeights: Set<CGFloat> = \[\]
preferences.forEach { preference in
let elementWidth = spacing + preference.rect.width
if width + elementWidth >= geometry.size.width {
width = 0
height += (rowHeights.max() ?? 0) + spacing
//rowHeights.removeAll()
}
let offset = CGPoint(x: 0 - width, y: 0 - height)
print("Alignment guides offset: \(offset)")
alignmentGuides\[preference.tag\] = offset
width += elementWidth
rowHeights.insert(preference.rect.height)
}
return (alignmentGuides, height + (rowHeights.max() ?? 0))
}
}][1]][1]
У меня была та же проблема, что и у меня, чтобы решить ее, я передаю элемент объекта функции, которая сначала создает представление для элемента, а затем через UIHostController я вычисляю следующую позицию на основе ширины элемента. представление элементов затем возвращается функцией.
import SwiftUI
class TestItem: Identifiable {
var id = UUID()
var str = ""
init(str: String) {
self.str = str
}
}
struct AutoWrap: View {
var tests: [TestItem] = [
TestItem(str:"Ninetendo"),
TestItem(str:"XBox"),
TestItem(str:"PlayStation"),
TestItem(str:"PlayStation 2"),
TestItem(str:"PlayStation 3"),
TestItem(str:"random"),
TestItem(str:"PlayStation 4"),
]
var body: some View {
var curItemPos: CGPoint = CGPoint(x: 0, y: 0)
var prevItemWidth: CGFloat = 0
return GeometryReader { proxy in
ZStack(alignment: .topLeading) {
ForEach(tests) { t in
generateItem(t: t, curPos: &curItemPos, containerProxy: proxy, prevItemWidth: &prevItemWidth)
}
}.padding(5)
}
}
func generateItem(t: TestItem, curPos: inout CGPoint, containerProxy: GeometryProxy, prevItemWidth: inout CGFloat, hSpacing: CGFloat = 5, vSpacing: CGFloat = 5) -> some View {
let viewItem = Text(t.str).padding([.leading, .trailing], 15).background(Color.blue).cornerRadius(25)
let itemWidth = UIHostingController(rootView: viewItem).view.intrinsicContentSize.width
let itemHeight = UIHostingController(rootView: viewItem).view.intrinsicContentSize.height
let newPosX = curPos.x + prevItemWidth + hSpacing
let newPosX2 = newPosX + itemWidth
if newPosX2 > containerProxy.size.width {
curPos.x = hSpacing
curPos.y += itemHeight + vSpacing
} else {
curPos.x = newPosX
}
prevItemWidth = itemWidth
return viewItem.offset(x: curPos.x, y: curPos.y)
}
}
struct AutoWrap_Previews: PreviewProvider {
static var previews: some View {
AutoWrap()
}
}
Вам нужно обрабатывать конфигурации строк сразу после текстового просмотра. Не используйте lineLimit(1), если вам нужно несколько строк.
HStack(alignment: .top, spacing: 10){
ForEach(collection.platforms, id: \.self) { platform in
Text(platform)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(10)
.multilineTextAlignment(.leading)
.padding(.all, 5)
.font(.caption)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
}
Я использовал пакет, но когда я использовал более 2WrappingHStack
вScrollView
, произошли пропадания кадров, поэтому я реализовал это как новыйRandomCollection
простым способом и сделаноWStack
доступно с iOS 13.0 или новее.
Посетите https://github.com/winkitee/WStack .
Пример кода
import SwiftUI
import WStack
struct ContentView: View {
let fruits = [
" Red Apple",
" Pear",
" Tangerine",
" Lemon",
" Banana",
" Watermelon"
]
var body: some View {
ScrollView {
VStack(alignment: .leading) {
WStack(fruits, spacing: 4, lineSpacing: 4) { fruit in
Text(fruit)
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(
fruit == " Avocado" ?
Color.indigo.opacity(0.6) :
Color.secondary.opacity(0.2)
)
.cornerRadius(20)
}
}
.padding()
}
}
}
Я нашел Framework-решение под названием ASCollectionView. Надеюсь, это поможет кому-то, у кого такая же проблема.