はじめまして、普段iOSアプリを開発している澁谷太智です。
最近、メイドコアというジャンルの音楽にハマっています。
もともとインスト楽曲が好きで、色々掘り下げて行った結果辿り着きました。
ニッチかつ成り立ちが大クセなので、気になる方は検索してみてください。
さて、SwiftUIを触り始めて2、3ヶ月経ちました。
現在、UIKitの画面をSwiftUIで作り替える作業を行なっています。
その作業の中で
refreshable(action:) を用いて画面更新処理を実装していました。
しかし、なぜか iOS 15 でだけ ScrollView を引っ張っても
refreshable の
action が実行されませんでした。
そこで、iOS 15 でも画面更新ができる ScrollView を作成したのでご紹介します。
目次
iOS 15 で動かない件
簡単なリストを作成して、動作を見てみましょう
- ScrollView と LazyVStack でリストを作成
- ScrollView で refreshable(action: ) を利用して更新処理を記述
上記コードを実行した結果が、表の動画になります。
iOS 17では、Viewを引っ張った際に3秒間ローディング表示が出ます。
それに対し、iOS 15では何の表示も出ません。
またiOS 15では、ブレイクポイントを
refreshable のクロージャ内に張っても引っかかりませんでした。
公式ドキュメントでは、iOS 15以上で
refreshable modifier を利用できるとのことですが、使えないようです。
iOS 15.5 | iOS 17.2 |
---|---|
iOS 15 以上で動く ScrollView を作る
動かないなら、動くものを作るしかない。
ということで、iOS 15以上で動く ScrollView を作成します。
下記の手順で作成していきます。
iOS 16以上でリフレッシュ可能なカスタムScrollViewを作成する
縦スクロール用の ScrollView を作成します。
- 外部から 更新処理を受け取れるようにする
- refreshAction: () async -> Void
- 外部から
LazyVStack 内に表示する View を受け取れるようにする
- content: () -> Content
iOS 15 でもリフレッシュできるように拡張する
スクロールの offset を監視する
スクロールの offset を監視する処理を記述します。
LazyVStackがどの程度スクロールされたかを監視します。
- LazyVStack の .background に GeometryReader を設定する
- 独自の
PreferenceKey、
OffsetPreferenceKey を作成する
- reduce() :常に値が更新されるように、 value += nextValue() で値を足し込んでいく
-
GeometryReader 内に値取得用の透明な View を作成し、
OffsetPreferenceKey を設定する
- Color.clear.preference(key:,value:)
- Color.clear:画面に影響が出ないようにクリアを指定している
- key: OffsetPreferenceKey を設定する
- value:縦スクロールなので、Y座標が全体でどの位置にあるのかを取得する
- PreferenceKey の変更を ScrollView の .onPreferenceChange() で取得する
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 の差分が、閾値を超えた場合」かつ「更新中でない場合」のみ更新処理を行う
- isRefreshing を withAnimation {} で囲うことで、 isRefreshing によって制御されている ProgressView の表示非表示が滑らかになる
これで、iOS 15 以上で利用できるリフレッシュ可能な ScrollView が完成しました。
挙動を見てみましょう。
完成形
まず、コードを RefreshableVScrollView を用いたものに書き換えます。
動かした様子がこちらになります。
最後に
いかがだったでしょうか。
全く同じ表示とはいかないまでも、だいぶ近い挙動まで近づけて実装することができたのではないかと思っています。
さらに、カスタムScrollView内にOSバージョンの分岐処理を隠蔽することで、利用時にOSバージョンの分岐を意識する必要がなくなり、利便性を損なうことなく実装できたのではないかと思います。
ローディング表示が出る秒数調整や、
LazyVStack の
spacing を外部から指定できるようにする、などカスタマイズしてご利用頂けると幸いです。
最後までご覧頂きありがとうございました!
付録
RefreshableVScrollView.swift
import SwiftUI struct RefreshableVScrollView<Content: View>: View { private let refreshAction: () async -> Void private let content: () -> Content init( refreshAction: @escaping () async -> Void, @ViewBuilder _ content: @escaping () -> Content ) { self.refreshAction = refreshAction self.content = content } private let refreshOffsetDiff: CGFloat = 145 @State private var isRefreshing = false @State private var initialOffset: CGFloat? var body: some View { ScrollView { if #unavailable(iOS 16), isRefreshing { ProgressView() .progressViewStyle(.circular) .controlSize(.regular) .frame(height: 44) .transition(.scale) } LazyVStack { content() } .background { if #unavailable(iOS 16) { GeometryReader { Color.clear.preference( key: OffsetPreferenceKey.self, value: $0.frame(in: .global).origin.y ) } } } } .refreshable { guard #available(iOS 16, *) else { return } await refreshAction() } .onPreferenceChange(OffsetPreferenceKey.self) { value in guard #unavailable(iOS 16) else { return } if let initialOffset { let offsetDiff = value - initialOffset if refreshOffsetDiff < offsetDiff, !isRefreshing { Task { withAnimation { isRefreshing = true } await refreshAction() withAnimation { isRefreshing = false } } } } else { initialOffset = value } } } } private struct OffsetPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value += nextValue() } } |
コードに出てきた
ViewState も載せておきます。
RedactionReasons を分かりやすい形に直した enum です。
ViewState.swift
import SwiftUI enum ViewState { case loading case loaded var redactionReasons: RedactionReasons { switch self { case .loading: return .placeholder case .loaded: return [] } } } |
参考
この記事を書いた人
最近書いた記事
- 2024.03.29【SwiftUI】iOS 15でリフレッシュ可能なScrollView を作る - RefreshableScrollView -
- 2024.03.29有線イヤホンをワイヤレスにしてみよう
- 2024.03.08イヤホンプラグの種類って?2024年版
- 2023.10.31【Maker Faire Tokyo 2023】サイリウム×ジェネラティブアートのゲーム「サイリズム」を作ってみた