Pull to refresh
VK
Building the Internet

SlideStackView или extending ViewGroup в Android

Reading time 19 min
Views 18K
Занимаясь разработкой почтового приложения под Android, мы в Mail.Ru очень часто анализируем, насколько удобно для конечного пользователя построена навигация внутри нашей программы. Думаю, что не стоит объяснять насколько это важно, потому что все, кто пользуются мобильными приложениями, и без этого знают, что продукт, который не предоставляет интуитивно понятную и быструю навигацию, будет проигрывать тем продуктам, которые об этом позаботились. Удобство и тщательно продуманная навигация – это то, за что пользователь будет либо любить ваше приложение, либо каждый раз проявлять невероятные усилия, чтобы не разбить свой телефон об пол.

В наших приложениях мы использовали “sliding menu” еще до того, как оно было включено в официальные ui паттерны для Android. После определенных исследований в usability lab мы решили, что нужно двигаться дальше и усовершенствовать боковое меню, чтобы предоставить пользователю лучший user experience.

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






В дополнение к тому, как контролл, названный нами SlideStackView, должен себя вести, было еще несколько требований как со стороны разработки, так и со стороны продукта:

1. Каждый слайд должен быть представлен фрагментом
2. Должен быть реализован «bouncing over scroll effect» — когда слайд как бы пружинит от края
3. Вертикальные списки внутри слайдов должны прокручиваться без каких-либо поведенческих глюков
4. Сохранение\восстановление состояния (позиция открытого слайда и т.д.)

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

Будем идти от простого к сложному — для начала необходимо решить, каким образом нам вклиниться в androidFramework, чтобы, во-первых, не писать велосипед, а во-вторых, предоставить максимально похожий user experience. Упомянутый выше ViewPager не подойдет, потому что модель позиционирования слайдов абсолютно ничем не похожа. Поэтому мы смотрим по иерархии вверх и останавливаемся на 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);
    }

Из особенного здесь можно только заметить, что мы просим использовать нестандартный порядок при отрисовке наших слайдов. Здесь изначально может возникнуть путаница. Все дело в том, что наши слайды должны нумероваться справа налево, т.к. первым должен идти слайд со списком писем (#0), когда закрываем его, то видим слайд со списком папок (#1), а последним видим слайд со списком аккаунтов (#2). В нумерации же самого ViewGroup намного удобнее использовать привычный порядок — то есть слева направо, как при добавлении слайдов, так и при их дальнейшей отрисовке, позиционировании и т.д. Дело тут даже не в том, что так принято, или так придется писать меньше на одну строчку кода. На самом деле все упирается в обработку MotionEvent’ов. При их передаче массив child’ов проходится по порядку от 0 до childCount, а т.к. верхний слайд может накладываться на нижние, то мы и должны начинать обход наших чайлдов от нижнего к верхнему в поиске того слайда, который может обработать передаваемый MotionEvent. Об этом я подробнее расскажу, когда займемся обработкой MotionEvent’ов.
Таким образом, нам очень часто нужен будет вот такой метод:

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


Тогда в порядке отрисовки нам ничего менять не нужно:

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



Теперь самое интересное. Мы, конечно, собираемся показывать слайды, количество которых может быть разнообразным. В Android Framework Team решили для этого использовать так называемый Adapter — отличный подход, так мы и сделаем. Причем ближе всего нам как раз та реализация адаптера, которая используется во ViewPager — нам потребуются дополнительные изменения в этом адаптере, но об этом позже.

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

Где Class Observer выглядит примерно так для начала:
    private final class Observer extends DataSetObserver{
 
        @Override
        public void onChanged() {
            //empty
        }
 
        @Override
        public void onInvalidated() {
            //empty
        }
    }


Теперь у нас есть адаптер, который служит связующим звеном между тем, что мы собираемся показывать и тем, как мы это будем делать.

Очевидно, что одним из ключевых моментов в такого рода контролле является то, как наши слайды будут перемещаться внутри SlideStackView. Это достаточно объемная задача как по возложенному на нее функционалу, так и по количеству кода, необходимого для эффективной реализации этой задачи. Отсюда вытекающее решение — вынести весь связанный с обработкой скроллинга функционал в класс, который так и будет называться.

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



Теперь мы спокойно можем всю кухню скроллинга вынести в этот модуль, и только информировать SlideStackView о необходимых событиях через ScrollingListener:

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



С чего же начинается имплементация любого ViewGroup? ViewGroup — это компановщик, который помимо того, что умеет делать View, также может и располагать внутри себя другие View. Поэтому ответ на наш вопрос – имплементация ViewGroup, которая начинается с методов Override:
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {

    }


где мы располагаем наши слайды:
   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
   }


и где мы измеряем наши слайды.
Начнем с измерения:
   @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);
           }
       }
   }



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

Т.к. мы не хотим, чтобы наши слайды были разными по высоте, то создаем спецификацию высоты с флагом MeasureSpec.EXACTLY, и значением, равным измеренной высоте слайд стека.

Чтобы измерять наши слайды, нам, естественно, понадобятся сами слайды, поэтому мы должны убедиться, что они уже добавлены в разметку. Для этого вызываем заполнение слайдов от верхнего к нижнему. После этого проходимся по слайдам, определяем их нужную ширину и измеряем их вызовом child.measure(widthSpec, mChildHeightMeasureSpec).

Ширина слайда определяется как ширина слайд стека минус отступы слева и справа для конкретного слайда, например, для слайда со списком папок.



После того как мы измерили наши слайды, остается только их правильно расположить:

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



Мы сначала должны убедиться, что у нас уже добавлены в разметку все слайды, которые мы можем показать, а потом непосредственно задаем положение для слайда в каждой конкретной позиции. При этом важно учитывать тот факт, что только что добавленные слайды могут не быть закрыты, это нужно исправить.

Также, если слайд добавили после того, как слайд стек был измерен, то этот слайд тоже нужно измерить.

Здесь SlideInfo – это обычный Holder, который содержит информацию о том, в каком положении и состоянии находится слайд.

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



На самом деле вместо того, чтобы использовать отдельно SlideInfo, можно было обойтись наследником класса LayoutParams, который в итоге я все равно использовал в дальнейшем. В момент написания я этого не знал и сейчас я так и не перенес эту информацию в layoutParams, но хочу сделать в самое ближайшее время. Ничего криминального в дополнительном холдере нет, но я сторонник подхода KISS (Keep It Simple Stupid), а использовать один объект вместо двух заметно проще :)

Итак мы разобрались, как нужно поступать со слайдами, которые мы уже добавили в ViewGroup. Вопрос, который остался пока не рассмотрен – как же их туда добавить. Для этого мы оставили метод:

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



Данный метод находит последний слайд и добавляет следующие слайды в SlideStackView, если они есть и будут видны пользователю. Все это происходит вот так:

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



Так как мы решили использовать фрагменты в качестве наших слайдов, то добавлять слайды лучше внутри одной транзакции фрагментов.

Затем делаем поправку начальных условий с учетом только что добавленных слайдов и повторяем процесс создания слайда до тех пор, пока либо слайды не кончатся, либо слайды не выйдут за границы видимости слайд стека. Если left становится <= getLeft() это означает, что самый последний слайд из тех, что мы добавили, перекрывает границу слайд стека. Это означает, что следующий слайд может находиться либо полностью под этим слайдом, либо может быть виден левее, но эта область не попадет в границы слайд стека и пользователю будет не видна. А т.к. пользователю слайд не будет виден, то и добавлять его пока нет смысла.

«Но где же само добавление слайда?» — спросите вы. После того как мы вызываем mAdapter.finishUpdate(this); adapter запускает транзакцию и FragmentManager начинает добавлять фрагменты в переданный контейнер (this – то есть SlideStackView). Процесс добавления фрагмента сложно описать в двух словах, поэтому оставим эту тему на самостоятельное рассмотрение читателя :) В процессе выполнения lifecycle’a фрагмента он будет добавлен в наш слайд стек через метод ViewGroup.addView(View,int,LayoutParams); Нам нужно будет сделать немного корректировок, чтобы потом правильно расположить слайд, поэтому переопределяем этот метод:
    /**
     * 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);
       }
    }



Заполняем необходимые данные. Затем добавляем в слайд стек в зависимости от того, добавляется ли слайд в процессе лейаута или нет, и закрываем слайд, если его позиция меньше позиции слайда, который сейчас активен.

Естественно, что если мы добавляем фрагменты в слайд стек, то нужно их когда-то и удалять. Поведение при удалении аналогично добавлению, только мы ищем те слайды, которые уже не видны пользователю и их удаляем из ViewGroup, используя наш adapter.

В методе fillViewsIn(); осталась последняя нерассмотренная строчка, в которой мы вызываем метод adjustViewsVisibility;

Этот метод, как следует из названия, подправляет видимость слайдов. Нужно это для того, чтобы не тратить время на отрисовку тех слайдов, которые еще не могут быть удалены из разметки, но уже не видны пользователю. Сама по себе регулировка видимости очень проста – мы лишь ставим видимость для слайда через View.setVisibility(int), где передаем либо View.VISIBLE, либо View.INVISIBLE. Сам метод выглядит немного сложнее, но разобравшись в принципе его работы, становится все понятно.

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



Постараюсь пошагово максимально просто расписать работу этого метода.

Общий алгоритм выглядит примерно так:

1. Берем правую границу слайд стека (потому что самый правый слайд всегда полностью виден и те, что находятся левее – перекрываются им. Наоборот быть не может.)
2. Идем по всем слайдам и как-бы прибавляем к занятой области то пространство внутри слайд стека, которое занимает каждый слайд.
3. При этом смотрим, если очередной слайд находится либо за границами слайд стека, либо полностью закрыт слайдами, которые лежат выше него, то отмечаем его как «невидимый». В противном случае слайд считается «видимым».

В приведенном методе drawingRect это как раз та область, которая уже занята слайдами, а childRect – это область, в которой расположен слайд в определенной позиции.

Осталось сделать поправку на тень. Тень мы рисуем сами, поэтому, если не учитывать пространство, которое будет занимать тень, при регулировке видимости слайдов, мы можем увидеть неприятное мигание в тот момент, когда слайды будут появляться\скрываться друг за другом. Все дело в том, что во ViewGroup в процессе рисования есть проверка, является ли чайлд видимым или невидимым. Если он невидимый, то метод drawChild(Canvas, View, long) просто не вызовется для этого чайлда, что очень логично. Следовательно, нам нужно считать слайд видимым даже если от него видна только тень. Для этого делаем дополнительную проверку и вносим поправку в тот момент, когда решили, что слайд не виден пользователю.
                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;
                }


В целом, важно отметить, конечно, тот факт, что весь метод adjustViewsVisibility() выполняется в целях оптимизации. Ведь мы не хотим, чтобы мобильное устройство нашего пользователя выполняло работу, которую он все равно не увидит. Более того, лишнее рисование очень сильно сказывается на производительности, и, соответственно, на плавности анимации.

Но только лишь предотвращение рисования тех слайдов, которые не видны пользователю, не решает проблему полностью. У нас все еще остается «наложение» слайдов. Происходит это в тот момент, когда мы сдвигаем один слайд в сторону и видим из-под него часть следующего слайда. То есть получается так, что видит пользователь только часть слайда (или даже только тень\часть тени от него), а рисуем мы весь слайд целиком. Затем, естественно, рисуем другой слайд поверх него.

Лично я не знаю другого способа решения такой проблемы, кроме использования метода Canvas.clipRect(Rect). Таким образом, нам необходимо определять, какая часть слайда будет видна пользователю в каждом отдельном кадре, ограничивать область для рисования на канвасе тем пространством, которое будет видно пользователю от этого слайда, и только потом рисовать слайд.

Упомянутый выше метод рисования слайда выглядит у меня примерно так:
    /**
     * 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();
        }
    }


Начинается все с того, что мы высчитываем прямоугольник для слайда, который будет виден пользователю на экране, после этого выполняем заливку пространства между слайдами (если оное присутствует), рисуем тень для слайда, и уже после этого ограничиваем канвас высчитанным childClip, и выполняем рисование самого слайда точно так, как оно определено во ViewGroup.

Как видите – все достаточно просто. Вся магия находится внутри метода подсчета области, по которой нужно «обрезать» 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());
                }
            }
        }
    }


В первом методе мы находим позицию слайда внутри слайд стека, затем находим его позицию в индексах слайд адаптера. После этого «вычитаем» все лишнее и возвращаем результат.

Второй метод как раз занимается тем, что «вычитает» это самое лишнее. Лишней мы считаем ту часть слайда, которая будет перекрываться другими слайдами, которые расположены правее (либо ближе к началу в индексах адаптера).

Осталось лишь рассмотреть метод, который непосредственно осуществляет вычитание одного прямоугольника из другого.

Сразу оговорюсь, что такое вычитание сложно сделать универсальным, поэтому методы написаны с учетом того, что все слайды одинаковой высоты.
    /**
     * 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;
    }


В принципе, код внутри этих методов достаточно прозрачный, и я надеюсь, что мои «рисунки» вносят хоть какую-то дополнительную ясность и понятны не только мне :)

Я уверен, что читая эту статью, особенно внимательные к деталям разработчики заметили – в коде очень много создается локальных объектов типа Rect. Конечно, этого стоит избегать. Причина в той же самой оптимизации. Сами объекты очень маленькие и никому не нанесут вреда, потому что занимают слишком мало памяти. Но если мы на каждом кадре создаем «немного» мусора, то в какой-то момент его накопится достаточное количество, и возникнет необходимость его собрать. Это абсолютно нормально, но может вызвать неприятные лаги в анимации. Отсутствие должной плавности может свести на нет всю проделанную работу и смазать впечатление для конечного пользователя. При написании слайд стека я старался избегать преждевременной оптимизации, поэтому сначала использовал локальные объекты, которые создавал каждый раз, когда они были необходимы. И только после того как сам по себе слайд стек заработал так, как было задумано, приступил к тестированию его производительности и дополнительной оптимизации.

Итак, мы рассмотрели как добавлять слайды, как размещать их внутри слайд стека, как правильно их рисовать и несколько дополнительных подходов, которые помогут вам избежать излишней нагрузки при рисовании слайдов.

Мы пока не рассмотрели очень важную часть, которая добавит интерактивность в слайд стек – скроллинг. Сам по себе скроллинг очень простой, но вместе с ним придется решать достаточно много проблем. О них я хотел бы рассказать в следующей части моей статьи, которую я надеюсь подготовить в самые ближайшие сроки. В нее, помимо непосредственного скроллинга, войдет обработка и прокидывание MotionEvent’ов для тех слайдов, которым они предназначаются, создание эффектов при скроллинге и овер-скроллинге, обработка мультитача и многое другое.

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

Если будет время – попробуйте-потестите как это все работает сейчас в нашем мобильном приложении Почты Mail.Ru. Буду рад увидеть в комментариях ваши пожелания по улучшению навигации внутри клиента. И готов ответить на вопросы, а также посильно помочь в решении ваших задач.
Tags:
Hubs:
+35
Comments 7
Comments Comments 7

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен