IGListKit/Examples/Examples-iOS/IGListKitExamples/ViewControllers/FeedViewController.swift
Abhyas Mall 01bd505d8b Feed View Controller Demo and Improve UI Testing Infrastructure (#1629)
Summary:
## Changes in this pull request

This PR adds a new feed view controller demo to the IGListKit example app, showcasing how to implement a social media feed with IGListKit. The demo features infinite scrolling with pagination, post deletion, and interactive elements. Also, I've improved the UI testing infrastructure by adding helper methods for more reliable tests across different device types. The new helpers make the tests more resilient when dealing with iPad split views and ensure elements are properly visible before interacting with them.

### Checklist

- [X] All tests pass. Demo project builds and runs.
- [X] I added tests, an experiment, or detailed why my change isn't tested.
- [X] I added an entry to the `CHANGELOG.md` for any breaking changes, enhancements, or bug fixes.
- [x] I have reviewed the [contributing guide](https://github.com/Instagram/IGListKit/blob/main/.github/CONTRIBUTING.md)

Pull Request resolved: https://github.com/Instagram/IGListKit/pull/1629

Reviewed By: fabiomassimo

Differential Revision: D77482204

Pulled By: TimOliver

fbshipit-source-id: 44ac86ed253e8452388aad50a1ed05bc6f3a31ab
2025-06-28 10:28:37 -07:00

211 lines
6.5 KiB
Swift

/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import IGListKit
import UIKit
final class FeedViewController: UIViewController, ListAdapterDataSource {
// MARK: - Properties
private lazy var adapter: ListAdapter = {
return ListAdapter(updater: ListAdapterUpdater(), viewController: self)
}()
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .systemBackground
collectionView.alwaysBounceVertical = true
return collectionView
}()
private var posts: [Post] = []
private var isLoading = false
private var shouldShowLoadingCell = false
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupAdapter()
loadInitialData()
}
// MARK: - Setup
private func setupUI() {
title = "Feed View"
view.backgroundColor = .systemBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .refresh,
target: self,
action: #selector(refreshFeed)
)
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func setupAdapter() {
adapter.collectionView = collectionView
adapter.scrollViewDelegate = self
adapter.dataSource = self
}
// MARK: - Data Loading
private func loadInitialData() {
isLoading = true
APIService.shared.resetPagination()
posts = []
shouldShowLoadingCell = true
adapter.performUpdates(animated: true)
APIService.shared.fetchPosts { [weak self] newPosts in
guard let self = self else { return }
self.posts = newPosts
self.isLoading = false
self.adapter.performUpdates(animated: true)
}
}
private func loadMoreData() {
guard !isLoading else { return }
isLoading = true
shouldShowLoadingCell = true
adapter.performUpdates(animated: true)
APIService.shared.fetchPosts { [weak self] newPosts in
guard let self = self else { return }
// Append new posts to existing posts
self.posts.append(contentsOf: newPosts)
self.isLoading = false
// If no new posts were fetched, hide the loading cell
if newPosts.isEmpty {
self.shouldShowLoadingCell = false
}
self.adapter.performUpdates(animated: true)
}
}
@objc private func refreshFeed() {
loadInitialData()
}
// MARK: - ListAdapterDataSource
func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
var objects: [ListDiffable] = posts
if shouldShowLoadingCell {
objects.append(LoadingCellModel())
}
return objects
}
func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
if object is Post {
let sectionController = PostSectionController()
sectionController.delegate = self
return sectionController
} else {
return LoadingSectionController()
}
}
func emptyView(for listAdapter: ListAdapter) -> UIView? {
let emptyView = UIView()
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "No posts available"
label.textAlignment = .center
label.textColor = .gray
emptyView.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: emptyView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: emptyView.centerYAnchor)
])
return emptyView
}
}
// MARK: - UIScrollViewDelegate
extension FeedViewController: UIScrollViewDelegate {
func scrollViewWillEndDragging(_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let distance = scrollView.contentSize.height - (targetContentOffset.pointee.y + scrollView.bounds.height)
if !isLoading && distance < 200 && !posts.isEmpty {
loadMoreData()
}
}
}
// MARK: - PostSectionControllerDelegate
extension FeedViewController: PostSectionControllerDelegate {
func postSectionController(_ sectionController: PostSectionController, didSelectOptionsFor post: Post, from sourceView: UIView) {
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
let deleteAction = UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
self?.deletePost(post)
}
let reportAction = UIAlertAction(title: "Report", style: .default) { _ in
print("Reported post: \(post.id)")
}
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alertController.addAction(deleteAction)
alertController.addAction(reportAction)
alertController.addAction(cancelAction)
// Configure for iPad
if let popoverController = alertController.popoverPresentationController {
// If we have a specific source view (like a button), use it
popoverController.sourceView = sourceView
popoverController.sourceRect = sourceView.bounds
}
present(alertController, animated: true, completion: nil)
}
func postSectionController(_ sectionController: PostSectionController, didRequestDeleteFor post: Post) {
deletePost(post)
}
private func deletePost(_ post: Post) {
if let index = posts.firstIndex(where: { $0.id == post.id }) {
posts.remove(at: index)
adapter.performUpdates(animated: true)
}
}
}