diff --git a/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityItem.java b/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityItem.java index c7d165e07c..cf545eeb9a 100644 --- a/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityItem.java +++ b/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityItem.java @@ -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; @@ -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; @@ -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(); @@ -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; } diff --git a/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.java b/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.java index 4c180d5d7d..d5f16cf0ff 100644 --- a/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.java +++ b/epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.java @@ -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; @@ -82,6 +83,9 @@ private static void setTracker( private boolean onChangedEnabled = true; + @Nullable + private Integer partialImpressionThresholdPercentage = null; + /** All nested visibility trackers */ private Map nestedTrackers = new HashMap<>(); @@ -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 null, 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. * @@ -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); @@ -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); diff --git a/epoxy-adapter/src/main/java/com/airbnb/epoxy/VisibilityState.java b/epoxy-adapter/src/main/java/com/airbnb/epoxy/VisibilityState.java index 3460a2dea2..77f1b9ef56 100644 --- a/epoxy-adapter/src/main/java/com/airbnb/epoxy/VisibilityState.java +++ b/epoxy-adapter/src/main/java/com/airbnb/epoxy/VisibilityState.java @@ -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 { } @@ -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; + + /** + * 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; } diff --git a/epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerNestedTest.kt b/epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerNestedTest.kt index a70945b8aa..fe6bc8b51f 100644 --- a/epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerNestedTest.kt +++ b/epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerNestedTest.kt @@ -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 @@ -98,6 +100,7 @@ class EpoxyVisibilityTrackerNestedTest { percentVisibleHeight = 0.0f, percentVisibleWidth = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = EpoxyVisibilityTrackerTest.ALL_STATES ) @@ -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 ) ) @@ -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 + ) ) } } @@ -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 + ) ) } } @@ -152,6 +166,7 @@ class EpoxyVisibilityTrackerNestedTest { percentVisibleHeight = 100.0f, percentVisibleWidth = 100.0f, visible = false, + partialImpression = true, fullImpression = true, visitedStates = EpoxyVisibilityTrackerTest.ALL_STATES ) @@ -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 + ) ) } } @@ -246,4 +265,4 @@ class EpoxyVisibilityTrackerNestedTest { fun tearDown() { epoxyVisibilityTracker.detach(recyclerView) } -} \ No newline at end of file +} diff --git a/epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerTest.kt b/epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerTest.kt index 1f9dc5ed43..2d6bec9428 100644 --- a/epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerTest.kt +++ b/epoxy-adapter/src/test/java/com/airbnb/epoxy/EpoxyVisibilityTrackerTest.kt @@ -12,6 +12,8 @@ import com.airbnb.epoxy.EpoxyVisibilityTracker.DEBUG_LOG import com.airbnb.epoxy.VisibilityState.FOCUSED_VISIBLE import com.airbnb.epoxy.VisibilityState.FULL_IMPRESSION_VISIBLE 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.UNFOCUSED_VISIBLE import com.airbnb.epoxy.VisibilityState.VISIBLE import org.junit.After @@ -54,6 +56,8 @@ class EpoxyVisibilityTrackerTest { INVISIBLE, FOCUSED_VISIBLE, UNFOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, + PARTIAL_IMPRESSION_INVISIBLE, FULL_IMPRESSION_VISIBLE ) @@ -102,10 +106,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) @@ -121,8 +127,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, + partialImpression = true, fullImpression = false, - visitedStates = intArrayOf(VISIBLE) + visitedStates = intArrayOf( + VISIBLE, + PARTIAL_IMPRESSION_VISIBLE + ) ) } } @@ -136,6 +146,7 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = intArrayOf() ) @@ -149,6 +160,143 @@ class EpoxyVisibilityTrackerTest { } } + /** + * Test visibility events when loading a recycler view but without any partial visible states + */ + @Test + fun testDataAttachedToRecyclerView_WithoutPartial() { + // disable partial visibility states + epoxyVisibilityTracker.setPartialImpressionThresholdPercentage(null) + + val testHelper = buildTestData(10, TWO_AND_HALF_VISIBLE) + + val firstHalfVisibleItem = 2 + val firstInvisibleItem = firstHalfVisibleItem + 1 + + // Verify visibility event + testHelper.forEachIndexed { index, helper -> + when { + + index in 0 until firstHalfVisibleItem -> { + + // Item expected to be 100% visible + + with(helper) { + assert( + visibleHeight = itemHeight, + percentVisibleHeight = 100.0f, + visible = true, + partialImpression = false, + fullImpression = true, + visitedStates = intArrayOf( + VISIBLE, + FOCUSED_VISIBLE, + FULL_IMPRESSION_VISIBLE + ) + ) + } + } + + index == firstHalfVisibleItem -> { + + // Item expected to be 50% visible + + with(helper) { + assert( + visibleHeight = itemHeight / 2, + percentVisibleHeight = 50.0f, + visible = true, + partialImpression = false, + fullImpression = false, + visitedStates = intArrayOf( + VISIBLE + ) + ) + } + } + + index in firstInvisibleItem..9 -> { + + // Item expected not to be visible + + with(helper) { + assert( + visibleHeight = 0, + percentVisibleHeight = 0.0f, + visible = false, + partialImpression = false, + fullImpression = false, + visitedStates = intArrayOf() + ) + } + } + + else -> throw IllegalStateException("index should not be bigger than 9") + } + + log("$index valid") + } + } + + /** + * Test partial visibility events when loading a recycler view + */ + @Test + fun testDataAttachedToRecyclerView_OneElementJustBelowPartialThreshold() { + val testHelper = buildTestData(2, 1.49f) + + val firstAlmostPartiallyVisibleItem = 1 + + // Verify visibility event + testHelper.forEachIndexed { index, helper -> + when { + + index in 0 until firstAlmostPartiallyVisibleItem -> { + + // Item expected to be 100% visible + + with(helper) { + assert( + visibleHeight = itemHeight, + percentVisibleHeight = 100.0f, + visible = true, + partialImpression = true, + fullImpression = true, + visitedStates = intArrayOf( + VISIBLE, + FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, + FULL_IMPRESSION_VISIBLE + ) + ) + } + } + + index == firstAlmostPartiallyVisibleItem -> { + + // Item expected to be 49% visible + + with(helper) { + assert( + visibleHeight = (itemHeight * 0.49).toInt(), + percentVisibleHeight = 49.0f, + visible = true, + partialImpression = false, + fullImpression = false, + visitedStates = intArrayOf( + VISIBLE + ) + ) + } + } + + else -> throw IllegalStateException("index should not be bigger than 9") + } + + log("$index valid") + } + } + /** * Test visibility events when adding data to a recycler view (item inserted from adapter) */ @@ -170,10 +318,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) @@ -184,10 +334,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, + partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, UNFOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE ) @@ -199,9 +351,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = intArrayOf( VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, + PARTIAL_IMPRESSION_INVISIBLE, INVISIBLE ) ) @@ -228,6 +383,7 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) @@ -238,10 +394,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) @@ -252,8 +410,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, + partialImpression = true, fullImpression = false, - visitedStates = intArrayOf(VISIBLE) + visitedStates = intArrayOf( + VISIBLE, + PARTIAL_IMPRESSION_VISIBLE + ) ) } } @@ -305,10 +467,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) @@ -320,10 +484,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) @@ -335,6 +501,7 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) @@ -379,10 +546,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) @@ -394,10 +563,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) @@ -409,6 +580,7 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) @@ -444,6 +616,7 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) @@ -459,6 +632,7 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) @@ -474,6 +648,7 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = ALL_STATES ) @@ -489,10 +664,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, + partialImpression = true, fullImpression = false, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE, UNFOCUSED_VISIBLE ) @@ -509,10 +686,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, FOCUSED_VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FULL_IMPRESSION_VISIBLE ) ) @@ -553,12 +732,15 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = intArrayOf( VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE, UNFOCUSED_VISIBLE, + PARTIAL_IMPRESSION_INVISIBLE, INVISIBLE ) ) @@ -574,8 +756,14 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, - visitedStates = intArrayOf(VISIBLE, INVISIBLE) + visitedStates = intArrayOf( + VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, + PARTIAL_IMPRESSION_INVISIBLE, + INVISIBLE + ) ) } } @@ -589,6 +777,7 @@ class EpoxyVisibilityTrackerTest { visibleHeight = 0, percentVisibleHeight = 0.0f, visible = false, + partialImpression = false, fullImpression = false, visitedStates = intArrayOf() ) @@ -604,8 +793,12 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight / 2, percentVisibleHeight = 50.0f, visible = true, + partialImpression = true, fullImpression = false, - visitedStates = intArrayOf(VISIBLE) + visitedStates = intArrayOf( + VISIBLE, + PARTIAL_IMPRESSION_VISIBLE + ) ) } } @@ -619,9 +812,11 @@ class EpoxyVisibilityTrackerTest { visibleHeight = itemHeight, percentVisibleHeight = 100.0f, visible = true, + partialImpression = true, fullImpression = true, visitedStates = intArrayOf( VISIBLE, + PARTIAL_IMPRESSION_VISIBLE, FOCUSED_VISIBLE, FULL_IMPRESSION_VISIBLE ) @@ -687,6 +882,7 @@ class EpoxyVisibilityTrackerTest { fun setup() { Robolectric.setupActivity(Activity::class.java).apply { setContentView(EpoxyRecyclerView(this).apply { + epoxyVisibilityTracker.setPartialImpressionThresholdPercentage(50) epoxyVisibilityTracker.attach(this) recyclerView = this // Plug an epoxy controller @@ -746,10 +942,14 @@ class EpoxyVisibilityTrackerTest { log("onVisibilityStateChanged[$itemPosition](id=${helper.id})=${state.description()}") helper.visitedStates.add(state) when (state) { - VISIBLE, INVISIBLE -> helper.visible = state == VISIBLE - FOCUSED_VISIBLE, UNFOCUSED_VISIBLE -> helper.focused = state == FOCUSED_VISIBLE - FULL_IMPRESSION_VISIBLE -> helper.fullImpression = state == - FULL_IMPRESSION_VISIBLE + VISIBLE, INVISIBLE -> + helper.visible = state == VISIBLE + FOCUSED_VISIBLE, UNFOCUSED_VISIBLE -> + helper.focused = state == FOCUSED_VISIBLE + PARTIAL_IMPRESSION_VISIBLE, PARTIAL_IMPRESSION_INVISIBLE -> + helper.partialImpression = state == PARTIAL_IMPRESSION_VISIBLE + FULL_IMPRESSION_VISIBLE -> + helper.fullImpression = state == FULL_IMPRESSION_VISIBLE } } } @@ -767,6 +967,7 @@ class EpoxyVisibilityTrackerTest { var percentVisibleWidth = 0.0f var visible = false var focused = false + var partialImpression = false var fullImpression = false fun assert( @@ -776,6 +977,7 @@ class EpoxyVisibilityTrackerTest { percentVisibleHeight: Float? = null, percentVisibleWidth: Float? = null, visible: Boolean? = null, + partialImpression: Boolean? = null, fullImpression: Boolean? = null, visitedStates: IntArray? = null ) { @@ -791,7 +993,7 @@ class EpoxyVisibilityTrackerTest { log("assert visibleHeight, got $it, expected ${this.visibleHeight}") Assert.assertTrue( "visibleHeight expected ${it}px got ${this.visibleHeight}px", - Math.abs(it - this.visibleHeight) < TOLERANCE_PIXELS + Math.abs(it - this.visibleHeight) <= TOLERANCE_PIXELS ) } visibleWidth?.let { @@ -799,21 +1001,23 @@ class EpoxyVisibilityTrackerTest { log("assert visibleWidth, got $it, expected ${this.visibleWidth}") Assert.assertTrue( "visibleWidth expected ${it}px got ${this.visibleWidth}px", - Math.abs(it - this.visibleWidth) < TOLERANCE_PIXELS + Math.abs(it - this.visibleWidth) <= TOLERANCE_PIXELS ) } percentVisibleHeight?.let { Assert.assertEquals( "percentVisibleHeight expected $it got ${this.percentVisibleHeight}", it, - this.percentVisibleHeight + this.percentVisibleHeight, + 0.05f ) } percentVisibleWidth?.let { Assert.assertEquals( "percentVisibleWidth expected $it got ${this.percentVisibleWidth}", it, - this.percentVisibleWidth + this.percentVisibleWidth, + 0.05f ) } visible?.let { @@ -823,6 +1027,13 @@ class EpoxyVisibilityTrackerTest { this.visible ) } + partialImpression?.let { + Assert.assertEquals( + "partialImpression expected $it got ${this.partialImpression}", + it, + this.partialImpression + ) + } fullImpression?.let { Assert.assertEquals( "fullImpression expected $it got ${this.fullImpression}", @@ -888,7 +1099,9 @@ private fun Int.description(): String { INVISIBLE -> "INVISIBLE" FOCUSED_VISIBLE -> "FOCUSED_VISIBLE" UNFOCUSED_VISIBLE -> "UNFOCUSED_VISIBLE" + PARTIAL_IMPRESSION_VISIBLE -> "PARTIAL_IMPRESSION_VISIBLE" + PARTIAL_IMPRESSION_INVISIBLE -> "PARTIAL_IMPRESSION_INVISIBLE" FULL_IMPRESSION_VISIBLE -> "FULL_IMPRESSION_VISIBLE" else -> throw IllegalStateException("Please declare new state here") } -} \ No newline at end of file +} diff --git a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/MainActivity.kt b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/MainActivity.kt index fc39309cff..bf3b515f23 100644 --- a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/MainActivity.kt +++ b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/MainActivity.kt @@ -24,6 +24,7 @@ class MainActivity : AppCompatActivity() { // Attach the visibility tracker to the RecyclerView. This will enable visibility events. val epoxyVisibilityTracker = EpoxyVisibilityTracker() + epoxyVisibilityTracker.setPartialImpressionThresholdPercentage(75) epoxyVisibilityTracker.attach(recyclerView) recyclerView.withModels { diff --git a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/CarouselItemCustomView.kt b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/CarouselItemCustomView.kt index ed65c726de..acccc2a121 100644 --- a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/CarouselItemCustomView.kt +++ b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/CarouselItemCustomView.kt @@ -62,6 +62,14 @@ class CarouselItemCustomView @JvmOverloads constructor( Log.d(TAG, "$title UnfocusedVisible") onVisibilityEventDrawable.focusedVisible = false } + VisibilityState.PARTIAL_IMPRESSION_VISIBLE -> { + Log.d(TAG, "$title PartialImpressionVisible") + onVisibilityEventDrawable.partialImpression = true + } + VisibilityState.PARTIAL_IMPRESSION_INVISIBLE -> { + Log.d(TAG, "$title PartialImpressionInVisible") + onVisibilityEventDrawable.partialImpression = false + } VisibilityState.FULL_IMPRESSION_VISIBLE -> { Log.d(TAG, "$title FullImpressionVisible") onVisibilityEventDrawable.fullImpression = true diff --git a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/ItemCustomView.kt b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/ItemCustomView.kt index a17454d498..32b3a1b462 100644 --- a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/ItemCustomView.kt +++ b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/ItemCustomView.kt @@ -88,6 +88,14 @@ class ItemCustomView @JvmOverloads constructor( Log.d(TAG, "$title UnfocusedVisible") onVisibilityEventDrawable.focusedVisible = false } + VisibilityState.PARTIAL_IMPRESSION_VISIBLE -> { + Log.d(TAG, "$title PartialImpressionVisible") + onVisibilityEventDrawable.partialImpression = true + } + VisibilityState.PARTIAL_IMPRESSION_INVISIBLE -> { + Log.d(TAG, "$title PartialImpressionInVisible") + onVisibilityEventDrawable.partialImpression = false + } VisibilityState.FULL_IMPRESSION_VISIBLE -> { Log.d(TAG, "$title FullImpressionVisible") onVisibilityEventDrawable.fullImpression = true diff --git a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/OnVisibilityEventDrawable.kt b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/OnVisibilityEventDrawable.kt index 4adb440b5e..f8330f961a 100644 --- a/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/OnVisibilityEventDrawable.kt +++ b/kotlinsample/src/main/java/com/airbnb/epoxy/kotlinsample/models/OnVisibilityEventDrawable.kt @@ -12,7 +12,8 @@ import android.graphics.drawable.Drawable * Drawable for sample app that draw the current visibility state : * - circle #1 : visible * - circle #2 : focused - * - circle #3 : full impression + * - circle #3 : partial impression + * - circle #4 : full impression * - rectangle : visibility percentage */ class OnVisibilityEventDrawable(context: Context) : Drawable() { @@ -25,9 +26,10 @@ class OnVisibilityEventDrawable(context: Context) : Drawable() { isAntiAlias = true strokeWidth = density } + private val circleCount = 4 init { - setBounds(0, 0, padding.toInt() * 4 + diameter.toInt() * 3, diameter.toInt()) + setBounds(0, 0, padding.toInt() * (circleCount + 1) + diameter.toInt() * circleCount, diameter.toInt()) } var visible = false @@ -42,6 +44,12 @@ class OnVisibilityEventDrawable(context: Context) : Drawable() { invalidateSelf() } + var partialImpression = false + set(value) { + field = value + invalidateSelf() + } + var fullImpression = false set(value) { field = value @@ -63,6 +71,7 @@ class OnVisibilityEventDrawable(context: Context) : Drawable() { fun reset() { visible = false focusedVisible = false + partialImpression = false fullImpression = false percentHeight = 0.0f percentWidth = 0.0f @@ -77,6 +86,10 @@ class OnVisibilityEventDrawable(context: Context) : Drawable() { paint.style = if (visible) Paint.Style.FILL_AND_STROKE else Paint.Style.STROKE canvas.drawCircle(x, y, diameter / 2, paint) + x += diameter + padding + paint.style = if (partialImpression) Paint.Style.FILL_AND_STROKE else Paint.Style.STROKE + canvas.drawCircle(x, y, diameter / 2, paint) + x += diameter + padding paint.style = if (focusedVisible) Paint.Style.FILL_AND_STROKE else Paint.Style.STROKE canvas.drawCircle(x, y, diameter / 2, paint) @@ -100,4 +113,4 @@ class OnVisibilityEventDrawable(context: Context) : Drawable() { override fun getOpacity() = PixelFormat.TRANSLUCENT override fun setColorFilter(colorFilter: ColorFilter?) = Unit -} \ No newline at end of file +}