Анимируем изменения размеров компонента в Android
Привет, %username%! Сегодня я хотел бы поделиться с тобой способом без лишних усилий реализовать анимированное изменение размеров компонента в приложении для Android.
Я много читал про анимацию, а вот использовать в своих интерфейсах до сих пор не довелось. Хотелось опробовать наконец всякие Layout Transitions, Animators, Layout Animations и написать по этому поводу статейку, чтобы и самому лучше запомнить, и другим разжевать. Закончилось, однако, всё гораздо прозаичней — кастомным ViewGroup и ObjectAnimator'ом.
Итак, мне захотелось сделать разворачивающийся при получении фокуса EditText, как в Chrome для Android, вот такой:
Быстро прошерстив StackOverflow для определения примерного направления движения нашёл 2 варианта реализации:
- Использовать ScaleAnimation.
- Так или иначе пошагово менять размер EditText'а и запрашивать requestLayout() на каждом шаге.
Первый вариант я сразу отмёл, как минимум, потому что буквы тоже растянутся. Второй вариант звучит куда логичней, за исключением того, что каждый шаг будет полностью отрабатывать цикл onMeasure/onLayout/onDraw для всей ViewGroup, хотя необходимо изменить отображение только EditText'а. К тому-же я подозревал, что такая анимация вовсе не будет смотреться плавной.
Берём за основу второй способ и начинаем думать как уйти от вызова requestLayout() на каждом шаге. Но начнём, как положено, с малого.
Пишем ViewGroup
Начнём с того, что создадим кастомный ViewGroup для размещения наших компонентов:
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageButton
style="@style/ImageButton"
android:id="@+id/newTabButton"
android:layout_width="@dimen/toolbar_button_size"
android:layout_height="@dimen/toolbar_button_size"
android:layout_gravity="start"
android:contentDescription="@string/content_desc_add_tab"
android:src="@drawable/ic_plus" />
<Button
android:id="@+id/tabSwitcher"
android:layout_width="@dimen/toolbar_button_size"
android:layout_height="@dimen/toolbar_button_size"
android:layout_gravity="end"
android:enabled="false" />
<com.bejibx.webviewexample.widget.UrlBar
android:id="@+id/urlContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="5dp"
android:freezesText="true"
android:hint="@string/hint_url_container"
android:imeOptions="actionGo|flagNoExtractUi|flagNoFullscreen"
android:inputType="textUri"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:singleLine="true"
android:visibility="gone" />
</merge>
public class ToolbarLayout extends ViewGroup
{
private static final String TAG = ToolbarLayout.class.getSimpleName();
private static final boolean DEBUG = true;
private ImageButton mNewTabButton;
private Button mTabSwitchButton;
private UrlBar mUrlContainer;
public ToolbarLayout(Context context)
{
super(context);
initializeViews(context);
}
public ToolbarLayout(Context context, AttributeSet attrs)
{
super(context, attrs);
initializeViews(context);
}
public ToolbarLayout(Context context, AttributeSet attrs, int defStyleAttr)
{
super(context, attrs, defStyleAttr);
initializeViews(context);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public ToolbarLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)
{
super(context, attrs, defStyleAttr, defStyleRes);
initializeViews(context);
}
private void initializeViews(Context context)
{
LayoutInflater.from(context).inflate(R.layout.fragment_address_bar_template, this, true);
mUrlContainer = (UrlBar) findViewById(R.id.urlContainer);
mNewTabButton = (ImageButton) findViewById(R.id.newTabButton);
mTabSwitchButton = (Button) findViewById(R.id.tabSwitcher);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
if (DEBUG)
{
Log.d(TAG, LogHelper.onMeasure(widthMeasureSpec, heightMeasureSpec));
}
int widthConstrains = getPaddingLeft() + getPaddingRight();
final int heightConstrains = getPaddingTop() + getPaddingBottom();
int totalHeightUsed = heightConstrains;
int childTotalWidth;
int childTotalHeight;
MarginLayoutParams lp;
measureChildWithMargins(
mNewTabButton,
widthMeasureSpec,
widthConstrains,
heightMeasureSpec,
heightConstrains);
lp = (MarginLayoutParams) mNewTabButton.getLayoutParams();
childTotalWidth = mNewTabButton.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
childTotalHeight =
mNewTabButton.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
widthConstrains += childTotalWidth;
totalHeightUsed += childTotalHeight;
measureChildWithMargins(
mTabSwitchButton,
widthMeasureSpec,
widthConstrains,
heightMeasureSpec,
heightConstrains);
lp = (MarginLayoutParams) mTabSwitchButton.getLayoutParams();
childTotalWidth =
mTabSwitchButton.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
childTotalHeight =
mTabSwitchButton.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
widthConstrains += childTotalWidth;
totalHeightUsed = Math.max(childTotalHeight + heightConstrains, totalHeightUsed);
/*
* [FIXED] find out how to handle match_parent here
* There was not a problem with match_parent interaction here. The real problem is
* layout_height="wrap_content" on high-level container cause EditText to measure it's
* height improperly. For now I'm just set layout_height on high-level layout to fixed value
* (this make sense because of top-level layout structure, see activity_main.xml) which
* measure EditText correctly.
*
* TODO I'm steel need to figure out whats going wrong in this particular case.
*/
if (mUrlContainer.getVisibility() != GONE)
{
measureChildWithMargins(
mUrlContainer,
widthMeasureSpec,
widthConstrains,
heightMeasureSpec,
heightConstrains);
lp = (MarginLayoutParams) mUrlContainer.getLayoutParams();
childTotalWidth = mUrlContainer.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
childTotalHeight =
mUrlContainer.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
widthConstrains += childTotalWidth;
totalHeightUsed = Math.max(childTotalHeight + heightConstrains, totalHeightUsed);
}
final int totalWidthUsed = widthConstrains;
setMeasuredDimension(
resolveSize(totalWidthUsed, widthMeasureSpec),
resolveSize(totalHeightUsed, heightMeasureSpec));
}
@Override
protected void onLayout(boolean changed, int parentLeft, int parentTop, int parentRight,
int parentBottom)
{
if (DEBUG)
{
Log.d(TAG, LogHelper.onLayout(changed, parentLeft, parentTop, parentRight,
parentBottom));
}
/*
* Layout order:
* 1. Layout "New tab" button on the left side.
* 2. Layout "Tab switch" button on the right side.
* 3. If url container is unfocused, layout it between "New tab" and "Tab switch" buttons.
* Otherwise layout it accordingly to mUrlContainerExpandedRect bounds.
*/
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
/*
* Edges for url container left and right bounds. Move it during layout childs
* located to right and left of url container.
*/
int leftEdge = parentLeft + paddingLeft;
int rightEdge = parentRight - paddingRight;
int childLeft, childTop, childRight, childBottom, childWidth, childHeight;
if (mNewTabButton.getVisibility() != GONE)
{
MarginLayoutParams lp = (MarginLayoutParams) mNewTabButton.getLayoutParams();
childWidth = mNewTabButton.getMeasuredWidth();
childHeight = mNewTabButton.getMeasuredHeight();
childLeft = parentLeft + paddingLeft + lp.leftMargin;
childTop = parentTop + paddingTop + lp.topMargin;
childRight = childLeft + childWidth;
childBottom = childTop + childHeight;
mNewTabButton.layout(childLeft, childTop, childRight, childBottom);
leftEdge = childRight + lp.rightMargin;
}
if (mTabSwitchButton.getVisibility() != GONE)
{
MarginLayoutParams lp = (MarginLayoutParams) mTabSwitchButton.getLayoutParams();
childWidth = mTabSwitchButton.getMeasuredWidth();
childHeight = mTabSwitchButton.getMeasuredHeight();
childRight = parentRight - paddingRight - lp.rightMargin;
childTop = parentTop + paddingTop + lp.topMargin;
childLeft = childRight - childWidth;
childBottom = childTop + childHeight;
mTabSwitchButton.layout(childLeft, childTop, childRight, childBottom);
rightEdge = childLeft - lp.leftMargin;
}
if (mUrlContainer.getVisibility() != GONE)
{
MarginLayoutParams lp = (MarginLayoutParams) mUrlContainer.getLayoutParams();
childHeight = mUrlContainer.getMeasuredHeight();
childLeft = leftEdge + lp.leftMargin;
childTop = parentTop + paddingTop + lp.topMargin;
childRight = rightEdge - lp.rightMargin;
childBottom = childTop + childHeight;
mUrlContainer.layout(childLeft, childTop, childRight, childBottom);
}
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs)
{
return new MarginLayoutParams(getContext(), attrs);
}
@Override
protected LayoutParams generateLayoutParams(LayoutParams p)
{
return new MarginLayoutParams(p);
}
@Override
protected LayoutParams generateDefaultLayoutParams()
{
return new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}
@Override
protected void measureChildWithMargins(
@NonNull View child,
int parentWidthMeasureSpec,
int widthUsed,
int parentHeightMeasureSpec,
int heightUsed)
{
MarginLayoutParams layoutParams = (MarginLayoutParams) child.getLayoutParams();
int childWidthMeasureSpec = getChildMeasureSpec(
parentWidthMeasureSpec,
widthUsed + layoutParams.leftMargin + layoutParams.rightMargin,
layoutParams.width);
int childHeightMeasureSpec = getChildMeasureSpec(
parentHeightMeasureSpec,
heightUsed + layoutParams.topMargin + layoutParams.bottomMargin,
layoutParams.height);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
Разметка содержит 3 элемента:
- Кнопка «Добавить таб», имеет фиксированный размер, находится слева.
- Кнопка «Выбрать таб», имеет фиксированный размер, находится справа.
- Поле для ввода URL (UrlBar, наследник от EditText'а), заполняет собой оставшееся свободное пространство.
Методы onMeasure и onLayout не представляют из себя ничего сложного — сначала меряем/располагаем кнопки, потом текстовое поле между ними.
Я делал всё это поверх другого примера, так что можно заметить присутствие лишнего кода. Например, кнопка «Добавить таб». Она отображается только при переключении в режим выбора таба, в нашем же случае она просто скрыта.
Добавляем аниматор
Сначала добавим параметр, который будет меняться во время анимации. Не будем напрямую изменять размер UrlBar'а из Animator'а, а введём переменную, которая будет отображать текущий прогресс анимации в процентах.
private static final float URL_FOCUS_CHANGE_FOCUSED_PERCENT = 1.0f;
private static final float URL_FOCUS_CHANGE_UNFOCUSED_PERCENT = 0.0f;
/**
* 1.0 is 100% focused, 0 is unfocused
*/
private float mUrlFocusChangePercent;
Мы собираемся использовать ObjectAnimator, так что нужно добавить getter и setter для нашего параметра, однако, если minSdkVersion >= 14, то, чтобы избежать рефлексии, лучше создать поле класса Property для этого.
/**
* Use actual property to avoid reflection when creating animators. For api from
* 11 (3.0.X Honeycomb) to 13 (3.2 Honeycomb_mr2) we should use reflection (see {@link <a href="http://developer.android.com/guide/topics/graphics/prop-animation.html#object-animator">Animating with ObjectAnimator</a>}).
* For older apis I'll recommend to use {@link <a href="http://nineoldandroids.com/">NineOldAndroids</a>} library.
*/
private final Property<ToolbarLayout, Float> mUrlFocusChangePercentProperty =
new Property<ToolbarLayout, Float>(Float.class, "")
{
@Override
public void set(ToolbarLayout object, Float value)
{
mUrlFocusChangePercent = value;
mUrlContainer.invalidate();
invalidate();
}
@Override
public Float get(ToolbarLayout object)
{
return object.mUrlFocusChangePercent;
}
};
Теперь добавим 2 inner-класса и 2 поля для старта анимации.
private boolean mDisableRelayout;
private final UrlContainerFocusChangeListener mUrlContainerFocusChangeListener
= new UrlContainerFocusChangeListener();
private class UrlContainerFocusChangeListener implements OnFocusChangeListener
{
@Override
public void onFocusChange(View v, boolean hasFocus)
{
if (DEBUG)
{
Log.d(TAG, LogHelper.onFocusChange(hasFocus));
}
// Trigger url focus animation
if (mUrlFocusingLayoutAnimator != null && mUrlFocusingLayoutAnimator.isRunning())
{
mUrlFocusingLayoutAnimator.cancel();
mUrlFocusingLayoutAnimator = null;
}
List<Animator> animators = new ArrayList<>();
Animator animator;
if (hasFocus)
{
animator = ObjectAnimator.ofFloat(this, mUrlFocusChangePercentProperty,
URL_FOCUS_CHANGE_FOCUSED_PERCENT);
}
else
{
animator = ObjectAnimator.ofFloat(this, mUrlFocusChangePercentProperty,
URL_FOCUS_CHANGE_UNFOCUSED_PERCENT);
}
animator.setDuration(URL_FOCUS_CHANGE_ANIMATION_DURATION_MS);
animator.setInterpolator(BakedBezierInterpolator.TRANSFORM_CURVE);
animators.add(animator);
mUrlFocusingLayoutAnimator = new AnimatorSet();
mUrlFocusingLayoutAnimator.playTogether(animators);
mUrlFocusingLayoutAnimator.addListener(new UrlFocusingAnimatorListenerAdapter(hasFocus));
mUrlFocusingLayoutAnimator.start();
}
}
private class UrlFocusingAnimatorListenerAdapter extends AnimatorListenerAdapter
{
private final boolean mHasFocus;
public UrlFocusingAnimatorListenerAdapter(boolean hasFocus)
{
super();
mHasFocus = hasFocus;
}
@Override
public void onAnimationEnd(Animator animation)
{
mDisableRelayout = false;
if (!hasFocus())
{
mTabSwitchButton.setVisibility(VISIBLE);
requestLayout();
}
}
@Override
public void onAnimationStart(Animator animation)
{
if (mHasFocus)
{
mTabSwitchButton.setVisibility(GONE);
requestLayout();
}
else
{
mDisableRelayout = true;
}
}
}
Не забудем зарегистрировать наш OnFocusChangeListener в initializeViews!
private void initializeViews(Context context)
{
//...
mUrlContainer.setOnFocusChangeListener(mUrlContainerFocusChangeListener);
}
На этом шаге логика работы непосредственно механизма анимации закончена, осталась визуальная составляющая, но сначала зазберёмся что, зачем и почему.
- При изменении фокуса мы создаём ObjectAnimator, который пошагово изменяет переменную, обозначающую процент получения фокуса полем.
- На каждом шаге вызывается invalidate() для ViewGroup. Данный метод не приводит к переразметке, он только перерисовывает компонент.
Процесс получения фокуса UrlBar'ом будет происходить следующим образом:
- Скрываем все остальные элементы чтобы они не мешали отрисовке анимации (в нашем случае это кнопка переключения табов).
- Вызываем requestLayout() чтобы после завершения анимации реальные границы UrlBar'а совпадали с наблюдаемыми (помните, что после вызова requestLayout() методы onMeasure+onLayout могут быть вызваны с задержкой!).
- Начинаем пошагово менять процент выполнения анимации, вызывая на каждом шаге invalidate().
- Вручную на каждом шаге высчитываем границы UrlBar'а для текущего процента и перерисовываем его.
При потере фокуса UrlBar'ом скрывать элементы и вызывать requestLayout() нужно наоборот, в конце работы анимации. Также, введём переменную для отключения этапа разметки, и не забудем добавить изменения в методы onMeasure и onLayout:
private boolean mDisableRelayout;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
if (!mDisableRelayout)
{
// ...
}
else
{
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int parentLeft, int parentTop, int parentRight,
int parentBottom)
{
if (!mDisableRelayout)
{
// ...
}
}
Готовимся к рисованию
Чтобы посчитать размер UrlBar'а на каждом шаге нам нужно знать его начальный и конечный размер. Добавим 2 переменные, в которые будем запоминать этот размер и в очередной раз немного поменяем onLayout:
/**
* Rectangle, which represents url container bounds relative to it's
* parent bounds when unfocused.
*/
private final Rect mUrlContainerCollapsedRect = new Rect();
/**
* Rectangle, which represents url container bounds relative to it's
* parent bounds when FOCUSED.
*/
private final Rect mUrlContainerExpandedRect = new Rect();
@Override
protected void onLayout(boolean changed, int parentLeft, int parentTop, int parentRight,
int parentBottom)
{
//...
updateUrlBarCollapsedRect();
/*
* Здесь задаётся финальный размер UrlBar'а. Мы хотим развернуть наш UrlBar на весь ViewGroup.
*/
mUrlContainerExpandedRect.set(0, 0, parentRight, parentBottom);
}
/*
* Запоминаем размер UrlBar'а без фокуса. Поскольку кнопка добавления таба не показывается
* вместе с ним, то считаем только правую границу по ширине кнопки переключения табов.
*/
private void updateUrlBarCollapsedRect()
{
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
int rightEdge = getMeasuredWidth() - paddingRight;
MarginLayoutParams lp = (MarginLayoutParams) mTabSwitchButton.getLayoutParams();
rightEdge -= (lp.leftMargin + mTabSwitchButton.getMeasuredWidth() + lp.rightMargin);
lp = (MarginLayoutParams) mUrlContainer.getLayoutParams();
int childHeight = mUrlContainer.getMeasuredHeight();
int childLeft = paddingLeft + lp.leftMargin;
int childTop = paddingTop + lp.topMargin;
int childRight = rightEdge - lp.rightMargin;
int childBottom = childTop + childHeight;
mUrlContainerCollapsedRect.set(childLeft, childTop, childRight, childBottom);
}
Рисуем!
Помните, непосредственно во время анимации реальный размер UrlBar'а не меняется, это происходит либо в начале, либо в конце анимации, а по-умолчанию отрисовывает он себя в соответствии с границами, полученными на этапе разметки. Таким образом, во время анимации реальный размер компонента больше наблюдаемого. Чтобы уменьшить в этой ситуации наблюдаемый размер при отрисовке UrlBar'а воспользуемся хитростью — будем делать clipRect на canvas'е.
Ещё одна хитрость заключается в том, чтобы убрать фон у UrlBar'а и отрисовывать его вручную.
Немножечко меняем разметку.
<com.bejibx.webviewexample.widget.UrlBar
...
android:background="@null" />
Вводим переменную для отрисовки фона.
private Drawable mUrlContainerBackground;
/**
* Variable to store url background padding's. This is important when we use
* 9-patch as background drawable.
*/
private final Rect mUrlBackgroundPadding = new Rect();
private void initializeViews(Context context)
{
//...
mUrlContainerBackground = ApiCompatibilityHelper.getDrawable(getResources(),
R.drawable.textbox);
mUrlContainerBackground.getPadding(mUrlBackgroundPadding);
}
И, наконец, отрисовка! Добавим в метод drawChild(Canvas, View, long) условие для UrlBar'а:
@Override
protected boolean drawChild(Canvas canvas, View child, long drawingTime)
{
if (child == mUrlContainer)
{
boolean clipped = false;
if (mUrlContainerBackground != null)
{
canvas.save();
int clipLeft = mUrlContainerCollapsedRect.left;
int clipTop = mUrlContainerCollapsedRect.top;
int clipRight = mUrlContainerCollapsedRect.right;
int clipBottom = mUrlContainerCollapsedRect.bottom;
int expandedLeft = mUrlContainerExpandedRect.left - mUrlBackgroundPadding.left;
int expandedTop = mUrlContainerExpandedRect.top - mUrlBackgroundPadding.top;
int expandedRight = mUrlContainerExpandedRect.right + mUrlBackgroundPadding.right;
int expandedBottom =
mUrlContainerExpandedRect.bottom + mUrlBackgroundPadding.bottom;
if (mUrlFocusChangePercent == URL_FOCUS_CHANGE_FOCUSED_PERCENT)
{
clipLeft = expandedLeft;
clipTop = expandedTop;
clipRight = expandedRight;
clipBottom = expandedBottom;
}
else
{
// No need to compute those when url bar completely focused or unfocused.
int deltaLeft = clipLeft - expandedLeft;
int deltaTop = clipTop - expandedTop;
int deltaRight = expandedRight - clipRight;
int deltaBottom = expandedBottom - clipBottom;
clipLeft -= deltaLeft * mUrlFocusChangePercent;
clipTop -= deltaTop * mUrlFocusChangePercent;
clipRight += deltaRight * mUrlFocusChangePercent;
clipBottom += deltaBottom * mUrlFocusChangePercent;
}
mUrlContainerBackground.setBounds(clipLeft, clipTop, clipRight,
clipBottom);
mUrlContainerBackground.draw(canvas);
canvas.clipRect(clipLeft, clipTop, clipRight, clipBottom);
clipped = true;
}
boolean result = super.drawChild(canvas, mUrlContainer, drawingTime);
if (clipped)
{
canvas.restore();
}
return result;
}
return super.drawChild(canvas, child, drawingTime);
}
Всё готово, можно запускать и смотреть:
Заключение
Принимаясь за работу, я ожидал, что задача окажется пустяковой и я справлюсь с ней буквально за один вечер. В который раз я натыкаюсь на эти грабли. Если у вас есть другие варианты реализации или замечания к текущей — обязательно поделитесь ими в комментариях.
Я же искренне надеюсь, что данный пример окажется для кого-то полезным. Удачи и да прибудет с вами плавная анимация!