[Swift][TextKit] 世界のナベアツエディタを作ってみる

Foundation

TextEdit2 のカスタマイズを説明していきます。例題として(実は意外と頭の良い)ナベアツエディタを作っていきます。

TextKit2 の主要クラスは、以下の記事で説明しています。
Foundation[Swift] TextKit2 の理解(主要クラスその1)

世界のナベアツエディタ

以前に、 3の倍数と3の付く数字の時だけアホになるというのを芸人さんがやっていましたが、
3の倍数と3の付く数字だけアホになるエディタを作ってみます。

実際には、3の倍数と3の付く数字だけ、変わったフォントで表示されるエディタを作ります。

詳細(?)仕様

入力をチェックして、3の付く数字 もしくは、3の倍数の数字は、変わったフォントにしてサイズも大きくして表示します。

数字の判断としては スペースもしくは数字以外の文字を セパレータとして扱い、数字を判断します。

例えばスペースの入っていない 12 は、1 と 2 ではなく、12 として扱い、スペースが間にある 1␣2 は、1 と 2 になります。

12 であれば 3 の倍数なので、変わったフォントで表示されますが、1 と 2 であれば、それぞれ 3の倍数でなく3も含まれないので、通常のフォントで表示されます。

ざっくりとした設計

TextKit 2のカスタマイズポイント

TextKit2 では、 処理の流れが、以下の3つのクラスでコントロールされています。

  • NSTextContentManager
  • NSTextLayoutManager
  • NSTextViewportLayoutController

NSTextContentManager

NSTextContentManager は、入力されたテキストを、NSTextElement に分割します。

NSTextLayoutManager

NSTextLayoutManager は、NSTextContentManager の持つ NSTextElement を NSTextLayoutFragment に変換していきます。

NSTextViewportLayoutController

NSTextViewportLayoutController は、NSTextLayoutFragment を使って、レイアウトしてくれるようです。 使ったことないので、あまり多くは語れません。

NSTextViewportLayoutController を使うことで、データが大きなケースでも表示を最適化するために工夫できるようになっていると思いますが、今回はケアしません。

今回のカスタマイズの方針

おおよそ以下のような方針で、カスタマイズしてみます。

  • NSTextStorage 内部の情報は変更しない
  • 適切に設定した NSTextParagraph を使うことで、表示を変更する
  • NSTextLayoutFragment をカスタマイズしない

NSTextContentManagerDelegate

今回は、NSTextContentManagerDelegate を使ってカスタマイズします。具体的には以下のメソッドです。


optional func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph?

この delegate は、textStorage の range で指定される部分について、どのような NSTextParagraph を作るかをカスタマイズするための delegate メソッドです。

# 文字列はすでに Paragraph に該当する部分に分割されていますので、分割具合を変更することはできません。

なお この delegate は変更された部分についてのみ呼び出されます。

この delegate を設定しない、もしくは、このメソッドで nil を返すと、NSTextContentManager 側で NSTextParagraph を作成してくれます。
ですので、独自の NSTextParagraph を作りたい時には、この メソッドで作成して返し、通常の NSTextParagraph で良いのであれば、nil を返すことになります。(自分で作ってしまっても問題ありません)

なお、NSTextParagraph は、任意の NSAttributedString から作成できますが、長さは、range.length と一致している必要があります。

実装してみる

文字列から数値を見つける

いわゆる 正規表現を使って、文字列中の数値を見つけます。

decimal separator 等々、数字を意味する文字列は多くのバリエーションが考えられますが、シンプルに [0-9] が連続するものを数値とします。

小数点がついているものは、小数とみなさずに、2つの整数が 小数点という文字列を挟んで存在しているという判定になります。

文字列から数値を見つける正規表現は、(?[0-9]+) です。後で参照しやすいように、マッチしたものに Number というラベルをつけています。

文字列中の数値の位置もあとで使うことになるので、数値表現と考えられる文字列、数値、全体文字列中での位置 を struct にして保持するようにしました。


struct FoundNumbers {
    let numString: String
    let numValue: Int
    let range: NSRange
}

Swift では、部分文字列を扱う時には、NSRange でなく、 Range の方が扱いやすいのですが、TextKit2 では、NSRange も登場するので、NSRange を持つようにしました。

対象文字列があれば、NSRange と Range は相互変換可能なので、どちらで保持しても同じです。
Swift[Swift] NSRange と Range の使い方

先ほどの正規表現を使って、与えられた文字列中から、FoundNumbers の配列を返すような関数は次のようになります。


    func findNumbers(_ str: String) -> [FoundNumbers] {
        let pattern = #"(?[0-9]+)"#
        let regEx = try! NSRegularExpression(pattern: pattern, options: [])
        let matches = regEx.matches(in: str, options: [], range: str.fullNSRange)
        var ret:[FoundNumbers] = []
        for match in matches {
            let numStr = String(str[Range(match.range(withName: "Number"), in:str)!])
            if let numValue = Int(numStr) {
                let newFinding = FoundNumbers(numString: numStr, numValue: numValue, range: match.range(withName: "Number"))
                ret.append(newFinding)
            }
        }
        return ret
    }
MEMO
.fullNSRange は、String 全体を表す NSRange を返す関数です。以下のような extension を作ってよく使ってます。


extension NSString {
    var fullNSRange: NSRange {
        NSRange(location: 0, length: self.length)
    }
}

適切な装飾のついた NSTextParagraph を返す

delegate 内で、range で指定された文字列が条件にマッチするかを確認し、マッチするのであれば、NSAttributedString に適切な attribute を付与し、その NSAttributedString から作成された NSTextParagraph を返します。

# デフォルトで少し大きめのフォントを使用したかったので、ベースとしても Attribute 付きの文字列を作成しています。
# おもしろフォントとしては、たれフォントを使わせていただいています。配布サイトは、こちら


extension NabeatsuDelegate: NSTextContentStorageDelegate {
    func textContentStorage(_ textContentStorage: NSTextContentStorage, textParagraphWith range: NSRange) -> NSTextParagraph? {
        guard let string = textContentStorage.textStorage?.string,
              let newRange = Range(range, in: string) else { return nil }
        let subString = String(string[newRange])

        let decorateFont = NSFont(name: "MT たれ", size: 60)!
        let foundNumbers = findNumbers(subString)
        let mutableString = NSMutableAttributedString(string: subString, attributes: [.font: NSFont.systemFont(ofSize: 30)])
        for foundNumber in foundNumbers {
            if foundNumber.numString.contains("3") ||
                foundNumber.numValue % 3 == 0 {
                mutableString.addAttribute(.font, value: decorateFont, range: foundNumber.range)
            }
        }
        return NSTextParagraph(attributedString: mutableString)
    }
}

これで、3の付く数字 もしくは、3の倍数の数字 の文字列については、たれフォントがサイズ60で設定されます。
(それ以外は、システムフォントがサイズ30で設定されています。)

NSTextContentStorageDelegate を設定する

上記のように実装したメソッドが適切に呼ばれるためには、NSTextContentStorage の delegate に実装したクラスを設定する必要があります。


//..snip
        let textContentStorage = NSTextContentStorage()
        textContentStorage.delegate = textContentStorageDelegate
//..snip

完成したエディタの動作

以下のような動作になりました。

入力した数字を瞬時に 3の倍数かどうか判定して表示を切り替えるので、アホになっているというよりは、賢いんじゃないかと・・・

まとめ:TextKit2 をカスタマイズする

TextKit2 をカスタマイズする
  • NSTextContentStorageDelegate を使うことで、作成される NSTextParagraph をカスタマイズできる

SwiftUI 学習におすすめの本

SwiftUI 徹底入門

SwiftUI は、グラフィカルなライブラリということもあり、文字だけのテキストよりは、画像が多く入れられた書籍を読むと理解が進みやすいです。

自分で購入した中でおすすめできるものとしては、以下のものです。

2019 年発表の SwiftUI 1.0 相当を対象にしているので、2020/2021 に追加された一部の機能は、説明されていません。

ですが、SwiftUI 入門書としては、非常によくできていますし、わかりやすいです。 この本で学習した後に、追加分を学習するのがおすすめです。

SwiftUIViewsMastery

英語での説明になってしまいますが、以下の本もおすすめです。

1ページに、コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です