The ins and outs of the Link Presentation framework

The Link Presentation framework enables you to present content-rich URLs in a consistent way. Retrieve metadata from a URL, present the rich link content inside your app, and provide link metadata to the share sheet experience in iOS.

When building my newsreader app for Hacker News called Hackr, I wanted to provide a rich link preview experience. I wanted to show the title, description, and image of the article, and I wanted to do it in a consistent way across the app. Using the Link Presentation framework, I was able to do just that:

The user interface of my app Hackr showing Top Stories on Hacker News with link previews

Fetching metadata using a provider

Before using LPMetadataProvider, I wrote my own code to fetch metadata from a URL. I read a page line by line and looked for <meta> tags with the property attribute set to og:title, og:description, and og:image. I had a maximum number of lines to read so that I wouldn't read the entire page. Sometimes the metadata was outside of that boundary due to formatting or other reasons, so it wasn’t always reliable.

Then I discovered LPMetadataProvider. It’s a quick and easy way to fetch metadata from a URL. Here’s an example of how I’m using it in Hackr:

func metadata(for url: URL) async -> LPLinkMetadata? {
    let provider = LPMetadataProvider()
    var metadata: LPLinkMetadata?

    do {
        metadata = try await provider.startFetchingMetadata(for: url)
    } catch {
        logger.error("Failed to fetch metadata for URL \(url): \(error)")

        return nil
    }

    guard let metadata else {
        return nil
    }

    return metadata
}

The provider LPMetadataProvider is what Apple refers to as a one-shot object. It can only be used once to fetch metadata for a URL so you need to create a new instance for each URL.

Presenting rich link content the Apple way

The LPLinkView is the go-to component for presenting rich link content. It’s the same familiar view that you see in iMessage when sharing links.

An example of the `LPLinkView` view component showing a preview of a URL pointing to an Apple Maps link of Apple Park.

It's a subclass of UIView that can be used to display a link preview. In SwiftUI, you can wrap it in a UIViewRepresentable to use it in your SwiftUI views. For my use case however, it lacked the customization I needed, so I ended up using the LPLinkMetadata class directly with my custom views instead.

The metadata object

The public properties of this class are currently limited to some basic properties that are useful for most apps, e.g., title and imageProvider. But what if you want to add more metadata to your link previews without having to fetch the data yourself in addition to using LPMetadataProvider? Since LPLinkMetadata inherits from NSObject, you can use key-value coding to gain access to its internal properties that are already there:

import Foundation
import LinkPresentation

extension LPLinkMetadata {
    var title: String? {
        self.value(forKey: "_title") as? String
    }

    var summary: String? {
        self.value(forKey: "_summary") as? String
    }

    var siteName: String? {
        self.value(forKey: "_siteName") as? String
    }

    var creator: String? {
        self.value(forKey: "_creator") as? String
    }

    var creatorFacebookProfile: String? {
        self.value(forKey: "_creatorFacebookProfile") as? String
    }

    var creatorTwitterUsername: String? {
        self.value(forKey: "_creatorTwitterUsername") as? String
    }

    var itemType: String? {
        self.value(forKey: "_itemType") as? String
    }

    var imageURL: URL? {
        (self.value(forKey: "images") as? [NSObject])?[0].value(forKey: "_URL") as? URL
    }
}

These are the properties I’ve found are most useful for my use case, but you can easily add more if you need them. Have a look at the bottom of the article of a dump of all the properties that are available.

Accelerating the Share Sheet preview

The share sheet preview is a great way to show your users what they’re about to share. But it can be slow to load, especially if you’re fetching the metadata from a remote server. To speed up the preview, you can use your already fetched metadata to create a new item source and provide it to the share sheet:

import Foundation
import LinkPresentation
import SwiftUI

class MyItemSource: NSObject, UIActivityItemSource {
    let metadata: LPLinkMetadata

    init(metadata: LPLinkMetadata) {
        self.metadata = metadata

        super.init()
    }

    func activityViewControllerPlaceholderItem(
        _ activityViewController: UIActivityViewController
    ) -> Any {
        return metadata.url?.host ?? ""
    }

    func activityViewController(
        _ activityViewController: UIActivityViewController,
        itemForActivityType activityType: UIActivity.ActivityType?
    ) -> Any? {
        return metadata.url
    }

    func activityViewController(
        _ activityViewController: UIActivityViewController,
        subjectForActivityType activityType: UIActivity.ActivityType?
    ) -> String {
        return metadata.title ?? ""
    }

    func activityViewControllerLinkMetadata(
        _ activityViewController: UIActivityViewController
    ) -> LPLinkMetadata? {
        return metadata
    }
}

Then you can initialize your UIActivityViewController with the MyItemSource:

let activityViewController = UIActivityViewController(
    activityItems: [
        MyItemSource(metadata: metadata)
    ]
)

What you’ll get is a Share Sheet that is fast to load and shows a rich preview of the link:

The share sheet of my app Hackr showing a link preview of the article I'm sharing.

Loading images

If you’ve never used NSItemProvider before, you might not be familiar with loading an image item from it. Both the icon and image provider conform to the NSItemProvider class, which means that you can use the loadObject(ofClass:completionHandler:) method to load the image:

DispatchQueue.global().async {
    imageProvider.loadObject(ofClass: UIImage.self) { image, _ in
        guard let uiImage = image as? UIImage else {
            return
        }

        DispatchQueue.main.async {
            // From here you can update the UI with the casted image.
        }
    }
}

Internal metadata object properties

Here’s a dump of all the internal properties of LPLinkMetadata that you can access using key-value coding:

(LPLinkMetadata?) $R0 = 0x00000001130131c0 {
  baseNSObject@0 = {
    isa = LPLinkMetadata
  }
  _asynchronousLoadGroup = 0x0000000000000000
  _asynchronousLoadDeferralTokenCount = 0
  _wasCopiedFromIncompleteMetadata = false
  _pendingAsynchronousLoadUpdateHandlers = 0x0000000000000000
  _version = 1
  _originalURL = "https://news.cornell.edu/stories/2023/01/fewer-40-new-yorkers-earn-living-wage" {
    baseNSObject@0 = {
      isa = NSURL
    }
    _urlString = 0x0000001d00001d80
    _baseURL = 0x080001004001c029
    _clients = 0x0000600003e9c9c0 {
      baseNSMutableString@0 = {
        baseNSString@0 = {
          baseNSObject@0 = {
            isa = __NSCFString{...}
          }
        }
      }
    }
    _reserved = <uninitialized>
  }
  _URL = "https://news.cornell.edu/stories/2023/01/fewer-40-new-yorkers-earn-living-wage" {
    baseNSObject@0 = {
      isa = NSURL
    }
    _urlString = 0x0000000100001d80
    _baseURL = 0x080001004001c029
    _clients = 0x0000600003ed5c80 {
      baseNSMutableString@0 = {
        baseNSString@0 = {
          baseNSObject@0 = {
            isa = __NSCFString{...}
          }
        }
      }
    }
    _reserved = <uninitialized>
  }
  _title = 0x0000600003ed4180 "Fewer than 40% of New Yorkers earn a living wage | Cornell Chronicle"
  _summary = 0x0000600002fd1c80 "The Cornell ILR Wage Atlas shows who in New York state earns living wages and where, helping policymakers and other stakeholders to understand patterns of inequality."
  _selectedText = 0x0000000000000000
  _siteName = 0x00006000014a68b0 "Cornell Chronicle"
  _itemType = "article"
  _relatedURL = 0x0000000000000000
  _creator = 0x0000000000000000
  _creatorFacebookProfile = 0x0000000000000000
  _creatorTwitterUsername = 0x0000000000000000
  _appleContentID = 0x0000000000000000
  _appleSummary = 0x0000000000000000
  _arAsset = 0x0000000000000000
  _arAssetMetadata = 0x0000000000000000
  _icon = 0x00006000028ee7f0 {
    baseNSObject@0 = {
      isa = LPImage
    }
    _originalPlatformImage = 0x0000000000000000
    _decodedPlatformImage = 0x0000000000000000
    _data = 1350 bytes {
      some = 0x000060000150e7f0 1350 bytes
    }
    _MIMEType = 0x0000600001bb4800 "image/x-icon"
    _properties = some {
      some = 0x0000600001bb4440 {
        baseNSObject@0 = {
          isa = LPImageProperties
        }
        _accessibilityText = 0x0000000000000000
        _type = 0
        _overlaidTextColor = 0x0000000000000000
      }
    }
    _itemProvider = 0x0000000000000000
    _imageLoadedFromItemProvider = 0x0000000000000000
    _itemProviderLoadGroup = 0x0000000000000000
    _asynchronousLoadGroup = 0x0000000000000000
    _isAnimated = false
    _hasComputedPixelSize = true
    _hasTransparency = false
    _hasComputedHasTransparency = false
    _hasComputedIsAnimated = false
    _fallbackIcon = false
    _precomposedAppIcon = false
    _useLossyCompressionForEncodedData = false
    _remoteURLsForEmailCompatibleOutput = 0x0000000000000000
    _darkInterfaceAlternativeImage = 0x0000000000000000
    _fileURL = 0x0000000000000000
    _platformImage = some {
      some = 0x00006000028eef40 {
        baseNSObject@0 = {
          isa = UIImage
        }
        _siblingImages = 0x0000000000000000
        _configuration = some {
          some = 0x0000600001bb47e0 {
            baseNSObject@0 ={...}
            _ignoresDynamicType = false
            _traitCollection = some{...}
          }
        }
        _baselineOffsetFromBottom = 0
        _capHeight = 0
        _imageAsset = 0x0000000000000000
        _content = some {
          some = 0x000060000150d8c0 {
            base_UIImageContent@0 ={...}
          }
        }
      }
    }
    __alternateHTMLImageGenerator = 0x0000000000000000
  }
  _iconMetadata = 0x0000600001b92fc0 {
    baseNSObject@0 = {
      isa = LPIconMetadata
    }
    _version = 1
    _URL = "https://news.cornell.edu/favicon.ico" {
      baseNSObject@0 = {
        isa = NSURL
      }
      _urlString = 0x0000000100001d80
      _baseURL = 0x080001004001c029
      _clients = 0x0000600000f83340 {
        baseNSMutableString@0 = {
          baseNSString@0 = {
            baseNSObject@0 ={...}
          }
        }
      }
      _reserved = <uninitialized>
    }
    _accessibilityText = 0x0000000000000000
  }
  _image = 0x00006000028cb450 {
    baseNSObject@0 = {
      isa = LPImage
    }
    _originalPlatformImage = 0x0000000000000000
    _decodedPlatformImage = 0x0000000000000000
    _data = 31716 bytes {
      some = 0x000060000150d920 31716 bytes
    }
    _MIMEType = 0x0000600001bb4700 "image/jpeg"
    _properties = some {
      some = 0x0000600001ba50c0 {
        baseNSObject@0 = {
          isa = LPImageProperties
        }
        _accessibilityText = 0x0000000000000000
        _type = 0
        _overlaidTextColor = 0x0000000000000000
      }
    }
    _itemProvider = 0x0000000000000000
    _imageLoadedFromItemProvider = 0x0000000000000000
    _itemProviderLoadGroup = 0x0000000000000000
    _asynchronousLoadGroup = 0x0000000000000000
    _isAnimated = false
    _hasComputedPixelSize = true
    _hasTransparency = false
    _hasComputedHasTransparency = false
    _hasComputedIsAnimated = true
    _fallbackIcon = false
    _precomposedAppIcon = false
    _useLossyCompressionForEncodedData = false
    _remoteURLsForEmailCompatibleOutput = 0x0000000000000000
    _darkInterfaceAlternativeImage = 0x0000000000000000
    _fileURL = 0x0000000000000000
    _platformImage = some {
      some = 0x00006000028caa30 {
        baseNSObject@0 = {
          isa = UIImage
        }
        _siblingImages = 0x0000000000000000
        _configuration = some {
          some = 0x0000600001ba50e0 {
            baseNSObject@0 ={...}
            _ignoresDynamicType = false
            _traitCollection = some{...}
          }
        }
        _baselineOffsetFromBottom = 0
        _capHeight = 0
        _imageAsset = 0x0000000000000000
        _content = some {
          some = 0x0000600001502fa0 {
            base_UIImageContent@0 ={...}
          }
        }
      }
    }
    __alternateHTMLImageGenerator = 0x0000000000000000
  }
  _alternateImages = 0x0000000000000000
  _imageMetadata = 0x0000600000f83a00 {
    baseNSObject@0 = {
      isa = LPImageMetadata
    }
    _version = 1
    _URL = "https://news.cornell.edu/sites/default/files/styles/story_thumbnail_lg/public/0109_wages_tnd_0.jpg?itok=VjmNhBT3" {
      baseNSObject@0 = {
        isa = NSURL
      }
      _urlString = 0x0000000100001d80
      _baseURL = 0x080001004001c0a9
      _clients = 0x00006000028d0d80 {
        baseNSMutableString@0 = {
          baseNSString@0 = {
            baseNSObject@0 ={...}
          }
        }
      }
      _reserved = <uninitialized>
    }
    _type = 0x0000000000000000
    _accessibilityText = 0x0000000000000000
  }
  _video = 0x0000000000000000
  _videoMetadata = 0x0000000000000000
  _audio = 0x0000000000000000
  _audioMetadata = 0x0000000000000000
  _arAssets = 0x00000001bac02fa8 0 elements
  _icons = 0x00006000018ed610 1 element
  _images = 0x00006000018ed670 1 element
  _videos = 0x00006000014ef7b0 0 elements
  _streamingVideos = 0x00006000014ef6c0 0 elements
  _audios = 0x00006000014efc60 0 elements
  _associatedApplication = 0x0000000000000000
  _originatingSynapseContentItem = 0x0000000000000000
  _conversationActivity = 0x0000000000000000
  _collaborationType = 0
  _sourceApplication = 0x0000000000000000
  _specialization = 0x0000000000000000
}