Pull to refresh

Анимируем изменения размеров компонента в Android

Development for Android *
Привет, %username%! Сегодня я хотел бы поделиться с тобой способом без лишних усилий реализовать анимированное изменение размеров компонента в приложении для Android.

Я много читал про анимацию, а вот использовать в своих интерфейсах до сих пор не довелось. Хотелось опробовать наконец всякие Layout Transitions, Animators, Layout Animations и написать по этому поводу статейку, чтобы и самому лучше запомнить, и другим разжевать. Закончилось, однако, всё гораздо прозаичней — кастомным ViewGroup и ObjectAnimator'ом.

Итак, мне захотелось сделать разворачивающийся при получении фокуса EditText, как в Chrome для Android, вот такой:



Быстро прошерстив StackOverflow для определения примерного направления движения нашёл 2 варианта реализации:

  1. Использовать ScaleAnimation.
  2. Так или иначе пошагово менять размер 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 элемента:

  1. Кнопка «Добавить таб», имеет фиксированный размер, находится слева.
  2. Кнопка «Выбрать таб», имеет фиксированный размер, находится справа.
  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);
}

На этом шаге логика работы непосредственно механизма анимации закончена, осталась визуальная составляющая, но сначала зазберёмся что, зачем и почему.

  1. При изменении фокуса мы создаём ObjectAnimator, который пошагово изменяет переменную, обозначающую процент получения фокуса полем.
  2. На каждом шаге вызывается invalidate() для ViewGroup. Данный метод не приводит к переразметке, он только перерисовывает компонент.

Процесс получения фокуса UrlBar'ом будет происходить следующим образом:

  1. Скрываем все остальные элементы чтобы они не мешали отрисовке анимации (в нашем случае это кнопка переключения табов).
  2. Вызываем requestLayout() чтобы после завершения анимации реальные границы UrlBar'а совпадали с наблюдаемыми (помните, что после вызова requestLayout() методы onMeasure+onLayout могут быть вызваны с задержкой!).
  3. Начинаем пошагово менять процент выполнения анимации, вызывая на каждом шаге invalidate().
  4. Вручную на каждом шаге высчитываем границы 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);
}

Всё готово, можно запускать и смотреть:



Заключение


Принимаясь за работу, я ожидал, что задача окажется пустяковой и я справлюсь с ней буквально за один вечер. В который раз я натыкаюсь на эти грабли. Если у вас есть другие варианты реализации или замечания к текущей — обязательно поделитесь ими в комментариях.

Я же искренне надеюсь, что данный пример окажется для кого-то полезным. Удачи и да прибудет с вами плавная анимация!
Tags:
Hubs:
Total votes 16: ↑16 and ↓0 +16
Views 23K
Comments Comments 14