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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 5 of this article. Click here to view the first page.

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:

Error handing label when metadata fetch times out.

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
Note: The default timeout is 30 seconds.

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:

  1. The user tapped the Spin the Wheel version of the button. This started the fetch and set activityIndicator.isAnimating to true.
  2. 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.

Cancelling the metadata fetch error.

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.

Note: 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:

  1. Render linkView and reset the views to normal if metadata exists.
  2. Start the fetch if metadata doesn't exist.
  3. 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!

Showing how fast links load when they're cached.

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.