Делаем parallax header в RecyclerView

в 12:00, , рубрики: adapter, android, header, mobile development, parallax, recyclerview, Блог компании ЕТранспорт, говнокод, Разработка под android, финтифлюшки

Приветствую!
С приходом material дизайна приходят и новые элементы. Например, появился RecyclerView, который многим уже известен. Про него на хабре писали не раз: тыц, туц.

Вроде как пользоваться им — понятно, но ведь хочется большего. Обычно при переходе на новые альтернативы чего-то не хватает. Вот и мне не хватило того, что есть. Понадобилось мне сделать parallax эффект, как в Google Play на странице конкретного приложения. Реализации для ListView и ScrollView имеются. Поискал я в великом и могучем, и все, что нашел — этот репозиторий. Решение вроде рабочее, да и народ пользуется. Однако мне не понравилось его юзабилити. И как водится, решил написать свое.

В общем начал я с простого, а именно с того, что нужно создать адаптер, который будет поддерживать хедер. Адаптер не должен был отличаться от принципов обычного адаптера для RecyclerView.

У класса RecyclerView.Adapter есть метод public int getItemViewType(int position), которые возвращает тип для каждой позиции, по умолчанию всегда возвращает 0. Он то и будет нам помогать.

Сразу предупрежу, получившийся класс будет абстрактным. И некоторые методы соответственно тоже.

Переопределяем его следующим образом:

    public static final int TYPE_VIEW_HEADER = Integer.MAX_VALUE;
    private int sizeDiff = 1;
    @Override
    public final int getItemViewType(final int position)
    {
        if (position == 0 && enableHeader)
            return TYPE_VIEW_HEADER;

        return getMainItemType(position - sizeDiff);
    }
 
   protected abstract int getMainItemType(int position);

Значение TYPE_VIEW_HEADER выбрано таким в попытке избежать случайных попаданий в getMainItemType.
По логике далее придется реализовать методы, отвечающие за создание View и отображение на нах информации, а так же несколько абстрактных методов.

Скрытый текст

    @Override
    public final RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType)
    {
        if (viewType == TYPE_VIEW_HEADER)
            return onCreateHeaderViewHolder(parent);
        return onCreateMainViewHolder(parent, viewType);
    }

    protected abstract HeaderHolder onCreateHeaderViewHolder(final ViewGroup parent);

    protected abstract VH onCreateMainViewHolder(final ViewGroup parent, final int viewType);

    @Override
    public final void onBindViewHolder(final RecyclerView.ViewHolder holder, final int position)
    {
        if (holder.getItemViewType() == TYPE_VIEW_HEADER)
        {
            onBindHeaderViewHolder((HeaderHolder) holder);
            return;
        }

        onBindMainViewHolder((VH) holder, position - sizeDiff);
    }

    protected abstract <HH extends HeaderHolder> void onBindHeaderViewHolder(final HH holder);

    protected abstract void onBindMainViewHolder(final VH holder, final int position);

    protected static class HeaderHolder extends RecyclerView.ViewHolder
    {

        public HeaderHolder(final View itemView)
        {
           super(itemView);
        }
    }

Да, приведение типов конечно выглядит не очень красиво, но я не придумал лучшего способа оставить в таком же виде без них.

Вкратце о том, что делает код выше. Сначала мы выдаем нужный тип для в методе getItemViewType, затем основываясь на нем, создаем нужный ViewHolder в onCreateViewHolder, и биндим данные в onBindViewHolder так же проверив viewType.

То, что написано уже предоставляет функционал, чтобы без труда делать обычнее header'ы, но заголовок статьи обещал большего.

Поэтому продолжаем.

Итак, header отображается, теперь давайте заставим его двигаться. Но двигаться он должен в обратном направлении движения контента RecyclerView.

Для этого нам понадобится вспомогательный контейнер, который будет может двигать свое содержимое на заданную величину. Это будет внутренний класс нашего адаптера.

Код этого самого класса

    private static class CustomWrapper extends FrameLayout
    {
        private int offset;

        public CustomWrapper(Context context)
        {
            super(context);
        }

        @Override
        protected void dispatchDraw(Canvas canvas)
        {
            canvas.clipRect(new Rect(getLeft(), getTop(), getRight(), getBottom() + offset));
            super.dispatchDraw(canvas);
        }

        public void setYOffset(int offset)
        {
            this.offset = offset;
            invalidate();
        }
    }

Затем мы перепишем наш класс HeaderHolder таким образом, чтобы он помещал передаваемую View в наш чудо-контейнер:

Обновленный HeaderHolder

    protected static class HeaderHolder extends RecyclerView.ViewHolder
    {

        public HeaderHolder(final View itemView)
        {
            super(new CustomWrapper(itemView.getContext()));
            final ViewGroup parent = (ViewGroup) itemView.getParent();
            if (parent != null)
                parent.removeView(itemView);
            ((CustomWrapper) this.itemView).addView(itemView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
            this.itemView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
        }
    }

Теперь осталось только посылать нужные значения в CustomWrapper и будет нам parallax. Для этого нужно подписаться на события скрола у RecyclerView. Я для этого использовал внутренний класс.

ScrollListener

    private class ScrollListener extends RecyclerView.OnScrollListener
    {

        @Override
        public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy)
        {
            totalScroll += dy;
            if (customWrapper != null && !headerOutOfVisibleRange())
            {
                doParallaxWithHeader(totalScroll);
            }
            changeVisibilityHeader();
        }      

        private void changeVisibilityHeader()
        {
            if (customWrapper != null)
            {
                customWrapper.setVisibility(headerOutOfVisibleRange() ? View.INVISIBLE : View.VISIBLE);
            }
        }

        private boolean headerOutOfVisibleRange()
        {
            return totalScroll > getHeaderHeight();
        }

    }

Тут все просто. При скролле вызывается метод onScrolled. В нем мы изменяем текущую позицию скролла и проверяем, можем ли мы сделать что-нибудь с header'ом. Если да, то делаем паралакс. И когда header выходит за область экрана, то прекращаем проводить с ним всяческие операции, потому в этом нет необходимости.

И последняя кодовая вставка

    private void doParallaxWithHeader(float offset)
    {
        float parallaxOffset = offset * SCROLL_SPEED;
        moveHeaderToOffset(parallaxOffset);

        if (parallaxListener != null && enableHeader)
            parallaxListener.onParallaxScroll(left);

        customWrapper.setYOffset(Math.round(parallaxOffset));
        notifyHeaderChanged();
    }

    private void moveHeaderToOffset(final float parallaxOffset)
    {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB)
        {
            customWrapper.setTranslationY(parallaxOffset);
        }
        else
        {
            TranslateAnimation anim = createTranslateAnimation(parallaxOffset);
            customWrapper.startAnimation(anim);
        }
    }

    private TranslateAnimation createTranslateAnimation(final float parallaxOffset)
    {
        TranslateAnimation anim = new TranslateAnimation(0, 0, parallaxOffset, parallaxOffset);
        anim.setFillAfter(true);
        anim.setDuration(0);
        return anim;
    }

    public final void notifyHeaderChanged()
    {
        notifyItemChanged(0);
    }

    public final void notifyMainItemChanged(final int position)
    {
        notifyItemChanged(position + sizeDiff);
    }

Думаю, очевидно то, что для эффекта паралакса нужно просто уменьшить «скорость» движения. Для этого используется коэффициент SCROLL_SPEED. Затем мы просто двигаем header на новое полученное значение — и все.

Использовать это все весьма просто.

Исходники можно взять здесь; пример там же. Это все опубликовано в jCenter, поэтому подключается одной строкой в gradle.

Бонусом идет HeaderGridLayoutManager, наследник GridLayoutManager, который предоставляет функциональность с header'ом, паралакс там тоже работает.

Еще там есть SpacesItemDecoration, который задает нужное расстояние между всеми элементами RecyclerView. Пока не работает с StaggeredGridLayoutManager.

Вроде все. Спасибо за внимание.

Автор: andreich

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js