on
SwiftUI - Integrating UIKit
SwiftUI doesn’t provide neither a UICollectionView
nor a UIActivityIndicator
equivalence so we need to integrate the components directly from UIKit.
Apple’s built-in solution to address this issue is the UIViewRepresentable
protocol.
When conforming to UIViewRepresentable
, our ReusableCollectionView
object must implement the following required methods:
func makeUIView(context: UIViewRepresentableContext<ReusableCollectionView>) -> UICollectionView
func updateUIView(_ uiView: UICollectionView, context: UIViewRepresentableContext<ReusableCollectionView>)
The first method will setup your representable view and will be called only once during the app lifetime. The second method will be called every time a view is being updated.
The UICollectionViewDiffableDataSource
class is used to provide our view with the latest data.
If you’re not familiar with this new API, check out my article here.
In the Headlines app, we need to update our datasource object with the latest data fetched from the web-service.
final class ReusableCollectionViewDelegate: NSObject, UICollectionViewDelegate {
let section: HeadlinesSection
let viewModel: HeadlinesViewModel
let showDetails: (Bool) -> ()
init(
section: HeadlinesSection,
viewModel: HeadlinesViewModel,
handler: @escaping (Bool) -> ()) {
self.section = section
self.viewModel = viewModel
self.showDetails = handler
}
func collectionView(
_ collectionView: UICollectionView,
didSelectItemAt indexPath: IndexPath) {
guard let category = self.viewModel
.headlines
.first(where: { $0.name == self.section }) else {
return
}
viewModel.selectedArticle = category.articles[indexPath.item]
showDetails(true)
}
}
struct ReusableCollectionView: UIViewRepresentable {
var viewModel: HeadlinesViewModel
let section: HeadlinesSection
let delegate: ReusableCollectionViewDelegate
let reloadData: Bool
init(
viewModel: HeadlinesViewModel,
section: HeadlinesSection,
shouldReloadData: Bool = true,
handler: @escaping (Bool) -> ()) {
self.viewModel = viewModel
self.section = section
self.reloadData = shouldReloadData
self.delegate = ReusableCollectionViewDelegate(
section: section,
viewModel: viewModel,
handler: handler
)
}
func makeUIView(context: UIViewRepresentableContext<ReusableCollectionView>) -> UICollectionView {
let collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: CollectionViewFlowLayout()
)
collectionView.showsHorizontalScrollIndicator = false
collectionView.delegate = delegate
let articleNib = UINib(
nibName: "\(ArticleCell.self)",
bundle: .main
)
collectionView.register(
articleNib,
forCellWithReuseIdentifier: "\(ArticleCell.self)"
)
let dataSource = UICollectionViewDiffableDataSource<HeadlinesSection, HeadlinesContainer>(collectionView: collectionView) { collectionView, indexPath, container in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "\(ArticleCell.self)",
for: indexPath) as? ArticleCell
guard let category = self.viewModel
.headlines
.first(where: { $0.name == self.section }) else {
return cell
}
cell?.configure(
article: category.articles[indexPath.item]
)
return cell
}
populate(dataSource: dataSource)
context.coordinator.dataSource = dataSource
return collectionView
}
func updateUIView(_ uiView: UICollectionView, context: UIViewRepresentableContext<ReusableCollectionView>) {
guard let dataSource = context
.coordinator
.dataSource else {
return
}
populate(dataSource: dataSource)
}
func makeCoordinator() -> MainCoordinator {
MainCoordinator()
}
func populate(dataSource: UICollectionViewDiffableDataSource<HeadlinesSection, HeadlinesContainer>) {
var snapshot = NSDiffableDataSourceSnapshot<HeadlinesSection, HeadlinesContainer>()
guard let category = self.viewModel
.headlines
.first(where: { $0.name == self.section }),
reloadData else {
return
}
let containers = category.articles.map(HeadlinesContainer.init)
snapshot.appendSections([category.name])
snapshot.appendItems(containers)
dataSource.apply(snapshot)
}
}
enum HeadlinesSection: String, CaseIterable {
case sports
case technology
case business
case general
case science
case health
case entertainment
case filtered
}
final class HeadlinesContainer: Hashable {
let id = UUID()
var article: Article
init(article: Article) {
self.article = article
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (
lhs: HeadlinesContainer,
rhs: HeadlinesContainer) -> Bool {
return lhs.id == rhs.id
}
}
When a SwiftUI view is rendered for the first time, collection views are initialized through the makeUI
method and the system keep these instances in memory.
func content(
forMode mode: Mode,
headlines: Headlines) -> some View {
Group {
if mode == .image {
VStack(alignment: .leading) {
HeaderView(headlines: headlines)
CategoryRow(
model: self.viewModel,
section: headlines.name,
shouldReloadData: !self.navigator.showSheet,
handler: { _ in
self.navigator.presenting = .details
}
)
}.frame(height: headlines.isFavorite ? 400: 300)
} else {
HeaderView(headlines: headlines)
ForEach(headlines.articles, id: \.title) { article in
ArticleRow(
article: article,
viewModel: self.viewModel
) { _ in
self.navigator.presenting = .details
}
}
}
}
}
struct CategoryRow: View {
let model: HeadlinesViewModel
let section: HeadlinesSection
let shouldReloadData: Bool
let handler: (Bool) -> ()
var body: some View {
ReusableCollectionView(
viewModel: model,
section: section,
shouldReloadData: shouldReloadData,
handler: handler
).edgesIgnoringSafeArea(
.init(arrayLiteral: .leading, .trailing)
)
}
}
These ReusableCollectionView
objects will then be updated through the updateUI
method each time a SwiftUI view is being rendered.
The issue here is that we don’t get to see which of our UICollectionView instances is being updated.
The key is to keep track of which category is being updated by storing a local section
variable, then using self.viewModel.headlines.first(where: { $0.name == self.section })
which enable us to update the right section with the right data.
Using UIKit APIs directly into a SwiftUI app looks a bit counter intuitive (and error prone), hopefully Apple will release SwiftUI built-in solutions for such components in the next years.