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 также есть обновленный и улучшенный вариант решения ниже с переносом и динамической высотой

Вот результат:

Swiftui упакованный макет

А вот и полный демонстрационный код (ориентация поддерживается автоматически):

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. Надеюсь, это поможет кому-то, у кого такая же проблема.

Другие вопросы по тегам