こんにちは、iOSエンジニアの chuymaster です!
最近とある案件で、ユーザーが特定のViewを見たかどうかを計測しました。iOSエンジニア向けに、その実装方法について紹介したいと思います。
目次
背景
弊社は、設置したバナーの効果測定のため、ユーザーが何人バナーを見て、何人クリックしたかというデータを集計しています。
広告用語でいうと、クリック率(CTR)に当たる数字を集計して、分析しています。
それに基づいて、どういうバナーが効果が良いのかの実験が日々行われていますが、iOSアプリではそのようなことができませんでした。
要件
元々各バナーのクリックイベントは記録していますが、インプレッション(表示)イベントを記録していなかったので、CTRを測定できませんでした。
今回はインプレッションを記録する実装を下記の「統合トップ」画面で行いました。
インプレッションの定義は様々ですが、Googleアドマネージャーの定義に従いました。
Google アド マネージャーでは、業界基準に沿った方法でモバイルアプリ インプレッションがカウントされます。つまり、デバイスの画面に広告クリエイティブが 1 ピクセル以上表示されると、モバイルアプリ インプレッションが 1 回カウントされるという仕組みです。
実装要件に置き換えると、対象Viewが画面上に1pxでも表示されればインプレッションイベントを送ることになります。
実装
下記が対象画面の構成になります。
見ての通り、インプレッションを判定したい箇所は3種類あって、かなり深い層にあります。 インプレッションログ送信の実装要件はこのようになります。
- UIScrollViewの中のUIViewが画面内に表示された際
- スクロールが無効なUICollectionViewの中にある、UICollectionViewCellが画面内に表示された際
- 横スクロールが有効なUICollectionViewの中にある、UICollectionViewCellが画面内に表示された際
基本的な実装コード
上記のスレを参考に、scrollViewDidScroll(_:)
時に、スクロールして見える bounds
と対象の UIView
の bounds
が
intersects(_:)
したら、インプレッションログを送信します。
しかし、Viewが複数階層でネストされている対象画面では、そのまま UIView
の bounds
で UIView
が交差しているかどうかを判定すると、ローカル座標が返されて間違った判定になってしまいます。そこで登場するのが convert(_:to:)
関数です。
view.convert(bounds, to: UIScreen.main.coordinateSpace)
これで画面上の空間での座標に変換できます。詳しくはこちらを読んでみてください。 developer.apple.com
①UIViewが画面上に表示されたことを判定するコード
完成したコードがこちらです。
func detectImpressions(scrollView: UIScrollView, view: UIView) { // スクロールした位置で見えているフレームの位置を計算する let currentVisibleFrame = CGRect(x: scrollView.contentOffset.x, y: scrollView.contentOffset.y, width: scrollView.frame.size.width, height: scrollView.frame.size.height) // 見えているフレームの位置を、画面上の位置に変換する let currentVisibleFrameInMainSpace = scrollView.convert(currentVisibleFrame, to: UIScreen.main.coordinateSpace) // Imp計測対象Viewのフレームの位置を、画面上の位置に変換する let viewFrameInMainSpace = view.convert(view.bounds, to: UIScreen.main.coordinateSpace) // 重なりを判定する if viewFrameInMainSpace.intersects(currentVisibleFrameInMainSpace) { // インプレッションログを送信する } }
スクロールして見えたフレームと、対象Viewのフレームを UIScreen.main.coordinateSpace
空間の座標に変換して、画面上に見えているかどうかを判定します。
あとは scrollViewDidScroll(_:)
で呼び出せば良いです。
これで、要件①UIScrollViewの中のUIViewが画面内に表示された際の判定処理が実現できました。
②UICollectionViewのUICollectionViewCellが表示されたことを判定するコード
UICollectionView
の場合は、中のセルのフレームを見て判定する必要があります。
上記の回答を参考に、今見えているセルの indexPath.row
を返す関数を実装しました。
func getVisibleRows(currentVisibleFrameInMainSpace: CGRect, collectionView: UICollectionView) -> [Int] { var visibleRows = [Int]() let visibleIndexPaths: [IndexPath] = collectionView.indexPathsForVisibleItems for indexPath in visibleIndexPaths { guard let cell = collectionView.cellForItem(at: indexPath) else { continue } // 画面描画位置の重ねで表示されたかどうかを判定する let cellRect = cell.contentView.convert(cell.contentView.bounds, to: UIScreen.main.coordinateSpace) if currentVisibleFrameInMainSpace.intersects(cellRect) { visibleRows.append(indexPath.row) } } return visibleRows }
引数に①の関数で取得した画面上のフレーム座標を渡して、各セルの座標と比較して画面上に見えているかどうかを判定します。
collectionView.indexPathsForVisibleItems
がそのまま使えない理由は、今回の実装では、全てのセルを最初から展開して描画させているため、すべてのセルの indexPath
が返却されるからです。
これにより、要件②スクロールが無効なUICollectionViewの中にある、UICollectionViewCellが画面内に表示された際の判定処理が可能になります。
要件③横スクロールが有効なUICollectionViewの中にある、UICollectionViewCellが画面内に表示された際のも同じコードでできますが、scrollViewDidScroll(_:)
のdelegate
は UICollectionView
に変える必要があるので、ご注意ください。
スクロールが有効なUICollectionViewなので、 collectionView:willDisplayCell:forItemAtIndexPath:
は一見使えるように見えますが、実際はユーザーが見えない場所で呼び出されることがあるので、使わない方が良いです。
注意点
初回表示時の判定
scrollViewDidScroll(_:)
で判定処理を入れているので、画面ロード後、ユーザーの操作なしだと判定処理が走りません。
そのため、画面ロード後に明示的に判定する必要があります。
GameWithアプリでは RxSwift
を使っているので、scrollView.rx.contentOffset
を Subscribe
して初期状態でも判定させることができました。
判定タイミング
この方法は、画面上の描画後の座標を元に、2つのフレームが交差したかどうかを判定しています。
そのため、描画完了前に判定してしまうと正しい座標になりません。
判定がおかしいなぁと思ったら
view.layoutIfNeeded()
で描画させるDispatchQueue.main.async
内で判定処理を呼ぶ
のどちらかを試してみてください
重複除外
scrollViewDidScroll(_:)
で表示判定をしているので、イベントが何回も送られます。
一度送ったイベントを送らないように除外する必要があります。
最後に
この実装でかなり時間がかかったので、同じ課題に当たるiOSエンジニアの方に少しでも役に立ったら幸いです!
GameWithのDeveloper向けTwitterアカウントがあります。
ブログの更新情報などを発信するので良かったらフォローしてください!!