LoginSignup
17
11

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-04

swiftui_advent_2021.png

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

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

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

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

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

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

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

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

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

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

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

大人しくLazyVStackを使えという話ではあるのですが、iOS13対応をしていると単純にそうもいかないので、Listのまま解決する方法がAppleのフォーラムに上がっています。
具体的には以下のようなViewModifierを用意することになります。

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

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

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

スクロール量を検知する

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

いくつかの記事ではスクロール量をとる際に一つのGeometryReaderを使ってスクロール量を直接検知しようとするコードがあるのですが、ここに少し罠があります。
何かというと、以下のようにスクロール量として取れるoffsetの基準がiOSバージョンによって変わってしまうのです。
image.png

特にLarge Titleがある画面だと注意が必要になります。そこでこちらの記事のように二個のGeometryReaderを使って差分を取ることでこのバージョン差を無くすという解決法に至りました。

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のデフォルト挙動で上書きされてしまう問題
  2. ScrollView/Listが厳密に最背面にいないとLarge Titleなどスクロールによって挙動が変わる機能が正常に働かない問題

一個ずつ見ていきます。

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

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

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

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?()
    }
}

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

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

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

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

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

特に最後のポイントが少し厄介です。この問題でいう重ね順には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の挙動が不安定などの問題もあるのでうまく検証しながら向き合っていきたいですね。


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

17
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
11