ScrollView内のビューが画面上に表示されたことを検知する仕組みを実装する

ScrollView内のビューが画面上に表示されたらなにか処理をするという仕組みを実装したので、実装方法をご紹介します。



実装したもの

ScrollView内に「TargetView XX」と表示された青い四角形のビューが縦に並んでいます。

スクロールしてこの「TargetView XX」の全体が画面に完全に表示されたら、コンソールに「TargetView XX is visible!!」と表示されるようになっています。

さらにスクロールして、「TargetView XX」がわずかでも隠れたら、コンソールに「TargetView XX is invisible!!」と表示されます。

f:id:bamboohero:20210920023952g:plain


コードの解説

実装コード全体はこんな感じです。

いくつか細かい部分に分けて解説していきます。

struct ContentView: View {
    @State private var detectedIds = Set<Int>()

    var body: some View {
        VStack(spacing: 0) {
            Color.red.frame(height: 4)
            GeometryReader { geometry in
                ScrollView {
                    LazyVStack(spacing: 32) {
                        ForEach(0..<100) {
                            targetView(id: $0)
                        }
                    }
                }
                .coordinateSpace(name: "scroll")
                .onScroll(
                    scrollViewHeight: geometry.size.height,
                    onItemBecameVisible: { id in
                        if let viewId = id as? Int, !detectedIds.contains(viewId) {
                            print("TargetView \(viewId) is visible!!")
                            detectedIds.insert(viewId)
                        }
                    },
                    onItemBecameInvisible: { id in
                        if let viewId = id as? Int, detectedIds.contains(viewId) {
                            print("TargetView \(viewId) is invisible!!")
                            detectedIds.remove(viewId)
                        }
                    }
                )
            }
            Color.red.frame(height: 4)
        }
        .background(Color.white)
    }

    private func targetView(id: Int) -> some View {
        Rectangle()
            .frame(height: 300)
            .foregroundColor(.blue)
            .overlay(Text("TargetView \(id)"))
            .identifiable(coordinateSpaceName: "scroll", id: id)
    }
}

extension View {
    func identifiable(coordinateSpaceName: AnyHashable, id: AnyHashable) -> some View {
        modifier(ItemModifier(id: id, coordinateSpaceName: coordinateSpaceName))
    }

    func onScroll(
        scrollViewHeight: CGFloat,
        onItemBecameVisible: @escaping (AnyHashable) -> Void,
        onItemBecameInvisible: @escaping (AnyHashable) -> Void
    ) -> some View {
        onPreferenceChange(ItemPreferenceKey.self) { values in
            values.forEach {
                let cellOffset = $0.rect.minY
                let threshold = scrollViewHeight - $0.rect.height
                let isVisible = cellOffset >= 0 && cellOffset <= threshold

                if isVisible {
                    onItemBecameVisible($0.id)
                } else {
                    onItemBecameInvisible($0.id)
                }
            }
        }
    }
}

private struct Item: Equatable {
    let id: AnyHashable
    let rect: CGRect
}

private struct ItemPreferenceKey: PreferenceKey {
    typealias Value = [Item]
    static var defaultValue: Value = []

    static func reduce(value: inout Value, nextValue: () -> Value) {
        value.append(contentsOf: nextValue())
    }
}

private struct ItemModifier: ViewModifier {
    let id: AnyHashable
    let coordinateSpaceName: AnyHashable

    func body(content: Content) -> some View {
        content.background(
            GeometryReader { geometry in
                Color.clear.preference(
                    key: ItemPreferenceKey.self,
                    value: [Item(id: id, rect: geometry.frame(in: .named(coordinateSpaceName)))]
                )
            }
        )
    }
}


ビューが画面に表示されたかどうかを判定するための情報を整理する

例えば下図において、TargetView 2の全体が画面に表示されたことを判定するにはどうすれば良いでしょうか?

f:id:bamboohero:20210920012702p:plain:w300


やり方はいくつかあると思いますが、私は以下の方法をとりました。

まず、以下の値を取得します。

①ScrollViewの高さ
②TargetViewの高さ
③TargetViewのScrollView内におけるオフセット値

③のオフセット値は、ScrollViewの上端つまり上の赤線からTargetViewの上端までの距離を指します。

f:id:bamboohero:20211016001255p:plain


そして、TargetViewの全体が画面に表示されるのは、①から②を引いた値と③の値とが一致するとき、あるいはそれより小さいときです。

③ <= ① - ②


さらに、スクロールを進めていくとTargetView 2はやがて見えなくなります。

TargetViewがわずかでも隠れたら、非表示と判定したいです。

f:id:bamboohero:20210920015739p:plain:w300


③はScrollViewの上端より上に行くとマイナスの値を取るため、TargetViewの全体が画面に表示されている状態は以下の式で表せます。

③ >= 0 && ③ <= ① - ②


各情報を取得する

GeometryReaderを使って①ScrollViewの高さを取得しています。

ここでは上下の赤線の間の領域のサイズをGeometryReaderから取得することができます。

VStack(spacing: 0) {
    Color.red.frame(height: 4)
    GeometryReader { geometry in
        ScrollView {
        ...
    Color.red.frame(height: 4)
}


次に、②TargetViewの高さと③TargetViewのScrollView内におけるオフセット値を取得する方法を説明します。

これらの値はidentifiable(coordinateSpaceName:id:)というカスタムModifierで簡単に取得できるようにしています。

Rectangle()
    .frame(height: 300)
    .foregroundColor(.blue)
    .overlay(Text("TargetView \(id)"))
    .identifiable(coordinateSpaceName: "scroll", id: id)


identifiable(coordinateSpaceName:id:)の実装の詳細はItemModifierというViewModifierに隠蔽されています。

ItemModifierはビューのbackgroundにGeometryReaderを仕込んでビューのフレームを取得します。
backgroundにGeometryReaderを配置してビューのフレームを取得するという方法はよく知られているようです。

また、ある特定の座標空間におけるフレームを取得したいので、ItemModifierにcoordinateSpaceNameというプロパティを持たせています。
今回の実装ではScrollViewの座標空間を指定していて、これによりビューのScrollView内におけるオフセット値が取得できるようになっています。

private struct ItemModifier: ViewModifier {
    let id: AnyHashable
    let coordinateSpaceName: AnyHashable

    func body(content: Content) -> some View {
        content.background(
            GeometryReader { geometry in
                Color.clear.preference(
                    key: ItemPreferenceKey.self,
                    value: [Item(id: id, rect: geometry.frame(in: .named(coordinateSpaceName)))]
                )
            }
        )
    }
}


ビューが画面に表示されたかどうかを判定する

ビューが画面に表示されたかどうかを判定するロジックについては前述の通りです。

ここではそのロジックを実装する方法を説明します。


GeometryReaderで取得したTargetViewのフレームは、onPreferenceChange Modifierで取得することができます。

onPreferenceChange Modifierでフレームを取得しビューが画面に表示されたかどうかを判定するというロジックを、onScroll(scrollViewHeight:onItemBecameVisible:onItemBecameInvisible:)というカスタムModifierとして実装しました。

func onScroll(
    scrollViewHeight: CGFloat,
    onItemBecameVisible: @escaping (AnyHashable) -> Void,
    onItemBecameInvisible: @escaping (AnyHashable) -> Void
) -> some View {
    onPreferenceChange(ItemPreferenceKey.self) { values in
        values.forEach {
            let cellOffset = $0.rect.minY
            let threshold = scrollViewHeight - $0.rect.height
            let isVisible = cellOffset >= 0 && cellOffset < threshold

            if isVisible {
                onItemBecameVisible($0.id)
            } else {
                onItemBecameInvisible($0.id)
            }
        }
    }
}


スクロールするとこの部分のロジックが呼ばれ、isVisibleに前述の式の結果が格納されます。

TargetViewの全体が画面に表示されたらisVisible == true、一部が画面から見えなくなったらisVisible == falseとなり、それぞれのクロージャが実行されます。


このカスタムModifierをScrollViewに対して実装してあげれば、TargetViewが画面に表示されたときに何かをするという処理を簡単に実装することができます。

GeometryReader { geometry in
    ScrollView {
    ...
    }
    .coordinateSpace(name: "scroll")
    .onScroll(
        scrollViewHeight: geometry.size.height,
        onItemBecameVisible: { id in
            // TargetViewが画面に表示された
        },
        onItemBecameInvisible: { id in
            // TargetViewが画面から見えなくなった
        }
    )
}


まとめ

ScrollView内のビューが画面上に表示されたらなにか処理をするという仕組みの実装方法についてご紹介しました。

SwiftUIで座標を扱うコードを書くためにはGeometryReaderやPreferenceKeyを駆使する必要がありますが、愚直に書くとかなり読みづらいコードになってしまいます。

今回ご紹介したように、適宜カスタムModifierなどを実装して汎用化することで、ある程度読みやすく、また再利用しやすいコードにすることができます。

少しでも参考になれば幸いです。