42
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

iOSAdvent Calendar 2021

Day 13

SwiftUIのPull to Refresh実現方法ケース別まとめ

Last updated at Posted at 2021-12-12

最近iOS開発に復帰した @ruwatana です。iOS Advent Calendar 2021 の13日目を担当させていただきます :bow:

SwiftUIでPull to Refreshを実現するには?

SwiftUI自体が提供されてからの歴は非常に浅く、UIKitで実現できていた簡単な機能をSwiftUI上で実現することが難しいケースも少なくありません。

Pull to Refreshの純正APIは、SwiftUI 3.0(iOS 15以降)でようやくサポートされました。
しかし、詳しくは後述しますがまだまだ制約が多い現状です。
そこで、2021年末時点のSwiftUIにおけるPull to Refreshの実現方法とどれを使うべきかのベストプラクティスについてまとめてみたいと思います。

Case1: Apple純正APIを使う

SwiftUI 3.0 (iOS 15以降) にて、SwiftUIユーザが待ちに待ったPull to Refreshの機能がついに提供されました。
この方法が実現方法としては、もちろん最もシンプルな方法となります。

List構造体に対してrefreshable(action:)というmodifierが提供されています。
refreshable(action:) | Apple Developer Documentation

func refreshable(action: @escaping () async -> Void) -> some View

このmodifierを指定するだけでPull to Refreshの実現が可能です。
引数のactionはasyncのクロージャとなっていますが、ここに非同期処理を記述することで、その完了時にPull to Refreshのインジケータが隠れます。

さて、雑なサンプルを作ってみます。
10個のセルを持つListにrefreshableモディファイアを追加し、actionには1秒スリープするという処理を記述します。

struct ContentView: View {
    var body: some View {
        List {
            ForEach(0...10, id: \.self) { index in
                Text("\(index)")
                    .padding()
            }
        }
        .refreshable {
            await Task.sleep(1000000000)
        }
    }
}

動作としては、こんな感じになります。
スクロールに追従してインジケータがインタラクティブに徐々に描画されていき、閾値(全部のインジケータが描画されるまで引っ張る)を越えればインジケータが表示され続け、引数の非同期処理が終わり次第、インジケータが縮小していきながら隠れます。
UIKitのUIRefreshControlそのものの動きで非常にインタラクティブでいいですね :sparkles:

Simulator Screen Recording - iPhone 13 - 2021-12-11 at 16.08.04.gif

もうもちろんお気づきだとは思いますが、この純正APIを使おうとした時に以下の問題が生じます :scream:

  • ScrollViewでは利用することができない
  • iOS14以下をサポートしている場合、iOS15でしかこの機能を利用することができない

Case2: ScrollViewをListでラップして純正APIを使う

先程の1つ目の問題であるScrollViewでは利用することができない問題を解消します。
とっても雑なやり方ですが、Listの中にScrollViewをラップし、refreshableを実装してあげるというやり方です。
Listと同じようにrefreshableを指定するだけで実現できるようにしてあげたいので、ViewModifierを作ってそれをScrollViewから利用できるようにします。
(試してはいないですが、ScrollView以外のViewに適用することももしかしたら可能かもしれません)

struct RefreshableModifier: ViewModifier {
    let action: @Sendable () async -> Void
    
    func body(content: Content) -> some View {
        List {
            content
        }
        .refreshable(action: action)
    }
}

extension ScrollView {
    func refreshable(action: @escaping @Sendable () async -> Void) -> some View {
        modifier(RefreshableModifier(action: action))
    }
}

これで準備が完了したので、先程のサンプルコードのListをScrollViewに書き直してみます。

struct ContentView: View {
    var body: some View {
        ScrollView { // List -> ScrollView
            ForEach(0...10, id: \.self) { index in
                Text("\(index)")
                    .padding()
            }
        }
        .refreshable {
            await Task.sleep(1000000000)
        }
    }
}

同じようにScrollViewでもPull to Refreshを実現することができました :clap:
が、refreshableモディファイア未指定のときと大きくUIが変わってしまいました・・・

refreshableなし refreshableあり

これは、SwiftUI 3.0(iOS 15)のListのデフォルト仕様によるものでScrollViewとは以下のような違いがあります。

項目 ScrollView List
周囲マージン なし デフォルトのGroupedListStyleによってコンテンツ周囲にマージン生成
コンテンツマージン なし listRowInsetsのデフォルトによって数pxずつマージン生成
horizontal alignment center leading
Separator なし コンテンツごとに下部にSeparatorが設定

そのため、作成したカスタムモディファイアの構造体に下記のような設定も追加する必要があります。

struct RefreshableModifier: ViewModifier {
    let action: @Sendable () async -> Void
    
    func body(content: Content) -> some View {
        List {
            HStack { // HStack + Spacerで中央揃え
                Spacer()
                content
                Spacer()
            }
            .listRowSeparator(.hidden) // 罫線非表示
            .listRowInsets(EdgeInsets()) // Insetsを0に
        }
        .refreshable(action: action)
        .listStyle(PlainListStyle()) // ListStyleの変更
    }
}

これでScrollViewのUIを保ったままListでラップすることができました :clap:
Simulator Screen Recording - iPhone 13 - 2021-12-11 at 17.30.49.gif

ScrollViewをListでラップしている分、複雑なUIを組むことで想定の動作をしてくれなくなる可能性はあるので、この方法の利用には注意が必要です。
(一番いいのはScrollViewにもrefreshableを提供してくれればよかったんですけどね、なんでListだけなんだ・・・:sob:

Case3: UIScrollViewをSwiftUIにラップして使う

さて、これで晴れてiOS 15環境ではPull to Refreshを実現することができるようになったわけですが、iOS 14以下では利用することができない問題があるのでこれを解消していきます。

SwiftUI純正のScrollViewの代わりに、UIScrollViewとそのプロパティのUIRefreshControlによるUIKitベースのコンポーネントをSwiftUIラップしたScrollViewを自作しPull to Refreshを実現する方法があります。
これは、UIKitベースなのでiOS 13や14でも使うことができる方法となります。

UIKitのコンポーネントをSwiftUIにラップする方法として、UIViewRepresentableやUIViewControllerRepresentableに準拠した構造体を作る方法が提供されています。
今回は、UIViewControllerRepresentableを使って実現しています(UIScrollViewのラッパーならUIViewRepresentableでいいじゃんと思うかもしれませんが、そちらではなぜか再描画時のコンテンツが正しく更新されない不具合があったため、UIViewControllerRepresentableにて実現しています)。

まずは、UIScrollViewとそのプロパティにセットするUIRefreshControl、さらにSwiftUIのコンテンツの入れ物としてUIHostingControllerを持ったUIViewControllerクラスを作っていきます。
(長いですが、コード内に解説を入れます)

final class ScrollViewController<Content: View>: UIViewController {
    private lazy var scrollView: UIScrollView = {
        let view = UIScrollView()
        view.alwaysBounceVertical = true
        view.refreshControl = refreshControl
        return view
    }()
    private lazy var refreshControl: UIRefreshControl = {
        let control = UIRefreshControl()
        control.addTarget(
            self,
            action: #selector(onValueChanged(sender:)),
            for: .valueChanged
        )
        return control
    }()
    private lazy var hostingController: UIHostingController<Content> = UIHostingController(rootView: content())
    
    typealias OnRefresh = (_ done: @escaping () -> Void) -> Void
    private let content: () -> Content
    private let onRefresh: OnRefresh
    
    init(content: @escaping () -> Content, onRefresh: @escaping OnRefresh) {
        self.content = content
        self.onRefresh = onRefresh
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // refreshControlを持ったUIScrollViewを画面いっぱいに貼る
        view.addSubview(scrollView)
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        view.addConstraints([
            view.topAnchor.constraint(equalTo: scrollView.topAnchor),
            view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
            view.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
            view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
        ])
        
        // UIScrollViewの中にSwiftUIのコンテンツをいっぱいに貼る
        scrollView.addSubview(hostingController.view)
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addConstraints([
            scrollView.topAnchor.constraint(equalTo: hostingController.view.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: hostingController.view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: hostingController.view.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: hostingController.view.bottomAnchor),
            scrollView.widthAnchor.constraint(equalTo: hostingController.view.widthAnchor)
        ])
    }
    
    func update() {
        hostingController.rootView = content()
        
        // 描画更新時に適切に制約が変わるように明示的に呼ぶ
        hostingController.view.setNeedsUpdateConstraints()
    }
    
    @objc private func onValueChanged(sender: UIRefreshControl) {
        // 非同期処理を呼び出し、Refresh終了時にUIRefreshControlを終了させる
        onRefresh(sender.endRefreshing)
    }
}

あとは、この入れ物をSwiftUIとして使えるようにUIViewControllerRepresentableに準拠した構造体を作るだけです。
今回は、RefreshableScrollViewという名前にしました。

struct RefreshableScrollView<Content: View>: UIViewControllerRepresentable {
    typealias OnRefresh = (_ done: @escaping () -> Void) -> Void
    
    private let content: () -> Content
    private let onRefresh: OnRefresh
    
    init(
        @ViewBuilder content: @escaping () -> Content,
        onRefresh: @escaping OnRefresh
    ) {
        self.content = content
        self.onRefresh = onRefresh
    }
    
    func makeUIViewController(context: Context) -> ScrollViewController<Content> {
        ScrollViewController(content: content, onRefresh: onRefresh)
    }
    
    func updateUIViewController(_ viewController: ScrollViewController<Content>, context: Context) {
        viewController.update()
    }
}

SwiftUIでの使用時はこのような形で実装するイメージです。
純正の使い勝手と比べても大差ないI/Fとなっています。
純正APIはasyncを使っていますが、iOS 14以下に対応するためクロージャの中で完了用のクロージャ (done) を呼ぶというインターフェイスを採用しています。

struct ContentView: View {
    var body: some View {
        RefreshableScrollView {
            ForEach(0...10, id: \.self) { index in
                Text("\(index)")
                    .padding()
            }
        } onRefresh: { done in
            // 非同期処理の完了時に引数のクロージャ(done)を実行する
            DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), execute: done)
        }
    }
}

動作としても、UIKitにおけるUIRefreshControlの使用感で使うことができるため、インタラクティブで良いです :ok_woman:
ただしこの方法も、少し注意が必要ではあります。

:warning: 現在わかっているものとして、iOS 14系(おそらく13も)にてRefreshableScrollViewの中にNavigationLinkを仕込んでも正しく動作してくれないバグがあります。
このバグ自体はGroupなどで囲んで定義を外に出してあげれば良いので回避可能ではあります。

自分の環境では、それ以外は問題なく動作してはいるものの、UIViewRepresentableでは何故か動作せずUIViewControllerRepresentableで実現しているなどの不安定さも含め、自作のSwiftUIコンポーネントはこうした予期せぬ不具合(特にOSごとに挙動が異なるのがSwiftUI特有)が起きることを念頭に入れながら、使っていく必要があるかと思います。

Case4: Introspectを使ってSwiftUIのListからUITableViewを取り出す

Case3ではPull to Refresh可能なScrollViewコンポーネントを自作しましたが、Listへは適用ができず、iOS14以下のListではPull to Refreshを動作させることができません。
このケースでは、iOS 14以下のListにも適用できる方法を紹介します(iOS 15上でも動作します)。

Introspect というSwiftUIの純正コンポーネントからUIKitのクラスを取り出してくれる便利なライブラリがあります。
このライブラリの内部ではUIViewの階層を再帰的に探査することによってそれを実現しており、今回はその再起探査メソッドのみを用いるアプローチとなります。
GitHub - siteline/SwiftUI-Introspect: Introspect underlying UIKit components from SwiftUI

※READMEより引用
image.png

これを使うことで、SwiftUIのListからUITableViewのインスタンスを検出し、それに対してUIRefreshControlを追加することが可能です。

実は、この方法を用いた SwiftUIRefresh というOSSが公開されていますが、更新が2年前となっており現在のiOS 14/15ではPull to Refreshの終了の処理が呼ばれてもインジケータがぐるぐる回ったままとなり動作しませんでした。
GitHub - siteline/SwiftUIRefresh: Pull To Refresh for SwiftUI lists

そこで、今回はこのライブラリの実装を参考に、現在の環境でも動作するように書き換えを行なっていきたいと思います。

まずは、PullToRefreshというUIViewRepresentableに準拠したUIコンポーネントを作ります。
長いのでコード内に解説を入れます。

struct PullToRefresh: UIViewRepresentable {
    // この型はCase3と同様
    typealias OnRefresh = (_ done: @escaping () -> Void) -> Void
    let onRefresh: OnRefresh
    
    class Coordinator {
        private let onRefresh: OnRefresh
        
        init(onRefresh: @escaping OnRefresh) {
            self.onRefresh = onRefresh
        }
        
        // UIControl.addTargetにてselectorを指定するためにCoordinatorというclassオブジェクトで制御する
        @objc func onValueChanged(sender: UIRefreshControl) {
            onRefresh(sender.endRefreshing)
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(onRefresh: onRefresh)
    }
    
    func makeUIView(context: UIViewRepresentableContext<PullToRefresh>) -> UIView {
        // ダミーのUIViewを作成
        let view = UIView(frame: .zero)
        view.isHidden = true
        view.isUserInteractionEnabled = false
        return view
    }

    func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PullToRefresh>) {
        // Introspectでの検出が非同期実行でないと取れない
        DispatchQueue.main.async {
            if let scrollView = self.scrollView(entry: uiView),
               scrollView.refreshControl == nil {
                // 一度だけ呼ばれてセットされる
                let refreshControl = UIRefreshControl()
                refreshControl.addTarget(context.coordinator, action: #selector(Coordinator.onValueChanged), for: .valueChanged)
                scrollView.refreshControl = refreshControl
            }
        }
    }
    
    /// IntrospectでダミーのUIViewの先祖を再帰的に探査してUIScrollViewのインスタンスを検出する
    private func scrollView(entry: UIView) -> UIScrollView? {
        if let scrollView = Introspect.findAncestor(ofType: UIScrollView.self, from: entry) {
            return scrollView
        }

        guard let viewHost = Introspect.findViewHost(from: entry) else {
            return nil
        }

        return Introspect.previousSibling(containing: UIScrollView.self, from: viewHost)
    }
}

あとは、これをSwiftUIのモディファイアとして呼び出してoverlayによってSwiftUI Contentの上に貼ります。
frameゼロのダミーのコンポーネントをオーバーレイすることで親階層のUIViewたちを検出できるようにしているアイデアはなるほどと思いました。

// ScrollViewではUIScrollViewを検出できず動作しないためListのextensionとしている
extension List {
    func refreshable(onRefresh: @escaping OnRefresh) -> some View {
        overlay(
            PullToRefresh(onRefresh: onRefresh)
                .frame(width: 0, height: 0)
        )
    }
}

これで、iOS 14以下のListにおいても、Pull to Refreshを動作させることができました :tada:

あれ?SwiftUIのScrollViewからもUIScrollViewのインスタンスを検出できるのでは?と考える人もいるかもしれません。
しかし、やってみるとわかりますがこのOSSの実装方法では動作できませんでした。
(IntrospectでのUIScrollViewの検出方法や実際のView Hierarchyを参考にすれば実現できそうな気はしたので時間ができたら調べてみます)

ちなみに、モディファイアの実装を下記のようにすることで、iOS 15では純正APIを使うように分岐をさせることも可能です。
iOS 14以下をサポートしている環境では、async / awaitも使えないので純正APIのインターフェースをUnsafeContinuationを使用してクロージャによるインターフェースへ変換するというギミックを使って実現しています。

extension List {
    @ViewBuilder
    func refreshable(onRefresh: @escaping OnRefresh) -> some View {
        if #available(iOS 15.0, *) {
             refreshable {
                 await withUnsafeContinuation { continuation in
                     onRefresh(continuation.resume)
                 }
             }
        } else {
            overlay(
                PullToRefresh(onRefresh: onRefresh)
                    .frame(width: 0, height: 0)
            )
        }
    }
}

このケースのみ、OSS (Introspect) に依存することになります。
ただ、Introspectの中でもView探査のロジックはかなり局所的なロジックなため、参考にしつつ自前のロジックを組んでしまうというのでも良いかなと思います。

番外編: UIActivityIndicatorViewをSwiftUIにラップして使う

:warning: このアプローチは問題があるため、個人的にはお勧めしないので番外編とさせていただきます。

Pull to RefreshのUIパーツは現在SwiftUIには提供されていないため、UIKitのUIActivityIndicatorViewをSwiftUI用にラップし、スクロール検知などをSwiftUIで実装する方法です。
アプローチの方法としては、むしろ王道といえるため、Web上でもよくこのやり方が紹介されています。

例えば、この swiftui-pull-to-refresh というOSSがあります。
GitHub - globulus/swiftui-pull-to-refresh: Pull to refresh functionality for any ScrollView in SwiftUI!

※READMEより引用

GeometryReaderやPreferenceKeyを駆使してスクロール量を検知するロジックは非常に参考になります。
(こういうことをSwiftUIでもやることができるんだなという発見や学びがあったので興味のある方は読んでみてください)

しかし、使ってみると分かるのですが以下の問題があります。

  • UIRefreshControlと異なり、スクロール位置に追従したインジケータの段階的描画がされず閾値がインタラクティブにはわからない
    • Pull to Refresh終了時も縮小しながらインジケータが消えていくインタラクティブな動きがない
  • 閾値を超えて手を離すと、一瞬謎にバウンドする(iOS 15で再現)

この辺がイマイチな点ですね。
もちろん段階的描画は、実装次第で実現ができるかもしれませんが、自前のインジケータのUIパーツを用意するなど手の込んだ実装が必要となりそうで現実的ではなさそうです。
謎にバウンドする不具合は、おそらくスクロールのオフセット調整とUIスレッドのSwiftUIの再描画処理が瞬間的にかち合って一瞬表示崩れを起こしているような挙動にも見れたので、この実装方法自体に限界があるのかもしれません。

まとめ

さいごに、SwiftUIプロジェクトの性質によってどれを選択するべきかをまとめます。

OSサポート Pull to Refreshを・・・ 実現方法
iOS 15以降のみ Listで使いたい Case1: Apple純正のAPI refreshable(action:) を使う
iOS 15以降のみ ScrollViewで使いたい Case2: ScrollViewをListでラップして純正APIを使う

Case3: UIScrollViewをSwiftUIにラップして使う
iOS 15未満もサポート Listで使いたい iOS 15以降では、
Case1: Apple純正のAPI refreshable(action:) を使う

iOS 14未満では、
Case4: Introspectを使ってSwiftUIのListからUITableViewを取り出す
iOS 15未満もサポート ScrollViewで使いたい Case3: UIScrollViewをSwiftUIにラップして使う

これをみても分かる通り、ケースに応じて様々なワークアラウンドを用意して実現する必要があることがわかると思います。
まだまだSwiftUIの柔軟で容易なAPIのサポートは薄いなという印象です。
早く、UIKit並みに純正APIが充実することを願っています :pray:

今回の記事は以上です。
お読みいただきありがとうございました :bow:

42
17
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
42
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?