【Swift】iOS13でNSAttributedStringのtrackingがしたい

はじめに

タイトルの件です。

iOS15のリリースが近付き、iOS13もあといつまでサポートするかって話になってきている昨今にかなりもう今さらな感じはしますが、それでもつい最近やる機会があったため、同じ問題に当たっている方のために記したいと思います。

開発環境は Xcode 12.5 / Swift 5.4 です。

NSAttributedStringのkernとtrackingについて

プログラミング初心者のために、軽く触れたいと思います。

NSAttributedStringは、文字列 (String)の一部や全部にフォントや文字色、文字と文字の間のスペースや行の高さ、行間のスペースなど、様々な装飾を施すためのクラスです。

この中で、文字と文字の間のスペースを設定するものとして、「カーニング (Kerning)」と「トラッキング (Tracking)」があります。

印刷業界用語としてのカーニングについては、詳しくはネットで検索するなどしていただくとして、NSAttributedStringにおけるカーニングとトラッキングの違いは、前者が「文字の後ろに付けるスペース量を指定するもの」で、後者が「指定した範囲の文字列の各文字間のスペースを指定するもの」と捉えて良いかと思います。

百聞は一見にしかずなので、実際に違いを見てみましょう。
Playgroundでサンプルコードを書いてみます。

import UIKit
import PlaygroundSupport

class ViewController: UIViewController {
    override func loadView() {

        let view = UIView()
        view.backgroundColor = .systemBackground
        self.view = view

        let text = "12345\nABCDEFG"
        let font = UIFont(name: "HiraginoSans-W3", size: 15)!
        let color = UIColor.red
        let spacing = NSNumber(value: 15)

        // Normal
        let labelNormal = UILabel()
        view.addSubview(labelNormal)
        labelNormal.translatesAutoresizingMaskIntoConstraints = false
        labelNormal.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10).isActive = true
        labelNormal.topAnchor.constraint(equalTo: view.topAnchor, constant: 10).isActive = true
        labelNormal.numberOfLines = 0
        labelNormal.backgroundColor = .green
        labelNormal.attributedText = NSAttributedString(string: text, attributes: [
            .font: font,
            .foregroundColor: color,
        ])

        // Kerning
        let labelKerning = UILabel()
        view.addSubview(labelKerning)
        labelKerning.translatesAutoresizingMaskIntoConstraints = false
        labelKerning.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10).isActive = true
        labelKerning.topAnchor.constraint(equalTo: labelNormal.bottomAnchor, constant: 20).isActive = true
        labelKerning.numberOfLines = 0
        labelKerning.backgroundColor = .green
        labelKerning.attributedText = NSAttributedString(string: text, attributes: [
            .font: font,
            .foregroundColor: color,
            .kern: spacing,
        ])

        // Tracking (iOS 14 and later only)
        let labelTracking = UILabel()
        view.addSubview(labelTracking)
        labelTracking.translatesAutoresizingMaskIntoConstraints = false
        labelTracking.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10).isActive = true
        labelTracking.topAnchor.constraint(equalTo: labelKerning.bottomAnchor, constant: 20).isActive = true
        labelTracking.numberOfLines = 0
        labelTracking.backgroundColor = .green

        if #available(iOS 14, *) {
            labelTracking.attributedText = NSAttributedString(string: text, attributes: [
                .font: font,
                .foregroundColor: color,
                .tracking: spacing,
            ])
        }
    }
}

PlaygroundPage.current.liveView = ViewController()

↓これを実行すると、以下のような画面が表示されます。

一番上はカーニングもトラッキングも指定していないラベル、二番目がカーニング (NSAttributedString.Key.kern) を指定したラベル、そして三番目が (NSAttributedString.Key.tracking) を指定したラベルになります。

カーニングとトラッキングは一見同じように見えますが、背景を緑色に塗ったことでわかるように、カーニングは文字の後ろのスペースを指定するため、「G」の後ろもスペースが付いていることがわかります (見えないけど「5」の後ろにも付いています)。

一方でトラッキングの場合は、指定された範囲内の各文字間のスペースのため、「G」の後ろはスペースが付いていません。

これがカーニングとトラッキングの違いになります。

そして、コード中にも記載してあるとおり、トラッキング (NSAttributedString.Key.tracking) はiOS14から追加されたものであるため、iOS13でこれをやりたい場合にどうするかというのが今回の本題になります。(SwiftUIはiOS13からtrackingが追加されてるのに何故…😱)

何故トラッキングしたい?

上記サンプルを見て、「Gの後ろにスペースが付いて何が困るの?」と思われるかもしれません。

「別に背景色を付けなければユーザーの見た目はどちらも同じだし」と。

しかし、Gの後ろにもスペースが付くことで、UILabelのサイズがそれを含んだサイズになってしまうため、例えばそのUILabelをNSLayoutConstraintのcenterXを使って画面中央に設置したり、UIButtonのsetAttributedTitleを使ってタイトルを設定したりした場合に、中央から若干左に寄ってしまう (左に寄って見えてしまう) 不都合が生じてしまいます。

なので、できればカーニングではなくトラッキングを使いたいのです。
スペースの分だけ右にずらすとかしたくないですし。

解決策

結論から言えば、iOS13でトラッキングをするにはワークアラウンドしかありません。(と思います)

↓以下、上記サンプルコードに4つ目のラベルを加えます。

(以上略)

        // Tracking Workaround
        let labelTrackingWA = UILabel()
        view.addSubview(labelTrackingWA)
        labelTrackingWA.translatesAutoresizingMaskIntoConstraints = false
        labelTrackingWA.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10).isActive = true
        labelTrackingWA.topAnchor.constraint(equalTo: labelTracking.bottomAnchor, constant: 20).isActive = true
        labelTrackingWA.numberOfLines = 0
        labelTrackingWA.backgroundColor = .green

        labelTrackingWA.attributedText = text
            .components(separatedBy: "\n")
            .enumerated()
            .reduce(into: NSMutableAttributedString()) { (result, item) in

                if item.offset > 0 {
                    result.append(NSAttributedString(string: "\n"))
                }

                let string = NSMutableAttributedString(string: item.element, attributes: [
                    .font: font,
                    .foregroundColor: color,
                ])

                if string.length > 0 {
                    string.addAttribute(.kern, value: spacing, range: NSRange(0..<(string.length - 1)))
                }

                result.append(string)
            }

(以下略)

散々前フリした割には何てことのないものなのですが、「最後の一文字だけカーニングをかけない」ということをしています。

文字列が複数行あることを想定して、改行コードで一度分割して、各行に「最後の一文字以外カーニング」処理を施し、そして結合するという単純なワークアラウンドです。

※ このサンプルには改行コードが「\n」固定だったり、行末が非文字 (制御コード等) であるケースを考慮していないなど、穴があります。なので、参考にされる場合は要件に合わせて条件を追加するなどしていただくようお願いします。

↓これを実行すると以下のようになります。

いい感じですね 👍

参考リンク

・カーニング – Wikipedia
https://ja.wikipedia.org/wiki/%E3%82%AB%E3%83%BC%E3%83%8B%E3%83%B3%E3%82%B0

・NSAttributedString.Key.kern – Apple Developer Documentation
https://developer.apple.com/documentation/foundation/nsattributedstring/key/1527891-kern

おわりに

トラッキングがSwiftUIにしてもiOS13から追加されたということは、12以前ではやはりこうしたワークアラウンドがされていたんでしょうかね?

検索するとStackOverflowなどでも質問が上がっていたりするあたり、意外とみんな一度は当たって何かしら乗り越えていたりするのかもしれませんね。

いずれにせよ、この記事がどなたかの助けとなれば幸いです。

それでは。