Навчання жестам Android

Що відбувається в системі, коли користувач торкається екрану? І ще більш важливо — як правильно з цим впоратися? Настав час розібратися з усім раз і назавжди! Сьогоднішній пост про те, що я дізнався про Touch System в Android і мій досвід роботи з ним.

  1. Зовнішність оманлива
  2. Що всередині?
  3. Системні детектори жестів і дотиків
  4. Ваш власний детектор жестів
  5. Перехоплюйте і делегуйте!
  6. Висновок

1. Зовнішність оманлива

Нещодавно я отримав завдання розробити бібліотеку FrescoImageViewer для перегляду фотографій, які були завантажені за допомогою Fresco. Окрім цього, мені також потрібно було реалізувати функцію "зум за допомогою стиснення", переключення з ViewPager та щось подібне до "свайпу для закриття" — закриття зображення вертикальним свайпом. Після складання основних компонентів я зіткнувся з серйозною проблемою: конфлікт жестів.

Оскільки в мене не було великого досвіду з жестами, перша ідея, яка прийшла мені в голову, полягала в аналізі подій у onTouchEvent() всередині мого CustomView і передачі контролю, коли це необхідно. Але поведінка виявилася менш очевидною, ніж я думав.

Документація стверджує, що onTouchEvent() має повертати true, якщо подія була оброблена, і false в іншому випадку. Але з якихось причин вона не згадує, що якщо ми повертаємо true, а потім змінюємо значення назад на false, поведінка не зміниться, поки жест не буде завершено. Це означає, що після того, як ми скажемо системі, що onTouchEvent() зацікавлений у тому, що відбувається, це рішення не можна змінити. Це було справжнім болем, тому я нарешті відкрив Google і почав вивчати фреймворк для управління жестами, що використовується в Android.

2. Що всередині?

Для кращого розуміння давайте підходимо до питання покроково і дізнаємося, що відбувається всередині цього не такого вже складного механізму. Як приклад я використаю Activity, на яку користувач щойно поклав палець, з ViewGroup та дочірнім View всередині:

  1. Система обертає вхідну подію в об'єкт MotionEvent, що містить усі корисні дані, такі як тип дії, поточні та попередні координати дотику, час події, кількість пальців, що торкаються екрану, та їх порядок тощо.
  2. Згенерований об'єкт потрапляє в Activity.dispatchTouchEvent(), який завжди викликається першим. Якщо активність не повертає true (не зацікавлена в обробці події на своєму рівні), подія надсилається кореневому View.
  3. Кореневий елемент викликає dispatchTouchEvent() у всіх залучених дочірніх елементах у зворотному порядку їх додавання. Вони роблять те ж саме зі своїми дочірніми елементами і передають подію вниз по ієрархії елементів, поки якийсь елемент не відреагує на неї (не поверне true).*
  4. Коли досягається dispatchTouchEvent() найнижчого View, ланцюг повертається назад за допомогою методу onTouchEvent(), який також повертає результат зацікавленості/незацікавленості.
  5. Якщо ніхто не зацікавлений, подія повертається в Activity.onTouchView().

Те ж саме стосується ViewGroup та View: перед викликом onTouchEvent() перевіряється наявність nTouchListener. Якщо він був вказаний, отримуємо OnTouchListener.onTouch(), якщо ні — onTouchEvent().

*Зверніть увагу: у ViewGroup після виклику методу dispatchTouchEvent() ми також отримуємо onInterceptTouchEvent(), що дозволяє перехоплювати подію без повідомлення вкладених елементів, таким чином роблячи поведінку ViewGroup ідентичною View:

  • Якщо під час перехоплення жесту ViewGroup говорить, що він зацікавлений, всі дочірні елементи отримують ACTION_CANCEL.
  • У разі, якщо потрібно уникнути перехоплення з боку батьківського контейнера та його предків всередині View, слід викликати requestDisallowInterceptTouchEvent(true) в ViewGroup.

3. Системні детектори жестів та дотиків

На щастя, немає потреби винаходити колесо та обробляти все вручну. Вбудований у SDK GestureDetector є великою допомогою в цьому. Він включає інтерфейси OnGestureListener, OnDoubleTapListener та OnContextClickListener для сповіщення про подію, що сталася, та її тип. Ось як вони виглядають:

publicinterface OnGestureListener {boolean onDown(MotionEvent e);void onShowPress(MotionEvent e);boolean onSingleTapUp(MotionEvent e);boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);void onLongPress(MotionEvent e);boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);}
 
publicinterface OnDoubleTapListener {boolean onSingleTapConfirmed(MotionEvent e);boolean onDoubleTap(MotionEvent e);boolean onDoubleTapEvent(MotionEvent e);}
 
publicinterface OnContextClickListener {boolean onContextClick(MotionEvent e);}

Як ви можете бачити з назв методів, за допомогою SDK GestureDetector ми можемо розрізняти singleTap, doubleTap, longPress, прокрутку та фліп (детальний опис цих методів ви можете знайти в Javadoc або в офіційній документації Android).

Але цього недостатньо! У нас також є ScaleGestureDetector з лише одним listener

publicinterface OnScaleGestureListener {boolean onScale(ScaleGestureDetector detector);boolean onScaleBegin(ScaleGestureDetector detector);void onScaleEnd(ScaleGestureDetector detector);}

Він розпізнає стиснення і сповіщає про його початок, закінчення та тривалість. Окрім listener, є також додаткові методи для отримання всієї необхідної інформації (ознайомтесь з документацією).

Оскільки тепер ми знайомі з вбудованими класами, давайте навчимося, як їх використовувати. Це досить просто! Просто створіть екземпляр детектора, який вам потрібен:

scaleDetector =new ScaleGestureDetector(context, listener());

А потім передайте MotionEvent, який ви отримали, йому. Наприклад, у onTouchEvent():

@Override
publicboolean onTouchEvent(MotionEvent event){
   scaleDetector.onTouchEvent(event);returnsuper.onTouchEvent(event);}

Ви на правильному шляху! Усі розпізнані жести передаються до listener, який був переданий.

4. Ваш власний детектор для жестів

На жаль, за стандартними функціями ми можемо дізнатися лише про дотики та рухи вказівників (MotionEvent.ACTION_DOWN та MotionEvent.ACTION_MOVE), але в деяких випадках (що є досить поширеним) під час обробки жестів нам також потрібно знати їх напрямок. Стандартні детектори тут не допоможуть, тому нам потрібно написати свій власний.

Назвемо його SwipeDirectionDetector. Логіка тут проста: ми запам'ятовуємо координати події на ACTION_DOWN, а потім вимірюємо відстань до точки на ACTION_MOVE. Як тільки відстань стає достатньою для визначення напрямку, ми розраховуємо кут і отримуємо дані про напрямок на його основі.

Спочатку давайте визначимо метод onTouchEvent(), який приймає MotionEvent, і опишемо логіку розрахунків у ньому:

publicboolean onTouchEvent(MotionEvent event){switch(event.getAction()){case MotionEvent.ACTION_DOWN:
           startX = event.getX();
           startY = event.getY();break;case MotionEvent.ACTION_CANCEL:case MotionEvent.ACTION_UP:
           startX = startY = 0.0f;break;case MotionEvent.ACTION_MOVE:if(getDistance(event)> touchSlop){float x = event.getX();float y = event.getY();
 
               Direction direction = Direction.get(getAngle(startX, startY, x, y));
               onDirectionDetected(direction);}break;}returnfalse;}

Ми визначаємо об'єкт Direction як enum і додаємо до нього методи get() для визначення напрямку на основі кута та inRange() для перевірки, чи знаходиться він в межах діапазону.

publicenum Direction {
   UP,
   DOWN,
   LEFT,
   RIGHT;
 
   publicstatic Direction get(double angle){if(inRange(angle, 45, 135)){return Direction.UP;}elseif(inRange(angle, 0, 45)|| inRange(angle, 315, 360)){return Direction.RIGHT;}elseif(inRange(angle, 225, 315)){return Direction.DOWN;}else{return Direction.LEFT;}}
 
   privatestaticboolean inRange(double angle, float init, float end){return(angle >= init)&&(angle < end);}}

Ми майже на фініші. Тепер давайте створимо екземпляр детектора і передамо отриманий MotionEvent:

directionDetector =new SwipeDirectionDetector(getContext()){
   @Override
   publicvoid onDirectionDetected(Direction direction){this.direction= direction;}};
...
@Override
publicboolean onTouchEvent(MotionEvent event){
   directionDetector.onTouchEvent(event);returnsuper.onTouchEvent(event);}

5. Перехоплюємо та делегуємо!

Тепер давайте розглянемо простий випадок: кастомний вигляд з ViewPager і контейнером для "свайпу для закриття". Якщо ми просто об'єднаємо компоненти, жести оброблятимуться одночасно, що з точки зору UX є не найкращим рішенням.

Щоб вирішити цю проблему, нам потрібно переопределити dispatchTouchEvent() і використовувати його для сповіщення детектора, який ми написали. Метод повинен повертати true, оскільки нам потрібно перехопити його, щоб отримати контроль. Як тільки напрямок буде визначено, ми можемо передати події правильному віджетові. Пам'ятайте, що ви повинні робити це тільки через dispatchTouchEvent().

@Override
publicboolean dispatchTouchEvent(MotionEvent event){
   directionDetector.onTouchEvent(event);
 
   //передача дії UP віджетам і скидання напрямкуif(event.getAction()== MotionEvent.ACTION_UP){
       direction =null;
       pager.dispatchTouchEvent(event);
       swipeDismissListener.onTouch(dismissContainer, event);}
 
   //передача початкової дії віджетамif(event.getAction()== MotionEvent.ACTION_DOWN){
       swipeDismissListener.onTouch(dismissContainer, event);
       pager.dispatchTouchEvent(event);}
 
   if(direction !=null){switch(direction){case UP:case DOWN:return swipeDismissListener.onTouch(dismissContainer, event);case LEFT:case RIGHT:return pager.dispatchTouchEvent(event);}}returntrue;}

Тепер конфлікт жестів вирішено, і все виглядає гарно та акуратно (що також добре :)

6. Висновок

Для прикладів та кращого розуміння цього підходу, будь ласка, ознайомтеся з джерелом коду FrescoImageViewer на GitHub. Щиро сподіваюся, що цей пост допоможе комусь краще зрозуміти систему жестів і заощадити їхній дорогоцінний час ;)

П. С. Ця лекція Дейва Сміта дуже допомогла мені на цьому шляху, тому рекомендую вам також ознайомитися з нею.

Потрібна розробка MVP, iOS та Android застосунків або прототипування? Ознайомтеся з нашим портфоліо та зробіть замовлення сьогодні!