📜 ⬆️ ⬇️

SlideStackView or extending ViewGroup in Android

Being engaged in the development of a mail application for Android, we in Mail.Ru often analyze how convenient it is for the end user to navigate within our program. I think that it is not worth explaining how important this is, because everyone who uses mobile applications knows without it that a product that does not provide intuitive and fast navigation will lose to those products that have taken care of it. Convenience and well-designed navigation is what the user will either love your application for, or every time make incredible efforts not to break your phone on the floor.

In our applications, we used the “sliding menu” before it was included in the official ui patterns for Android. After certain studies in the usability lab, we decided that we need to move forward and improve the side menu to provide the user with the best user experience.

So, more to the point. Instead of such a menu where a fragment with folders and accounts moves along with a swipe of a list of letters, we wanted to make the fragments move in turns, such as a stack of sheets, which we shift to view the next page.
')





In addition to how the controller, called SlideStackView, should behave, there were several other requirements from both the development and the product side:

1. Each slide must be represented by a fragment.
2. Must be implemented "bouncing over scroll effect" - when the slide as it springs from the edge
3. Vertical lists inside slides should scroll without any behavioral glitches.
4. Save / restore state (position of open slide, etc.)

Naturally, before proceeding to the fulfillment of minor requirements, you need to implement the ViewGroup itself, which will manage your children the way we planned.

We will go from simple to complex - first we need to decide how we fit into androidFramework, in order, first, not to write a bicycle, and second, to provide the most similar user experience. The above-mentioned ViewPager will not work, because the slide positioning model is completely different. Therefore, we look up the hierarchy and stop at the ViewGroup.

public class SlideStackView extends ViewGroup{ public SlideStackView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); ... initAttributes(context, attrs); // we need to draw childs in reverse order setChildrenDrawingOrderEnabled(true); } public SlideStackView(Context context, AttributeSet attrs) { this(context, attrs, android.R.style.Theme_Black); } public SlideStackView(Context context) { this(context, null); } 

From the special one can only note that we ask you to use non-standard order when drawing our slides. Here confusion may initially arise. The thing is that our slides should be numbered from right to left, because the first is to go with the list of letters (# 0), when we close it, we see the slide with the list of folders (# 1), and the last is the slide with the list of accounts (# 2). In the numbering of the ViewGroup itself, it is much more convenient to use the usual order - that is, from left to right, both when adding slides, and when drawing them further, positioning, etc. The point here is not even that it is so accepted, or so it is necessary to write less by one line of code. In fact, everything depends on the processing of MotionEvent. When they are transmitted, the array of child'ov is passed in order from 0 to childCount, and since the upper slide can be superimposed on the lower slide, then we should start walking around our children from the lower to the upper in search of the slide that MotionEvent can process. I will tell you more about this when we deal with the processing of MotionEvents.
Thus, we very often need this method:

  /** * Same as {@link #getChildAt(int)} but uses adapter's * slide order in depending on the actual child order * in the view group * @param position position of the child to be retrieved * @return view in specified */ private View getChild(int position){ return getChildAt(getChildCount() - 1 - position); } 


Then in order of rendering, we do not need to change anything:

  /** * Use this to manage child drawing order. We should draw * the slide #{lastSlide} (in adapter's data set indexes) first. Then draw * the slide with #{lastSlide - 1} and so on until the slide #0 inclusive * <p> * {@inheritDoc} */ @Override protected int getChildDrawingOrder(int childCount, int i) { //draw the last slide first. /** * __________ * __|0 | * |1 | | * | | | * | |__________| * |__________| */ return /*childCount - */i /*- 1*/; } 


Now the fun part. Of course, we are going to show slides, the number of which can be varied. In the Android Framework Team decided to use the so-called Adapter for this - an excellent approach, so we will. And the closest thing to us is exactly the adapter implementation that is used in the ViewPager - we will need additional changes in this adapter, but more on that later.

  /** * Used in case we have no valid position or actual position * can not be found. */ static final int INVALID_POSITION = -1; /** * Index of the first element from adapter's data set * added to the layout of the slide stack */ private int mFirst; /** * Current selected slide position in adapter's data set */ private int mSelected; /** * A data set adapter that prepares view for the slide stack view * and is responsible for base information about the containing * data set. */ private SlideStateAdapter mAdapter; /** * {@link DataSetObserver} that indicates about changes in slides * data set */ private final DataSetObserver mObserver = new Observer(); /** * Sets the adapter for providing the SlideStackView with * slides. * @param adapter */ public void setAdapter(SlideStateAdapter adapter){ if (mAdapter != null){ mAdapter.unregDataSetObserver(mObserver); mFirst = INVALID_POSITION; mScroller.stopScrolling(); removeAllViews(); } if(adapter != null){ mAdapter = adapter; mAdapter.regDataSetObserver(mObserver); } } 

Where the Class Observer looks like this to begin with:
  private final class Observer extends DataSetObserver{ @Override public void onChanged() { //empty } @Override public void onInvalidated() { //empty } } 


Now we have an adapter that serves as a link between what we are going to show and how we will do it.

Obviously, one of the key points in this kind of control is how our slides will move within SlideStackView. This is a fairly voluminous task both for the functionality assigned to it and for the amount of code necessary for the effective implementation of this task. From here the following decision - to carry out all functionality connected with processing of scrolling in a class which and will be called.

  public static class SlideScroller extends Scroller implements OnTouchListener{ private final ScrollingListener mListener; private final GestureDetector mGestureDetector; public SlideScroller(Context context, ScrollingListener listener, OnGestureListener gestureListener) { super(context); this.mListener = listener; this.mGestureDetector = new GestureDetector(context, gestureListener); mGestureDetector.setIsLongpressEnabled(false); } public void scroll(int distance, int time) { // ... } public void fling(int velocity){ ... } public void stopScrolling() { ... } @Override public boolean onTouch(View v, MotionEvent event) { ... } void finishScrolling() { ... } boolean isScrolling(){ ... } boolean isJustifying(){ ... } boolean isTouchScrolling(){ ... } } 


Now we can safely bring the entire scrolling kitchen into this module, and only inform SlideStackView about the necessary events via ScrollingListener:

  public interface ScrollingListener { void onScroll(int distance); void onStarted(); void onFinished(); void onJustify(); } 


Where does the implementation of any ViewGroup begin? A ViewGroup is a compiler that, in addition to being able to do a View, can also have other View within itself. Therefore, the answer to our question is the implementation of the ViewGroup, which begins with the Override methods:
  @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { } 

where we place our slides:
  @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { } 

and where we measure our slides.
Let's start with the measurement:
  @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(getDefaultSize(0, widthMeasureSpec), getDefaultSize(0, heightMeasureSpec)); int childHeightSize = getMeasuredHeight(); int mChildHeightMeasureSpec = MeasureSpec.makeMeasureSpec( childHeightSize, MeasureSpec.EXACTLY); // Make sure we have created all fragments that we need to have shown. mInLayout = true; fillViewsIn(); mInLayout = false; // measure slides int size = getChildCount(); for (int i = 0; i < size; ++i) { final View child = getChild(i); if (child.getVisibility() != GONE) { int childWidthSize = getMeasuredWidth() - ( getRightEdgeDelta(mFirst + i - 1) + getLeftEdge(mFirst + i)); final int widthSpec = MeasureSpec.makeMeasureSpec( childWidthSize, MeasureSpec.EXACTLY); // LOG.v("Measuring #" + i + " " + child // + ": " + widthSpec); child.measure(widthSpec, mChildHeightMeasureSpec); } } } 


Everything is simple - first we set the size for the slide stack based on the specifications that the container gave us. In mobile mail, the slide stack is the root markup element, so this way we fill the entire accessible area.

Since we don't want our slides to be different in height, then we create a height specification with the MeasureSpec.EXACTLY flag, and a value equal to the measured height of the slide stack.

To measure our slides, we naturally need the slides themselves, so we need to make sure that they are already added to the markup. To do this, call filling the slides from the top to the bottom. After that we go through the slides, determine their desired width and measure them by calling child.measure (widthSpec, mChildHeightMeasureSpec).

The width of a slide is defined as the width of the slide stack minus the padding on the left and right for a particular slide, for example, for a slide with a list of folders.



After we have measured our slides, it remains only to position them correctly:

  @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { mInLayout = true; fillViewsIn(); mInLayout = false; for (int i = 0; i < getChildCount(); i++) { View child = getChild(i); int position = i + mFirst; onLayoutChild(child, position, changed); } mDirty.setEmpty(); } /** * Layouts child at the specified position (in adapter's data set). * Measures the child if needed. * @param child a child we are going to layout * @param position position of the child in adapter's data set */ protected void onLayoutChild(View child, int position, boolean changed) { if (child.getVisibility() != GONE) { LOG.d("onLayoutChild " + position); if (position < mSelected && changed){ closeView(position); LOG.v("close slide at " + position); } final LayoutParams lp = (LayoutParams) child.getLayoutParams(); SlideInfo info = getSlideInfo(child); int childLeft = getLeftEdge(position) + info.mOffset; int childRight = getRightEdge(position - 1) + info.mOffset; int childTop = getTop(); if (lp.needsMeasure) { lp.needsMeasure = false; final int widthSpec = MeasureSpec.makeMeasureSpec( childRight - childLeft, MeasureSpec.EXACTLY); final int heightSpec = MeasureSpec.makeMeasureSpec( getMeasuredHeight(), MeasureSpec.EXACTLY); child.measure(widthSpec, heightSpec); } // LOG.v("Positioning #" + position + " " + child + ":" + childLeft // + "," + childTop + " " + child.getMeasuredWidth() + "x" // + child.getMeasuredHeight()); child.layout(childLeft, getTop(), childRight, getBottom()); } } 


We first need to make sure that we have already added all the slides that we can show to the markup, and then directly set the position for the slide in each specific position. It is important to take into account the fact that the newly added slides may not be closed, this should be corrected.

Also, if a slide was added after the slide stack was measured, then this slide also needs to be measured.

Here SlideInfo is the usual Holder, which contains information about the position and state of the slide.

  /** * Simple info holder for the slide item * @author k.kharkov */ public static final class SlideInfo implements Parcelable{ /** * Describes slide offset relative to the slide stack. * Note that offset value do not describe actual slide * position inside the slide stack but only shows the * offset relative to the left position of the slide. * <p> * This means * <code>getLeftEdge(position) + info.mOffset</code> * equals to actual offset of the slide relative to * the slide stack view. */ private int mOffset = 0; /** * Indicates whether the slide is visible to the user * or hidden at near the slide stack side */ private boolean mOpened = true; /** * Position of the slide inside the slide stack */ private int mPosition = INVALID_POSITION; /** * The drawable to fill space between this slide and the * previous one. * @see SlideStackView#fillAreaToPrevSlide(Canvas, View) */ private Drawable mSpace; public SlideInfo() { super(); } } 


In fact, instead of using SlideInfo separately, it was possible to get along with the successor of the LayoutParams class, which I still used in the future. At the time of writing, I did not know this and now I have not transferred this information to layoutParams, but I want to do it as soon as possible. There is nothing criminal in the additional holder, but I support the KISS (Keep It Simple Stupid) approach, and using one object instead of two is noticeably simpler :)

So we figured out how to deal with the slides that we have already added to the ViewGroup. The question that has not yet been addressed is how to add them there. For this we left the method:

  /** * Adds all required views in layout first. * Then adjusts visibility for each child. * @see #addViewsInside(int, int) * @see #adjustViewsVisibility() */ private void fillViewsIn() { int position = 0; int left = 0; if (getChildCount() > 0){ position = getLastVisiblePosition() + 1; View lastView = getChild(position - mFirst - 1); left = lastView.getLeft() - lastView.getScrollX(); } addViewsInside(position, left); adjustViewsVisibility(); } 


This method finds the last slide and adds the following slides to SlideStackView, if they exist and will be visible to the user. All this happens like this:

  /** * Uses the adapter to add views to the slide stack view. * @param position last visible position of the view * @param left left coordinate of the last visible slide */ private void addViewsInside(int position, int left) { if (mAdapter == null || mAdapter.getCount() == 0){ return; } mAdapter.startUpdate(this); while ((position <= mSelected + 1 || left > getLeft()) && position < mAdapter.getCount()){ if (mFirst == INVALID_POSITION){ mFirst = 0; } LOG.d("addView inside " + position + " mSelected " + mSelected); mAdapter.instantiateItem(this, position); left = mSelected > position ? getLeftEdge(position) : getRightEdge(position - 1); position ++; } mAdapter.finishUpdate(this); } 


Since we decided to use fragments as our slides, it is better to add slides within a single transaction of fragments.

Then we make an adjustment of the initial conditions taking into account the newly added slides and repeat the process of creating the slide until either the slides run out or the slides go beyond the limits of the slide stack's visibility. If left becomes <= getLeft (), this means that the very last slide we added overlaps the border of the slide stack. This means that the next slide may be either completely under this slide, or it may be visible to the left, but this area will not fall within the slide stack and the user will not be visible. And since the slide will not be visible to the user;

“But where is the addition of the slide itself?” You ask. After we call mAdapter.finishUpdate (this); The adapter starts the transaction and the FragmentManager starts adding fragments to the transferred container (this is the SlideStackView). The process of adding a fragment is difficult to describe in a nutshell, so we’ll leave this topic to the reader’s independent consideration :) During the execution of the fragment’s lifecycle, it will be added to our slide stack via the ViewGroup.addView (View, int, LayoutParams) method; We will need to make a few adjustments in order to arrange the slide correctly, so we override this method:
  /** * Specifies correct layout parameters for the child and * adds it according the the current {@link #mInLayout} * status. * <p> * {@inheritDoc} */ @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { LOG.d("Add view from outside " + child); if (!checkLayoutParams(params)) { params = generateLayoutParams(params); } final LayoutParams lp = (LayoutParams) params; SlideInfo info = getSlideInfo(child); info.mPosition = getChildCount(); if (mAdapter != null){ info.mSpace = mAdapter.getSpaceDrawable(info.mPosition); } if (mInLayout) { lp.needsMeasure = true; addViewInLayout(child, 0, params); } else { super.addView(child, 0, params); } if (info.mPosition < mSelected){ closeView(info.mPosition); } } 


Fill in the required data. Then we add the stack to the slide depending on whether the slide is added during the layout process or not, and close the slide if its position is less than the position of the slide that is currently active.

Naturally, if we add fragments to the slide stack, we need to delete them once. Deletion behavior is similar to adding, only we look for those slides that are no longer visible to the user and delete them from the ViewGroup using our adapter.

In the fillViewsIn () method; there is a last unreviewed line in which we call the method adjustViewsVisibility;

This method, as the name suggests, corrects the visibility of the slides. This is necessary in order not to waste time on drawing those slides that can not yet be removed from the markup, but are no longer visible to the user. By itself, adjusting the visibility is very simple - we just set the visibility for the slide through View.setVisibility (int), where we pass either View.VISIBLE or View.INVISIBLE. The method itself looks a little more complicated, but having understood in principle its work, it becomes all clear.

  /** * Sets visibility parameter for each child according * to the actual visibility on the screen. Takes into * account that child shouldn't be invisible if it's * shadow is visible on the screen because it would * prevent from triggering {@link #drawChild(Canvas, View, long)} * method over that child. */ private void adjustViewsVisibility() { /** * init the rect to align right edge if the slide stack view */ Rect drawingRect = new Rect(); drawingRect.left = getRight(); drawingRect.top = getTop(); drawingRect.right = getRight(); drawingRect.bottom = getTop() + getHeight(); Rect parent = getChildDrawingRectPositive(this); /** * Then union each child drawing rect * with drawingRect of the slide stack * in order to determine when the views * behind the last actually visible view * in the slideStack view and hide all * the following views in order to prevent * re-drawing non-visible views * ________________________________ * | slideStackView __________| * | ____________________| | * || _ _ _ _ | | * ||| | |visible | * || |slide #0 | * ||| hidden slide| | | * || #2 | | * |||_ _ _ _| | | * ||last actual visible | | * ||slide #1 |__________| * ||__________________________| | * |________________________________| */ for (int i = 0; i < getChildCount(); i ++){ boolean hideNext = false; // LOG.v("adjustVisibility " + i); View child = getChild(i); Rect childRect = getChildDrawingRectPositive(child); // LOG.v("child.left " + childRect.left + // " drawing.left" + drawingRect.left + // " parent " + parent.toShortString() + // " child" + childRect.toShortString()); if (childRect.left < drawingRect.left && Rect.intersects(parent, childRect)){ drawingRect.union(childRect); // LOG.d("drawingRect after union with child " + i + " = " + // drawingRect.toShortString()); hideNext = false; } else { // LOG.d("hide view " + i); // LOG.v("child " + childRect.toShortString() + // " drawing " + drawingRect.toShortString() + // " parent " + parent.toShortString()); Rect childShadow = getShadowRect(childRect); if (childShadow.isEmpty()){ hideNext = true; } // if shadow visible - do not hide the slide // and let the shadow to be drawn if (childShadow.left < drawingRect.left && Rect.intersects(parent, childShadow)){ hideNext = false; } else { hideNext = true; } } // LOG.d("set visibility for child " + i + " = " + // (hideNext ? "Invisible" : "Visible")); child.setVisibility(hideNext ? View.INVISIBLE : View.VISIBLE); } } 


I will try step by step as easy as possible to paint the work of this method.

The general algorithm looks like this:

1. Take the right border of the slide stack (because the rightmost slide is always fully visible and those to the left are overlapped by it. On the contrary, it cannot be.)
2. Go through all the slides and, as it were, add to the occupied area the space inside the slide stack that each slide occupies.
3. At the same time, if the next slide is located either outside the stack, or is completely closed by the slides that lie above it, then we mark it as “invisible”. Otherwise, the slide is considered "visible."

In the above drawingRect method, this is exactly the area that is already occupied by the slides, and childRect is the area in which the slide is located in a specific position.

It remains to make an amendment to the shadow. We draw the shadow ourselves, so if we do not take into account the space that the shadow will occupy when adjusting the visibility of the slides, we can see unpleasant blinking at the moment when the slides appear / hide behind each other. The thing is that in the ViewGroup in the process of drawing there is a check whether the child is visible or invisible. If it is invisible, then the drawChild method (Canvas, View, long) will simply not be called for this child, which is very logical. Therefore, we need to consider the slide visible even if only a shadow is visible from it. To do this, we do an additional check and make an amendment at the moment when we decided that the slide is not visible to the user.
  Rect childShadow = getShadowRect(childRect); if (childShadow.isEmpty()){ hideNext = true; } // if shadow visible - do not hide the slide // and let the shadow to be drawn if (childShadow.left < drawingRect.left && Rect.intersects(parent, childShadow)){ hideNext = false; } else { hideNext = true; } 

In general, it is important to note, of course, the fact that the entire method of adjustViewsVisibility () is performed for optimization purposes. After all, we do not want our user's mobile device to perform work that he will not see anyway. Moreover, unnecessary drawing greatly affects the performance and, consequently, the smoothness of the animation.

But just preventing the drawing of those slides that are not visible to the user does not completely solve the problem. We still have the "overlay" of slides. This happens at the moment when we move one slide to the side and see from under it part of the next slide. That is, it turns out that the user sees only part of the slide (or even only the shadow / part of the shadow from it), and we draw the entire slide entirely. Then, of course, draw another slide on top of it.

Personally, I do not know any other way to solve this problem, except for using the Canvas.clipRect (Rect) method. Thus, we need to determine which part of the slide will be visible to the user in each separate frame, limit the area for drawing on the canvas to the space that will be visible to the user from this slide, and only then draw the slide.

The above method of drawing a slide looks like this for me:
  /** * Clips the canvas to the child current bounds plus shadow. * <p> * Draws the shadow as well for each child. * <p> * {@inheritDoc} * @see #applyShadowToChild(Canvas, View) */ @Override protected boolean drawChild(Canvas canvas, View child, long drawingTime) { Rect childClip = getChildClipRect(child); // LOG.d("canvas " + canvas.getClipBounds().toShortString()); // LOG.d("draw Child " + child + " clip = " + childClip.toShortString()); // first // draw spaces between slides fillAreaToPrevSlide(canvas, child); // second // draw shadow for the slide applyShadowToChild(canvas, child); // third // actually draw child try{ canvas.save(); canvas.clipRect(childClip); boolean r = super.drawChild(canvas, child, drawingTime); return r; } finally { canvas.restore(); } } 

It all starts with the fact that we calculate the rectangle for the slide, which will be visible to the user on the screen, then fill the space between the slides (if present), draw the shadow for the slide, and after that we limit the canvas to the calculated childClip, and draw The slide is exactly as defined in the ViewGroup.

As you can see - everything is quite simple. All magic is inside the method of counting the area on which you want to “trim” the canvas.
  /** * Retrieves children's visible position on the screen * without it's shadow. * @param child we should retrieve visible bounds for * @return a child's visible bounds */ private Rect getChildClipRect(View child) { Rect childClip = getChildDrawingRectPositive(child); int position = getPositionForView(child); subtractRectWithChilds(childClip, position); // LOG.v("child clip " + position + " " + childClip.toShortString()); return childClip; } /** * Changes the specified clip rectangle, to subtract all the * following children from it. * @param childClip initial child rectangle in the screen * @param position child position within adapter's data set * @see #subtractToLeft(Rect, Rect) */ private void subtractRectWithChilds(Rect childClip, int position) { if (position >= 0){ position -= mFirst; for (int i = position - 1; i >= 0; i --){ View c = getChild(i); Rect r = getChildDrawingRectPositive(c); boolean changed = subtractToLeft(childClip, r); if (changed){ // LOG.v("child clipped " + childClip.toShortString()); } } } } 

In the first method, we find the position of the slide inside the slide stack, then we find its position in the indexes of the adapter slide.After that, we “subtract” all unnecessary and return the result.

The second method is just what “subtracts” this is the most unnecessary. Excess, we believe that part of the slide, which will overlap other slides, which are located to the right (or closer to the beginning in the adapter indexes).

It remains to consider a method that directly subtracts one rectangle from another.

Immediately make a reservation that such a subtraction is difficult to make universal, so the methods are written taking into account the fact that all slides are the same height.
  /** * Same as {@link #subtract(Rect, Rect)} method, but processes * the case where source rectangle wasn't changed because it * contains <code>r</code>. In this case it adjusts <code>r</code> * from this: * <pre> * _______________________ * | source _________ | * | | r | | * | | | | * | | | | * | | | | * | |_________| | * |_______________________| * </pre> * * to this: in order to leave only left side of the source rectangle. * <pre> * ___________________________ * | source | r |1px| * | | |<->| * | | | | * | | | | * | | | | * | | | | * | | | | * |_________|_____________|___| * </pre> * @param source the rectangle we are going to subtract from * @param r the rectangle we are going to subtract from * source * @return <code>true</code> in case the source rectangle * has been changed. <code>false</code> otherwise */ private static boolean subtractToLeft(Rect source, Rect r){ boolean changed = subtract(source, r); if (!changed && source.contains(r)){ // adjust intersected rect manually r.right = source.right + 1; r.top = source.top; r.bottom = source.bottom; changed = subtract(source, r); } return changed; } /** * Subtracts <code>r</code> from the <code>source</code>. * Sets <code>r</code> rectangle to the intersection as well. * @param source * @param r * @return <code>true</code> if rectangle has been subtracted, * <code>false</code> otherwise. */ private static boolean subtract(Rect source, Rect r) { if (source.isEmpty() || r.isEmpty()){ return false; } if (r.contains(source)){ // LOG.w("return empty rect"); source.setEmpty(); return true; } if (source.contains(r)){ return false; } if (!r.intersect(source)){ return false; } boolean changed = false; if (source.left == r.left){ source.left = r.right; changed = true; } if (source.right == r.right){ source.right = r.left; changed = true; } if (source.top == r.top){ source.top = r.bottom; changed = true; } if (source.bottom == r.bottom){ source.bottom = r.top; changed = true; } source.sort(); return changed; } 

In principle, the code inside these methods is quite transparent, and I hope that my “drawings” bring at least some additional clarity and are understandable not only to me :)

, , – Rect. , . . , . «» , - , . , . . , , , . , , .

, , , , .

, – . , . , . , , MotionEvent' , , -, .

, , , ViewGroup.

– - Mail.Ru. . , .

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


All Articles