Commit 42b4abad authored by Dan Harrington's avatar Dan Harrington Committed by Commit Bot

feedv2: Send slice views to feed component

The first time a slice is 2/3rds visible, we call
reportSliceViewed() on FeedStreamSurface.

This is done by adding an onPreDrawListener for the recyclerView,
and then checking geometry for slices in the viewport.

Bug: 1044139
Change-Id: I7b1f07711fd0347574e3bd9b80875dbb068a230d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2233598Reviewed-by: default avatarCathy Li <chili@chromium.org>
Commit-Queue: Dan H <harringtond@chromium.org>
Cr-Commit-Position: refs/heads/master@{#776124}
parent 8ff91c81
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.feed.v2;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.HashSet;
/**
* Tracks position of slice views. When a slice's view is first 2/3rds visible in the viewport,
* the observer is notified.
*/
class FeedSliceViewTracker implements ViewTreeObserver.OnPreDrawListener {
private static final String TAG = "FeedSliceViewTracker";
private static final double DEFAULT_VIEW_LOG_THRESHOLD = .66;
@Nullable
private RecyclerView mRootView;
@Nullable
private FeedListContentManager mContentManager;
// The set of content keys already reported as visible.
private HashSet<String> mContentKeysVisible = new HashSet<String>();
@Nullable
private Observer mObserver;
/** Notified the first time slices are visible */
public interface Observer {
void sliceVisible(String sliceId);
}
FeedSliceViewTracker(@NonNull RecyclerView rootView,
@NonNull FeedListContentManager contentManager, @NonNull Observer observer) {
mRootView = (RecyclerView) rootView;
mContentManager = contentManager;
mObserver = observer;
mRootView.getViewTreeObserver().addOnPreDrawListener(this);
}
/** Stop observing rootView. Prevents further calls to observer. */
public void destroy() {
if (mRootView != null && mRootView.getViewTreeObserver().isAlive()) {
mRootView.getViewTreeObserver().removeOnPreDrawListener(this);
}
mRootView = null;
mObserver = null;
mContentManager = null;
}
// ViewTreeObserver.OnPreDrawListener.
@Override
public boolean onPreDraw() {
// Not sure why, but this method can be called just after destroy().
if (mRootView == null) return false;
if (!(mRootView.getLayoutManager() instanceof LinearLayoutManager)) return true;
LinearLayoutManager layoutManager = (LinearLayoutManager) mRootView.getLayoutManager();
int firstPosition = layoutManager.findFirstVisibleItemPosition();
int lastPosition = layoutManager.findLastVisibleItemPosition();
for (int i = firstPosition;
i <= lastPosition && i < mContentManager.getItemCount() && i >= 0; ++i) {
String contentKey = mContentManager.getContent(i).getKey();
View childView = layoutManager.findViewByPosition(i);
if (mContentKeysVisible.contains(contentKey) || childView == null
|| !isViewVisible(childView)) {
continue;
}
mContentKeysVisible.add(contentKey);
mObserver.sliceVisible(contentKey);
}
return true;
}
@VisibleForTesting
boolean isViewVisible(View childView) {
Rect rect = new Rect(0, 0, childView.getWidth(), childView.getHeight());
int viewArea = rect.width() * rect.height();
if (viewArea <= 0) return false;
if (!mRootView.getChildVisibleRect(childView, rect, null)) return false;
int visibleArea = rect.width() * rect.height();
return (float) visibleArea / viewArea >= DEFAULT_VIEW_LOG_THRESHOLD;
}
}
......@@ -9,7 +9,9 @@ import android.content.Context;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
......@@ -63,6 +65,8 @@ public class FeedStreamSurface implements SurfaceActionsHandler, FeedActionsHand
private final HybridListRenderer mHybridListRenderer;
private final SnackbarManager mSnackbarManager;
private final Activity mActivity;
@Nullable
private FeedSliceViewTracker mSliceViewTracker;
private int mHeaderCount;
......@@ -146,6 +150,14 @@ public class FeedStreamSurface implements SurfaceActionsHandler, FeedActionsHand
if (mHybridListRenderer != null) {
mRootView = mHybridListRenderer.bind(mContentManager);
// XSurface returns a View, but it should be a RecyclerView.
assert (mRootView instanceof RecyclerView);
mSliceViewTracker = new FeedSliceViewTracker(
(RecyclerView) mRootView, mContentManager, (String sliceId) -> {
FeedStreamSurfaceJni.get().reportSliceViewed(
mNativeFeedStreamSurface, FeedStreamSurface.this, sliceId);
});
} else {
mRootView = null;
}
......@@ -155,6 +167,10 @@ public class FeedStreamSurface implements SurfaceActionsHandler, FeedActionsHand
* Performs all necessary cleanups.
*/
public void destroy() {
if (mSliceViewTracker != null) {
mSliceViewTracker.destroy();
mSliceViewTracker = null;
}
mHybridListRenderer.unbind();
FeedStreamSurfaceJni.get().surfaceClosed(mNativeFeedStreamSurface, FeedStreamSurface.this);
}
......
......@@ -384,6 +384,7 @@ if (enable_feed_in_chrome) {
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/tooltip/FeedTooltipUtils.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedListContentManager.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedServiceBridge.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedSliceViewTracker.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedStream.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/FeedStreamSurface.java",
"//chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/v2/NativeViewListRenderer.java",
......@@ -646,6 +647,7 @@ if (enable_feed_in_chrome) {
"junit/src/org/chromium/chrome/browser/feed/NtpStreamLifecycleManagerTest.java",
"junit/src/org/chromium/chrome/browser/feed/action/FeedActionHandlerTest.java",
"junit/src/org/chromium/chrome/browser/feed/v2/FeedListContentManagerTest.java",
"junit/src/org/chromium/chrome/browser/feed/v2/FeedSliceViewTrackerTest.java",
"junit/src/org/chromium/chrome/browser/feed/v2/FeedStreamSurfaceTest.java",
"junit/src/org/chromium/chrome/browser/feed/v2/NativeViewListRendererTest.java",
]
......
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.feed.v2;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.graphics.Rect;
import android.support.test.filters.SmallTest;
import android.view.View;
import android.view.ViewTreeObserver;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.robolectric.annotation.Config;
import org.chromium.base.test.BaseRobolectricTestRunner;
import java.util.Arrays;
/** Unit tests for {@link FeedSliceViewTracker}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class FeedSliceViewTrackerTest {
// Mocking dependencies that are always present, but using a real FeedListContentManager.
@Mock
RecyclerView mParentView;
@Mock
FeedSliceViewTracker.Observer mObserver;
@Mock
LinearLayoutManager mLayoutManager;
@Mock
ViewTreeObserver mViewTreeObserver;
FeedListContentManager mContentManager;
FeedSliceViewTracker mTracker;
// Child view mocks are used as needed in some tests.
@Mock
View mChildA;
@Mock
View mChildB;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mContentManager = new FeedListContentManager(null, null);
doReturn(mLayoutManager).when(mParentView).getLayoutManager();
doReturn(mViewTreeObserver).when(mParentView).getViewTreeObserver();
mTracker = Mockito.spy(new FeedSliceViewTracker(mParentView, mContentManager, mObserver));
}
@Test
@SmallTest
public void testIsItemVisible_JustEnoughnViewport() {
mockViewDimensions(mChildA, 10, 10);
mockGetChildVisibleRect(mChildA, 0, 7);
Assert.assertTrue(mTracker.isViewVisible(mChildA));
}
@Test
@SmallTest
public void testIsItemVisible_NotEnoughnViewport() {
mockViewDimensions(mChildA, 10, 10);
mockGetChildVisibleRect(mChildA, 0, 6);
Assert.assertFalse(mTracker.isViewVisible(mChildA));
}
@Test
@SmallTest
public void testIsItemVisible_ZeroAreaInViewport() {
mockViewDimensions(mChildA, 10, 10);
mockGetChildVisibleRect(mChildA, 0, 0);
Assert.assertFalse(mTracker.isViewVisible(mChildA));
}
@Test
@SmallTest
public void testIsItemVisible_getChildVisibleRectReturnsFalse() {
mockViewDimensions(mChildA, 10, 10);
mockGetChildVisibleRectIsEmpty(mChildA);
Assert.assertFalse(mTracker.isViewVisible(mChildA));
}
@Test
@SmallTest
public void testIsItemVisible_ZeroArea() {
mockViewDimensions(mChildA, 0, 0);
mockGetChildVisibleRect(mChildA, 0, 0);
Assert.assertFalse(mTracker.isViewVisible(mChildA));
}
@Test
@SmallTest
public void testOnPreDraw_BothVisibleAreReportedExactlyOnce() {
mContentManager.addContents(0,
Arrays.asList(new FeedListContentManager.FeedContent[] {
new FeedListContentManager.NativeViewContent("key1", mChildA),
new FeedListContentManager.NativeViewContent("key2", mChildB),
}));
doReturn(0).when(mLayoutManager).findFirstVisibleItemPosition();
doReturn(1).when(mLayoutManager).findLastVisibleItemPosition();
doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));
doReturn(true).when(mTracker).isViewVisible(mChildA);
doReturn(true).when(mTracker).isViewVisible(mChildB);
mTracker.onPreDraw();
verify(mObserver).sliceVisible(eq("key1"));
verify(mObserver).sliceVisible(eq("key2"));
mTracker.onPreDraw(); // Does not repeat call to sliceVisible().
}
@Test
@SmallTest
public void testOnPreDraw_OnlyOneVisible() {
mContentManager.addContents(0,
Arrays.asList(new FeedListContentManager.FeedContent[] {
new FeedListContentManager.NativeViewContent("key1", mChildA),
new FeedListContentManager.NativeViewContent("key2", mChildB),
}));
doReturn(0).when(mLayoutManager).findFirstVisibleItemPosition();
doReturn(1).when(mLayoutManager).findLastVisibleItemPosition();
doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));
doReturn(false).when(mTracker).isViewVisible(mChildA);
doReturn(true).when(mTracker).isViewVisible(mChildB);
mTracker.onPreDraw();
verify(mObserver).sliceVisible(eq("key2"));
}
@Test
@SmallTest
public void testOnPreDraw_EmptyRecyclerView() {
mContentManager.addContents(0,
Arrays.asList(new FeedListContentManager.FeedContent[] {
new FeedListContentManager.NativeViewContent("key1", mChildA),
new FeedListContentManager.NativeViewContent("key2", mChildB),
}));
doReturn(RecyclerView.NO_POSITION).when(mLayoutManager).findFirstVisibleItemPosition();
doReturn(RecyclerView.NO_POSITION).when(mLayoutManager).findLastVisibleItemPosition();
mTracker.onPreDraw();
}
@Test
@SmallTest
public void testDestroy() {
doReturn(true).when(mViewTreeObserver).isAlive();
mTracker.destroy();
verify(mViewTreeObserver).removeOnPreDrawListener(any());
mTracker.destroy(); // A second destroy() does nothing.
}
void mockViewDimensions(View view, int width, int height) {
when(view.getWidth()).thenReturn(10);
when(view.getHeight()).thenReturn(10);
}
void mockGetChildVisibleRect(View child, int rectTop, int rectBottom) {
doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) {
Rect rect = (Rect) invocation.getArguments()[1];
rect.top = rectTop;
rect.bottom = rectBottom;
return true;
}
})
.when(mParentView)
.getChildVisibleRect(eq(child), any(), any());
}
void mockGetChildVisibleRectIsEmpty(View child) {
doAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) {
return false;
}
})
.when(mParentView)
.getChildVisibleRect(eq(child), any(), any());
}
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment