Пользователь
0,0
рейтинг
27 августа 2013 в 17:12

Разработка → 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>




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

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


Для реализации перетягивания панельки пальцем нужно переопределить метод 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 для ускорения анимации.

Спасибо за внимание.
Александр @evilduck
карма
62,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (12)

  • 0
    Спасибо грамотная и конкретная статья. Подробно разжевывается интересный материал. И даже исходники доступный. В общем «народу нужные такие статьи» (с) ;-)
  • –1
    Главное правило при анимациях — НЕ ВЫПОЛНЯТЬ LAYOUT!!!
    Спасибо, кэп!
    • +4
      Возможно, для Вас это очевидно, но, поверьте, не для всех, далеко не для всех. Так что всегда пожалуйста, обращайтесь :)
    • 0
      Это было бы смешно если бы не было так печально. Увы кто-то должен говорить вслух вещи очевидные продвинутым экспертам.
  • 0
    Приятно получилось
  • +1
    Круто. Спасибо. Продолжайте писать.
  • 0
    Вообще, очень нравятся ваши статьи. Обычно они покрывают редкообсуждаемые темы (а редко, видимо, из-за низкого уровня андроид разработчиков в целом). Жаль, что такие статьи не получают большого внимания. Если у вас будет время, то делитесь еще. Это действительно очень полезно.
    • 0
      Спасибо. Очень приятно читать такие комментарии :) Конечно буду. Как будет время и интересная тема в голове.
  • 0
    А вы смотрели ViewDragHelper? Очень упрощает имплементацию всего этого дела.
    • 0
      неа, мне же интересно самому поковыряться и разобраться :)
  • 0
    А, еще такой момент: когда панель не показывается, то она все равно рисуется, как я понимаю. Иначе не получится анимация. Как вы думаете, стоит ли все-таки до начала анимации резать размер нижней панели и только в момент начала запрашивать полный лэйаут?
    • 0
      Стоит, конечно, если хотите максимально оптимизировать. Я, честно говоря, не могу сказать, насколько это что-то улучшит, т. к. Андроид, скорее всего, тоже не дурак, и не будет рисовать что-то за границами экрана, но все же. Но, это немного усложнит задачу, т. к. вам нужно будет не просто менять размер содержимого панели, а отрезать часть. В принципе, можно ее разделить на две части — «заголовок» — то, что будет торчать и остальной контент, тогда будет проще.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.