chromium/ui/android/java/src/org/chromium/ui/modelutil/ModelListAdapter.java

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.ui.modelutil;

import android.util.Pair;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.ui.R;
import org.chromium.ui.modelutil.ListObservable.ListObserver;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor.ViewBinder;

import java.util.Collection;

/**
 * Adapter for providing data and views to a ListView.
 *
 * To use, register a {@link PropertyModelChangeProcessor.ViewBinder} and {@link ViewBuilder}
 * for each view type in the list using
 * {@link #registerType(int, ViewBuilder, PropertyModelChangeProcessor.ViewBinder)}.
 * The constructor takes a {@link ListObservable} list in the form of a {@link ModelList}. Any
 * changes that occur in the list will be automatically updated in the view.
 *
 * When creating a new view, ModelListAdapter will bind all set properties. When reusing/rebinding
 * a view, in addition to binding all properties set on the new model, properties that were
 * previously set on the old model but are not set on the new model will be bound to "reset" the
 * view. ViewBinders registered for this adapter may therefore need to handle bind calls for
 * properties that are not set on the model being bound.
 *
 * Additionally, ModelListAdapter will hook up a {@link PropertyModelChangeProcessor} when binding
 * views to ensure that changes to the PropertyModel for that list item are bound to the view.
 */
public class ModelListAdapter extends BaseAdapter implements MVCListAdapter {
    private final ModelList mModelList;
    private final SparseArray<Pair<ViewBuilder, ViewBinder>> mViewBuilderMap = new SparseArray<>();
    private final ListObserver<Void> mListObserver;

    public ModelListAdapter(ModelList data) {
        mModelList = data;
        mListObserver =
                new ListObserver<Void>() {
                    @Override
                    public void onItemRangeInserted(ListObservable source, int index, int count) {
                        notifyDataSetChanged();
                    }

                    @Override
                    public void onItemRangeRemoved(ListObservable source, int index, int count) {
                        notifyDataSetChanged();
                    }

                    @Override
                    public void onItemRangeChanged(
                            ListObservable<Void> source,
                            int index,
                            int count,
                            @Nullable Void payload) {
                        notifyDataSetChanged();
                    }

                    @Override
                    public void onItemMoved(ListObservable source, int curIndex, int newIndex) {
                        notifyDataSetChanged();
                    }
                };
        mModelList.addObserver(mListObserver);
    }

    @Override
    public int getCount() {
        return mModelList.size();
    }

    @Override
    public Object getItem(int position) {
        return mModelList.get(position);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public <T extends View> void registerType(
            int typeId, ViewBuilder<T> builder, ViewBinder<PropertyModel, T, PropertyKey> binder) {
        assert mViewBuilderMap.get(typeId) == null;
        mViewBuilderMap.put(typeId, new Pair<>(builder, binder));
    }

    @Override
    public int getItemViewType(int position) {
        return mModelList.get(position).type;
    }

    @Override
    public int getViewTypeCount() {
        return Math.max(1, mViewBuilderMap.size());
    }

    /**
     * Make an attempt to convert view to desiredType.
     *
     * The basic implementation verifies whether the view can be re-used as is without any
     * modifications, assuming the current view type is same as the desired view type.
     * Subclasses should override this method if any specific changes can to be made in order
     * to convert views from one type to another.
     *
     * @param view View to convert
     * @param desiredType Target type of the view to convert to.
     * @return Whether conversion was successful.
     */
    protected boolean canReuseView(View view, int desiredType) {
        // Check if view type changed. If not, we can re-use this view as is without any
        // modifications.
        return view != null
                && view.getTag(R.id.view_type) != null
                && (int) view.getTag(R.id.view_type) == desiredType;
    }

    /**
     * Create a new view of the desired type.
     *
     * @param parent Parent view.
     * @param typeId Type of the view to create.
     * @return Created view.
     */
    protected View createView(ViewGroup parent, int typeId) {
        return mViewBuilderMap.get(typeId).first.buildView(parent);
    }

    @SuppressWarnings("unchecked")
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        //  1. Destroy the old PropertyModelChangeProcessor if it exists.
        if (convertView != null && convertView.getTag(R.id.view_mcp) != null) {
            PropertyModelChangeProcessor propertyModelChangeProcessor =
                    (PropertyModelChangeProcessor) convertView.getTag(R.id.view_mcp);
            propertyModelChangeProcessor.destroy();
        }

        // 2. Build a new view if needed. Otherwise, fetch the old model from the convertView.
        PropertyModel oldModel = null;
        final int desiredViewType = getItemViewType(position);

        if (convertView == null || !canReuseView(convertView, desiredViewType)) {
            convertView = createView(parent, desiredViewType);
            // Since the view type returned by getView is not guaranteed to return a view of that
            // type, we need a means of checking it. The "view_type" tag is attached to the views
            // and identify what type the view is. This should allow lists that aren't necessarily
            // recycler views to work correctly with heterogeneous lists.
            convertView.setTag(R.id.view_type, desiredViewType);
        } else {
            oldModel = (PropertyModel) convertView.getTag(R.id.view_model);
        }

        PropertyModel model = mModelList.get(position).model;
        PropertyModelChangeProcessor.ViewBinder binder =
                mViewBuilderMap.get(mModelList.get(position).type).second;

        // 3. Attach a PropertyModelChangeProcessor and PropertyModel to the view (for #1/2 above
        //    when re-using a view).
        convertView.setTag(
                R.id.view_mcp,
                PropertyModelChangeProcessor.create(
                        model, convertView, binder, /* performInitialBind= */ false));
        convertView.setTag(R.id.view_model, model);

        // 4. Bind properties to the convertView.
        bindNewModel(model, oldModel, convertView, binder);

        // TODO(tedchoc): Investigate whether this is still needed.
        convertView.jumpDrawablesToCurrentState();

        return convertView;
    }

    /**
     * Binds all set properties to the view. If oldModel is not null, binds properties that were
     * previously set in the oldModel but are not set in the new model.
     *
     * @param newModel The new model to bind to {@code view}.
     * @param oldModel The old model previously bound to {@code view}. May be null.
     * @param view The view to bind.
     * @param binder The ViewBinder that will bind model properties to {@code view}.
     */
    @VisibleForTesting
    static void bindNewModel(
            PropertyModel newModel,
            @Nullable PropertyModel oldModel,
            View view,
            PropertyModelChangeProcessor.ViewBinder binder) {
        Collection<PropertyKey> setProperties = newModel.getAllSetProperties();
        for (PropertyKey key : newModel.getAllProperties()) {
            if (oldModel != null) {
                // Skip binding properties that haven't changed.
                if (newModel.compareValue(oldModel, key)) {
                    continue;
                }
            } else if (!setProperties.contains(key)) {
                // If there is no previous model, skip binding properties that haven't been set.
                continue;
            }

            binder.bind(newModel, view, key);
        }
    }
}