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:
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.
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:
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
}