From 02b30add5e66621f91d5c72d769bd2692a69bd49 Mon Sep 17 00:00:00 2001 From: Rodrigo Porto Date: Tue, 18 Mar 2025 07:33:51 -0300 Subject: [PATCH] Capture cell reference and load/reload cell resources on `willDisplayCell` as a fix for iOS 15 cell prefetching behavior change. On iOS 15+, for performance reasons, the table view data source may not recreate a cell using the `cellForRow` method if there's a cached cell for the given IndexPath. In this case, it'll just call `willDisplayCell`. However, we release a reference of the cell and cancel requests on `didEndDisplayingCell`. So, since `cellForRow` may not be called, we need to implement `willDisplayCell` to know when the cached cell is becoming visible again to recapture a reference of the cell and load/reload any resource needed for the cell. --- .../FeedUIIntegrationTests.swift | 41 +++++++++++++++++++ ...t => ListViewController+TestHelpers.swift} | 11 +++++ .../Controllers/FeedImageCellController.swift | 5 +++ .../Controllers/ListViewController.swift | 5 +++ 4 files changed, 62 insertions(+) rename EssentialApp/EssentialAppTests/Helpers/{FeedViewController+TestHelpers.swift => ListViewController+TestHelpers.swift} (89%) diff --git a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift index 2a14230..8b9c31c 100644 --- a/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/FeedUIIntegrationTests.swift @@ -164,6 +164,23 @@ final class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected two cancelled image URL requests once second image is also not visible anymore") } + func test_feedImageView_reloadsImageURLWhenBecomingVisibleAgain() { + let image0 = makeImage(url: URL(string: "http://url-0.com")!) + let image1 = makeImage(url: URL(string: "http://url-1.com")!) + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0, image1]) + + sut.simulateFeedImageBecomingVisibleAgain(at: 0) + + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image0.url], "Expected two image URL request after first view becomes visible again") + + sut.simulateFeedImageBecomingVisibleAgain(at: 1) + + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image0.url, image1.url, image1.url], "Expected two new image URL request after second view becomes visible again") + } + func test_feedImageViewLoadingIndicator_isVisibleWhileLoadingImage() { let (sut, loader) = makeSUT() @@ -182,6 +199,10 @@ final class FeedUIIntegrationTests: XCTestCase { loader.completeImageLoadingWithError(at: 1) XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator state change for first view once second image loading completes with error") XCTAssertEqual(view1?.isShowingImageLoadingIndicator, false, "Expected no loading indicator for second view once second image loading completes with error") + + view1?.simulateRetryAction() + XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator state change for first view once second image loading completes with error") + XCTAssertEqual(view1?.isShowingImageLoadingIndicator, true, "Expected loading indicator state change for second view on retry action") } func test_feedImageView_rendersImageLoadedFromURL() { @@ -326,6 +347,26 @@ final class FeedUIIntegrationTests: XCTestCase { XCTAssertEqual(newView.renderedImage, imageData) } + func test_feedImageView_configuresViewCorrectlyWhenCellBecomingVisibleAgain() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage()]) + + let view0 = sut.simulateFeedImageBecomingVisibleAgain(at: 0) + + XCTAssertEqual(view0?.renderedImage, nil, "Expected no rendered image when view becomes visible again") + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action when view becomes visible again") + XCTAssertEqual(view0?.isShowingImageLoadingIndicator, true, "Expected loading indicator when view becomes visible again") + + let imageData = UIImage.make(withColor: .red).pngData()! + loader.completeImageLoading(with: imageData, at: 1) + + XCTAssertEqual(view0?.renderedImage, imageData, "Expected rendered image when image loads successfully after view becomes visible again") + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry when image loads successfully after view becomes visible again") + XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator when image loads successfully after view becomes visible again") + } + func test_feedImageView_doesNotRenderLoadedImageWhenNotVisibleAnymore() { let (sut, loader) = makeSUT() diff --git a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift similarity index 89% rename from EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift rename to EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift index 0896610..f8bdc5d 100644 --- a/EssentialApp/EssentialAppTests/Helpers/FeedViewController+TestHelpers.swift +++ b/EssentialApp/EssentialAppTests/Helpers/ListViewController+TestHelpers.swift @@ -47,6 +47,17 @@ extension ListViewController { return feedImageView(at: index) as? FeedImageCell } + @discardableResult + func simulateFeedImageBecomingVisibleAgain(at row: Int) -> FeedImageCell? { + let view = simulateFeedImageViewNotVisible(at: row) + + let delegate = tableView.delegate + let index = IndexPath(row: row, section: feedImagesSection) + delegate?.tableView?(tableView, willDisplay: view!, forRowAt: index) + + return view + } + @discardableResult func simulateFeedImageViewNotVisible(at row: Int) -> FeedImageCell? { let view = simulateFeedImageViewVisible(at: row) diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift index dd541f0..c206649 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift @@ -43,6 +43,11 @@ extension FeedImageCellController: UITableViewDataSource, UITableViewDelegate, U return cell! } + public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + self.cell = cell as? FeedImageCell + delegate.didRequestImage() + } + public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { cancelLoad() } diff --git a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift index b174a22..274594e 100644 --- a/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift +++ b/EssentialFeed/EssentialFeediOS/Shared UI/Controllers/ListViewController.swift @@ -82,6 +82,11 @@ public final class ListViewController: UITableViewController, UITableViewDataSou onViewIsAppearing?(self) } + public override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let dl = cellController(at: indexPath)?.delegate + dl?.tableView?(tableView, willDisplay: cell, forRowAt: indexPath) + } + public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { let dl = cellController(at: indexPath)?.delegate dl?.tableView?(tableView, didEndDisplaying: cell, forRowAt: indexPath)