Skip to content

Commit

Permalink
Follow-up for visibility tracking : adapter data changes (#575)
Browse files Browse the repository at this point in the history
* Fix for delete/moved/inserted

* Fix the insert / delete from the adapter

* Fix the moved within the adapter

* Disable logs by default

* remove checkstyle warning

* fix javadoc in annotations

* Optimization on detach event, only data changed require to re-process all children

* Also shift the items between the move from-to. With tests.

* Optimize the data changed flag, will set to true only in case visible data get updated

* Reset visibility item only when the adapter position is valid and changed

* Remove system.out log

* Add explicit clearVisibilityStates() in api so user is in control

* Remove extra `recyclerView.getChildViewHolder(child)`
  • Loading branch information
eboudrant authored and elihart committed Oct 17, 2018
1 parent bb634e8 commit 52eaacb
Show file tree
Hide file tree
Showing 5 changed files with 449 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class EpoxyVisibilityItem {
/** Store last value for de-duping */
private int lastVisibleSizeNotified = NOT_NOTIFIED;

EpoxyVisibilityItem(int adapterPosition) {
reset(adapterPosition);
}

/**
* Update the visibility item according the current layout.
*
Expand Down Expand Up @@ -173,5 +177,9 @@ private boolean checkAndUpdateUnfocusedVisible(boolean detachEvent) {
private boolean checkAndUpdateFullImpressionVisible() {
return fullyVisible = visibleSize == sizeInScrollingDirection;
}

void shiftBy(int offsetPosition) {
adapterPosition += offsetPosition;
}
}

254 changes: 226 additions & 28 deletions epoxy-adapter/src/main/java/com/airbnb/epoxy/EpoxyVisibilityTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.Adapter;
import android.support.v7.widget.RecyclerView.AdapterDataObserver;
import android.support.v7.widget.RecyclerView.OnChildAttachStateChangeListener;
import android.support.v7.widget.RecyclerView.OnScrollListener;
import android.support.v7.widget.RecyclerView.ViewHolder;
import android.util.Log;
import android.util.SparseArray;
import android.view.View;
import android.view.View.OnLayoutChangeListener;

import java.util.ArrayList;
import java.util.List;

/**
* A simple way to track visibility events on {@link com.airbnb.epoxy.EpoxyModel} within a {@link
* android.support.v7.widget.RecyclerView}.
Expand All @@ -21,24 +27,40 @@
* Note regarding nested lists: The visibility event tracking is not properly handled yet. This is
* on the todo.
* <p>
*
* @see OnVisibilityChanged
* @see OnVisibilityStateChanged
* @see OnModelVisibilityChangedListener
* @see OnModelVisibilityStateChangedListener
*/
public class EpoxyVisibilityTracker {

private static final String TAG = "EpoxyVisibilityTracker";

// Not actionable at runtime. It is only useful for internal test-troubleshooting.
static final boolean DEBUG_LOG = false;

/** Maintain visibility item indexed by view id (identity hashcode) */
private final SparseArray<EpoxyVisibilityItem> visibilityIdToItemMap = new SparseArray<>();
private final List<EpoxyVisibilityItem> visibilityIdToItems = new ArrayList<>();

/** listener used to process scroll, layout and attach events */
private final Listener listener = new Listener();

/** listener used to process data events */
private final DataObserver observer = new DataObserver();

@Nullable
private RecyclerView attachedRecyclerView = null;
@Nullable
private Adapter lastAdapterSeen = null;

private boolean onChangedEnabled = true;

/** This flag is for optimizing the process on detach. If detach is from data changed then it
* need to re-process all views, else no need (ex: scroll). */
private boolean visibleDataChanged = false;

/**
* Enable or disable visibility changed event. Default is `true`, disable it if you don't need
* (triggered by every pixel scrolled).
Expand All @@ -65,7 +87,7 @@ public void attach(@NonNull RecyclerView recyclerView) {
/**
* Detach the tracker
*
* @param recyclerView The recyclerview that the EpoxyController has its adapter added to.
* @param recyclerView The recycler view that the EpoxyController has its adapter added to.
*/
public void detach(@NonNull RecyclerView recyclerView) {
recyclerView.removeOnScrollListener(this.listener);
Expand All @@ -74,60 +96,132 @@ public void detach(@NonNull RecyclerView recyclerView) {
attachedRecyclerView = null;
}

private void processChildren() {
/**
* The tracker is storing visibility states internally and is using if to send events, only the
* difference is sent. Use this method to clear the states and thus regenerate the visibility
* events. This may be useful when you change the adapter on the {@link
* android.support.v7.widget.RecyclerView}
*/
public void clearVisibilityStates() {
// Clear our visibility items
visibilityIdToItemMap.clear();
visibilityIdToItems.clear();
}

private void processChangeEvent(String debug) {
processChangeEventWithDetachedView(null, debug);
}

private void processChangeEventWithDetachedView(@Nullable View detachedView, String debug) {
final RecyclerView recyclerView = attachedRecyclerView;
if (recyclerView != null) {

// On every every events lookup for a new adapter
processNewAdapterIfNecessary();

// Process the detached child if any
if (detachedView != null) {
processChild(detachedView, true, debug);
}

// Process all attached children

for (int i = 0; i < recyclerView.getChildCount(); i++) {
final View child = recyclerView.getChildAt(i);
if (child != null) {
processChild(child);
if (child != null && child != detachedView) {
// Is some case the detached child is still in the recycler view. Don't process it as it
// was already processed.
processChild(child, false, debug);
}
}
}
}

private void processChild(@NonNull View child) {
processChild(child, false);
/**
* If there is a new adapter on the attached RecyclerView it will resister the data observer and
* clear the current visibility states
*/
private void processNewAdapterIfNecessary() {
if (attachedRecyclerView != null && attachedRecyclerView.getAdapter() != null) {
if (lastAdapterSeen != attachedRecyclerView.getAdapter()) {
if (lastAdapterSeen != null) {
// Unregister the old adapter
lastAdapterSeen.unregisterAdapterDataObserver(this.observer);
}
// Register the new adapter
attachedRecyclerView.getAdapter().registerAdapterDataObserver(this.observer);
lastAdapterSeen = attachedRecyclerView.getAdapter();
}
}
}

private void processChild(@NonNull View child, boolean detachEvent) {
/**
* Don't call this method directly, it is called from
* {@link EpoxyVisibilityTracker#processVisibilityEvents}
*
* @param child the view to process for visibility event
* @param detachEvent true if the child was just detached
* @param eventOriginForDebug a debug strings used for logs
*/
private void processChild(@NonNull View child, boolean detachEvent, String eventOriginForDebug) {
final RecyclerView recyclerView = attachedRecyclerView;
if (recyclerView != null) {
recyclerView.getChildViewHolder(child);
final ViewHolder holder = recyclerView.getChildViewHolder(child);
if (holder instanceof EpoxyViewHolder) {
processVisibilityEvents(recyclerView, (EpoxyViewHolder) holder,
recyclerView.getLayoutManager().canScrollVertically(), detachEvent);
} else throw new IllegalEpoxyUsage(
"`EpoxyVisibilityTracker` cannot be used with non-epoxy view holders."
);
processVisibilityEvents(
recyclerView,
(EpoxyViewHolder) holder,
recyclerView.getLayoutManager().canScrollVertically(),
detachEvent,
eventOriginForDebug
);
} else {
throw new IllegalEpoxyUsage(
"`EpoxyVisibilityTracker` cannot be used with non-epoxy view holders."
);
}
}
}

/**
* Call this methods every time something related to ui (scroll, layout, ...) or something related
* to data changed.
*
* @param recyclerView the recycler view
* @param epoxyHolder the {@link RecyclerView}
* @param vertical true if the scrolling is vertical
* @param detachEvent true if the event originated from a view detached from the
* recycler view
* @param eventOriginForDebug a debug strings used for logs
*/
private void processVisibilityEvents(
@NonNull RecyclerView recyclerView,
@NonNull EpoxyViewHolder epoxyHolder,
boolean vertical, boolean detachEvent
boolean vertical, boolean detachEvent,
String eventOriginForDebug
) {

// TODO EpoxyVisibilityTrackerTest testInsertData / testInsertData are disabled as they fail as
// insert/delete not properly handled in the tracker

if (epoxyHolder.getAdapterPosition() == RecyclerView.NO_POSITION) {
return;
if (DEBUG_LOG) {
Log.d(TAG, String.format("%s.processVisibilityEvents %s, %s, %s",
eventOriginForDebug,
System.identityHashCode(epoxyHolder),
detachEvent,
epoxyHolder.getAdapterPosition()
));
}

final View itemView = epoxyHolder.itemView;
final int id = System.identityHashCode(itemView);

EpoxyVisibilityItem vi = visibilityIdToItemMap.get(id);
if (vi == null) {
vi = new EpoxyVisibilityItem();
// New view discovered, assign an EpoxyVisibilityItem
vi = new EpoxyVisibilityItem(epoxyHolder.getAdapterPosition());
visibilityIdToItemMap.put(id, vi);
}

if (vi.getAdapterPosition() != epoxyHolder.getAdapterPosition()) {
// EpoxyVisibilityItem being re-used for a different position
visibilityIdToItems.add(vi);
} else if (epoxyHolder.getAdapterPosition() != RecyclerView.NO_POSITION
&& vi.getAdapterPosition() != epoxyHolder.getAdapterPosition()) {
// EpoxyVisibilityItem being re-used for a different adapter position
vi.reset(epoxyHolder.getAdapterPosition());
}

Expand Down Expand Up @@ -155,22 +249,126 @@ public void onLayoutChange(
int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom
) {
processChildren();
processChangeEvent("onLayoutChange");
}

@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
processChildren();
processChangeEvent("onScrolled");
}

@Override
public void onChildViewAttachedToWindow(View child) {
processChild(child);
processChild(child, false, "onChildViewAttachedToWindow");
}

@Override
public void onChildViewDetachedFromWindow(View child) {
processChild(child, true);
if (visibleDataChanged) {
// On detach event caused by data set changed we need to re-process all children because
// the removal caused the others views to changes.
processChangeEventWithDetachedView(child, "onChildViewDetachedFromWindow");
visibleDataChanged = false;
} else {
processChild(child, true, "onChildViewDetachedFromWindow");
}
}
}

/**
* The layout/scroll events are not enough to detect all sort of visibility changes. We also
* need to look at the data events from the adapter.
*/
class DataObserver extends AdapterDataObserver {

/**
* Clear the current visibility statues
*/
@Override
public void onChanged() {
if (DEBUG_LOG) {
Log.d(TAG, "onChanged()");
}
visibilityIdToItemMap.clear();
visibilityIdToItems.clear();
visibleDataChanged = true;
}

/**
* For all items after the inserted range shift each {@link EpoxyVisibilityTracker} adapter
* position by inserted item count.
*/
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
if (DEBUG_LOG) {
Log.d(TAG, String.format("onItemRangeInserted(%d, %d)", positionStart, itemCount));
}
for (EpoxyVisibilityItem item : visibilityIdToItems) {
if (item.getAdapterPosition() >= positionStart) {
visibleDataChanged = true;
item.shiftBy(itemCount);
}
}
}

/**
* For all items after the removed range reverse-shift each {@link EpoxyVisibilityTracker}
* adapter position by removed item count
*/
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
if (DEBUG_LOG) {
Log.d(TAG, String.format("onItemRangeRemoved(%d, %d)", positionStart, itemCount));
}
for (EpoxyVisibilityItem item : visibilityIdToItems) {
if (item.getAdapterPosition() >= positionStart) {
visibleDataChanged = true;
item.shiftBy(-itemCount);
}
}
}

/**
* This is a bit more complex, for move we need to first swap the moved position then shift the
* items between the swap. To simplify we split any range passed to individual item moved.
*
* ps: anyway {@link android.support.v7.util.AdapterListUpdateCallback} does not seem to use
* range for moved items.
*/
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
for (int i = 0; i < itemCount; i++) {
onItemMoved(fromPosition + i, toPosition + i);
}
}

private void onItemMoved(int fromPosition, int toPosition) {
if (DEBUG_LOG) {
Log.d(TAG,
String.format("onItemRangeMoved(%d, %d, %d)", fromPosition, toPosition, 1));
}
for (EpoxyVisibilityItem item : visibilityIdToItems) {
int position = item.getAdapterPosition();
if (position == fromPosition) {
// We found the item to be moved, just swap the position.
item.shiftBy(toPosition - fromPosition);
visibleDataChanged = true;
} else if (fromPosition < toPosition) {
// Item will be moved down in the list
if (position > fromPosition && position <= toPosition) {
// Item is between the moved from and to indexes, it should move up
item.shiftBy(-1);
visibleDataChanged = true;
}
} else if (fromPosition > toPosition) {
// Item will be moved up in the list
if (position >= toPosition && position < fromPosition) {
// Item is between the moved to and from indexes, it should move down
item.shiftBy(1);
visibleDataChanged = true;
}
}
}
}
}
}
Loading

0 comments on commit 52eaacb

Please sign in to comment.