1. Home
  2. テクノロジー
  3. 【SwiftUI】iOS 16のスワイプリフレッシュ不具合:ネストしたScrollViewでの挙動と対処法

【SwiftUI】iOS 16のスワイプリフレッシュ不具合:ネストしたScrollViewでの挙動と対処法

はじめに

「ニフティ不動産」アプリのiOS開発を担当している、ryu-kobayashiです。

先日、弊社から提供している「ニフティ不動産アプリ 賃貸版」にてiOS 15のサポートを終了しました。これによってSwiftUI の ScrollView の実装方針に変化があったため、その知見を共有したいと思います。

iOS 15の ScrollView の悩み

iOS 15では、ScrollViewrefreshable を適用しても不具合により機能せず、スワイプリフレッシュ機能が実現できませんでした。そのため、これまでは以下のような代替手段で実装する必要がありました。

List で実装する

  • Listpadding・背景色・区切り線を打ち消す処理が必要
  • 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サポート終了により、ScrollViewrefreshable が使用可能になりました! これにより、List のスタイルを打ち消すような冗長なコードが不要になり、実装が非常にスッキリしました。

ScrollView {
    Text("タイトル")
        .font(.largeTitle.bold())

    Text("テキスト")
        .font(.system(size: 16, weight: .regular))
}
.refreshable {
    refreshControlValueChanged()
}

iOS 16 の ScrollView の問題点

ScrollViewrefreshable が使用可能になり便利になった一方で、 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バージョンだけで発生する挙動」も存在するため、実機での検証は欠かせません。

一見地味なバグ修正ですが、こうした知見(ナレッジ)の一つひとつがチームの技術資産になります。 ニフティライフスタイルは、個人の気づきをチーム全体の力に変え、「より高品質なアプリを、より効率よく」開発できる組織を目指しています。 ユーザーのために技術を磨き、チームで成長していきたいエンジニアの方、ぜひ一度お話ししませんか?

https://niftylifestyle.co.jp/careers

この記事をシェア

掲載内容は、記事執筆時点の情報をもとにしています。