UICollectionView 和 SwiftUI?

UICollectionView and SwiftUI?

如何使用 SwiftUI 创建方形项目网格(例如 iOS 照片库中的网格)?

我尝试过这种方法,但它不起作用:

var body: some View {
    List(cellModels) { _ in
        Color.orange.frame(width: 100, height: 100)
    }
}

列表仍然具有 UITableView 样式:

尝试使用 VStack 和 HStack

var body: some View {
    GeometryReader { geometry in
        VStack {
            ForEach(1...3) {_ in
                HStack {
                    Color.orange.frame(width: 100, height: 100)
                    Color.orange.frame(width: 100, height: 100)
                    Color.orange.frame(width: 100, height: 100)
                }.frame(width: geometry.size.width, height: 100)
            }
        }
    }
}

如果你想滚动,你可以包裹在 ScrollView 中

一种可能的解决方案是将您的 UICollectionView 包装成 UIViewRepresentable。请参阅 Combining and Creating Views SwiftUI 教程,其中以 MKMapView 为例。

到目前为止,SwiftUI 中还没有 UICollectionView 的等价物,而且还没有计划。请参阅 tweet.

下的讨论

要了解更多详细信息,请查看 Integrating SwiftUI WWDC 视频(~8:08)。

更新:

自 iOS 14(测试版)起,我们可以使用 Lazy*Stack 至少在 SwiftUI 中实现集合视图的性能。关于单元格的布局,我认为我们仍然需要在 per-row/per-column 的基础上手动管理它。

我想你可以像这样使用滚动视图

struct MovieItemView : View {
    var body: some View {
        VStack {
            Image("sky")
                .resizable()
                .frame(width: 150, height: 200)
            VStack {
                Text("Movie Title")
                    .font(.headline)
                    .fontWeight(.bold)
                Text("Category")
                    .font(.subheadline)
            }
        }
    }
}

struct MoviesView : View {
    var body: some View {
        VStack(alignment: .leading, spacing: 10){
            Text("Now Playing")
                .font(.title)
                .padding(.leading)
            ScrollView {
                HStack(spacing: 10) {
                    MovieItemView()
                    MovieItemView()
                    MovieItemView()
                    MovieItemView()
                    MovieItemView()
                    MovieItemView()
                }
            }
            .padding(.leading, 20)
        }
    }
}

在SwiftUI中思考,有一个简单的方法:

struct MyGridView : View {
var body: some View {
    List() {
        ForEach(0..<8) { _ in
            HStack {
                ForEach(0..<3) { _ in
                    Image("orange_color")
                        .resizable()
                        .scaledToFit()
                }
            }
        }
    }
}

}

如果需要,SwiftUI 就足够了,有时您需要忘记例如 UIColectionView..

我编写了一个名为 GridStack 的小组件,它可以生成一个可根据可用宽度进行调整的网格。即使当你旋转 iPad.

时动态变化

https://github.com/pietropizzi/GridStack

该实现的要点与其他人在此回复的类似(因此 HStacks 在 VStack 内),不同之处在于它根据可用宽度和 a配置你传过去。

  • 使用 minCellWidth,您可以定义您希望网格中的项目应具有的最小宽度
  • 使用 spacing 可以定义网格中项目之间的 space。

例如

GridStack(
    minCellWidth: 320,
    spacing: 15,
    numItems: yourItems.count
) { index, cellWidth in
    YourItemView(item: yourItems[index]).frame(width: cellWidth)
}

QGrid 是我创建的一个小型库,它使用与 SwiftUI 的 List 视图相同的方法,通过从已识别数据的基础集合中按需计算其单元格:

在最简单的形式中,QGrid 可以与 View 正文中的这一行代码一起使用,假设您已经有自定义单元格视图:

struct PeopleView: View {
  var body: some View {
    QGrid(Storage.people, columns: 3) { GridCell(person: [=10=]) }
  }
}   

struct GridCell: View {
  var person: Person
  var body: some View {
    VStack() {
      Image(person.imageName).resizable().scaledToFit()
      Text(person.firstName).font(.headline).color(.white)
      Text(person.lastName).font(.headline).color(.white)
    }
  }
}


您还可以自定义默认布局配置:

struct PeopleView: View {
  var body: some View {
    QGrid(Storage.people,
          columns: 3,
          columnsInLandscape: 4,
          vSpacing: 50,
          hSpacing: 20,
          vPadding: 100,
          hPadding: 20) { person in
            GridCell(person: person)
    }
  }
} 

请参考 GitHub 存储库中的演示 GIF 和测试应用程序:

https://github.com/Q-Mobile/QGrid

签出基于 ZStack 的示例 here

Grid(0...100) { _ in
    Rectangle()
        .foregroundColor(.blue)
}

XCode 11.0

在寻找了一段时间后,我决定我想要 UICollectionView 的所有便利和性能。所以我实现了 UIViewRepresentable 协议。

此示例未实现 DataSource,并且在集合视图上有一个虚拟 data: [Int] 字段。 您可以在 AlbumGridView 上使用 @Bindable var data: [YourData] 以在数据更改时自动重新加载视图。

AlbumGridView 然后可以像 SwiftUI 中的任何其他视图一样使用。

代码

class AlbumPrivateCell: UICollectionViewCell {
    private static let reuseId = "AlbumPrivateCell"

    static func registerWithCollectionView(collectionView: UICollectionView) {
        collectionView.register(AlbumPrivateCell.self, forCellWithReuseIdentifier: reuseId)
    }

    static func getReusedCellFrom(collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> AlbumPrivateCell{
        return collectionView.dequeueReusableCell(withReuseIdentifier: reuseId, for: indexPath) as! AlbumPrivateCell
    }

    var albumView: UILabel = {
        let label = UILabel()
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(self.albumView)

        albumView.translatesAutoresizingMaskIntoConstraints = false

        albumView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
        albumView.leftAnchor.constraint(equalTo: contentView.leftAnchor).isActive = true
        albumView.rightAnchor.constraint(equalTo: contentView.rightAnchor).isActive = true
        albumView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
    }

    required init?(coder: NSCoder) {
        fatalError("init?(coder: NSCoder) has not been implemented")
    }
}

struct AlbumGridView: UIViewRepresentable {
    var data = [1,2,3,4,5,6,7,8,9]

    func makeUIView(context: Context) -> UICollectionView {
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
        collectionView.backgroundColor = .blue
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.dataSource = context.coordinator
        collectionView.delegate = context.coordinator

        AlbumPrivateCell.registerWithCollectionView(collectionView: collectionView)
        return collectionView
    }

    func updateUIView(_ uiView: UICollectionView, context: Context) {
        //
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
        private let parent: AlbumGridView

        init(_ albumGridView: AlbumGridView) {
            self.parent = albumGridView
        }

        // MARK: UICollectionViewDataSource

        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            self.parent.data.count
        }

        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let albumCell = AlbumPrivateCell.getReusedCellFrom(collectionView: collectionView, cellForItemAt: indexPath)
            albumCell.backgroundColor = .red

            return albumCell
        }

        // MARK: UICollectionViewDelegateFlowLayout

        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
            let width = collectionView.frame.width / 3
            return CGSize(width: width, height: width)
        }
    }
}

截图

根据 Will 的回答,我将其全部封装在 SwiftUI ScrollView 中。 所以你可以实现水平(在这种情况下)或垂直滚动​​。

它还使用 GeometryReader,因此可以使用屏幕尺寸进行计算。

GeometryReader{ geometry in
 .....
 Rectangle()
    .fill(Color.blue)
    .frame(width: geometry.size.width/6, height: geometry.size.width/6, alignment: .center)
 }

这是一个工作示例:

import SwiftUI

struct MaterialView: View {

  var body: some View {

    GeometryReader{ geometry in

      ScrollView(Axis.Set.horizontal, showsIndicators: true) {
        ForEach(0..<2) { _ in
          HStack {
            ForEach(0..<30) { index in
              ZStack{
                Rectangle()
                  .fill(Color.blue)
                  .frame(width: geometry.size.width/6, height: geometry.size.width/6, alignment: .center)

                Text("\(index)")
              }
            }
          }.background(Color.red)
        }
      }.background(Color.black)
    }

  }
}

struct MaterialView_Previews: PreviewProvider {
  static var previews: some View {
    MaterialView()
  }
}

因为我没有使用 Catalina Beta,所以我在这里写了我的代码,你可以 运行 在 Xcode 11 (Mojave) 作为一个游乐场来利用 运行-time编译和预览

基本上,当您寻找网格方法时,您应该记住 SwiftUI 子视图从父视图获取理想的大小参数,因此它们可以根据自己的内容自动适应,这种行为可以被覆盖(不要混淆使用 swift 覆盖指令)通过 .frame(...) 方法强制视图为特定大小。

在我看来,这使得 View 行为稳定,并且 Apple SwiftUI 框架已经过正确测试。

import PlaygroundSupport
import SwiftUI

struct ContentView: View {

    var body: some View {

        VStack {
            ForEach(0..<5) { _ in
                HStack(spacing: 0) {
                    ForEach(0..<5) { _ in
                        Button(action: {}) {
                            Text("Ok")
                        }
                        .frame(minWidth: nil, idealWidth: nil, maxWidth: .infinity, minHeight: nil, idealHeight: nil, maxHeight: .infinity, alignment: .center)
                        .border(Color.red)
                    }
                }
            }
        }
    }
}

let contentView = ContentView()
PlaygroundPage.current.liveView = UIHostingController(rootView: contentView)

我自己一直在解决这个问题,并以@Anjali 和@phillip (the work of Avery Vine) 上面发布的源代码为基础,我包装了一个 UI CollectionView 是功能...ish?它将根据需要显示和更新网格。我还没有尝试过更多可定制的视图或任何其他东西,但就目前而言,我认为它会做。

我在下面评论了我的代码,希望它对某人有用!

首先是包装器。

struct UIKitCollectionView: UIViewRepresentable {
    typealias UIViewType = UICollectionView

    //This is where the magic happens! This binding allows the UI to update.
    @Binding var snapshot: NSDiffableDataSourceSnapshot<DataSection, DataObject>

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: UIViewRepresentableContext<UIKitCollectionView>) -> UICollectionView {

        //Create and configure your layout flow seperately
        let flowLayout = UICollectionViewFlowLayout()
        flowLayout.sectionInsets = UIEdgeInsets(top: 25, left: 0, bottom: 25, right: 0)


        //And create the UICollection View
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)

        //Create your cells seperately, and populate as needed.
        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "customCell")

        //And set your datasource - referenced from Avery
        let dataSource = UICollectionViewDiffableDataSource<DataSection, DataObject>(collectionView: collectionView) { (collectionView, indexPath, object) -> UICollectionViewCell? in
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "customCell", for: indexPath)
            //Do cell customization here
            if object.id.uuidString.contains("D") {
                cell.backgroundColor = .red
            } else {
                cell.backgroundColor = .green
            }


            return cell
        }

        context.coordinator.dataSource = dataSource

        populate(load: [DataObject(), DataObject()], dataSource: dataSource)
        return collectionView
    }

    func populate(load: [DataObject], dataSource: UICollectionViewDiffableDataSource<DataSection, DataObject>) {
        //Load the 'empty' state here!
        //Or any default data. You also don't even have to call this function - I just thought it might be useful, and Avery uses it in their example.

        snapshot.appendItems(load)
        dataSource.apply(snapshot, animatingDifferences: true) {
            //Whatever other actions you need to do here.
        }
    }


    func updateUIView(_ uiView: UICollectionView, context: UIViewRepresentableContext<UIKitCollectionView>) {
        let dataSource = context.coordinator.dataSource
        //This is where updates happen - when snapshot is changed, this function is called automatically.

        dataSource?.apply(snapshot, animatingDifferences: true, completion: {
            //Any other things you need to do here.
        })

    }

    class Coordinator: NSObject {
        var parent: UIKitCollectionView
        var dataSource: UICollectionViewDiffableDataSource<DataSection, DataObject>?
        var snapshot = NSDiffableDataSourceSnapshot<DataSection, DataObject>()

        init(_ collectionView: UIKitCollectionView) {
            self.parent = collectionView
        }
    }
}

现在,DataProvider class 将允许我们访问该可绑定快照并在需要时更新 UI。 class 对于正确更新集合视图来说 必不可少。模型 DataSectionDataObjectAvery Vine 提供的模型结构相同 - 所以如果您需要这些,请看那里。

class DataProvider: ObservableObject { //This HAS to be an ObservableObject, or our UpdateUIView function won't fire!
    var data = [DataObject]()

    @Published var snapshot : NSDiffableDataSourceSnapshot<DataSection, DataObject> = {
        //Set all of your sections here, or at least your main section.
        var snap = NSDiffableDataSourceSnapshot<DataSection, DataObject>()
        snap.appendSections([.main, .second])
        return snap
        }() {
        didSet {
            self.data = self.snapshot.itemIdentifiers
            //I set the 'data' to be equal to the snapshot here, in the event I just want a list of the data. Not necessary.
        }
    }

    //Create any snapshot editing functions here! You can also simply call snapshot functions directly, append, delete, but I have this addItem function to prevent an exception crash.
    func addItems(items: [DataObject], to section: DataSection) {
        if snapshot.sectionIdentifiers.contains(section) {
            snapshot.appendItems(items, toSection: section)
        } else {
            snapshot.appendSections([section])
            snapshot.appendItems(items, toSection: section)
        }
    }
}

现在,CollectionView 将展示我们的新系列。我制作了一个带有一些按钮的简单 VStack,因此您可以看到它的实际效果。

struct CollectionView: View {
    @ObservedObject var dataProvider = DataProvider()

    var body: some View {
        VStack {
            UIKitCollectionView(snapshot: $dataProvider.snapshot)
            Button("Add a box") {
                self.dataProvider.addItems(items: [DataObject(), DataObject()], to: .main)
            }

            Button("Append a Box in Section Two") {
                self.dataProvider.addItems(items: [DataObject(), DataObject()], to: .second)
            }

            Button("Remove all Boxes in Section Two") {
                self.dataProvider.snapshot.deleteSections([.second])
            }
        }
    }
}

struct CollectionView_Previews: PreviewProvider {
    static var previews: some View {
        CollectionView()
    }
}

仅供那些视觉参考(是的,这是 Xcode 预览 window 中的 运行:

我们开发了一个 swift 包,它提供了一个功能齐全的 CollectionView 以供在 SwiftUI 中使用。

Find it here: https://github.com/apptekstudios/ASCollectionView

它的设计易于使用,但也可以充分利用新的 UICollectionViewCompositionalLayout 进行更复杂的布局。它支持自动调整单元格大小。

要实现网格视图,您可以按如下方式使用它:

import SwiftUI
import ASCollectionView

struct ExampleView: View {
    @State var dataExample = (0 ..< 21).map { [=10=] }

    var body: some View
    {
        ASCollectionView(data: dataExample, dataID: \.self) { item, _ in
            Color.blue
                .overlay(Text("\(item)"))
        }
        .layout {
            .grid(layoutMode: .adaptive(withMinItemSize: 100),
                  itemSpacing: 5,
                  lineSpacing: 5,
                  itemSize: .absolute(50))
        }
    }
}

有关更复杂布局的示例,请参阅演示项目。

更新:这个答案与 iOS 13 有关。对于 iOS 14,我们有 LazyGrids + 很多东西,遵循这个答案不会有帮助。

要在不使用 UIKit 的情况下制作 CollectionView,首先我们需要一个数组扩展。数组扩展将帮助我们将我们想要制作 TableView 的数组分块。下面是扩展的代码,+ 3 个示例。要进一步了解此扩展程序的工作原理,请查看此站点,我从该站点复制了扩展程序:https://www.hackingwithswift.com/example-code/language/how-to-split-an-array-into-chunks

    extension Array {
    func chunked(into size: Int) -> [[Element]] {
        return stride(from: 0, to: count, by: size).map {
            Array(self[[=10=] ..< Swift.min([=10=] + size, count)])
        }
    }
}

let exampleArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

print(exampleArray.chunked(into: 2)) // prints [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10], [11, 12]]

print(exampleArray.chunked(into: 3)) // prints [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]

print(exampleArray.chunked(into: 5)) // prints [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12]]

现在让我们创建 SwiftUI 视图:

struct TestView: View {
    
    let arrayOfInterest = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].chunked(into: 4)
    // = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16], [17, 18]] 
    
    var body: some View {
        
        return VStack {
            
            ScrollView {
                
                VStack(spacing: 16) {
                    
                    ForEach(self.arrayOfInterest.indices, id:\.self) { idx in
                        
                        HStack {
                            
                            ForEach(self.arrayOfInterest[idx].indices, id:\.self) { index in
                                
                                HStack {
                                    
                                    Spacer()
                                    Text("\(self.arrayOfInterest[idx][index])")
                                        .font(.system(size: 50))
                                    .padding(4)
                                        .background(Color.blue)
                                        .cornerRadius(8)
                                    
                                    Spacer()
                                    
                                }
                                
                            }
                            
                        }
                        
                    }
                    
                }
                
            }
            
        }
        
    }
    
}


struct TestView_Preview : PreviewProvider {
    
    static var previews: some View {
            TestView()
        }
    
}

Image of Preview of the code above

解释:

首先我们需要弄清楚我们需要多少列并将该数字放入我们的分块扩展中。在我的示例中,我们有一个从 1 到 18 的数字数组(arrayOfInterest),我们想在我们的视图中显示它,我决定我希望我的视图有 4 列,所以我将它分成 4(所以 4 是数字我们的专栏)。

要制作一个 CollectionView,最明显的是我们的 CollectionView 是一个项目列表,所以它应该在一个列表中以使其易于滚动(不,不要那样做!改用 ScrollView。我当这 2 个 foreach 在列表中时,我看到了奇怪的行为)。 在 ScrollView 之后我们有 2 个 ForEach s,第一个使我们能够根据需要循环尽可能多的行,而第二个帮助我们创建列。

我知道我没有完美地解释代码,但我确信它值得与您分享,这样可以让您 table 更轻松地查看。 This Image is an early example of a real app i'm making,它看起来与 CollectionView 无异,所以你可以确定这种方法很有效。

问题:拥有一个数组并试图让 swift 为 foreach 创建这些索引有什么意义?
这很简单!如果您有一个在运行时定义其 values/number-of-values 的数组,例如你从网络上获取数字 api 并且 api 告诉你数组中有多少数字,那么你需要使用这样的方法并让 swift 小心foreachs 的索引数。

更新:

更多信息,阅读这些是可选的。

LIST VS SCROLLVIEW:有些人可能不知道,列表的工作方式与滚动视图略有不同。当您创建滚动视图时,它总是会计算整个滚动视图,然后将其显示给我们。但是 list 不这样做,当使用列表时,swift 自动计算仅显示当前视图所需的列表的几个组件,当您向下滚动到列表底部时,它只会替换旧的滚动出的值,以及屏幕底部的新值。所以一般来说,list 总是更轻,当你处理一个沉重的视图时,它会快得多,因为它不会在一开始就计算你所有的视图,只计算必要的东西,而 ScrollView 则不会。

为什么你说我们应该使用 SCROLLVIEW 而不是 LIST? 正如我之前所说,您可能不喜欢与列表的一些交互。例如,当创建一个列表时,每一行都是可点击的,这很好,但不好的是只有整行是可点击的!这意味着您不能为一行的左侧设置点击操作,而为右侧设置不同的点击操作!这只是 List() 的奇怪交互之一 这要么需要一些我没有的知识!或者是一个大 xcode-ios 问题,或者它可能很好并且符合预期!我认为这是一个苹果问题,我希望它最多能在下一次 WWDC 之前得到解决。 (更新:当然,通过为 iOS14-SwiftUI 引入 LazyGrids 等所有东西,它当然得到了修复)

有什么方法可以解决这个问题? 据我所知,唯一的方法是使用 UIKit。我用 SwiftUI 尝试了很多方法,虽然我发现你可以从 ActionSheet 和 ContextMenu 获得帮助,当你点击它们时在选项方面更好地制作列表,但我无法从中获得最佳的预期功能一个 SwiftUI 列表。所以从我的观点来看,SwiftUI 开发者现在只能等待了。

厌倦了寻找许多复杂的解决方案或 Github 库,我决定自己做一个简单而漂亮的 数学 解决方案。

  1. 认为您有一系列项目 var items : [ITEM] = [...YOUR_ITEMS...]
  2. 您想显示 Nx2
  3. 的网格

When N is the number of ROWS and 2 is the number of COLUMNS

  1. 要显示所有项目,您需要使用两个 ForEach 语句,一个用于列,一个用于行。

Into both ForEach: (i) current index of ROWS, and (j) current index of COLUMNS

  1. 显示索引中的当前项[(i * 2) + j ]
  2. 现在我们来看代码:

Note: Xcode 11.3.1

var items : [ITEM] = [...YOUR_ITEMS...]
var body: some View {
VStack{
    // items.count/2 represent the number of rows
    ForEach(0..< items.count/2){ i in
        HStack(alignment: .center,spacing: 20){
            //2 columns 
            ForEach(0..<2){ j in
               //Show your custom view here
               // [(i*2) + j] represent the index of the current item 
                ProductThumbnailView(product: self.items[(i*2) + j])
            }
        }
        }.padding(.horizontal)
    Spacer()
   }
}

虽然下一届 WWDC 即将召开,但我们仍然缺少 SwiftUI 中的集合视图。我已尝试提供一个很好的示例,说明如何使用 UIViewControllerRepresentableCombine 创建您自己的 SwiftUI collection view。我选择创建自己的集合视图而不是使用开源库,因为我觉得它们缺少一些关键功能或者因为太多东西而变得臃肿。在我的示例中,我使用了 UICollectionViewDiffableDataSourceUICollectionViewCompositionalLayout 来创建集合视图。它同时支持 pullToRefreshpagination 功能。

可以在以下位置找到完整的实现:https://github.com/shabib87/SwiftUICollectionView

iOS 14 和 XCode 12

SwiftUI for iOS 14 带来了一个易于使用的新原生网格视图,称为 LazyVGridhttps://developer.apple.com/documentation/swiftui/lazyvgrid

您可以从定义 GridItem 的 数组开始。 GridItems 用于指定每一列的布局属性。在这种情况下,所有 GridItem 都是灵活的。

LazyVGrid 将 GridItem 的数组作为其参数,并根据定义的 GridItems 显示包含的视图。

import SwiftUI

struct ContentView: View {
    
    let columns = [
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible()),
        GridItem(.flexible())
    ]
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns) {
                ForEach(0...100, id: \.self) { _ in
                    Color.orange.frame(width: 100, height: 100)
                }
            }
        }
    }
}