縦書きの日本語をウィジェットで表現したい

当記事は [フラー株式会社 Advent Calendar 2022](https://qiita.com/advent-calendar/2022/fuller-inc) 24日目の記事です。
23日目の記事は @okuzawats さんの「[[Android] EventBusの思い出🚌](https://okuzawats.com/blog/eventbus/)」でした。

縦書きの日本語をウィジェットに表示したいということで、
WidgetKit で気づいた事や、備忘録も含めて書きました。

# やること
- ウィジェットの全体の流れを把握したい
- Largeサイズのウィジェットに縦書き表示の文字列を表示したい
- 数種類のテキストを1時間毎にランダムで表示

# ウィジェット表示

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/403173/06260493-9c15-685e-8528-137bee319ec4.png" width="300" />

# 実装
プロジェクトから Widget Extension を追加すればテンプレートが用意されているので、
手軽に利用できます。

### エントリーポイント
ウィジェットが最初に実行されるエントリーポイントを作成。
`@main`属性を指定してあげる。
また、ここではウィジェットギャラリーに表示する文言を指定することができる。

```swift
@main
struct SampleWidget: Widget {
    let kind: String = "SampleWidget"

    var body: some WidgetConfiguration {
        // SampleWidgetProvider からテキストを取得し、
        // SampleWidgetEntryView に渡す
        StaticConfiguration(kind: kind, provider: SampleWidgetProvider()) { entry in
            SampleWidgetEntryView(entry: entry)
        }
        // ウィジェットギャラリーに表示される文言
        .configurationDisplayName("縦書き")
        .description("日本語を縦書き表示")
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/403173/c3eb0e5c-0bb3-1219-b7f5-e08a800d42bb.png" width="300" /> 

### TimeLineProvider
ウィジェットの更新タイミングを提供する TimeLineProvider を用意する。

```swift
struct SampleWidgetProvider: TimelineProvider {
    // ウィジェットのプレースホルダーを表示するために呼ばれるメソッド
    func placeholder(in context: Context) -> SampleWidgetEntryModel {
        SampleWidgetEntryModel(ku: [["曇りなき", "心の月を先立てて", "浮世の闇を", "照らしてぞ行く"]],
                              random: arc4random_uniform(0),
                              date: Date())
    }

    // ウィジェットを追加時のウィジェットギャラリー表示や、ウィジェットの更新に数秒以上かかる場合に呼び出されるようです
    func getSnapshot(in context: Context, completion: @escaping (SampleWidgetEntryModel) -> ()) {
        let entry = SampleWidgetEntryModel(ku: [["曇りなき", "心の月を先立てて", "浮世の闇を", "照らしてぞ行く"]],
                                          random: arc4random_uniform(0),
                                          date: Date())
        completion(entry)
    }

    // ウィジェットの更新頻度を決めるメソッド
    // 今回は4時間後まで1時間ごとにテキストを呼び出してウィジェットに表示させる。
    // 複数のテキストを用意して、１時間毎にランダム表示させる。
    // 必要な場合はここでAPI通信して SampleWidgetEntryModel に渡すことになりそうです。
    func getTimeline(in context: Context, completion: @escaping (Timeline<SampleWidgetEntryModel>) -> ()) {
        var entries: [SampleWidgetEntryModel] = []

        let text = [["曇りなき", "心の月を先立てて", "浮世の闇を", "照らしてぞ行く", "伊達政宗"],
                    ["浮世をば", "今こそ渡れ武士の", "名を高松の", "苔に残して", "清水宗治"],
                    ["あらざらむ", "この世のほかの", "思ひ出に今ひとたびの", "あふこともがな", "和泉式部"],
                    ["益荒男が", "たばさむ太刀の", "鞘鳴りに幾とせ耐へて", "今日の初霜", "三島由紀夫"],
                    ["春は花", "夏ほととぎす秋は月", "冬雪さえて", "冷しかりけり", "道元禅師"],
                    ["つひにゆく", "道とはかねて聞きしかど", "昨日今日とは", "思はざりしを", "在原業平",]]
        
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SampleWidgetEntryModel(ku: text,
                                              random: arc4random_uniform(6),
                                              date: entryDate)
            entries.append(entry)
        }

        // 4個分のエントリーを TimeLine に投げて、タイムラインが終了したら、「.atEnd」指定でタイムラインを再要求する
        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}
```

### TimelineEntry
ウィジェットの表示間隔を示す date を定義（必須）して、ウィジェット表示に必要な項目を定義する。

```swift
struct SampleWidgetEntryModel: TimelineEntry {
    let ku: [[String]]
    let random: UInt32
    let date: Date
}
```

### View（SwiftUI）
ウィジェットの View を構成する。（TimeLineProviderで取得した情報を View に流し込みます）

```swift
struct SampleWidgetEntryView : View {
    var entry: SampleWidgetProvider.Entry

    var body: some View {
        ZStack {
            Image("Image_Washi")
                .resizable()
                .aspectRatio(contentMode: .fill)
            VStack {
                Spacer().frame(height: 24)
                HStack(alignment: .top, spacing: 20) {
                    VStack {
                        Spacer().frame(alignment: .leading)
                        TategakiText(text: entry.ku[Int(entry.random)][4],
                                     fontSize: 18,
                                     spacing: 0)
                        Spacer().frame(height: 20)
                    }
                    HStack(alignment: .top) {
                        TategakiText(text: entry.ku[Int(entry.random)][3])
                        TategakiText(text: entry.ku[Int(entry.random)][2])
                        TategakiText(text: entry.ku[Int(entry.random)][1])
                        TategakiText(text: entry.ku[Int(entry.random)][0])
                    }
                }
            }
        }
    }
}

struct SampleWidget_Previews: PreviewProvider {
    static var previews: some View {
        SampleWidgetEntryView(entry: SampleWidgetEntryModel(ku: [["曇りなき", "心の月を先立てて", "浮世の闇を", "照らしてぞ行く", "伊達政宗"],
                                                               ["夜もすがら", "契りしことを忘れずは", "恋ひむ涙の", "色ぞゆかしき", "藤原定子"]],
                                                          random: arc4random_uniform(2),
                                                          date: Date()))
            .previewContext(WidgetPreviewContext(family: .systemLarge))
    }
}
```

### 縦書き表示を提供するための View を作る
UIKit で文字列を縦書き表示する方法はいくつかあるようです。
SwiftUI は UIViewRepresentable を利用して UIKit の機能を利用できますが、
WidgetKit では UIViewRepresentable を利用できないようなので（もしかしたらあるかもしれません）、
SwiftUI のみを使って自力で縦書き表示するしかないようです。
https://developer.apple.com/forums/thread/653471
https://developer.apple.com/forums/thread/652042

ForEach を使って文字配列から１文字ずつ VStack で縦に表示させる。
（この方法だと、フォントサイズの変更や文字数が多い場合は改行ができなかったりと柔軟な表示ができないので伸びしろしかない）

```swift TategakiText.swift
        ・・・
        VStack(alignment: alignment, spacing: spacing){
            ForEach(Array(zip(_text, 0..<_text.count)), id: \.0) { item in
                Text(_text[item.1])
                    .foregroundColor(fontColor)
                    .font(.system(size: fontSize, design: .serif))
                    .fontWeight(.medium)
            }
        }
        ・・・
```

# ウィジェット表示
縦書き表示と、数時間後に文言が更新できた（はず）。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/403173/06260493-9c15-685e-8528-137bee319ec4.png" width="300" /> 

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/403173/f3ecc980-608e-6fe6-a5f7-94e4483789c1.png" width="300" />

# おわりに
ウィジェットは
- UIRepresentable が利用できない
  - ウィジェットのようなシンプル構成のコンテンツは利用させてくない…？
- ビデオやアニメーション機能を有していないようです
  - iOS 純正の時計ウィジェットはアニメーションしてるので機能制限かけていそうです

先人たちの短歌を「縦書きで」ウィジェット表示することができました。
ウィジェットは分かりやすいデザインが求められるのと、制限が課された中で作るのがウィジェットの面白さだと思っています。