ScrollView内のビューが画面上に表示されたことを検知する仕組みを実装する
ScrollView内のビューが画面上に表示されたらなにか処理をするという仕組みを実装したので、実装方法をご紹介します。
実装したもの
ScrollView内に「TargetView XX」と表示された青い四角形のビューが縦に並んでいます。
スクロールしてこの「TargetView XX」の全体が画面に完全に表示されたら、コンソールに「TargetView XX is visible!!」と表示されるようになっています。
さらにスクロールして、「TargetView XX」がわずかでも隠れたら、コンソールに「TargetView XX is invisible!!」と表示されます。
コードの解説
実装コード全体はこんな感じです。
いくつか細かい部分に分けて解説していきます。
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の全体が画面に表示されたことを判定するにはどうすれば良いでしょうか?
やり方はいくつかあると思いますが、私は以下の方法をとりました。
まず、以下の値を取得します。
①ScrollViewの高さ
②TargetViewの高さ
③TargetViewのScrollView内におけるオフセット値
③のオフセット値は、ScrollViewの上端つまり上の赤線からTargetViewの上端までの距離を指します。
そして、TargetViewの全体が画面に表示されるのは、①から②を引いた値と③の値とが一致するとき、あるいはそれより小さいときです。
③ <= ① - ②
さらに、スクロールを進めていくとTargetView 2はやがて見えなくなります。
TargetViewがわずかでも隠れたら、非表示と判定したいです。
③は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などを実装して汎用化することで、ある程度読みやすく、また再利用しやすいコードにすることができます。
少しでも参考になれば幸いです。