ViewabilityHelper.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. /**
  2. * Copyright (c) Facebook, Inc. and its affiliates.
  3. *
  4. * This source code is licensed under the MIT license found in the
  5. * LICENSE file in the root directory of this source tree.
  6. *
  7. * @flow
  8. * @format
  9. */
  10. 'use strict';
  11. const invariant = require('invariant');
  12. export type ViewToken = {
  13. item: any,
  14. key: string,
  15. index: ?number,
  16. isViewable: boolean,
  17. section?: any,
  18. ...
  19. };
  20. export type ViewabilityConfigCallbackPair = {
  21. viewabilityConfig: ViewabilityConfig,
  22. onViewableItemsChanged: (info: {
  23. viewableItems: Array<ViewToken>,
  24. changed: Array<ViewToken>,
  25. ...
  26. }) => void,
  27. ...
  28. };
  29. export type ViewabilityConfig = {|
  30. /**
  31. * Minimum amount of time (in milliseconds) that an item must be physically viewable before the
  32. * viewability callback will be fired. A high number means that scrolling through content without
  33. * stopping will not mark the content as viewable.
  34. */
  35. minimumViewTime?: number,
  36. /**
  37. * Percent of viewport that must be covered for a partially occluded item to count as
  38. * "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means
  39. * that a single pixel in the viewport makes the item viewable, and a value of 100 means that
  40. * an item must be either entirely visible or cover the entire viewport to count as viewable.
  41. */
  42. viewAreaCoveragePercentThreshold?: number,
  43. /**
  44. * Similar to `viewAreaPercentThreshold`, but considers the percent of the item that is visible,
  45. * rather than the fraction of the viewable area it covers.
  46. */
  47. itemVisiblePercentThreshold?: number,
  48. /**
  49. * Nothing is considered viewable until the user scrolls or `recordInteraction` is called after
  50. * render.
  51. */
  52. waitForInteraction?: boolean,
  53. |};
  54. /**
  55. * A Utility class for calculating viewable items based on current metrics like scroll position and
  56. * layout.
  57. *
  58. * An item is said to be in a "viewable" state when any of the following
  59. * is true for longer than `minimumViewTime` milliseconds (after an interaction if `waitForInteraction`
  60. * is true):
  61. *
  62. * - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item
  63. * visible in the view area >= `itemVisiblePercentThreshold`.
  64. * - Entirely visible on screen
  65. */
  66. class ViewabilityHelper {
  67. _config: ViewabilityConfig;
  68. _hasInteracted: boolean = false;
  69. _timers: Set<number> = new Set();
  70. _viewableIndices: Array<number> = [];
  71. _viewableItems: Map<string, ViewToken> = new Map();
  72. constructor(
  73. config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0},
  74. ) {
  75. this._config = config;
  76. }
  77. /**
  78. * Cleanup, e.g. on unmount. Clears any pending timers.
  79. */
  80. dispose() {
  81. /* $FlowFixMe(>=0.63.0 site=react_native_fb) This comment suppresses an
  82. * error found when Flow v0.63 was deployed. To see the error delete this
  83. * comment and run Flow. */
  84. this._timers.forEach(clearTimeout);
  85. }
  86. /**
  87. * Determines which items are viewable based on the current metrics and config.
  88. */
  89. computeViewableItems(
  90. itemCount: number,
  91. scrollOffset: number,
  92. viewportHeight: number,
  93. getFrameMetrics: (
  94. index: number,
  95. ) => ?{
  96. length: number,
  97. offset: number,
  98. ...
  99. },
  100. // Optional optimization to reduce the scan size
  101. renderRange?: {
  102. first: number,
  103. last: number,
  104. ...
  105. },
  106. ): Array<number> {
  107. const {
  108. itemVisiblePercentThreshold,
  109. viewAreaCoveragePercentThreshold,
  110. } = this._config;
  111. const viewAreaMode = viewAreaCoveragePercentThreshold != null;
  112. const viewablePercentThreshold = viewAreaMode
  113. ? viewAreaCoveragePercentThreshold
  114. : itemVisiblePercentThreshold;
  115. invariant(
  116. viewablePercentThreshold != null &&
  117. (itemVisiblePercentThreshold != null) !==
  118. (viewAreaCoveragePercentThreshold != null),
  119. 'Must set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold',
  120. );
  121. const viewableIndices = [];
  122. if (itemCount === 0) {
  123. return viewableIndices;
  124. }
  125. let firstVisible = -1;
  126. const {first, last} = renderRange || {first: 0, last: itemCount - 1};
  127. if (last >= itemCount) {
  128. console.warn(
  129. 'Invalid render range computing viewability ' +
  130. JSON.stringify({renderRange, itemCount}),
  131. );
  132. return [];
  133. }
  134. for (let idx = first; idx <= last; idx++) {
  135. const metrics = getFrameMetrics(idx);
  136. if (!metrics) {
  137. continue;
  138. }
  139. const top = metrics.offset - scrollOffset;
  140. const bottom = top + metrics.length;
  141. if (top < viewportHeight && bottom > 0) {
  142. firstVisible = idx;
  143. if (
  144. _isViewable(
  145. viewAreaMode,
  146. viewablePercentThreshold,
  147. top,
  148. bottom,
  149. viewportHeight,
  150. metrics.length,
  151. )
  152. ) {
  153. viewableIndices.push(idx);
  154. }
  155. } else if (firstVisible >= 0) {
  156. break;
  157. }
  158. }
  159. return viewableIndices;
  160. }
  161. /**
  162. * Figures out which items are viewable and how that has changed from before and calls
  163. * `onViewableItemsChanged` as appropriate.
  164. */
  165. onUpdate(
  166. itemCount: number,
  167. scrollOffset: number,
  168. viewportHeight: number,
  169. getFrameMetrics: (
  170. index: number,
  171. ) => ?{
  172. length: number,
  173. offset: number,
  174. ...
  175. },
  176. createViewToken: (index: number, isViewable: boolean) => ViewToken,
  177. onViewableItemsChanged: ({
  178. viewableItems: Array<ViewToken>,
  179. changed: Array<ViewToken>,
  180. ...
  181. }) => void,
  182. // Optional optimization to reduce the scan size
  183. renderRange?: {
  184. first: number,
  185. last: number,
  186. ...
  187. },
  188. ): void {
  189. if (
  190. (this._config.waitForInteraction && !this._hasInteracted) ||
  191. itemCount === 0 ||
  192. !getFrameMetrics(0)
  193. ) {
  194. return;
  195. }
  196. let viewableIndices = [];
  197. if (itemCount) {
  198. viewableIndices = this.computeViewableItems(
  199. itemCount,
  200. scrollOffset,
  201. viewportHeight,
  202. getFrameMetrics,
  203. renderRange,
  204. );
  205. }
  206. if (
  207. this._viewableIndices.length === viewableIndices.length &&
  208. this._viewableIndices.every((v, ii) => v === viewableIndices[ii])
  209. ) {
  210. // We might get a lot of scroll events where visibility doesn't change and we don't want to do
  211. // extra work in those cases.
  212. return;
  213. }
  214. this._viewableIndices = viewableIndices;
  215. if (this._config.minimumViewTime) {
  216. const handle = setTimeout(() => {
  217. /* $FlowFixMe(>=0.63.0 site=react_native_fb) This comment suppresses an
  218. * error found when Flow v0.63 was deployed. To see the error delete
  219. * this comment and run Flow. */
  220. this._timers.delete(handle);
  221. this._onUpdateSync(
  222. viewableIndices,
  223. onViewableItemsChanged,
  224. createViewToken,
  225. );
  226. }, this._config.minimumViewTime);
  227. /* $FlowFixMe(>=0.63.0 site=react_native_fb) This comment suppresses an
  228. * error found when Flow v0.63 was deployed. To see the error delete this
  229. * comment and run Flow. */
  230. this._timers.add(handle);
  231. } else {
  232. this._onUpdateSync(
  233. viewableIndices,
  234. onViewableItemsChanged,
  235. createViewToken,
  236. );
  237. }
  238. }
  239. /**
  240. * clean-up cached _viewableIndices to evaluate changed items on next update
  241. */
  242. resetViewableIndices() {
  243. this._viewableIndices = [];
  244. }
  245. /**
  246. * Records that an interaction has happened even if there has been no scroll.
  247. */
  248. recordInteraction() {
  249. this._hasInteracted = true;
  250. }
  251. _onUpdateSync(
  252. viewableIndicesToCheck,
  253. onViewableItemsChanged,
  254. createViewToken,
  255. ) {
  256. // Filter out indices that have gone out of view since this call was scheduled.
  257. viewableIndicesToCheck = viewableIndicesToCheck.filter(ii =>
  258. this._viewableIndices.includes(ii),
  259. );
  260. const prevItems = this._viewableItems;
  261. const nextItems = new Map(
  262. viewableIndicesToCheck.map(ii => {
  263. const viewable = createViewToken(ii, true);
  264. return [viewable.key, viewable];
  265. }),
  266. );
  267. const changed = [];
  268. for (const [key, viewable] of nextItems) {
  269. if (!prevItems.has(key)) {
  270. changed.push(viewable);
  271. }
  272. }
  273. for (const [key, viewable] of prevItems) {
  274. if (!nextItems.has(key)) {
  275. changed.push({...viewable, isViewable: false});
  276. }
  277. }
  278. if (changed.length > 0) {
  279. this._viewableItems = nextItems;
  280. onViewableItemsChanged({
  281. viewableItems: Array.from(nextItems.values()),
  282. changed,
  283. viewabilityConfig: this._config,
  284. });
  285. }
  286. }
  287. }
  288. function _isViewable(
  289. viewAreaMode: boolean,
  290. viewablePercentThreshold: number,
  291. top: number,
  292. bottom: number,
  293. viewportHeight: number,
  294. itemLength: number,
  295. ): boolean {
  296. if (_isEntirelyVisible(top, bottom, viewportHeight)) {
  297. return true;
  298. } else {
  299. const pixels = _getPixelsVisible(top, bottom, viewportHeight);
  300. const percent =
  301. 100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength);
  302. return percent >= viewablePercentThreshold;
  303. }
  304. }
  305. function _getPixelsVisible(
  306. top: number,
  307. bottom: number,
  308. viewportHeight: number,
  309. ): number {
  310. const visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0);
  311. return Math.max(0, visibleHeight);
  312. }
  313. function _isEntirelyVisible(
  314. top: number,
  315. bottom: number,
  316. viewportHeight: number,
  317. ): boolean {
  318. return top >= 0 && bottom <= viewportHeight && bottom > top;
  319. }
  320. module.exports = ViewabilityHelper;