📏

SwiftUI でリスト要素のインプレッションを計測する

2023/12/02に公開

こちらは Wantedly Advent Calendar 2023 2日目の記事です
https://qiita.com/advent-calendar/2023/wantedly

はじめに

ネイティブアプリではスクリーン表示やタップなどのイベントの計測が一般的にありますが、一覧の中にある特定の要素が表示されたかを計測したい要件が必要な場合があります

UIKit では willDisplayCell や scrollViewDidScroll などで実現していたものを SwiftUI ではどのように行ったかを紹介します

ゴールとしてはこのようなリストデータ上のセルの表示領域が 70% 以上現れたらインプレッションされたと扱います

ScrollView {
    LazyVStack {
        ForEach(items) { item in
            Cell(for: item)
        }
    }
}

anchorPreference

preference は environment とは逆に子要素側から親要素へ値を伝えるモディファイアで anchorPreference はそれのジオメトリに特化したモディファイアになります

https://developer.apple.com/documentation/swiftui/view/anchorpreference(key:value:transform:)

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