Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -116,6 +118,8 @@
080EDF0B21B6DAE800813479 /* FeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImage.swift; sourceTree = "<group>"; };
080EDF0D21B6DCB600813479 /* FeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoader.swift; sourceTree = "<group>"; };
231DA90B2D28B3AB00A50156 /* RemoteFeedLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeedLoader.swift; sourceTree = "<group>"; };
234EE2702F1FE49000817225 /* FeedImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageCell.swift; sourceTree = "<group>"; };
234EE2742F21394800817225 /* UIView+Shimmering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Shimmering.swift"; sourceTree = "<group>"; };
2370B4082EEC68FE00737DAC /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
2370B40A2EEC6BE200737DAC /* FeedItemsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapper.swift; sourceTree = "<group>"; };
2377131D2F199AAF00D1122A /* FeedViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewControllerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -312,6 +316,8 @@
23F5D6F02F182E5600AC7D9F /* EssentialFeediOS */ = {
isa = PBXGroup;
children = (
234EE2742F21394800817225 /* UIView+Shimmering.swift */,
234EE2702F1FE49000817225 /* FeedImageCell.swift */,
2377132C2F1EC94D00D1122A /* FeedViewController.swift */,
23F5D6FA2F18345C00AC7D9F /* CI_iOS.xctestplan */,
);
Expand Down Expand Up @@ -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;
};
Expand Down
28 changes: 28 additions & 0 deletions EssentialFeed/EssentialFeediOS/FeedImageCell.swift
Original file line number Diff line number Diff line change
@@ -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?()
}
}
82 changes: 77 additions & 5 deletions EssentialFeed/EssentialFeediOS/FeedViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,36 @@
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<Data, Error>

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() {
super.viewDidLoad()

refreshControl = UIRefreshControl()
refreshControl?.addTarget(self, action: #selector(load), for: .valueChanged)
tableView.prefetchDataSource = self
onViewIsAppearing = { vc in
vc.onViewIsAppearing = nil
vc.load()
Expand All @@ -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
}
}
60 changes: 60 additions & 0 deletions EssentialFeed/EssentialFeediOS/UIView+Shimmering.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
Loading