/CompositionalList

Customizable SwiftUI List powered by UICollectionViewDiffableDataSource and UICollectionViewLayout

Primary LanguageSwiftMIT LicenseMIT

CompositionalList 🧩

gradienta-ix_kUDzCczo-unsplash (1) ForTheBadge built-with-love Open Source? Yes! MIT license swift-version swiftui-version xcode-version swift-package-manager

CompositionalList is a SwiftUI UIViewControllerRepresentable wrapper powered by UIKit DiffableDataSource and Compositional Layout. 🥸
It is customizable and flexible and supports multiple sections and cell selection. It allows to use of any kind of SwiftUI view inside of cells, headers, or footers.

Requirements

  • iOS 13.0 or later

Features

  • Supports multiple sections.
  • Supports adapting UI to any kind of custom layout.
  • Supports cell selection.

CompositionalList adds SwiftUI views as children of UICollectionViewCell's and UICollectionReusableView's using UIHostingController's, it takes an array of data structures defined by a public protocol called SectionIdentifierViewModel that holds a section identifier and an array of cell identifiers.

public protocol SectionIdentifierViewModel {
    associatedtype SectionIdentifier: Hashable
    associatedtype CellIdentifier: Hashable
    var sectionIdentifier: SectionIdentifier { get }
    var cellIdentifiers: [CellIdentifier] { get }
}

CompositionalList basic structure looks like this...

struct CompositionalList<ViewModel, RowView, HeaderFooterView> where ViewModel : SectionIdentifierViewModel, RowView : View, HeaderFooterView : View
  • ViewModel must conform to SectionIdentifierViewModel. To satisfy this protocol you must create a data structure that contains a section identifier, for example, an enum, and an array of objects that conform to Hashable.
  • RowView the compiler will infer the return value in the CellProvider closure as long it conforms to View.
  • HeaderFooterView must conform to View, which represents a header or a footer in a section. The developer must provide a view to satisfying the generic parameter. By now we need to return any kind of View to avoid the compiler force us to define the Types on initialization, if a header is not needed return a Spacer with a height of 0.

Getting Started

  • Read this Readme doc
  • Read the How to use section.
  • Clone the Example project as needed.

How to use.

CompositionalList is initialized with an array of data structures that conform to SectionIdentifierViewModel which represents a section, this means it can have one or X number of sections.

  • Step 1, create a section identifier like this...
public enum SectionIdentifierExample: String, CaseIterable {
    case popular = "Popular"
    case new = "New"
    case top = "Top Items"
    case recent = "Recent"
    case comingSoon = "Coming Soon"
}
  • Step 2, create a data structure that conforms to SectionIdentifierViewModel...
struct FeedSectionIdentifier: SectionIdentifierViewModel {
    let sectionIdentifier: SectionIdentifierExample // <- This is your identifier for each section.
    let cellIdentifiers: [FeedItemViewModel] // <- This is your model for each cell.
}
  • Step 3, creating a section, can be done inside a data provider view model that conforms to ObservableObject. 😉

For simplicity, here we are creating a single section, for the full code on how to create multiple sections check the example source code.

struct Remote: ObservableObject {

@Published var sectionIdentifiers: [FeedSectionIdentifier]
  
  func fetch() {
/// your code for fetching some models...
    sectionIdentifiers = [FeedSectionIdentifier(sectionIdentifier: .popular, cellIdentifiers: models)]
  }
}
  • Step4 🤖, initialize the CompositionalList with the array of section identifiers...
import CompositionalList

.....

    @ObservedObject private var remote = Remote()

    var body: some View {
       NavigationView {
    /// 5
          if items.isEmpty {
              ActivityIndicator()
          } else {
              CompositionalList(remote.sectionIdentifiers) { model, indexPath in
              /// 1
                  Group {
                     switch indexPath.section {
                     case 0, 2, 3:
                         TileInfo(artworkViewModel: model)
                     case 1:
                         ListItem(artworkViewModel: model)
                     default:
                         ArtWork(artworkViewModel: model)
                     }
                 }
             }.sectionHeader { sectionIdentifier, kind, indexPath in
             /// 2
                 TitleHeaderView(title: sectionIdentifier?.rawValue ?? "")
             }
             .selectedItem {
             /// 3
                 selectedItem = $0
             }
             /// 4
             .customLayout(.composed())
         }
      }.onAppear {
         remote.fetch()
      }
    }
  1. CellProvider closure that provides a model and an indexpath and expects a View as the return value. Here you can return different SwiftUI views for each section, if you use a conditional statement like a Switch in this case, you must use a Group as the return value. For example in this case the compiler will infer this as the return value:
Group<_ConditionalContent<_ConditionalContent<TileInfo, ListItem>, ArtWork>>
  1. HeaderFooterProvider closure that provides the section identifier, the kind which can be UICollectionView.elementKindSectionHeader or UICollectionView.elementKindSectionFooter this will be defined by your layout, and the indexPath for the corresponding section. It expects a View as a return value, you can customize your return value based on the section or if it's a header or a footer. Same as CellProvider if a conditional statement is used make sure to wrap it in a Group. This closure is required even If you don't define headers or footers in your layout you still need to return a View, in that case, you can return a Spacer with a height of 0. (looking for a more elegant solution by now 🤷🏽‍♂️).

  2. SelectionProvider closure, internally uses UICollectionViewDelegate cell did select a method to provide the selected item, this closure is optional.

  3. customLayout environment object, here you can return any kind of layout as long is a UICollectionViewLayout. You can find the code for the layout here. 😉

  4. For a reason that I still don't understand, we need to use a conditional statement verifying that the array is not empty, is handy for this case because we can return a spinner. 😬

Installation

Installation with Swift Package Manager (Xcode 11+) Swift Package Manager (SwiftPM) is a tool for managing the distribution of Swift code as well as C-family dependency. From Xcode 11, SwiftPM got natively integrated with Xcode.

CompositionalList supports SwiftPM from version 5.1.0. To use SwiftPM, you should use Xcode 11 to open your project. Click File -> Swift Packages -> Add Package Dependency, enter CompositionalList repo's URL. Or you can log in to Xcode with your GitHub account and just type CompositionalList to search.

After selecting the package, you can choose the dependency type (tagged version, branch, or commit). Then Xcode will set up all the stuff for you.

How To Collaborate

  • This repo contains a convenient Compositional Layout extension to compose different layouts, feel free to add more layouts!
  • Open a PR for any proposed change pointing it to main branch.

DEMO

k1

Important:

Folow the Example project 🤓

CompositionalList is open source, feel free to collaborate!

TODO:

  • Improve loading data, UIVIewRepresentable does not update its context, need to investigate why.
  • Investigate why we need to make a conditional statement checking if the data is empty inside the view.