はじめまして、普段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 []
}
}
}
参考
澁谷太智

