Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import android.graphics.Rect;
import android.view.View;

import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Px;
import androidx.recyclerview.widget.RecyclerView;
Expand Down Expand Up @@ -41,6 +42,7 @@ class EpoxyVisibilityItem {
@Px
private int viewportWidth;

private boolean partiallyVisible = false;
private boolean fullyVisible = false;
private boolean visible = false;
private boolean focusedVisible = false;
Expand Down Expand Up @@ -110,6 +112,19 @@ void handleFocus(EpoxyViewHolder epoxyHolder, boolean detachEvent) {
}
}

void handlePartialImpressionVisible(EpoxyViewHolder epoxyHolder, boolean detachEvent,
@IntRange(from = 0, to = 100) int thresholdPercentage) {
boolean previousPartiallyVisible = partiallyVisible;
partiallyVisible = !detachEvent && isPartiallyVisible(thresholdPercentage);
if (partiallyVisible != previousPartiallyVisible) {
if (partiallyVisible) {
epoxyHolder.visibilityStateChanged(VisibilityState.PARTIAL_IMPRESSION_VISIBLE);
} else {
epoxyHolder.visibilityStateChanged(VisibilityState.PARTIAL_IMPRESSION_INVISIBLE);
}
}
}

void handleFullImpressionVisible(EpoxyViewHolder epoxyHolder, boolean detachEvent) {
boolean previousFullyVisible = fullyVisible;
fullyVisible = !detachEvent && isFullyVisible();
Expand Down Expand Up @@ -153,6 +168,17 @@ private boolean isInFocusVisible() {
: totalArea == visibleArea;
}

private boolean isPartiallyVisible(@IntRange(from = 0, to = 100) int thresholdPercentage) {
// special case 0%: trigger as soon as some pixels are one the screen
if (thresholdPercentage == 0) return isVisible();

final int totalArea = height * width;
final int visibleArea = visibleHeight * visibleWidth;
final float visibleAreaPercentage = (visibleArea / (float) totalArea) * 100;

return visibleAreaPercentage >= thresholdPercentage;
}

private boolean isFullyVisible() {
return visibleHeight == height && visibleWidth == width;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.util.Map;

import androidx.annotation.IdRes;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
Expand Down Expand Up @@ -82,6 +83,9 @@ private static void setTracker(

private boolean onChangedEnabled = true;

@Nullable
private Integer partialImpressionThresholdPercentage = null;

/** All nested visibility trackers */
private Map<RecyclerView, EpoxyVisibilityTracker> nestedTrackers = new HashMap<>();

Expand All @@ -100,6 +104,20 @@ public void setOnChangedEnabled(boolean enabled) {
onChangedEnabled = enabled;
}

/**
* Set the threshold of percentage visible area to identify the partial impression view state.
*
* @param thresholdPercentage Percentage of visible area of an element in the range [0..100].
* Defaults to <code>null</code>, which disables
* {@link VisibilityState#PARTIAL_IMPRESSION_VISIBLE} and
* {@link VisibilityState#PARTIAL_IMPRESSION_INVISIBLE} events.
*/
public void setPartialImpressionThresholdPercentage(
@Nullable @IntRange(from = 0, to = 100) Integer thresholdPercentage
) {
partialImpressionThresholdPercentage = thresholdPercentage;
}

/**
* Attach the tracker.
*
Expand Down Expand Up @@ -279,6 +297,12 @@ private boolean processVisibilityEvents(
if (vi.update(itemView, recyclerView, detachEvent)) {
// View is measured, process events
vi.handleVisible(epoxyHolder, detachEvent);

if (partialImpressionThresholdPercentage != null) {
vi.handlePartialImpressionVisible(epoxyHolder, detachEvent,
partialImpressionThresholdPercentage);
}

vi.handleFocus(epoxyHolder, detachEvent);
vi.handleFullImpressionVisible(epoxyHolder, detachEvent);
changed = vi.handleChanged(epoxyHolder, onChangedEnabled);
Expand All @@ -292,6 +316,7 @@ private void processChildRecyclerViewAttached(@NonNull RecyclerView childRecycle
EpoxyVisibilityTracker tracker = getTracker(childRecyclerView);
if (tracker == null) {
tracker = new EpoxyVisibilityTracker();
tracker.setPartialImpressionThresholdPercentage(partialImpressionThresholdPercentage);
tracker.attach(childRecyclerView);
}
nestedTrackers.put(childRecyclerView, tracker);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
public final class VisibilityState {

@Retention(RetentionPolicy.SOURCE)
@IntDef({VISIBLE, INVISIBLE, FOCUSED_VISIBLE, UNFOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE})
@IntDef({VISIBLE,
INVISIBLE,
FOCUSED_VISIBLE,
UNFOCUSED_VISIBLE,
FULL_IMPRESSION_VISIBLE,
PARTIAL_IMPRESSION_VISIBLE,
PARTIAL_IMPRESSION_INVISIBLE})
public @interface Visibility {
}

Expand Down Expand Up @@ -45,4 +51,20 @@ public final class VisibilityState {
* become visible.
*/
public static final int FULL_IMPRESSION_VISIBLE = 4;

/**
* Event triggered when a Component enters the Partial Impression Range. This happens, for
* instance in the case of a vertical RecyclerView, when the percentage of the visible area is
* at least the specified threshold. The threshold can be set in
* {@link EpoxyVisibilityTracker#setPartialImpressionThresholdPercentage(int)}.
*/
public static final int PARTIAL_IMPRESSION_VISIBLE = 5;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you look at the FOCUSED_VISIBLE it protect against component that are bigger than the view port.

  /**
   * Event triggered when a Component enters the Focused Range. This happens when either the
   * Component occupies at least half of the viewport or, if the Component is smaller than half of
   * the viewport, when the it is fully visible.
   */
  public static final int FOCUSED_VISIBLE = 2;

I was wondering if you want to implements the same protection?

The problem is, if one of the component is bigger than view port (or if threshold % bigger that view port) you will never receive a PARTIAL_IMPRESSION_VISIBLE event.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have a look into FOCUSED_VISIBLE and it's protection

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is, if one of the component is bigger than view port (or if threshold % bigger that view port) you will never receive a PARTIAL_IMPRESSION_VISIBLE event.

What do you think users would expect? I see some possible ways of dealing with this:

  1. Do actually define it as accurate behavior that no event is fired if the threshold cannot be met. The events would more likely to be fired if the threshold is small. Event firing cannot be guaranteed though as you mentioned.
  2. If the element is bigger than the viewport, change the frame of reference for the threshold to relate to the viewport instead of the element (so with a 50% threshold the event would fire once the element covers 50% of the viewport). The question here is if this two-fold behavior could be confusing to users of the lib or just plain undesired.
  3. Hybrid approach: For as long as the threshold can be met, fire it. In the case the threshold cannot be reached, fired it if the view covers the whole viewport.

@eboudrant From your experience with epoxy in use and feedback so far, which of these behaviors (or which other suggestion) would be most appropriate?

Copy link
Author

@mediavrog mediavrog May 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another comment: I looked at the code for FULL_IMPRESSION_VISIBLE, which also does not handle the case if an element is bigger than the viewport. I tend towards keeping this same behavior for the PARTIAL_IMPRESSION_VISIBLE as well as it would be consistent and predictable. (referring the list above it would be behavior 1.)

In the case with FOCUSED_VISIBLE, the definition of what is focused gives some leeway for interpretation, so I think in that case having a fluid focused range is appropriate.


/**
* Event triggered when a Component exits the Partial Impression Range. This happens, for
* instance in the case of a vertical RecyclerView, when the percentage of the visible area is
* less than a specified threshold. The threshold can be set in
* {@link EpoxyVisibilityTracker#setPartialImpressionThresholdPercentage(int)}.
*/
public static final int PARTIAL_IMPRESSION_INVISIBLE = 6;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker.DEBUG_LOG
import com.airbnb.epoxy.VisibilityState.INVISIBLE
import com.airbnb.epoxy.VisibilityState.PARTIAL_IMPRESSION_INVISIBLE
import com.airbnb.epoxy.VisibilityState.PARTIAL_IMPRESSION_VISIBLE
import com.airbnb.epoxy.VisibilityState.VISIBLE
import org.junit.After
import org.junit.Before
Expand Down Expand Up @@ -98,6 +100,7 @@ class EpoxyVisibilityTrackerNestedTest {
percentVisibleHeight = 0.0f,
percentVisibleWidth = 0.0f,
visible = false,
partialImpression = false,
fullImpression = false,
visitedStates = EpoxyVisibilityTrackerTest.ALL_STATES
)
Expand All @@ -110,9 +113,12 @@ class EpoxyVisibilityTrackerNestedTest {
percentVisibleHeight = 0.0f,
percentVisibleWidth = 0.0f,
visible = false,
partialImpression = false,
fullImpression = false,
visitedStates = intArrayOf(
VISIBLE,
PARTIAL_IMPRESSION_VISIBLE,
PARTIAL_IMPRESSION_INVISIBLE,
INVISIBLE
)
)
Expand All @@ -127,8 +133,12 @@ class EpoxyVisibilityTrackerNestedTest {
visibleHeight = 50,
visibleWidth = 100,
visible = true,
partialImpression = true,
fullImpression = false,
visitedStates = intArrayOf(VISIBLE)
visitedStates = intArrayOf(
VISIBLE,
PARTIAL_IMPRESSION_VISIBLE
)
)
}
}
Expand All @@ -138,8 +148,12 @@ class EpoxyVisibilityTrackerNestedTest {
visibleHeight = 50,
visibleWidth = 50,
visible = true,
partialImpression = true,
fullImpression = false,
visitedStates = intArrayOf(VISIBLE)
visitedStates = intArrayOf(
VISIBLE,
PARTIAL_IMPRESSION_VISIBLE
)
)
}
}
Expand All @@ -152,6 +166,7 @@ class EpoxyVisibilityTrackerNestedTest {
percentVisibleHeight = 100.0f,
percentVisibleWidth = 100.0f,
visible = false,
partialImpression = true,
fullImpression = true,
visitedStates = EpoxyVisibilityTrackerTest.ALL_STATES
)
Expand All @@ -163,8 +178,12 @@ class EpoxyVisibilityTrackerNestedTest {
percentVisibleHeight = 100.0f,
percentVisibleWidth = 50.0f,
visible = false,
partialImpression = true,
fullImpression = false,
visitedStates = intArrayOf(VISIBLE)
visitedStates = intArrayOf(
VISIBLE,
PARTIAL_IMPRESSION_VISIBLE
)
)
}
}
Expand Down Expand Up @@ -246,4 +265,4 @@ class EpoxyVisibilityTrackerNestedTest {
fun tearDown() {
epoxyVisibilityTracker.detach(recyclerView)
}
}
}
Loading