📜 ⬆️ ⬇️

Animate component resizing in Android

Hi% username%! Today I would like to share with you a way without any extra effort to implement an animated resizing of a component in an Android application.

I read a lot about animation, but I still haven’t been able to use it in my interfaces. Finally, I wanted to try out all sorts of Layout Transitions , Animators , Layout Animations and write an article on this subject in order to be remembered by myself and chew on others. It ended, however, everything is much more prosaic - custom ViewGroup and ObjectAnimator .

So, I wanted to make EditText unfolding when I got the focus, as in Chrome for Android, like this:


')
Quickly sweeping through StackOverflow to determine the approximate direction of movement found 2 options for implementation:

  1. Use ScaleAnimation .
  2. Anyway, step by step, resize the EditText and request requestLayout () at each step.

The first option, I immediately dismissed, at least, because the letters also stretch. The second option sounds much more logical, except that each step will fully work out the onMeasure / onLayout / onDraw cycle for the entire ViewGroup, although you only need to change the display of the EditText. Besides, I suspected that such animation would not look smooth at all.

We take the second method as a basis and begin to think how to get away from the requestLayout () call at each step. But let's start, as expected, with small.

We write ViewGroup


Let's start by creating a custom ViewGroup to accommodate our components:

Markup
<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> 


Code
 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); } } 


The markup contains 3 elements:

  1. The button "Add tab", has a fixed size, is on the left.
  2. The button "Select tab", has a fixed size, is located on the right.
  3. The URL entry field (UrlBar, derived from EditText), fills the remaining free space.

The onMeasure and onLayout methods are nothing complicated - first we measure / arrange the buttons, then the text field between them.

I did all this on top of another example, so you can notice the presence of extra code. For example, the button "Add tab". It is displayed only when switching to the tab selection mode, in our case, it is simply hidden.

Add an animator


First we add a parameter that will change during the animation. We will not directly change the size of the UrlBar from Animator, but introduce a variable that will display the current progress of the animation as a percentage.

 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; 

We are going to use ObjectAnimator, so we need to add getter and setter for our parameter, however, if minSdkVersion> = 14, then to avoid reflection, it is better to create a field of the Property class for this.

 /** * 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; } }; 

Now add 2 inner-classes and 2 fields to start the animation.

 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; } } } 

Do not forget to register our OnFocusChangeListener with initializeViews!
 private void initializeViews(Context context) { //... mUrlContainer.setOnFocusChangeListener(mUrlContainerFocusChangeListener); } 

At this step, the logic of the animation mechanism itself is finished, the visual component remains, but first we will see what, why and why.

  1. When the focus changes, we create ObjectAnimator, which incrementally changes the variable denoting the percentage of focus getting by the field.
  2. At each step, invalidate () is called for the ViewGroup. This method does not cause redevelopment; it only redraws the component.

The process of obtaining the focus by UrlBar will be as follows:

  1. We hide all other elements so that they do not interfere with the rendering of the animation (in our case, this is the button for switching between tabs).
  2. Call the requestLayout () so that after the animation is completed, the real boundaries of UrlBar coincide with the observed ones (remember that after the call to the requestLayout () call, the onMeasure + onLayout methods may be delayed!).
  3. We start step by step to change the percentage of the animation, causing invalidate () at each step.
  4. Manually, at each step, we calculate the boundaries of the UrlBar for the current percentage and redraw it.

When UrlBar loses focus, hide elements and call requestLayout () on the contrary, at the end of the animation. Also, we introduce a variable to disable the markup stage, and do not forget to add changes to the onMeasure and onLayout methods:

 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) { // ... } } 

Getting ready for drawing


To calculate the size of the UrlBar at each step, we need to know its initial and final size. Add 2 variables in which we will memorize this size and once again change a bit 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); } 

We draw!


Remember, directly during the animation, the actual size of the UrlBar does not change, it happens either at the beginning or at the end of the animation, and by default it draws itself in accordance with the boundaries obtained at the markup stage. Thus, during the animation, the real size of the component is larger than the observed. To reduce the observed size in this situation, when drawing UrlBar, we use the trick - we will do clipRect on the canvas .

Another trick is to remove the background from UrlBar and draw it manually.

A little change the markup.

 <com.bejibx.webviewexample.widget.UrlBar ... android:background="@null" /> 

Enter a variable to draw the background.

 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); } 

And finally, the rendering! Add a condition for the UrlBar to the drawChild method (Canvas, View, long) :

 @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); } 

Everything is ready, you can run and watch:



Conclusion


When I got to work, I expected the task to be trivial and I would cope with it literally in one evening. Once again I stumble on this rake. If you have other options for implementation or comments on the current - be sure to share them in the comments.

I sincerely hope that this example will be useful for someone. Good luck and let the smooth animation come with you!

Source: https://habr.com/ru/post/268889/


All Articles