blob: 9939d762f68d1381a607a2c5cecfb700237dc643 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package io.v.baku.toolkit.bind;
import android.support.v7.widget.RecyclerView;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java8.util.function.Function;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Delegate;
import rx.Observable;
/**
* This {@link ListDeltaAccumulator} derives its deltas from {@link ListAccumulator} snapshots.
*
* @param <T>
*/
@RequiredArgsConstructor
public class DerivedListDeltaAccumulator<T> implements ListDeltaAccumulator<T> {
@RequiredArgsConstructor
private static class DiffContext<T> {
final NumericIdMapper ids;
final Function<? super ImmutableList<T>, ? extends ListAccumulator<T>> stepFactory;
final List<DerivedListDeltaAccumulator<T>> steps = new ArrayList<>();
/**
* Algorithm:
*
* 1. Notify of all removals.
* 2. Notify of moves to reorder intersection.
* 3. Notify of all insertions.
*
* This algorithm does not trigger any item change events, preferring remove/insert instead.
* Also, it fails if any items are not unique.
*/
DiffContext<T> diff(final ListAccumulator<T> a, final ListAccumulator<T> b) {
if (a == null) {
steps.add(new DerivedListDeltaAccumulator<>(ids, b));
} else {
final ImmutableList<T>
aList = a.getListSnapshot(),
bList = b.getListSnapshot();
final ImmutableSet<T>
aItems = ImmutableSet.copyOf(aList),
bItems = ImmutableSet.copyOf(bList);
final List<T> mutable = processRemovals(aList, bItems);
final List<T> to = new ArrayList<>(bList);
to.retainAll(aItems);
processMoves(mutable, to);
processInsertions(mutable, bList);
}
return this;
}
ListAccumulator<T> produceStepSnapshot(final List<T> working) {
return stepFactory.apply(ImmutableList.copyOf(working));
}
/**
* @param start the mutable ordered start state
* @param goal the elements in the end state
* @return the result of retaining `goal` from `start`
*/
List<T> processRemovals(final ImmutableList<T> start, final ImmutableSet<T> goal) {
final List<T> working = new ArrayList<>(start);
int removalCount = 0, i = 0;
for (final T item : start) {
if (goal.contains(item)) {
processRemovals(working, i, removalCount);
i++;
removalCount = 0;
} else {
removalCount++;
}
}
processRemovals(working, i, removalCount);
return working;
}
/**
* Helper to {@link #processRemovals(ImmutableList, ImmutableSet)} that performs multi-item
* removal, single-item removal, or no-ops based on contiguous scan results.
*/
void processRemovals(final List<T> working, final int index, final int count) {
if (count > 0) {
working.subList(index, index + count).clear();
steps.add(new DerivedListDeltaAccumulator<T>(ids, produceStepSnapshot(working)) {
@Override
public void notifyDeltas(RecyclerView.Adapter<?> rva) {
rva.notifyItemRangeRemoved(index, count);
}
});
}
}
/**
* Transitions from {@code working} to {@code goal} by moving one item at a time. Moves are
* performed in clusters of like deltas, moving smaller clusters first in the hopes that
* larger clusters will fall into place as a result of the smaller moves. While this usually
* results in optimal plans, there are cases where it can result in terrible plan, e.g.
* <p>
* 3 4 2 1 -> 3 2 4 1 -> 2 4 3 1 -> 4 2 3 1 -> 2 3 1 4 -> 1 2 3 4
* <p>
* I believe we can prevent this pitfall by rejecting moves that would break clusters:
* <p>
* 3 4 2 1 -> 1 3 4 2 -> 1 2 3 4
* <p>
* TODO(rosswang): Do this (and add a test).
*/
void processMoves(final List<T> working, final List<T> goal) {
final HashMap<T, Integer> goalIndices = new HashMap<>(goal.size());
for (int i = 0; i < goal.size(); i++) {
goalIndices.put(goal.get(i), i);
}
class MoveProcessor {
int start, end, clusterStart, clusterDelta,
minClusterStart, minClusterEnd, minClusterDelta;
boolean hasMovingCluster() {
return minClusterDelta != 0;
}
void startCluster(final int index, final int delta) {
clusterStart = index;
clusterDelta = delta;
}
void endCluster(final int indexAfter) {
if (clusterDelta == 0) {
if (!hasMovingCluster()) {
start = indexAfter;
} else if (indexAfter == end) {
end = clusterStart;
}
} else if (!hasMovingCluster() ||
indexAfter - clusterStart < minClusterEnd - minClusterStart) {
// smallest cluster; keep it as a prime candidate for moving
minClusterStart = clusterStart;
minClusterEnd = indexAfter;
minClusterDelta = clusterDelta;
}
}
MoveProcessor() {
this(0, working.size());
}
MoveProcessor(final int start, final int end) {
this.start = start;
this.end = end;
for (int i = start; i < end; i++) {
final int delta = goalIndices.get(working.get(i)) - i;
if (delta != clusterDelta) {
endCluster(i);
startCluster(i, delta);
}
}
endCluster(end);
}
DerivedListDeltaAccumulator<T> step(final int from, final int to) {
working.add(to, working.remove(from));
return new DerivedListDeltaAccumulator<T>(ids, produceStepSnapshot(working)) {
@Override
public void notifyDeltas(RecyclerView.Adapter<?> rva) {
rva.notifyItemMoved(from, to);
}
};
}
void addMinClusterSteps() {
if (minClusterDelta < 0) {
for (int i = minClusterStart; i < minClusterEnd; i++) {
steps.add(step(i, i + minClusterDelta));
}
} else {
for (int i = minClusterEnd - 1; i >= minClusterStart; i--) {
steps.add(step(i, i + minClusterDelta));
}
}
}
}
for (MoveProcessor mp = new MoveProcessor(); mp.hasMovingCluster();
mp = new MoveProcessor(mp.start, mp.end)) {
mp.addMinClusterSteps();
}
}
/**
* @param working the mutable ordered (intermediate) start state
* @param goal the ordered end state
*/
void processInsertions(final List<T> working, final ImmutableList<T> goal) {
final List<T> insert = new ArrayList<>();
for (int i = 0; i < goal.size(); i++) {
final T item = goal.get(i);
final int insertAt = i - insert.size();
if (insertAt < working.size() && item.equals(working.get(insertAt))) {
processInsertions(working, insertAt, insert);
insert.clear();
} else {
insert.add(item);
}
}
if (!insert.isEmpty()) {
// Capture these ints so that we're not holding onto instances of the working lists
// and opening us to errors if they're modified.
final int count = insert.size(), index = goal.size() - count;
steps.add(new DerivedListDeltaAccumulator<T>(ids, stepFactory.apply(goal)) {
@Override
public void notifyDeltas(RecyclerView.Adapter<?> rva) {
rva.notifyItemRangeInserted(index, count);
}
});
}
}
/**
* Helper to {@link #processInsertions(List, ImmutableList)} that performs multi-item
* insertion, single-item insertion, or no-ops based on contiguous scan results.
*/
void processInsertions(final List<T> working, final int index, final List<T> insert) {
if (!insert.isEmpty()) {
working.addAll(index, insert);
// Capture count so that we're not holding onto an instance of insert and opening us
// to errors if (and when) it's modified.
final int count = insert.size();
steps.add(new DerivedListDeltaAccumulator<T>(ids, produceStepSnapshot(working)) {
@Override
public void notifyDeltas(RecyclerView.Adapter<?> rva) {
rva.notifyItemRangeInserted(index, count);
}
});
}
}
}
private final NumericIdMapper mIds;
@Delegate
private final ListAccumulator<T> mSnapshot;
public static <T> Observable<DerivedListDeltaAccumulator<T>> scanFrom(
final Observable<? extends ListAccumulator<T>> snapshots,
final Function<? super ImmutableList<T>, ? extends ListAccumulator<T>> stepFactory) {
final NumericIdMapper ids = new NumericIdMapper();
return Observable.concat(Observable.zip(
// need to wrap null as an Observable (or Iterable) to capture generic wildcard
// without overload ambiguity
snapshots.startWith(Observable.just(null)),
snapshots,
(previous, next) -> Observable.from(new DiffContext<>(ids, stepFactory)
.diff(previous, next).steps)));
}
/**
* The default implementation simply calls
* {@link android.support.v7.widget.RecyclerView.Adapter#notifyDataSetChanged()}.
* However, it is fully expected that subclasses, such as the anonymous subclasses generated
* within {@link #scanFrom(Observable, Function)}, override this for more specific
* notifications.
*/
@Override
public void notifyDeltas(final RecyclerView.Adapter<?> rva) {
rva.notifyDataSetChanged();
}
@Override
public long getItemId(final int position) {
return mIds.assignNumericId(getRowAt(position));
}
}