Робимо parallax header в RecyclerView

Вітаю!
З приходом 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.

Ніби все. Дякую за увагу.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.