Pull to refresh
VK
Building the Internet

SlideStackView или Extending ViewGroup в Android (часть 2)

Reading time 31 min
Views 10K


Недавно я рассказывал о своём опыте разработки SlideStackView в мобильной почте Mail.Ru под Android. Тогда я пообещал, что в ближайшее время подготовлю вторую часть, в которой расскажу о том, как реализовать наиболее интересную, с точки зрения программирования визуальных компонентов, часть. Естественно, речь пойдёт о том, что добавляет интерактивность в приложение — об анимации. Все уже давно привыкли к тому, что мобильное приложение должно быть отзывчивым к действиям пользователя. Очевидно, что основным способом взаимодействия с вашим приложением является использование Touch screen.

Так как мы пишем навигационный контролл — SlideStackView, — то интерактивности нам добавят анимированные переходы между основными частями приложения. В мобильной почте Mail.Ru это три фрагмента: список учетных записей, добавленных в приложение, список папок внутри выбранного почтового ящика и список писем, который показывает содержимое одной папки.

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

Я уверен, что большинство разработчиков, в рамках решаемых задач, сталкивались с этим и имеют какое-то представление о том, как осуществляется обработка MotionEvent’ов в Android Framework, но, тем не менее, начну я с самого начала.

Итак, базовый класс View спроектирован таким образом, что подразумевает потенциальную обработку MotionEvent’ов. И в ответ на соответствующее действие виджет получает этот самый MotionEvent, который хранит в себе всю необходимую информацию о том, каким образом пользователь взаимодействует с TouchScreen’ом.

В нашем примере нам интересны только те event’ы, которые помогут осуществить скроллинг слайдов внутри слайдстека.

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

  • Не перегружай класс лишним функционалом
  • Всегда рефактори свой код, когда тебе кажется, что эта часть может потом пригодиться
  • Не оптимизируй раньше времени
  • Каждое поле или метод не должны вызывать недоумение при взгляде на них в отрыве от самого компонента


С последним пунктом, наверное, будет сложнее всего, потому что зачастую в порыве добиться желаемого результата все программисты идут на компромисс, и нередко склоняются к тому, чтобы сделать «лишь бы работало» сейчас с заметкой «поменяю потом, когда будет время». Я не говорю, что это абсолютно неправильно, я лишь только хочу предостеречь, что, скорее всего, вы об этом вспомните потом, когда уже менять что-то станет слишком поздно или ещё хуже — невозможно.

И последний момент, который я считаю необходимым отметить, это немного противоречивый призыв. Делайте всё вышеперечисленное без фанатизма. То есть, если перед вами стоит задача написать виджет, который должен использоваться как контролл для навигации между экранами вашего приложения, и в требованиях главной задачей является удобство при управлении через Touch Screen, то не нужно до того, как вы добьётесь этого, обращать внимание на такие моменты, как, например, сенсорное управление. Безусловно, осуществлять навигацию путем простого поворота или движением телефона это очень круто, и даже удобно, но всё-таки это не основная задача, и её можно отложить, так сказать «до лучших времен».

Итак, вернёмся к нашим баранам.

На первый взгляд, всё достаточно просто, но это только на первый взгляд. Если вам приходилось раньше обрабатывать скроллинг, то наверняка вы знаете, что среди всевозможных MotionEvent’ов не так-то просто определить истинное намерение пользователя. Решил ли он просто подвинуть слайд в сторону, или он нажимает на элемент, который находится внутри списка, и т.д. Вся эта логика может быть реализована как бы отдельно от слайдстека, поэтому будет удобно выделить всю алгоритм обработки скроллинга в отдельный компонент SlideStackScroller.

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


Скроллинг не является инновационной задачей, поэтому, конечно, для нас доступны классы, которые могут нам помочь. А именно Scroller и GestureDetector. Первый предоставляет удобный интерфейс для расчёта скроллинга, а второй помогает определять стандартные типы жестов, нас в этом случае больше всего интересует жест fling. Помимо главного преимущества использования готового решения (ленивые программисты меня поймут) — не нужно писать эту логику самому, — при использовании решений, предоставляемых платформой, легче добиться так называемого consistent user experience, или поведения, которое будет привычно пользователю и не будет выделяться среди прочих компонентов платформы. А это особенно важно учитывать, если вы разрабатываете интерактивную часть системы.

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

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


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

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

Как вы, наверное, заметили, всё делегирование происходит через метод onTouch(View v, MotionEvent event) интерфейса android.view.View.OnTouchListener.

		@Override
		public boolean onTouch(View v, MotionEvent event) {
			switch (event.getAction()) {
			case MotionEvent.ACTION_DOWN:{ 
				int pointerId = MotionEventCompat.findPointerIndex(event, mActivePointerId);
				if (pointerId == INVALID_POINTER_ID){
					break;			
				}
				mLastTouchX = MotionEventCompat.getX(event, pointerId);
				mJustifying = false;
				forceFinished(true);
				clearMessages();
				break;
			}
			case MotionEvent.ACTION_MOVE:{ 
				int pointerId = MotionEventCompat.findPointerIndex(event, mActivePointerId);
				if (pointerId == INVALID_POINTER_ID){
					break;			
				}
				// perform scrolling
				float x = MotionEventCompat.getX(event, pointerId); 
				int distanceX = (int)(x - mLastTouchX);
				if (distanceX != 0) {
					mTouchScrolling = true;
					startScrolling();
					mListener.onScroll(-distanceX);
					mLastTouchX = x;
				}
				break;
			}
			case MotionEvent.ACTION_UP:
				mTouchScrolling = false;
				mActivePointerId = INVALID_POINTER_ID;
				break;
			}
			if ((!mGestureDetector.onTouchEvent(event) 
					|| ((SlideStackView)v).isOverScrolled())
					&& (event.getAction() == MotionEvent.ACTION_UP 
					|| event.getAction() == MotionEvent.ACTION_CANCEL)){
				justify();
			}
			return true;
		}



Идём по порядку:

  • ACTION_DOWN говорит нам о том, пользователь коснулся экрана в какой-то области с определёнными координатами. В этом случае мы должны выполнить вполне стандартную процедуру для таких случаев, а именно подготовить почву и быть готовыми к последующим движениям пользователя по экрану, в ответ на которые мы и будем скроллить наш слайдстек. В данном случае эта процедура включает в себя запоминание начальной позиции и сброс всех флагов и текущего скроллинга, если он выполнялся
  • ACTION_MOVE говорит о самом движении, то есть пользовательское касание переместилось в новые координаты. Не сложно догадаться, что теперь нам необходимо высчитать разницу координат и сообщить слайдстеку о том, что пользователь совершил скролл. Выделение такого компонента, как SlideStackScroller, позволило избежать чрезмерной связанности между процессингом Motion Event’ов и внутренней логикой слайдстека. Иными словами, здесь нас не заботит, в каком состоянии сейчас находится слайдстек, может ли он скроллиться в какую-то сторону или нет. Мы просто сообщаем ему о том, что сделал пользователь, а реагировать ли на это и как реагировать, слайдстек уже сам решит
  • ACTION_UP. Так часто завершается взаимодействие пользователя с экраном. НО: это не означает, что нам можно расслабиться. Возможно, здесь заканчивается только самая простая часть работы. Я имею в виду жест, к которому все привыкли, и называется он обычно fling. Буквальный перевод («бросок») не позволяет понять, что это означает. В данном контексте подразумевается жест, при котором пользователь, двигая пальцем по экрану, как бы разгоняет элемент, и, прекратив касание, ожидает, что элемент будет двигаться так, как это происходило бы с физическим телом в реальной жизни. То есть по инерции. Например, как скользил бы телефон, если бы мы толкнули его по столу другому человеку.
  • И наконец, нам нужно решить, закончился ли жест, чтобы мы могли подровнять положение слайдов, или совершенный пользователем жест может быть трактован как флинг, и тогда нам следует совершить соответствующее действие и дождаться его завершения перед тем, как приступить к выравниванию слайдов


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

			if ((!mGestureDetector.onTouchEvent(event) 
					|| ((SlideStackView)v).isOverScrolled())
					&& (event.getAction() == MotionEvent.ACTION_UP 
					|| event.getAction() == MotionEvent.ACTION_CANCEL)){
				justify();
			}


Ещё раз посмотрим на это условие. Здесь очень важно не столько проверить, как отреагировал на наш жест GestureDetector, сколько то, что все жесты пройдут через него. В этом случае мы точно уверены, что не пропустили ни одного движения пользователя и сумеем определить нужный нам жест именно тогда, когда он произойдёт. Если в этом условии мы переставим местами порядок проверки, то наш слайдстек перестанет реагировать на свайп, потому что большинство MotionEvent’ов до Gesture Detector’а просто не дойдут.

В итоге, если флинг будет совершён, для нас всё закончится (или начнётся, кому как удобнее) вызовом метода onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY):

		@Override
		public boolean onFling(MotionEvent e1, MotionEvent e2, 
				float velocityX, float velocityY) {
			if (mScroller.isTouchScrolling()){
				LOG.w("mTouchScrolling in fling");
			}
			SlideInfo slide = getSlideInfo(mSelected);
			int dx = getAdjustedTargetX(velocityX) - slide.mOffset;
			mScroller.fling(-(int)getVelocity(dx));
			return true;
		}


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

Зачем же считать скорость, если нам она уже дана? Всё дело в том, что скорость может быть разной, но в данном случае нам важно, чтобы любой флинг закончился в конкретной точке, и эта точка рассчитывается в методе getAdjustedTargetX():

		/**
		 * Defines target x coordinate of the slide.
		 * It depends on fling direction
		 * <p>
		 * In case right fling it is calculated like this
		 * <pre>
		 * 
		 * getLeftEdge()           getRightEdge()         targetX
		 *  _|___________________________|______________________|__
		 * | |                           |rightOverScrollInFling|  |     
		 * |  _ _ _ _ _ _ _ _ _ _ _ _ _ _ <-------------------->   |
		 * | |                           |                      |  |
		 * |                                                       |
		 * | |                           |                      |  |
		 * |      mSelectedSlide                                   |
		 * | |                           |                      |  |
		 * |                                                       |
		 * | |_ _ _ _ _ _ _ _ _ _ _ _ _ _|                      |  |
		 * |       SlideStackView                                  |
		 * |_|___________________________|______________________|__|
		 * </pre>
		 * <p>
		 * In case left fling it is calculated like this
		 * <pre>
		 *                          0
		 *  ________________________|_____________________________
		 * | |leftOverScrollInFling |                             |     
		 * |  <--------------------> _ _ _ _ _ _ _ _ _ _ _ _ _ _  |
		 * | |                      |                           | |
		 * |                                                      |
		 * | |                      |                           | |
		 * |                                 mSelectedSlide       |
		 * | |                      |                           | |
		 * |                                                      |
		 * | |                      |_ _ _ _ _ _ _ _ _ _ _ _ _ _| |
		 * |       SlideStackView                                 |
		 * |_|______________________|_____________________________|
		 * </pre>
		 * 
		 * @param velocityX velocity that defines direction of the fling
		 * @return delta x in pixels that slide needs to scolled by
		 * @see SlideStackView#getLeftEdge(int)
		 * @see SlideStackView#getRightEdge(int)
		 * @see SlideStackView#mRightOverScrollInFling
		 * @see SlideStackView#mLeftOverScrollInFling
		 */
		private int getAdjustedTargetX(float velocityX) {
			int result = 0;
			if (velocityX > 0){
				result = getRightEdge(mSelected) - getLeftEdge(mSelected) 
						+ mRightOverScrollInFling;
//				LOG.v("onFling " + targetX);
			} else {
				// relative to layout position of the slide
				result = 0 - mLeftOverScrollInFling;
			}
			return result;
		}



Как видно из тела метода, расчёт этой точки зависит от направления флинга, но неизменной остаётся конечная позиция. В случае флинга вправо — это крайняя правая позиция слайда + «занос» на то количество пикселей, на которое слайд улетит дальше крайней правой позиции. Если флинг влево, то это крайняя левая позиция – занос. И это при любой начальной скорости движения. Такое ограничение сделано искусственно, так как анализ user experience показал, что конкретно для этого контролла такое поведение выглядит более естественно.

На платформе Android расчётом кинематики элемента при флинге занимается скроллер. В качестве модели разработчиками из Google была взята всем известная со школы формула равноускоренного движения: S=V0*t-(g*t2)/2. Начальная скорость движения нам известна, время мы можем измерять с начала движения, остаётся только выбрать ускорение, с которым будет останавливаться слайд.

Разработчики не стали гадать и взяли за основу ускорение свободного падения:

        mDefaultDeceleration = SensorManager.GRAVITY_EARTH   // g (m/s^2)
                      * 39.37f                        // inch/meter
                      * ppi                           // pixels per inch
                      * ViewConfiguration.getScrollFriction()
                      * 10.0f;


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

Возьмём положение открытого и закрытого слайда таким:





Допустим, что мы делаем флинг из начального положения. Упомянутые 4 возможных варианта получаются в результате перебора пройденного расстояния относительно крайнего левого (1 скриншот) и крайнего правого (2 скриншот) положений слайда. Допустим, что расстояние между этими положениями Sn



S < Sn/2

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



Sn/2<S< Sn

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

Sn<S< Sn+overscroll

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

S> Sn+overscroll

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

Если разбирать флинг в другую сторону, то мы увидим всё те же самые случаи за исключением того, что опорные расстояния будут рассчитываться немного по-другому.

Итак, как обработать флинг мы разобрались. Теперь необходимо решить, как нам во время такого движения создать анимацию. Для этого рассмотрим такой метод:

		public void fling(int velocity){
			mLastX = 0;
			final int maxX = 0x7FFFFFFF;
			final int minX = -maxX;
			fling(mLastX, 0, velocity, 0, minX, maxX, 0, 0);
			setNextMessage(MSG_SCROLL);
		}


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

Метод этот предельно прост, в нём мы очищаем все ранее помещенные туда сообщения и отправляем в хендлер только одно, необходимое в данный момент сообщение:

		private final Handler mAnimationHandler = new AnimationHandler();
	
		private void setNextMessage(int message) {
			clearMessages();
			mAnimationHandler.sendEmptyMessage(message);
		}		
		private void clearMessages() {
			mAnimationHandler.removeMessages(MSG_SCROLL);
			mAnimationHandler.removeMessages(MSG_JUSTIFY);
		}


mAnimationHandler является обычным вспомогательным иннер классом, который получает сообщение о том, что мы выполняем какой-то скроллинг, который осуществляется уже после контакта пользователя с TouchScreen’ом:

		private final class AnimationHandler extends Handler {
			@Override
			public void handleMessage(Message msg) {
				computeScrollOffset();
				int currX = getCurrX();
				int delta = mLastX - currX;
				mLastX = currX;
				if (delta != 0) {
					mListener.onScroll(-delta);
				}
				…
			}
		}


Всё очень просто: сначала просим скроллер посчитать текущее положение анимации в тот момент, когда мы получили это сообщение. Затем высчитываем разницу между новым положением и последним известным нам. В конце сообщаем слайдстеку о том, что скролл выполнился. Точно так же как мы это делали из тела метода onTouchEvent().

Теперь дело за малым — нужно в ответ на эти сообщения изменить позицию слайдов на экране. Отправной точкой станет имплементация метода onScroll():

		@Override
		public void onScroll(int distance) {
			if (distance == 0){
				return;
			}
//			LOG.d("onScroll " + distance);
			doScroll(distance);
		}


Сразу хочу успокоить: внутри метода не последует единственной строкой вызов метода doScrollInternal(), внутри которого вызов метода actuallyDoScroll() и так далее. В этом случае я решил разделить алгоритм таким образом, чтобы мы могли скроллить слайдстек не только через наш скроллер, но и, например, программно через api, имея доступ только к слайдстеку. Но об этом немного позже.

	/**
	 * Performs actual scrolling. Moves the views according
	 * to the current selected slide number and distance 
	 * passed to the method. After the scrolling has been
	 * performed method {@link #onScrollPerformed()} will be
	 * called where you can apply some visual effects.
	 * @param distance scroll distance
	 */
	private void doScroll(int distance) {
		adjustScrollDistance(distance);
//		LOG.d("scroll delta " + mScrollDelta);
		View selected = getChild(getSelectedViewIndex());
		scrollChildBy(selected, mScrollDelta);
		notifyScrollPerformed();
		onScrollPerformed();
		fillViewsIn();
		if (!mDirty.isEmpty()){
			invalidate(mDirty.left, mDirty.top, mDirty.right, mDirty.bottom);
			mDirty.setEmpty();
		}
	}


Пока не будем вдаваться в детали метода adjustScrollDistance(). Скажу лишь, что он меняет значение дистанции в зависимости от положения слайдов для создания пружинящего эффекта в том случае, когда пользователь пытается утянуть слайд за границы его нормального положения.
Далее мы находим текущий слайд, движение которого и пытался осуществить пользователь, результатом чего стал вызов этого метода. Двигаем этот слайд на относительное количество пикселей. Уведомляем слушателя слайдстека о том, что слайд в определенной позиции был перемещён.
Основная часть практически закончена, остаётся только добавить или удалить слайды, учитывая их новое положение, а также изменить их видимость или невидимость, в зависимости от новых позиций, в которых они оказались. Подробнее о том, как это делается, можно прочитать в первой части статьи. Ну и теперь самое главное — нам необходимо перерисовать область экрана, которая была изменена вследствие перемещения.

Теперь немного подробнее о перечисленных методах:

	/**
	 * Moves the specified child by some amount of pixels
	 * @param child child to move
	 * @param scrollDelta scrolling delta
	 */
	private void scrollChildBy(View child, int scrollDelta) {
		SlideInfo info = getSlideInfo(child);
//		LOG.d("apply scroll  " + info.mPosition + " delta " + scrollDelta);
		Rect childDirty = getChildRectWithShadow(child);
		info.mOffset -= scrollDelta;
		child.offsetLeftAndRight(-scrollDelta);
		childDirty.union(getChildRectWithShadow(child));
		mDirty.union(childDirty);
//		LOG.d("apply scroll  " + info.mPosition + " newoff " + info.mOffset);
	}


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

	/**
	 * Notifies scroll listeners about selected slide has been scrolled.
	 * Do nothing if there is no scroll listener was set earlier.
	 */
	private void notifyScrollPerformed() {
		if (mScrollListener != null){
			final float p = getSlidePositionInBounds(mSelected);
//			LOG.v("notifyScrollPerformed " + mSelected + ", " + p);
			mScrollListener.onSlideScrolled(mSelected, p);
		}
	}
	/**
	 * Calculates position for the specified slide relative to it's
	 * scrollable bounds. 
	 * <p>
	 * <b>Note:</b> Slide position coulld be <code>< 0.0f</code> and
	 * <code> > 1.0f
	 * @param slidePosition
	 * @return
	 */
	private float getSlidePositionInBounds(int slidePosition) {
		SlideInfo info = getSlideInfo(slidePosition);
		int offset = info.mOffset;
		int scrollBounds = getWidth() 
				- getRightEdgeDelta(info.mPosition) - getLeftEdge(info.mPosition);
		float p = ((float) offset) / scrollBounds;
		return p;
	}


Зачастую, когда вы проектируете какой-то компонент, который необходимо будет переиспользовать, вам нужно думать о том, что каким-то пользователям будет полезно знать о событиях, происходящих внутри вашего компонента. Не стоит забывать об этом и по возможности предоставлять пользователям вашего класса какой-то интерфейс, используя который они смогут получать высокоуровневую информацию о происходящем внутри. В моём случае это два основных события, связанных со скроллингом слайдстека. События достаточно очевидны, а именно:
событие, которое уведомляет пользователя о том, что внутри слайдстека изменился текущий слайд, причем важно отметить, что это событие наступает не в тот момент, когда слайд визуально практически стал активным, а лишь в тот момент, когда скроллинг полностью завершился.
событие, которое уведомляет пользователя о том, что позиция слайда изменилась относительно его изначальных границ. В моём случае относительное положение может выходить за границы [0, 1], что будет означать тот самый «занос», о котором я ещё расскажу подробнее.

    /**
     * Listener interface that informs about slide scrolling
     * related events such as current selected slide has changed,
     * or current selected slide scroll position has changed.
     * @author k.kharkov
     */
    public interface OnSlideScrollListener{
    	/**
    	 * Called when the current selected position for slide
    	 * has changed. Usually it happen after scrolling finished.
    	 * @param selectedSlide
    	 */
    	void onSlideChanged(int selectedSlide);
    	/**
    	 * Informs about changing scroll position of the slide.
    	 * @param position current selected slide position
    	 * @param p position of the slide inside it's scroll
    	 * bounds. 0.0f at left edge, 1.0f at right edge. If
    	 * <code>p < 0.0f || p > 1.0f</code> the slide is over
    	 * scrolled to left or to the right.
    	 */
    	void onSlideScrolled(int position, float p);
    }


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

	/**
	 * Retrieves slide's left edge coordinate in opened state
	 * relative to parent.
	 * @param position slide number in adapter's data set
	 * @return coordinate of the slide's left in opened state
	 */
	private int getLeftEdge(int position){
		return mAdapter == null ? 0 :mAdapter.getSlideOffset(position);
	}


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

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

	/**
	 * Retrieves coordinate of the slide's left edge in closed state
	 * relative to parent.
	 * @param position slide number in adapter's data set
	 * @return coordinate of slide's left in closed state.
	 */
	private int getRightEdge(int position) {
		int rightEdge = getRight() - getRightEdgeDelta(position);
		return rightEdge;
	}
	/**
	 * Just calculates delta between child's right edge and
	 * parent's right edge
	 * @param position position of the child (in adapter's
	 * data set indexes)
	 * @return delta in pixels
	 */
	private int getRightEdgeDelta(int position){
		if (position < 0){
			return 0;
		}
		int delta = mSlideInvisibleOffsetFirst + 
				mSlideInvisibleOffset * position;
		return delta;
	}


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

Нерассмотренными остались 2 метода: onScrollPerformed(), adjustScrollDistance().

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

Теперь давайте поговорим о так называемом bouncing effect. Он, почему-то, очень нравится всем менеджерам. Надеюсь, не потому, что он придаёт Android-приложению схожесть со стандартными контроллами, используемыми в iOS. Тем не менее, реализация подобного эффекта является достаточно интересной задачей с точки зрения программирования. У меня не было цели сделать абсолютно идентичный look&feel. Я думаю, сделать это будет крайне сложно, учитывая, что в iOS используется другая модель скроллинга, которая не основана на физике равноускоренного движения. По крайней мере, складывается такое ощущение. Так вот, моей задачей было сделать так, чтобы при выходе слайда за границы, которые ему
отведены внутри слайдстека, создавался пружинящий эффект и слайд начинал замедляться, как-будто попал в сетку, которая начинает его тормозить.

Как мы уже говорили, скроллинг может осуществляться двумя основными способами: когда пользователь непосредственно касается экрана и тянет слайд в нужном направлении, или когда он совершил флинг. В этих двух случаях нужно обрабатывать момент выхода слайда за свои границы по-разному. За это и отвечает метод adjustScrollDistance():

	/**
	 * Processes scroll distance according to the current scroll 
	 * state of the slide stack view. Takes into account 
	 * over scrolling, justifying.  
	 * @param distance desired distance to scroll.
	 */
	private void adjustScrollDistance(int distance) {
		mScrollDelta = distance;
		if (mScroller.isJustifying()){
			processNonOverScroll(distance);
		} else if (mScrollDelta < 0 && isRightOverScrolled()){
			processOverScroll(distance);
		} else if (mScrollDelta > 0 && isLeftOverScrolled()){
			processOverScroll(distance);
		} else {
			processNonOverScroll(distance);
		}
	}


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

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

	/**
	 * @return <code>true</code> if slide stack over scrolled
	 * to the right. <code>false</code> otherwise
	 */
	private boolean isRightOverScrolled(){
		/**
		 *  info.mOffset - it is
		 *  the latest position of the slide's left side
		 *  so if it is over scrolled - return true
		 *    _________________
		 *   |    _____________|_
		 *   |   |lastSlide    | |
		 *   |<->|             | |
		 *   |   |             | |
		 *   |   |_____________|_|
		 *   |_________________|
		 *        SlideStack
		 */
		SlideInfo info = getSlideInfo(getSelectedViewIndex());
		if (mSelected == mFirst + getChildCount() - 1){
			if (info.mOffset > getLeftEdge(info.mPosition)){
				return true;
			}
		}
		/**
		 *  getRightEdge() - it is left bound of the slide
		 *  when it is hidden
		 *    ___________|______
		 *   |           |    __|____________
		 *   |           |   |  |anySlide    |
		 *   |           |<->|  |            |
		 *   |           |   |  |            |
		 *   |           |   |__|____________|
		 *   |___________|______|
		 *        SlideStack
		 */
		int left = info.mOffset + getLeftEdge(mSelected); 
		if (left > getRightEdge(mSelected)){
			return true;
		}
		return false;
	}


Мы считаем, что слайд вошёл в режим оверскролла, если:

  • Это последний слайд, и его левая граница находится правее предусмотренного положения. Предусмотренное положение в данном случае определяется положением его левой
    границы в открытом состоянии.
  • Это не последний слайд, и его левая граница находится правее крайнего правого положение. В нашем случае крайнее правое положение — это положение закрытого слайда.


	/**
	 * @return <code>true</code> if the slide stack view is 
	 * over scrolled to the left. <code>false</code> otherwise. 
	 */
	private boolean isLeftOverScrolled(){		 
		View selected = getChild(getSelectedViewIndex());
		SlideInfo info = getSlideInfo(selected);
		return selected.getRight() + info.mOffset < getRightEdge(mSelected - 1);
	}


Оверсроллом влево считается такое положение слайда, при котором его правая граница находится левее левой границы предыдущего слайда. То есть, если у нас есть закрытый первый слайд, то для второго слайда оверскроллом будет считаться положение, в котором его правая границы будет левее левого края первого (закрытого) слайда. Для первого слайда нет предыдущего, поэтому оверскроллом будет считаться его положение, в котором правая граница будет левее правой границы слайдстека (см. getRightEdge()).

Самое время приступить к вычислению поправки, в том случае, если мы находимся в режиме оверскролла:

	/**
	 * Changes actual scroll delta in case over scroll.
	 * Depends on whether we in fling mode or not.
	 * @param distance
	 */
	private void processOverScroll(int distance) {
//		LOG.d("process overscroll " + distance);
		//process over scroll while in fling mode;
		if (!mScroller.isTouchScrolling()){
			mScroller.setDecelerationFactor(mDecelerationFactor);
		} else{ // or just slow down while touch scrolling
			mOverScrollOffset += distance;
			int nOffsetAbsolute = (int) (mOverScrollOffset / mOverScrollFactor);
			int oldOffsetAbsolute = mLastOverScrollOffset;
			int scrollDelta = nOffsetAbsolute - oldOffsetAbsolute;
			mLastOverScrollOffset += scrollDelta;
			mScrollDelta = scrollDelta;
		}
	}


Пока нас интересует та часть, которая отвечает за touchScrolling, или скроллинг, при котором пользователь касается экрана и принудительно ведёт слайд в какую-то сторону. Мне в голову сначала пришла максимально простая обработка такого поведения, а именно: просто разделить исходную дистанцию на какое-то значение, которое будет представлять собой степень замедления движения в режиме тач оверскролла. На первый взгляд кажется, что так оно и должно быть. Проблема возникает в том случае, когда mOverscrollFactor принимает достаточно большие значения. При незначительном оверскролле получаемое в результате значение близко к нулю. Неприятное ощущение появляется, если пользователь потихоньку двигает слайд в сторону оверскролла, но делает это достаточно долго. Например, у нас overscroll factor = 5, и пользователь двигает слайд таким образом, что каждый раз мы получаем distance = 1. Первый раз мы действительно не должны двигать слайд. В таком случае наша поправка сработает. Второй, третий, четвертый раз тоже всё нормально. Но пользователь продолжает двигать слайд дальше, в пятый раз. И тут получается, что суммарное движение в сторону оверскролла составило 5 пикселей, и, согласно выбранной поправке, мы должны сдвинуть слайд на 1 пиксель. Теперь ясно, что мы должны учитывать всё предыдущее движение, чтобы обрабатывать оверскролл более правдоподобно. Для этого мы
запоминаем две величины mOverScrollOffset — это суммарное значение в пикселях на, которое пользователь задвинул слайд в оверскролл. И mLastOverScrollOffset — это значение в пикселях, на которое мы на самом деле сдвинули слайд. Далее путем нехитрой арифметики мы сможем решить, нужно ли сдвинуть слайд в при данном скролле, и на сколько.

Тут есть ещё один подводный камень: при движении обратно, после оверскролла, мы должны менять значения mOverScrollOffset и mLastOverScrollOffset. Делать это необходимо на тот случай, когда пользователь после оверскролла двигает слайд в противоположном направлении (уменьшает оверскролл), а потом опять начинает двигать его в оверскролл. Если эту поправку не делать, то могут возникнуть неприятные подёргивания слайдов. За эту поправку как раз и отвечает следующий метод:

	/**
	 * We need assume that actual scroll delta is distance parameter,
	 * we need adjust {@link #mLastOverScroll} if we will not go out 
	 * from over scroll mode and over scroll again.
	 * @param distance raw distance passed from the scroller.
	 */
	private void processNonOverScroll(int distance) {
		mScrollDelta = distance;
		if (isOverScrolled()){
			mLastOverScrollOffset += distance;
			mOverScrollOffset = (int) (mLastOverScrollOffset * mOverScrollFactor);
		} else {
			mLastOverScrollOffset = 0;
			mOverScrollOffset = 0;
		}
	}


Осталось последняя уязвимость: мы должны сбросить значения mOverScrollOffset и mLastOverScrollOffset в том случае, если дистанция, на которую пользователь попытался вернуть слайд из режима оверскролла, больше того значения, на которое оверсколл был совершён. Теперь для нового оверскролла всё начнется с исходного положения — как и должно быть.

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

Тут уже не обойдёшься простым делением, потому что это будет выглядеть неестественно в связке с кинетически закономерным движением слайда. Здесь я попробовал множество разных вариантов, и параметрические кривые (кривые Безье) и много чего ещё. Но в конце концов я пришёл к выводу, что сделать это движение таким же естественным, как и сам расчёт движения при флинге, мне поможет следование тем же законам, которым следует Scroller. Вспомним формулу, которую я приводил ранее в этой статье:

S=V0*t-(g*t2)/2 (равноускоренное движение)

Теперь для того, чтобы элемент с определенного момента начинал уменьшать свою скорость быстрее, нам нужно начать увеличивать ускорение. Разобьём все движения на 3 отрезка:

S=V0*t-(g*t2)/2

Элемент двигался по правилам равноускоренного движения до того, как вышел за границы и перешёл в режим оверскролла

S2=Vk*t2- ((g*p)*〖t2〗2)/2

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

S3=Vk2*t3- ((g*p*p)*〖t3〗2)/2

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

mScroller.setDecelerationFactor(mDecelerationFactor);

Этот метод мне пришлось дописать в скроллер, вытащив его исходники. Кто с ними знаком, тот сразу поймёт, о чём идёт речь:

    /** 
     * Adjusts the current deceleration to slow down more or less.
     * @param factor if > 1.0 the scroller will slow down more.
     * if factor < 1.0 the scroller will slow down less.
     */
    public void setDecelerationFactor(float factor){    	
    	mVelocity = mVelocity - mDeceleration * mPassed / 1000.0f;
    	float velocity = mVelocity;
    	mDeceleration *= factor;
    	mDuration = (int) (1000.0f * mVelocity / mDeceleration);
    	int startX = mStartX = mCurrX;
    	int startY = mStartY = mCurrY;
    	
        int totalDistance = (int) ((velocity * velocity) / (2.0f * mDeceleration));
        
        mFinalX = startX + Math.round(totalDistance * mCoeffX);
        // Pin to mMinX <= mFinalX <= mMaxX
        mFinalX = Math.min(mFinalX, mMaxX);
        mFinalX = Math.max(mFinalX, mMinX);
        
        mFinalY = startY + Math.round(totalDistance * mCoeffY);
        // Pin to mMinY <= mFinalY <= mMaxY
        mFinalY = Math.min(mFinalY, mMaxY);
        mFinalY = Math.max(mFinalY, mMinY);
        mStartTime += mPassed;
    }


Еще одной неожиданностью для меня стало то, что в той имплементации скроллера, которую я использовал за основу, был метод, который отмечен аннотацией hide:

    /**
     * @hide
     * Returns the current velocity.
     *
     * @return The original velocity less the deceleration. Result may be
     * negative.
     */
    public float getCurrVelocity() {
        return mVelocity - mDeceleration * timePassed() / 2000.0f;
    }


Для меня остается непонятным, почему разработчики решили изменить привычную формулу v(t)=v0+at и разделить произведение ускорения и времени пополам (1000 — это поправка на миллисекунды). Если у кого-то есть догадки, то вот тут я оставлял вопрос, на который пока никто не смог ответить.

Мы разобрали, как можно обрабатывать тач event’ы для того, чтобы осуществлять скроллинг. Это лишь часть задачи, которую должен выполнять слайдстек. Как я уже говорил, не всегда можно сразу понять, что собирается сделать пользователь, когда он касается экрана. Android Framework ещё не научился читать мысли, а значит нам нужно будет взять эту задачу на себя. К счастью, нет необходимости определять это заранее. Нам хватит той информации, которая будет поступать в наш контролл через метод ViewGroup.dispatchTouchEvent().

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

  • Сначала пытаемся определить child, которому предназначается touchEvent.
  • Если такой child обнаружен, то передаём ему event, переместив для него в относительные координаты. Если такого чайлда нет, то event направляется для обработки самому ViewGroup.
  • При получении очередного MotionEvent’a у ViewGroup есть возможность перехватить его. В этом случае следующий MotionEvent пойдёт к самому ViewGroup, минуя child’ов.


Вообще, на мой взгляд, этот способ решения конфликта обработки касаний разными View достаточно прост в понимании.

Допустим, что один из слайдов нашего слайдстека содержит в себе список, который можно прокручивать вертикально. На самом деле идеально вертикально и горизонтально пользователи никогда не двигают элементы, вот тут и появляется конфликт. Задача разработчика, анализируя приходящие event’ы понять, что пытался сделать пользователь: закрыть слайд со списком или добраться до элемента внизу списка.

Пойдём по порядку. Вся магия происходит в методе:

	/**
	 * Determines whether the user tries to scroll the slide stack view
	 * or just tries to scroll some scrollable content inside the slide.
	 * <p>
	 * {@inheritDoc}
	 */
	@Override
	public boolean onInterceptTouchEvent(MotionEvent ev) {


Тут, собственно, и решаются все конфликты между слайдстеком, списком писем\папок\аккаунтов и быстрыми действиями пользователя.
Для начала нужно определить, какое действие произошло:

		final int action = MotionEventCompat.getActionMasked(ev);

		if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP){
			/*
			 * That means we need to abort all scrolling and return to nearest
			 * stable position in the slide stack. So justify position.
			 */
			mBeingDrag = false;
			mUnableToDrag = false;
			mScroller.justify();
//			LOG.v("OnInterceptTouchEvent: action cancel | up");
			return false;
		}
		/*
		 * In case we have already determined whether we need this
		 * touch event or not - just return immediately
		 */
		if (action != MotionEvent.ACTION_DOWN){
			if (mBeingDrag){
//				LOG.v("OnInterceptTouchEvent: already dragging");
				return true;
			}
			if (mUnableToDrag){
//				LOG.v("OnInterceptTouchEvent: already unable to drag");
				return false;
			}
		}


Если жест закончился или его отменили (например, родитель перехватил MotionEvent), то мы больше никаких event’ов не получим и нам нужно вернуться в исходное состояние, подровнять положение слайдов и сбросить в начальное положение все флаги.

Но если мы уже определили, что этот жест точно нам или не нам, то тогда вся работа уже сделана и мы можем сразу ответить на вопрос о том, нужно перехватывать event или нет.

		switch (action){
		case MotionEvent.ACTION_DOWN:{
			/*
			 * remember the start coordinates for the motion event
			 * in order to determine drag event length
			 */
			mInitialX = ev.getX();
			mInitialY = ev.getY();
			mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
			/*
			 * pass down event to the scroller after we have decided to intercept,
			 * not here. It helps to start calculation motion event in case we
			 * decide to intercept it.
			 */
			mScroller.setActivePointer(mActivePointerId);
			if (mScroller.isScrolling() || isHiddenSlideMove(false)){
				/*
				 * in case the user start the touch while we didn't
				 * accomplish scrolling - intercept touch event.
				 *
				 */
				mBeingDrag = true;
				mUnableToDrag = false;
			} else {
				/*
				 * Otherwise let's start the process of detecting
				 * who the touch event belongs to.
				 */
				mBeingDrag = false;
				mUnableToDrag = false;
			}
//			LOG.v("OnInterceptTouchEvent: DOWN being drag " + mBeingDrag +
//					", unable to drag " + mUnableToDrag);
			return mBeingDrag;
		}


Если мы получили event о начале жеста, то первым делом нужно запомнить координаты начала движения. Это поможет в дальнейшем определить направление движения пользователя по экрану смартфона. Если никаких дополнительных условий нет, то мы не будем перехватывать MotionEvent.ACTION_DOWN. И уже со следующими event’ами будем определять, что хотел сделать пользователь. В моём случае дополнительным ограничением было то, что пользователь может только открывать закрытый слайд, значит никакие event’ы child’у не передаём. Коснулся пользователь закрытого слайда или нет, определяет метод isHiddenSlideMove(). Второе ограничение, при котором не стоит передавать event’ы нашим «детям», относится к тем случаям, когда мы начинаем новый жест, пока слайдстек не остановился. То есть анимация ещё не закончилась, а значит скроллер находится в состоянии движения.

Очень простое определение, касается ли пользователь закрытого слайда:

	/**
	 * Defines whether motion events has been started on the closed slide or not
	 *
	 * @param extend
	 *            if <code>true</code> it will take into account
	 *            {@link #mTouchExtension}. Otherwise this method will only take
	 *            into account {@link #mInitialX} and {@link #mInitialY}
	 * @return <code>true</code> in case the motion event has been started to
	 *         the right of the last closed slide, <code>false</code> otherwise.
	 */
	private boolean isHiddenSlideMove(boolean extend) {
		int x = (int) mInitialX;
		int y = (int) mInitialY;

		Rect rightSide = new Rect();
		boolean right = false;
		for (int i = getLastHiddenSlideIndex(); i >= 0 && !right; i--) {
			View view = getChild(i);
			Rect rect = new Rect();
			view.getHitRect(rect);
			rightSide.union(rect);
			if (rightSide.contains(x, y)
					|| (extend && rightSide.contains(x + mTouchExtension, y))) {
				right = true;
			}
		}
		return right;
	}


Дальше самое интересное решение — перехватывать или нет:

		case MotionEvent.ACTION_MOVE:{
			final int activePointerId = mActivePointerId;
			if (activePointerId == INVALID_POINTER_ID) {
				// If we don't have a valid id, the touch down wasn't on content.
				break;
			}
			final int pointerIndex = MotionEventCompat.findPointerIndex(ev, activePointerId);
            final float x = MotionEventCompat.getX(ev, pointerIndex);
			final float dx = x - mInitialX;
			final float xDiff = Math.abs(dx);

			final float y = MotionEventCompat.getY(ev, pointerIndex);
			final float dy = y - mInitialY;
			final float yDiff = Math.abs(dy);

            if (dx != 0 && canScroll(this, false, (int) dx, (int) x, (int) y)) {
            	// Nested view has scrollable area under this point. Let it be handled there.
            	if(!isHiddenSlideMove(false)) {
            		mUnableToDrag = true;
            		return false;
            	}
            }
			// if it seems to be horizontal scroll
			if (xDiff > mTouchSlop && xDiff * 0.5f > yDiff){
//				LOG.v("OnInterceptTouchEvent: MOVE start drag");
				ev.setAction(MotionEvent.ACTION_DOWN);
				adjustSelectedSlide();
				mScroller.onTouch(this, ev);
				mBeingDrag = true;
			} else if (yDiff > mTouchSlop){
//				LOG.v("OnInterceptTouchEvent: MOVE unable to drag");
				mUnableToDrag = true;
			}
			break;
		}


Давайте разбираться. Всё определение строится на простой геометрии и ещё более простой логике. Во-первых, для того, чтобы определить, в горизонтальном или вертикальном направлении пользователь двигает пальцем по экрану, нужно, чтобы движение было достаточно большим (чтобы не ошибиться). С другой стороны, мы должны сделать максимально точный вывод как можно раньше, чтобы начать адекватно реагировать на действия пользователя. Проще всего, когда мы можем упростить движение по тач скрину до
прямой линии. Для этого достаточно соединить две точки, начальную и последнюю известную на данный момент. Теперь мы можем охарактеризовать направление через угол наклона этой прямой относительно осей координат. Все прямые, наклон которых к положительному направлению оси Х меньше, чем 22,5 градуса, мы будем считать горизонтальным движением.



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

Таким образом достаточно эффективно решается конфликт между горизонтальным и вертикальным скроллом. Но, как я уже говорил, трудно определить, пытается ли пользователь открыть меню быстрым движением (горизонтальный скролл по элементу списка писем), либо он пытается закрыть слайд со списком писем, чтобы перейти в другую папку. Тут пришлось прибегнуть к небольшой хитрости, которую я подсмотрел у разработчиков из Google (см. ViewPager):

            if (dx != 0 && canScroll(this, false, (int) dx, (int) x, (int) y)) {
            	// Nested view has scrollable area under this point. Let it be handled there.
            	if(!isHiddenSlideMove(false)) {
            		mUnableToDrag = true;
            		return false;
            	}
            }

    /**
     * Tests scrollability within child views of v given a delta of dx.
     *
     * @param v View to test for horizontal scrollability
     * @param checkV Whether the view v passed should itself be checked for scrollability (true),
     *               or just its children (false).
     * @param dx Delta scrolled in pixels
     * @param x X coordinate of the active touch point
     * @param y Y coordinate of the active touch point
     * @return true if child views of v can be scrolled by delta of dx.
     */
    protected boolean canScroll(View v, boolean checkV, int dx, int x, int y) {
        if (v instanceof ViewGroup) {
            final ViewGroup group = (ViewGroup) v;
            final int scrollX = v.getScrollX();
            final int scrollY = v.getScrollY();
            final int count = group.getChildCount();
            // Count backwards - let topmost views consume scroll distance first.
            for (int i = count - 1; i >= 0; i--) {
                final View child = group.getChildAt(i);
                if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight() &&
                        y + scrollY >= child.getTop() && y + scrollY < child.getBottom() &&
                        canScroll(child, true, dx, x + scrollX - child.getLeft(),
                                y + scrollY - child.getTop())) {
                    return true;
                }
            }
        }

        return (checkV && ViewCompat.canScrollHorizontally(v, -dx));
	}


Этот метод просто перебирает всё дерево View, и когда добирается до «листьев», которые находятся под областью, которую скроллят, то вызывает соответствующий метод ViewCompat.canScrollHorizontally(). Правда, такой способ появился отнюдь не сразу, и для многих платформ мы не сможем таким образом определить горизонтальный скролл. Мне оставалось лишь добавить свой ViewCompat, в котором делается проверка для меню быстрых действий.

public class ViewCompat {
	
	public static boolean canScrollHorizontally(View v, int direction){
		if (v instanceof QuickActionView){
			return ((QuickActionView)v).canScrollHorizontally(direction);
		} else {
			return android.support.v4.view.ViewCompat.canScrollHorizontally(v, direction);
		}
	}
}
}


Практически финиш. Осталась пара штрихов.

	/**
	 * {@inheritDoc}
	 */
	@Override
	public boolean onTouchEvent(MotionEvent event) {
		LOG.i("onTouchEvent: " + event);
        if (event.getAction() == MotionEvent.ACTION_DOWN && event.getEdgeFlags() != 0) {
            // Don't handle edge touches immediately -- they may actually belong to one of our
            // descendants.
            return false;
        }
        if ((event.getAction() & MotionEventCompat.ACTION_MASK) == MotionEvent.ACTION_POINTER_UP){
        	continueWithSecondPointer(event);
        	return true;
        }
        // adjust selected slide in case we didn't it in #onInterceptTouchEvent() method
        // if we have no touchable child under the touch event for instance
        if (!mScroller.isScrolling() && event.getAction() == MotionEvent.ACTION_DOWN){
			adjustSelectedSlide();
        }
		return mScroller.onTouch(this, event);
	}


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

	@Override
	public boolean dispatchTouchEvent(MotionEvent ev) {
//		LOG.v("dispatchTouchEvent: " + ev);
		if (getChildCount() == 0){
			return false;
		}
		return super.dispatchTouchEvent(ev);
	}


И защита от дурака. Нет смысла выполнять какие-то расчёты, если у нас нет ни одного добавленного слайда.

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

Подскажу одну хитрость — старые добрые логи. Всегда можно обложить логами разного уровня три метода dispatchTouchEvent(), onInterceptTouchEvent() и onTouchEvent(), а в трудной ситуации проследить, куда и как передаётся этот event. Но, как всегда, самым надёжным ключом к пониманию остаётся чтение документации. К настоящему моменту на developer.android.com можно найти неплохие гайды, в которых описаны принципы обработки event’ов.

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

Присылайте свои замечания и пожелания :)
Tags:
Hubs:
+37
Comments 6
Comments Comments 6

Articles

Information

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