diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 2997119..5b78473 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -69,8 +69,11 @@ EDFBE2692B15754100EFB793 /* FeedRefreshViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE2682B15754100EFB793 /* FeedRefreshViewController.swift */; }; EDFBE26B2B157EBA00EFB793 /* FeedImageCellController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE26A2B157EBA00EFB793 /* FeedImageCellController.swift */; }; EDFBE26E2B15830500EFB793 /* FeedUIComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE26D2B15830500EFB793 /* FeedUIComposer.swift */; }; - EDFBE2712B159E5200EFB793 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE2702B159E5200EFB793 /* FeedViewModel.swift */; }; EDFBE2732B15A5BD00EFB793 /* FeedImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE2722B15A5BD00EFB793 /* FeedImageViewModel.swift */; }; + EDFBE2762B15BA4700EFB793 /* FeedPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE2752B15BA4700EFB793 /* FeedPresenter.swift */; }; + EDFBE2782B15C8EB00EFB793 /* FeedImagePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE2772B15C8EB00EFB793 /* FeedImagePresenter.swift */; }; + EDFBE27A2B15CC5700EFB793 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE2792B15CC5700EFB793 /* FeedViewModel.swift */; }; + EDFBE27C2B15CDC600EFB793 /* FeedLoadingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFBE27B2B15CDC600EFB793 /* FeedLoadingViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -189,8 +192,11 @@ EDFBE2682B15754100EFB793 /* FeedRefreshViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedRefreshViewController.swift; sourceTree = ""; }; EDFBE26A2B157EBA00EFB793 /* FeedImageCellController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageCellController.swift; sourceTree = ""; }; EDFBE26D2B15830500EFB793 /* FeedUIComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedUIComposer.swift; sourceTree = ""; }; - EDFBE2702B159E5200EFB793 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = ""; }; EDFBE2722B15A5BD00EFB793 /* FeedImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageViewModel.swift; sourceTree = ""; }; + EDFBE2752B15BA4700EFB793 /* FeedPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPresenter.swift; sourceTree = ""; }; + EDFBE2772B15C8EB00EFB793 /* FeedImagePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImagePresenter.swift; sourceTree = ""; }; + EDFBE2792B15CC5700EFB793 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = ""; }; + EDFBE27B2B15CDC600EFB793 /* FeedLoadingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoadingViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -326,6 +332,7 @@ ED0EDEBC2B0FB681004857B7 /* EssentialFeediOS */ = { isa = PBXGroup; children = ( + EDFBE2742B15B0FB00EFB793 /* Feed Presentation */, EDFBE24E2B14FD2400EFB793 /* Feed Image Loader */, EDFBE24F2B14FD3E00EFB793 /* Feed UI */, ); @@ -464,7 +471,6 @@ EDFBE24F2B14FD3E00EFB793 /* Feed UI */ = { isa = PBXGroup; children = ( - EDFBE26F2B159E3E00EFB793 /* Models */, EDFBE26C2B1582F400EFB793 /* Composers */, EDFBE2512B14FDB600EFB793 /* Views */, EDFBE2502B14FDA700EFB793 /* Controllers */, @@ -538,13 +544,16 @@ path = Composers; sourceTree = ""; }; - EDFBE26F2B159E3E00EFB793 /* Models */ = { + EDFBE2742B15B0FB00EFB793 /* Feed Presentation */ = { isa = PBXGroup; children = ( - EDFBE2702B159E5200EFB793 /* FeedViewModel.swift */, EDFBE2722B15A5BD00EFB793 /* FeedImageViewModel.swift */, + EDFBE2752B15BA4700EFB793 /* FeedPresenter.swift */, + EDFBE2772B15C8EB00EFB793 /* FeedImagePresenter.swift */, + EDFBE2792B15CC5700EFB793 /* FeedViewModel.swift */, + EDFBE27B2B15CDC600EFB793 /* FeedLoadingViewModel.swift */, ); - path = Models; + path = "Feed Presentation"; sourceTree = ""; }; /* End PBXGroup section */ @@ -840,14 +849,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + EDFBE2762B15BA4700EFB793 /* FeedPresenter.swift in Sources */, EDFBE2672B15741100EFB793 /* FeedImageDataLoader.swift in Sources */, EDFBE2692B15754100EFB793 /* FeedRefreshViewController.swift in Sources */, EDFBE24D2B13186200EFB793 /* UIView+Shimmering.swift in Sources */, + EDFBE2782B15C8EB00EFB793 /* FeedImagePresenter.swift in Sources */, + EDFBE27A2B15CC5700EFB793 /* FeedViewModel.swift in Sources */, EDFBE2492B10534300EFB793 /* FeedViewController.swift in Sources */, + EDFBE27C2B15CDC600EFB793 /* FeedLoadingViewModel.swift in Sources */, EDFBE24B2B107C8900EFB793 /* FeedImageCell.swift in Sources */, EDFBE2732B15A5BD00EFB793 /* FeedImageViewModel.swift in Sources */, EDFBE26B2B157EBA00EFB793 /* FeedImageCellController.swift in Sources */, - EDFBE2712B159E5200EFB793 /* FeedViewModel.swift in Sources */, EDFBE26E2B15830500EFB793 /* FeedUIComposer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImagePresenter.swift b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImagePresenter.swift new file mode 100644 index 0000000..0a4be46 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImagePresenter.swift @@ -0,0 +1,58 @@ +// +// FeedImagePresenter.swift +// EssentialFeediOS +// +// Created by Oluwaseun Adebanwo on 28/11/2023. +// + +import Foundation +import EssentialFeed + +protocol FeedImageView { + associatedtype Image + + func display(_ model: FeedImageViewModel) +} + +final class FeedImagePresenter where View.Image == Image { + private let view: View + private let imageTransformer: (Data) -> Image? + + internal init(view: View, imageTransformer: @escaping (Data) -> Image?) { + self.view = view + self.imageTransformer = imageTransformer + } + + func didStartLoadingImageData(for model: FeedImage) { + view.display(FeedImageViewModel( + description: model.description, + location: model.location, + image: nil, + isLoading: true, + shouldRetry: false)) + } + + private struct InvalidImageDataError: Error {} + + func didFinishLoadingImageData(with data: Data, for model: FeedImage) { + guard let image = imageTransformer(data) else { + return didFinishLoadingImageData(with: InvalidImageDataError(), for: model) + } + + view.display(FeedImageViewModel( + description: model.description, + location: model.location, + image: image, + isLoading: false, + shouldRetry: false)) + } + + func didFinishLoadingImageData(with error: Error, for model: FeedImage) { + view.display(FeedImageViewModel( + description: model.description, + location: model.location, + image: nil, + isLoading: false, + shouldRetry: true)) + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImageViewModel.swift b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImageViewModel.swift new file mode 100644 index 0000000..aae2bef --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImageViewModel.swift @@ -0,0 +1,18 @@ +// +// FeedImageViewModel.swift +// EssentialFeediOS +// +// Created by Oluwaseun Adebanwo on 28/11/2023. +// + +struct FeedImageViewModel { + let description: String? + let location: String? + let image: Image? + let isLoading: Bool + let shouldRetry: Bool + + var hasLocation: Bool { + return location != nil + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedLoadingViewModel.swift b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedLoadingViewModel.swift new file mode 100644 index 0000000..2df866b --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedLoadingViewModel.swift @@ -0,0 +1,10 @@ +// +// FeedLoadingViewModel.swift +// EssentialFeediOS +// +// Created by Oluwaseun Adebanwo on 28/11/2023. +// + +struct FeedLoadingViewModel { + let isLoading: Bool +} diff --git a/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedPresenter.swift new file mode 100644 index 0000000..eb6b62b --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedPresenter.swift @@ -0,0 +1,39 @@ +// +// FeedPresenter.swift +// EssentialFeediOS +// +// Created by Oluwaseun Adebanwo on 28/11/2023. +// + +import EssentialFeed + +protocol FeedLoadingView { + func display(_ viewModel: FeedLoadingViewModel) +} + +protocol FeedView { + func display(_ viewModel: FeedViewModel) +} + +final class FeedPresenter { + private let feedView: FeedView + private let loadingView: FeedLoadingView + + init(feedView: FeedView, loadingView: FeedLoadingView) { + self.feedView = feedView + self.loadingView = loadingView + } + + func didStartLoadingFeed() { + loadingView.display(FeedLoadingViewModel(isLoading: true)) + } + + func didFinishLoadingFeed(with feed: [FeedImage]) { + feedView.display(FeedViewModel(feed: feed)) + loadingView.display(FeedLoadingViewModel(isLoading: false)) + } + + func didFinishLoadingFeed(with error: Error) { + loadingView.display(FeedLoadingViewModel(isLoading: false)) + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedViewModel.swift b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedViewModel.swift new file mode 100644 index 0000000..1fd5d67 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedViewModel.swift @@ -0,0 +1,12 @@ +// +// FeedViewModel.swift +// EssentialFeediOS +// +// Created by Oluwaseun Adebanwo on 28/11/2023. +// + +import EssentialFeed + +struct FeedViewModel { + let feed: [FeedImage] +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Composers/FeedUIComposer.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Composers/FeedUIComposer.swift index cb0850c..416fa57 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Composers/FeedUIComposer.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Composers/FeedUIComposer.swift @@ -12,18 +12,113 @@ public final class FeedUIComposer { private init() {} public static func feedComposedWith(feedLoader: FeedLoader, imageLoader: FeedImageDataLoader) -> FeedViewController { - let feedViewModel = FeedViewModel(feedLoader: feedLoader) - let refreshController = FeedRefreshViewController(viewModel: feedViewModel) + let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader: feedLoader) + let refreshController = FeedRefreshViewController(delegate: presentationAdapter) let feedController = FeedViewController(refreshController: refreshController) - feedViewModel.onFeedLoad = adaptFeedToCellControllers(forwardingTo: feedController, loader: imageLoader) + + presentationAdapter.presenter = FeedPresenter( + feedView: FeedViewAdapter(controller: feedController, imageLoader: imageLoader), + loadingView: WeakRefVirtualProxy(refreshController) + ) + return feedController } +} + +private final class WeakRefVirtualProxy { + private weak var object: T? + + init(_ object: T) { + self.object = object + } +} + +extension WeakRefVirtualProxy: FeedLoadingView where T: FeedLoadingView { + func display(_ viewModel: FeedLoadingViewModel) { + object?.display(viewModel) + } +} + +extension WeakRefVirtualProxy: FeedImageView where T: FeedImageView, T.Image == UIImage { + func display(_ model: FeedImageViewModel) { + object?.display(model) + } +} + +private final class FeedViewAdapter: FeedView { + private weak var controller: FeedViewController? + private let imageLoader: FeedImageDataLoader + + init(controller: FeedViewController, imageLoader: FeedImageDataLoader) { + self.controller = controller + self.imageLoader = imageLoader + } + + func display(_ viewModel: FeedViewModel) { + controller?.tableModel = viewModel.feed.map { model in + let adapter = FeedImageDataLoaderPresentationAdapter, UIImage>(model: model, imageLoader: imageLoader) + let view = FeedImageCellController(delegate: adapter) + + adapter.presenter = FeedImagePresenter( + view: WeakRefVirtualProxy(view), + imageTransformer: UIImage.init) + + return view + } + } +} + +private final class FeedLoaderPresentationAdapter: FeedRefreshViewControllerDelegate { + private let feedLoader: FeedLoader + var presenter: FeedPresenter? + + init(feedLoader: FeedLoader) { + self.feedLoader = feedLoader + } + + func didRequestFeedRefresh() { + presenter?.didStartLoadingFeed() - private static func adaptFeedToCellControllers(forwardingTo controller: FeedViewController, loader: FeedImageDataLoader) -> ([FeedImage]) -> Void { - return { [weak controller] feed in - controller?.tableModel = feed.map { model in - FeedImageCellController(viewModel: FeedImageViewModel(model: model, imageLoader: loader, imageTransformer: UIImage.init)) + feedLoader.load { [weak self] result in + switch result { + case let .success(feed): + self?.presenter?.didFinishLoadingFeed(with: feed) + + case let .failure(error): + self?.presenter?.didFinishLoadingFeed(with: error) } } } } + +private final class FeedImageDataLoaderPresentationAdapter: FeedImageCellControllerDelegate where View.Image == Image { + private let model: FeedImage + private let imageLoader: FeedImageDataLoader + private var task: FeedImageDataLoaderTask? + + var presenter: FeedImagePresenter? + + init(model: FeedImage, imageLoader: FeedImageDataLoader) { + self.model = model + self.imageLoader = imageLoader + } + + func didRequestImage() { + presenter?.didStartLoadingImageData(for: model) + + let model = self.model + task = imageLoader.loadImageData(from: model.url) { [weak self] result in + switch result { + case let .success(data): + self?.presenter?.didFinishLoadingImageData(with: data, for: model) + + case let .failure(error): + self?.presenter?.didFinishLoadingImageData(with: error, for: model) + } + } + } + + func didCancelImageRequest() { + task?.cancel() + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift index 78685a1..92813b3 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift @@ -7,45 +7,39 @@ import UIKit -final class FeedImageCellController { - private let viewModel: FeedImageViewModel +protocol FeedImageCellControllerDelegate { + func didRequestImage() + func didCancelImageRequest() +} + +final class FeedImageCellController: FeedImageView { + private let delegate: FeedImageCellControllerDelegate + private lazy var cell = FeedImageCell() - init(viewModel: FeedImageViewModel) { - self.viewModel = viewModel + init(delegate: FeedImageCellControllerDelegate) { + self.delegate = delegate } func view() -> UITableViewCell { - let cell = binded(FeedImageCell()) - viewModel.loadImageData() + delegate.didRequestImage() return cell } func preload() { - viewModel.loadImageData() + delegate.didRequestImage() } func cancelLoad() { - viewModel.cancelImageDataLoad() + delegate.didCancelImageRequest() } - private func binded(_ cell: FeedImageCell) -> FeedImageCell { + func display(_ viewModel: FeedImageViewModel) { cell.locationContainer.isHidden = !viewModel.hasLocation cell.locationLabel.text = viewModel.location cell.descriptionLabel.text = viewModel.description - cell.onRetry = viewModel.loadImageData - - viewModel.onImageLoad = { [weak cell] image in - cell?.feedImageView.image = image - } - - viewModel.onImageLoadingStateChange = { [weak cell] isLoading in - cell?.feedImageContainer.isShimmering = isLoading - } - - viewModel.onShouldRetryImageLoadStateChange = { [weak cell] shouldRetry in - cell?.feedImageRetryButton.isHidden = !shouldRetry - } - - return cell + cell.feedImageView.image = viewModel.image + cell.feedImageContainer.isShimmering = viewModel.isLoading + cell.feedImageRetryButton.isHidden = !viewModel.shouldRetry + cell.onRetry = delegate.didRequestImage } } diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedRefreshViewController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedRefreshViewController.swift index 300a016..047c15b 100644 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedRefreshViewController.swift +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedRefreshViewController.swift @@ -7,28 +7,33 @@ import UIKit -final class FeedRefreshViewController: NSObject { - private(set) lazy var view = binded(UIRefreshControl()) +protocol FeedRefreshViewControllerDelegate { + func didRequestFeedRefresh() +} + +final class FeedRefreshViewController: NSObject, FeedLoadingView { + private(set) lazy var view = loadView() - private let viewModel: FeedViewModel + private let delegate: FeedRefreshViewControllerDelegate - init(viewModel: FeedViewModel) { - self.viewModel = viewModel + init(delegate: FeedRefreshViewControllerDelegate) { + self.delegate = delegate } @objc func refresh() { - viewModel.loadFeed() + delegate.didRequestFeedRefresh() } - private func binded(_ view: UIRefreshControl) -> UIRefreshControl { - viewModel.onLoadingStateChange = { [weak view] isLoading in - if isLoading { - view?.beginRefreshing() - } else { - view?.endRefreshing() - } + func display(_ viewModel: FeedLoadingViewModel) { + if viewModel.isLoading { + view.beginRefreshing() + } else { + view.endRefreshing() } - + } + + private func loadView() -> UIRefreshControl { + let view = UIRefreshControl() view.addTarget(self, action: #selector(refresh), for: .valueChanged) return view } diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Models/FeedImageViewModel.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Models/FeedImageViewModel.swift deleted file mode 100644 index b3b505a..0000000 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Models/FeedImageViewModel.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// FeedImageViewModel.swift -// EssentialFeediOS -// -// Created by Oluwaseun Adebanwo on 28/11/2023. -// - -import Foundation -import EssentialFeed - -final class FeedImageViewModel { - typealias Observer = (T) -> Void - - private var task: FeedImageDataLoaderTask? - private let model: FeedImage - private let imageLoader: FeedImageDataLoader - private let imageTransformer: (Data) -> Image? - - init(model: FeedImage, imageLoader: FeedImageDataLoader, imageTransformer: @escaping (Data) -> Image?) { - self.model = model - self.imageLoader = imageLoader - self.imageTransformer = imageTransformer - } - - var description: String? { - return model.description - } - - var location: String? { - return model.location - } - - var hasLocation: Bool { - return location != nil - } - - var onImageLoad: Observer? - var onImageLoadingStateChange: Observer? - var onShouldRetryImageLoadStateChange: Observer? - - func loadImageData() { - onImageLoadingStateChange?(true) - onShouldRetryImageLoadStateChange?(false) - task = imageLoader.loadImageData(from: model.url) { [weak self] result in - self?.handle(result) - } - } - - private func handle(_ result: FeedImageDataLoader.Result) { - if let image = (try? result.get()).flatMap(imageTransformer) { - onImageLoad?(image) - } else { - onShouldRetryImageLoadStateChange?(true) - } - onImageLoadingStateChange?(false) - } - - func cancelImageDataLoad() { - task?.cancel() - task = nil - } -} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Models/FeedViewModel.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Models/FeedViewModel.swift deleted file mode 100644 index 45acdae..0000000 --- a/EssentialFeed/EssentialFeediOS/Feed UI/Models/FeedViewModel.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// FeedViewModel.swift -// EssentialFeediOS -// -// Created by Oluwaseun Adebanwo on 28/11/2023. -// - -import Foundation -import EssentialFeed - -final class FeedViewModel { - typealias Observer = (T) -> Void - - private let feedLoader: FeedLoader - - init(feedLoader: FeedLoader) { - self.feedLoader = feedLoader - } - - var onLoadingStateChange: Observer? - var onFeedLoad: Observer<[FeedImage]>? - - func loadFeed() { - onLoadingStateChange?(true) - feedLoader.load { [weak self] result in - if let feed = try? result.get() { - self?.onFeedLoad?(feed) - } - self?.onLoadingStateChange?(false) - } - } -}