Day 68 - 100 Days of Swift

4 minute read

Project 19 (part 2)

Day 68 is the second part of the nineteenth project. You get the communication between the app and the extension set up. You add a UITextView so the user can write their code. And you listen for the keyboard changing notifications so that you can update the textView to make sure its contents stay visible.

To verify communication between the app and the extension you add this line to the run function in Action.js to pass the web page’s URL and Title to the extension:

1
parameters.completionFunction({ "URL": document.URL, "title": document.title })

Then you unwrap it and print it out like this, just to verify that everything is hooked up correctly:

1
2
3
4
5
6
7
8
9
guard let itemDictionary = dict as? NSDictionary else { return }
guard let javaScriptValues = itemDictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary else { return }
print(javaScriptValues)

// Prints something like:
// {
//    URL = "https://www.google.com";
//    title = "Google";
// }

Then you add a UITextField to the storyboard and turn off all the auto-correction and capitalization and what not off, because the user will be typing code here and they probably want the text to be exactly what they write. You also embed the view controller in a UINavigationController. Then you add an outlet for the text view and a couple of variables to hold the information passed from the page:

1
2
3
4
var pageTitle = ""
var pageURL = ""

@IBOutlet weak var scriptView: UITextView!

And replace the print statement with an update the user can actually see:

1
2
3
4
5
6
self?.pageTitle = javaScriptValues["title"] as? String ?? ""
self?.pageURL = javaScriptValues["URL"] as? String ?? ""

DispatchQueue.main.async {
    self?.title = self?.pageTitle
}

Next, you add a UIBarButtonItem and hook it up to the existing done function:

1
2
3
4
navigationItem.rightBarButtonItem =
    UIBarButtonItem(barButtonSystemItem: .done,
                    target: self,
                    action: #selector(done))

Currently it will just dismiss the extension, so you update it to actually pass the code the user types in back to the so it can be run. This requires a series of wrapping things in other things because this API is a little convoluted:

1
2
3
4
5
6
7
8
9
10
// in the done method
let item = NSExtensionItem()
let argument: NSDictionary = ["customJavaScript": scriptView.text ?? ""]
let key = NSExtensionJavaScriptFinalizeArgumentKey
let webDictionary: NSDictionary = [key: argument]
let customJavaScript = NSItemProvider(item: webDictionary,
                                      typeIdentifier: kUTTypePropertyList as String)
item.attachments = [customJavaScript]

extensionContext?.completeRequest(returningItems: [item])

Finally, you just have to update the finalize function in Action.js so that is actually gets called:

1
2
var customJavaScript = parameters["customJavaScript"];
eval(customJavaScript);

At this point you can run the extension, type in whatever JavaScript you want and it will get run when you dismiss the extension.

Screenshots of working app.

The last thing you do is subscribe to notifications for when the keyboard is shown or hidden, so that you can update the size that the text view displays text in, so there isn’t any text hidden behind the keyboard. You add observers in viewDidLoad:

1
2
3
4
5
6
7
8
9
notificationCenter.addObserver(self,
                               selector: #selector(adjustForKeyboard),
                               name: UIResponder.keyboardWillHideNotification,
                               object: nil)

notificationCenter.addObserver(self,
                               selector: #selector(adjustForKeyboard),
                               name: UIResponder.keyboardWillChangeFrameNotification,
                               object: nil)

And you write the adjustForKeyboard method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let key = UIResponder.keyboardFrameEndUserInfoKey
guard let keyboardValue = notification.userInfo?[key] as? NSValue else {
            return
}

let keyboardScreenEndFrame = keyboardValue.cgRectValue
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame,
                                        from: view.window)

if notification.name == UIResponder.keyboardWillHideNotification {
    scriptView.contentInset = .zero
} else {
    let bottom = keyboardViewEndFrame.height - view.safeAreaInsets.bottom
    scriptView.contentInset = UIEdgeInsets(top: 0,
                                           left: 0,
                                           bottom: bottom,
                                           right: 0)
}

scriptView.scrollIndicatorInsets = scriptView.contentInset

let selectedRange = scriptView.selectedRange
scriptView.scrollRangeToVisible(selectedRange)

With that, everything works, and even if you write a long bit of Javascript, it will stay visible on the screen.

You can find my version of this project at the end of day 68 on Github here.