Skip to content

Commit

Permalink
Reader: Show empty state for individual rows (#23144)
Browse files Browse the repository at this point in the history
  • Loading branch information
dvdchr authored May 9, 2024
2 parents bf4319e + e91bbb7 commit 65bed38
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// A flow layout that properly invalidates the layout when the collection view's bounds changed,
/// (e.g., orientation changes).
///
/// This method ensures that we work with the latest/correct bounds after the size change, and potentially
/// avoids race conditions where we might get incorrect bounds while the view is still in transition.
///
/// See: https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617781-shouldinvalidatelayout
class AdaptiveCollectionViewFlowLayout: UICollectionViewFlowLayout {

override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
// NOTE: Apparently we need to *manually* invalidate the layout because `invalidateLayout()`
// is NOT called after this method returns true.
if let collectionView, collectionView.bounds.size != newBounds.size {
invalidateLayout()
}
return super.shouldInvalidateLayout(forBoundsChange: newBounds)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ private extension ReaderTagCardCell {
func registerCells() {
let tagCell = UINib(nibName: ReaderTagCell.classNameWithoutNamespaces(), bundle: nil)
let footerView = UINib(nibName: ReaderTagFooterView.classNameWithoutNamespaces(), bundle: nil)
collectionView.register(ReaderTagCardEmptyCell.self, forCellWithReuseIdentifier: ReaderTagCardEmptyCell.defaultReuseID)
collectionView.register(tagCell, forCellWithReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces())
collectionView.register(footerView,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter,
Expand Down
6 changes: 3 additions & 3 deletions WordPress/Classes/ViewRelated/Reader/ReaderTagCardCell.xib
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@
</connections>
</button>
<collectionView multipleTouchEnabled="YES" contentMode="scaleToFill" bounces="NO" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" bouncesZoom="NO" dataMode="none" translatesAutoresizingMaskIntoConstraints="NO" id="GeQ-Gs-OvG">
<rect key="frame" x="24" y="62" width="288" height="297"/>
<rect key="frame" x="16" y="62" width="296" height="297"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" constant="297" id="uTt-pZ-gUW"/>
</constraints>
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="16" id="0Qb-19-SWX">
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="16" id="0Qb-19-SWX" customClass="AdaptiveCollectionViewFlowLayout" customModule="WordPress" customModuleProvider="target">
<size key="itemSize" width="240" height="297"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="50" height="50"/>
Expand All @@ -43,7 +43,7 @@
<constraints>
<constraint firstAttribute="bottom" secondItem="GeQ-Gs-OvG" secondAttribute="bottom" id="2ng-2c-tFF"/>
<constraint firstAttribute="trailingMargin" relation="greaterThanOrEqual" secondItem="gUR-Rt-jPM" secondAttribute="trailing" id="7rj-F7-Ol7"/>
<constraint firstItem="GeQ-Gs-OvG" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leadingMargin" constant="8" id="8Dv-Lq-jru"/>
<constraint firstItem="GeQ-Gs-OvG" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leadingMargin" id="8Dv-Lq-jru"/>
<constraint firstItem="GeQ-Gs-OvG" firstAttribute="top" secondItem="gUR-Rt-jPM" secondAttribute="bottom" constant="8" id="VRI-ge-6KZ"/>
<constraint firstItem="GeQ-Gs-OvG" firstAttribute="trailing" secondItem="iN0-l3-epB" secondAttribute="trailingMargin" id="ZNj-Nk-9pY"/>
<constraint firstItem="gUR-Rt-jPM" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="24" id="gKT-Ns-5xs"/>
Expand Down
117 changes: 95 additions & 22 deletions WordPress/Classes/ViewRelated/Reader/ReaderTagCardCellViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,18 @@ protocol ReaderTagCardCellViewModelDelegate: NSObjectProtocol {

class ReaderTagCardCellViewModel: NSObject {

private typealias DataSource = UICollectionViewDiffableDataSource<Int, NSManagedObjectID>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Int, NSManagedObjectID>
enum Section: Int {
case emptyState = 101
case posts
}

enum CardCellItem: Hashable {
case empty
case post(id: NSManagedObjectID)
}

private typealias DataSource = UICollectionViewDiffableDataSource<Section, CardCellItem>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, CardCellItem>

let slug: String
weak var viewDelegate: ReaderTagCardCellViewModelDelegate? = nil
Expand All @@ -22,29 +32,21 @@ class ReaderTagCardCellViewModel: NSObject {
.init(coreDataStack: coreDataStack)
}()

private lazy var dataSource: DataSource? = {
guard let collectionView else {
private lazy var dataSource: DataSource? = { [weak self] in
guard let self,
let collectionView else {
return nil
}
let dataSource = DataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, objectID in
guard let post = try? ContextManager.shared.mainContext.existingObject(with: objectID) as? ReaderPost,
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces(), for: indexPath) as? ReaderTagCell else {
return UICollectionViewCell()
}
cell.configure(parent: self?.parentViewController,
post: post,
isLoggedIn: self?.isLoggedIn ?? AccountHelper.isLoggedIn)
return cell
}
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard let slug = self?.slug,
kind == UICollectionView.elementKindSectionFooter,

let dataSource = DataSource(collectionView: collectionView, cellProvider: self.cardCellProvider)
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
guard kind == UICollectionView.elementKindSectionFooter,
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind,
withReuseIdentifier: ReaderTagFooterView.classNameWithoutNamespaces(),
for: indexPath) as? ReaderTagFooterView else {
return nil
}
view.configure(with: slug) { [weak self] in
view.configure(with: self.slug) { [weak self] in
self?.onTagButtonTapped()
}
return view
Expand All @@ -65,6 +67,8 @@ class ReaderTagCardCellViewModel: NSObject {
return resultsController
}()

// MARK: Methods

init(parent: UIViewController?,
tag: ReaderTagTopic,
collectionView: UICollectionView?,
Expand Down Expand Up @@ -122,13 +126,65 @@ class ReaderTagCardCellViewModel: NSObject {

}

// MARK: - Private Methods

private extension ReaderTagCardCellViewModel {
/// Configures and returns a collection view cell according to the index path and `CardCellItem`.
/// This method that satisfies the `UICollectionViewDiffableDataSource.CellProvider` closure signature.
func cardCellProvider(_ collectionView: UICollectionView,
_ indexPath: IndexPath,
_ item: CardCellItem) -> UICollectionViewCell? {
switch item {
case .empty:
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCardEmptyCell.defaultReuseID,
for: indexPath) as? ReaderTagCardEmptyCell else {
return UICollectionViewCell()
}

cell.configure(tagTitle: slug) { [weak self] in
self?.fetchTagPosts(syncRemotely: true)
}

return cell

case .post(let objectID):
guard let post = try? ContextManager.shared.mainContext.existingObject(with: objectID) as? ReaderPost,
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ReaderTagCell.classNameWithoutNamespaces(),
for: indexPath) as? ReaderTagCell else {
return UICollectionViewCell()
}

cell.configure(parent: parentViewController, post: post, isLoggedIn: isLoggedIn)
return cell
}
}

/// Translates a diffable snapshot from `NSFetchedResultsController` to a snapshot that fits the collection view.
///
/// Snapshots returned from `NSFetchedResultsController` always have the type `<String, NSManagedObjectID>`, so
/// it needs to be converted to match the correct type required by the collection view.
///
/// - Parameter snapshotRef: The snapshot returned from the `NSFetchedResultsController`
/// - Returns: `Snapshot`
private func collectionViewSnapshot(from snapshotRef: NSDiffableDataSourceSnapshotReference) -> Snapshot {
let coreDataSnapshot = snapshotRef as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>
let isEmpty = coreDataSnapshot.numberOfItems == .zero
var snapshot = Snapshot()

// there must be at least one section.
snapshot.appendSections([isEmpty ? .emptyState : .posts])
snapshot.appendItems(isEmpty ? [.empty] : coreDataSnapshot.itemIdentifiers.map { .post(id: $0) })

return snapshot
}
}

// MARK: - NSFetchedResultsControllerDelegate

extension ReaderTagCardCellViewModel: NSFetchedResultsControllerDelegate {

func controller(_ controller: NSFetchedResultsController<any NSFetchRequestResult>,
didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
dataSource?.apply(snapshot as Snapshot, animatingDifferences: false) { [weak self] in
dataSource?.apply(collectionViewSnapshot(from: snapshot), animatingDifferences: false) { [weak self] in
self?.viewDelegate?.hideLoading()
}
}
Expand All @@ -140,7 +196,9 @@ extension ReaderTagCardCellViewModel: NSFetchedResultsControllerDelegate {
extension ReaderTagCardCellViewModel: UICollectionViewDelegate {

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let sectionInfo = resultsController.sections?[safe: indexPath.section],
guard let section = dataSource?.sectionIdentifier(for: indexPath.section),
section == .posts,
let sectionInfo = resultsController.sections?[safe: indexPath.section],
indexPath.row < sectionInfo.numberOfObjects else {
return
}
Expand All @@ -156,10 +214,25 @@ extension ReaderTagCardCellViewModel: UICollectionViewDelegate {
extension ReaderTagCardCellViewModel: UICollectionViewDelegateFlowLayout {

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return cellSize() ?? .zero
guard let section = dataSource?.sectionIdentifier(for: indexPath.section),
let size = cellSize() else {
return .zero
}

switch section {
case .emptyState:
return CGSize(width: collectionView.frame.width, height: size.height)
case .posts:
return size
}
}

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
guard let sectionIdentifier = dataSource?.sectionIdentifier(for: section),
sectionIdentifier == .posts else {
return .zero
}

var viewSize = cellSize() ?? .zero
viewSize.width = Constants.footerWidth
return viewSize
Expand Down
120 changes: 120 additions & 0 deletions WordPress/Classes/ViewRelated/Reader/ReaderTagCardEmptyCell.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import SwiftUI
import DesignSystem

class ReaderTagCardEmptyCell: UICollectionViewCell, Reusable {

var tagTitle: String {
get {
swiftUIView.tagTitle
}
set {
swiftUIView.tagTitle = newValue
}
}

var retryHandler: (() -> Void)? = nil

private lazy var swiftUIView: ReaderTagCardEmptyCellView = {
ReaderTagCardEmptyCellView(buttonTapped: { [weak self] in
self?.retryHandler?()
})
}()

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

override init(frame: CGRect) {
super.init(frame: .zero)

let viewToEmbed = UIView.embedSwiftUIView(swiftUIView)
contentView.addSubview(viewToEmbed)
contentView.pinSubviewToAllEdges(viewToEmbed)
}

override func prepareForReuse() {
tagTitle = String()
retryHandler = nil
super.prepareForReuse()
}

func configure(tagTitle: String, retryHandler: (() -> Void)?) {
self.tagTitle = tagTitle
self.retryHandler = retryHandler
}
}

// MARK: - SwiftUI

private struct ReaderTagCardEmptyCellView: View {

var tagTitle = String()
var buttonTapped: (() -> Void)? = nil

@ScaledMetric(relativeTo: Font.TextStyle.callout)
private var iconLength = 32.0

var body: some View {
VStack(spacing: .DS.Padding.double) {
Image(systemName: "wifi.slash")
.resizable()
.frame(width: iconLength, height: iconLength)
.foregroundStyle(Color.DS.Foreground.secondary)

// added to double the padding between the Image and the VStack.
Spacer().frame(height: .hairlineBorderWidth)

VStack(spacing: .DS.Padding.single) {
Text(Strings.title)
.font(.callout)
.fontWeight(.semibold)

Text(Strings.body)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}

Button {
buttonTapped?()
} label: {
Text(Strings.buttonTitle)
.font(.callout)
.padding(.vertical, .DS.Padding.half)
.padding(.horizontal, .DS.Padding.single)
}
}
.padding(.DS.Padding.single)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}

private struct Strings {
static let title = NSLocalizedString(
"reader.tagStream.cards.emptyView.error.title",
value: "Posts failed to load",
comment: """
The title of an empty state component for one of the tags in the tag stream.
This empty state component is displayed only when the app fails to load posts under this tag.
"""
)

static let body = NSLocalizedString(
"reader.tagStream.cards.emptyView.error.body",
value: "We couldn't load posts from this tag right now",
comment: """
The body text of an empty state component for one of the tags in the tag stream.
This empty state component is displayed only when the app fails to load posts under this tag.
"""
)

static let buttonTitle = NSLocalizedString(
"reader.tagStream.cards.emptyView.button",
value: "Retry",
comment: """
Verb. The button title of an empty state component for one of the tags in the tag stream.
This empty state component is displayed only when the app fails to load posts under this tag.
When tapped, the app will try to reload posts under this tag.
"""
)
}
}
Loading

0 comments on commit 65bed38

Please sign in to comment.