Custom layout. Выплывающая панель + параллаксный скроллинг

Сегодня я хотел рассказать, как можно создать нестандартный менеджер разметки (layout manager) и вдохнуть в него жизнь при помощи анимаций.
Нам в DataArt часто требуется реализовывать нестандартные компоненты для приложений заказчика, поэтому у меня накопился определенный опыт в этом деле, которым решил поделиться.
В качестве примера решил реализовать аналог часто встречающейся в социальных сетях выплывающей снизу панели. Обычно этот прием используется при необходимости показать контент, например, фото, и добавить возможность комментировать на дополнительной панельке, которую пользователь может вытянуть снизу. При этом основной контент обычно тоже уплывает наверх, но немного медленнее, чем основная панель. Это называется «параллаксный скроллинг».
Специально для этой статьи я решил с нуля реализовать подобный компонент. Сразу хочу заметить, что это не полноценный, стабильный и готовый для продакшен код, а всего лишь демонстрация, написанная за пару часов, чтобы показать основные приемы.

Расширяем существующий компонент

Для простоты реализации я решил не расширять с нуля ViewGroup, а наследоваться от FrameLayout. Это избавит от необходимости реализации базовых рутинных вещей, таких, как измерение детей, компоновка и т. п., но в то же время предоставит достаточно гибкости для реализации нашей затеи.
Итак, создаем класс DraggablePanelLayout.
Первое, что мы хотим сделать, — модифицировать процедуру компоновки, чтобы верхний слой был смещен вниз, и лишь его часть выглядывала. Для этого переопределим onLayout:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);

    if (getChildCount() != 2) {
        throw new IllegalStateException("DraggedPanelLayout must have 2 children!");
    }

    bottomPanel = getChildAt(0);
    bottomPanel.layout(left, top, right, bottom - bottomPanelPeekHeight);

    slidingPanel = getChildAt(1);
    if (!opened) {
        int panelMeasuredHeight = slidingPanel.getMeasuredHeight();
        slidingPanel.layout(left, bottom - bottomPanelPeekHeight, right, bottom - bottomPanelPeekHeight
            + panelMeasuredHeight);
    }
}

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

<com.dataart.animtest.DraggedPanelLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:dp="http://schemas.android.com/apk/res/com.dataart.animtest"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    dp:bottom_panel_height="64dp"
    tools:context=".MainActivity" >

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@drawable/stripes" >

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:contentDescription="@string/android"
            android:src="@drawable/android" />
    </FrameLayout>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#FFFFFF"
        android:text="@string/hello_world" >

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:text="@string/random_button" />
    </FrameLayout>

</com.dataart.animtest.DraggedPanelLayout>

b85808ec9ccb8be074f88999e5f9af9c

Как видим, нижняя панель успешно сместилась вниз. То, что нам нужно.

Добавляем перетягивание пальцем

Для реализации перетягивания панельки пальцем нужно переопределить метод onTouchEvent. Здесь мы при нажатии пальцем (ACTION_DOWN) запомним, в каком месте пользователь нажал, далее при движении пальцем (ACTION_MOVE), мы будем смещать наши панели, и, наконец, при ACTION_UP, мы завершим действие. Завершение действия, пожалуй, самая интересная задача, но об этом дальше.

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
        startDragging(event);
    } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
        if (touching) {
        float translation = event.getY() - touchY;
        translation = boundTranslation(translation);
        slidingPanel.setTranslationY(translation);
        bottomPanel
            .setTranslationY((float) (opened ? -(getMeasuredHeight() - bottomPanelPeekHeight - translation)
                * parallaxFactor : translation * parallaxFactor));
        }
    } else if (event.getAction() == MotionEvent.ACTION_UP) {
        isBeingDragged = false;
        touching = false;
    }
    return true;
 }

Здесь все просто. Метод boundTranslation ограничивает перемещение панели пальцем в рамках экрана, setTranslation задает смещение.

Здесь мне хочется сделать небольшое отступление и поговорить о layout и translation. Layout — это процесс компоновки вашей разметки, т. е. для каждого View рекурсивно определяется его размер и положение на экране. Как не трудно догадаться, это затратная операция. Именно поэтому очень не рекомендуется выполнять эту процедуру при анимации, если вы только не хотите получить эффект тормозящей анимации. Свойство translation, в свою очередь, позволяет задать дешевое смещение элемента относительно заданной позиции без выполнения компоновки всей иерархии. Это очень полезно при анимациях. Помимо translation, у View есть такие свойства, как Rotation, Scale. Более продвинутые преобразования также возможно делать, создав подкласс желаемого компонента и выполняя необходимые преобразования канвы. Пример этого можно увидеть в моей предыдущей статье про анимирование ListView.

Еще раз, но кратко и капсом. Главное правило при анимациях — НЕ ВЫПОЛНЯТЬ LAYOUT!!!

Завершение жеста

Итак, мы научились двигать нашу панель пальцами, теперь нужно добавить завершение жеста. Т. е. если пользователь убирает палец, мы будем придерживаться следующей логики:

  1. Если скорость панели достаточно высокая — довести панель до конца и перевести компонент в противоположное состояние.
  2. Если же скорость не высока, проверить, провел ли пользователь панель через половину расстояния. Если да, продолжить движение с фиксированной скоростью, иначе — вернуть панель в исходное состояние.
public void finishAnimateToFinalPosition(float velocityY) {
    final boolean flinging = Math.abs(velocityY) > 0.5;

    boolean opening;
    float distY;
    long duration;

    if (flinging) {
        opening = velocityY < 0;
        distY = calculateDistance(opening);
        duration = Math.abs(Math.round(distY / velocityY));
        animatePanel(opening, distY, duration);
    } else {
        boolean halfway = Math.abs(slidingPanel.getTranslationY()) >= (getMeasuredHeight() - bottomPanelPeekHeight) / 2;
        opening = opened ? !halfway : halfway;
        distY = calculateDistance(opening);
        duration = Math.round(300 * (double) Math.abs((double) slidingPanel.getTranslationY())
            / (double) (getMeasuredHeight() - bottomPanelPeekHeight));
    }

    animatePanel(opening, distY, duration);
}

Метод выше реализует эту логику. Для вычисления скорости используем встроенный класс VelocityTracker.

Наконец, создаем ObjectAnimator и завершаем анимацию:

public void animatePanel(final boolean opening, float distY, long duration) {
    ObjectAnimator slidingPanelAnimator = ObjectAnimator.ofFloat(slidingPanel, View.TRANSLATION_Y,
        slidingPanel.getTranslationY(), slidingPanel.getTranslationY() + distY);
    ObjectAnimator bottomPanelAnimator = ObjectAnimator.ofFloat(bottomPanel, View.TRANSLATION_Y,
        bottomPanel.getTranslationY(), bottomPanel.getTranslationY() + (float) (distY * parallaxFactor));

    AnimatorSet set = new AnimatorSet();
    set.playTogether(slidingPanelAnimator, bottomPanelAnimator);
    set.setDuration(duration);
    set.setInterpolator(sDecelerator);
    set.addListener(new MyAnimListener(opening));
    set.start();
}

При завершении анимации переводим компонент в новое состояние, обнуляем смещение и выполняем layout.

@Override
public void onAnimationEnd(Animator animation) {
        setOpenedState(opening);

        bottomPanel.setTranslationY(0);
        slidingPanel.setTranslationY(0);

        requestLayout();
}

Перехват touch’a у других элементов

Сейчас, если мы поместим, например, кнопку на нашу панельку, увидим, что, если попытаться тянуть панель, нажав пальцем на кнопку, мы не сможем этого сделать. Кнопка нажмется, но наша панель останется неподвижной. Это потому, что кнопка
«крадет» у панели событие touch и обрабатывает его сама.
Стандартный подход — перехватывать событие, убедиться, что мы действительно тянем панель, а не просто клацнули по кнопке, и отобрать контроль у кнопки, полностью захватив его нашим компонентом. Специально для этого у View есть метод onInterceptTouchEvent. Логика работы этого метода и взаимодействия с onTouchEvent весьма нетривиальна, но хорошо расписана в документации.

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
        touchY = event.getY();
    } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
        if (Math.abs(touchY - event.getY()) > touchSlop) {
        isBeingDragged = true;
        startDragging(event);
        }
    } else if (event.getAction() == MotionEvent.ACTION_UP) {
        isBeingDragged = false;
    }	

    return isBeingDragged;
}

В нашей реализации мы проверяем, сместил ли пользователь палец достаточно (touchSlop), прежде чем возвращать true (что означает, что мы захватили контроль).
Готово, теперь пользователь может и нажать на кнопку, и начать двигать панель в любом месте. Кнопка просто не зарегистрирует нажатие, а получит событие ACTION_CANCEL.

Завершение

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

Все исходники компонента доступны на github. Помимо того, что описано в статье, реализация добавляет:

  1. рисование тени между панелями;
  2. кастомные аттрибуты;
  3. использование hardware layers для ускорения анимации.

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