Create new PietScrollObserver to trigger visibility actions on first draw

PiperOrigin-RevId: 276285964
Change-Id: Ie28b8272e4a90990780c6ef919d1a37c3ec601f0
diff --git a/src/main/java/com/google/android/libraries/feed/basicstream/internal/viewholders/PietViewHolder.java b/src/main/java/com/google/android/libraries/feed/basicstream/internal/viewholders/PietViewHolder.java
index 880fbd5..a614431 100644
--- a/src/main/java/com/google/android/libraries/feed/basicstream/internal/viewholders/PietViewHolder.java
+++ b/src/main/java/com/google/android/libraries/feed/basicstream/internal/viewholders/PietViewHolder.java
@@ -18,7 +18,6 @@
 
 import android.content.Context;
 import android.support.annotation.VisibleForTesting;
-import android.support.v7.widget.RecyclerView;
 import android.support.v7.widget.RecyclerView.LayoutParams;
 import android.view.View;
 import android.view.ViewGroup;
@@ -39,7 +38,7 @@
 import com.google.android.libraries.feed.sharedstream.logging.VisibilityMonitor;
 import com.google.android.libraries.feed.sharedstream.piet.PietEventLogger;
 import com.google.android.libraries.feed.sharedstream.publicapi.scroll.ScrollObservable;
-import com.google.android.libraries.feed.sharedstream.publicapi.scroll.ScrollObserver;
+import com.google.android.libraries.feed.sharedstream.scroll.PietScrollObserver;
 import com.google.android.libraries.feed.sharedstream.scroll.ScrollListenerNotifier;
 import com.google.search.now.ui.action.FeedActionPayloadProto.FeedActionPayload;
 import com.google.search.now.ui.piet.PietProto.Frame;
@@ -123,7 +122,8 @@
     this.streamActionApi = streamActionApi;
     this.swipeAction = swipeAction;
     this.actionParser = actionParser;
-    scrollObserver = new PietViewActionScrollObserver(frameAdapter, viewport, loggingListener);
+    scrollObserver =
+        new PietViewActionScrollObserver(frameAdapter, viewport, scrollObservable, loggingListener);
     // Need to reset padding here.  Setting a background can affect padding so if we switch from
     // a background which has padding to one that does not, then the padding needs to be removed.
     cardView.setPadding(0, 0, 0, 0);
@@ -171,6 +171,7 @@
     visibilityMonitor.setListener(null);
     if (scrollObserver != null) {
       scrollObservable.removeScrollObserver(scrollObserver);
+      scrollObserver.uninstallFirstDrawTrigger();
     }
     bound = false;
   }
@@ -217,33 +218,23 @@
         actionParser, "Action parser can only be retrieved once view holder has been bound");
   }
 
-  static class PietViewActionScrollObserver implements ScrollObserver {
-    private final FrameAdapter frameAdapter;
-    private final View viewport;
+  static class PietViewActionScrollObserver extends PietScrollObserver {
     private final LoggingListener loggingListener;
 
     PietViewActionScrollObserver(
-        FrameAdapter frameAdapter, View viewport, LoggingListener loggingListener) {
-      this.frameAdapter = frameAdapter;
-      this.viewport = viewport;
+        FrameAdapter frameAdapter,
+        View viewport,
+        ScrollObservable scrollObservable,
+        LoggingListener loggingListener) {
+      super(frameAdapter, viewport, scrollObservable);
       this.loggingListener = loggingListener;
     }
 
     @Override
     public void onScrollStateChanged(View view, String featureId, int newState, long timestamp) {
-      // There was logic in here previously ensuring that the state had changed; however, you can
-      // have a case where the feed is idle and you fling the feed, and it goes to idle again
-      // without triggering the observer during scrolling. Just triggering this every time the feed
-      // comes to rest appears to have the desired behavior. We can think about also triggering
-      // while the feed is scrolling; not sure how frequently the observer triggers during scroll.
-      if (newState == RecyclerView.SCROLL_STATE_IDLE) {
-        frameAdapter.triggerViewActions(viewport);
-      }
+      super.onScrollStateChanged(view, featureId, newState, timestamp);
       loggingListener.onScrollStateChanged(
           ScrollListenerNotifier.convertRecyclerViewScrollStateToListenerState(newState));
     }
-
-    @Override
-    public void onScroll(View view, String featureId, int dx, int dy) {}
   }
 }
diff --git a/src/main/java/com/google/android/libraries/feed/sharedstream/scroll/BUILD b/src/main/java/com/google/android/libraries/feed/sharedstream/scroll/BUILD
index 2919b45..d6e26dc 100644
--- a/src/main/java/com/google/android/libraries/feed/sharedstream/scroll/BUILD
+++ b/src/main/java/com/google/android/libraries/feed/sharedstream/scroll/BUILD
@@ -12,6 +12,7 @@
         "//src/main/java/com/google/android/libraries/feed/common/concurrent",
         "//src/main/java/com/google/android/libraries/feed/common/logging",
         "//src/main/java/com/google/android/libraries/feed/common/time",
+        "//src/main/java/com/google/android/libraries/feed/piet",
         "//src/main/java/com/google/android/libraries/feed/sharedstream/publicapi/scroll",
         "//src/main/proto/com/google/android/libraries/feed/sharedstream/proto:scroll_state_java_proto_lite",
         "@com_google_code_findbugs_jsr305//jar",
diff --git a/src/main/java/com/google/android/libraries/feed/sharedstream/scroll/PietScrollObserver.java b/src/main/java/com/google/android/libraries/feed/sharedstream/scroll/PietScrollObserver.java
new file mode 100644
index 0000000..ba8257a
--- /dev/null
+++ b/src/main/java/com/google/android/libraries/feed/sharedstream/scroll/PietScrollObserver.java
@@ -0,0 +1,111 @@
+// Copyright 2019 The Feed Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.libraries.feed.sharedstream.scroll;
+
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.View.OnAttachStateChangeListener;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import com.google.android.libraries.feed.piet.FrameAdapter;
+import com.google.android.libraries.feed.sharedstream.publicapi.scroll.ScrollObservable;
+import com.google.android.libraries.feed.sharedstream.publicapi.scroll.ScrollObserver;
+
+/** Scroll observer that triggers Piet scroll actions. */
+public class PietScrollObserver implements ScrollObserver {
+
+  private final FrameAdapter frameAdapter;
+  private final View viewport;
+  private final ScrollObservable scrollObservable;
+  private final FirstDrawTrigger firstDrawTrigger;
+
+  /**
+   * Construct a PietScrollObserver.
+   *
+   * @param frameAdapter Piet FrameAdapter which will have view actions triggered by this scroll
+   *     observer
+   * @param viewport the view containing the frameAdapter, the bounds of which determine whether the
+   *     frame is visible
+   * @param scrollObservable used to get the scroll state to ensure scrolling is idle before
+   *     triggering
+   */
+  @SuppressWarnings("initialization") // Doesn't like the OnAttachStateChangeListener.
+  public PietScrollObserver(
+      FrameAdapter frameAdapter, View viewport, ScrollObservable scrollObservable) {
+    this.frameAdapter = frameAdapter;
+    this.viewport = viewport;
+    this.scrollObservable = scrollObservable;
+
+    firstDrawTrigger = new FirstDrawTrigger();
+
+    frameAdapter
+        .getFrameContainer()
+        .addOnAttachStateChangeListener(
+            new OnAttachStateChangeListener() {
+              @Override
+              public void onViewAttachedToWindow(View view) {
+                installFirstDrawTrigger();
+              }
+
+              @Override
+              public void onViewDetachedFromWindow(View view) {
+                uninstallFirstDrawTrigger();
+              }
+            });
+  }
+
+  @Override
+  public void onScrollStateChanged(View view, String featureId, int newState, long timestamp) {
+    // There was logic in here previously ensuring that the state had changed; however, you can
+    // have a case where the feed is idle and you fling the feed, and it goes to idle again
+    // without triggering the observer during scrolling. Just triggering this every time the feed
+    // comes to rest appears to have the desired behavior. We can think about also triggering
+    // while the feed is scrolling; not sure how frequently the observer triggers during scroll.
+    if (newState == RecyclerView.SCROLL_STATE_IDLE) {
+      frameAdapter.triggerViewActions(viewport);
+    }
+  }
+
+  @Override
+  public void onScroll(View view, String featureId, int dx, int dy) {
+    // no-op
+  }
+
+  /** Install the first-draw triggering observer. */
+  public void installFirstDrawTrigger() {
+    frameAdapter
+        .getFrameContainer()
+        .getViewTreeObserver()
+        .addOnGlobalLayoutListener(firstDrawTrigger);
+  }
+
+  /** Uninstall the first-draw triggering observer. */
+  public void uninstallFirstDrawTrigger() {
+    frameAdapter
+        .getFrameContainer()
+        .getViewTreeObserver()
+        .removeOnGlobalLayoutListener(firstDrawTrigger);
+  }
+
+  class FirstDrawTrigger implements OnGlobalLayoutListener {
+    @Override
+    public void onGlobalLayout() {
+      uninstallFirstDrawTrigger();
+
+      if (scrollObservable.getCurrentScrollState() == RecyclerView.SCROLL_STATE_IDLE) {
+        frameAdapter.triggerViewActions(viewport);
+      }
+    }
+  }
+}
diff --git a/src/test/java/com/google/android/libraries/feed/sharedstream/scroll/BUILD b/src/test/java/com/google/android/libraries/feed/sharedstream/scroll/BUILD
index aad383f..8725566 100644
--- a/src/test/java/com/google/android/libraries/feed/sharedstream/scroll/BUILD
+++ b/src/test/java/com/google/android/libraries/feed/sharedstream/scroll/BUILD
@@ -3,6 +3,28 @@
 licenses(["notice"])  # Apache 2
 
 android_local_test(
+    name = "PietScrollObserverTest",
+    size = "small",
+    timeout = "moderate",
+    srcs = ["PietScrollObserverTest.java"],
+    manifest_values = DEFAULT_ANDROID_LOCAL_TEST_MANIFEST,
+    deps = [
+        "//src/main/java/com/google/android/libraries/feed/piet",
+        "//src/main/java/com/google/android/libraries/feed/piet/host",
+        "//src/main/java/com/google/android/libraries/feed/piet/testing",
+        "//src/main/java/com/google/android/libraries/feed/sharedstream/publicapi/scroll",
+        "//src/main/java/com/google/android/libraries/feed/sharedstream/scroll",
+        "//src/main/proto/search/now/ui/piet:piet_java_proto_lite",
+        "//third_party:robolectric",
+        "@com_google_protobuf_javalite//:protobuf_java_lite",
+        "@maven//:com_android_support_recyclerview_v7",
+        "@maven//:com_google_guava_guava",
+        "@maven//:org_mockito_mockito_core",
+        "@robolectric//bazel:android-all",
+    ],
+)
+
+android_local_test(
     name = "ScrollListenerNotifierTest",
     size = "small",
     timeout = "moderate",
diff --git a/src/test/java/com/google/android/libraries/feed/sharedstream/scroll/PietScrollObserverTest.java b/src/test/java/com/google/android/libraries/feed/sharedstream/scroll/PietScrollObserverTest.java
new file mode 100644
index 0000000..704f25a
--- /dev/null
+++ b/src/test/java/com/google/android/libraries/feed/sharedstream/scroll/PietScrollObserverTest.java
@@ -0,0 +1,147 @@
+// Copyright 2019 The Feed Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.android.libraries.feed.sharedstream.scroll;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import android.app.Activity;
+import android.support.v7.widget.RecyclerView;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.LinearLayout.LayoutParams;
+import com.google.android.libraries.feed.piet.FrameAdapter;
+import com.google.android.libraries.feed.piet.host.ActionHandler;
+import com.google.android.libraries.feed.piet.host.ActionHandler.ActionType;
+import com.google.android.libraries.feed.piet.testing.FakeFrameAdapter;
+import com.google.android.libraries.feed.sharedstream.publicapi.scroll.ScrollObservable;
+import com.google.common.collect.ImmutableList;
+import com.google.search.now.ui.piet.ActionsProto.Action;
+import com.google.search.now.ui.piet.PietProto.Frame;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link PietScrollObserverTest}. */
+@RunWith(RobolectricTestRunner.class)
+public class PietScrollObserverTest {
+
+  @Mock private ScrollObservable scrollObservable;
+  @Mock private ActionHandler actionHandler;
+
+  // .newBuilder().build() creates a unique instance we can check same() against.
+  private static final Action VIEW_ACTION = Action.newBuilder().build();
+
+  private FrameAdapter frameAdapter;
+  private FrameLayout viewport;
+  private LinearLayout frameView;
+  private PietScrollObserver pietScrollObserver;
+
+  @Before
+  public void setUp() {
+    initMocks(this);
+    Activity activity = Robolectric.setupActivity(Activity.class);
+    viewport = new FrameLayout(activity);
+    activity.getWindow().addContentView(viewport, new LayoutParams(100, 100));
+
+    frameAdapter =
+        FakeFrameAdapter.builder(activity)
+            .setActionHandler(actionHandler)
+            .addViewAction(VIEW_ACTION)
+            .build();
+    frameAdapter.bindModel(Frame.getDefaultInstance(), 0, null, ImmutableList.of());
+    frameView = frameAdapter.getFrameContainer();
+
+    pietScrollObserver = new PietScrollObserver(frameAdapter, viewport, scrollObservable);
+  }
+
+  @Test
+  public void testTriggersActionsWhenIdle() {
+    pietScrollObserver.onScrollStateChanged(viewport, "", RecyclerView.SCROLL_STATE_IDLE, 123L);
+
+    verify(actionHandler)
+        .handleAction(
+            same(VIEW_ACTION),
+            eq(ActionType.VIEW),
+            eq(Frame.getDefaultInstance()),
+            eq(frameAdapter.getFrameContainer()),
+            any() /* LogData */);
+  }
+
+  @Test
+  public void testDoesNotTriggerWhenScrolling() {
+    pietScrollObserver.onScrollStateChanged(viewport, "", RecyclerView.SCROLL_STATE_DRAGGING, 123L);
+
+    verifyZeroInteractions(actionHandler);
+  }
+
+  @Test
+  public void testTriggersOnFirstDraw() {
+    pietScrollObserver.installFirstDrawTrigger();
+    when(scrollObservable.getCurrentScrollState()).thenReturn(RecyclerView.SCROLL_STATE_IDLE);
+
+    frameView.getViewTreeObserver().dispatchOnGlobalLayout();
+
+    verify(actionHandler)
+        .handleAction(
+            same(VIEW_ACTION),
+            eq(ActionType.VIEW),
+            eq(Frame.getDefaultInstance()),
+            eq(frameView),
+            any() /* LogData */);
+  }
+
+  @Test
+  public void testDoesNotTriggerOnSecondDraw() {
+    pietScrollObserver.installFirstDrawTrigger();
+    when(scrollObservable.getCurrentScrollState()).thenReturn(RecyclerView.SCROLL_STATE_DRAGGING);
+
+    // trigger on global layout - actions will not trigger because scrolling is not IDLE
+    frameView.getViewTreeObserver().dispatchOnGlobalLayout();
+
+    when(scrollObservable.getCurrentScrollState()).thenReturn(RecyclerView.SCROLL_STATE_IDLE);
+
+    // trigger on global layout - actions will not trigger because observer has been removed.
+    frameView.getViewTreeObserver().dispatchOnGlobalLayout();
+
+    verifyZeroInteractions(actionHandler);
+  }
+
+  @Test
+  public void testTriggersOnAttach() {
+    // Actions will not fire before attached to window
+    frameView.getViewTreeObserver().dispatchOnGlobalLayout();
+    verifyZeroInteractions(actionHandler);
+
+    // Now attach to window and actions should fire
+    viewport.addView(frameView);
+    frameView.getViewTreeObserver().dispatchOnGlobalLayout();
+    verify(actionHandler)
+        .handleAction(
+            same(VIEW_ACTION),
+            eq(ActionType.VIEW),
+            eq(Frame.getDefaultInstance()),
+            eq(frameView),
+            any() /* LogData */);
+  }
+}