From 52f19c146e75569739b641f8e03ae056251dafed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Tue, 20 Jan 2026 15:27:26 -0500 Subject: [PATCH 01/14] Render loaded feed on successful load completion --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 + .../EssentialFeediOS/FeedImageCell.swift | 14 ++++ .../EssentialFeediOS/FeedViewController.swift | 19 ++++- .../FeedViewControllerTests.swift | 79 ++++++++++++++++++- 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 EssentialFeed/EssentialFeediOS/FeedImageCell.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index c45351f..0701309 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0B21B6DAE800813479 /* FeedImage.swift */; }; 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 231DA90C2D28B3AE00A50156 /* RemoteFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231DA90B2D28B3AB00A50156 /* RemoteFeedLoader.swift */; }; + 234EE2712F1FE49200817225 /* FeedImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 234EE2702F1FE49000817225 /* FeedImageCell.swift */; }; 2370B4092EEC68FE00737DAC /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2370B4082EEC68FE00737DAC /* HTTPClient.swift */; }; 2370B40B2EEC6BE200737DAC /* FeedItemsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2370B40A2EEC6BE200737DAC /* FeedItemsMapper.swift */; }; 2377131E2F199AAF00D1122A /* FeedViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2377131D2F199AAF00D1122A /* FeedViewControllerTests.swift */; }; @@ -116,6 +117,7 @@ 080EDF0B21B6DAE800813479 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = ""; }; 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 231DA90B2D28B3AB00A50156 /* RemoteFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeedLoader.swift; sourceTree = ""; }; + 234EE2702F1FE49000817225 /* FeedImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageCell.swift; sourceTree = ""; }; 2370B4082EEC68FE00737DAC /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; 2370B40A2EEC6BE200737DAC /* FeedItemsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapper.swift; sourceTree = ""; }; 2377131D2F199AAF00D1122A /* FeedViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewControllerTests.swift; sourceTree = ""; }; @@ -312,6 +314,7 @@ 23F5D6F02F182E5600AC7D9F /* EssentialFeediOS */ = { isa = PBXGroup; children = ( + 234EE2702F1FE49000817225 /* FeedImageCell.swift */, 2377132C2F1EC94D00D1122A /* FeedViewController.swift */, 23F5D6FA2F18345C00AC7D9F /* CI_iOS.xctestplan */, ); @@ -699,6 +702,7 @@ buildActionMask = 2147483647; files = ( 2377132D2F1EC9D800D1122A /* FeedViewController.swift in Sources */, + 234EE2712F1FE49200817225 /* FeedImageCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift new file mode 100644 index 0000000..023ab0a --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift @@ -0,0 +1,14 @@ +// +// FeedImageCell.swift +// EssentialFeed +// +// Created by Andres Sanchez on 20/1/26. +// + +import UIKit + +public final class FeedImageCell: UITableViewCell { + public let locationContainer = UIView() + public let locationLabel = UILabel() + public let descriptionLabel = UILabel() +} diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index d523c5c..d0fc19b 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -10,6 +10,8 @@ import EssentialFeed final public class FeedViewController: UITableViewController { private var loader: FeedLoader? + private var tableModel = [FeedImage]() + private var onViewIsAppearing: ((FeedViewController) -> Void)? public convenience init(loader: FeedLoader) { @@ -35,8 +37,23 @@ final public class FeedViewController: UITableViewController { @objc private func load() { refreshControl?.beginRefreshing() - loader?.load { [weak self] _ in + loader?.load { [weak self] result in + self?.tableModel = (try? result.get()) ?? [] + self?.tableView.reloadData() self?.refreshControl?.endRefreshing() } } + + public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return tableModel.count + } + + public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cellModel = tableModel[indexPath.row] + let cell = FeedImageCell() + cell.locationContainer.isHidden = (cellModel.location == nil) + cell.locationLabel.text = cellModel.location + cell.descriptionLabel.text = cellModel.description + return cell + } } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 2030e7a..47ef838 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -54,6 +54,24 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading is completed") } + func test_loadFeedCompletion_rendersSuccessfullyLoadedFeed() { + let image0 = makeImage(description: "a description", location: "a location") + let image1 = makeImage(description: nil, location: "another location") + let image2 = makeImage(description: "another description", location: nil) + let image3 = makeImage(description: nil, location: nil) + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + assertThat(sut, isRendering: []) + + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoading(with: [image0, image1, image2, image3], at: 1) + assertThat(sut, isRendering: [image0, image1, image2, image3]) + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { @@ -64,6 +82,35 @@ final class FeedViewControllerTests: XCTestCase { return (sut, loader) } + private func assertThat(_ sut: FeedViewController, isRendering feed: [FeedImage], file: StaticString = #file, line: UInt = #line) { + guard sut.numberOfRenderedFeedImageViews() == feed.count else { + return XCTFail("Expected \(feed.count) images, got \(sut.numberOfRenderedFeedImageViews()) instead.", file: file, line: line) + } + + feed.enumerated().forEach { index, image in + assertThat(sut, hasViewConfiguredFor: image, at: index, file: file, line: line) + } + } + + private func assertThat(_ sut: FeedViewController, hasViewConfiguredFor image: FeedImage, at index: Int, file: StaticString = #file, line: UInt = #line) { + let view = sut.feedImageView(at: index) + + guard let cell = view as? FeedImageCell else { + return XCTFail("Expected \(FeedImageCell.self) instance, got \(String(describing: view)) instead", file: file, line: line) + } + + let shouldLocationBeVisible = (image.location != nil) + XCTAssertEqual(cell.isShowingLocation, shouldLocationBeVisible, "Expected `isShowingLocation` to be \(shouldLocationBeVisible) for image view at index (\(index))", file: file, line: line) + + XCTAssertEqual(cell.locationText, image.location, "Expected location text to be \(String(describing: image.location)) for image view at index (\(index))", file: file, line: line) + + XCTAssertEqual(cell.descriptionText, image.description, "Expected description text to be \(String(describing: image.description)) for image view at index (\(index)", file: file, line: line) + } + + private func makeImage(description: String? = nil, location: String? = nil, url: URL = URL(string: "http://any-url.com")!) -> FeedImage { + return FeedImage(id: UUID(), description: description, location: location, url: url) + } + class LoaderSpy: FeedLoader { private var completions = [(FeedLoader.Result) -> Void]() @@ -75,8 +122,8 @@ final class FeedViewControllerTests: XCTestCase { completions.append(completion) } - func completeFeedLoading(at index: Int) { - completions[index](.success([])) + func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { + completions[index](.success(feed)) } } @@ -126,6 +173,34 @@ private extension FeedViewController { var isShowingLoadingIndicator: Bool { return refreshControl?.isRefreshing == true } + + func numberOfRenderedFeedImageViews() -> Int { + return tableView.numberOfRows(inSection: feedImagesSection) + } + + func feedImageView(at row: Int) -> UITableViewCell? { + let ds = tableView.dataSource + let index = IndexPath(row: row, section: feedImagesSection) + return ds?.tableView(tableView, cellForRowAt: index) + } + + private var feedImagesSection: Int { + return 0 + } +} + +private extension FeedImageCell { + var isShowingLocation: Bool { + return !locationContainer.isHidden + } + + var locationText: String? { + return locationLabel.text + } + + var descriptionText: String? { + return descriptionLabel.text + } } private extension UIRefreshControl { From 94f65205e6504ec4effddabc74b0b643e14fd978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Tue, 20 Jan 2026 15:34:06 -0500 Subject: [PATCH 02/14] Does not alter current feed rendering state on load error --- .../EssentialFeediOS/FeedViewController.swift | 11 ++++++++--- .../FeedViewControllerTests.swift | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index d0fc19b..3b3d3bb 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -38,9 +38,14 @@ final public class FeedViewController: UITableViewController { @objc private func load() { refreshControl?.beginRefreshing() loader?.load { [weak self] result in - self?.tableModel = (try? result.get()) ?? [] - self?.tableView.reloadData() - self?.refreshControl?.endRefreshing() + switch result { + case let .success(feed): + self?.tableModel = feed + self?.tableView.reloadData() + self?.refreshControl?.endRefreshing() + + case .failure: break + } } } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 47ef838..d986058 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -71,6 +71,19 @@ final class FeedViewControllerTests: XCTestCase { loader.completeFeedLoading(with: [image0, image1, image2, image3], at: 1) assertThat(sut, isRendering: [image0, image1, image2, image3]) } + + func test_loadFeedCompletion_doesNotAlterCurrentRenderingStateOnError() { + let image0 = makeImage() + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoadingWithError(at: 1) + assertThat(sut, isRendering: [image0]) + } // MARK: - Helpers @@ -125,6 +138,11 @@ final class FeedViewControllerTests: XCTestCase { func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { completions[index](.success(feed)) } + + func completeFeedLoadingWithError(at index: Int = 0) { + let error = NSError(domain: "an error", code: 0) + completions[index](.failure(error)) + } } } From 6f6340180a2f68715a909b5fcf9f7af93cb50597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Tue, 20 Jan 2026 15:42:56 -0500 Subject: [PATCH 03/14] Hide loading indicator on both load error and success --- EssentialFeed/EssentialFeediOS/FeedViewController.swift | 7 ++----- .../EssentialFeediOSTests/FeedViewControllerTests.swift | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index 3b3d3bb..201d1f6 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -38,14 +38,11 @@ final public class FeedViewController: UITableViewController { @objc private func load() { refreshControl?.beginRefreshing() loader?.load { [weak self] result in - switch result { - case let .success(feed): + if let feed = try? result.get() { self?.tableModel = feed self?.tableView.reloadData() - self?.refreshControl?.endRefreshing() - - case .failure: break } + self?.refreshControl?.endRefreshing() } } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index d986058..039dffa 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -45,13 +45,13 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once view appears") loader.completeFeedLoading(at: 0) - XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading is completed") + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading completes successfully") sut.simulateUserInitiatedFeedReload() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once user initiates a reload") - loader.completeFeedLoading(at: 1) - XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading is completed") + loader.completeFeedLoadingWithError(at: 1) + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error") } func test_loadFeedCompletion_rendersSuccessfullyLoadedFeed() { From ed1ec7480a41d50f5618e8e48db136d993cb0920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Tue, 20 Jan 2026 15:56:41 -0500 Subject: [PATCH 04/14] Load image URL when image view is visible --- .../EssentialFeediOS/FeedViewController.swift | 15 +++-- .../FeedViewControllerTests.swift | 62 ++++++++++++++----- 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index 201d1f6..68bda96 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -8,15 +8,21 @@ import UIKit import EssentialFeed +public protocol FeedImageDataLoader { + func loadImageData(from url: URL) +} + final public class FeedViewController: UITableViewController { - private var loader: FeedLoader? + private var feedLoader: FeedLoader? + private var imageLoader: FeedImageDataLoader? private var tableModel = [FeedImage]() private var onViewIsAppearing: ((FeedViewController) -> Void)? - public convenience init(loader: FeedLoader) { + public convenience init(feedLoader: FeedLoader, imageLoader: FeedImageDataLoader) { self.init() - self.loader = loader + self.feedLoader = feedLoader + self.imageLoader = imageLoader } public override func viewDidLoad() { @@ -37,7 +43,7 @@ final public class FeedViewController: UITableViewController { @objc private func load() { refreshControl?.beginRefreshing() - loader?.load { [weak self] result in + feedLoader?.load { [weak self] result in if let feed = try? result.get() { self?.tableModel = feed self?.tableView.reloadData() @@ -56,6 +62,7 @@ final public class FeedViewController: UITableViewController { cell.locationContainer.isHidden = (cellModel.location == nil) cell.locationLabel.text = cellModel.location cell.descriptionLabel.text = cellModel.description + imageLoader?.loadImageData(from: cellModel.url) return cell } } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 039dffa..8e1b5cb 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -14,27 +14,27 @@ final class FeedViewControllerTests: XCTestCase { func test_loadFeedActions_requestFeedFromLoader() { let (sut, loader) = makeSUT() - XCTAssertEqual(loader.loadCallCount, 0, "Expected no loading requests before view appears") + XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view is loaded") sut.simulateAppearance() - XCTAssertEqual(loader.loadCallCount, 1, "Expected a loading request once view appears") + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") sut.simulateUserInitiatedFeedReload() - XCTAssertEqual(loader.loadCallCount, 2, "Expected another loading request once user initiates a reload") + XCTAssertEqual(loader.loadFeedCallCount, 2, "Expected another loading request once user initiates a reload") sut.simulateUserInitiatedFeedReload() - XCTAssertEqual(loader.loadCallCount, 3, "Expected yet another loading request once user initiates another reload") + XCTAssertEqual(loader.loadFeedCallCount, 3, "Expected yet another loading request once user initiates another reload") } func test_loadFeedActions_runsAutomaticallyOnlyOnFirstAppearance() { let (sut, loader) = makeSUT() - XCTAssertEqual(loader.loadCallCount, 0, "Expected no loading requests before view appears") + XCTAssertEqual(loader.loadFeedCallCount, 0, "Expected no loading requests before view appears") sut.simulateAppearance() - XCTAssertEqual(loader.loadCallCount, 1, "Expected a loading request once view appears") + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected a loading request once view appears") sut.simulateAppearance() - XCTAssertEqual(loader.loadCallCount, 1, "Expected no loading request the second time view appears") + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expected no loading request the second time view appears") } @@ -85,11 +85,28 @@ final class FeedViewControllerTests: XCTestCase { assertThat(sut, isRendering: [image0]) } + func test_feedImageView_loadsImageURLWhenVisible() { + 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]) + + XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until views become visible") + + sut.simulateFeedImageViewVisible(at: 0) + XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first view becomes visible") + + sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second view also becomes visible") + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { let loader = LoaderSpy() - let sut = FeedViewController(loader: loader) + let sut = FeedViewController(feedLoader: loader, imageLoader: loader) trackForMemoryLeaks(loader, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) return (sut, loader) @@ -124,24 +141,35 @@ final class FeedViewControllerTests: XCTestCase { return FeedImage(id: UUID(), description: description, location: location, url: url) } - class LoaderSpy: FeedLoader { - private var completions = [(FeedLoader.Result) -> Void]() + class LoaderSpy: FeedLoader, FeedImageDataLoader { - var loadCallCount: Int { - return completions.count + // MARK: - FeedLoader + + private var feedRequests = [(FeedLoader.Result) -> Void]() + + var loadFeedCallCount: Int { + return feedRequests.count } func load(completion: @escaping (FeedLoader.Result) -> Void) { - completions.append(completion) + feedRequests.append(completion) } func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { - completions[index](.success(feed)) + feedRequests[index](.success(feed)) } func completeFeedLoadingWithError(at index: Int = 0) { let error = NSError(domain: "an error", code: 0) - completions[index](.failure(error)) + feedRequests[index](.failure(error)) + } + + // MARK: - FeedImageDataLoader + + private(set) var loadedImageURLs = [URL]() + + func loadImageData(from url: URL) { + loadedImageURLs.append(url) } } @@ -188,6 +216,10 @@ private extension FeedViewController { refreshControl?.simulatePullToRefresh() } + func simulateFeedImageViewVisible(at index: Int) { + _ = feedImageView(at: index) + } + var isShowingLoadingIndicator: Bool { return refreshControl?.isRefreshing == true } From aef0f09dc60a61d3ee2fa67e67a132b61a7a5fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 11:19:01 -0500 Subject: [PATCH 05/14] Cancel image loading when image view is not visible anymore --- .../EssentialFeediOS/FeedViewController.swift | 6 ++++ .../FeedViewControllerTests.swift | 34 +++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index 68bda96..4f6e13b 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -10,6 +10,7 @@ import EssentialFeed public protocol FeedImageDataLoader { func loadImageData(from url: URL) + func cancelImageDataLoad(from url: URL) } final public class FeedViewController: UITableViewController { @@ -65,4 +66,9 @@ final public class FeedViewController: UITableViewController { imageLoader?.loadImageData(from: cellModel.url) return cell } + + public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let cellModel = tableModel[indexPath.row] + imageLoader?.cancelImageDataLoad(from: cellModel.url) + } } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 8e1b5cb..54878dd 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -101,6 +101,22 @@ final class FeedViewControllerTests: XCTestCase { sut.simulateFeedImageViewVisible(at: 1) XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second view also becomes visible") } + + func test_feedImageView_cancelsImageLoadingWhenNotVisibleAnymore() { + 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]) + XCTAssertEqual(loader.cancelledImageURLs, [], "Expected no cancelled image URL requests until image is not visible") + + sut.simulateFeedImageViewNotVisible(at: 0) + XCTAssertEqual(loader.cancelledImageURLs, [image0.url], "Expected one cancelled image URL request once first image is not visible anymore") + + sut.simulateFeedImageViewNotVisible(at: 1) + XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected two cancelled image URL requests once second image is also not visible anymore") + } // MARK: - Helpers @@ -167,10 +183,15 @@ final class FeedViewControllerTests: XCTestCase { // MARK: - FeedImageDataLoader private(set) var loadedImageURLs = [URL]() + private(set) var cancelledImageURLs = [URL]() func loadImageData(from url: URL) { loadedImageURLs.append(url) } + + func cancelImageDataLoad(from url: URL) { + cancelledImageURLs.append(url) + } } } @@ -216,8 +237,17 @@ private extension FeedViewController { refreshControl?.simulatePullToRefresh() } - func simulateFeedImageViewVisible(at index: Int) { - _ = feedImageView(at: index) + @discardableResult + func simulateFeedImageViewVisible(at index: Int) -> FeedImageCell? { + return feedImageView(at: index) as? FeedImageCell + } + + func simulateFeedImageViewNotVisible(at row: Int) { + let view = simulateFeedImageViewVisible(at: row) + + let delegate = tableView.delegate + let index = IndexPath(row: row, section: feedImagesSection) + delegate?.tableView?(tableView, didEndDisplaying: view!, forRowAt: index) } var isShowingLoadingIndicator: Bool { From 60fed8cf1c88fd7f856e61f5b847b4df3e56a9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 11:39:47 -0500 Subject: [PATCH 06/14] Extract `cancelImageDataLoad(from: URL)` method from `FeedImageDataLoader` protocol into a new `FeedImageDataLoaderTask` protocol that represents a task that can be cancelled. This way, we respect the Interface Segregation Principle and `FeedImageDataLoader` implementations are not forced to be stateful. --- .../EssentialFeediOS/FeedViewController.swift | 14 +++++++++----- .../FeedViewControllerTests.swift | 14 +++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index 4f6e13b..1fa3373 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -8,15 +8,19 @@ import UIKit import EssentialFeed +public protocol FeedImageDataLoaderTask { + func cancel() +} + public protocol FeedImageDataLoader { - func loadImageData(from url: URL) - func cancelImageDataLoad(from url: URL) + func loadImageData(from url: URL) -> FeedImageDataLoaderTask } final public class FeedViewController: UITableViewController { private var feedLoader: FeedLoader? private var imageLoader: FeedImageDataLoader? private var tableModel = [FeedImage]() + private var tasks = [IndexPath: FeedImageDataLoaderTask]() private var onViewIsAppearing: ((FeedViewController) -> Void)? @@ -63,12 +67,12 @@ final public class FeedViewController: UITableViewController { cell.locationContainer.isHidden = (cellModel.location == nil) cell.locationLabel.text = cellModel.location cell.descriptionLabel.text = cellModel.description - imageLoader?.loadImageData(from: cellModel.url) + tasks[indexPath] = imageLoader?.loadImageData(from: cellModel.url) return cell } public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - let cellModel = tableModel[indexPath.row] - imageLoader?.cancelImageDataLoad(from: cellModel.url) + tasks[indexPath]?.cancel() + tasks[indexPath] = nil } } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 54878dd..936f3a4 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -182,15 +182,19 @@ final class FeedViewControllerTests: XCTestCase { // MARK: - FeedImageDataLoader + private struct TaskSpy: FeedImageDataLoaderTask { + let cancelCallback: () -> Void + func cancel() { + cancelCallback() + } + } + private(set) var loadedImageURLs = [URL]() private(set) var cancelledImageURLs = [URL]() - func loadImageData(from url: URL) { + func loadImageData(from url: URL) -> FeedImageDataLoaderTask { loadedImageURLs.append(url) - } - - func cancelImageDataLoad(from url: URL) { - cancelledImageURLs.append(url) + return TaskSpy { [weak self] in self?.cancelledImageURLs.append(url) } } } From 2e05d405938dcdb8afb2f9dd1363e14ca4927889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 11:53:17 -0500 Subject: [PATCH 07/14] Feed image view loading indicator is visible while loading image --- .../EssentialFeed.xcodeproj/project.pbxproj | 4 ++ .../EssentialFeediOS/FeedImageCell.swift | 1 + .../EssentialFeediOS/FeedViewController.swift | 9 ++- .../EssentialFeediOS/UIView+Shimmering.swift | 60 +++++++++++++++++++ .../FeedViewControllerTests.swift | 44 +++++++++++++- 5 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 0701309..7682b16 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 080EDF0E21B6DCB600813479 /* FeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080EDF0D21B6DCB600813479 /* FeedLoader.swift */; }; 231DA90C2D28B3AE00A50156 /* RemoteFeedLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231DA90B2D28B3AB00A50156 /* RemoteFeedLoader.swift */; }; 234EE2712F1FE49200817225 /* FeedImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 234EE2702F1FE49000817225 /* FeedImageCell.swift */; }; + 234EE2752F21394A00817225 /* UIView+Shimmering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 234EE2742F21394800817225 /* UIView+Shimmering.swift */; }; 2370B4092EEC68FE00737DAC /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2370B4082EEC68FE00737DAC /* HTTPClient.swift */; }; 2370B40B2EEC6BE200737DAC /* FeedItemsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2370B40A2EEC6BE200737DAC /* FeedItemsMapper.swift */; }; 2377131E2F199AAF00D1122A /* FeedViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2377131D2F199AAF00D1122A /* FeedViewControllerTests.swift */; }; @@ -118,6 +119,7 @@ 080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = ""; }; 231DA90B2D28B3AB00A50156 /* RemoteFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeedLoader.swift; sourceTree = ""; }; 234EE2702F1FE49000817225 /* FeedImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageCell.swift; sourceTree = ""; }; + 234EE2742F21394800817225 /* UIView+Shimmering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Shimmering.swift"; sourceTree = ""; }; 2370B4082EEC68FE00737DAC /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; 2370B40A2EEC6BE200737DAC /* FeedItemsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapper.swift; sourceTree = ""; }; 2377131D2F199AAF00D1122A /* FeedViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewControllerTests.swift; sourceTree = ""; }; @@ -314,6 +316,7 @@ 23F5D6F02F182E5600AC7D9F /* EssentialFeediOS */ = { isa = PBXGroup; children = ( + 234EE2742F21394800817225 /* UIView+Shimmering.swift */, 234EE2702F1FE49000817225 /* FeedImageCell.swift */, 2377132C2F1EC94D00D1122A /* FeedViewController.swift */, 23F5D6FA2F18345C00AC7D9F /* CI_iOS.xctestplan */, @@ -701,6 +704,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 234EE2752F21394A00817225 /* UIView+Shimmering.swift in Sources */, 2377132D2F1EC9D800D1122A /* FeedViewController.swift in Sources */, 234EE2712F1FE49200817225 /* FeedImageCell.swift in Sources */, ); diff --git a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift index 023ab0a..7aa54b2 100644 --- a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift +++ b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift @@ -11,4 +11,5 @@ public final class FeedImageCell: UITableViewCell { public let locationContainer = UIView() public let locationLabel = UILabel() public let descriptionLabel = UILabel() + public let feedImageContainer = UIView() } diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index 1fa3373..bc8da26 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -13,7 +13,9 @@ public protocol FeedImageDataLoaderTask { } public protocol FeedImageDataLoader { - func loadImageData(from url: URL) -> FeedImageDataLoaderTask + typealias Result = Swift.Result + + func loadImageData(from url: URL, completion: @escaping (Result) -> Void) -> FeedImageDataLoaderTask } final public class FeedViewController: UITableViewController { @@ -67,7 +69,10 @@ final public class FeedViewController: UITableViewController { cell.locationContainer.isHidden = (cellModel.location == nil) cell.locationLabel.text = cellModel.location cell.descriptionLabel.text = cellModel.description - tasks[indexPath] = imageLoader?.loadImageData(from: cellModel.url) + cell.feedImageContainer.startShimmering() + tasks[indexPath] = imageLoader?.loadImageData(from: cellModel.url) { [weak cell] result in + cell?.feedImageContainer.stopShimmering() + } return cell } diff --git a/EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift b/EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift new file mode 100644 index 0000000..98036d9 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift @@ -0,0 +1,60 @@ +// +// UIView+Shimmering.swift +// EssentialFeed +// +// Created by Andres Sanchez on 21/1/26. +// + +import UIKit + +extension UIView { + public var isShimmering: Bool { + set { + if newValue { + startShimmering() + } else { + stopShimmering() + } + } + + get { + layer.mask is ShimmeringLayer + } + } + + private func startShimmering() { + layer.mask = ShimmeringLayer(size: bounds.size) + } + + private func stopShimmering() { + layer.mask = nil + } + + private class ShimmeringLayer: CAGradientLayer { + private var observer: Any? + + convenience init(size: CGSize) { + self.init() + + let white = UIColor.white.cgColor + let alpha = UIColor.white.withAlphaComponent(0.75).cgColor + + colors = [alpha, white, alpha] + startPoint = CGPoint(x: 0.0, y: 0.4) + endPoint = CGPoint(x: 1.0, y: 0.6) + locations = [0.4, 0.5, 0.6] + frame = CGRect(x: -size.width, y: 0, width: size.width*3, height: size.height) + + let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) + animation.fromValue = [0.0, 0.1, 0.2] + animation.toValue = [0.8, 0.9, 1.0] + animation.duration = 1.25 + animation.repeatCount = .infinity + add(animation, forKey: "shimmer") + + observer = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.add(animation, forKey: "shimmer") + } + } + } +} diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 936f3a4..351a266 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -118,6 +118,26 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected two cancelled image URL requests once second image is also not visible anymore") } + func test_feedImageViewLoadingIndicator_isVisibleWhileLoadingImage() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(view0?.isShowingImageLoadingIndicator, true, "Expected loading indicator for first view while loading first image") + XCTAssertEqual(view1?.isShowingImageLoadingIndicator, true, "Expected loading indicator for second view while loading second image") + + loader.completeImageLoading(at: 0) + XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator for first view once first image loading completes successfully") + XCTAssertEqual(view1?.isShowingImageLoadingIndicator, true, "Expected no loading indicator state change for second view once first image loading completes successfully") + + 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") + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { @@ -189,13 +209,27 @@ final class FeedViewControllerTests: XCTestCase { } } - private(set) var loadedImageURLs = [URL]() + private var imageRequests = [(url: URL, completion: (FeedImageDataLoader.Result) -> Void)]() + + var loadedImageURLs: [URL] { + return imageRequests.map { $0.url } + } + private(set) var cancelledImageURLs = [URL]() - func loadImageData(from url: URL) -> FeedImageDataLoaderTask { - loadedImageURLs.append(url) + func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask { + imageRequests.append((url, completion)) return TaskSpy { [weak self] in self?.cancelledImageURLs.append(url) } } + + func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) { + imageRequests[index].completion(.success(imageData)) + } + + func completeImageLoadingWithError(at index: Int = 0) { + let error = NSError(domain: "an error", code: 0) + imageRequests[index].completion(.failure(error)) + } } } @@ -282,6 +316,10 @@ private extension FeedImageCell { return locationLabel.text } + var isShowingImageLoadingIndicator: Bool { + return feedImageContainer.isShimmering + } + var descriptionText: String? { return descriptionLabel.text } From 1b7875307964a0a41bb362b56b5a49699dd64987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 12:06:21 -0500 Subject: [PATCH 08/14] Feed image view loading indicator is visible while loading image (with updated shimmering logic) Update shimmering and lifecycle in prototype --- .../EssentialFeediOS/UIView+Shimmering.swift | 4 +- Prototype/Prototype/FeedImageCell.swift | 65 ++++++++++++------- Prototype/Prototype/FeedViewController.swift | 4 +- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift b/EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift index 98036d9..9094cfc 100644 --- a/EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift +++ b/EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift @@ -22,11 +22,11 @@ extension UIView { } } - private func startShimmering() { + func startShimmering() { layer.mask = ShimmeringLayer(size: bounds.size) } - private func stopShimmering() { + func stopShimmering() { layer.mask = nil } diff --git a/Prototype/Prototype/FeedImageCell.swift b/Prototype/Prototype/FeedImageCell.swift index b33adfb..fbe442c 100644 --- a/Prototype/Prototype/FeedImageCell.swift +++ b/Prototype/Prototype/FeedImageCell.swift @@ -45,35 +45,54 @@ final class FeedImageCell: UITableViewCell { } } -private extension UIView { - private var shimmerAnimationKey: String { - return "shimmer" +extension UIView { + public var isShimmering: Bool { + set { + if newValue { + startShimmering() + } else { + stopShimmering() + } + } + + get { + layer.mask is ShimmeringLayer + } } func startShimmering() { - let white = UIColor.white.cgColor - let alpha = UIColor.white.withAlphaComponent(0.7).cgColor - let width = bounds.width - let height = bounds.height - - let gradient = CAGradientLayer() - gradient.colors = [alpha, white, alpha] - gradient.startPoint = CGPoint(x: 0.0, y: 0.4) - gradient.endPoint = CGPoint(x: 1.0, y: 0.6) - gradient.locations = [0.4, 0.5, 0.6] - gradient.frame = CGRect(x: -width, y: 0, width: width*3, height: height) - layer.mask = gradient - - let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) - animation.fromValue = [0.0, 0.1, 0.2] - animation.toValue = [0.8, 0.9, 1.0] - animation.duration = 1 - animation.repeatCount = .infinity - gradient.add(animation, forKey: shimmerAnimationKey) + layer.mask = ShimmeringLayer(size: bounds.size) } func stopShimmering() { layer.mask = nil } + + private class ShimmeringLayer: CAGradientLayer { + private var observer: Any? + + convenience init(size: CGSize) { + self.init() + + let white = UIColor.white.cgColor + let alpha = UIColor.white.withAlphaComponent(0.75).cgColor + + colors = [alpha, white, alpha] + startPoint = CGPoint(x: 0.0, y: 0.4) + endPoint = CGPoint(x: 1.0, y: 0.6) + locations = [0.4, 0.5, 0.6] + frame = CGRect(x: -size.width, y: 0, width: size.width*3, height: size.height) + + let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) + animation.fromValue = [0.0, 0.1, 0.2] + animation.toValue = [0.8, 0.9, 1.0] + animation.duration = 1.25 + animation.repeatCount = .infinity + add(animation, forKey: "shimmer") + + observer = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in + self?.add(animation, forKey: "shimmer") + } + } + } } - diff --git a/Prototype/Prototype/FeedViewController.swift b/Prototype/Prototype/FeedViewController.swift index 07bd19f..25b4a24 100644 --- a/Prototype/Prototype/FeedViewController.swift +++ b/Prototype/Prototype/FeedViewController.swift @@ -16,8 +16,8 @@ struct FeedImageViewModel { final class FeedViewController: UITableViewController { private var feed = [FeedImageViewModel]() - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) + override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) refresh() tableView.setContentOffset(CGPoint(x: 0, y: -tableView.contentInset.top), animated: false) From 49f999bcadcfcb7f72d5978d7aed8a44783709ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 14:25:13 -0500 Subject: [PATCH 09/14] Render loaded images from URL (Updated image creation) --- .../EssentialFeediOS/FeedImageCell.swift | 1 + .../EssentialFeediOS/FeedViewController.swift | 3 ++ .../FeedViewControllerTests.swift | 39 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift index 7aa54b2..ef261b4 100644 --- a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift +++ b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift @@ -12,4 +12,5 @@ public final class FeedImageCell: UITableViewCell { public let locationLabel = UILabel() public let descriptionLabel = UILabel() public let feedImageContainer = UIView() + public let feedImageView = UIImageView() } diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index bc8da26..2625c71 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -69,8 +69,11 @@ final public class FeedViewController: UITableViewController { cell.locationContainer.isHidden = (cellModel.location == nil) cell.locationLabel.text = cellModel.location cell.descriptionLabel.text = cellModel.description + cell.feedImageView.image = nil cell.feedImageContainer.startShimmering() tasks[indexPath] = imageLoader?.loadImageData(from: cellModel.url) { [weak cell] result in + let data = try? result.get() + cell?.feedImageView.image = data.map(UIImage.init) ?? nil cell?.feedImageContainer.stopShimmering() } return cell diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 351a266..20c6141 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -138,6 +138,28 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertEqual(view1?.isShowingImageLoadingIndicator, false, "Expected no loading indicator for second view once second image loading completes with error") } + func test_feedImageView_rendersImageLoadedFromURL() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(view0?.renderedImage, .none, "Expected no image for first view while loading first image") + XCTAssertEqual(view1?.renderedImage, .none, "Expected no image for second view while loading second image") + + let imageData0 = UIImage.make(withColor: .red).pngData()! + loader.completeImageLoading(with: imageData0, at: 0) + XCTAssertEqual(view0?.renderedImage, imageData0, "Expected image for first view once first image loading completes successfully") + XCTAssertEqual(view1?.renderedImage, .none, "Expected no image state change for second view once first image loading completes successfully") + + let imageData1 = UIImage.make(withColor: .blue).pngData()! + loader.completeImageLoading(with: imageData1, at: 1) + XCTAssertEqual(view0?.renderedImage, imageData0, "Expected no image state change for first view once second image loading completes successfully") + XCTAssertEqual(view1?.renderedImage, imageData1, "Expected image for second view once second image loading completes successfully") + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { @@ -323,6 +345,10 @@ private extension FeedImageCell { var descriptionText: String? { return descriptionLabel.text } + + var renderedImage: Data? { + return feedImageView.image?.pngData() + } } private extension UIRefreshControl { @@ -334,3 +360,16 @@ private extension UIRefreshControl { } } } + +private extension UIImage { + static func make(withColor color: UIColor) -> UIImage { + let rect = CGRect(x: 0, y: 0, width: 1, height: 1) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + + return UIGraphicsImageRenderer(size: rect.size, format: format).image { rendererContext in + color.setFill() + rendererContext.fill(rect) + } + } +} From 3ac6688474094c2e1115d50de89dba188e94b67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 14:32:58 -0500 Subject: [PATCH 10/14] Feed image view retry button is visible on image url load error --- .../EssentialFeediOS/FeedImageCell.swift | 1 + .../EssentialFeediOS/FeedViewController.swift | 2 ++ .../FeedViewControllerTests.swift | 25 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift index ef261b4..1c3fe8a 100644 --- a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift +++ b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift @@ -13,4 +13,5 @@ public final class FeedImageCell: UITableViewCell { public let descriptionLabel = UILabel() public let feedImageContainer = UIView() public let feedImageView = UIImageView() + public let feedImageRetryButton = UIButton() } diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index 2625c71..684fa94 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -70,10 +70,12 @@ final public class FeedViewController: UITableViewController { cell.locationLabel.text = cellModel.location cell.descriptionLabel.text = cellModel.description cell.feedImageView.image = nil + cell.feedImageRetryButton.isHidden = true cell.feedImageContainer.startShimmering() tasks[indexPath] = imageLoader?.loadImageData(from: cellModel.url) { [weak cell] result in let data = try? result.get() cell?.feedImageView.image = data.map(UIImage.init) ?? nil + cell?.feedImageRetryButton.isHidden = (data != nil) cell?.feedImageContainer.stopShimmering() } return cell diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 20c6141..658c254 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -160,6 +160,27 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertEqual(view1?.renderedImage, imageData1, "Expected image for second view once second image loading completes successfully") } + func test_feedImageViewRetryButton_isVisibleOnImageURLLoadError() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action for first view while loading first image") + XCTAssertEqual(view1?.isShowingRetryAction, false, "Expected no retry action for second view while loading second image") + + let imageData = UIImage.make(withColor: .red).pngData()! + loader.completeImageLoading(with: imageData, at: 0) + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action for first view once first image loading completes successfully") + XCTAssertEqual(view1?.isShowingRetryAction, false, "Expected no retry action state change for second view once first image loading completes successfully") + + loader.completeImageLoadingWithError(at: 1) + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action state change for first view once second image loading completes with error") + XCTAssertEqual(view1?.isShowingRetryAction, true, "Expected retry action for second view once second image loading completes with error") + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { @@ -338,6 +359,10 @@ private extension FeedImageCell { return locationLabel.text } + var isShowingRetryAction: Bool { + return !feedImageRetryButton.isHidden + } + var isShowingImageLoadingIndicator: Bool { return feedImageContainer.isShimmering } From c7026722b699294ef452d2ad11ed095856a68563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 14:37:19 -0500 Subject: [PATCH 11/14] Feed image view retry button is visible on invalid loaded image data --- .../EssentialFeediOS/FeedViewController.swift | 5 +++-- .../FeedViewControllerTests.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index 684fa94..f04d0a6 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -74,8 +74,9 @@ final public class FeedViewController: UITableViewController { cell.feedImageContainer.startShimmering() tasks[indexPath] = imageLoader?.loadImageData(from: cellModel.url) { [weak cell] result in let data = try? result.get() - cell?.feedImageView.image = data.map(UIImage.init) ?? nil - cell?.feedImageRetryButton.isHidden = (data != nil) + let image = data.map(UIImage.init) ?? nil + cell?.feedImageView.image = image + cell?.feedImageRetryButton.isHidden = (image != nil) cell?.feedImageContainer.stopShimmering() } return cell diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 658c254..317ed44 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -181,6 +181,20 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertEqual(view1?.isShowingRetryAction, true, "Expected retry action for second view once second image loading completes with error") } + func test_feedImageViewRetryButton_isVisibleOnInvalidImageData() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage()]) + + let view = sut.simulateFeedImageViewVisible(at: 0) + XCTAssertEqual(view?.isShowingRetryAction, false, "Expected no retry action while loading image") + + let invalidImageData = Data("invalid image data".utf8) + loader.completeImageLoading(with: invalidImageData, at: 0) + XCTAssertEqual(view?.isShowingRetryAction, true, "Expected retry action once image loading completes with invalid image data") + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { From f3e3f5ee732ce05a2ab8de55b42e95a504f411c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 21:26:03 -0500 Subject: [PATCH 12/14] Retry failed image load on retry action --- .../EssentialFeediOS/FeedImageCell.swift | 13 ++++++- .../EssentialFeediOS/FeedViewController.swift | 21 ++++++++--- .../FeedViewControllerTests.swift | 37 +++++++++++++++++++ 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift index 1c3fe8a..53e5478 100644 --- a/EssentialFeed/EssentialFeediOS/FeedImageCell.swift +++ b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift @@ -13,5 +13,16 @@ public final class FeedImageCell: UITableViewCell { public let descriptionLabel = UILabel() public let feedImageContainer = UIView() public let feedImageView = UIImageView() - public let feedImageRetryButton = UIButton() + + private(set) public lazy var feedImageRetryButton: UIButton = { + let button = UIButton() + button.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside) + return button + }() + + var onRetry: (() -> Void)? + + @objc private func retryButtonTapped() { + onRetry?() + } } diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index f04d0a6..c84da2e 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -72,13 +72,22 @@ final public class FeedViewController: UITableViewController { cell.feedImageView.image = nil cell.feedImageRetryButton.isHidden = true cell.feedImageContainer.startShimmering() - tasks[indexPath] = imageLoader?.loadImageData(from: cellModel.url) { [weak cell] result in - let data = try? result.get() - let image = data.map(UIImage.init) ?? nil - cell?.feedImageView.image = image - cell?.feedImageRetryButton.isHidden = (image != nil) - cell?.feedImageContainer.stopShimmering() + + let loadImage = { [weak self, weak cell] in + guard let self = self else { return } + + self.tasks[indexPath] = self.imageLoader?.loadImageData(from: cellModel.url) { [weak cell] result in + let data = try? result.get() + let image = data.map(UIImage.init) ?? nil + cell?.feedImageView.image = image + cell?.feedImageRetryButton.isHidden = (image != nil) + cell?.feedImageContainer.stopShimmering() + } } + + cell.onRetry = loadImage + loadImage() + return cell } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 317ed44..6f6616a 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -194,6 +194,29 @@ final class FeedViewControllerTests: XCTestCase { loader.completeImageLoading(with: invalidImageData, at: 0) XCTAssertEqual(view?.isShowingRetryAction, true, "Expected retry action once image loading completes with invalid image data") } + + func test_feedImageViewRetryAction_retriesImageLoad() { + 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]) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected two image URL request for the two visible views") + + loader.completeImageLoadingWithError(at: 0) + loader.completeImageLoadingWithError(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected only two image URL requests before retry action") + + view0?.simulateRetryAction() + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url, image0.url], "Expected third imageURL request after first view retry action") + + view1?.simulateRetryAction() + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url, image0.url, image1.url], "Expected fourth imageURL request after second view retry action") + } // MARK: - Helpers @@ -365,6 +388,10 @@ private extension FeedViewController { } private extension FeedImageCell { + func simulateRetryAction() { + feedImageRetryButton.simulateTap() + } + var isShowingLocation: Bool { return !locationContainer.isHidden } @@ -390,6 +417,16 @@ private extension FeedImageCell { } } +private extension UIButton { + func simulateTap() { + allTargets.forEach { target in + actions(forTarget: target, forControlEvent: .touchUpInside)?.forEach { + (target as NSObject).perform(Selector($0)) + } + } + } +} + private extension UIRefreshControl { func simulatePullToRefresh() { allTargets.forEach { target in From a5d3100cdc3f890fdd3be4671f3f41513238ae1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 21:26:51 -0500 Subject: [PATCH 13/14] Preload image URL when image view is near visible --- .../EssentialFeediOS/FeedViewController.swift | 10 ++++++++- .../FeedViewControllerTests.swift | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index c84da2e..53dbf47 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -18,7 +18,7 @@ public protocol FeedImageDataLoader { func loadImageData(from url: URL, completion: @escaping (Result) -> Void) -> FeedImageDataLoaderTask } -final public class FeedViewController: UITableViewController { +final public class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching { private var feedLoader: FeedLoader? private var imageLoader: FeedImageDataLoader? private var tableModel = [FeedImage]() @@ -37,6 +37,7 @@ final public class FeedViewController: UITableViewController { refreshControl = UIRefreshControl() refreshControl?.addTarget(self, action: #selector(load), for: .valueChanged) + tableView.prefetchDataSource = self onViewIsAppearing = { vc in vc.onViewIsAppearing = nil vc.load() @@ -95,4 +96,11 @@ final public class FeedViewController: UITableViewController { tasks[indexPath]?.cancel() tasks[indexPath] = nil } + + public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + indexPaths.forEach { indexPath in + let cellModel = tableModel[indexPath.row] + _ = imageLoader?.loadImageData(from: cellModel.url) { _ in } + } + } } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 6f6616a..978acf5 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -218,6 +218,22 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url, image0.url, image1.url], "Expected fourth imageURL request after second view retry action") } + func test_feedImageView_preloadsImageURLWhenNearVisible() { + 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]) + XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until image is near visible") + + sut.simulateFeedImageViewNearVisible(at: 0) + XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first image is near visible") + + sut.simulateFeedImageViewNearVisible(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second image is near visible") + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { @@ -368,6 +384,12 @@ private extension FeedViewController { delegate?.tableView?(tableView, didEndDisplaying: view!, forRowAt: index) } + func simulateFeedImageViewNearVisible(at row: Int) { + let ds = tableView.prefetchDataSource + let index = IndexPath(row: row, section: feedImagesSection) + ds?.tableView(tableView, prefetchRowsAt: [index]) + } + var isShowingLoadingIndicator: Bool { return refreshControl?.isRefreshing == true } From 6191a5fa9346836b5a882976b16f5d30f76b1a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 21 Jan 2026 21:32:50 -0500 Subject: [PATCH 14/14] Cancel image URL preloading when image view is not near visible anymore --- .../EssentialFeediOS/FeedViewController.swift | 14 ++++++++--- .../FeedViewControllerTests.swift | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/EssentialFeed/EssentialFeediOS/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/FeedViewController.swift index 53dbf47..48b908e 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -93,14 +93,22 @@ final public class FeedViewController: UITableViewController, UITableViewDataSou } public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - tasks[indexPath]?.cancel() - tasks[indexPath] = nil + cancelTask(forRowAt: indexPath) } public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { indexPaths.forEach { indexPath in let cellModel = tableModel[indexPath.row] - _ = imageLoader?.loadImageData(from: cellModel.url) { _ in } + tasks[indexPath] = imageLoader?.loadImageData(from: cellModel.url) { _ in } } } + + public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + indexPaths.forEach(cancelTask) + } + + private func cancelTask(forRowAt indexPath: IndexPath) { + tasks[indexPath]?.cancel() + tasks[indexPath] = nil + } } diff --git a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 978acf5..8d53f42 100644 --- a/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift +++ b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift @@ -234,6 +234,22 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second image is near visible") } + func test_feedImageView_cancelsImageURLPreloadingWhenNotNearVisibleAnymore() { + 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]) + XCTAssertEqual(loader.cancelledImageURLs, [], "Expected no cancelled image URL requests until image is not near visible") + + sut.simulateFeedImageViewNotNearVisible(at: 0) + XCTAssertEqual(loader.cancelledImageURLs, [image0.url], "Expected first cancelled image URL request once first image is not near visible anymore") + + sut.simulateFeedImageViewNotNearVisible(at: 1) + XCTAssertEqual(loader.cancelledImageURLs, [image0.url, image1.url], "Expected second cancelled image URL request once second image is not near visible anymore") + } + // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> (sut: FeedViewController, loader: LoaderSpy) { @@ -389,6 +405,14 @@ private extension FeedViewController { let index = IndexPath(row: row, section: feedImagesSection) ds?.tableView(tableView, prefetchRowsAt: [index]) } + + func simulateFeedImageViewNotNearVisible(at row: Int) { + simulateFeedImageViewNearVisible(at: row) + + let ds = tableView.prefetchDataSource + let index = IndexPath(row: row, section: feedImagesSection) + ds?.tableView?(tableView, cancelPrefetchingForRowsAt: [index]) + } var isShowingLoadingIndicator: Bool { return refreshControl?.isRefreshing == true