Привет, %username%! Сегодня я хотел бы поделиться с тобой способом Я много читал про анимацию, а вот использовать в своих интерфейсах до сих пор не довелось. Хотелось опробовать наконец всякие 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); }
Всё готово, можно запускать и смотреть:
Заключение
Принимаясь за работу, я ожидал, что задача окажется пустяковой и я справлюсь с ней буквально за один вечер. В который раз я натыкаюсь на эти грабли. Если у вас есть другие варианты реализации или замечания к текущей — обязательно поделитесь ими в комментариях.
Я же искренне надеюсь, что данный пример окажется для кого-то полезным. Удачи и да прибудет с вами плавная анимация!
