SwiftUI: 如何使HStack沿多行换行其子元素(类似于集合视图)?

11

我正在尝试用SwiftUI重新创建基本的集合视图行为:

我有许多视图(例如照片)横向显示在一起。当没有足够的空间在同一行上显示所有照片时,其余的照片应该换到下一行。

这是一个例子:

landscape version portrait

看起来可以使用一个带有多个HStack元素的VStack,每个HStack包含一行中的照片。

我尝试使用GeometryReader和迭代照片视图以动态创建此类布局,但它不会编译(包含声明的闭包不能与函数生成器'ViewBuilder'一起使用)。是否可以动态创建视图并返回它们?

说明:

盒子/照片的宽度可以不同(与经典“网格”不同)。棘手的部分是我必须知道当前盒子的宽度,以便决定它是否适合当前行,还是我必须开始新的一行。


您可以使用 https://github.com/Q-Mobile/QGrid 包,将其包含在您的项目中,或者仅作为自己解决方案的灵感。 - kontiki
谢谢您提供的链接。看起来很有前途,但不完全符合我的需求。在我的情况下,盒子的宽度是不同的,即第一行可能有2个,下一行可能有3个。导致困难的是我需要知道当前盒子的宽度以确定它是否适合当前行,还是必须开始新的一行。 - Mark
我写了一些关于视图偏好设置的文章,可能会有所帮助(如果需要该信息,请查看我的个人资料中博客的链接)。 - kontiki
3个回答

8

以下是我使用 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)
        }
    }
}

它不仅适用于文本,还可用于任何视图。


最好使用位置而不是偏移量,以便于MultilineHStack周围的视图正确定位而不是在元素上方。不过还是很棒的东西。 - Daniel
最终,这个答案对我来说并没有起作用,因为它定位元素的方式有误(即使使用了position而不是offset),所以我最终创建了一个GitHub项目(WrappingHStack)以便未来更容易处理。 - Daniel
谢谢这个。我对这段代码做了一些修改,使其更像一个LazyHStack,因为我无法将我的自定义视图传递到这个结构体中。我会将它作为一个答案发布,并致谢。 - JohnnyD

5

我使用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"])
    }
}

0
这是对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)
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接