SwiftUI でリスト要素のインプレッションを計測する
こちらは Wantedly Advent Calendar 2023 2日目の記事です
はじめに
ネイティブアプリではスクリーン表示やタップなどのイベントの計測が一般的にありますが、一覧の中にある特定の要素が表示されたかを計測したい要件が必要な場合があります
UIKit では willDisplayCell や scrollViewDidScroll などで実現していたものを SwiftUI ではどのように行ったかを紹介します
ゴールとしてはこのようなリストデータ上のセルの表示領域が 70% 以上現れたらインプレッションされたと扱います
ScrollView {
LazyVStack {
ForEach(items) { item in
Cell(for: item)
}
}
}
anchorPreference
preference は environment とは逆に子要素側から親要素へ値を伝えるモディファイアで anchorPreference はそれのジオメトリに特化したモディファイアになります
key には PreferenceKey
に適合した型を用意します
セルはリスト内に複数存在するため、配列で管理します
struct ImpressionKey: PreferenceKey {
typealias Value = [Item]
struct Item: Equatable {
let index: Int
let anchor: Anchor<CGRect>
let action: () -> Void
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.index == rhs.index && lhs.anchor == rhs.anchor
}
}
static var defaultValue: Value { [] }
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
今回はセルの領域を取得したいので value に .bounds
を、 transform は ImpressionKey
で扱いたい値に変換させています
Cell(for: item)
.anchorPreference(key: ImpressionKey.self, value: .bounds) { anchor in
return [
ImpressionKey.Item(index: i, anchor: anchor) {
if !impressed.contains(item.id) {
impressed.insert(item.id)
print("send impression event at \(i)")
}
}
]
}
backgroundPreferenceValue
子要素の各 preference の値は通常の preference と同様に {overlay/background}PreferenceValue や onPreferenceChange で受け取ることができます
Anchor<CGRect>
は GeometryReader
を用いて実際の領域を取得します
そのため backgroundPreferenceValue を用いてビューの裏側に計算用のダミーのビューをレイアウトさせます
GeometryReader
が参照できれば良いのでビュー自体は Color.clear で何も表示させません
また iOS17 からは onChange(of:initial:_:) が利用できますが、それ以下では onChange と onAppear の組み合わせで代用します
ScrollView {
...
}
.backgroundPreferenceValue(ImpressionKey.self) { items in
GeometryReader { proxy in
Color.clear
.onChange(of: items) { items in
checkImpressions(for: items, in: proxy)
}
.onAppear {
checkImpressions(for: items, in: proxy)
}
}
}
インプレッション判定
はじめに述べたように表示されたかどうかは、対象のセルが画面内に 70% 以上現れたかどうかで判定します
扱っている Anchor は CGRect
なので画面表示部分の CGRect
との交差で判定できます
その結果が真であれば、イベント送信などのインプレッション用の処理を呼び出します
※リストを Lazy~ で扱っている場合、 preference 対象は表示領域周辺のセルのみが配列としてやってくるので単純にすべての items に対して座標の計算を行っています
private func checkImpressions(for items: [ImpressionKey.Item], in proxy: GeometryProxy) {
func isMostlyDisplayed(of target: CGRect, in viewport: CGRect) -> Bool {
let intersection = viewport.intersection(target)
return intersection.height > target.height * 0.7
}
let viewport = CGRect(origin: .zero, size: proxy.size)
for item in items {
let frame = proxy[item.anchor]
if isMostlyDisplayed(of: frame, in: viewport) {
item.action()
}
}
}
さいごに
SwiftUI ではスクロール位置を直接観測できないため、 UIKit の頃より少しトリッキーな形での実装になりました
今回の実装を実現するにあたり Preference やその reduce の挙動など、頻繁に使うことのなかったこれらの機能の理解を深めることができました
Discussion