chromium/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/home/list/DateOrderedListView.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.chrome.browser.download.home.list;

import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
import android.view.View;

import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ItemDecoration;
import androidx.recyclerview.widget.RecyclerView.Recycler;
import androidx.recyclerview.widget.RecyclerView.State;

import org.chromium.chrome.browser.download.home.DownloadManagerUiConfig;
import org.chromium.chrome.browser.download.home.list.DateOrderedListCoordinator.DateOrderedListObserver;
import org.chromium.chrome.browser.download.home.list.ListItem.OfflineItemListItem;
import org.chromium.chrome.browser.download.home.list.holder.ListItemViewHolder;
import org.chromium.chrome.browser.download.internal.R;
import org.chromium.components.browser_ui.widget.displaystyle.HorizontalDisplayStyle;
import org.chromium.components.browser_ui.widget.displaystyle.UiConfig;
import org.chromium.ui.modelutil.ForwardingListObservable;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
import org.chromium.ui.modelutil.RecyclerViewAdapter;

/**
 * The View component of a DateOrderedList.  This takes the DateOrderedListModel and creates the
 * glue to display it on the screen.
 */
class DateOrderedListView {
    private final DownloadManagerUiConfig mConfig;
    private final DecoratedListItemModel mModel;

    private final int mIdealImageWidthPx;
    private final int mInterImagePaddingPx;
    private final int mPrefetchVerticalPaddingPx;
    private final int mHorizontalPaddingPx;
    private final int mVerticalPaddingPx;
    private final int mMaxWidthImageItemPx;

    private final RecyclerView mView;
    private final GridLayoutManager mGridLayoutManager;
    private final UiConfig mUiConfig;
    private Runnable mOnConfigurationChangedCallback;

    /** Creates an instance of a {@link DateOrderedListView} representing {@code model}. */
    public DateOrderedListView(
            Context context,
            DownloadManagerUiConfig config,
            DecoratedListItemModel model,
            DateOrderedListObserver dateOrderedListObserver,
            Runnable onConfigurationChangedCallback) {
        mConfig = config;
        mModel = model;

        mIdealImageWidthPx =
                context.getResources()
                        .getDimensionPixelSize(R.dimen.download_manager_ideal_image_width);
        mInterImagePaddingPx =
                context.getResources()
                        .getDimensionPixelOffset(R.dimen.download_manager_image_padding);
        mHorizontalPaddingPx =
                context.getResources()
                        .getDimensionPixelSize(R.dimen.download_manager_horizontal_margin);
        mPrefetchVerticalPaddingPx =
                context.getResources()
                        .getDimensionPixelSize(R.dimen.download_manager_prefetch_vertical_margin);
        mVerticalPaddingPx =
                context.getResources()
                        .getDimensionPixelSize(
                                R.dimen.download_manager_vertical_margin_between_download_types);
        mMaxWidthImageItemPx =
                context.getResources()
                        .getDimensionPixelSize(
                                R.dimen.download_manager_max_image_item_width_wide_screen);

        mView =
                new RecyclerView(context) {
                    private int mScreenOrientation = Configuration.ORIENTATION_UNDEFINED;

                    @Override
                    protected void onConfigurationChanged(Configuration newConfig) {
                        super.onConfigurationChanged(newConfig);
                        mUiConfig.updateDisplayStyle();
                        if (newConfig.orientation == mScreenOrientation) return;

                        mScreenOrientation = newConfig.orientation;
                        mView.invalidateItemDecorations();
                        mOnConfigurationChangedCallback.run();
                    }
                };
        mView.setId(R.id.download_home_recycler_view);
        mView.setHasFixedSize(true);
        ((DefaultItemAnimator) mView.getItemAnimator()).setSupportsChangeAnimations(false);
        mView.getItemAnimator().setMoveDuration(0);

        mGridLayoutManager = new GridLayoutManagerImpl(context);
        mView.setLayoutManager(mGridLayoutManager);
        mView.addItemDecoration(new ItemDecorationImpl());
        mView.setClipToPadding(false);

        PropertyModelChangeProcessor.create(
                mModel.getProperties(), mView, new ListPropertyViewBinder());

        // Do the final hook up to the underlying data adapter.
        DateOrderedListViewAdapter adapter =
                new DateOrderedListViewAdapter(
                        mModel, new ModelChangeProcessor(mModel), ListItemViewHolder::create);
        mView.setAdapter(adapter);
        mView.post(adapter::notifyDataSetChanged);
        mView.addOnScrollListener(
                new RecyclerView.OnScrollListener() {
                    @Override
                    public void onScrolled(RecyclerView view, int dx, int dy) {
                        dateOrderedListObserver.onListScroll(mView.canScrollVertically(-1));
                    }
                });

        mUiConfig = new UiConfig(mView);
        mUiConfig.addObserver(
                (newDisplayStyle) -> {
                    int padding =
                            getPaddingForDisplayStyle(newDisplayStyle, context.getResources());
                    mView.setPaddingRelative(
                            padding, mView.getPaddingTop(), padding, mView.getPaddingBottom());
                });
        mOnConfigurationChangedCallback = onConfigurationChangedCallback;
    }

    /** @return The Android {@link View} representing this widget. */
    public View getView() {
        return mView;
    }

    /**
     * @return The start and end padding of the recycler view for the given display style.
     */
    private static int getPaddingForDisplayStyle(
            UiConfig.DisplayStyle displayStyle, Resources resources) {
        int padding = 0;
        if (displayStyle.horizontal == HorizontalDisplayStyle.WIDE) {
            int screenWidthDp = resources.getConfiguration().screenWidthDp;
            padding =
                    (int)
                            (((screenWidthDp - UiConfig.WIDE_DISPLAY_STYLE_MIN_WIDTH_DP) / 2.f)
                                    * resources.getDisplayMetrics().density);
            padding =
                    (int)
                            Math.max(
                                    resources.getDimensionPixelSize(
                                            R.dimen
                                                    .download_manager_recycler_view_min_padding_wide_screen),
                                    padding);
        }
        return padding;
    }

    /** @return The view width available after start and end padding. */
    private int getAvailableViewWidth() {
        return mView.getWidth()
                - ViewCompat.getPaddingStart(mView)
                - ViewCompat.getPaddingEnd(mView);
    }

    private class GridLayoutManagerImpl extends GridLayoutManager {
        /** Creates an instance of a {@link GridLayoutManagerImpl}. */
        public GridLayoutManagerImpl(Context context) {
            super(context, /* spanCount= */ 1, VERTICAL, /* reverseLayout= */ false);
            setSpanSizeLookup(new SpanSizeLookupImpl());
        }

        // GridLayoutManager implementation.
        @Override
        public void onLayoutChildren(Recycler recycler, State state) {
            assert getOrientation() == VERTICAL;

            int availableWidth = getAvailableViewWidth() - 2 * mHorizontalPaddingPx;
            int columnWidth = mIdealImageWidthPx - mInterImagePaddingPx;

            int easyFitSpan = availableWidth / columnWidth;
            double remaining =
                    ((double) (availableWidth - easyFitSpan * columnWidth)) / columnWidth;
            if (remaining > 0.5) easyFitSpan++;
            setSpanCount(Math.max(1, easyFitSpan));

            super.onLayoutChildren(recycler, state);
        }

        @Override
        public boolean supportsPredictiveItemAnimations() {
            return false;
        }

        private class SpanSizeLookupImpl extends SpanSizeLookup {
            // SpanSizeLookup implementation.
            @Override
            public int getSpanSize(int position) {
                return ListUtils.getSpanSize(mModel.get(position), mConfig, getSpanCount());
            }
        }
    }

    private class ItemDecorationImpl extends ItemDecoration {
        // ItemDecoration implementation.
        @Override
        public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
            int position = parent.getChildAdapterPosition(view);
            if (position < 0 || position >= mModel.size()) return;

            ListItem item = mModel.get(position);
            boolean isFullWidthMedia = false;
            @ListUtils.ViewType
            int viewType = ListUtils.getViewTypeForItem(mModel.get(position), mConfig);
            switch (viewType) {
                case ListUtils.ViewType.IMAGE: // fall through
                case ListUtils.ViewType.IMAGE_FULL_WIDTH: // fall through
                case ListUtils.ViewType.IN_PROGRESS_IMAGE:
                    isFullWidthMedia = ((ListItem.OfflineItemListItem) item).spanFullWidth;
                    if (isFullWidthMedia || mGridLayoutManager.getSpanCount() == 1) {
                        outRect.left = mHorizontalPaddingPx;
                        outRect.right = mHorizontalPaddingPx;
                    } else {
                        computeItemDecoration(position, outRect);
                    }

                    outRect.top = mInterImagePaddingPx / 2;
                    outRect.bottom = mInterImagePaddingPx / 2;
                    break;
                case ListUtils.ViewType.VIDEO: // Intentional fallthrough.
                case ListUtils.ViewType.IN_PROGRESS_VIDEO:
                    outRect.left = mHorizontalPaddingPx;
                    outRect.right = mHorizontalPaddingPx;
                    outRect.top = mPrefetchVerticalPaddingPx / 2;
                    outRect.bottom = mPrefetchVerticalPaddingPx / 2;
                    isFullWidthMedia = true;
                    break;
                case ListUtils.ViewType.PREFETCH_ARTICLE: // fall through
                case ListUtils.ViewType.AUDIO:
                    outRect.left = mHorizontalPaddingPx;
                    outRect.right = mHorizontalPaddingPx;
                    outRect.top = mPrefetchVerticalPaddingPx / 2;
                    outRect.bottom = mPrefetchVerticalPaddingPx / 2;
                    break;
                case ListUtils.ViewType.GROUP_CARD_HEADER: // fall through
                case ListUtils.ViewType.GROUP_CARD_FOOTER: // fall through
                case ListUtils.ViewType.GROUP_CARD_ITEM: // fall through
                case ListUtils.ViewType.GROUP_CARD_DIVIDER_MIDDLE:
                    outRect.left = mHorizontalPaddingPx;
                    outRect.right = mHorizontalPaddingPx;
                    break;
                case ListUtils.ViewType.GROUP_CARD_DIVIDER_TOP:
                    outRect.left = mHorizontalPaddingPx;
                    outRect.right = mHorizontalPaddingPx;
                    outRect.top = mPrefetchVerticalPaddingPx / 2;
                    break;
                case ListUtils.ViewType.GROUP_CARD_DIVIDER_BOTTOM:
                    outRect.left = mHorizontalPaddingPx;
                    outRect.right = mHorizontalPaddingPx;
                    outRect.bottom = mPrefetchVerticalPaddingPx / 2;
                    break;
            }

            if (isFullWidthMedia
                    && mUiConfig.getCurrentDisplayStyle().horizontal
                            == HorizontalDisplayStyle.WIDE) {
                outRect.right += Math.max(getAvailableViewWidth() - mMaxWidthImageItemPx, 0);
            }

            // If the current item is the last of its download type in a given section and not
            // displayed in a grid, add padding below. Grid items are handled differently as
            // described in the next section.
            if (isLastOfDownloadTypeInSection(position) && !(isGridItem(position))) {
                outRect.bottom += mVerticalPaddingPx;
            }

            // If the previous item was a grid item, and current one is not, add padding above to
            // differentiate between sections.
            if (position > 0
                    && isLastOfDownloadTypeInSection(position - 1)
                    && isGridItem(position - 1)) {
                outRect.top += mVerticalPaddingPx;
            }
        }

        private void computeItemDecoration(int position, Rect outRect) {
            GridLayoutManager.SpanSizeLookup spanLookup = mGridLayoutManager.getSpanSizeLookup();
            int spanCount = mGridLayoutManager.getSpanCount();
            int columnIndex = spanLookup.getSpanIndex(position, spanCount);

            horizontallyRepositionGridItem(
                    columnIndex,
                    spanCount,
                    mHorizontalPaddingPx,
                    mHorizontalPaddingPx,
                    mInterImagePaddingPx,
                    outRect);
        }
    }

    /**
     * Given the column index for an image in a grid view, computes the left and right edge
     * offsets.
     * @param columnIndex Column index for the item
     * @param spanCount Span count of the grid
     * @param leftMargin Leftmost margin in the row
     * @param rightMargin Rightmost margin in the row
     * @param padding Spacing between two items
     * @param outRect The output rect that contains the computed offsets
     */
    private static void horizontallyRepositionGridItem(
            int columnIndex,
            int spanCount,
            int leftMargin,
            int rightMargin,
            int padding,
            Rect outRect) {
        assert spanCount > 1;

        // Margin here refers to the leftmost or rightmost margin in the row, and padding here
        // refers to the inter-image spacing.

        // Calculate how much each image should be shrunk compared to the ideal image size if no
        // margin or padding were present.
        int shrink = (leftMargin + rightMargin + (spanCount - 1) * padding) / spanCount;

        // Starting from left, calculate how much the image is shifted from ideal position
        // due to the leftmost margin and padding between previous images. Subtract the
        // total shrink for the previous images from this value.
        outRect.left = leftMargin + columnIndex * padding - columnIndex * shrink;

        // For right edge, the calculation is exactly same as left, except we have one extra
        // shrink. Negate the final value.
        outRect.right = -(leftMargin + columnIndex * padding - (columnIndex + 1) * shrink);
    }

    /**
     * Determines whether or not the item at position is an OfflineItemListItem (representing a
     * downloaded item) and is the last of its type in a given section. Does so by comparing the
     * current item to the following item.
     * @param position Index of the item we are checking
     * @return Whether or not the current item is the last of its download type in a given section.
     */
    private boolean isLastOfDownloadTypeInSection(int position) {
        // If the current item is not an OfflineItemListItem, it cannot have a download type, and
        // thus can't be the last of its download type.
        ListItem currentItem = mModel.get(position);
        if (!(currentItem instanceof OfflineItemListItem)) return false;

        // If the next item is not an OfflineItemListItem, it cannot have a download type. This
        // means the next item can't be the same type as the current item and the current item is
        // therefore the last of its download type.
        ListItem nextItem = position >= mModel.size() - 1 ? null : mModel.get(position + 1);
        if (!(nextItem instanceof OfflineItemListItem)) return true;

        // If both items are OfflineItemListItems, but are of different type, then the current item
        // is the last of its type.
        boolean nextItemIsDifferentType =
                ((OfflineItemListItem) currentItem).item.filter
                        != ((OfflineItemListItem) nextItem).item.filter;
        if (nextItemIsDifferentType) return true;

        // If this point is reached, both items are OfflineListItems and the same type, meaning the
        // current item is not the last of its type.
        return false;
    }

    /**
     * Determines whether or not the item at position is displayed in a grid (e.g. multiple images).
     * Does so by checking if the item's span size is less than the span count of a row.
     * @param position Index of the item we are checking
     * @return Whether or not the item at position is displayed in a grid.
     */
    private boolean isGridItem(int position) {
        GridLayoutManager.SpanSizeLookup spanLookup = mGridLayoutManager.getSpanSizeLookup();
        int spanCount = mGridLayoutManager.getSpanCount();
        int spanSize = spanLookup.getSpanSize(position);

        return spanSize < spanCount;
    }

    private class ModelChangeProcessor extends ForwardingListObservable<Void>
            implements RecyclerViewAdapter.Delegate<ListItemViewHolder, Void> {
        private final DecoratedListItemModel mModel;

        public ModelChangeProcessor(DecoratedListItemModel model) {
            mModel = model;
            model.addObserver(this);
        }

        @Override
        public int getItemCount() {
            return mModel.size();
        }

        @Override
        public int getItemViewType(int position) {
            return ListUtils.getViewTypeForItem(mModel.get(position), mConfig);
        }

        @Override
        public void onBindViewHolder(
                ListItemViewHolder viewHolder, int position, @Nullable Void payload) {
            viewHolder.bind(mModel.getProperties(), mModel.get(position));
        }

        @Override
        public void onViewRecycled(ListItemViewHolder viewHolder) {
            viewHolder.recycle();
        }
    }
}