package com.airbnb.epoxy;
import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* Helper to track changes in the models list.
*/
class DiffHelper {
private ArrayList<ModelState> oldStateList = new ArrayList<>();
// Using a HashMap instead of a LongSparseArray to
// have faster look up times at the expense of memory
private Map<Long, ModelState> oldStateMap = new HashMap<>();
private ArrayList<ModelState> currentStateList = new ArrayList<>();
private Map<Long, ModelState> currentStateMap = new HashMap<>();
private final BaseEpoxyAdapter adapter;
private final boolean immutableModels;
private final DifferModelListObserver modelListObserver = new DifferModelListObserver();
private final boolean usingModelListObserver;
/**
* Set to true if an end user notifies adapter changes. We track this because our {@link
* #modelListObserver} already tracks structural changes and we shouldn't double notify those
* changes if the user already manually notified them. This generally shouldn't happen for normal
* usage of the adapter, somebody would have to do something like notify an item insertion and
* then notify models changed. We expect them to always just notify models changed. We could
* automate this by updating the observer to remove operations from its list when it hears they
* were notified, but that does not seem worth the effort for this small case.
*/
private boolean notifiedOfStructuralChanges;
DiffHelper(BaseEpoxyAdapter adapter, boolean immutableModels) {
this.adapter = adapter;
this.immutableModels = immutableModels;
adapter.registerAdapterDataObserver(observer);
usingModelListObserver = adapter instanceof EpoxyAdapter;
if (usingModelListObserver) {
((ModelList) adapter.getCurrentModels()).setObserver(modelListObserver);
}
}
private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() {
@Override
public void onChanged() {
throw new UnsupportedOperationException(
"Diffing is enabled. You should use notifyModelsChanged instead of notifyDataSetChanged");
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount) {
for (int i = positionStart; i < positionStart + itemCount; i++) {
currentStateList.get(i).hashCode = adapter.getCurrentModels().get(i).hashCode();
}
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
if (itemCount == 0) {
// no-op
return;
}
notifiedOfStructuralChanges = true;
if (itemCount == 1 || positionStart == currentStateList.size()) {
for (int i = positionStart; i < positionStart + itemCount; i++) {
currentStateList.add(i, createStateForPosition(i));
}
} else {
// Add in a batch since multiple insertions to the middle of the list are slow
List<ModelState> newModels = new ArrayList<>(itemCount);
for (int i = positionStart; i < positionStart + itemCount; i++) {
newModels.add(createStateForPosition(i));
}
currentStateList.addAll(positionStart, newModels);
}
// Update positions of affected items
int size = currentStateList.size();
for (int i = positionStart + itemCount; i < size; i++) {
currentStateList.get(i).position += itemCount;
}
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
if (itemCount == 0) {
// no-op
return;
}
notifiedOfStructuralChanges = true;
List<ModelState> modelsToRemove =
currentStateList.subList(positionStart, positionStart + itemCount);
for (ModelState model : modelsToRemove) {
currentStateMap.remove(model.id);
}
modelsToRemove.clear();
// Update positions of affected items
int size = currentStateList.size();
for (int i = positionStart; i < size; i++) {
currentStateList.get(i).position -= itemCount;
}
}
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
if (fromPosition == toPosition) {
// no-op
return;
}
if (itemCount != 1) {
throw new IllegalArgumentException("Moving more than 1 item at a time is not "
+ "supported. Number of items moved: " + itemCount);
}
notifiedOfStructuralChanges = true;
ModelState model = currentStateList.remove(fromPosition);
model.position = toPosition;
currentStateList.add(toPosition, model);
if (fromPosition < toPosition) {
// shift the affected items left
for (int i = fromPosition; i < toPosition; i++) {
currentStateList.get(i).position--;
}
} else {
// shift the affected items right
for (int i = toPosition + 1; i <= fromPosition; i++) {
currentStateList.get(i).position++;
}
}
}
};
/**
* Set the current list of models. The diff callbacks will be notified of the changes between the
* current list and the last list that was set.
*/
void notifyModelChanges() {
UpdateOpHelper updateOpHelper = new UpdateOpHelper();
if (usingModelListObserver && modelListObserver.hasNoChanges()) {
updateHashes(updateOpHelper);
} else if (!notifiedOfStructuralChanges
&& (modelListObserver.hasOnlyInsertions() || modelListObserver.hasOnlyRemovals())) {
// If the list only had insertions OR removals then nothing could have moved, and the observer
// has an accurate record of the removals/insertions. We can use it to update the state list,
// and then just need to check for item updates. If the user already notified some of these
// changes then we don't know what is left to notify and don't want to duplicate the notify
// calls so we do a full diff instead.
// We don't suspend our own observer for this because they will update the models list
// for us to reflect the insertions or removals
notifyChanges(modelListObserver);
updateHashes(updateOpHelper);
} else {
// We need to run a full diff to figure out what changed
buildDiff(updateOpHelper);
}
// Send out the proper notify calls for the diff. We remove our
// observer first so that we don't react to our own notify calls
adapter.unregisterAdapterDataObserver(observer);
notifyChanges(updateOpHelper);
adapter.registerAdapterDataObserver(observer);
modelListObserver.reset();
notifiedOfStructuralChanges = false;
}
/**
* This updates our state list with the current model hashes and collects any update
* notifications. Used only when the state list is already up to date with the adapter models.
*/
private void updateHashes(UpdateOpHelper updateOpHelper) {
int modelCount = adapter.getCurrentModels().size();
if (modelCount != currentStateList.size()) {
throw new IllegalStateException("State list does not match current models");
}
for (int i = 0; i < modelCount; i++) {
EpoxyModel<?> model = adapter.getCurrentModels().get(i);
ModelState state = currentStateList.get(i);
int newHash = model.hashCode();
if (state.hashCode != newHash) {
updateOpHelper.update(i, state.model);
state.hashCode = newHash;
}
}
}
private void notifyChanges(UpdateOpHelper opHelper) {
for (UpdateOp op : opHelper.opList) {
switch (op.type) {
case UpdateOp.ADD:
adapter.notifyItemRangeInserted(op.positionStart, op.itemCount);
break;
case UpdateOp.MOVE:
adapter.notifyItemMoved(op.positionStart, op.itemCount);
break;
case UpdateOp.REMOVE:
adapter.notifyItemRangeRemoved(op.positionStart, op.itemCount);
break;
case UpdateOp.UPDATE:
if (immutableModels && op.payloads != null) {
adapter.notifyItemRangeChanged(op.positionStart, op.itemCount,
new DiffPayload(op.payloads));
} else {
adapter.notifyItemRangeChanged(op.positionStart, op.itemCount);
}
break;
default:
throw new IllegalArgumentException("Unknown type: " + op.type);
}
}
}
/**
* Create a list of operations that define the difference between {@link #oldStateList} and {@link
* #currentStateList}.
*/
private UpdateOpHelper buildDiff(UpdateOpHelper updateOpHelper) {
prepareStateForDiff();
// The general approach is to first search for removals, then additions, and lastly changes.
// Focusing on one type of operation at a time makes it easy to coalesce batch changes.
// When we identify an operation and add it to the
// result list we update the positions of items in the oldStateList to reflect
// the change, this way subsequent operations will use the correct, updated positions.
collectRemovals(updateOpHelper);
// Only need to check for insertions if new list is bigger
boolean hasInsertions =
oldStateList.size() - updateOpHelper.getNumRemovals() != currentStateList.size();
if (hasInsertions) {
collectInsertions(updateOpHelper);
}
collectMoves(updateOpHelper);
collectChanges(updateOpHelper);
return updateOpHelper;
}
private void prepareStateForDiff() {
// We use a list of the models as well as a map by their id,
// so we can easily find them by both position and id
oldStateList.clear();
oldStateMap.clear();
// Swap the two lists so that we have a copy of the current state to calculate the next diff
ArrayList<ModelState> tempList = oldStateList;
oldStateList = currentStateList;
currentStateList = tempList;
Map<Long, ModelState> tempMap = oldStateMap;
oldStateMap = currentStateMap;
currentStateMap = tempMap;
// Remove all pairings in the old states so we can tell which of them were removed. The items
// that still exist in the new list will be paired when we build the current list state below
for (ModelState modelState : oldStateList) {
modelState.pair = null;
}
int modelCount = adapter.getCurrentModels().size();
currentStateList.ensureCapacity(modelCount);
for (int i = 0; i < modelCount; i++) {
currentStateList.add(createStateForPosition(i));
}
}
private ModelState createStateForPosition(int position) {
EpoxyModel<?> model = adapter.getCurrentModels().get(position);
model.addedToAdapter = true;
ModelState state = ModelState.build(model, position, immutableModels);
ModelState previousValue = currentStateMap.put(state.id, state);
if (previousValue != null) {
int previousPosition = previousValue.position;
EpoxyModel<?> previousModel = adapter.getCurrentModels().get(previousPosition);
throw new IllegalStateException("Two models have the same ID. ID's must be unique!"
+ " Model at position " + position + ": " + model
+ " Model at position " + previousPosition + ": " + previousModel);
}
return state;
}
/**
* Find all removal operations and add them to the result list. The general strategy here is to
* walk through the {@link #oldStateList} and check for items that don't exist in the new list.
* Walking through it in order makes it easy to batch adjacent removals.
*/
private void collectRemovals(UpdateOpHelper helper) {
for (ModelState state : oldStateList) {
// Update the position of the item to take into account previous removals,
// so that future operations will reference the correct position
state.position -= helper.getNumRemovals();
// This is our first time going through the list, so we
// look up the item with the matching id in the new
// list and hold a reference to it so that we can access it quickly in the future
state.pair = currentStateMap.get(state.id);
if (state.pair != null) {
state.pair.pair = state;
continue;
}
helper.remove(state.position);
}
}
/**
* Find all insertion operations and add them to the result list. The general strategy here is to
* walk through the {@link #currentStateList} and check for items that don't exist in the old
* list. Walking through it in order makes it easy to batch adjacent insertions.
*/
private void collectInsertions(UpdateOpHelper helper) {
Iterator<ModelState> oldItemIterator = oldStateList.iterator();
for (ModelState itemToInsert : currentStateList) {
if (itemToInsert.pair != null) {
// Update the position of the next item in the old list to take any insertions into account
ModelState nextOldItem = getNextItemWithPair(oldItemIterator);
if (nextOldItem != null) {
nextOldItem.position += helper.getNumInsertions();
}
continue;
}
helper.add(itemToInsert.position);
}
}
/**
* Check if any items have had their values changed, batching if possible.
*/
private void collectChanges(UpdateOpHelper helper) {
for (ModelState newItem : currentStateList) {
ModelState previousItem = newItem.pair;
if (previousItem == null) {
continue;
}
// We use equals when we know the models are immutable and available, otherwise we have to
// rely on the stored hashCode
boolean modelChanged;
if (immutableModels) {
// Make sure that the old model hasn't changed, otherwise comparing it with the new one
// won't be accurate.
if (previousItem.model.isDebugValidationEnabled()) {
previousItem.model
.validateStateHasNotChangedSinceAdded("Model was changed before it could be diffed.",
previousItem.position);
}
modelChanged = !previousItem.model.equals(newItem.model);
} else {
modelChanged = previousItem.hashCode != newItem.hashCode;
}
if (modelChanged) {
helper.update(newItem.position, previousItem.model);
}
}
}
/**
* Check which items have had a position changed. Recyclerview does not support batching these.
*/
private void collectMoves(UpdateOpHelper helper) {
// This walks through both the new and old list simultaneous and checks for position changes.
Iterator<ModelState> oldItemIterator = oldStateList.iterator();
ModelState nextOldItem = null;
for (ModelState newItem : currentStateList) {
if (newItem.pair == null) {
// This item was inserted. However, insertions are done at the item's final position, and
// aren't smart about inserting at a different position to take future moves into account.
// As the old state list is updated to reflect moves, it needs to also consider insertions
// affected by those moves in order for the final change set to be correct
if (helper.moves.isEmpty()) {
// There have been no moves, so the item is still at it's correct position
continue;
} else {
// There have been moves, so the old list needs to take this inserted item
// into account. The old list doesn't have this item inserted into it
// (for optimization purposes), but we can create a pair for this item to
// track its position in the old list and move it back to its final position if necessary
newItem.pairWithSelf();
}
}
// We could iterate through only the new list and move each
// item that is out of place, however in cases such as moving the first item
// to the end, that strategy would do many moves to move all
// items up one instead of doing one move to move the first item to the end.
// To avoid this we compare the old item to the new item at
// each index and move the one that is farthest from its correct position.
// We only move on from a new item once its pair is placed in
// the correct spot. Since we move from start to end, all new items we've
// already iterated through are guaranteed to have their pair
// be already in the right spot, which won't be affected by future MOVEs.
if (nextOldItem == null) {
nextOldItem = getNextItemWithPair(oldItemIterator);
// We've already iterated through all old items and moved each
// item once. However, subsequent moves may have shifted an item out of
// its correct space once it was already moved. We finish
// iterating through all the new items to ensure everything is still correct
if (nextOldItem == null) {
nextOldItem = newItem.pair;
}
}
while (nextOldItem != null) {
// Make sure the positions are updated to the latest
// move operations before we calculate the next move
updateItemPosition(newItem.pair, helper.moves);
updateItemPosition(nextOldItem, helper.moves);
// The item is the same and its already in the correct place
if (newItem.id == nextOldItem.id && newItem.position == nextOldItem.position) {
nextOldItem = null;
break;
}
int newItemDistance = newItem.pair.position - newItem.position;
int oldItemDistance = nextOldItem.pair.position - nextOldItem.position;
// Both items are already in the correct position
if (newItemDistance == 0 && oldItemDistance == 0) {
nextOldItem = null;
break;
}
if (oldItemDistance > newItemDistance) {
helper.move(nextOldItem.position, nextOldItem.pair.position);
nextOldItem.position = nextOldItem.pair.position;
nextOldItem.lastMoveOp = helper.getNumMoves();
nextOldItem = getNextItemWithPair(oldItemIterator);
} else {
helper.move(newItem.pair.position, newItem.position);
newItem.pair.position = newItem.position;
newItem.pair.lastMoveOp = helper.getNumMoves();
break;
}
}
}
}
/**
* Apply the movement operations to the given item to update its position. Only applies the
* operations that have not been applied yet, and stores how many operations have been applied so
* we know which ones to apply next time.
*/
private void updateItemPosition(ModelState item, List<UpdateOp> moveOps) {
int size = moveOps.size();
for (int i = item.lastMoveOp; i < size; i++) {
UpdateOp moveOp = moveOps.get(i);
int fromPosition = moveOp.positionStart;
int toPosition = moveOp.itemCount;
if (item.position > fromPosition && item.position <= toPosition) {
item.position--;
} else if (item.position < fromPosition && item.position >= toPosition) {
item.position++;
}
}
item.lastMoveOp = size;
}
/**
* Gets the next item in the list that has a pair, meaning it wasn't inserted or removed.
*/
@Nullable
private ModelState getNextItemWithPair(Iterator<ModelState> iterator) {
ModelState nextItem = null;
while (nextItem == null && iterator.hasNext()) {
nextItem = iterator.next();
if (nextItem.pair == null) {
// Skip this one and go on to the next
nextItem = null;
}
}
return nextItem;
}
}