четверг, 10 мая 2012 г.

Осваиваем Multitouch

Сегодня мы рассмотрим животрепещущую тему обработки событий MultiTouch-а. При грамотном подходе, он обеспечит нам не только набивший оскомину "зум двумя пальцами", но и заставит заиграть наши приложения новыми красками (ну или, во всяком случае, сделает их чуть более юзабельными).

Осваиваем Multitouch

Собственно говоря, никакой тайной магии надцатого уровня в мультитаче нет. Мы попрежнему будем отрабатывать события MotionEvent, например при помощи обработчика View.onTouchEvent. Просто есть несколько моментов, про которые следует помнить:

  1. Событие MotionEvent содержит описание нескольких точек касания, количество которых можно получить, используя метод getPointerCount(). Каждая точка касания получает уникальный идентификатор, который не изменяется при удалении точек из списка (после отработки события ACTION_POINTER_UP) и позволяет индивидуально отслеживать изменения по каждой точке касания.
  2. Все методы, возвращающие свойства точек касания (getX, getY, getPressure и getSize) принимают ИНДЕКС (не идентификатор!) точки касания. Методы возвращающие значения свойств события вцелом (getDownTime, getEventTime) никаких индексов не принимают и существуют в единственном экземпляре :)
  3.  Значение, возвращаемое методом getAction(), на самом деле составное и помимо собственно типа события содержит ИДЕНТИФИКАТОР точки касания. Выделить код события можно использовав битовую маску MotionEvent.ACTION_MASK, а значение идентификатора используя маску ACTION_POINTER_ID_MASK и сдвиг ACTION_POINTER_ID_SHIFT (которые зачем-то сделали depricated :( ).
  4. Пока getPointerCount() > 1, вместо кодов ACTION_DOWN и ACTION_UP (ACTION_CANCEL) мы получаем ACTION_POINTER_DOWN и ACTION_POINTER_UP, соответственно. ACTION_MOVE по прежнему приходит с тем-же кодом.
  5. Главная засада: В целях обеспечения обратной совместимости, при getPointerCount() = 1, action формируется не так как описано в п.3, а так как если бы это был SingleTouch :), то есть без идентификатора точки касания, что вносит некоторый сумбур в логику обработки.
Осмыслив все эти правила, приступаем к кодингу. Создадим Android-проект, обозвав его, например Multitouch (с воображением в части названий у нас совсем туго). В MotiontouchActivity, по привычке, пишем следующий код:

package com.WhiteRabbit.Multitouch;

...
public class MultitouchActivity extends Activity {
   
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        LinearLayout.LayoutParams containerParams =
                new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.WRAP_CONTENT, 0.0F);
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);
        root.setBackgroundColor(Color.LTGRAY);
        root.setLayoutParams(containerParams);
        View view = new MainView(this);
        root.addView(view);
        setContentView(root);
    }
}


Также, по привычке, создаем MainView, расширяющий функциональность View:

package com.WhiteRabbit.Multitouch;

...
public class MainView extends View {

    public MainView(Context context) {
        super(context);
    }

    private int maxX = 10000;
    private int maxY = 10000;
    private int szX;
    private int szY;

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if ((MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED) ||
            (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.UNSPECIFIED)) {
            setMeasuredDimension(maxX, maxY);
        } else {
            szX = MeasureSpec.getSize(widthMeasureSpec);
            szY = MeasureSpec.getSize(heightMeasureSpec);
            if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.AT_MOST) {
                if (szX > maxX) {
                    szX = maxX;
                }
            }
            if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST) {
                if (szY > maxY) {
                    szY = maxY;
                }
            }
            setMeasuredDimension(szX, szY);
        }
    }
}


Здесь мы объявляем, что, по возможности, хотим получить в свое распоряжение регион для рисования пикселов так 1000x1000, а когда вышестоящий Layout сообщает, что у него стока нет, соглашаемся с тем, что дают.
Далее, нам нужно как-то визуализировать Multitouch, для чего потребуется хранить набор точек, ассоциированных с идентификаторами точек касания. Создаем элементарный класс Point, для хранения требуемой нам информации:

package com.WhiteRabbit.Multitouch;

import android.graphics.Color;

public class Point {
   
    private float x;
    private float y;
    private int color = Color.WHITE;
   
    public Point(float x, float y) {
        this.x = x;
        this.y = y;
    }
   
    public void setColor(int color) {
        this.color = color;
    }
   
    public int getColor() {
        return color;
    }
   
    public void setXY(float x, float y) {
        this.x = x;
        this.y = y;
    }
   
    public float getX() {
        return x;
    }

    public float getY() {
        return y;
    }
}


и добавляем метод onDraw в наш View:

package com.WhiteRabbit.Multitouch;

...
public class MainView extends View {

    private Map<Integer, Point> points = new HashMap<Integer, Point>();

    ...
    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawColor(Color.BLACK);
        Paint p = new Paint();
        p.setAntiAlias(true);
        for (Point pt: points.values()) {
            p.setColor(pt.getColor());
            canvas.drawLine(0, pt.getY(), szX, pt.getY(), p);
            canvas.drawLine(pt.getX(), 0, pt.getX(), szY, p);
            canvas.drawCircle(pt.getX(), pt.getY(), 20, p);
        }
    }
}


После чего, при запуске, видим экран радикально черного цвета.
Хорошо-бы как-то начать обрабатывать события TouchPad-а:

package com.WhiteRabbit.Multitouch;

...
public class MainView extends View {
   
    private Map<Integer, Point> points = new HashMap<Integer, Point>();
    private Set<Integer> mfd = new HashSet<Integer>();

    @Override
    ...
    public boolean onTouchEvent(MotionEvent event) {
        mfd.clear();
        for (Integer i: points.keySet()) {
            mfd.add(i);
        }
        int cnt = event.getPointerCount();
        for (int i = 0; i < cnt; i++) {
            Integer id = event.getPointerId(i);
            Point pt = points.get(id);
            if (pt == null) {
                pt = new Point(event.getX(i), event.getY(i));
                points.put(id, pt);
            } else {
                pt.setXY(event.getX(i), event.getY(i));
                mfd.remove(id);
            }
        }
        for (Integer i: mfd) {
            points.remove(i);
        }
        invalidate();
        return true;
    }
}


Этот, не претендующий на особую гениальность, код делает следующее:

  1. Очищает список точек подлежащих удалению, после чего, вносит в него все имеющиеся точки
  2. Для каждой точки касания, выполняет ее поиск по ID и, если находит, изменяет ее координаты, удаляя ее из списка точек, подлежащих удалению. В случае если точка не найдена, она создается и добавляется в массив известных нам точек
  3. Удаляет все точки, идентификаторы которых содержаться в списке точек, подлежащих удалению
  4. Инвалидирует View, инициируя его перерисовку
Запускаем этот код на выполнение и наблюдаем радостно бегающий по экрану кругляшочек :) Плохо только, что при завершении касания, кружочек не исчезает, а останавливается на месте как вкопанный :(






что, впрочем, не удивительно, поскольку мы ДАЖЕ НЕ ВЫЗЫВАЕМ getAction. Исправляем это досадное недоразумение, попутно добавляя в мир красок:

package com.WhiteRabbit.Multitouch;

...
public class MainView extends View {

    private Map<Integer, Point> points = new HashMap<Integer, Point>();
    private Set<Integer> mfd = new HashSet<Integer>();
    private Map<Integer, Integer> colors = new HashMap<Integer, Integer>();

    public MainView(Context context) {
        super(context);
        colors.put(0, Color.WHITE);
        colors.put(1, Color.GREEN);
        colors.put(2, Color.BLUE);
        colors.put(3, Color.RED);
        colors.put(4, Color.YELLOW);
    }

    ...
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mfd.clear();
        for (Integer i: points.keySet()) {
            mfd.add(i);
        }
        int cnt = event.getPointerCount();
        for (int i = 0; i < cnt; i++) {
            Integer id = event.getPointerId(i);
            Point pt = points.get(id);
            if (pt == null) {
                pt = new Point(event.getX(i), event.getY(i));
                Integer color = colors.get(id % colors.size());
                if (color != null) {
                    pt.setColor(color);
                }
                points.put(id, pt);
            } else {
                pt.setXY(event.getX(i), event.getY(i));
                mfd.remove(id);
            }
        }
        if (cnt == 1) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    mfd.add(event.getPointerId(0));
                    break;
            }
        }
        for (Integer i: mfd) {
            points.remove(i);
        }
        invalidate();
        return true;
    }
}



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

Комментариев нет:

Отправить комментарий