[Swift] SwiftUIの不思議な機能を実現する変数展開"\()"について調べた

この記事は[Swift その2 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/swift2)の20日目の記事です。
SwiftUIの例を中心にSwiftにおける変数展開[^1]についてみていきます。

#SwiftUIの不思議な機能

SwiftUIでは次のようなコードが書けます。

```Swift
struct MyView: View {
    var body: some View {
        HStack{
            Text("これは\(Image(systemName: "swift"))です")  //OK
            Text("これは\(Text("太い文字").bold())です")      //OK
        }
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638786/4ee36be8-5da8-f10d-84d0-33a0423d9e21.png)

めちゃくちゃ便利です。つい最近まで魔法だと思っていました。
ところで、次のようなコードはエラーが出ます。

```Swift
    var body: some View {
        HStack{
            Text("これは\(Button("ボタン"){})です") //Instance method 'appendInterpolation' requires that 'Button<Text>' conform to '_FormatSpecifiable'
            Text("これは\(Color.red)です")         //Instance method 'appendInterpolation' requires that 'Color' conform to '_FormatSpecifiable'
        }
    }
```

テキストの中にButtonが埋め込めないのは理解できます。ところが、次のコードもエラーです

```Swift
    var body: some View {
        HStack{
            Text("これは\(false)です") //No exact matches in call to instance method 'appendInterpolation'
        }
    }
```
普通ならばこの`Text`は「これはfalseです」と表示すべきです。ところがそうはなりません。

また、こうやると画像を表示できなくなります。

```Swift
struct MyView: View {
    let text: String
    var body: some View {
        HStack{
            Text(text)
        }
    }
}

MyView(text: "これは\(Image(systemName: "swift"))です")   //これはImage(provider: SwiftUI.ImageProviderBox<SwiftUI.Image.(unknown context at $1905e5fc0).NamedImageProvider>)です
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638786/e7957de6-3a44-dfa9-c705-81b6f1679b3d.png)

**何かがおかしい**のです。

エラーを読むと、どうやら`appendInterpolation`と`_FormatSpecifiable`が大事なようです。この謎を解くため、まずは`appendInterpolation`について調べてみましょう。

# ExpressibleByStringInterpolation
Swiftには型を文字列リテラルで初期化できるようにする`ExpressibleByStringLiteral`というprotocolがあります。

例えば次のような構造体を作れば、文字列リテラルで初期化できるようになります。

```Swift
struct MyString: ExpressibleByStringLiteral, CustomStringConvertible {
    typealias StringLiteralType = String

    let body: String

    init(stringLiteral: Self.StringLiteralType){
        self.body = stringLiteral
    }

    var description: String {
        return body
    }
}

let myString: MyString = "アイウエオ"       //OK
```
ところが、これだけではこのコードはエラーになります。

```Swift
let myString: MyString = "\("アイウエオ")"  //エラー
```
これは変数展開は`ExpressibleByStringLiteral`だけでは足りないからです。
そこで登場するのが`ExpressibleByStringInterpolation`です。

```Swift
extension MyString: ExpressibleByStringInterpolation {}
let myString: MyString = "\("アイウエオ")"  //OK
```
これで無事変数展開が使えるようになります[^2]。

この`ExpressibleByStringInterpolation`はassociatedtypeとして`StringInterpolationProtocol`に準拠する`StringInterpolation`という型を要求します。デフォルトではこれに`DefaultStringInterpolation`が指定されています。

```Swift
associatedtype StringInterpolation : StringInterpolationProtocol = DefaultStringInterpolation where Self.StringLiteralType == Self.StringInterpolation.StringLiteralType
```
つまりこのprotocolが付与されたことで、`MyString`には`StringInterpolation`といくつかのメソッドが追加され、それらの働きによって変数展開が可能になったという事です。

`StringInterpolationProtocol`に準拠する型は`appendInterpolation`と`appendLiteral`というメソッドを持っています。そうです、さっき出ていた`appendInterpolation`というのはどうやら変数展開の際に呼ばれるメソッドのようです。

## 変数展開を拡張
`appendInterpolation`を理解するために、自前で新しい変数展開を作ってみましょう。

[ドキュメント](https://developer.apple.com/documentation/swift/stringinterpolationprotocol)によると`appendInterpolation`を追加で定義することで、変数展開をカスタマイズできます。

>`appendInterpolation` methods support virtually all features of methods: they can have any number of parameters, can specify labels for any or all of their parameters, can provide default values, can have variadic parameters, and can have parameters with generic types. Most importantly, they can be overloaded, so a type that conforms to `StringInterpolationProtocol` can provide several different `appendInterpolation` methods with different behaviors. An `appendInterpolation` method can also throw; when a user writes a literal with one of these interpolations, they must mark the string literal with try or one of its variants.

`String`の`StringInterPolation`はデフォルトの`DefaultStringInterpolation`ですから、これを拡張すれば通常の`String`の変数展開も拡張できます。やってみましょう。

```Swift
extension DefaultStringInterpolation {
    mutating func appendInterpolation(reversed value: String) {
        self.appendInterpolation(String(value.reversed()))
    }
}

let string = "アイウエオ"
print("Reversed string: \(reversed: string)")  //オエウイア
```

なんとこれだけで`"\(reversed: string)"`という新しい記法が定義されてしまいました[^3]。

# SwiftUIの変数展開
さて、ではSwiftUIの動作です。[ドキュメント](https://developer.apple.com/documentation/swiftui/text/init(_:)-9d1g4)によると`Text`は次のように動作します。
>SwiftUI doesn’t call the init(\_:) method when you initialize a text view with a string literal as the input. Instead, a string literal triggers the init(_:tableName:bundle:comment:) method — which treats the input as a LocalizedStringKey instance — and attempts to perform localization.


特に指定することなくイニシャライザを呼んだ場合、`init(_:tableName:bundle:comment:)`を呼び出すようですが、このイニシャライザの最初の引数は`LocalizedStringKey`という型になっています。

この`LocalizedStringKey`は`ExpressibleByStringInterpolation`に準拠する型で、内部には`StringInterpolation`というstructがあります。つまり`LocalizedStringKey`は`DefaultStringInterpolation`ではなく独自に実装した`StringInterpolation`を使っていることになります。

この独自の`StringInterpolation`には次のようなメソッドがあります。

```Swift
func appendInterpolation<T>(T)
func appendInterpolation(String)
func appendInterpolation(Text)
func appendInterpolation(Image)
func appendInterpolation(ClosedRange<Date>)
func appendInterpolation(DateInterval)
func appendInterpolation<Subject>(Subject, formatter: Formatter?)
func appendInterpolation<Subject>(Subject, formatter: Formatter?)
func appendInterpolation<T>(T, specifier: String)
func appendInterpolation(Date, style: Text.DateStyle)
```
これで冒頭にあげた不思議な挙動が説明できるようになります。
`Text`と`Image`を`Text`の中で変数展開できるのは、それぞれ以下2つのメソッドが用意されているからです。

```Swift
func appendInterpolation(Text)
func appendInterpolation(Image)
```

また、一番上の`appendInterpolation<T>(T)`は次のように定義されています。

```Swift
mutating func appendInterpolation<T>(_ value: T) where T : _FormatSpecifiable
```
つまり`Button`や`Color`をこのメソッドの引数に入れようとして、`_FormatSpecifiable`に準拠していないよ、と怒られていたわけです。

`String`として`Text`の引数を受け取ると画像が表示できなくなるのは、そもそも指定すべきが`String`ではなく`LocalizedStringKey`だからです。したがって次のようにすれば解決です。

```Swift
struct MyView: View {
    let text: LocalizedStringKey
    var body: some View {
        HStack{
            Text(text)
        }
    }
}

MyView(text: "これは\(Image(systemName: "swift"))です")
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638786/59687d49-98b0-7d00-e178-a6588e7f4b54.png)

カスタムビューの実装でよく`Text`の引数として使う値を`String`として受け取っているものを見かけますが、`LocalizedStringKey`として受け取った方がいいのかもしれません。

しかし`"\(false)"`がエラーになるなど、`LocalizedStringKey`では困る場面もあります。このため`Text`には次のようなイニシャライザが用意されています。これは引数をverbatim(文字通り)に解釈します。

```Swift
init(verbatim content: String)
```
これを使えば`Bool`が入れられない問題は次のように解消できます。

```Swift
    var body: some View {
        HStack{
            Text(verbatim: "これは\(false)です")
        }
    }
```

もちろん

```Swift
    var body: some View {
        HStack{
            Text("これは\(false)です" as String)
        }
    }
```
でもいいと思います。
## Textの変数展開を拡張する
せっかく調べたので、最後に拡張してみましょう。といってもextensionを書くだけなので簡単です。

```Swift
extension LocalizedStringKey.StringInterpolation{
    mutating func appendInterpolation(bold value: LocalizedStringKey){
        self.appendInterpolation(Text(value).bold())
    }

    mutating func appendInterpolation(underline value: LocalizedStringKey){
        self.appendInterpolation(Text(value).underline())
    }

    mutating func appendInterpolation(italic value: LocalizedStringKey){
        self.appendInterpolation(Text(value).italic())
    }

    mutating func appendInterpolation(systemImage name: String){
        self.appendInterpolation(Image(systemName: name))
    }
}
```
このように定義すれば

```Swift
struct MyView: View {
    var body: some View {
        Text("これは\(bold: "太字")で\(underline: "下線")で\(italic: "italic")で\(systemImage: "swift")です！")
    }
}
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638786/6a59f652-93ac-1109-a5f3-6c38ef430207.png)
簡単に文字列を装飾できるようになりました。

# 結論
* Swiftの文字列リテラル内の変数展開は、代入先の型が`ExpressibleByStringInterpolation`に準拠していることが必要
* `ExpressibleByStringInterpolation`のassociatedtypeである`StringInterpolation`は`appendInterpolation`というメソッドを持ち、これが変数展開の動作を担う
* `String`は`DefaultStringInterpolation`を使っている
* `Text("hoge")`と書いた時、`"hoge"`の型は`LocalizedStringKey`になる
* `LocalizedStringKey`は`StringInterpolation`を独自に持っている
* `LocalizedStringKey.StringInterpolation`は`Text`や`Image`を引数に取る`appendInterpolation`が実装されているので、これらを引数にできる

SwiftUIでなんとなく使ってきた機能の謎が解けてとてもスッキリしました。やはりドキュメントは読むべきですね。

# 参考資料
* [StringInterpolationProtocol | Apple Developer Documentation](https://developer.apple.com/documentation/swift/stringinterpolationprotocol)
* [DefaultStringInterpolation | Apple Developer Documentation](https://developer.apple.com/documentation/swift/defaultstringinterpolation)
* [Text | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/text)
* [LocalizedStringKey | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/localizedstringkey)
* [LocalizedStringKey.StringInterpolation | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/localizedstringkey/stringinterpolation)

[^1]: String Interpolationは文字列補完や文字列挿入と訳すのが正確なようですが、ここでは便宜的に変数展開と呼ぶことにします。
[^2]: なお`ExpressibleByStringInterpolation`は`ExpressibleByStringLiteral`に準拠するので、最初からこちらを用いても問題ないでしょう。
[^3]: `StringInterPolationProtocol`が要求しているのは`appendLiteral`の実装なのですが、ドキュメントによれば変数展開ではSwiftが呼び出すのは`appendInterpolation`の方だそうです。この辺りは何か事情があるのかもしれません。