【SwiftUI】iOS 16のスワイプリフレッシュ不具合:ネストしたScrollViewでの挙動と対処法
はじめに
「ニフティ不動産」アプリのiOS開発を担当している、ryu-kobayashiです。
先日、弊社から提供している「ニフティ不動産アプリ 賃貸版」にてiOS 15のサポートを終了しました。これによってSwiftUI の ScrollView の実装方針に変化があったため、その知見を共有したいと思います。
iOS 15の ScrollView の悩み
iOS 15では、ScrollView に refreshable を適用しても不具合により機能せず、スワイプリフレッシュ機能が実現できませんでした。そのため、これまでは以下のような代替手段で実装する必要がありました。
List で実装する
Listのpadding・背景色・区切り線を打ち消す処理が必要Listを使用していますが、中身がリストではないので少し違和感がある
List {
Text("テキスト")
.font(.largeTitle.bold())
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
Text("テキスト")
.font(.system(size: 16, weight: .regular))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.refreshable {
refreshControlValueChanged()
}
UINavigationBar にリロードボタンを配置する
UIBarButtonItemを生成する際、iOSのバージョンで分岐させる処理が必要(「ニフティ不動産アプリ 賃貸版」ではUIViewControllerで分岐をさせていました。)
final SampleViewController: UIViewController {
private var barButtonItem: UIBarButtonItem {
let loginAction = makeUIAction(title: "ログイン", symbol: .person) { [weak self] in
self?.didTapLogin()
}
let logoutAction = makeUIAction(title: "ログアウト", symbol: .person) { [weak self] in
self?.didTapLogout()
}
// リロードボタンを配置
let refreshAction: UIAction? = {
if #unavailable(iOS 16) {
let refreshAction = makeUIAction(title: "更新", symbol: .arrowClockwise) { [weak self] in
self?.didTapRefreshButton()
}
return refreshAction
} else {
return nil
}
}()
}
}
開発者からするとメンテナンスコストがかかります。一方で、実装しなければユーザーは「スワイプで更新できない」という不便さを強いられる、というジレンマがありました。
iOS 16 以降の ScrollView
iOS 15サポート終了により、ScrollView で refreshable が使用可能になりました! これにより、List のスタイルを打ち消すような冗長なコードが不要になり、実装が非常にスッキリしました。
ScrollView {
Text("タイトル")
.font(.largeTitle.bold())
Text("テキスト")
.font(.system(size: 16, weight: .regular))
}
.refreshable {
refreshControlValueChanged()
}
iOS 16 の ScrollView の問題点
ScrollView で refreshable が使用可能になり便利になった一方で、 iOS 16.0〜16.3 において、特定の条件下で挙動がおかしくなるバグに遭遇しました。
具体的には、「refreshable をつけた親View(Listなど)の中に、子の ScrollView がネストされている」 という状況です。 この状態で子の ScrollView を操作すると、子の方でもリフレッシュインジケータが表示されてしまったり、スクロール判定が子に奪われてしまうという問題が発生します。
List {
VStack(alignment: .leading, spacing: 16) {
Text("タイトル")
.font(.largeTitle.bold())
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
Text("テキスト")
.font(.system(size: 16, weight: .regular))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
Text("リスト")
.font(.system(size: 18, weight: .bold))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
ScrollView(.horizontal) {
LazyHStack(spacing: 8) {
ForEach(0..<10, id: \.self) { index in
VStack {
Image(systemName: "photo")
.resizable()
.scaledToFit()
.frame(width: 60, height: 60)
}
.frame(width: 80, height: 80)
.background(Color.white)
}
}
}
}
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.padding(.horizontal, 16)
.padding(.top, 16)
.refreshable {
refreshControlValueChanged()
}
意図せずリフレッシュインジケータが表示され、スクロール操作が阻害されるため、ユーザー体験としては非常に良くない挙動になってしまいます。
対処方法
この問題は、子の List もしくは ScrollView に、refreshable を無効にする modifier を付与することで回避できます。
// View+Extension.swift
@ViewBuilder
func disableRefreshIfNeeded() -> some View {
if #available(iOS 16.4, *) {
self
} else {
// swiftlint:disable:next force_cast
self.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
}
List {
VStack(alignment: .leading, spacing: 16) {
Text("タイトル")
.font(.largeTitle.bold())
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
Text("テキスト")
.font(.system(size: 16, weight: .regular))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
Text("リスト")
.font(.system(size: 18, weight: .bold))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
ScrollView(.horizontal) {
LazyHStack(spacing: 8) {
ForEach(0..<10, id: \.self) { index in
VStack {
Image(systemName: "photo")
.resizable()
.scaledToFit()
.frame(width: 60, height: 60)
}
.frame(width: 80, height: 80)
.background(Color.white)
}
}
}
// View の Extension メソッドを追記する
.disableRefreshIfNeeded()
}
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.padding(.horizontal, 16)
.padding(.top, 16)
.refreshable {
refreshControlValueChanged()
}
これにより、子にスクロール判定が奪われてしまう問題が解消され、縦方向・横方向ともにスムーズにスクロールすることが可能になりました。また、こちらはiOS 16.4 以降では修正されています。リリースノート 102052575
まとめ
iOS 15のサポート終了で実装は楽になりましたが、今回のように「特定のOSバージョンだけで発生する挙動」も存在するため、実機での検証は欠かせません。
一見地味なバグ修正ですが、こうした知見(ナレッジ)の一つひとつがチームの技術資産になります。 ニフティライフスタイルは、個人の気づきをチーム全体の力に変え、「より高品質なアプリを、より効率よく」開発できる組織を目指しています。 ユーザーのために技術を磨き、チームで成長していきたいエンジニアの方、ぜひ一度お話ししませんか?
掲載内容は、記事執筆時点の情報をもとにしています。