From b92c9d609431bf5b9ccb35b126b8f298f5795003 Mon Sep 17 00:00:00 2001 From: ispiryanaram Date: Thu, 6 Jun 2024 17:37:57 +0400 Subject: [PATCH 1/9] Add Feed storyboard and assets with layout from prototype. --- FeedApp.xcodeproj/project.pbxproj | 8 + FeedAppiOS/Feed UI/Views/Feed.storyboard | 144 ++++++++++++++++++ .../Feed UI/Views/Feed.xcassets/Contents.json | 6 + .../Feed.xcassets/pin.imageset/Contents.json | 12 ++ .../Views/Feed.xcassets/pin.imageset/pin.png | Bin 0 -> 2783 bytes 5 files changed, 170 insertions(+) create mode 100644 FeedAppiOS/Feed UI/Views/Feed.storyboard create mode 100644 FeedAppiOS/Feed UI/Views/Feed.xcassets/Contents.json create mode 100644 FeedAppiOS/Feed UI/Views/Feed.xcassets/pin.imageset/Contents.json create mode 100644 FeedAppiOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png diff --git a/FeedApp.xcodeproj/project.pbxproj b/FeedApp.xcodeproj/project.pbxproj index d8513f9..6d20c4a 100644 --- a/FeedApp.xcodeproj/project.pbxproj +++ b/FeedApp.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 97102C5B2BE05B60007C90B3 /* FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97102C5A2BE05B60007C90B3 /* FeedStoreSpecs.swift */; }; 97102C5D2BE05E19007C90B3 /* XCTestCase+FeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97102C5C2BE05E19007C90B3 /* XCTestCase+FeedStoreSpecs.swift */; }; 97192B8B2BD69E9800AE5E69 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97192B8A2BD69E9800AE5E69 /* CacheFeedUseCaseTests.swift */; }; + 9728EA9A2C11F22900F28C4A /* Feed.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9728EA992C11F22900F28C4A /* Feed.storyboard */; }; + 9728EA9C2C11F29800F28C4A /* Feed.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9728EA9B2C11F29800F28C4A /* Feed.xcassets */; }; 973BA9CE2BC5478C00013B53 /* FeedApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 973BA9C52BC5478900013B53 /* FeedApp.framework */; }; 973BA9D32BC5478C00013B53 /* LoadFeedFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973BA9D22BC5478C00013B53 /* LoadFeedFromRemoteUseCaseTests.swift */; }; 973BA9D42BC5478C00013B53 /* FeedApp.h in Headers */ = {isa = PBXBuildFile; fileRef = 973BA9C82BC5478900013B53 /* FeedApp.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -127,6 +129,8 @@ 97102C5A2BE05B60007C90B3 /* FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpecs.swift; sourceTree = ""; }; 97102C5C2BE05E19007C90B3 /* XCTestCase+FeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedStoreSpecs.swift"; sourceTree = ""; }; 97192B8A2BD69E9800AE5E69 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; + 9728EA992C11F22900F28C4A /* Feed.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Feed.storyboard; sourceTree = ""; }; + 9728EA9B2C11F29800F28C4A /* Feed.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Feed.xcassets; sourceTree = ""; }; 973BA9C52BC5478900013B53 /* FeedApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FeedApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 973BA9C82BC5478900013B53 /* FeedApp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedApp.h; sourceTree = ""; }; 973BA9CD2BC5478C00013B53 /* FeedAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeedAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -375,6 +379,8 @@ children = ( 974D6F402BF3A07B00F7211C /* UIView+Shimmering.swift */, 974D6F3E2BF366A700F7211C /* FeedImageCell.swift */, + 9728EA992C11F22900F28C4A /* Feed.storyboard */, + 9728EA9B2C11F29800F28C4A /* Feed.xcassets */, ); path = Views; sourceTree = ""; @@ -735,6 +741,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9728EA9A2C11F22900F28C4A /* Feed.storyboard in Resources */, + 9728EA9C2C11F29800F28C4A /* Feed.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/FeedAppiOS/Feed UI/Views/Feed.storyboard b/FeedAppiOS/Feed UI/Views/Feed.storyboard new file mode 100644 index 0000000..50c3e19 --- /dev/null +++ b/FeedAppiOS/Feed UI/Views/Feed.storyboard @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FeedAppiOS/Feed UI/Views/Feed.xcassets/Contents.json b/FeedAppiOS/Feed UI/Views/Feed.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/FeedAppiOS/Feed UI/Views/Feed.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FeedAppiOS/Feed UI/Views/Feed.xcassets/pin.imageset/Contents.json b/FeedAppiOS/Feed UI/Views/Feed.xcassets/pin.imageset/Contents.json new file mode 100644 index 0000000..d3f899f --- /dev/null +++ b/FeedAppiOS/Feed UI/Views/Feed.xcassets/pin.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pin.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FeedAppiOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png b/FeedAppiOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png new file mode 100644 index 0000000000000000000000000000000000000000..b1b2b540c378c0f1115700d09466fd3c6d555fec GIT binary patch literal 2783 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 literal 0 HcmV?d00001 From cdc50b0b14e4ceda00783c543f98019e0c5761ff Mon Sep 17 00:00:00 2001 From: ispiryanaram Date: Thu, 6 Jun 2024 17:46:41 +0400 Subject: [PATCH 2/9] Initiate FeedViewController from "storyboard". --- FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift | 6 +++++- FeedAppiOS/Feed UI/Controllers/FeedViewController.swift | 7 +------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift b/FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift index 1ed9d83..ae0cbf5 100644 --- a/FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift +++ b/FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift @@ -14,7 +14,11 @@ public final class FeedUIComposer { public static func feedComposedWith(feedLoader: FeedLoader, imageLoader: FeedImageDataLoader) -> FeedViewController { let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader: feedLoader) let refreshController = FeedRefreshViewController(delegate: presentationAdapter) - let feedController = FeedViewController(refreshController: refreshController) + + let bundle = Bundle(for: FeedViewController.self) + let storyboard = UIStoryboard(name: "Feed", bundle: bundle) + let feedController = storyboard.instantiateInitialViewController() as! FeedViewController + feedController.refreshController = refreshController presentationAdapter.presenter = FeedPresenter( feedView: FeedViewAdapter(controller: feedController, imageLoader: imageLoader), diff --git a/FeedAppiOS/Feed UI/Controllers/FeedViewController.swift b/FeedAppiOS/Feed UI/Controllers/FeedViewController.swift index bea1d19..d9a86ab 100644 --- a/FeedAppiOS/Feed UI/Controllers/FeedViewController.swift +++ b/FeedAppiOS/Feed UI/Controllers/FeedViewController.swift @@ -8,18 +8,13 @@ import UIKit public final class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching { - private var refreshController: FeedRefreshViewController? + var refreshController: FeedRefreshViewController? var tableModel = [FeedImageCellController]() { didSet { tableView.reloadData() } } private var isViewIsAppeared = false private var cellControllers = [IndexPath: FeedImageCellController]() - convenience init(refreshController: FeedRefreshViewController) { - self.init() - self.refreshController = refreshController - } - public override func viewIsAppearing(_ animated: Bool) { super.viewIsAppearing(animated) From 70dd03e058fe65e4fc557e42844ad47e118b02f0 Mon Sep 17 00:00:00 2001 From: ispiryanaram Date: Thu, 6 Jun 2024 17:58:10 +0400 Subject: [PATCH 3/9] Move FeedViewController + FeedRefreshViewController composition (instantiation and configuration) to Storyboard --- .../Feed UI/Composers/FeedUIComposer.swift | 4 +- .../FeedRefreshViewController.swift | 25 ++++------- .../Controllers/FeedViewController.swift | 3 +- FeedAppiOS/Feed UI/Views/Feed.storyboard | 44 +++++++++++-------- 4 files changed, 37 insertions(+), 39 deletions(-) diff --git a/FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift b/FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift index ae0cbf5..411f8c3 100644 --- a/FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift +++ b/FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift @@ -13,12 +13,12 @@ public final class FeedUIComposer { public static func feedComposedWith(feedLoader: FeedLoader, imageLoader: FeedImageDataLoader) -> FeedViewController { let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader: feedLoader) - let refreshController = FeedRefreshViewController(delegate: presentationAdapter) let bundle = Bundle(for: FeedViewController.self) let storyboard = UIStoryboard(name: "Feed", bundle: bundle) let feedController = storyboard.instantiateInitialViewController() as! FeedViewController - feedController.refreshController = refreshController + let refreshController = feedController.refreshController! + refreshController.delegate = presentationAdapter presentationAdapter.presenter = FeedPresenter( feedView: FeedViewAdapter(controller: feedController, imageLoader: imageLoader), diff --git a/FeedAppiOS/Feed UI/Controllers/FeedRefreshViewController.swift b/FeedAppiOS/Feed UI/Controllers/FeedRefreshViewController.swift index 2ab222e..05f7391 100644 --- a/FeedAppiOS/Feed UI/Controllers/FeedRefreshViewController.swift +++ b/FeedAppiOS/Feed UI/Controllers/FeedRefreshViewController.swift @@ -12,28 +12,19 @@ protocol FeedRefreshViewControllerDelegate { } final class FeedRefreshViewController: NSObject, FeedLoadingView { - private(set) lazy var view = loadView() - private let delegate: FeedRefreshViewControllerDelegate - - init(delegate: FeedRefreshViewControllerDelegate) { - self.delegate = delegate - } - - @objc func refresh() { - delegate.didRequestFeedRefresh() + @IBOutlet private var view: UIRefreshControl? + var delegate: FeedRefreshViewControllerDelegate? + + @IBAction func refresh() { + delegate?.didRequestFeedRefresh() } func display(_ viewModel: FeedLoadingViewModel) { if viewModel.isLoading { - view.beginRefreshing() + view?.beginRefreshing() } else { - view.endRefreshing() + view?.endRefreshing() } } - - private func loadView() -> UIRefreshControl { - let view = UIRefreshControl() - view.addTarget(self, action: #selector(refresh), for: .valueChanged) - return view - } + } diff --git a/FeedAppiOS/Feed UI/Controllers/FeedViewController.swift b/FeedAppiOS/Feed UI/Controllers/FeedViewController.swift index d9a86ab..503d4be 100644 --- a/FeedAppiOS/Feed UI/Controllers/FeedViewController.swift +++ b/FeedAppiOS/Feed UI/Controllers/FeedViewController.swift @@ -8,7 +8,7 @@ import UIKit public final class FeedViewController: UITableViewController, UITableViewDataSourcePrefetching { - var refreshController: FeedRefreshViewController? + @IBOutlet var refreshController: FeedRefreshViewController? var tableModel = [FeedImageCellController]() { didSet { tableView.reloadData() } } @@ -28,7 +28,6 @@ public final class FeedViewController: UITableViewController, UITableViewDataSou super.viewDidLoad() tableView.prefetchDataSource = self - refreshControl = refreshController?.view } public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { diff --git a/FeedAppiOS/Feed UI/Views/Feed.storyboard b/FeedAppiOS/Feed UI/Views/Feed.storyboard index 50c3e19..99d6896 100644 --- a/FeedAppiOS/Feed UI/Views/Feed.storyboard +++ b/FeedAppiOS/Feed UI/Views/Feed.storyboard @@ -1,18 +1,15 @@ - - - - + + - - + - + @@ -23,29 +20,29 @@ - + - - + + - + - + - + - + @@ -69,12 +66,12 @@ Location - + - @@ -98,7 +104,7 @@ Location + + + + + + + + - + @@ -140,6 +154,6 @@ Location - + diff --git a/FeedAppiOS/Feed UI/Views/FeedImageCell.swift b/FeedAppiOS/Feed UI/Views/FeedImageCell.swift index c2603a6..80512c6 100644 --- a/FeedAppiOS/Feed UI/Views/FeedImageCell.swift +++ b/FeedAppiOS/Feed UI/Views/FeedImageCell.swift @@ -8,21 +8,16 @@ 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 - }() + @IBOutlet private(set) public var locationContainer: UIView! + @IBOutlet private(set) public var locationLabel: UILabel! + @IBOutlet private(set) public var feedImageContainer: UIView! + @IBOutlet private(set) public var feedImageView: UIImageView! + @IBOutlet private(set) public var feedImageRetryButton: UIButton! + @IBOutlet private(set) public var descriptionLabel: UILabel! var onRetry: (() -> Void)? - - @objc private func retryButtonTapped() { + + @IBAction private func retryButtonTapped() { onRetry?() } } diff --git a/FeedAppiOSTest/Feed UI-Controllers/Controllers/FeedViewControllerTests.swift b/FeedAppiOSTest/Feed UI-Controllers/Controllers/FeedViewControllerTests.swift index d9a2bd8..e3475ea 100644 --- a/FeedAppiOSTest/Feed UI-Controllers/Controllers/FeedViewControllerTests.swift +++ b/FeedAppiOSTest/Feed UI-Controllers/Controllers/FeedViewControllerTests.swift @@ -239,6 +239,17 @@ final class FeedViewControllerTests: XCTestCase { 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") } + + func test_feedImageView_doesNotRenderLoadedImageWhenNotVisibleAnymore() { + let (sut, loader) = makeSUT() + sut.loadViewIfNeeded() + loader.completeFeedLoading(with: [makeImage()]) + + let view = sut.simulateFeedImageViewNotVisible(at: 0) + loader.completeImageLoading(with: anyImageData()) + + XCTAssertNil(view?.renderedImage, "Expected no rendered image when an image load finishes after the view is not visible anymore") + } // MARK: - Helpers @@ -253,6 +264,10 @@ final class FeedViewControllerTests: XCTestCase { private func makeImage(description: String? = nil, location: String? = nil, url: URL = URL(string: "http://any-url.com")!) -> FeedImage { FeedImage(id: UUID(), description: description, location: location, url: url) } + + private func anyImageData() -> Data { + return UIImage.make(withColor: .red).pngData()! + } } class FakeRefreshControl: UIRefreshControl { diff --git a/FeedAppiOSTest/Feed UI-Controllers/Controllers/Helpers/FeedViewController+TestHelpers.swift b/FeedAppiOSTest/Feed UI-Controllers/Controllers/Helpers/FeedViewController+TestHelpers.swift index 28e9023..1d3eb74 100644 --- a/FeedAppiOSTest/Feed UI-Controllers/Controllers/Helpers/FeedViewController+TestHelpers.swift +++ b/FeedAppiOSTest/Feed UI-Controllers/Controllers/Helpers/FeedViewController+TestHelpers.swift @@ -29,12 +29,15 @@ extension FeedViewController { return feedImageView(at: index) as? FeedImageCell } - func simulateFeedImageViewNotVisible(at row: Int) { + @discardableResult + func simulateFeedImageViewNotVisible(at row: Int) -> FeedImageCell? { let view = simulateFeedImageViewVisible(at: row) let delegate = tableView.delegate let index = IndexPath(row: row, section: feedImagesSection) delegate?.tableView?(tableView, didEndDisplaying: view!, forRowAt: index) + + return view } func simulateAppearance() { From 0e85fc4295a4e4d9a20d3162b3f7f2eea6dc362d Mon Sep 17 00:00:00 2001 From: ispiryanaram Date: Fri, 7 Jun 2024 15:17:24 +0400 Subject: [PATCH 7/9] Add uitableview helper to dequeu cells by classname. --- FeedApp.xcodeproj/project.pbxproj | 4 ++++ .../Controllers/FeedImageCellController.swift | 5 ++--- .../Feed UI/Views/UITableView+Dequeueing.swift | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 FeedAppiOS/Feed UI/Views/UITableView+Dequeueing.swift diff --git a/FeedApp.xcodeproj/project.pbxproj b/FeedApp.xcodeproj/project.pbxproj index 7f60be6..cf1016f 100644 --- a/FeedApp.xcodeproj/project.pbxproj +++ b/FeedApp.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 97192B8B2BD69E9800AE5E69 /* CacheFeedUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97192B8A2BD69E9800AE5E69 /* CacheFeedUseCaseTests.swift */; }; 9728EA9A2C11F22900F28C4A /* Feed.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9728EA992C11F22900F28C4A /* Feed.storyboard */; }; 9728EA9C2C11F29800F28C4A /* Feed.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9728EA9B2C11F29800F28C4A /* Feed.xcassets */; }; + 9728EA9E2C13238200F28C4A /* UITableView+Dequeueing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EA9D2C13238200F28C4A /* UITableView+Dequeueing.swift */; }; 973BA9CE2BC5478C00013B53 /* FeedApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 973BA9C52BC5478900013B53 /* FeedApp.framework */; }; 973BA9D32BC5478C00013B53 /* LoadFeedFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973BA9D22BC5478C00013B53 /* LoadFeedFromRemoteUseCaseTests.swift */; }; 973BA9D42BC5478C00013B53 /* FeedApp.h in Headers */ = {isa = PBXBuildFile; fileRef = 973BA9C82BC5478900013B53 /* FeedApp.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -130,6 +131,7 @@ 97192B8A2BD69E9800AE5E69 /* CacheFeedUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheFeedUseCaseTests.swift; sourceTree = ""; }; 9728EA992C11F22900F28C4A /* Feed.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Feed.storyboard; sourceTree = ""; }; 9728EA9B2C11F29800F28C4A /* Feed.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Feed.xcassets; sourceTree = ""; }; + 9728EA9D2C13238200F28C4A /* UITableView+Dequeueing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+Dequeueing.swift"; sourceTree = ""; }; 973BA9C52BC5478900013B53 /* FeedApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FeedApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 973BA9C82BC5478900013B53 /* FeedApp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedApp.h; sourceTree = ""; }; 973BA9CD2BC5478C00013B53 /* FeedAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeedAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -378,6 +380,7 @@ 974D6F3E2BF366A700F7211C /* FeedImageCell.swift */, 9728EA992C11F22900F28C4A /* Feed.storyboard */, 9728EA9B2C11F29800F28C4A /* Feed.xcassets */, + 9728EA9D2C13238200F28C4A /* UITableView+Dequeueing.swift */, ); path = Views; sourceTree = ""; @@ -818,6 +821,7 @@ 974D6F6B2BF78D2800F7211C /* FeedPresenter.swift in Sources */, 9762D4972BF8A60D00FCCC7E /* FeedViewModel.swift in Sources */, 974D6F472BF4DB9900F7211C /* FeedImageDataLoader.swift in Sources */, + 9728EA9E2C13238200F28C4A /* UITableView+Dequeueing.swift in Sources */, 9762D4952BF8A5D900FCCC7E /* FeedLoadingViewModel.swift in Sources */, 974D6F412BF3A07B00F7211C /* UIView+Shimmering.swift in Sources */, 9762D4932BF8A4B200FCCC7E /* FeedImagePresenter.swift in Sources */, diff --git a/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift b/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift index 6a6d6a0..8ca0a7a 100644 --- a/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift @@ -21,10 +21,9 @@ final class FeedImageCellController: FeedImageView { } func view(in tableView: UITableView) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "FeedImageCell") as! FeedImageCell - self.cell = cell + cell = tableView.dequeueReusableCell() delegate.didRequestImage() - return cell + return cell! } func preload() { diff --git a/FeedAppiOS/Feed UI/Views/UITableView+Dequeueing.swift b/FeedAppiOS/Feed UI/Views/UITableView+Dequeueing.swift new file mode 100644 index 0000000..fd7b0ee --- /dev/null +++ b/FeedAppiOS/Feed UI/Views/UITableView+Dequeueing.swift @@ -0,0 +1,15 @@ +// +// UITableView+Dequeueing.swift +// FeedAppiOS +// +// Created by Aram Ispiryan on 07.06.24. +// + +import UIKit + +extension UITableView { + func dequeueReusableCell () -> T { + let identifier = String(describing: T.self) + return dequeueReusableCell(withIdentifier: identifier) as! T + } +} From 653c5971d865efb9327f5dd9715f21dc2f260a06 Mon Sep 17 00:00:00 2001 From: ispiryanaram Date: Fri, 7 Jun 2024 15:22:03 +0400 Subject: [PATCH 8/9] Display image with animation. --- FeedApp.xcodeproj/project.pbxproj | 4 ++++ .../Controllers/FeedImageCellController.swift | 2 +- .../Feed UI/Views/UIImage+Animation.swift | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 FeedAppiOS/Feed UI/Views/UIImage+Animation.swift diff --git a/FeedApp.xcodeproj/project.pbxproj b/FeedApp.xcodeproj/project.pbxproj index cf1016f..0edd406 100644 --- a/FeedApp.xcodeproj/project.pbxproj +++ b/FeedApp.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 9728EA9A2C11F22900F28C4A /* Feed.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9728EA992C11F22900F28C4A /* Feed.storyboard */; }; 9728EA9C2C11F29800F28C4A /* Feed.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9728EA9B2C11F29800F28C4A /* Feed.xcassets */; }; 9728EA9E2C13238200F28C4A /* UITableView+Dequeueing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EA9D2C13238200F28C4A /* UITableView+Dequeueing.swift */; }; + 9728EAA02C13240E00F28C4A /* UIImage+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EA9F2C13240E00F28C4A /* UIImage+Animation.swift */; }; 973BA9CE2BC5478C00013B53 /* FeedApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 973BA9C52BC5478900013B53 /* FeedApp.framework */; }; 973BA9D32BC5478C00013B53 /* LoadFeedFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973BA9D22BC5478C00013B53 /* LoadFeedFromRemoteUseCaseTests.swift */; }; 973BA9D42BC5478C00013B53 /* FeedApp.h in Headers */ = {isa = PBXBuildFile; fileRef = 973BA9C82BC5478900013B53 /* FeedApp.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -132,6 +133,7 @@ 9728EA992C11F22900F28C4A /* Feed.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Feed.storyboard; sourceTree = ""; }; 9728EA9B2C11F29800F28C4A /* Feed.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Feed.xcassets; sourceTree = ""; }; 9728EA9D2C13238200F28C4A /* UITableView+Dequeueing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+Dequeueing.swift"; sourceTree = ""; }; + 9728EA9F2C13240E00F28C4A /* UIImage+Animation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Animation.swift"; sourceTree = ""; }; 973BA9C52BC5478900013B53 /* FeedApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FeedApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 973BA9C82BC5478900013B53 /* FeedApp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedApp.h; sourceTree = ""; }; 973BA9CD2BC5478C00013B53 /* FeedAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeedAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -381,6 +383,7 @@ 9728EA992C11F22900F28C4A /* Feed.storyboard */, 9728EA9B2C11F29800F28C4A /* Feed.xcassets */, 9728EA9D2C13238200F28C4A /* UITableView+Dequeueing.swift */, + 9728EA9F2C13240E00F28C4A /* UIImage+Animation.swift */, ); path = Views; sourceTree = ""; @@ -823,6 +826,7 @@ 974D6F472BF4DB9900F7211C /* FeedImageDataLoader.swift in Sources */, 9728EA9E2C13238200F28C4A /* UITableView+Dequeueing.swift in Sources */, 9762D4952BF8A5D900FCCC7E /* FeedLoadingViewModel.swift in Sources */, + 9728EAA02C13240E00F28C4A /* UIImage+Animation.swift in Sources */, 974D6F412BF3A07B00F7211C /* UIView+Shimmering.swift in Sources */, 9762D4932BF8A4B200FCCC7E /* FeedImagePresenter.swift in Sources */, 9708B1C82BF28E9800D170EA /* FeedViewController.swift in Sources */, diff --git a/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift b/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift index 8ca0a7a..8a8e9bd 100644 --- a/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift @@ -39,7 +39,7 @@ final class FeedImageCellController: FeedImageView { cell?.locationContainer.isHidden = !viewModel.hasLocation cell?.locationLabel.text = viewModel.location cell?.descriptionLabel.text = viewModel.description - cell?.feedImageView.image = viewModel.image + cell?.feedImageView.setImageAnimated(viewModel.image) cell?.feedImageContainer.isShimmering = viewModel.isLoading cell?.feedImageRetryButton.isHidden = !viewModel.shouldRetry cell?.onRetry = delegate.didRequestImage diff --git a/FeedAppiOS/Feed UI/Views/UIImage+Animation.swift b/FeedAppiOS/Feed UI/Views/UIImage+Animation.swift new file mode 100644 index 0000000..178e4f3 --- /dev/null +++ b/FeedAppiOS/Feed UI/Views/UIImage+Animation.swift @@ -0,0 +1,22 @@ +// +// UIImageView+Animation.swift +// FeedAppiOS +// +// Created by Aram Ispiryan on 07.06.24. +// + +import UIKit + +extension UIImageView { + func setImageAnimated(_ newImage: UIImage?) { + image = newImage + + guard newImage != nil else { return } + + alpha = 0 + + UIView.animate(withDuration: 0.25) { + self.alpha = 1 + } + } +} From 8b87ac9cfedcb2869932be06f49c017af050ea6b Mon Sep 17 00:00:00 2001 From: ispiryanaram Date: Fri, 7 Jun 2024 16:19:12 +0400 Subject: [PATCH 9/9] iOS 15 updates. --- .../Controllers/FeedImageCellController.swift | 8 ++- FeedAppiOS/Feed UI/Views/FeedImageCell.swift | 7 +++ .../Controllers/FeedViewControllerTests.swift | 52 +++++++++++++++---- .../Helpers/FeedImageCell+TestHelpers.swift | 4 +- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift b/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift index 8a8e9bd..c805b73 100644 --- a/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/FeedAppiOS/Feed UI/Controllers/FeedImageCellController.swift @@ -42,10 +42,16 @@ final class FeedImageCellController: FeedImageView { cell?.feedImageView.setImageAnimated(viewModel.image) cell?.feedImageContainer.isShimmering = viewModel.isLoading cell?.feedImageRetryButton.isHidden = !viewModel.shouldRetry - cell?.onRetry = delegate.didRequestImage + cell?.onRetry = { [weak self] in + self?.delegate.didRequestImage() + } + cell?.onReuse = { [weak self] in + self?.releaseCellForReuse() + } } private func releaseCellForReuse() { + cell?.onReuse = nil cell = nil } } diff --git a/FeedAppiOS/Feed UI/Views/FeedImageCell.swift b/FeedAppiOS/Feed UI/Views/FeedImageCell.swift index 80512c6..0f99344 100644 --- a/FeedAppiOS/Feed UI/Views/FeedImageCell.swift +++ b/FeedAppiOS/Feed UI/Views/FeedImageCell.swift @@ -16,8 +16,15 @@ public final class FeedImageCell: UITableViewCell { @IBOutlet private(set) public var descriptionLabel: UILabel! var onRetry: (() -> Void)? + var onReuse: (() -> Void)? @IBAction private func retryButtonTapped() { onRetry?() } + + public override func prepareForReuse() { + super.prepareForReuse() + + onReuse?() + } } diff --git a/FeedAppiOSTest/Feed UI-Controllers/Controllers/FeedViewControllerTests.swift b/FeedAppiOSTest/Feed UI-Controllers/Controllers/FeedViewControllerTests.swift index e3475ea..ded42e3 100644 --- a/FeedAppiOSTest/Feed UI-Controllers/Controllers/FeedViewControllerTests.swift +++ b/FeedAppiOSTest/Feed UI-Controllers/Controllers/FeedViewControllerTests.swift @@ -128,26 +128,24 @@ final class FeedViewControllerTests: XCTestCase { 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 image0 = UIImage.make(height: 1) - let imageData0 = image0.pngData()! + + let imageData0 = UIImage.make(withColor: .red).pngData()! loader.completeImageLoading(with: imageData0, at: 0) - XCTAssertEqual(view0?.renderedImage?.size.height, image0.size.height, "Expected image for first view once first image loading completes successfully") + 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 image1 = UIImage.make(height: 2) - let imageData1 = image1.pngData()! + + let imageData1 = UIImage.make(withColor: .blue).pngData()! loader.completeImageLoading(with: imageData1, at: 1) - XCTAssertEqual(view0?.renderedImage?.size.height, image0.size.height, "Expected no image state change for first view once second image loading completes successfully") - XCTAssertEqual(view1?.renderedImage?.size.height, image1.size.height, "Expected image for second view once second image loading completes successfully") + 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() { @@ -250,6 +248,38 @@ final class FeedViewControllerTests: XCTestCase { XCTAssertNil(view?.renderedImage, "Expected no rendered image when an image load finishes after the view is not visible anymore") } + + func test_feedImageView_doesNotShowDataFromPreviousRequestWhenCellIsReused() throws { + let (sut, loader) = makeSUT() + + sut.loadViewIfNeeded() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let view0 = try XCTUnwrap(sut.simulateFeedImageViewVisible(at: 0)) + view0.prepareForReuse() + + let imageData0 = UIImage.make(withColor: .red).pngData()! + loader.completeImageLoading(with: imageData0, at: 0) + + XCTAssertEqual(view0.renderedImage, .none, "Expected no image state change for reused view once image loading completes successfully") + } + + func test_feedImageView_showsDataForNewViewRequestAfterPreviousViewIsReused() throws { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()]) + + let previousView = try XCTUnwrap(sut.simulateFeedImageViewNotVisible(at: 0)) + + let newView = try XCTUnwrap(sut.simulateFeedImageViewVisible(at: 0)) + previousView.prepareForReuse() + + let imageData = UIImage.make(withColor: .red).pngData()! + loader.completeImageLoading(with: imageData, at: 1) + + XCTAssertEqual(newView.renderedImage, imageData) + } // MARK: - Helpers diff --git a/FeedAppiOSTest/Feed UI-Controllers/Controllers/Helpers/FeedImageCell+TestHelpers.swift b/FeedAppiOSTest/Feed UI-Controllers/Controllers/Helpers/FeedImageCell+TestHelpers.swift index cc17f27..c1e15ca 100644 --- a/FeedAppiOSTest/Feed UI-Controllers/Controllers/Helpers/FeedImageCell+TestHelpers.swift +++ b/FeedAppiOSTest/Feed UI-Controllers/Controllers/Helpers/FeedImageCell+TestHelpers.swift @@ -34,7 +34,7 @@ extension FeedImageCell { return descriptionLabel.text } - var renderedImage: UIImage? { - return feedImageView.image + var renderedImage: Data? { + return feedImageView.image?.pngData() } }