[Swift] iOS13とフォントのお話

# はじめに

[Combineを使ってMVVMでNotificationCenterを実装する](https://qiita.com/H_Crane/items/48fbf9ccde2090e7202c)でも書きましたが、ありがたいことにiOS13以上の案件に携わりました。そこでは、新しくiOS13で追加されたフォント周りに関しても触れることができ、大変良い経験となりました。

また、それに付随してフォントの話で登壇する機会をいただきました。
（画像をクリックすると資料に飛べます）

[![thumbnail](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/513fdbc4-9d7f-964c-79da-a1003a8e0be9.png)](https://speakerdeck.com/hcrane/ios13tohuontofalseohua)

内容はスライドをみていただくとわかりますが、iOS13周りで増えたフォントに関しての話をさせていただき、、
具体的なコードの話はあまり触れなかったので、こちらで**実装面**に関して触れていきたいと思います。

主に、フォント周りでできるようになったことを中心に記載していきます。

## iOS13とフォント

iOS13からCTFontまわりのものがいくつか増えました。

![スクリーンショット 2019-12-23 4.57.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/4af41416-a74c-8c1d-318e-522afb4a86dd.png)

この中のAPIを使ってフォント周りを操作していきます。
それでは実装コードを早速みていきましょう。

## カスタムフォントをサクッと使う

資料でもこちらは触れているのでさらっと書いていきます。
``UIFontPickerViewController``を使うことでAppleが用意してくれたフォントを簡単に使うことができます。
（以下、実際に作成された方の動画をお借りしています。）

<img width="200" alt="UIFontPickerViewController.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/f223f3db-5bca-2885-0d61-e4d3b21fa873.gif">

選択したフォントが即座にラベルに反映されているのがみてわかります。

原理としてはとても単純で

![スクリーンショット 2019-12-23 6.56.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/1642635d-86cd-cbb1-19cd-c26ecbc82487.png)

Delegateを通りしてFontを取得できるので、そちらをセットしているだけです。


## 自前のカスタムフォントを使えるように設定する

フォントの扱うには、準備が必要なので先に行いましょう。

### ① フォントをアプリ内に用意する

今回は例として「**NotoSans-Black**」のフォントを使用します。

**Asset**にフォントのリソースを追加します。

<img width="1100" alt="スクリーンショット 2019-12-23 3.31.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/4ebdd42d-cfb1-cc49-c130-c26de135e8a8.png">

その後、**Resource Tag**にも追加します。

<img width="1159" alt="スクリーンショット 2019-12-23 3.31.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/7c60d80b-e28d-d5ce-39bd-5748dbc6cb2d.png">

上記で、追加したリソースタグ名を「**Font**」という名前を設定していますが、これは**後々コードでも使用**することになります。
（なので各自でユニークな名前を設定してください）


### ② フォントのentitlementsを追加する

これがないといくら実装してもエラーになります。

「Signin & Capabilities」から「＋」で追加しましょう。
（Xcode11からUIが微妙に変わって探しづらいので注意）

<img width="465" alt="スクリーンショット 2019-12-23 5.04.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/ff5dd77a-3593-f855-6d54-ee369bc0dba6.png">

「fonts」と検索すると該当するものが出てくるので追加します。

<img width="727" alt="スクリーンショット 2019-12-23 4.03.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/3b253f27-515e-ba44-aa78-390d647e18b8.png">

無事に追加したらチェックをつけましょう。

<img width="957" alt="スクリーンショット 2019-12-23 4.03.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/10fc11ed-2bbb-5460-2bbc-34b81bd6857e.png">

これで準備は完了です。

## フォントのインストール / アンインストール

### 1. フォントをインストールする

いくつかの工程を挟むため段階的に説明していきます。

#### ①  リソースアクセスの確認

フォントをインストールするには、まずリソースにアクセスできるか確認する必要があります。
（しないとコードでエラーが出てしまうため）

アクセスできるかを確認するには``NSBundleResourceRequest``で確認します。

```.swift

private var resourceRequest: NSBundleResourceRequest?

func requestFont(tags: Set<String>, fonts: CFArray) {
    resourceRequest = NSBundleResourceRequest(tags: tags)
    resourceRequest?.conditionallyBeginAccessingResources { [weak self] isAvailable in
        if isAvailable {
            debugPrint("is available")
        } else {
            debugPrint("is not available")
        }
    }
}
```

１つ目の引数``tags``は先ほど追加した**リソースタグ名**になります。
２つ目の引数``fonts``はインストールしたいフォント名を引数として渡します。

#### ② リソースにアクセスする

アクセスができない場合は、アクセスできるようにします。
（①の処理は初回でfalseになるとはずなのでこちらを通るはず）

```.swift
func accessFont(fonts: CFArray) {
    resourceRequest?.beginAccessingResources { [weak self] error in
        if error == nil {
            debugPrint("success")
        } else {
            debugPrint("failure", error?.localizedDescription ?? "")
        }
        self?.resourceRequest?.endAccessingResources()
    }
}
```

#### ③ インストールする

iOS13から追加された``CTFontManagerRegisterFontsWithAssetNames``を使用してインストールします。

```.swift
func installFont(fonts: CFArray) {
    CTFontManagerRegisterFontsWithAssetNames(fonts, CFBundleGetMainBundle(), .persistent, true) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font install failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font install success")
            return true
        }
    }
}
```

引数にはインストールしたいフォント名を引数として渡します。
エラーをみたい場合は``unsafeBitCast``で取り出す必要があります。

#### 総括

①~③のコードを連携させたものです。
（単発のコード群だったので、ちゃんと機能するものを記載しておきます。）

```.swift
private var resourceRequest: NSBundleResourceRequest?

func requestFont(tags: Set<String>, fonts: CFArray) {
    resourceRequest = NSBundleResourceRequest(tags: tags)
    resourceRequest?.conditionallyBeginAccessingResources { [weak self] isAvailable in
        if isAvailable {
            debugPrint("is available")
            self?.installFont(fonts: fonts)
        } else {
            debugPrint("is not available")
            self?.accessFont(fonts: fonts)
        }
    }
}

func accessFont(fonts: CFArray) {
    resourceRequest?.beginAccessingResources { [weak self] error in
        if error == nil {
            self?.installFont(fonts: fonts)
        } else {
            debugPrint("failure", error?.localizedDescription ?? "")
        }
        self?.resourceRequest?.endAccessingResources()
    }
}

func installFont(fonts: CFArray) {
    CTFontManagerRegisterFontsWithAssetNames(fonts, CFBundleGetMainBundle(), .persistent, true) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font install failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font install success")
            return true
        }
    }
}
```

実際の実行コード例は以下になります。

```.swift
let fontList = ["NotoSans-Black"] as CFArray
let assetList: Set<String> = ["Font"]
requestFont(tags: assetList, fonts: fontList)
```



実際に設定画面で確認してみるとインストールされていることが確認できます。

<img width="1764" alt="スクリーンショット 2019-12-23 4.33.17 2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/c94bffb2-3779-6d1a-9ef5-18bfdbbe8193.png">

この設定画面からアンインストールすることも可能ですが、次はコードからの実装をみてみましょう。

### 2. フォントをアンインストールする

アンインストール用の型が決まっているため、それを用意するためにStringのExtensionを用意しました。
``CTFontDescriptor``の配列を用意する必要があるため、フォント名から取得できるようにStringから拡張できるようにしています。
（個人的な好みでExtensionにしたので、メソッドでもかまいません）

```.swift
extension String {
    
    var fontDescriptor: CTFontDescriptor? {
        return (CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor])?.first {
            CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String == self
        }
    }
}
```

``CTFontManagerCopyRegisteredFontDescriptors ``は後々の文章で説明しますが、行なっていることとしては、
「指定したフォント名がインストールされていれば``CTFontDescriptor``を返す」
というExtensionになっています。

では、実際のアンインストールコードをみていきます。

```.swift
func uninstall(fontDescriptors: [CTFontDescriptor]) {
    CTFontManagerUnregisterFontDescriptors(fontDescriptors as CFArray, .persistent) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font uninstall failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font uninstall success")
            return true
        }
    }
}
```

お気付きの方もいるかもしれませんが、先ほどのインストールメソッドとほとんど変わりません。
実際の実行コード例は以下になります。

```.swift
let fontList = ["NotoSans-Black"]
let fontDescriptors = fontList.compactMap { $0.fontDescriptor }
uninstall(fontDescriptors: fontDescriptors)
```

先ほどと同様に設定画面からフォントがアンインストールされているか確認することができます。

## フォントをアプリ内で使用する


### 1. フォントを参照する

``CTFontManagerCopyRegisteredFontDescriptors``
でそのアプリでいれたフォントを参照することができます。

ただし
「**他のアプリや外部からインストールしたものは参照できない！！！**」
ので注意してください。

例として「*Noto Sans*」の「*NotoSans-Bold*」をアプリからインストールしていた場合は以下のようになります。

```.swift
let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

print(descriptors ?? [])
```

printの内容は以下

```
[UICTFontDescriptor <0x6000026827c0> = {
    NSCTFontFileURLAttribute = "file:///Users/XXXXXX/Library/Developer/CoreSimulator/Devices/60946E20-27DB-42F6-BAA1-35ABB6308F7B/data/Containers/Data/Application/E6695D3B-D062-4C35-9AEE-48DC6F50CD03/tmp/NotoSans-Bold-C91C8631-7E83-45F7-AD21-EC0502A772C5";
    NSFontFamilyAttribute = "Noto Sans";
    NSFontNameAttribute = "NotoSans-Bold";
}]

// ※ XXXXXXはユーザー名
```

このようにインストール済みのフォント情報を配列で参照することができます。


### 2. フォントを使用する

取り出した``UICTFontDescriptor``は``UIFont``でそのまま使用できます。

```.swift
let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

/// 今回はとりあえず１つなので先頭のものを取り出しています。
guard let notoSansBoldDescriptor = descriptors?.first else { return }

label.font = UIFont(descriptor: notoSansBoldDescriptor, size: 30.0)
```

実際のところフォント情報の配列で返ってくるので、WWDCの動画にもあった「フォント名」を``String``の配列にして保持しておくのが良いかと思います。

（以下動画の抜粋）
![スクリーンショット 2019-11-04 14.26.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/41522e27-b525-1f9f-65cf-0565abc3ce67.png)

先の自分が記載したコードでは１つのフォント(``descriptors?.first``の部分)しか取りだしていませんが、このように「フォント名」を``String``の配列で保持することで、必要なフォントを選択できるような実装が可能です。

``CTFontDescriptorCopyAttribute`` を使用することで ``CTFontDescriptor``からフォントの名前だけを取り出すことが可能です。

先のコードの延長として簡易なコードを記載しておきます。

```.swift
// インストール済みフォント一覧取得
let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

// Descriptor の取得
guard let notoSansBoldDescriptor = descriptors?.first else { return }

// Font名 の取得
guard let notoSansBold = CTFontDescriptorCopyAttribute(notoSansBoldDescriptor, kCTFontNameAttribute) as? String else { return }
        
label.font = UIFont(name: notoSansBold, size: 30.0)
```



## フォントに使用期限を設ける

主にサブスクリプションなどを行う際に必要になるかと思います。
設定することで**OS側でフォントを消してくれる**ようになります。

実際に設定すると、その期限になった時にアラートがでるようになります。

<img width="629" alt="スクリーンショット 2019-12-23 6.27.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/432411ff-f7ff-1a4d-51ea-6eba5910ec44.png">

設定するためには、info.plistに追加の記載が必要になります。

```
- URL Scheme
- FontProviderSubscriptionSupportInfo
```

それぞれで設定したschcmeを揃える必要があります。

<img width="553" alt="スクリーンショット 2020-01-22 1.55.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/d7e7254f-d22b-d850-5c44-61505e793a7a.png">



この追加する「FontProviderSubscriptionSupportInfo」ですが、
公式に公開されているAPIではないので**補完されてでてきません**。
なので、plistに**コードで直接追加**してください。

<img width="360" alt="スクリーンショット 2020-01-22 2.02.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/a866fd93-34cb-030d-7bca-c76da077c366.png">


設定項目としては

```
- warn： 警告を表示するまでの日数
- expire： フォントを削除するまでの日数
- url: openを押した際のscheme設定
- test: テストモードのon/off
```
となっており、この日数はフォントをインストールしてから換算されます。

warnで設定したアラートを無視し続けると、いずれexpireで設定した日にアラートが出て**フォントは自動で削除**されます。
testをYesにすると、warn/expireで設定した**1日が1分換算**になり、すぐテストできるようになります。

*シミュレータのみ
*きっかり１分というわけではなく、例えば「5」にしても5-10分くらいかかったりするので注意

# 終わりに

iOS13ではSwiftUIやCombineに目がいきがちですが、、、
こういったデザイン周りに関わってくるアップデートもたくさんあり、着目してみると面白いものです。

まだ、情報としてあまり出回っていないので、面白い知見があれば是非お教え願いたいです！

サクッと動作するものを置いておきます
Github: [Font_Install_Demo](https://github.com/crane-hiromu/Font_Install_Demo)