Visually Rich Links Tutorial for iOS: Image Thumbnails
Generate visually rich links from the URL of a webpage. In this tutorial, you’ll transform Open Graph metadata into image thumbnail previews for an iOS app. By Lea Marolt Sonnenschein.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Visually Rich Links Tutorial for iOS: Image Thumbnails
35 mins
- Getting Started
- Understanding Rich Links
- Understanding Rich Links: Web Page Metadata
- Understanding Rich Links: Open Graph Protocol
- Building the Preview
- Building the Preview: The URL
- Building the Preview: The Title
- Building the Preview: The Icon
- Building the Preview: The Image
- Building the Preview: The Video
- Retrieving the Metadata
- Presenting Your Links
- Adding an Activity Indicator
- Handling Errors
- Handling Errors: Error Messages
- Handling Errors: Cancel Fetch
- Storing the Metadata
- Storing the Metadata: Cache and Retrieve
- Storing the Metadata: Refactor
- Sharing Links
- Sharing Links: UIActivityItemSource
- Sharing Links: View Update
- Saving Favorites
- Using UIStackView Versus UITableView
- Where to Go From Here?
Handling Errors: Error Messages
Go to LPError+Extension.swift and replace LPError
with this:
extension LPError {
var prettyString: String {
switch self.code {
case .metadataFetchCancelled:
return "Metadata fetch cancelled."
case .metadataFetchFailed:
return "Metadata fetch failed."
case .metadataFetchTimedOut:
return "Metadata fetch timed out."
case .unknown:
return "Metadata fetch unknown."
@unknown default:
return "Metadata fetch unknown."
}
}
}
This extension creates a human-readable error string for the different LPErrors
.
Now go back to SpinViewController.swift and add this at the top of spin(_:)
:
errorLabel.isHidden = true
This clears out the error when the user taps spinButton
.
Next, update the fetch block to show the error like this:
guard
let metadata = metadata,
error == nil
else {
if let error = error as? LPError {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.activityIndicator.stopAnimating()
self.errorLabel.text = error.prettyString
self.errorLabel.isHidden = false
}
}
return
}
In the code above, you check for any errors. If one exists, you update the UI on the main thread to stop the activity indicator and then display the error to the user.
Unfortunately, you can't test this with the current setup. So, add this to spin(_:)
, right after the new provider instance:
provider.timeout = 1
This will trigger an error message when any of the links take longer than one second to load. Build and run to see this:
You set timeout
to 1
to test the error message. Bump it up to 5 now to allow a more reasonable amount of time for these rich previews to load:
provider.timeout = 5
Handling Errors: Cancel Fetch
Your users don't know the fetch will time out at five seconds, and they might not want to wait longer than two. If it's taking that long, they'd rather cancel the fetch. You'll give them that option next.
Inside the implementation of spin(_:)
, add this right under errorLabel.isHidden = true
:
guard !activityIndicator.isAnimating else {
cancel()
return
}
spinButton.setTitle("Cancel", for: .normal)
First, you make sure activityIndicator
isn't spinning. But if it is, you know:
- The user tapped the Spin the Wheel version of the button. This started the fetch and set
activityIndicator.isAnimating
totrue
. - The user also tapped the Cancel version of the button because they decided to bail on the fetch.
If so, you call cancel()
and return
.
Otherwise, if activityIndicator
isn't spinning, you know the user only tapped the Spin the Wheel version of the button. So, before you kick off the fetch, you change the button title to Cancel, in case they want to cancel the fetch later.
At this point, cancel()
doesn't do anything. You'll fix that next. Replace it with this:
private func cancel() {
provider.cancel()
provider = LPMetadataProvider()
resetViews()
}
Here, you first call cancel()
on the provider itself. Then you create a new provider instance and call resetViews
.
But resetViews()
doesn't do anything yet either. Fix that by replacing it with this:
private func resetViews() {
activityIndicator.stopAnimating()
spinButton.setTitle("Spin the Wheel", for: .normal)
}
In the code above, you stop the activity indicator and set the title for spinButton
back to "Spin the Wheel":
Also, to get this same functionality in provider.startFetchingMetadata
, replace the two instances of self.activityIndicator.stopAnimating()
with self.resetViews()
:
self.resetViews()
Now if you encounter an error or the preview loads, you'll stop the activity indicator and reset the title of spinButton
to "Spin the Wheel".
Build and run. Make sure you can cancel the request and that errorLabel
shows the correct issue.
Storing the Metadata
It can get a bit tedious to watch these links load, especially if you get the same result back more than once. To speed up the process, you can cache the metadata. This is a common tactic because web page metadata doesn't change very often.
And guess what? You're in luck. LPLinkMetadata
is serializable by default, which makes caching it a breeze. It also conforms to NSSecureCoding
, which you'll need to keep in mind when archiving. You can learn about NSSecureCoding
in this tutorial.
Storing the Metadata: Cache and Retrieve
Go to MetadataCache.swift and add these methods to the top of MetadataCache
:
static func cache(metadata: LPLinkMetadata) {
// Check if the metadata already exists for this URL
do {
guard retrieve(urlString: metadata.url!.absoluteString) == nil else {
return
}
// Transform the metadata to a Data object and
// set requiringSecureCoding to true
let data = try NSKeyedArchiver.archivedData(
withRootObject: metadata,
requiringSecureCoding: true)
// Save to user defaults
UserDefaults.standard.setValue(data, forKey: metadata.url!.absoluteString)
}
catch let error {
print("Error when caching: \(error.localizedDescription)")
}
}
static func retrieve(urlString: String) -> LPLinkMetadata? {
do {
// Check if data exists for a particular url string
guard
let data = UserDefaults.standard.object(forKey: urlString) as? Data,
// Ensure that it can be transformed to an LPLinkMetadata object
let metadata = try NSKeyedUnarchiver.unarchivedObject(
ofClass: LPLinkMetadata.self,
from: data)
else { return nil }
return metadata
}
catch let error {
print("Error when caching: \(error.localizedDescription)")
return nil
}
}
Here, you're using NSKeyedArchiver
and NSKeyedUnarchiver
to transform LPLinkMetadata
into or from Data
. You use UserDefaults
to store and retrieve it.
UserDefaults
is a database included with iOS that you can use with very minimal setup. Data stored in UserDefaults
persists on hard drive storage even after the user quits your app.Storing the Metadata: Refactor
Hop back to SpinViewController.swift.
spin(_:)
is getting a little long. Refactor it by extracting the metadata fetching into a new method called fetchMetadata(for:)
. Add this code after resetViews()
:
private func fetchMetadata(for url: URL) {
// 1. Check if the metadata exists
if let existingMetadata = MetadataCache.retrieve(urlString: url.absoluteString) {
linkView = LPLinkView(metadata: existingMetadata)
resetViews()
} else {
// 2. If it doesn't start the fetch
provider.startFetchingMetadata(for: url) { [weak self] metadata, error in
guard let self = self else { return }
guard
let metadata = metadata,
error == nil
else {
if let error = error as? LPError {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.errorLabel.text = error.prettyString
self.errorLabel.isHidden = false
self.resetViews()
}
}
return
}
// 3. And cache the new metadata once you have it
MetadataCache.cache(metadata: metadata)
// 4. Use the metadata
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.linkView.metadata = metadata
self.resetViews()
}
}
}
}
In this new method, you not only extract the metadata fetching, you also add the following functionality:
- Render
linkView
and reset the views to normal if metadata exists. - Start the fetch if metadata doesn't exist.
- Cache the results of the fetch.
Next, replace provider.startFetchingMetadata()
with a call to your new method. When you're done, you'll have the single line calling fetchMetadata()
between linkView
and stackView
:
linkView = LPLinkView(url: url)
// Replace the prefetching functionality
fetchMetadata(for: url)
stackView.insertArrangedSubview(linkView, at: 0)
Build and run to observe how fast your links load. Keep tapping Spin the Wheel until you get a link that has been cached. Notice that your links will load immediately if you've seen them before!
What's the point of finding all these great tutorials if you can't share them with your friends though? You'll fix that next.