Open8

SwiftUI・UIKitの細かい知見をまとめたい

このスクラップは?

  • SwiftUIを探究するうちに気付いた・知ったさまざまなことをまとめていく場所です
  • 記事の形式にはせず、まずは雑多にまとめたいという気持ちでまとめていきます
  • 中にはHostingControllerの挙動起因のことも書いてあるかもしれませんが、純粋SwiftUIでの検証をしていないのであくまでその前提で見てください

Listのセパレーターを消す

iOS13での方法

IntrospectをしてTableViewの設定を使う。IntrospectはSwiftUI-Introspectを使う。このライブラリはViewの中身を見に行って再帰的にベースのViewを見つけ出すのでバージョン依存がそこまでない。

List {
}
.introspectTableView { tableView in
  tableView.separatorStyle = .none
}

iOS14での方法

iOS14からはベースとなっているTableViewの設定が描画に影響を与えてくれない。そのためこのスレッドに書かれているコードを使う。簡易的なものは以下。

yourRowContent
  .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
  .frame(
    minWidth: 0, maxWidth: .infinity,
    minHeight: 44,
    alignment: .leading
  )
  .listRowInsets(EdgeInsets())
  .background(Color.white)

なにが起きているか、ポイントとしては

  • listRowInsetsがセパレーターの位置を計算しているらしい
  • それとは別に中身のコンテンツの大きさは制御できる
  • 重なり順としてはコンテンツの方が上にある
  • よってlistRowInsetsを0にし、コンテンツの大きさを引き伸ばし、背景色をつけてあげることで、コンテンツの背面にセパレーターが隠れてくれる
  • ただし隠すのはそのセルの次のセルとの間にあるセパレーターなので、一番上のセパレーターはスクロールすると出てきてしまう

iOS15の方法

List {
}
.listRowSeparator(.hidden)

Pull to refreshする

iOS13,14での方法

IntrospectでScrollViewもしくはTableViewを取得してきて、UIRefreshControlを使う。状態はUIViewRepresentableを作って管理するといい感じ。具体的な方法はSwiftUIRefreshを参照。

注意点

  • ScrollViewのIntrospect方法が実はiOS13と14で変わっている。よって両方対応する場合は少し工夫が必要(添付のコード)
  • SwiftUIの表示領域がSafeArea内である場合、一般にUIRefreshControlが表示されるのがSafeArea外であるため一瞬で消えるバグが起きる。そのため必ずSwiftUIをアタッチするViewは画面全体に広げておくのが大事。
  • また上記がうまくコントロールできてないのと同様の理由でか、ラージタイトルでない場合にScrollViewのUIRefreshControlの表示位置がバグってしまう。これは未解決
private struct PullToRefresh: UIViewRepresentable {
    class Coordinator {
        let onRefresh: () -> Void
        let isShowing: Binding<Bool>

        init(
            onRefresh: @escaping () -> Void,
            isShowing: Binding<Bool>
        ) {
            self.onRefresh = onRefresh
            self.isShowing = isShowing
        }

        @objc
        func onValueChanged() {
            isShowing.wrappedValue = true
            onRefresh()
        }
    }

    @Binding var isShowing: Bool
    let onRefresh: () -> Void

    init(
        isShowing: Binding<Bool>,
        onRefresh: @escaping () -> Void
    ) {
        _isShowing = isShowing
        self.onRefresh = onRefresh
    }

    func makeUIView(context _: UIViewRepresentableContext<PullToRefresh>) -> UIView {
        let view = UIView(frame: .zero)
        view.isHidden = true
        view.isUserInteractionEnabled = false
        return view
    }

    func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PullToRefresh>) {
        DispatchQueue.main.asyncAfter(deadline: .now()) {
            // NOTE: ScrollViewの場合はscrollViewが、Listの場合はtableViewが読み出される
            guard let scrollView: UIScrollView = scrollView(entry: uiView) ?? tableView(entry: uiView) else {
                return
            }

            if let refreshControl = scrollView.refreshControl {
                if self.isShowing {
                    refreshControl.beginRefreshing()
                } else {
                    refreshControl.endRefreshing()
                }
                return
            }

            let refreshControl = UIRefreshControl()
            refreshControl.tintColor = RenewAsset.RenewColors.Semantics.brandPrimary.color
            refreshControl.addTarget(context.coordinator, action: #selector(Coordinator.onValueChanged), for: .valueChanged)
            scrollView.refreshControl = refreshControl
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(onRefresh: onRefresh, isShowing: $isShowing)
    }

    // MARK: Introspectメソッド
    private func scrollView(entry: UIView) -> UIScrollView? {
        if #available(iOS 14, *) {
            return Introspect.siblingOfTypeOrAncestor(from: entry)
        } else {
            return Introspect.siblingContainingOrAncestor(from: entry)
        }
    }

    private func tableView(entry: UIView) -> UITableView? {
        Introspect.ancestorOrSiblingContaining(from: entry)
    }
}

iOS15の方法

List {
}
.refreshable {
  await fetchData()
}

ScrollViewのOffsetをとる

  • 方法としてはGeometryReaderを要素の中と外につけてminX/minYの差分を検知する
  • ZStackまたはbackgroundで透過のものを置いてそこに内側のGeometryReaderをつける
  • 具体的にはこの記事が参考になる

注意点

  • GeometryReaderが一個だとiOS13と14以降で挙動が変わる。具体的にはoffsetの計算基準がiOS14の場合画面全体に対してなのに対し、iOS14以降はScrollViewのスクロールしていない状態を基準としているので、特にLarge TitleやNavigation Barと一緒に使っている場合はその分の高さがiOS13には足されてしまう
  • Listにつけるときも工夫が必要。内側につけようとするとセル一個一個全てについてしまう。そのためindexなどをenumerateなどの手段でとって最初のセルにだけつけるなどの工夫が必要になってくる

Large Titleに追従するUI要素

  • 厳密にはAutoLayoutのハックなのでSwiftUIではないけど...
  • Appleの標準Appが時々やってるやつ
  • 具体的には上方向のスクロールにはScrollViewの上辺に制約をつけると勝手についていってくれる
  • 下方向に引っ張る時はoffset分制約をいじればいい
  • なおこの時ScrollViewが最背面にあることが必須になる

iOS15からのサイレント変更

  • Pickerのデフォルトスタイルが.menuになった
  • Listのデフォルトスタイルが.insetGroupedになった
  • NavigationBarとTabBarのscrollEdgeAppearanceのデフォルト挙動が変わり、ラージタイトルでない場合も透過がデフォルトになった(ただし同じコードベースでiOS14以下だと色がつく)

NavigationBar周りで注意したいこと

isTranslucent, edgesForExtendedLayoutのバグ

  • Xcode13 + iOS15だけで確認
  • isTranslucent = falseもしくはedgesForExtendedLayout = []を使うとNavigationBarが黒くなる
  • 対処法としてはscrollEdgeAppearanceで透過しないようにする必要がある。そのためNavigationBar全体に影響するisTranslucent = falseの方法はおすすめしない

Large Titleとスクロールを正しく同期するために

  • SwiftUIで必ずScrollView / Listが最背面に来ている必要がある
  • ちなみに最背面にするためには.backgroundで背景をつけることも不可(渡したViewが最背面になるため)
  • 背景色をつけるにはHostingController.view.backgroundColorをいじる必要があるため、純正SwiftUIなら大丈夫かも。

Large Titleを隠すとき

  • Large Titleを有効にした画面から次の画面で無効にしたい場合prefresLargeTitle = falseではなく、largeTitleDisplayMode = .neverで設定しないとアニメーションがバグる

HostingControllerを使っている際のNavigationBarを隠す設定

  • HostingControllerを使う場合、HostingControllerのライフサイクルが最優先される
  • そのため親になっているViewControllerでナビゲーション周りの設定などしても反映されないことがある
  • HostingControllerを継承した新しいクラスでviewWillAppearなどをoverrideし、イベントを渡せるようにすることでViewControllerのコード内で完結できる
  • なおiOS14以前でワークアラウンドとして知られていたSwiftUIの方で設定するという方法はiOS15から無効になった

まさかのデバイス間差異

  • Pickerのデフォルトの幅はiPhone12以降とiPhone11以前で変わっている(iOSではなく、iPhone)
  • ほとんどの場合気が付かないが、自家製のInsetGroupなどにPickerを埋め込む場合は注意が必要。
  • 解決法としてはデバイスの画面幅を取ってきて補正するしかない
作成者以外のコメントは許可されていません