Kait

Implementing a share extension with SwiftUI

As part of my plan to spend more time bikeshedding building out my web presence than actually creating content, I wanted to build an iOS app that allowed me to share short snippets of text or photos to my blog. I've also always wanted to understand Swift generally and building an iOS app specifically, so it seemed like a nice little rabbit hole.

With the help of Swift UI Apprentice, getting a basic app that posted a content, headline and tags to my API wasn't super difficult (at least, it works in the simulator. I'm not putting it on my phone until it's more useful). I figured adding a share extension would be just as simple, with the real difficulty coming when it came time to posting the image to the server.

Boy was I wrong.

Apple's documentation on Share Extensions (as I think they're called? But honestly it's hard to tell) is laughably bad, almost entirely referring to sharing things out from your app, and even the correct shitty docs haven't been updated in it looks like 4+ years.

There are some useful posts out there, but most/all of them assume you're using UIKit. Since I don't trust Apple not to deprecate a framework they've clearly been dying to phase out for years, I wanted to stick to SwiftUI as much as I could. Plus, I don't reallllly want to learn two paradigms to do the same thing. I have enough different references to keep in my head switching between languages.

Thank god for Oluwadamisi Pikuda, writing on Medium. His post is an excellent place to get a good grasp on the subject, and I highly suggest visiting it if you're stuck. However, since Medium is a semi-paywalled content garden, I'm going to provide a cleanroom implementation here in case you cannot access it.

It's important to note that the extension you're creating is, from a storage and code perspective, a separate app. To the point that technically I think you could just publish a Share Extension, though I doubt Apple would allow it. That means if you want to share storage between your extension and your primary app, you'll need to create an App Group to share containers. If you want to share code, you'll need to create an embedded framework.

But once you have all that set up, you need to actually write the extension. Note that for this example we're only going to be dealing with text shared from another app, with a UI so you can modify it. You'll see where you can make modifications to work with other types.

You start by creating a new target (File -> New -> Target, then in the modal "Share Extension").

A screenshot of the XCode menu selecting "File", then "New," then "Target..."The Xcode new target modal popover, with "Share Extension" selectedOnce you fill out the info, this will create a new directory with a UIKit Storyboard file (MainInterface), ViewController and plist. We're not gonna use hardly any of this. Delete the Storyboard file. Then change your ViewController to use the UIViewController class. This is where we'll define what the user sees when content is shared. The plist is where we define what can be passed to our share extension.

There are only two functions we're concerned about in the ViewController — viewDidLoad() and close(). Close is going to be what closes the extension while viewDidLoad, which inits our code when the view is loaded into memory.

For close(), we just find the extensionContext and complete the request, which removes the view from memory.

viewDidLoad(), however, has to do more work. We call the super class function first, then we need to make sure we have access to the items that are been shared to us.

import SwiftUI

class ShareViewController: UIViewController {

    override func viewDidLoad() {
       super.viewDidLoad()
       
       // Ensure access to extensionItem and itemProvider
       guard
           let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
           let itemProvider = extensionItem.attachments?.first else {
           self.close()
           return
       }
     }

   func close() {
       self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
   }
}

Since again we're only working with text in this case, we need to verify the items are the correct type (in this case, UTType.plaintext).

import UniformTypeIdentifiers
import SwiftUI

class ShareViewController: UIViewController {
    override func viewDidLoad() {
       ...
        
       let textDataType = UTType.plainText.identifier
       if itemProvider.hasItemConformingToTypeIdentifier(textDataType) {
       // Load the item from itemProvider
       itemProvider.loadItem(forTypeIdentifier: textDataType , options: nil) { (providedText, error) in
            if error != nil {
                self.close()
                return
            }
               if let text = providedText as? String {
                 // this is where we load our view
               } else {
                   self.close()
                   return
               }
        }
}

Next, let's define our view! Create a new file, ShareViewExtension.swift. We are just editing text in here, so it's pretty darn simple. We just need to make sure we add a close() function that calls NotificationCenter so we can close our extension from the controller.

import SwiftUI

struct ShareExtensionView: View {
    @State private var text: String
    
    init(text: String) {
        self.text = text
    }
    
    var body: some View {
        NavigationStack{
            VStack(spacing: 20){
                Text("Text")
                TextField("Text", text: $text, axis: .vertical)
                    .lineLimit(3...6)
                    .textFieldStyle(.roundedBorder)
                
                Button {
                    // TODO: Something with the text
                    self.close()
                } label: {
                    Text("Post")
                        .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                
                Spacer()
            }
            .padding()
            .navigationTitle("Share Extension")
            .toolbar {
                Button("Cancel") {
                    self.close()
                }
            }
        }
    }
    
   // so we can close the whole extension
    func close() {
        NotificationCenter.default.post(name: NSNotification.Name("close"), object: nil)
    }
}

Back in our view controller, we import our SwiftUI view.

import UniformTypeIdentifiers
import SwiftUI

class ShareViewController: UIViewController {
    override func viewDidLoad() {
       ...
               if let text = providedText as? String {
                   DispatchQueue.main.async {
                       // host the SwiftUI view
                       let contentView = UIHostingController(rootView: ShareExtensionView(text: text))
                       self.addChild(contentView)
                       self.view.addSubview(contentView.view)
                       
                       // set up constraints
                       contentView.view.translatesAutoresizingMaskIntoConstraints = false
                       contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
                       contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true
                       contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
                       contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true
                   }
               } else {
                   self.close()
                   return
               }
     }
}

In that same function, we'll also add an observer to listen for that close event, and call our close function.

NotificationCenter.default.addObserver(forName: NSNotification.Name("close"), object: nil, queue: nil) { _ in
   DispatchQueue.main.async {
      self.close()
   }
}

The last thing you need to do is register that your extension can handle Text. In your info.plist, you'll want to add an NSExtensionAttributes dictionary with an NSExtensionActivtionSupportsText boolean set to true.

A screenshot of a plist file having accomplished the instructions in the post.You should be able to use this code as a foundation to accept different inputs and do different things. It's a jumping-off point! Hope it helps.

I later expanded the app's remit to include cross-posting to BlueSky and Mastodon, which is a double-bonus because BlueSky STILL doesn't support sharing an image from another application (possibly because they couldn't find the Medium post???)