📜 ⬆️ ⬇️

Custom layout. Pop-up panel + parallax scrolling

Hello colleagues.

Today I wanted to tell you how you can create a non-standard layout manager and breathe life into it with animations.
We in DataArt often need to implement non-standard components for customer applications, so I have gained some experience in this business, which I decided to share.
As an example, I decided to implement an analogue of a panel emerging from the bottom that is often found on social networks. Typically, this technique is used when you need to show content, for example, a photo, and add the ability to comment on an additional socket that the user can pull from below. At the same time, the main content usually also floats up, but a little slower than the main panel. This is called "parallax scrolling."
Especially for this article, I decided to implement a similar component from scratch. Just want to note that this is not a complete, stable and ready for production code, but just a demonstration, written a couple of hours to show the basic techniques.


')

Expand existing component


For ease of implementation, I decided not to extend the ViewGroup from scratch, but to inherit from FrameLayout. This will eliminate the need to implement basic routine things, such as measuring children, layout, etc., but at the same time provide enough flexibility to implement our venture.
So, create the class DraggablePanelLayout.
The first thing we want to do is modify the layout procedure so that the top layer is shifted down, and only part of it looks out. To do this, override 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); } } 


Everything is simple here: we restrict our layout so that it can store only two descendants. Then forcibly move the upper child down and squeeze the bottom of the bottom. Let's do the simplest layout and see what we got:

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




As you can see, the bottom panel has successfully shifted down. What we need.

Add finger dragging


To implement dragging the panel with your finger, you need to override the onTouchEvent method. Here, when we press with our finger (ACTION_DOWN), we will remember where the user clicked, then with his finger (ACTION_MOVE), we will shift our panels, and finally, with ACTION_UP, we will complete the action. Completing the action is perhaps the most interesting task, but more on that later.

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


Everything is simple here. The method of boundTranslation limits the movement of the panel with a finger within the screen, setTranslation sets the offset.

Here I want to make a small digression and talk about layout and translation. Layout is the process of arranging your markup, i.e., for each View, its size and position on the screen is determined recursively. As it is not difficult to guess, this is a costly operation. That is why it is not recommended to perform this procedure during animation, if you just do not want to get the effect of braking animation. The translation property, in turn, allows you to set a cheap displacement of an element relative to a given position without performing the layout of the entire hierarchy. This is very useful for animations. In addition to translation, the View has properties such as Rotation, Scale. It is also possible to do more advanced transformations by creating a subclass of the desired component and performing the necessary transformations of the canvas. An example of this can be seen in my previous article about animating a ListView.


Once again, but briefly and caps. The main rule for animations - DO NOT EXECUTE LAYOUT !!!

End gesture


So, we have learned to move our panel with our fingers, now we need to add the completion of the gesture. That is, if the user removes the finger, we will adhere to the following logic:
  1. If the panel speed is high enough, bring the panel to the end and transfer the component to the opposite state.
  2. If the speed is not high, check if the user has passed the panel through half the distance. If yes, continue driving at a fixed speed, otherwise - return the panel to its original state.


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


The method above implements this logic. To calculate the speed, use the built-in class VelocityTracker.
Finally, create an ObjectAnimator and complete the animation:

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


When the animation is completed, we transfer the component to a new state, reset the offset and execute the layout.

 @Override public void onAnimationEnd(Animator animation) { setOpenedState(opening); bottomPanel.setTranslationY(0); slidingPanel.setTranslationY(0); requestLayout(); } 


Interception touch'a other elements


Now, if we place, for example, a button on our socket, we see that if we try to pull the panel by pressing a button with our finger, we will not be able to do this. The button will click, but our panel will remain fixed. This is because the button
The touch event “steals” the panel and handles it itself.
The standard approach is to intercept an event, make sure that we really pull the panel, and not just clicked a button, and take control from the button, completely capturing it with our component. Specifically for this, View has an onInterceptTouchEvent method. The logic of this method and the interaction with onTouchEvent is very nontrivial, but well written in the documentation.

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


In our implementation, we check if the user has moved the finger enough (touchSlop) before returning true (which means that we have taken control).
Done, now the user can press a button and start moving the panel anywhere. The button simply does not register a click, but will receive an ACTION_CANCEL event.

Completion


This article describes the basic approach to the implementation of animated markup. Of course, a full-fledged implementation will require taking into account some additional factors, but, starting with what is described here, it is easy to adapt it to your needs.

All source components are available on github . In addition to what is described in the article, the implementation adds:
  1. drawing shadows between panels;
  2. custom attributes;
  3. Use hardware layers to speed up animation.

Thanks for attention.

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


All Articles