diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index c45351f..7682b16 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 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 */; }; + 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 */; }; @@ -116,6 +118,8 @@ 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 = ""; }; + 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 = ""; }; @@ -312,6 +316,8 @@ 23F5D6F02F182E5600AC7D9F /* EssentialFeediOS */ = { isa = PBXGroup; children = ( + 234EE2742F21394800817225 /* UIView+Shimmering.swift */, + 234EE2702F1FE49000817225 /* FeedImageCell.swift */, 2377132C2F1EC94D00D1122A /* FeedViewController.swift */, 23F5D6FA2F18345C00AC7D9F /* CI_iOS.xctestplan */, ); @@ -698,7 +704,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 234EE2752F21394A00817225 /* UIView+Shimmering.swift in Sources */, 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..53e5478 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/FeedImageCell.swift @@ -0,0 +1,28 @@ +// +// 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() + public let feedImageContainer = UIView() + public let feedImageView = UIImageView() + + 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 d523c5c..48b908e 100644 --- a/EssentialFeed/EssentialFeediOS/FeedViewController.swift +++ b/EssentialFeed/EssentialFeediOS/FeedViewController.swift @@ -8,13 +8,28 @@ import UIKit import EssentialFeed -final public class FeedViewController: UITableViewController { - private var loader: FeedLoader? +public protocol FeedImageDataLoaderTask { + func cancel() +} + +public protocol FeedImageDataLoader { + typealias Result = Swift.Result + + func loadImageData(from url: URL, completion: @escaping (Result) -> Void) -> FeedImageDataLoaderTask +} + +final public class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching { + private var feedLoader: FeedLoader? + private var imageLoader: FeedImageDataLoader? + private var tableModel = [FeedImage]() + private var tasks = [IndexPath: FeedImageDataLoaderTask]() + 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() { @@ -22,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() @@ -35,8 +51,64 @@ final public class FeedViewController: UITableViewController { @objc private func load() { refreshControl?.beginRefreshing() - loader?.load { [weak self] _ in + feedLoader?.load { [weak self] result in + if let feed = try? result.get() { + self?.tableModel = feed + 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 + cell.feedImageView.image = nil + cell.feedImageRetryButton.isHidden = true + cell.feedImageContainer.startShimmering() + + 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 + } + + public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + cancelTask(forRowAt: indexPath) + } + + public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + indexPaths.forEach { indexPath in + let cellModel = tableModel[indexPath.row] + 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/EssentialFeediOS/UIView+Shimmering.swift b/EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift new file mode 100644 index 0000000..9094cfc --- /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 + } + } + + func startShimmering() { + 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/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedViewControllerTests.swift index 2030e7a..8d53f42 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") } @@ -45,38 +45,302 @@ 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() { + 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]) + } + + 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]) + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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) { 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) } - class LoaderSpy: FeedLoader { - private var completions = [(FeedLoader.Result) -> Void]() + 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, FeedImageDataLoader { + + // MARK: - FeedLoader + + private var feedRequests = [(FeedLoader.Result) -> Void]() - var loadCallCount: Int { - return completions.count + var loadFeedCallCount: Int { + return feedRequests.count } func load(completion: @escaping (FeedLoader.Result) -> Void) { - completions.append(completion) + feedRequests.append(completion) } - func completeFeedLoading(at index: Int) { - completions[index](.success([])) + func completeFeedLoading(with feed: [FeedImage] = [], at index: Int = 0) { + feedRequests[index](.success(feed)) + } + + func completeFeedLoadingWithError(at index: Int = 0) { + let error = NSError(domain: "an error", code: 0) + feedRequests[index](.failure(error)) + } + + // MARK: - FeedImageDataLoader + + private struct TaskSpy: FeedImageDataLoaderTask { + let cancelCallback: () -> Void + func cancel() { + cancelCallback() + } + } + + 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, 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)) } } @@ -123,9 +387,90 @@ private extension FeedViewController { refreshControl?.simulatePullToRefresh() } + @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) + } + + func simulateFeedImageViewNearVisible(at row: Int) { + let ds = tableView.prefetchDataSource + 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 } + + 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 { + func simulateRetryAction() { + feedImageRetryButton.simulateTap() + } + + var isShowingLocation: Bool { + return !locationContainer.isHidden + } + + var locationText: String? { + return locationLabel.text + } + + var isShowingRetryAction: Bool { + return !feedImageRetryButton.isHidden + } + + var isShowingImageLoadingIndicator: Bool { + return feedImageContainer.isShimmering + } + + var descriptionText: String? { + return descriptionLabel.text + } + + var renderedImage: Data? { + return feedImageView.image?.pngData() + } +} + +private extension UIButton { + func simulateTap() { + allTargets.forEach { target in + actions(forTarget: target, forControlEvent: .touchUpInside)?.forEach { + (target as NSObject).perform(Selector($0)) + } + } + } } private extension UIRefreshControl { @@ -137,3 +482,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) + } + } +} 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)