package com.airbnb.epoxy; import android.support.annotation.LayoutRes; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.airbnb.epoxy.EpoxyController.ModelInterceptorCallback; import java.util.List; /** * Helper to bind data to a view using a builder style. The parameterized type should extend * Android's View. */ public abstract class EpoxyModel<T> { /** * Counts how many of these objects are created, so that each new object can have a unique id . * Uses negative values so that these autogenerated ids don't clash with database ids that may be * set with {@link #id(long)} */ private static long idCounter = -1; /** * An id that can be used to uniquely identify this {@link EpoxyModel} for use in RecyclerView * stable ids. It defaults to a unique id for this object instance, if you want to maintain the * same id across instances use {@link #id(long)} */ private long id; @LayoutRes private int layout; private boolean shown = true; /** * Set to true once this model is diffed in an adapter. Used to ensure that this model's id * doesn't change after being diffed. */ boolean addedToAdapter; /** * The first controller this model was added to. A reference is kept in debug mode in order to run * validations. The model is allowed to be added to other controllers, but we only keep a * reference to the first. */ private EpoxyController firstControllerAddedTo; /** * Models are staged when they are changed. This allows them to be automatically added when they * are done being changed (eg the next model is changed/added or buildModels finishes). It is only * allowed for AutoModels, and only if implicity adding is enabled. */ EpoxyController controllerToStageTo; private boolean currentlyInInterceptors; private int hashCodeWhenAdded; private boolean hasDefaultId; protected EpoxyModel(long id) { id(id); } public EpoxyModel() { this(idCounter--); hasDefaultId = true; } boolean hasDefaultId() { return hasDefaultId; } /** * Get the view type to associate with this model in the recyclerview. For models that use a * layout resource, the view type is simply the layout resource value. * * @see android.support.v7.widget.RecyclerView.Adapter#getItemViewType(int) */ int getViewType() { return getLayout(); } View buildView(ViewGroup parent) { return LayoutInflater.from(parent.getContext()).inflate(getLayout(), parent, false); } /** * Binds the current data to the given view. You should bind all fields including unset/empty * fields to ensure proper recycling. */ public void bind(T view) { } /** * Similar to {@link #bind(Object)}, but provides a non null, non empty list of payloads * describing what changed. This is the payloads list specified in the adapter's notifyItemChanged * method. This is a useful optimization to allow you to only change part of a view instead of * updating the whole thing, which may prevent unnecessary layout calls. If there are no payloads * then {@link #bind(Object)} is called instead. This will only be used if the model is used with * an {@link EpoxyAdapter} */ public void bind(T view, List<Object> payloads) { bind(view); } /** * Similar to {@link #bind(Object)}, but provides a non null model which was previously bound to * this view. This will only be called if the model is used with an {@link EpoxyController}. * * @param previouslyBoundModel This is a model with the same id that was previously bound. You can * compare this previous model with the current one to see exactly * what changed. * <p> * This model and the previously bound model are guaranteed to have * the same id, but will not necessarily be of the same type depending * on your implementation of {@link EpoxyController#buildModels()}. * With common usage patterns of Epoxy they should be the same type, * and will only differ if you are using different model classes with * the same id. * <p> * Comparing the newly bound model with the previous model allows you * to be more intelligent when binding your view. This may help you * optimize view binding, or make it easier to work with animations. * <p> * If the new model and the previous model have the same view type * (given by {@link EpoxyModel#getViewType()}), and if you are using * the default ReyclerView item animator, the same view will be * reused. This means that you only need to update the view to reflect * the data that changed. If you are using a custom item animator then * the view will be the same if the animator returns true in * canReuseUpdatedViewHolder. * <p> * This previously bound model is taken as a payload from the diffing * process, and follows the same general conditions for all * recyclerview change payloads. */ public void bind(T view, EpoxyModel<?> previouslyBoundModel) { bind(view); } /** * Called when the view bound to this model is recycled. Subclasses can override this if their * view should release resources when it's recycled. * <p> * Note that {@link #bind(Object)} can be called multiple times without an unbind call in between * if the view has remained on screen to be reused across item changes. This means that you should * not rely on unbind to clear a view or model's state before bind is called again. * * @see EpoxyAdapter#onViewRecycled(EpoxyViewHolder) */ public void unbind(T view) { } public long id() { return id; } /** * Override the default id in cases where the data subject naturally has an id, like an object * from a database. This id can only be set before the model is added to the adapter, it is an * error to change the id after that. */ public EpoxyModel<T> id(long id) { if ((addedToAdapter || firstControllerAddedTo != null) && id != this.id) { throw new IllegalEpoxyUsage( "Cannot change a model's id after it has been added to the adapter."); } hasDefaultId = false; this.id = id; return this; } /** * Use multiple numbers as the id for this model. Useful when you don't have a single long that * represents a unique id. * <p> * This hashes the numbers, so there is a tiny risk of collision with other ids. */ public EpoxyModel<T> id(Number... ids) { long result = 0; for (Number id : ids) { result = 31 * result + hashLong64Bit(id.hashCode()); } return id(result); } /** * Use two numbers as the id for this model. Useful when you don't have a single long that * represents a unique id. * <p> * This hashes the two numbers, so there is a tiny risk of collision with other ids. */ public EpoxyModel<T> id(long id1, long id2) { long result = hashLong64Bit(id1); result = 31 * result + hashLong64Bit(id2); return id(result); } /** * Use a string as the model id. Useful for models that don't clearly map to a numerical id. This * is preferable to using {@link String#hashCode()} because that is a 32 bit hash and this is a 64 * bit hash, giving better spread and less chance of collision with other ids. * <p> * Since this uses a hashcode method to convert the String to a long there is a very small chance * that you may have a collision with another id. Assuming an even spread of hashcodes, and * several hundred models in the adapter, there would be roughly 1 in 100 trillion chance of a * collision. (http://preshing.com/20110504/hash-collision-probabilities/) * * @see EpoxyModel#hashString64Bit(CharSequence) */ public EpoxyModel<T> id(CharSequence key) { id(hashString64Bit(key)); return this; } /** * Set an id that is namespaced with a string. This is useful when you need to show models of * multiple types, side by side and don't want to risk id collisions. * <p> * Since this uses a hashcode method to convert the String to a long there is a very small chance * that you may have a collision with another id. Assuming an even spread of hashcodes, and * several hundred models in the adapter, there would be roughly 1 in 100 trillion chance of a * collision. (http://preshing.com/20110504/hash-collision-probabilities/) * * @see EpoxyModel#hashString64Bit(CharSequence) * @see EpoxyModel#hashLong64Bit(long) */ public EpoxyModel<T> id(CharSequence key, long id) { long result = hashString64Bit(key); result = 31 * result + hashLong64Bit(id); id(result); return this; } /** * Hash a long into 64 bits instead of the normal 32. This uses a xor shift implementation to * attempt psuedo randomness so object ids have an even spread for less chance of collisions. * <p> * From http://stackoverflow.com/a/11554034 * <p> * http://www.javamex.com/tutorials/random_numbers/xorshift.shtml */ private static long hashLong64Bit(long value) { value ^= (value << 21); value ^= (value >>> 35); value ^= (value << 4); return value; } /** * Hash a string into 64 bits instead of the normal 32. This allows us to better use strings as a * model id with less chance of collisions. This uses the FNV-1a algorithm for a good mix of speed * and distribution. * <p> * Performance comparisons found at http://stackoverflow.com/a/1660613 * <p> * Hash implementation from http://www.isthe.com/chongo/tech/comp/fnv/index.html#FNV-1a */ private static long hashString64Bit(CharSequence str) { long result = 0xcbf29ce484222325L; final int len = str.length(); for (int i = 0; i < len; i++) { result ^= str.charAt(i); result *= 0x100000001b3L; } return result; } @LayoutRes protected abstract int getDefaultLayout(); public EpoxyModel<T> layout(@LayoutRes int layoutRes) { onMutation(); layout = layoutRes; return this; } @LayoutRes public final int getLayout() { if (layout == 0) { return getDefaultLayout(); } return layout; } /** * Sets fields of the model to default ones. */ public EpoxyModel<T> reset() { onMutation(); layout = 0; shown = true; return this; } /** * Add this model to the given controller. Can only be called from inside {@link * EpoxyController#buildModels()}. */ public void addTo(EpoxyController controller) { controller.addInternal(this); } /** * Add this model to the given controller if the condition is true. Can only be called from inside * {@link EpoxyController#buildModels()}. */ public void addIf(boolean condition, EpoxyController controller) { if (condition) { addTo(controller); } else if (controllerToStageTo != null) { // Clear this model from staging since it failed the add condition. If this model wasn't // staged (eg not changed before addIf was called, then we need to make sure to add the // previously staged model. controllerToStageTo.clearModelFromStaging(this); controllerToStageTo = null; } } /** * Add this model to the given controller if the {@link AddPredicate} return true. Can only be * called from inside {@link EpoxyController#buildModels()}. */ public void addIf(AddPredicate predicate, EpoxyController controller) { addIf(predicate.addIf(), controller); } /** * @see #addIf(AddPredicate, EpoxyController) */ public interface AddPredicate { boolean addIf(); } /** * This is used internally by generated models to turn on validation checking when * "validateEpoxyModelUsage" is enabled and the model is used with an {@link EpoxyController}. */ protected final void addWithDebugValidation(EpoxyController controller) { if (controller == null) { throw new IllegalArgumentException("Controller cannot be null"); } if (controller.isModelAddedMultipleTimes(this)) { throw new IllegalEpoxyUsage( "This model was already added to the controller at position " + controller.getFirstIndexOfModelInBuildingList(this)); } if (firstControllerAddedTo == null) { firstControllerAddedTo = controller; // We save the current hashCode so we can compare it to the hashCode at later points in time // in order to validate that it doesn't change and enforce mutability. hashCodeWhenAdded = hashCode(); // The one time it is valid to change the model is during an interceptor callback. To support // that we need to update the hashCode after interceptors have been run. // The model can be added to multiple controllers, but we only allow an interceptor change // the first time, since after that it will have been added to an adapter. controller.addAfterInterceptorCallback(new ModelInterceptorCallback() { @Override public void onInterceptorsStarted(EpoxyController controller) { currentlyInInterceptors = true; } @Override public void onInterceptorsFinished(EpoxyController controller) { hashCodeWhenAdded = EpoxyModel.this.hashCode(); currentlyInInterceptors = false; } }); } } boolean isDebugValidationEnabled() { return firstControllerAddedTo != null; } /** * This is used internally by generated models to do validation checking when * "validateEpoxyModelUsage" is enabled and the model is used with an {@link EpoxyController}. * This method validates that it is ok to change this model. It is only valid if the model hasn't * yet been added, or the change is being done from an {@link EpoxyController.Interceptor} * callback. * <p> * This is also used to stage the model for implicitly adding it, if it is an AutoModel and * implicit adding is enabled. */ protected final void onMutation() { // The model may be added to multiple controllers, in which case if it was already diffed // and added to an adapter in one controller we don't want to even allow interceptors // from changing the model in a different controller if (isDebugValidationEnabled() && !currentlyInInterceptors) { throw new ImmutableModelException(this, getPosition(firstControllerAddedTo, this)); } if (controllerToStageTo != null) { controllerToStageTo.setStagedModel(this); } } private static int getPosition(EpoxyController controller, EpoxyModel<?> model) { // If the model was added to multiple controllers, or was removed from the controller and then // modified, this won't be correct. But those should be very rare cases that we don't need to // worry about if (controller.isBuildingModels()) { return controller.getFirstIndexOfModelInBuildingList(model); } return controller.getAdapter().getModelPosition(model); } /** * This is used internally by generated models to do validation checking when * "validateEpoxyModelUsage" is enabled and the model is used with a {@link EpoxyController}. This * method validates that the model's hashCode hasn't been changed since it was added to the * controller. This is similar to {@link #onMutation()}, but that method is only used for * specific model changes such as calling a setter. By checking the hashCode, this method allows * us to catch more subtle changes, such as through setting a field directly or through changing * an object that is set on the model. */ protected final void validateStateHasNotChangedSinceAdded(String descriptionOfChange, int modelPosition) { if (isDebugValidationEnabled() && !currentlyInInterceptors && hashCodeWhenAdded != hashCode()) { throw new ImmutableModelException(this, descriptionOfChange, modelPosition); } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof EpoxyModel)) { return false; } EpoxyModel<?> that = (EpoxyModel<?>) o; if (id != that.id) { return false; } if (getViewType() != that.getViewType()) { return false; } return shown == that.shown; } @Override public int hashCode() { int result = (int) (id ^ (id >>> 32)); result = 31 * result + getViewType(); result = 31 * result + (shown ? 1 : 0); return result; } /** * Subclasses can override this if they want their view to take up more than one span in a grid * layout. * * @param totalSpanCount The number of spans in the grid * @param position The position of the model * @param itemCount The total number of items in the adapter */ public int getSpanSize(int totalSpanCount, int position, int itemCount) { return 1; } /** * Change the visibility of the model so that it's view is shown. This only works if the model is * used in {@link EpoxyAdapter} or a {@link EpoxyModelGroup}, but is not supported in {@link * EpoxyController} */ public EpoxyModel<T> show() { return show(true); } /** * Change the visibility of the model's view. This only works if the model is * used in {@link EpoxyAdapter} or a {@link EpoxyModelGroup}, but is not supported in {@link * EpoxyController} */ public EpoxyModel<T> show(boolean show) { onMutation(); shown = show; return this; } /** * Change the visibility of the model so that it's view is hidden. This only works if the model is * used in {@link EpoxyAdapter} or a {@link EpoxyModelGroup}, but is not supported in {@link * EpoxyController} */ public EpoxyModel<T> hide() { return show(false); } /** * Whether the model's view should be shown on screen. If false it won't be inflated and drawn, * and will be like it was never added to the recycler view. */ public boolean isShown() { return shown; } /** * Whether the adapter should save the state of the view bound to this model. */ public boolean shouldSaveViewState() { return false; } /** * Called if the RecyclerView failed to recycle this model's view. You can take this opportunity * to clear the animation(s) that affect the View's transient state and return <code>true</code> * so that the View can be recycled. Keep in mind that the View in question is already removed * from the RecyclerView. * * @return True if the View should be recycled, false otherwise * @see EpoxyAdapter#onFailedToRecycleView(android.support.v7.widget.RecyclerView.ViewHolder) */ public boolean onFailedToRecycleView(T view) { return false; } /** * Called when this model's view is attached to the window. * * @see EpoxyAdapter#onViewAttachedToWindow(android.support.v7.widget.RecyclerView.ViewHolder) */ public void onViewAttachedToWindow(T view) { } /** * Called when this model's view is detached from the the window. * * @see EpoxyAdapter#onViewDetachedFromWindow(android.support.v7.widget.RecyclerView.ViewHolder) */ public void onViewDetachedFromWindow(T view) { } @Override public String toString() { return getClass().getSimpleName() + "{" + "id=" + id + ", viewType=" + getViewType() + ", shown=" + shown + ", addedToAdapter=" + addedToAdapter + '}'; } }