Snippey: A keyboard extension for your own Snippets. For iPhone.

  • von

Back in 2017, reading about keyboard extensions in iOS, I thought that it would be cool to build a little keyboard extension of my own. The idea was, to have a customizable keyboard with stuff you frequently use in messaging, but typically don‘t, because it‘s to complicated to type on a mobile – the shrug text emoticon ¯\_(ツ)_/¯ being an obvious example. However, back then I did not find the time to finish it.

Despite of knowing, that there are multiple similar apps out there, I recently picked up the project and brought it to a releasable state. Why? Well, several reasons: First of all, I wanted to check it off my personal list. Starting something with the intent to finish and release it and then not being able to finish it for whatever reason can be an annoying feeling. Also, I wanted to get up to speed with Swift in its current state, after not looking at it for about a year. I also used this opportunity to rewrite some parts of it and build this thing’s UI entirely in code (yes, before the announcement of SwiftUI 🤪). Due to the lack of a better name, I called it Snippet.

Here are some thoughts and learnings, from building it.

UI without Storyboards

I decided to remove all storyboards from the project and build both the keyboard extension and the companion apps using Swift only.

For the keyboard extension, this means deleting the storyboard file and creating a view controller implementation for the extension, inheriting from UIInputController. This implementation needs to be referenced in the keyboard extensions Info.plist file in the NSExtension dictionary, e.g.:

<key>NSExtension</key>
<dict>
    ...
    <key>NSExtensionPrincipalClass</key>
    <string>$(PRODUCT_MODULE_NAME).KeyboardViewController</string>
</dict>

For the companion app, remove the UIMainStoryboardFile key and value from the app’s Info.plist file, instantiate your view controller implementation in the app delegate and assign it to the UIWindow.

let viewController = ViewController()
window?.rootViewController = viewController;

I really like not having to jump context between the non-UI related and UI related code-base. This can be annoying, even in small projects in my opinion. However, the code tends to grow rather verbose, when having to alter to the standard behavior of particular controls beyond a certain degree. The following example, shows how I structured my code on instantiating and configuring a `UITextView`. All other code is omitted.

class AddSnippetViewController: UIViewController {
    var textView: UITextView?

    override func viewDidLoad() {
        super.viewDidLoad()
        createSnippetTextView()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        textView!.becomeFirstResponder()
        textView!.selectedTextRange = textView!.textRange(from: textView!.beginningOfDocument, to: textView!.beginningOfDocument)
    }

    override func viewDidLayoutSubviews() {
        textView?.frame = textView!.frame.inset(by: UIEdgeInsets(top: CGFloat.zero, left: CGFloat.zero, bottom: CGFloat.zero, right: Constants.defaultMargin))
    }

    fileprivate func createSnippetTextView() {
        textView = UITextView()
        view.addSubview(textView!)
        textView!.delegate = self
        textView!.keyboardType = .default
        textView!.accessibilityHint = "access-add-textView-label".localized
        textView!.textContainer.maximumNumberOfLines = Constants.snippetMaximumNumberOfLines
        textView!.textContainer.lineBreakMode = .byTruncatingTail
        textView!.translatesAutoresizingMaskIntoConstraints = false
        textView!.topAnchor.constraint(equalTo: view.topAnchor, constant: Constants.margin).isActive = true
        textView!.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Constants.margin).isActive = true
        textView!.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: Constants.margin).isActive = true
        textView!.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: Constants.margin).isActive = true
        textView!.font = UIFont.systemFont(ofSize: UIFont.systemFontSize + 1)
        textView!.text = "add-new-snippet-alert-text-placeholder".localized
        textView!.textColor = Constants.placeholderColor
    }
}

extension AddSnippetViewController: UITextViewDelegate {
    // Tons of code to imitate placeholder behavior on UITextView with a predefined text and handle user input
}

I think this is a perfect example, that everything is easily achievable in a code-based approach, but the verbosity is not negligible. Comparing it to other UI development approaches (SwiftUI, or cross-platform toolkits like Flutter or Xamarin). The task of identifying the right point-in-time in the view controller’s (and hence control’s) lifecycle, when to call which API on a control in order to achieve the desired effect, also was not always obvious to me and ended in trial and error activities.

UIAppearance

Theming and visual adaptability are common nowadays. UIAppearance was introduced back in iOS 5 (see docs), allowing to change the visual presentation of UIView-based controls on type level. This is a good idea, obviously. But only if all controls comply with the approach, by applying these changes similarly. As it turns out, this is not the case for all controls and I ended up having to tweak a bit here and there:

UITableViewCell styling is not easily achievable, even trying to specify a control in its hierarchy using the whenContainedInInstancesOf containerTypes API, which got introduced later, in iOS 9 (see docs). Hence I ended up with a central function in the code I shared across the app and the extension, styling the cells similarly.

class func applyCellStyle(tableViewCell: UITableViewCell, isDark: Bool) {...}

Apart from that, the controls used in the keyboard extension, despite being added to the root UITableView I used, would not adapt correctly. Even not for the properties documented as supporting UIAppearance, such as the background color. This got worse, when implementing the logic, to adapt the keyboard visuals to UIKeyboardAppearance propagated via the UIInputController’s textDocumentProxy property.

Sharing data between keyboard extension and companion app

Being able to enter text snippets in the app and use them in the keyboard extension, means that sharing data across both bundles is obviously required. For the sake of completeness, I want to mention here that this was easily achievable, by specifying an app group in the app’s entitlements.

<key>com.apple.security.application-groups</key>
<array>
    <string>app.group.key</string>
</array>

I chose to store it as a single serialized string in the UserDefaults, from where it can be accessed using the group identifier as the suite name.

let defaults = UserDefaults(suiteName: "app.group.key")

No, the defaults might not be the best choice to store the data, especially as it could grow beyond some point. However, for the sake of simplicity and the expectedly small size of the data to be stored, I chose to go down that path until it turns out to be a problem. Please consider one of the multiple articles on which storage to use for persisting your app’s data before choosing one: UserDefaults or CoreData.

Keyboard height in Keyboard extensions

When building the behavior of your text-entry enabled UI, you need to decide at some point how the screen should behave, whenever the software keyboard of the OS is displayed (or not). The go-to approach on iOS in order to determine the correct height of the keyboard (they vary across device models), when it is appearing and adapt your UI accordingly, is via a dedicated notification propagated by the NSNotificationCenter (see docs).

However, this seems to be not an option for keyboard extensions as the notification is not propagated there. Hence I ended up with a hard-coded keyboard height 🤷🏻‍♂️. While not being happy about it, it works and looks ok-ish on both older and newer iPhones. If anyone has a better approach, I am happy to learn about it.

Conclusion

These were some key points I wanted to persist, after working on the app and being anxious to forget about these details and needing them for another project. While hoping this short post might help someone else like me as well, I can say that I will probably not use UIAppearance in a future project and go for another approach to style the app. Swift-code based UI in a pre-iOS 13 environment is cumbersome at some points, but eventually works for me.

With respect to keyboard extensions and UIInputController: I heard someone say in a podcast recently that

… developers who are still working at keyboard extensions should think about their priorities.

— unremembered podcast, might have been ATP

While thinking, that this might be a bit to harsh, I agree that probably most keyboard use-cases should be covered in the app store by now. 😉

Snippey is available in the app store for free: Snippey on the Apple App Store.

Feedback is always welcome, thanks for making it to the end of the post!

Cheers