deep-linking-sample/Apps/Wikipedia/Wikipedia/Code/ColumnarCollectionViewController.swift
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

431 lines
17 KiB
Swift

import UIKit
import WMF
class ColumnarCollectionViewController: ViewController, ColumnarCollectionViewLayoutDelegate, UICollectionViewDataSourcePrefetching, CollectionViewFooterDelegate, HintPresenting {
enum HeaderStyle {
case sections
case exploreFeedDetail
}
open var headerStyle: HeaderStyle {
return .exploreFeedDetail
}
lazy var layout: ColumnarCollectionViewLayout = {
return ColumnarCollectionViewLayout()
}()
lazy var layoutCache: ColumnarCollectionViewControllerLayoutCache = {
return ColumnarCollectionViewControllerLayoutCache()
}()
@objc lazy var collectionView: UICollectionView = {
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
cv.delegate = self
cv.dataSource = self
cv.isPrefetchingEnabled = true
cv.prefetchDataSource = self
cv.preservesSuperviewLayoutMargins = true
scrollView = cv
return cv
}()
lazy var layoutManager: ColumnarCollectionViewLayoutManager = {
return ColumnarCollectionViewLayoutManager(view: view, collectionView: collectionView)
}()
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
view.wmf_addSubviewWithConstraintsToEdges(collectionView)
layoutManager.register(CollectionViewHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CollectionViewHeader.identifier, addPlaceholder: true)
layoutManager.register(CollectionViewFooter.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: CollectionViewFooter.identifier, addPlaceholder: true)
collectionView.alwaysBounceVertical = true
extendedLayoutIncludesOpaqueBars = true
}
@objc open func contentSizeCategoryDidChange(_ notification: Notification?) {
collectionView.reloadData()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if isFirstAppearance {
isFirstAppearance = false
viewWillHaveFirstAppearance(animated)
updateEmptyState()
isEmptyDidChange() // perform initial update even though the value might not have changed
} else {
updateEmptyState()
}
if let selectedIndexPaths = collectionView.indexPathsForSelectedItems {
for selectedIndexPath in selectedIndexPaths {
collectionView.deselectItem(at: selectedIndexPath, animated: animated)
}
}
for cell in collectionView.visibleCells {
guard let cellWithSubItems = cell as? SubCellProtocol else {
continue
}
cellWithSubItems.deselectSelectedSubItems(animated: animated)
}
}
open func viewWillHaveFirstAppearance(_ animated: Bool) {
// subclassers can override
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
contentSizeCategoryDidChange(nil)
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { (context) in
let boundsChange = self.collectionView.bounds
guard self.layout.shouldInvalidateLayout(forBoundsChange: boundsChange) else {
return
}
let invalidationContext = self.layout.invalidationContext(forBoundsChange: boundsChange)
self.layout.invalidateLayout(with: invalidationContext)
})
}
// MARK: HintPresenting
var hintController: HintController?
// MARK: - UIScrollViewDelegate
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
super.scrollViewWillBeginDragging(scrollView)
hintController?.dismissHintDueToUserInteraction()
}
// MARK: - Refresh Control
final var isRefreshControlEnabled: Bool = false {
didSet {
if isRefreshControlEnabled {
let refreshControl = UIRefreshControl()
refreshControl.tintColor = theme.colors.refreshControlTint
refreshControl.layer.zPosition = -100
refreshControl.addTarget(self, action: #selector(refreshControlActivated), for: .valueChanged)
collectionView.refreshControl = refreshControl
} else {
collectionView.refreshControl = nil
}
}
}
var refreshStart: Date = Date()
@objc func refreshControlActivated() {
refreshStart = Date()
self.refresh()
}
open func refresh() {
assert(false, "default implementation shouldn't be called")
self.endRefreshing()
}
open func endRefreshing() {
let now = Date()
let timeInterval = 0.5 - now.timeIntervalSince(refreshStart)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + timeInterval, execute: {
self.collectionView.refreshControl?.endRefreshing()
})
}
// MARK: - Empty State
var emptyViewType: WMFEmptyViewType = .none
final var isEmpty = true
final var showingEmptyViewType: WMFEmptyViewType?
final func updateEmptyState() {
let sectionCount = numberOfSections(in: collectionView)
var isCurrentlyEmpty = true
for sectionIndex in 0..<sectionCount {
if self.collectionView(collectionView, numberOfItemsInSection: sectionIndex) > 0 {
isCurrentlyEmpty = false
break
}
}
guard isCurrentlyEmpty != isEmpty || showingEmptyViewType != emptyViewType else {
return
}
isEmpty = isCurrentlyEmpty
isEmptyDidChange()
}
private var emptyViewFrame: CGRect {
let insets = scrollView?.contentInset ?? UIEdgeInsets.zero
let frame = view.bounds.inset(by: insets)
return frame
}
open weak var emptyViewTarget: AnyObject?
open var emptyViewAction: Selector?
open func isEmptyDidChange() {
if isEmpty {
wmf_showEmptyView(of: emptyViewType, target: emptyViewTarget, action: emptyViewAction, theme: theme, frame: emptyViewFrame)
showingEmptyViewType = emptyViewType
} else {
wmf_hideEmptyView()
showingEmptyViewType = nil
}
}
override func scrollViewInsetsDidChange() {
super.scrollViewInsetsDidChange()
wmf_setEmptyViewFrame(emptyViewFrame)
}
// MARK: - Themeable
override func apply(theme: Theme) {
super.apply(theme: theme)
guard viewIfLoaded != nil else {
return
}
view.backgroundColor = theme.colors.baseBackground
collectionView.backgroundColor = theme.colors.paperBackground
collectionView.indicatorStyle = theme.scrollIndicatorStyle
collectionView.reloadData()
wmf_applyTheme(toEmptyView: theme)
}
// MARK: - UICollectionViewDataSourcePrefetching
private lazy var imageURLsCurrentlyBeingPrefetched: Set<URL> = {
return []
}()
open func imageURLsForItemAt(_ indexPath: IndexPath) -> Set<URL>? {
return nil
}
func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let imageURLs = imageURLsForItemAt(indexPath) else {
continue
}
let imageURLsToPrefetch = imageURLs.subtracting(imageURLsCurrentlyBeingPrefetched)
// SINGLETONTODO
let imageController = MWKDataStore.shared().cacheController.imageCache
imageURLsCurrentlyBeingPrefetched.formUnion(imageURLsToPrefetch)
for imageURL in imageURLsToPrefetch {
imageController.prefetch(withURL: imageURL) {
self.imageURLsCurrentlyBeingPrefetched.remove(imageURL)
}
}
}
}
// MARK: - Header
var headerTitle: String?
var headerSubtitle: String?
open func configure(header: CollectionViewHeader, forSectionAt sectionIndex: Int, layoutOnly: Bool) {
header.title = headerTitle
header.subtitle = headerSubtitle
header.style = .detail
header.apply(theme: theme)
}
// MARK: - Footer
var footerButtonTitle: String?
open func configure(footer: CollectionViewFooter, forSectionAt sectionIndex: Int, layoutOnly: Bool) {
footer.buttonTitle = footerButtonTitle
footer.delegate = self
footer.apply(theme: theme)
}
// MARK: - ColumnarCollectionViewLayoutDelegate
func collectionView(_ collectionView: UICollectionView, estimatedHeightForHeaderInSection section: Int, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate {
var estimate = ColumnarCollectionViewLayoutHeightEstimate(precalculated: true, height: 0)
switch headerStyle {
case .exploreFeedDetail:
guard section == 0, headerTitle != nil else {
return estimate
}
case .sections:
guard self.collectionView(collectionView, numberOfItemsInSection: section) > 0 else {
return estimate
}
}
guard let placeholder = layoutManager.placeholder(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CollectionViewHeader.identifier) as? CollectionViewHeader else {
return estimate
}
configure(header: placeholder, forSectionAt: section, layoutOnly: true)
estimate.height = placeholder.sizeThatFits(CGSize(width: columnWidth, height: UIView.noIntrinsicMetric), apply: false).height
estimate.precalculated = true
return estimate
}
open func collectionView(_ collectionView: UICollectionView, estimatedHeightForFooterInSection section: Int, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate {
var estimate = ColumnarCollectionViewLayoutHeightEstimate(precalculated: true, height: 0)
guard footerButtonTitle != nil else {
return estimate
}
guard let placeholder = layoutManager.placeholder(forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: CollectionViewFooter.identifier) as? CollectionViewFooter else {
return estimate
}
configure(footer: placeholder, forSectionAt: section, layoutOnly: true)
estimate.height = placeholder.sizeThatFits(CGSize(width: columnWidth, height: UIView.noIntrinsicMetric), apply: false).height
estimate.precalculated = true
return estimate
}
func collectionView(_ collectionView: UICollectionView, shouldShowFooterForSection section: Int) -> Bool {
return section == collectionView.numberOfSections - 1
}
open func collectionView(_ collectionView: UICollectionView, estimatedHeightForItemAt indexPath: IndexPath, forColumnWidth columnWidth: CGFloat) -> ColumnarCollectionViewLayoutHeightEstimate {
return ColumnarCollectionViewLayoutHeightEstimate(precalculated: false, height: 0)
}
func metrics(with size: CGSize, readableWidth: CGFloat, layoutMargins: UIEdgeInsets) -> ColumnarCollectionViewLayoutMetrics {
return ColumnarCollectionViewLayoutMetrics.tableViewMetrics(with: size, readableWidth: readableWidth, layoutMargins: layoutMargins)
}
// MARK: - Event logging utiities
var percentViewed: Double {
guard collectionView.contentSize.height > 0 else {
return 0
}
return Double(((collectionView.contentOffset.y + collectionView.bounds.height) / collectionView.contentSize.height) * 100)
}
var _maxViewed: Double = 0
var maxViewed: Double {
return min(max(_maxViewed, percentViewed), 100)
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
super.scrollViewDidScroll(scrollView)
_maxViewed = max(_maxViewed, percentViewed)
}
// MARK: - CollectionViewFooterDelegate
func collectionViewFooterButtonWasPressed(_ collectionViewFooter: CollectionViewFooter) {
}
}
// MARK: - UICollectionViewDataSource
extension ColumnarCollectionViewController: UICollectionViewDataSource {
open func numberOfSections(in collectionView: UICollectionView) -> Int {
return 0
}
open func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 0
}
open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
return collectionView.dequeueReusableCell(withReuseIdentifier: "", for: indexPath)
}
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
if kind == UICollectionView.elementKindSectionHeader {
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CollectionViewHeader.identifier, for: indexPath)
guard let header = view as? CollectionViewHeader else {
return view
}
configure(header: header, forSectionAt: indexPath.section, layoutOnly: false)
return header
} else if kind == UICollectionView.elementKindSectionFooter {
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CollectionViewFooter.identifier, for: indexPath)
guard let footer = view as? CollectionViewFooter else {
return view
}
configure(footer: footer, forSectionAt: indexPath.section, layoutOnly: false)
return footer
}
return UICollectionReusableView()
}
}
extension ColumnarCollectionViewController: UICollectionViewDelegate {
}
extension ColumnarCollectionViewController {
func push(_ viewController: UIViewController, context: FeedFunnelContext?, index: Int?, animated: Bool) {
logFeedEventIfNeeded(for: context, index: index, pushedViewController: viewController)
push(viewController, animated: animated)
}
func logFeedEventIfNeeded(for context: FeedFunnelContext?, index: Int?, pushedViewController: UIViewController) {
guard navigationController != nil, let viewControllers = navigationController?.viewControllers else {
return
}
let isFirstViewControllerExplore = viewControllers.first is ExploreViewController
let isPushedFromExplore = viewControllers.count == 1 && isFirstViewControllerExplore
let isPushedFromExploreDetail = viewControllers.count == 2 && isFirstViewControllerExplore
if isPushedFromExplore {
let isArticle = pushedViewController is ArticleViewController
if isArticle {
FeedFunnel.shared.logFeedCardReadingStarted(for: context, index: index)
} else {
FeedFunnel.shared.logFeedCardOpened(for: context)
}
} else if isPushedFromExploreDetail {
FeedFunnel.shared.logArticleInFeedDetailReadingStarted(for: context, index: index, maxViewed: maxViewed)
}
}
}
// MARK: - CollectionViewContextMenuShowing
extension ColumnarCollectionViewController {
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let contextMenuCollectionVC = self as? CollectionViewContextMenuShowing, let vc = contextMenuCollectionVC.previewingViewController(for: indexPath, at: point) else {
return nil
}
let previewProvider: () -> UIViewController? = {
return vc
}
return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider) { (suggestedActions) -> UIMenu? in
guard let previewActions = (vc as? ArticleViewController)?.contextMenuItems else {
return nil
}
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: previewActions)
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
guard let previewedViewController = animator.previewViewController else {
assertionFailure("Should be able to find previewed VC")
return
}
animator.addCompletion { [weak self] in
(self as? CollectionViewContextMenuShowing)?.poppingIntoVCCompletion()
previewedViewController.wmf_removePeekableChildViewControllers()
self?.push(previewedViewController, animated: true)
}
}
}