The missing collection view in SwiftUI (2023)

The missing collection view in SwiftUI (1)

Adams Angst

April 29, 2020 • Reading time 16 minutes

The missing collection view in SwiftUI (2)

Hello SwiftUI!

June 2019 was overwhelming for the Swift Developers Community. During WWDC, Apple announced a brand new framework for building user interfaces - SwiftUI.

(Video) Mobile Warsaw #78 – Adam Niepokój – The Missing CollectionView in SwiftUI

In short, it's a declarative framework that can massively reduce the time it takes to create and refactor views in our apps. This blog post is intended for people who already have a basic understanding of SwiftUI. Otherwise, this is a good introduction to the topicSession number 204 of WWDC 2019– read first and then come back here!

The missing part of SwiftUI

After the announcement, developers from all over the world started exploring the possibilities of SwiftUI and were mostly amazed. But as the excitement mounted, some of them were quick to realize that a track we all love is sadly missing. There is no direct equivalent to our belovedUICollectionView! For me personally, it was quite strange since this control is commonly used in apps across the App Store. Luckily, some of us had to check with the source on how to handle it. Here's what they said.

The missing collection view in SwiftUI (3)

The Apple approach

According to a post on Avery Vine's blog, Apple's proposed approach is to integrate the Collection View - built with our old friend UIKit - into SwiftUI apps. The whole topic of integration between these two frameworks was covered in WWDC 2019 Talk #231 as well as the blog post mentioned above (short for this specific case). Basically we need to do the following:

  • Create a structure implementationUIViewRepresentableProtocol that wraps UICollectionView in a renderable form in SwiftUI.
  • ImmakeUIView (Context:)method of this structure, make sure you create a collection view.
  • A... createCoordinatorObject that will feed our collection with datacontextObject.

That seemed like a lot of work to me, and it definitely lacked the feel of a SwiftUI adventure.

The missing collection view in SwiftUI (4)

The most obvious thing - play with SwiftUI

Not happy with Apple's approach, I did what any other developer would do (after going through StackOverflow) - I started playing with what's out there. The default component for rendering records in SwiftUI is aList, which is basically an equivalent ofUITableView –so no horizontal scrolling and no custom layouts. And I really wanted that!

Stack & Scroll

Before moving on to implementing the collection in SwiftUI, I would like to recommend an already available solution. Created by Karol Kulesza,QGridis a reusable collection view that might be useful to you!

Note: In the following code examples I will use some classes (e.g.Pokémon) that were not previously declared - you can refer to themdieses Github-Repositoryfor their explanations :)

(Video) SwiftUI Collection View 2019 - Scrolling List in Swift UI - Xcode 11 SwiftUI Tutorial

Creating a horizontally scrollable object was pretty quick and intuitive. Luckily, SwiftUI provides an object calledScrollView,what combined withHStackand some more SwiftUI magic, gives us what we want. Here's how:

  • First we need data. In order to work with what's coming in the next few steps, the objects we want to display need to implement thatIdentifiableProtocol. It is a requirement ofFor each-Loop to correctly identify objects from the specified collection.
struct PokemonImage: Identifiable { var id: Int { pokemon.id } var pokemon: Pokemon var image: UIImage}
  • Now that we know what we want to display, we need to tell SwiftUI how to do that. So we need a cell object.
struct PokemonCell: View { let pokemon: PokemonImage var body: some View { VStack { Image(uiImage: pokemon.image) .resizable() .frame(maxWidth: 100, maxHeight: 100) Text(pokemon.pokemon.pokeName) .fontWeight (.halbfett) .padding([.leading, .trailing, .bottom], 5) } }}
  • Finally, we can work on the collection view itself. What we need is a horizontal ScrollView with embedded HStack and ForEach loop. Just like this one:
struct CollectionView: view {
@State var pokemons = [PokemonImage]() /// Don't forget to fill it with data!

var body: some View {
ScrollView(.horizontal) {HStack {ForEach(pokemons) {PokemonCell(pokemon: $0) .background(Color.yellow) .cornerRadius(5) .padding(10)} }
}
}}

The final effect might look like this:

The missing collection view in SwiftUI (5)
The missing collection view in SwiftUI (6)

That was easy, wasn't it? SwiftUI magic! 🤩🧙‍♂️ You can of course do the same thing vertically, but then you get aListBasically.

While it looked good, it still lacked some of the features I really wanted - the ability to customize the layout of cells and slow-load new views (as inListor in similar controls in UIKit). Beyond that, I wanted to land with something generic! And that's not all.

It clearly wasn't enough in my quest for a collection view in SwiftUI, but if you're looking for quick and easy fixes - this might be your chance!

Generic Collection View - my research

To achieve all of the above, I started from scratch. The end effect is quite satisfying; look below:

The missing collection view in SwiftUI (7)
The missing collection view in SwiftUI (8)
The missing collection view in SwiftUI (9)
(Video) SwiftUI CollectionView | SwiftUI for beginners

Lazy Loading - Different layout types - General

ingredients

To implement a collection view like this, I needed 6 key ingredients, some of which were more crucial to the overall implementation and others just adding a final polished layer. But in the end all 6 were necessary for success.

Size management using GeometryReader

In general, SwiftUI lays out views from top (parent) to bottom (child). Level by level the children are told what size they should be. But as we all know, some children may not want to listen to their parents. SwiftUI will help you with thatGeometryReader. It's basically a different view, but with a secret stash that includes size properties. These properties (size, border, etc.) can be used by child viewsdetermine their own size based on what their parents decided for them.We can use GeometryReader by wrapping it around the view we want to display, then reading the size suggested by the parent and finally tweaking. In our case we would use it to get the actual size and pass the collection view to calculate the size of the subviews.

typealias CollectionViewElementSize<Elements> = [Elements.Element.ID: CGSize] where Elements: RandomAccessCollection, Elements.Element: Identifiableenum CollectionViewLayout { case flow
/// More examples: single line and multiline see the Github repository linked above! func layout<Elements>(for: elements, containerSize: CGSize, sizes: CollectionViewElementSize<Elements>) -> CollectionViewElementSize<Elements> { switch self { case .flow: return flowLayout(for: elements, containerSize: containerSize, size: size ) /// will be declared later } }}struct SwiftUICollectionView<Elements, Content>: view whereElements: RandomAccessCollection, /// crucial: RandomAccessCollection required by for eachContent: View,Elements.Element: Identifiable /// required by for each{private var layout: CollectionViewLayout@State private var sizes: CollectionViewElementSize<Elements> = [:] // here we store sizes of elements var body: some View { GeometryReader { Proxy in /// container sizeself.bodyFor(self.layout,containerSize: proxy. size,offsets: self.layout.layout( for: self.pagedCollection.dataDisplayed, /// will be declared later containerSize: proxy.size, sizes: self.sizes ))}}
private func bodyFor( _ layout: CollectionViewLayout, containerSize: CGSize, offsets: CollectionViewElementSize<Elements> ) -> some View { switch layout { case .flow:return AnyView(flowLayoutBody(containerSize: containerSize, offsets: offsets)) /// will im nächsten Schritt hinzugefügt werden } }}

Communicating features up the hierarchy with PreferenceKey

We already know that some kids views might want a different size than their parents told them. Actually, in this case, all views are like that! So in order for them to live together on screen, we need to somehow communicate their properties up the view hierarchy. SwiftUI has a tool for this calledPreferenceKey. In our case, pairs of values ​​are stored – the ID of the object and its size. To propagate a child's value to a parent, we need to use its view wrapper to send the value of the key so the parent view can respond to the changes. Be careful here! You don't want to end up in a recursive loop. In our case we should store propagated sizes in a property calledsizes,which are then passed to an object that calculates the layout positions of collection cells.

private func flowLayoutBody(containerSize: CGSize,offsets: CollectionViewElementSize) -> some View{/// properties important for ScrollView body - will be added later. return ScrollView(.horizontal) { /// will be added later } .onPreferenceChange( CollectionViewSizeKey.self) { self.sizes = $0 }}private struct PropagateSize<V: View, ID: Hashable>: View {var content: Vvar id: IDvar body: some View {content.background(GeometryReader { proxy inColor.clear. Preference(key: CollectionViewSizeKey.self, value: [self.id: Proxy.Size])})}}private struct CollectionViewSizeKey<ID: Hashable>: PreferenceKey { typealias Value = [ID: CGSize] static var defaultValue: [ID: CGSize] { [:] } Static function Reduce(Value: inout [ID: CGSize], nextValue: () -> [ID: CGSize]) { value.merge (nextValue(), uniquingKeysWith: { $1 })}}

Layout the collection with ZStack

To lay out the views in SwiftUI we need to use stacks:HStack,VStapel,andZStack. HStack and VStack allow us to arrange the views on an axis (horizontal and vertical respectively). Only theZStackallows us to specify view positions on both axes. Since we want to lay out our views without axis constraints, we useZStack.By default,ZStackWe'll try to use as little screen as possible, but we want all the available space to be used! To achieve that, we need to use a little hack. By setting acolour.clearObject in the stack together with our collection, we will force it to occupy all possible space.

ZStackhas noDistanceproperty because it doesn't make sense (since it works on the Z axis). Instead, we should use theOffset()Modifier in which we indicate the desired position of the collection cell.

To be able to scroll naturallyZStackmust be enteredScrollViewbecause stacks don't offer this functionality by default.

/// Add this to the SwiftUICollectionView properties

private var contentView: (Elements.Element) -> content // cell by line at the index path of /// Add this to the flowLayoutBody() method declared above
ZStack(Ausrichtung: .topLeading) {ForEach(pagedCollection.dataDisplayed) {PropagateSize(content: self.contentView($0).embededInNavigationLink, id: $0.id).offset(offsets[$0.id] ?? CGSize.zero). animation(Animation.spring()).onFrameChange {/// View modifier body - wird später hinzugefügt} }Color.clear.frame(width: containerSize.width, height: containerSize.height)}

Calculating cells that are offset independently of the UI framework

The last point regarding the layout is the calculation of the offsets of the collection cells. This can be done independently of the UI framework. It's explained quite nicely inone of the episodes of Swift Talk. Depending on your layout type and preferences, you can calculate it differently. When implementing the flow layout, I decided to follow the approach shown in the Swift Talk episode, which you can check out below:

(Video) Create User Profile Page With SwiftUI Using A Collection View

/// Add this to CollectionViewLayout enumprivate func flowLayout<Elements> (für Elemente: Elements,containerSize: CGSize,sizes: CollectionViewElementSize<Elements>) -> CollectionViewElementSize<Elements> {var state = FlowLayout(containerSize: containerSize)var result: CollectionViewElementSize <Elements> = [:]für Element in Elementen {let rect = state.add(Element: sizes[element.id] ?? .zero)result[element.id] = CGSize(width: rect.origin.x, height : rect.origin.y)}Ergebnis zurückgeben}private struct FlowLayout {let space: UIOffsetlet containerSize: CGSizevar current = CGPoint.zerovar lineHeight = CGFloat.zero init(containerSize: CGSize, spaced: UIOffset = UIOffset(horizontal: 10, vertical: 10)) {self.spacing = spaceing self.containerSize = containerSize}mutating func add(element size: CGSize) -> CGRect {if current.x + size.width > containerSize.width {current.x = 0current.y += lineHeight + space.verticallineHeight = 0}defer {lineHeight = max(lineHeight, size.height)current.x += size.width + s pacing.horizontal} return CGRect(origin: current, size: size)}}

Feed the collection view with data - piece by piece

Due to its declarative nature, SwiftUI renders the entire provided dataset at once. So imagine you have a collection view that needs to display hundreds of cells or update the data source based on input from a network. By default, SwiftUI re-renders the entire dataset because the views it contains are structures. In order to keep the partially constant state of the data source somewhere, we need to wrap it (along with the collection view object). Now that we've properly packaged the collection, we need to pass it to the view using@EnvironmentObject Property Wrapper,So it is not recreated each time the view is re-rendered.@EnvironmentObjectinserts the data into the view from the outside and protects it from changes in the view.

/// WrappedInt and PagedCollectionParameters are helper classes,
/// refer to the github repository linked above for their implementation

/// Add this class to the project

final class PagedRandomAccessCollection<Elements>: ObservableObject where Elements: RandomAccessCollection{private var collection: Elements { willSet { if newValue.count < collection.count { observablePage.value = newValue.count / pageSize - 1 } } } private var currentPage: Int = 0private var observablePage: WrappedInt // Hilfsklasse, siehe Repo oben.private var pageSize: Intprivate var pages: [Elements.SubSequence] {return collection.split(size: pageSize)} private var hasNextPage: Bool { return collection.count / pageSize - 1 > currentPage } var canGetNextPage = true @Published var dataDisplayed: [Elements.Element] = []// Für die Methodenimplementierung siehe das oben verlinkte Github-Repository :)}

/// Add this wrapper view to the project
struct LazySwiftUICollectionView<Elements, Content>: View whereElements: RandomAccessCollection,Content: View,Elements.Element: Identifiable{private var pagedCollection: PagedRandomAccessCollection<Elements>private var layout: CollectionViewLayoutprivate var contentView: (Elements.Element) -> Contentinit( data: PagedRandomAccessCollection<Elements>, Layout: CollectionViewLayout, contentView: @escaping (Elements.Element) -> Content ) { self.pagedCollection = data self.layout = layout self.contentView = contentView } var body: some View {LazySwiftUIPagedCollectionViewProvider<Elements, Content >(layout: layout, contentView: contentView).environmentObject(pagedCollection)}private struct LazySwiftUIPagedCollectionViewProvider<Elements, Content>: View where Elements: RandomAccessCollection, Content: View, Elements.Element: Identifiable { @EnvironmentObject private var pagedCollection: PagedRandomAccessCollectionvar body: einige Ansicht {SwiftUICollectionView(pagedData: pagedCollection,Layout:lay aus) {self.contentView($0)}}}}

Catch the scrolling view reaching its end

In order to properly use paged collections, we need to query the piece of data at the right moment. In UIKit we would only observe scroll view events, but SwiftUI doesn't provide such an API. So we have to somehow generate it ourselves by observing the frame change of cells. Here's a handy extension to the view structure that implements a modifier to do just that.

/// Add this extension wherever it suits you .frame(in: .global))}}))} private func beforeReturn(_ onBeforeReturn: () -> ()) -> Self { onBeforeReturn() return self }}

Based on the calculated maximum offset we can determine when we will reach the end of the rendered cells and add some more data to show the data set.

/// Add this to the body of SwiftUICollectionView's flowLayoutBody method (before returning): let maxOffset = offsets.map { $0.value.height }.max()let padding = maxOffset == nil ? CGFloat.zero : maxOffset! - 3 * containerSize.height / 4self.pagedCollection.canGetNextPage = true

/// Fill the onFrameChange view modifier in this method:
.onFrameChange { Frame in if -frame.origin.y > padding && self.pagedCollection.canGetNextPage { self.pagedCollection.nextPage() } }

One thing is also worth noting here: ZStack embedded in ScrollView only shows us things that fit on the screen. In order to scroll "beyond" the edge of the screen, we need to add properlyUpholstery,calculated as the maximum value of the cell offsets.

/// Add fill view modifier to ScrollView returned in SwiftUICollectionView's flowLayoutBody method.

Conclusions

And that's it! It's not particularly easy or intuitive to get a fully-functional collection view in SwiftUI, but it's possible. If you want to be puristic about SwiftUI usage, you can do it as I described. On the other hand, if you want something fast and simple, you can just combine Stack and ScrollView. Finally, if you need to be pixel perfect and sure you've got every possible case covered, go with the Apple approach.

If you want to recap everything you've learned here, feel free to check it outmein Github-Repositorywith all the code – have fun!

(Video) (2020) SwiftUI - Expanding Views (Inspired by the AppStore) - 40 Minutes - Advanced

Photo ofKaren WardazarjananUnsplash

Videos

1. CollectionView Paging Layout for SwiftUI and Layout Designer
(Stewart Lynch)
2. SwiftUI Collection View - Project 11
(AppsinMotion)
3. UICollectionView Tutorial
(Code Pro)
4. The missing piece when you want to use Combine with UIKit - Create 2-way bindings from UI elements
(Karin Prater)
5. Swift 4.2 Xcode Tutorial - Collection View Part 3 - iOS 12 Geeky Lemon Development
(GeekyLemon)
6. Swift 4.2 Xcode Tutorial - Collection View Part 2 - iOS 12 Geeky Lemon Development
(GeekyLemon)

References

Top Articles
Latest Posts
Article information

Author: Chrissy Homenick

Last Updated: 05/22/2023

Views: 5333

Rating: 4.3 / 5 (54 voted)

Reviews: 85% of readers found this page helpful

Author information

Name: Chrissy Homenick

Birthday: 2001-10-22

Address: 611 Kuhn Oval, Feltonbury, NY 02783-3818

Phone: +96619177651654

Job: Mining Representative

Hobby: amateur radio, Sculling, Knife making, Gardening, Watching movies, Gunsmithing, Video gaming

Introduction: My name is Chrissy Homenick, I am a tender, funny, determined, tender, glorious, fancy, enthusiastic person who loves writing and wants to share my knowledge and understanding with you.