スクロールインジケータを点滅させる(iOS 17、互換実装)

本記事は beta 版ソフトウェアの内容を含んでいるため、将来的に挙動が変更される可能性があります。

iOS 17 では ScrollView のインジケータを点滅させる2つの API が追加されました。

前者は表示時、後者は特定の値が変化したときに点滅させるモディファイアです。

以下は簡単な利用例です。

struct ContentView: View {
    @State var items: [Int] = (0..<10).map { $0 }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack {
                    ForEach(items, id: \.self) { index in
                        Color.red.opacity(0.3)
                            .frame(height: 120)
                            .border(.red)
                            .padding(.horizontal)
                            .overlay {
                                Text("\(index)")
                            }
                    }
                }
            }
            .scrollIndicatorsFlash(onAppear: true) // ⚡️ 表示時
            .scrollIndicatorsFlash(trigger: items) // ⚡️ `items`が変化したとき
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Add") {
                        numbers.append(numbers.count)
                    }
                }
            }
        }
    }
}

このコード例では表示されたタイミングと、右上の「Add」ボタンから要素が追加されたときに、スクロールインジケータが点滅されます。


互換 API を実装する

これは非常に良い進化だと思いますが、例によって iOS 17+ な API なので、今回も互換 API を実装してみたいと思います。

UIScrollView をラップする方法もありますが、今回は 以前の記事 と同様に SwiftUI-Introspect を使って ScrollView の裏側にある UIScrollView にアクセスする方法で実装してみます。

UIScrollView 取得用の共通関数

まず、SwiftUI-Introspect で UIScrollView を取得する共通関数を用意しておきます。

private extension View {
    func introspectScrollView(_ customize: @escaping (UIScrollView) -> Void) -> some View {
        introspect(.scrollView, on: .iOS(.v15, .v16, .v17), customize: customize)
    }
}

大したコードではありませんが、iOS バージョンの指定はプロジェクトで共通のはずなので、用意しておいた方が実装のブレによるトラブルを防げるかと思います。

onAppear 版の実装

まず onAppear 版のモディファイアを実装します。

import SwiftUI
@_spi(Advanced) import SwiftUIIntrospect // `@Weak`を使用するために`@_spi`でインポート

private struct ScrollIndicatorsFlashOnAppearModifier: ViewModifier {
    var onAppear: Bool

    @Weak private var scrollView: UIScrollView?

    func body(content: Content) -> some View {
        content
            .introspectScrollView { scrollView in
                // UIScrollView を取得して保持。
                self.scrollView = scrollView
            }
            .onAppear {
                if onAppear {
                    // Note:
                    // 即時に呼び出すと`introspectScrollView`の前に評価することがあるので遅延させる。
                    DispatchQueue.main.async {
                        scrollView?.flashScrollIndicators() // ⚡️
                    }
                }
            }
    }
}

全体的には、

  1. UIScrollView を取得
  2. weak 参照で保持
  3. onAppear でフラグが true なら UIScrollView の flashScrollIndicators を呼び出し

という流れになっています。

onAppear の箇所に Note コメントを書いていますが、即時呼び出しだと introspectScrollView の前に評価されて、nil の状態のことがあったので遅延処理を入れています。(今なら Task を使うほうがベターかも知れません)

UIScrollView の保持に利用している @Weak は最新の リリース 0.12.0 で追加された実験的な API で、今回のように保持しておいてあとから参照したい場合に利用できるようです。

(現時点での)実体はクラスベースの Property-wrapper で weak 参照するだけのシンプルな実装でした。

@_spi(Advanced)
@propertyWrapper
public final class Weak<T: AnyObject> {
    private weak var _wrappedValue: T?

    public var wrappedValue: T? {
        get { _wrappedValue }
        set { _wrappedValue = newValue }
    }

    public init(wrappedValue: T? = nil) {
        self._wrappedValue = wrappedValue
    }
}

View の構造体が再生成された時に nil で最初期化される形になりますが、body 関数の評価時に introspect されて再保持されるので、個人的には問題ない実装だろうと感じています。(@StateObject で実装する方法を後述します)

現時点では実験的な API として @_spi(Advanced) でマークされているので、以下のように @_spi でインポートして使えるようにしています。

@_spi(Advanced) import SwiftUIIntrospect

最後に簡単に呼び出せるように View の extension として関数を定義して完了です。

extension View {
    func scrollIndicatorsFlash(onAppear: Bool) -> some View {
        modifier(ScrollIndicatorsFlashOnAppearModifier(onAppear: onAppear))
    }
}

trigger 版の実装

trigger 版のモディファイアも onChange を利用して、ほとんど同じように実装できます。

private struct ScrollIndicatorsFlashTriggerModifier<V: Equatable>: ViewModifier {
    var trigger: V

    @Weak private var scrollView: UIScrollView?

    func body(content: Content) -> some View {
        content
            .introspectScrollView { scrollView in
                self.scrollView = scrollView
            }
            .onChange(of: trigger) { _ in
                scrollView?.flashScrollIndicators() // ⚡️
            }
    }
}

View の extension に定義する関数も同様です。

extension View {
    func scrollIndicatorsFlash(trigger: some Equatable) -> some View {
        modifier(ScrollIndicatorsFlashTriggerModifier(trigger: trigger))
    }
}

余談ですが、関数シグネチャでの単一の Equatable 要求になるので some Equatable の構文が利用できます。

これで、iOS 17 と同様にモディファイアが利用できるようになりました。

おまけ1:UIScrollView の取得のための走査回数を減らす

Introspect では UIView 階層を走査して、対象の UIKit コンポーネントのインスタンスを取得する仕組みになっています。

今回作成したモディファイアを両方とも利用した場合、body 関数の評価につき2回の走査が走ることになります。

ScrollView {
    ...
}
.scrollIndicatorsFlash(onAppear: true)   // 🔍 1回目の走査
.scrollIndicatorsFlash(trigger: numbers) // 🔍 2回目の走査

個人的には、これがパフォーマンス上で問題になることはないだろうと予測していますが、場合によっては走査回数を抑えたいケースも出てくるかもしれません。

その場合は、両方のモディファイアを統合してしまうのもありかもしれません。

private struct ScrollIndicatorsFlashModifier<V: Equatable>: ViewModifier {
    var onAppear: Bool
    var trigger: V

    @Weak private var scrollView: UIScrollView?

    func body(content: Content) -> some View {
        content
            // 走査して取得
            .introspectScrollView { scrollView in
                self.scrollView = scrollView
            }
            // onAppear 用の処理
            .onAppear {
                if onAppear {
                    DispatchQueue.main.async {
                        scrollView?.flashScrollIndicators()
                    }
                }
            }
            // trigger 用の処理
            .onChange(of: trigger) { _ in
                self.scrollView?.flashScrollIndicators()
            }
    }
}

extension View {
    // `onAppear`のみを指定できるように`trigger`のデフォルト値を`true`に
    func scrollIndicatorsFlash(onAppear: Bool, trigger: some Equatable = true) -> some View {
        modifier(ScrollIndicatorsFlashModifier(onAppear: onAppear, trigger: trigger))
    }
}

コメントで記載していますが、onAppear 版のみを利用できるように trigger のデフォルト値には true を固定値で入れて、onChange が発火されないようにしています。

おまけ2:@StateObject で UIScrollView を保持する

前述しましたが、UIScrollView を保持するのに利用した @Weak は class ベースの Property-wrapper で、現時点では @_spi(Advanced) で実験的な API として位置づけられています。

前述したとおり、個人的にはこの実装は問題ないと考えていますが、SwiftUI において一般的な実装ではなく、View 構造体が再生成されるタイミングで毎回一緒に再生成されるという実装にはなっています(といってもこれがパフォーマンス上のボトルネックになる可能性は極めて低いと予想しています)。

代わりに SwiftUI において一般的な @StateObject を使った実装も書いてみたいと思います。

まず、weak reference として保持するための ObservableObject を作成します。

private final class WeakReference<T: AnyObject>: ObservableObject {
    private(set) var value: T?

    func set(value: T) {
        self.value = value
    }
}

private(set) で宣言して setter を用意していますが、事前アサーションなども不要ですし、単なるプロパティとして宣言しても良いかもしれません。

あとは @Weak の代わりにこちらを使用するように変更します。

private struct ScrollIndicatorsFlashTriggerModifier<V: Equatable>: ViewModifier {
    var trigger: V

    // weak reference として保持する`@StateObject`
    @StateObject private var scrollViewReference: WeakReference<UIScrollView> = .init()

    // View内で参照する際のエイリアス
    var scrollView: UIScrollView? { scrollViewReference.value }

    func body(content: Content) -> some View {
        content
            .introspectScrollView { scrollView in
                scrollViewReference.set(value: scrollView) // 保持
            }
            .onChange(of: trigger) {_ in
                self.scrollView?.flashScrollIndicators() // ⚡️
            }
    }
}

@StateObject として宣言するようになったので、WeakReference は View のライフサイクルに完全に一致するようになったはずです。

ただボイラープレートは増えてしまうので、ひと手間かけて DynamicProperty を利用するようにしたほうが便利かもしれません。

@propertyWrapper
private struct WeakRef<T: AnyObject>: DynamicProperty {
    @StateObject private var ref: WeakReference

    var wrappedValue: T? {
        get { ref.value }
        nonmutating set { ref.value = newValue }
    }

    init(wrappedValue: T? = nil) {
        _ref = .init(wrappedValue: .init(value: wrappedValue))
    }

    private final class WeakReference: ObservableObject {
        weak var value: T?

        init(value: T?) {
            self.value = value
        }
    }
}

これは Firebase SDK に用意された @FirestoreQuery と同じ実装パターンなので、 おそらく問題なく利用でき、かつ @Weak と同じように使用できます。

private struct ScrollIndicatorsFlashTriggerModifier<V: Equatable>: ViewModifier {
    var trigger: V
    @WeakRef private var scrollView: UIScrollView?

    func body(content: Content) -> some View {
        content
            .introspectScrollView { scrollView in
                self.scrollView = scrollView // 保持
            }
            .onChange(of: trigger) {_ in
                scrollView?.flashScrollIndicators() // ⚡️
            }
    }
}

繰り返しになりますが、個人的には SwiftUI-Introspect に用意された @Weak で問題ないと感じているので、もし問題があったときのワークアラウンド的な位置づけにするのが良いかと思っています。

まとめ

iOS 17 では ScrollView のインジケータを点滅させる2つの API が追加されました。

これらは iOS 17+ な最新の API ですが、機能自体は UIScrollView に古くから実装されているため、SwiftUI-Introspect で UIScrollView にアクセスすることで利用でき、iOS 17 未満向けの互換 API を実装することもできました。

今回紹介したテクニックは UIScrollView に限らず、他のコンポーネントでも同じように使えるかと思います。

本記事で紹介したサンプルコードはこちらからダウンロードできます。