An Interactive UITextView with RxSwift

Recently I once again had need for a text view in which any arbitrary word could be marked as a button. This certainly isn't the first time I've needed such a view, but it is the first time I've wanted one with `RxSwift` support. I did a cursory search but didn't find anything simple enough which did what I wanted, so I built one. I've made it a gist but for ease I'll also paste it below. Hopefully you'll find it helpful.

//
//  InteractiveTextView.swift
//  Views
//
//  Created by James Valaitis on 05/03/2018.
//  Copyright © 2018 VIPR Digital. All rights reserved.
//

import RxSwift
import UIKit

//  MARK: Interactive Text View
/**
A text view with support for tapping on certain words.
 */
open class InteractiveTextView: UITextView {
    //  MARK: Properties
    private let interactiveAttribute = NSAttributedStringKey("com.fixr.interactiveAttribute")
    private typealias Callback = () -> ()
    private var callbacks = [String: Callback]()
    //  MARK: Initialization
    public override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        setup()
    }
    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }
}
//  MARK: Configuration
private extension InteractiveTextView {
    func setup() {
        isUserInteractionEnabled = true
        let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
        addGestureRecognizer(tapRecognizer)
    }
}
//  MARK: Interactions
public extension InteractiveTextView {
    /**
    Observes any tap made on the first instance of the provided text found within the `attributedText`.
    - Parameter text:    The portion of text to be monitored.
    - Returns:    An observable for any tap made on the text. An error will be returned if the text could not be found within `attributedText`.
     */
    func tap(onString text: String) -> Observable<Void> {
        guard let range = attributedText.string.range(of: text) else { return .error(InteractiveTextViewError.wordNotFound) }
        let updatedText = NSMutableAttributedString(attributedString: attributedText)
        updatedText.addAttributes([interactiveAttribute: text], range: NSRange(range, in: attributedText.string))
        attributedText = updatedText
        return .create { [weak self] observer in
            guard let `self` = self else { return Disposables.create() }
            self.callbacks[text] = { observer.onNext(()) }
            return Disposables.create { self.callbacks[text] = nil }
        }
    }
}
private extension InteractiveTextView {
    @objc func handleTap(_ recognizer: UITapGestureRecognizer) {
        //    location of tap in myTextView coordinates and taking the inset into account
        var location = recognizer.location(in: self)
        location.x -= textContainerInset.left
        location.y -= textContainerInset.top

        //    character index at tap location
        let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

        //    if index is valid then do something
        guard characterIndex < textStorage.length else { return }

        // check if the tap location has a certain attribute
        guard let observedText = attributedText.attribute(interactiveAttribute, at: characterIndex, effectiveRange: nil) as? String,
            let callback = callbacks[observedText] else { return }

        callback()
    }
}
//  MARK: Interactive Text View Error
public enum InteractiveTextViewError: Error {
    ///    The provided word could not be found within the text.
    case wordNotFound
}