Распознавание пользовательских жестов

в 11:00, , рубрики: android, android development, swipe, мультитач, Разработка под android, метки: , , ,

Недавно, при разработке игры под Android, я столкнулся с проблемой реализации работы с пользовательскими жестами. В стандартной комплектации Android SDK имеется класс GestureDetector (тут демонстрация работы с этим классом), однако в нём реализованы не все жесты, что мне были нужны, а также некоторые из них работали не так, как мне надо (onLongPress, например, срабатывал не только по длительному касанию, но и по длительному касанию с ведением пальца по экрану). Кроме игр жесты могут использоваться и в обычных приложениях. Они могут заменить некоторые элементы интерфейса, тем самым сделав его проще. Жесты уже используются в очень многих приложениях для устройств с сенсорным вводом и это даёт нам право предполагать, что пользователь уже знаком с ними. Сегодня мы реализуем в нашем приложении распознавание long press, double touch, pinch open, pinch close и других.

Hello, Habr!

Препродакшн

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

Основа приложения

Для начала создаём Android Application Project в Eclipse. Наш проект не будет использовать каких-либо функций из новых SDK, поэтому в качестве Minimum Required SDK можно поставить API 8 (Android 2.2), например. MainActivity (если его нету, то нужно будет создать) приведём к такому виду:

import android.os.Bundle;
import android.app.Activity;

public class MainActivity extends Activity {
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(new ApplicationView(this));
    }
}

Соответственно, мы должны создать класс ApplicationView, который будет наследовать от View:

import android.view.View;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Canvas;

public class ApplicationView extends View {
    Paint paint = new Paint();			// Стиль рисования, кисть
    static float density;
    
    public ApplicationView(Context context) {
        super(context);
        this.setBackgroundColor(Color.GRAY);	// Устанавливаем серый цвет фона
        paint.setStrokeWidth(5*density);	// Устанавливаем ширину кисти в 5dp
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        invalidate();
    }
}

Теперь нам надо разобраться, как мы будем отображать холст. Мы создаём Canvas, передав ему в параметры Bitmap. В данном случае при рисовании чего-либо на нашем Canvas, оно отображается на Bitmap. Так же нам нужна переменная позиции нашего холста и расстояния до него. Добавляем в класс ApplicationView несколько переменных:

Canvas canvas;				// Холст
Bitmap image;				// Содержимое холста
float zoom = 500;			// Расстояние до холста
Point position = new Point(50, 50);	// Позиция холста

В конструкторе класса инициализируем холст и его содержимое, а так же закрашиваем его белым цветом:

image = Bitmap.createBitmap(500, 500, Config.ARGB_4444);	// Создаём содержимое холста
canvas = new Canvas(image);					// Создаём холст
canvas.drawColor(Color.WHITE);					// Закрашиваем его белым цветом

В методе onDraw отображаем его:

canvas.translate(position.x, position.y);	// Перемещаем холст
canvas.scale(zoom / 500, zoom / 500);		// Изменяем расстояние до холста
canvas.drawBitmap(image, 0, 0, paint);		// Рисуем холст
invalidate();

Если мы сейчас запустим наше приложение, мы увидим белый квадрат на сером фоне.

Сделать лучше

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

<activity
    android:name=".MainActivity"
    android:label="@string/title_activity_main"
    android:screenOrientation="landscape"
    android:theme="@android:style/Theme.Holo.Light.NoActionBar.Fullscreen">

Реализация работы с сенсорным экраном

Нам нужно начать начать взаимодействовать с сенсорным экраном с помощью переопределения метода onTouchEvent (подробнее о работе с сенсорным экраном тут):

ArrayList<Finger> fingers = new ArrayList<Finger>();		// Все пальцы, находящиеся на экране

@Override
public boolean onTouchEvent(MotionEvent event) {
    int id = event.getPointerId(event.getActionIndex());	// Идентификатор пальца
    int action = event.getActionMasked(); // Действие
    if(action  == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN)
      fingers.add(event.getActionIndex(), new Finger(id, (int)event.getX(), (int)event.getY()));
    else if(action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP)
      fingers.remove(fingers.get(event.getActionIndex()));	// Удаляем палец, который был отпущен
    else if(action == MotionEvent.ACTION_MOVE){
      for(int n = 0; n < fingers.size(); n++){			// Обновляем положение всех пальцев
        fingers.get(n).setNow((int)event.getX(n), (int)event.getY(n));
      }
      //checkGestures();
    }
    return true;
}

Мы видим, что при касании пальца, в список fingers мы добавляем объект типа Finger. Когда палец поднимаем, мы удаляем этот объект, а при перемещении пальцев мы обновляем координаты всех объектов. В итоге список fingers содержит в себе все касания с актуальными и предыдущими координатами. Класс Finger:

import android.graphics.Point;

public class Finger {
    public int ID;			// Идентификатор пальца
    public Point Now;
    public Point Before;
    boolean enabled = false;		// Было ли уже сделано движение
    
    public Finger(int id, int x, int y){
        ID = id;
        Now = Before = new Point(x, y);
    }
    
    public void setNow(int x, int y){
        if(!enabled){
          enabled = true;
          Now = Before = new Point(x, y);
        }
        Before = Now;
        Now = new Point(x, y);
    }
}
Проверка правильности

Мы можем нехитрой манипуляций отобразить все касания. Для этого в методе onDraw вписываем это:
canvas.restore();				// Сбрасываем показатели отдаления и перемещения
for(int i = 0; i < fingers.size(); i++){	// Отображаем все касания в виде кругов
    canvas.drawCircle(fingers.get(i).Now.x, fingers.get(i).Now.y, 40 * density, paint);
}

Перемещение холста

Для реализации перемещения холста нам нужно в методе onTouchEvent, в блоке перемещения объекта, вызывать метод checkGestures, который будет работать с касаниями. Вызов этого метода там уже есть, однако под комментарием. Раскомментируем его и пишем сам метод:

public void checkGestures(){
    Finger point = fingers.get(0);		// Получаем нужный палец
    position.x += point.Now.x - point.Before.x;	// Изменяем координаты
    position.y += point.Now.y - point.Before.y;
}

Можно запустить и поводить пальцем и если всё сделано правильно, то холст должен таскаться за ним.

Изменение расстояния до холста

Для реализации данного жеста нужно разделить всё содержимое метода на multitouch (когда пальцев более одного) и обычное касание. Если это multitouch, то мы будем постоянно проверять нынешнее расстояние между двумя пальцами и прошлое. Изменим содержимое метода checkGestures:

Finger point = fingers.get(0);
if(fingers.size() > 1){					// Multitouch
    // Расстояние между пальцами сейчас (now) и раньше (before)
    float now = checkDistance(point.Now, fingers.get(1).Now);
    float before = checkDistance(point.Before, fingers.get(1).Before);
    float oldSize = zoom;				// Запоминаем старый размер картинки
    zoom = Math.max(now - before + zoom, density * 25);	// Изменяем расстояние до холста
    position.x -= (zoom - oldSize) / 2;			// Изменяем положение картинки
    position.y -= (zoom - oldSize) / 2;
}else{							// Обычное касание
    position.x += point.Now.x - point.Before.x;
    position.y += point.Now.y - point.Before.y;
}

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

static float checkDistance(Point p1, Point p2){	// Функция вычисления расстояния между двумя точками
    return FloatMath.sqrt((p1.x - p2.x)*(p1.x - p2.x)+(p1.y - p2.y)*(p1.y - p2.y));
}

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

Изменение режима

Нам нужно создать переменную, которая будет отвечать за режим. Я назвал её drawingMode типа boolean. Для реализации долгого касания нам придётся использовать метод, который будет вызываться через время. Тут есть три варианта развития событий:

  1. мы пишем наш код в методе onDraw;
  2. мы пишем наш код в onTouchEvent;
  3. мы создаём таймер и пишем код в него;

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

TimerTask task = new TimerTask() {
    public void run() {
        
    }
};
Timer timer = new Timer();
timer.schedule(task, 0, 300);

Теперь нам в классе Finger надо создать переменную wasDown типа long, которая будет содержать в себе время нажатия. В конструкторе этой переменной надо задать значение System.currentTimeMillis(). Ещё нам надо добавить переменную startPoint типа Point, которая будет содержать в себе стартовую позицию пальца. Она должна содержать в себе то значение, что было передано в конструктор, или при первом вызове setNow. Так же нам надо создать переменную enabledLongTouch типа boolean, отображающую пригодность данного касания для реализуемого нами события. Нам надо постоянно проверять, не отошёл ли палец слишком далеко от старта. Этот функционал можно реализовать в setNow. В итоге должно получиться примерно так:

import android.graphics.Point;

public class Finger {
	public int ID;
	public Point Now;
	public Point Before;
	public long wasDown;
	boolean enabled = false;
	public boolean enabledLongTouch = true;
	Point startPoint;

	public Finger(int id, int x, int y){
	    wasDown = System.currentTimeMillis();
	    ID = id;
	    Now = Before = startPoint = new Point(x, y);
	}

	public void setNow(int x, int y){
	    if(!enabled){
	        enabled = true;
	        Now = Before = startPoint = new Point(x, y);
	    }
	    Before = Now;
	    Now = new Point(x, y);
	    if(ApplicationView.checkDistance(Now, startPoint) > ApplicationView.density * 25)
	        enabledLongTouch = false;
	}
}

Теперь в методе run нашего таймера мы можем проверять, долгое ли это касание:

if(fingers.size() > 0 && fingers.get(0).enabledLongTouch &&
    System.currentTimeMillis() - fingers.get(0).wasDown > 1000){
  fingers.get(0).enabledLongTouch = false;	// Деактивируем двойное касание
  drawingMode = !drawingMode;			// Меняем режим
}
Сделать лучше

Чтобы сделать длительное касание лучше — надо включить лёгкую вибрацию, когда оно активировалось. Для этого надо просто создать переменную vibrator типа Vibrator и в конструкторе установить ей значение следующим образом:

vibrator = (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE);

Важно: для работы с вибрацией в manifest'е должна быть следующая строка:

<uses-permission android:name="android.permission.VIBRATE"/>

Тогда в методе run, нашего таймера в конце проверки на длительное касание можно вписать:

vibrator.vibrate(80);

Рисование

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

Finger finger = fingers.get(0);
if(fingers.size() > 1){
    float now = checkDistance(finger.Now, fingers.get(1).Now);
    float before = checkDistance(finger.Before, fingers.get(1).Before);
    if(!drawingMode){
        float oldSize = zoom;
        zoom = Math.max(now - before + zoom, density * 25);
        position.x -= (zoom - oldSize) / 2;
        position.y -= (zoom - oldSize) / 2;
    }else paint.setStrokeWidth(paint.getStrokeWidth() + (now - before) / 8);
}else{
    if(!drawingMode){
        position.x += finger.Now.x - finger.Before.x;
        position.y += finger.Now.y - finger.Before.y;
    }else{
        float x1 = (finger.Before.x-position.x)*500/zoom;		// Вычисляем координаты
        float x2 = (finger.Now.x-position.x)*500/zoom;			// с учётом перемещения
        float y1 = (finger.Before.y-position.y)*500/zoom;		// и расстояния
        float y2 = (finger.Now.y-position.y)*500/zoom;
        canvas.drawLine(x1, y1, x2, y2, paint);				// Рисуем линию
        canvas.drawCircle(x1, y1, paint.getStrokeWidth() / 2, paint);	// Сглаживаем концы
        canvas.drawCircle(x2, y2, paint.getStrokeWidth() / 2, paint);
        cursor = finger.Now;
    }
}

В последней строке я задаю значение некому cursor. Это переменная типа Point, содержащая координаты курсора. Курсор нужен лишь для того, чтобы ориентироваться в размере кисти. Для отображения курсора в методе onDraw добавляем:

if(drawingMode){
    int old = paint.getColor();			// Сохраняем старый цвет
    paint.setColor(Color.GRAY);			// Курсор будет серого цвета
    canvas.drawCircle((cursor.x-position.x)*500/zoom, (cursor.y-position.y)*500/zoom,
        paint.getStrokeWidth() / 2, paint);	// Рисуем курсор в виде круга
    paint.setColor(old);			// Возвращаем старый цвет
}

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

Выбор цвета

Выбор цвета происходит после двойного касания по экрану. Для этого в ApplicationView нужно создать переменную, сохраняющую прошлое касание по экрану и переменную, сохраняющую координаты этого касания. Первая пусть будет называться lastTapTime типа long, а вторая — lastTapPosition типа Point. Тогда изменим метод onTouchEvent:

int id = event.getPointerId(event.getActionIndex());
int action = event.getActionMasked();
if(action  == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN)
    fingers.add(event.getActionIndex(), new Finger(id, (int)event.getX(), (int)event.getY()));
else if(action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP){
    Finger finger = fingers.get(event.getActionIndex());  // Получаем нужный палец
    // Если касание длилось менее 100мс, после предыдущего касания прошло не более 200мс,
    // если касание было после предыдущего и дистанция между двумя касаниями не более 25dp
    if(System.currentTimeMillis() - finger.wasDown < 100 && finger.wasDown - lastTapTime < 200 &&
       finger.wasDown - lastTapTime > 0 && checkDistance(finger.Now, lastTapPosition) < density * 25){
        // Тут произошло двойное касание
    }
    lastTapTime = System.currentTimeMillis();		// Сохраняем время последнего касания
    lastTapPosition = finger.Now;			// Сохраняем позицию последнего касания
    fingers.remove(fingers.get(event.getActionIndex()));
}else if(action == MotionEvent.ACTION_MOVE){
  for(int n = 0; n < fingers.size(); n++){
    fingers.get(n).setNow((int)event.getX(n), (int)event.getY(n));
  }
  checkGestures();
}
return true;

Нам осталось лишь реализовать диалог выбора цвета. Там, где происходит касание (помечено комментарием), пишем:

Builder builder = new AlertDialog.Builder(getContext());
String[] items = {"Красный", "Зелёный", "Синий", "Голубой", "Чёрный", "Белый", "Жёлый", "Розовый"};
final AlertDialog dialog = builder.setTitle("Выберите цвет кисти").setItems(items, new DialogInterface.OnClickListener() {
    public void onClick(DialogInterface dialog, int which) {
        switch (which) {
            case 0:				// Красный
              paint.setColor(Color.RED);
              break;
            case 1:				// Зелёный
              paint.setColor(Color.GREEN);
              break;
            case 2:				// Синий
              paint.setColor(Color.BLUE);
              break;
            case 3:				// Голубой
              paint.setColor(0xFF99CCFF);
              break;
            case 4:				// Чёрный
              paint.setColor(Color.BLACK);
              break;
            case 5:				// Белый
              paint.setColor(Color.WHITE);
              break;
            case 6:				// Жёлый
              paint.setColor(Color.YELLOW);
              break;
            case 7:				// Розовый
              paint.setColor(0xFFFFCC99);
              break;
        }
    }
}).create();
dialog.show();

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

Заключение

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

Исходники проекта тут.

Использовал информацию с developer.android.com. На началах использовал эту статью.

Автор: suVrik

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js