Today we are going to make our app beautiful. Fetching data from the internet is only the first step toward making a successful app. We need to make sure our app is pleasing to look at - that's what we'll be focussing on today by adding images.
- Complete the workshop 1 tutorial
- Complete the workshop 2 tutorial
-
Remember that our cell is defined as a
Prototype Cell
in ourMain.storyboard
. This is the quickest way to add aUITableViewCell
into a table and we are going to use it to customize our cell. -
In order to customize our cell, let's pull it out of the storyboard into it's own file. First, create a new
Cocoa Touch Class
: -
Name the file
ArticleCell
, make it a Subclass ofUITableViewCell
. Make sure to disable the checkbox toAlso create XIB file
. -
Tap
next
and pick thenCreate
. Make sure thenews-reader
checkbox is checked underTargets
. -
Now we have the new file added to our project -
ArticleCell.swift
-
The next step is to configure our prototype cell in Storyboard to use the newly added
ArticleCell
class. Open the storyboard file and select our prototype cell. -
All we need to do now is set the
Class
under theCustom Clss
section. Tap on theIdentity Inspector
button and typeArticleCell
underClass
. -
Now we can fire up the app and see our custom cell!
-
Let's try alternating the cell backgrounds so we can more easily tell them apart. We do this by setting the
backgroundColor
depending onindexPath.row
:func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { ... cell.detailTextLabel?.text = article.description cell.detailTextLabel?.numberOfLines = 0 // Divide the row's index by 2 and if there's a remainder of 1, we have an odd index if indexPath.row % 2 == 1 { cell.backgroundColor = UIColor(white: 0.95, alpha: 1.0) } return cell }
-
Run the app and scroll up and down. What is going on now?
-
This issue appears because of cell reuse. Recall in
workshop 1
we explained howUITableViewCells
are reused for memory efficiency. In this case, our light gray cell is being reused, so we need to ensure that we reset it each time it's reused. Luckily, there's a method for that inUITableViewCell
:class ArticleCell: UITableViewCell { ... override func prepareForReuse() { backgroundColor = .white } }
-
Now run the app and marvel at our custom
ArticleCell
with alternating background colors. 🎉
-
Let's start by adding an image to each cell. Eventually we'll fetch an appropriate image from
newsapi.org
, but for now a static image will do. -
Download placeholder-image.png to your computer. We use this format as pdfs can be scaled without losing quality. Note that iOS doesn't actually render these images as pdf, Xcode converts them to png first.
-
Now open the
Assets.xcassets
folder in Xcode and drop the image into the large gray area: -
The next part is tricky. In order to add a
UIImageView
to our customArticleCell
, we need to change it's type fromSubtitle
toCustom
. First go to theMain.storyboard
. Select theArticleCell
and tap on theAttributes Inspector
button. Then, change the cell style fromSubtitle
toCustom
. -
But when we do this, we lose our
Title
andSubtitle
labels!!☹️ -
This means we need to recreate our view piece by piece. Let's do this by adding a vertical
UIStackView
filling our entire cell: -
Next we can re-add title and subtitle
UIlabels
to the stack view. TheUIStackView
takes care of all our layout automatically! 😀 -
Note that Xcode notifies us that there is an issue with our layout. Let's make Xcode fix this for us.
-
Xcode changed the
Content hugging priority
of the first Label to a value of250
. Setting this value indicates that the first label may grow larger than its content. Those properties are necessary so Auto Layout can correctly calculate the size of our components based on the content they host. -
We're not quite there yet. We forgot to hook up our new labels as
IBOutlets
. We can also override the methods that returntextLabel
&detailTextLabel
so that we don't need to change the code inViewController
. 🆒class ArticleCell: UITableViewCell { @IBOutlet weak var articleTitle: UILabel! @IBOutlet weak var articleDescription: UILabel! override var textLabel: UILabel? { return articleTitle } override var detailTextLabel: UILabel? { return articleDescription } override func prepareForReuse() { backgroundColor = .white } ... }
-
Let's run the app and see how it looks now!
-
See how white space can really affect the look of an app? We can tweak a few fields on the
UIStackView
insideArticleCell.xib
to give it more white space:- Set
Spacing
property to5
- Set the
Trailing
&Leading
constraint constants to15
- Set the
Bottom
&Top
constraint constants to8
- Set
-
Notice that our label are now getting cut. This only happens in the storyboard, so let's just increase the height of our cell. Select the
ArticleCell
and set itsRow Height
to60
. -
Let's check how that looks:
-
Now let's add a
UIImageView
to the stack view betweentitle
andsubtitle
. Ensure the UIImageView is configured as:- Set
Image
property toplaceholder-image
- Set
Content Mode
toAspect Fill
- Set
-
How is this looking in the simulator?
-
We're showing the image, but it doesn't look beautiful. Let's make it smaller so it fits more evenly with the text. First, change the
Height
of theArticleCell
from60
to180
, so we can have some room to design our cell. Next, Add aHeight
constraint with a constant vaue of100
. -
It's also easy to round the corners of our image, so let's do that for a more polished look. First we need to connect the image to an
IBOutlet
inArticleCell.swift
. -
Next we set the
cornerRadius
&masksToBounds
properties on the view'slayer
:class ArticleCell: UITableViewCell { @IBOutlet weak var articleTitle: UILabel! @IBOutlet weak var articleImage: UIImageView! { didSet { articleImage.layer.cornerRadius = 12.0 articleImage.layer.masksToBounds = true } } @IBOutlet weak var articleDescription: UILabel! ... }
-
Run the app now to see each article with our beautiful placeholder image!!
-
Showing a placeholder image is a good start, but now let's download the image associated with each article from the
newsapi.org
. Like we did inworkshop 2
, we need to add the name of the field exactly as per the api. Here's a snippet from thehttps://newsapi.org/
home page:{ "status": "ok", "totalResults": 1053, "articles": [ { ... "title": "Where does the Apple Watch go next?", "description": "Apple is widely expected to announce the latest iteration of the Apple Watch at its upcoming iPhone event.", "url": "https://www.theverge.com/applewatch.html", "urlToImage": "https://cdn.vox-cdn.com/thumbor/apple-watch.jpg", "publishedAt": "2019-09-08T14:00:00Z", ... } ] ... }
-
Add the field
urlToImage
to ourNewsItem
inNewsFetching.swift
. We make it optional just in casenewsapi.org
returns anull
URL (unfortunately this is possible):struct NewsItem: Decodable { let title: String let description: String let url: URL let urlToImage: URL? }
-
Before we go further, let's modify our code so that we navigate to the article image when we tap on a cell:
extension ViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { ... // Create a new screen for loading Web Content using SFSafariViewController // Temporarily change this to urlToImage to test that URL is being parsed correctly let articleViewController = SFSafariViewController(url: selectedArticle.urlToImage) ... } }
-
Run the app and check that when you tap on a cell, you see the article's image!
-
Now revert the
UITableViewDelegate
to again loadurl
instead ofurlToImage
. And remove the corresponding comment!extension ViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { ... // Create a new screen for loading Web Content using SFSafariViewController let articleViewController = SFSafariViewController(url: selectedArticle.url) ... } }
-
Good, we're successfully fetching the URL of the image associated with each news article. Next we need to download it asynchronously and place it in our
UIImageView
. In order to do this, download ImageDownloading.swift and drag it into your Xcode project. -
ImageDownloading.swift
contains a simple class for downloading an image for a givenURL
. It's quite similar toNewsFetcher
, so feel free to have a look after the workshop. -
Now we need to add a function to
ArticleCell
that will load an image for us:func loadImage(at url: URL) { ImageDownloader().downloadImage(url: url) { result in switch result { case .success(let image): DispatchQueue.main.async { self.articleImage.image = image } case .failure(let error): print(error) } } }
-
Now we have all the pieces, it's simple to update our
UITableViewDataSource
insideViewController.swift
:func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // This always fetches a cell of type UITableViewCell, even if we are using a custom subclass let cell = tableView.dequeueReusableCell(withIdentifier: "ArticleCell", for: indexPath) ... if indexPath.row % 2 == 1 { cell.backgroundColor = UIColor(white: 0.95, alpha: 1.0) } // We need to ensure that we actually have a cell of type ArticleCell guard let articleCell = cell as? ArticleCell else { return cell } if let imageUrl = article.urlToImage { articleCell.loadImage(at: imageUrl) } return articleCell }
-
Note that we have to unwrap our cell as an
ArticleCell
. This is possible becauseArticleCell
subclassesUITableViewCell
. That is, we can treat cell as aUITableViewCell
or anArticleCell
. SincetableView.dequeueReusableCell()
always returns the superclassUITableViewCell
, we need to use aguard unwrap
to get a strongly typedArticleCell
. -
Run the app in the simulator and watch as the images appear! Now quickly scroll up and down, then stop. What is going on? The images are flickering! 😧
-
The issue here, once again, is cell reuse. We're recycling existing cells that already have an image. So rather than seeing the placeholder for a recycled cell, we see the last image that was downloaded. There is an easy fix for this in
ArticleCell.swift
:override func prepareForReuse() { backgroundColor = .white articleImage.image = UIImage(named: "placeholder-image") }
-
Let's try again. Launch the app again and slowly scroll up and down. Now we see the placeholder when an image is being downloaded. What happens if we scroll quickly again? There are still flickering images! 😭
-
Now we're dealing with an even trickier issue. This behaviour is because of our asynchronous downloading. What's actually happening is:
Cell A
is shown atposition 0
with placeholder imageCell A
requests to start downloadingimage 0
- User quickly scrolls down so that
Cell A
is now being reused atposition 15
Cell A
is shown atposition 15
with placeholder imageCell A
start downloadingimage 15
- Download of
image 0
completes, soCell A
is updated withimage 0
- User briefly sees
image 0
alongsidearticle 15
❌ - Download of
image 15
completes, soCell A
is updated withimage 15
- User now sees
image 15
alongsidearticle 15
✅
-
The solution now is to ensure that we cancel any image downloads when we reuse a cell. In order to do this we need to save any image download task, and cancel it during
prepareForReuse()
:class ArticleCell: UITableViewCell { ... var imageDownloadTask: URLSessionDataTask? override func prepareForReuse() { backgroundColor = .white articleImage.image = UIImage(named: "placeholder-image") imageDownloadTask?.cancel() } ... }
-
Start the app and try again! Is it fixed now?
-
Oops. No. We forgot to save the
imageDownloadTask
in ourloadImage
function insideArticleCell
:func loadImage(at url: URL) { imageDownloadTask = ImageDownloader().downloadImage(url: url) { result in switch result { case .success(let image): DispatchQueue.main.async { self.articleImage.image = image } case .failure(let error): print(error) } } }
-
Let's run the app again. Isn't it beautiful?
☺️ It's getting there. Next workshop we'll make it delightful!!