SwiftUIを実務で使ってわかったアレコレ

![swiftui_advent_2021.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10943/2b756c9b-6a55-c6ea-18bf-8efb54975ea3.png)

この記事はGoodpatchエンジニアアドベントカレンダー5日目の記事です。

こんにちは！iOSエンジニアのとうようです。[2日目の記事](https://goodpatch-tech.hatenablog.com/entry/engineer_communication_elementary)も書いているのですが、あまり技術的な内容は触れなかったのと、まだ空いていたので急遽この枠で技術的な記事も書いていこうということにしました。
多分このアドベントカレンダーはほとんどQiitaに書く人がいないので、一個ぐらいはQiitaに書き残しておこうかなとここで書き進めています。

今回書く内容は、自分が実際に参加した案件での実例を通したSwiftUI周りのお話になります。実際どのアプリなのかは伏せさせていただきますが、以下のような前提条件での学びになるためそこを踏まえた上で、**実プロダクトに現段階でSwiftUIをどう取り入れるべきなのか？**といったところの参考にしてもらえればいいなと思います。

- 途中までiOS13対応も考慮されていた
- アプリのリニューアルに際して導入されたため、ベースはUIKitであり、実際の見た目の部分を`UIHostingController`を使ってSwiftUIで実装している
- また、画面遷移はStoryboard + Wireframeパターン[^1]で作られており、全画面にStoryboardファイルが存在している

[^1]: VIPERアーキテクチャのRouterで使う、Wireframeプロトコルをプロトコルエクステンションで記述したものです。extensionの中でStoryboardからの初期化やさまざまな設定を実装することで、各画面からは関数を呼び出すだけで遷移できるようになり、個別の実装を行う必要がなくなります。

また、自分が担当した役割の性質上、デザイン寄りの話題もだいぶ多いことも踏まえた上でお読みいただければと思います。

## Listのセパレーターを消すには

`UITableView`のような表示をしたい時、`List`だとセパレーターが入ってしまいます。普通であれば別に問題ないですが、デザイン上消したいということもあるでしょう。そのようなときどのような方法があるでしょうか？
まず、通常コンポーネントで各iOSでの解決法を見ていきます。

解決法|iOS13|iOS14|iOS15
---|---|---|---
Listのままの解決法|なし|なし|.listRowSeparator(.hidden)を使う
それ以外の方法|VStackを使う|LazyVStackを使う|LazyVStackを使う

この表から分かるように、iOS15以降でしか正式な手段は提供されていません。
またそれ以外の方法も`LazyVStack`を使えるiOS14以降はいいですが、iOS13だとただの`VStack`になってしまうため大量の要素を表示する際のパフォーマンス面が気になります。

これらを全てのバージョンについて解決する方法としては、二つの手法を組み合わせるアプローチを取りました。
一個が、基本的に内部でUIKitがレンダリングに使われているiOS13に対応するための[SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect)、もう一つがiOS14のためのちょっとしたハックModifierです。

iOS13については完全に`UITableView`のパラメータをいじらなければ見た目を変えることができないのですが、もちろん外側から普通にやっていじることはできません。そこで**再帰関数を用いて中のUIKitのパーツまでいきいじれるようにするのがSwiftUI-Introspect**です。ライブラリを導入して、`.introspectTableView { tableView in }`というModifierで中の`UITableView`のあれこれをいじることができます。
iOS14は少し厄介です。iOS15のように設定できるものがないのですが、レンダリングはSwiftUI独自のものになっているため、Introspectでいじっても見た目には反映されません。

大人しく`LazyVStack`を使えという話ではあるのですが、iOS13対応をしていると単純にそうもいかないので、`List`のまま解決する方法が[Appleのフォーラム](https://developer.apple.com/forums/thread/651028)に上がっています。
具体的には以下のような`ViewModifier`を用意することになります。

```swift
struct HideRowSeparatorModifier: ViewModifier {
    static let defaultListRowHeight: CGFloat = 44

    var insets: EdgeInsets
    var background: Color

    init(insets: EdgeInsets, background: Color) {
        self.insets = insets

        var alpha: CGFloat = 0
        if #available(iOS 14, *) {
            UIColor(background).getWhite(nil, alpha: &alpha)
            assert(alpha == 1, "Setting background to a non-opaque color will result in separators remaining visible.")
        }
        self.background = background
    }

    func body(content: Content) -> some View {
        content
            .padding(insets)
            .frame(minWidth: 0, maxWidth: .infinity,
                   minHeight: Self.defaultListRowHeight, alignment: .leading)
            .listRowInsets(EdgeInsets())
            .background(background)
    }
}
```

これは何をやっているのかを図解してみたものがこちらです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10943/7ba4262e-628c-d98c-84d3-d4111e68bb56.png)

SwiftUIの`List`のレンダリングの要素としては、中のコンテンツ、そしてListRowというものがあると考えられます（完全に推測です）
さまざまなModifierはこのListRowに対しての設定がされていると見ていいでしょう。セパレーターも同様です。そして全般的に重なり順としてはコンテンツが上に来るようになっています。
そこで`.listRowInsets`を0にしてあげると**コンテンツが目一杯に広がり、透過されていない限りセパレーターを隠してくれます**。
唯一解決できないのは全体の一番上についているセパレーターです。下に引っ張らないと出てこない部分ではあるのですが、こちらは色々調整してみたものの消すことはできませんでした。

この工夫により無事セパレーターを全バージョン`List`のまま消すことが叶ったのですが、いかんせんこのModifierの中で余白などを指定することになるのでレイアウトの調整が煩雑になります。iOS13を切れるならiOS13を切って、`LazyVStack`を使う方が幸せになれると思います。

## スクロール量を検知する

続いてはスクロール量を検知する際の話です。基本的にSwiftUIでこのような座標などをとる操作をするときには`GeometryReader`というものを使うのですが、これに関してiOS13とiOS14以降で少し挙動が変わるため注意が必要でしたという話です。

いくつかの記事ではスクロール量をとる際に一つの`GeometryReader`を使ってスクロール量を直接検知しようとするコードがあるのですが、ここに少し罠があります。
何かというと、以下のようにスクロール量として取れる`offset`の基準がiOSバージョンによって変わってしまうのです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10943/868f4cc9-451d-f018-9008-9334cc29a1f9.png)

特にLarge Titleがある画面だと注意が必要になります。そこで[こちらの記事](https://medium.com/@maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec)のように二個の`GeometryReader`を使って差分を取ることでこのバージョン差を無くすという解決法に至りました。

```swift
struct TrackableScrollView<Content: View>: View {
    private let axes: Axis.Set
    private let showIndicators: Bool
    private let content: Content
    private let onChangeOffset: (CGFloat) -> Void

    init(
        _ axes: Axis.Set = .vertical,
        showIndicators: Bool = true,
        onChangeOffset: @escaping (CGFloat) -> Void,
        @ViewBuilder _ content: () -> Content
    ) {
        self.axes = axes
        self.showIndicators = showIndicators
        self.onChangeOffset = onChangeOffset
        self.content = content()
    }

    var body: some View {
        GeometryReader { outsideProxy in
            ScrollView(axes, showsIndicators: showIndicators) {
                content
                    .background(GeometryReader { insideProxy in
                        Color.clear.preference(
                            key: ScrollViewOffsetKey.self,
                            value: calculateContentOffset(from: outsideProxy, insideProxy: insideProxy)
                        )
                    })
                    .onPreferenceChange(ScrollViewOffsetKey.self) {
                        onChangeOffset($0)
                    }
            }
        }
    }

    private func calculateContentOffset(from outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
        if axes == .vertical {
            return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
        } else {
            return outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX
        }
    }
}

private struct ScrollViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue: Value = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}
```

ただしこれを`List`にも応用したいというときは注意が必要です。これはあくまでスクロールする中のコンテンツに透明な背景色をつけ、その要素の座標をとっているだけです。そのため、`List`のようなものの場合一番上にくる要素にこの座標取得用の背景をつけるようにしないと正しい座標を取れなくなってしまいます。コンポーネントとして切り出すのはやや難しい対応になってしまうので、その場合は個別実装することになるでしょう。

## NavigationBarのあれこれ

SwiftUIを`UIHostingController`で使う際、`NavigationBar`の扱いは少しややこしくなります。
UIKitかSwiftUIのどちらかによっていれば起きにくいことではあるのですが、以下の問題がありました。

1. `NavigationBar`を隠す設定が`UIHostingController`のデフォルト挙動で上書きされてしまう問題
1. `ScrollView`/`List`が厳密に最背面にいないとLarge Titleなどスクロールによって挙動が変わる機能が正常に働かない問題

一個ずつ見ていきます。

### NavigationBarを隠す設定がUIHostingControllerのデフォルト挙動で上書きされてしまう問題

何らかの理由で`NavigationBar`を隠したい時、SwiftUI + `UIHostingController`だとUIKit側のライフサイクルで設定しても反映されないという現象があります。iOS14まではこのワークアラウンドとして、SwiftUI側で`.navigationBarHidden(true)`をするという解決法がよく言われていましたがこれがiOS15から効かなくなりました。
これに関してよくよく探っていくと、どうやら`UIHostingController`のライフサイクルにNavigationBarを表示するような動作が入っており、その実行が親の`UIViewController`のライフサイクルの後になってしまうためにうまくいってないようでした。

逆に言えば、`UIHostingController`のライフサイクルのタイミングで諸々の設定ができれば良さそうです。そのためにこのようなクラスを用意することで解決することができました。

```swift
class CustomHostingController<Content>: UIHostingController<AnyView> where Content: View {
    private var onViewWillAppear: (() -> Void)?
    private var onViewWillDisapper: (() -> Void)?

    public init(onViewWillAppear: (() -> Void)?, onViewWillDisapper: (() -> Void)?, rootView: Content) {
        self.onViewWillAppear = onViewWillAppear
        self.onViewWillDisapper = onViewWillDisapper
        super.init(rootView: AnyView(rootView))
    }

    @available(*, unavailable)
    @MainActor @objc dynamic required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        onViewWillAppear?()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        onViewWillDisapper?()
    }
}
```

この`CustomHostingController`は`viewWillAppear`と`viewWillDisappear`で実行してほしい内容を渡すことができるため上書きされずに対処が可能です。
もちろん他のライフサイクルでも何か実行したいことがあれば拡張することもできるでしょう。中でどのようなデフォルト動作が組まれているかは謎ですが、もし何かうまく設定できないということがあればこちらを試してみるのもおすすめです。

### ScrollView/Listが厳密に最背面にいないとLarge Titleなどスクロールによって挙動が変わる機能が正常に働かない問題

こちらの問題は実はUIKitにも存在しているものです。**スクロールする要素が最背面かつセーフエリアなど画面一番上まで領域が広がっていないと、Large Titleがスクロールに合わせてしまわれなかったり、iOS15だとスクロールしてもNavigationBarの背景色がつかずに透明なままになったりします**。

UIKit + SwiftUIではこのためにいくつかチェックする必要のある項目がありました。

- SwiftUIをアタッチするViewの後ろに別のViewがないか
- `ScrollView`/`List`が画面上部まで広がっているか？また`ZStack`などで後ろに他の要素がある状態になっていないか
- `ScrollView`/`List`に`background`がついていないか

特に最後のポイントが少し厄介です。この問題でいう重ね順には`background`でつけた要素も別個のものとしてカウントされてしまいます。そのためもし背景色をつけたい場合は、`UIHostingController().view.backgroundColor`でUIKit側から設定するようにしなければなりません。


## 実は存在した、iPhone世代間の挙動の差異

他にもさまざまあるのですがあまりにも長くなり過ぎてしまうのでこれで最後のトピックにしようと思います。最後に取り上げるのはiPhoneの世代間の差異です。iOSのバージョン違いやiPhoneのサイズの違い、あるいはセーフエリアの有無とかいう話ではありません。
一番顕著だったところで言うと、iPhone11までとiPhone12以降でいくつかの挙動が変わっていたのでそれを最後に紹介したいと思います。

今回発見しているのは以下の二点です。ただ、これがある以上注意深く検証すれば他にもあるかもしれません。

- `Picker`の標準の大きさが違う
- `Text`のトランケーションされる基準が違う

この二つはほとんど同じ原因の問題とも考えられます。実はiPhone12以降、若干標準コンポーネントの大きさが大きくなっている場合があるのです。（少なくともそう考えるしかないようなバグがちらほらありました）
そのため、`Picker`に関しては`frame`で大きさを想定通りのものに調整できるようにし、`Text`は発見するたびに`fixedSize(horizontal: false, vertical: true)`をつけていく対応が発生しました。

Dynamic Typeを扱ったりもしていたので完全に推測通りの原因とは言い切れませんが、少し検証時に注意が必要なのかもしれません。

## まとめ

以上、SwiftUIを実際に実プロダクトに使ったときに見つかった少し変わった注意点をいくつかご紹介してみました。
個人的な肌感としては、サポートはなるべくiOS15以上にできるアプリで、なおかつSwiftUIをメインに、一部`UIViewRepresentable`で対応するというのが実プロダクトでSwiftUIをストレスなく使う条件になってくると思いました。ただまだまだ足りない機能や`onAppear`の挙動が不安定などの問題もあるのでうまく検証しながら向き合っていきたいですね。