【SwiftUI】iOS 15でリフレッシュ可能なScrollView を作る - RefreshableScrollView -

iOS, refreshable, ScrollView, Swift, SwiftUI

はじめまして、普段iOSアプリを開発している澁谷太智です。
最近、メイドコアというジャンルの音楽にハマっています。
もともとインスト楽曲が好きで、色々掘り下げて行った結果辿り着きました。
ニッチかつ成り立ちが大クセなので、気になる方は検索してみてください。

さて、SwiftUIを触り始めて2、3ヶ月経ちました。
現在、UIKitの画面をSwiftUIで作り替える作業を行なっています。
その作業の中で refreshable(action:) を用いて画面更新処理を実装していました。
しかし、なぜか iOS 15 でだけ ScrollView を引っ張っても refreshableaction が実行されませんでした。
そこで、iOS 15 でも画面更新ができる ScrollView を作成したのでご紹介します。

目次

  1. iOS 15 で動かない件
  2. iOS 15 以上で動く ScrollView を作る
    1. iOS 16以上でリフレッシュ可能なカスタムScrollViewを作成する
    2. iOS 15 でもリフレッシュできるように拡張する
  3. 完成形
  4. 最後に
  5. 付録
  6. 参考

iOS 15 で動かない件

簡単なリストを作成して、動作を見てみましょう

  • ScrollView と LazyVStack でリストを作成
  • ScrollView で refreshable(action: ) を利用して更新処理を記述

normal_scrollview

上記コードを実行した結果が、表の動画になります。
iOS 17では、Viewを引っ張った際に3秒間ローディング表示が出ます。
それに対し、iOS 15では何の表示も出ません。
またiOS 15では、ブレイクポイントを refreshable のクロージャ内に張っても引っかかりませんでした。
公式ドキュメントでは、iOS 15以上で refreshable modifier を利用できるとのことですが、使えないようです。

iOS 15.5 iOS 17.2
normal_scrollview_ios15 normal_scrollview_ios17

iOS 15 以上で動く ScrollView を作る

動かないなら、動くものを作るしかない。
ということで、iOS 15以上で動く ScrollView を作成します。
下記の手順で作成していきます。

  1. iOS 16以上でリフレッシュ可能なカスタムScrollViewを作成する
  2. iOS 15 でもリフレッシュできるように拡張する
    1. スクロールの offset を監視する
    2. offset の値に応じて、ローディング表示制御を行う

iOS 16以上でリフレッシュ可能なカスタムScrollViewを作成する

縦スクロール用の ScrollView を作成します。

  • 外部から 更新処理を受け取れるようにする
    • refreshAction: () async -> Void
  • 外部から LazyVStack 内に表示する View を受け取れるようにする
    • content: () -> Content

refreshable_v_scrollview

iOS 15 でもリフレッシュできるように拡張する

スクロールの offset を監視する

スクロールの offset を監視する処理を記述します。
LazyVStackがどの程度スクロールされたかを監視します。

  • LazyVStack.backgroundGeometryReader を設定する
  • 独自の PreferenceKeyOffsetPreferenceKey を作成する
    • reduce() :常に値が更新されるように、 value += nextValue() で値を足し込んでいく
  • GeometryReader 内に値取得用の透明な View を作成し、 OffsetPreferenceKey を設定する
    • Color.clear.preference(key:,value:)
    • Color.clear:画面に影響が出ないようにクリアを指定している
    • keyOffsetPreferenceKey を設定する
    • value:縦スクロールなので、Y座標が全体でどの位置にあるのかを取得する
  • PreferenceKey の変更を ScrollView の .onPreferenceChange() で取得する

preference_key

offsetpreferencekey

offset の値に応じて、ローディング表示制御を行う

  • プロパティを3種類用意する
    • @State private var isRefreshing: Bool:更新処理中か否か
    • @State private var initialOffset: CGFloat:offset の初期位置(以降、初期offset)
    • private let refreshOffsetDiff: CGFloat:更新表示を行うか否かのスクロールの閾値
    • 今回は 145 に設定
    • 更新動作の行いやすい値に設定してください
  • 更新表示用の ProgressView を追加する
    • iOS 16以上では表示しないので #unavailable で除外する
    • 更新中か否かに応じて、表示を切り替える
  • onPreferenceChange() のクロージャ内を記述する
    • 初期 offset を設定する
    • 「初期 offset と 現在の offset の差分が、閾値を超えた場合」かつ「更新中でない場合」のみ更新処理を行う
    • isRefreshingwithAnimation {} で囲うことで、 isRefreshing によって制御されている ProgressView の表示非表示が滑らかになる

completion

これで、iOS 15 以上で利用できるリフレッシュ可能な ScrollView が完成しました。
挙動を見てみましょう。

完成形

まず、コードを RefreshableVScrollView を用いたものに書き換えます。

normal_scrollview_to_refreshable

動かした様子がこちらになります。

refreshable_scrollview_ios15

最後に

いかがだったでしょうか。
全く同じ表示とはいかないまでも、だいぶ近い挙動まで近づけて実装することができたのではないかと思っています。
さらに、カスタムScrollView内にOSバージョンの分岐処理を隠蔽することで、利用時にOSバージョンの分岐を意識する必要がなくなり、利便性を損なうことなく実装できたのではないかと思います。
ローディング表示が出る秒数調整や、 LazyVStackspacing を外部から指定できるようにする、などカスタマイズしてご利用頂けると幸いです。
最後までご覧頂きありがとうございました!

付録

RefreshableVScrollView.swift

コードに出てきた ViewState も載せておきます。
RedactionReasons を分かりやすい形に直した enum です。

ViewState.swift

参考