Вступление
Приветствую, коллеги!
Достаточно часто при разработке мультимедийных Андроид-приложений (далее просто “приложений”) мы сталкиваемся с задачей создания собственных компонентов, не предусмотренных в системе. Это могут быть всяческие ручки-переключатели, визуализаторы спектра и т. п. Некоторые из них могут быть получены простой заменой графического ресурса, поворотом канвы на 90 градусов и т. п. Но, иногда, все же приходится делать что-то свое “с нуля”.
В этой статье я собираюсь рассказать о создании компонента — простой клавиатуры фортепиано, при помощи наследования от класса View и реализации всех внутренностей “самостоятельно”. Почему в кавычках — увидите дальше.
В серии статей я постараюсь осветить такие вопросы, как:
- отрисовка компонента
- добавление скроллинга с использованием стандартных скроллбаров
- взаимодействие, использование селекторов для клавиш
- Сохранение состояние компонента при повороте экрана
- добавление подсветки при оверскролле
- передача параметры в XML
- pinch zoom
Первая статья будет о первых трёх пунктах.
Если Вам интересны эти темы, добро пожаловать под кат.
Предыстория
Когда-то давно, когда я писал свое музыкальное приложение, о котором рассказывал в предыдущих статьях, я столкнулся с необходимостью запилить фортепиано. Поскольку это было мое совсем первое андроид приложение, да и андроид тогда был совсем не такой, как сейчас, в первой версии я совершил далеко не одно извращение, чтобы сделать более менее рабочий компонент. Я держал в памяти гигантский Bitmap, слепленный из 4 картинок с октавой, для скроллинга у меня был отдельный поток, который циклически уменьшал скорость скроллинга через заданный интервал и засыпал, до получения следующей задачи. Интерактивности было ноль.
Сейчас, спустя некоторое время, я пишу проект, во многом похожий на свой первый, но на совершенно другом уровне качества и функциональности, и мне опять нужно фортепиано. Вот о нем я и буду рассказывать.
Разработка компонента
View или SurfaceView?
Rule of thumb, который я для себя вывел — это стараться использовать View где только это возможно, и избегать SurfaceView, если только не нужно иметь компонент, который постоянно рисует какое-то изменяющееся состояние с более менее сложной графикой (игра, видео). Во всех остальных случаях View — Ваш выбор. Также нужно учесть, что используя SurfaceView, мы лишаем себя возможности в будущем анимировать этот компонент внутри вашего layout.
Начальный этап
Так, приступим, первое, что мы сделаем — создадим новый класс, наследник android.view.View. Назовем его PianoView.
public class PianoView extends View {
public PianoView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
}
Как видим, у нас есть конструктор, в который нам передается контекст и набор атрибутов. В методе onDraw мы будем рисовать наш компонент. Этот метод вызывается каждый раз, когда становится необходимо перерисовать view, например, на каждый кадр анимации.
Отрисовка клавиатуры. Графические ресурсы.
Для отрисовки клавиш я буду использовать стандартные средства Android: selector, nine-patch drawable.
Для белых клавиш я заготовил следующие 9-patch изображения. Выделенное состояние я решил сделать при помощи стандартной для Holo голубой подсветки.
Для черных:
И для каждой из них создал селектор:
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/white_key_pressed" android:state_pressed="true"></item>
<item android:drawable="@drawable/white_key"></item>
</selector>
Всё, что теперь осталось, это получить эти Drawables в коде при помощи context.getResourses.getDrawable();
Отрисовка клавиатуры. Код
Итак, чтобы держать код компонента чистым, я вынес всю отрисовку клавиатуры и хранение необходимой для этого информации в класс Keyboard. В нашем onDraw
я буду просто вызывать его метод:
protected void onDraw(Canvas canvas) {
if (measurementChanged) {
measurementChanged = false;
keyboard.initializeInstrument(getMeasuredHeight(), getContext());
instrumentWidth = keyboard.getWidth();
}
keyboard.draw(canvas);
}
Я не буду очень подробно рассказывать, как происходит отрисовка фортепиано просто потому, что это займет слишком много места нудным текстом и кодом. Все, кто захотят рассмотреть детали, могут взять мой код и посмотреть. Здесь я объясню лишь принцип.
Первый этап — инициализация. Инициализация включает в себя рассчёт массива клавиш.
Key[] keysArray;
Это — наша модель. Каждый элемент — клавиша. Клавише известны свои координаты (в системе координат компонента) и размеры, черная она или белая, а так же нажата она в данный момент или нет.
class Key {
float startX;
float endX;
float startY;
float endY;
int midiCode;
boolean black;
boolean pressed = false;
void setBounds(float startX, float endX, float startY, float endY) {
this.startX = startX;
this.startY = startY;
this.endX = endX;
this.endY = endY;
}
boolean containsPoint(float x, float y) {
return startX <= x && endX > x && startY <= y && endY > y;
}
}
Этот процесс происходит при каждом изменении физического размера нашего компонента происходит инициализация нашей клавиатуры (за это отвечает флаг measurementChanged, который мы просто выставляем в true в методе onMeasure). Таким образом мы не будем рассчитывать положений клавиши каждый раз при отрисовке.
public void initializeInstrument(float measuredHeight, Context context) {
whiteKeyWidth = Math.round(measuredHeight / WHITE_KEY_ASPECT_RATIO);
octaveWidth = whiteKeyWidth * 7;
int blackHalfWidth = octaveWidth / 20;
blackKeyHeight = Math.round(measuredHeight / BLACK_KEY_HEIGHT_PERCENT);
keysArray = new Key[KEYS_IN_OCTAVE * OCTAVES];
int whiteIndex = 0;
int blackIndex = 0;
for (int i = 0; i < KEYS_IN_OCTAVE; i++) {
Key key = new Key();
if (isWhite(i)) {
key.black = false;
key.setBounds(whiteKeyWidth * whiteIndex, whiteKeyWidth * whiteIndex + whiteKeyWidth, 0, measuredHeight);
whiteIndex++;
} else {
key.black = true;
int indexDisplacement = i == 1 || i == 3 ? 1 : 2;
key.setBounds(whiteKeyWidth * (blackIndex + indexDisplacement) - blackHalfWidth, whiteKeyWidth
* (blackIndex + indexDisplacement) + blackHalfWidth, 0, blackKeyHeight);
blackIndex++;
}
key.midiCode = START_MIDI_CODE + i;
keysArray[i] = key;
}
for (int i = KEYS_IN_OCTAVE; i < KEYS_IN_OCTAVE * OCTAVES; i++) {
Key firstOctaveKey = keysArray[i % KEYS_IN_OCTAVE];
Key key = firstOctaveKey.clone();
key.startX += (i / KEYS_IN_OCTAVE) * octaveWidth;
key.endX += (i / KEYS_IN_OCTAVE) * octaveWidth;
key.midiCode = START_MIDI_CODE + i;
keysArray[i] = key;
}
}
Здесь мы рассчитываем ширину клавиш на основе высоты компонента и строим массив клавиш. Сначала строится первая октава, затем она клонируется и смещается нужно число раз для получения остальных. Также у нас каждой клавише будет соответствовать MIDI код, по которому можно было бы проигрывать звук. Midi коды имеют сквозную нумерацию. У нас код первой клавиши будет START_MIDI_CODE. Код любой клавиши вычисляется сложением стартового код и индекса клавиши в массиве.
Далее — отрисовка клавиш. В цикле по всему массиву клавиш мы рисуем следующим образом:
private void drawSingleKey(Canvas canvas, Key key, int firstVisibleKey, int lastVisibleKey) {
Drawable drawable = key.black ? blackKeyDrawable : whiteKeyDrawable;
drawable.setState(new int[] { key.pressed ? android.R.attr.state_pressed : -android.R.attr.state_pressed });
drawable.setBounds((int) key.startX, (int) key.startY, (int) key.endX, (int) key.endY);
drawable.draw(canvas);
}
Отрисовка происходит в 2 этапа, т. к. сначала нам необходимо нарисовать белые клавиши, затем черные, чтобы не возникало наложений. Мы могли бы этого избежать, если бы 9-патчи для клавиш сделали не прямоугольными, с вырезами. Более того, это могло бы нам помочь удалить лишние перерисовки пикселей, но для целей этой статьи давайте держать все максимально примитивным.
Готово, наш инструмент успешно рисуется:
Неплохо. Разумеется, при клике по клавишам сейчас ничего не происходит. Давайте это исправим.
Взаимодействие с клавишами
Для взаимодействиями с пользовательскими нажатиями обычно переопределяют метод onTouchEvent и в нем определяют, что сделал пользователь — дотронулся пальцем, выполнил жест, двойное прикосновение, длительное прикосновение и т. п. К счастью, в большинстве случаев мы с вами избавлены от подобных неприятностей.
Мы воспользуемся классом GestureDetector, любезно предоставляемым платформой с ее первых дней.
Давайте добавим в наш инструмент поле private GestureDetector gestureDetector;
и инициализируем его
private void init() {
if (!isInEditMode()) {
gestureDetector = new GestureDetector(getContext(), gestureListener);
}
}
В конструктор мы передаем listener gestureListener, это то место, где мы получаем коллбэки от детектора при обнаружении каких-то жестов.
private OnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() {
public boolean onDown(MotionEvent e) {
if (keyboard.touchItem(e.getX(), e.getY())) {
invalidate();
}
return true;
}
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
resetTouchFeedback();
return true;
}
public boolean onSingleTapUp(MotionEvent e) {
resetTouchFeedback();
return super.onSingleTapUp(e);
}
};
Итак, алгоритм работы прост, в методе onDown мы передаем координаты нажатия нашей клавиатуре, где выполняем поиск нажатой клавиши (метод touchItem по координатам клавиши рассчитывает ее индекс без необходимости сканирования всего массива). Если клавиша найдена, она помечается как нажатая, и мы вызываем invalidate, что приводит к перерисовке.
В остальных методах мы сбрасываем нажатую клавишу (при скролле, пальце вверх и т. п.). Сделано это по аналогии, например, с ListView, когда мы начинаем скроллить лист, выделение сбрасывается.
Следующий шаг — подключить детектор к нашему компоненту. Делается это очень просто:
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_CANCEL) {
resetTouchFeedback();
}
return super.onTouchEvent(event) || gestureDetector.onTouchEvent(event);
}
Обратите внимание, что мы также проверяем, не является ли действие ACTION_CANCEL
, и, в этом случае, тоже сбрасываем выделение, т. к. GestureDetector никак не реагирует на него, и если оно вдруг произошло, мы рискуем остаться с навсегда выделенной клавишей.
Проверяем:
Ура, теперь оно выглядит чуть более живым. Но мы по-прежнему видим только часть клавиатуры… Не беда, давайте прикрутим скроллинг.
Добавление скроллинга к компоненту
Итак, рассмотрим в первую очередь то, как мы будем сдвигать наше содержимое. Самый простой способ — ничего не сдвигать, а рисовать так же, но, двигать саму канву. Класс Canvas позволяет выполнять афинные преобразования над собой.
Давайте добавим простое поле
private int xOffset;
в наш класс.
Теперь расширим наш метод onDraw сдеующей конструкцией:
protected void onDraw(Canvas canvas) {
if (measurementChanged) {
measurementChanged = false;
keyboard.initializeInstrument(getMeasuredHeight(), getContext());
instrumentWidth = keyboard.getWidth();
}
canvas.save();
canvas.translate(-xOffset, 0);
keyboard.updateBounds(xOffset, canvasWidth + xOffset);
keyboard.draw(canvas);
canvas.restore();
}
Давайте рассмотрим, что мы сделали:
- canvas.save() — запоминает текущее состояние канвы. Создает своего рода контрольную точку
- canvas.translate() — смещает канву на заданное расстояние
- canvas.restore() — восстанавливат исходное состояние канвы.
Также мы добавили в наш класс Keyboard метод updateBounds. Он позволяет передавать левую и правую видимую границу, чтобы мы не рисовали клавиши, выходящие за рамки экрана. Оптимизация такая.
Теперь, когда мы добавили поддержку скроллинга на этапе рисования, добавим его в пользовательское взаимодействие — GestureDetector. Модифицируем onScroll:
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
resetTouchFeedback();
xOffset += distanceX;
if (xOffset < 0) {
xOffset = 0;
}
if (xOffset > instrumentWidth - getMeasuredWidth()) {
xOffset = instrumentWidth - getMeasuredWidth();
}
invalidate();
return true;
}
Готово, теперь, когда мы двигаем пальцем по нашей клавиатуре, она мило скроллится, не выходя за границу клавиатуры. Но нам этого мало. Мы хотим, чтобы можно было дернуть пальцем и запустить клавиатуру мотаться по-инерции — сделать fling.
К счастью, нам не придется рассчитывать скорость пальца и расстояние, пройденное им на экране. Все это за нас делает наш любимый GestureDetector. Нам лишь нужно переопределить метод onFling. Он нам поможет узнать, что пользователь совершил fling, а так же его начальные характеристики. А вот чтобы отслеживать состояние прокрутки, интерполировать между начальной и конечно точками нам понадобится другой компонент — Scroller, а точнее, его брат — OverScroller (мы же хотим в будущем добавить эффекты свечения). Scroller — крайне полезный компонент для любых видов скроллинга в Android, используется в бесчисленном количестве внутренних компонентов, и реализует стандартное поведение скроллинга.
Добавим наш скроллер:
private OverScroller scroller;
и инициализируем его в конструкторе компонента.
Далее, модифицируем GestureDetector следующим образом:
private OnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() {
public boolean onDown(MotionEvent e) {
scroller.forceFinished(true);
if (keyboard.touchItem(e.getX() / scaleX + xOffset, e.getY())) {
invalidate();
}
return true;
}
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
scroller.fling(xOffset, 0, (int) -velocityX, 0, 0, instrumentWidth - getMeasuredWidth(), 0, 0);
return true;
}
// ...
};
Как видно из кода, мы запускаем скроллер с начальным оффсетом и скоростью, указываем ему минимум и максимум скроллинга.
Следующий шаг — onDraw
protected void onDraw(Canvas canvas) {
if (scroller.computeScrollOffset()) {
xOffset = scroller.getCurrX();
}
if (measurementChanged) {
measurementChanged = false;
keyboard.initializeInstrument(getMeasuredHeight(), getContext());
instrumentWidth = keyboard.getWidth();
}
canvas.save();
canvas.scale(scaleX, 1.0f);
canvas.translate(xOffset , 0);
keyboard.updateBounds(xOffset , canvasWidth + xOffset );
keyboard.draw(canvas);
canvas.restore();
if (!scroller.isFinished()) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
Что изменилось здесь? На каждый кадр анимации мы вызываем scroller.computeScrollOffset(), этот метод возвращает истину в том случае, если скроллер анимируется, тогда мы получаем текущее значение переменной xOffset.
Поскольку анимация подразумевает серию перерисовок — в конце метода мы проверяем, закончил ли анимироваться скроллер, и, если нет, назначаем следующий кадр анимации. Таким образом до тех пор, пока скроллер не закончит работу, или не будет остановлен силой, метод onDraw будет вызываться настолько часто, насколько это возможно и рисовать ваш компонент.
Теперь наш компонент симпатично скроллится и поддерживает fling. Но чего-то не хватает, правда? Не хватает стандартных скроллбаров внизу. Не проблема.
Добавление стандартных сколлбаров
Добавление стандартных скроллбаров — это как заклинание, тут нет каких-то особых тайн, просто последовательность действий.
Во-первых, нужно сказать нашему компоненту, что он поддерживает все стандартные атрибуты скроллинга. Для этого нужно в нашем values каталоге создать файл attrs.xml, в который добавить следующее определение:
<declare-styleable name="View">
<attr name="android:fadeScrollbars" />
<attr name="android:scrollbarAlwaysDrawHorizontalTrack" />
<attr name="android:scrollbarAlwaysDrawVerticalTrack" />
<attr name="android:scrollbarDefaultDelayBeforeFade" />
<attr name="android:scrollbarFadeDuration" />
<attr name="android:scrollbarSize" />
<attr name="android:scrollbarStyle" />
<attr name="android:scrollbarThumbHorizontal" />
<attr name="android:scrollbarThumbVertical" />
<attr name="android:scrollbarTrackHorizontal" />
<attr name="android:scrollbarTrackVertical" />
<attr name="android:scrollbars" />
</declare-styleable>
Теперь, в конструктор добавим:
setVerticalScrollBarEnabled(false);
setHorizontalScrollBarEnabled(true);
TypedArray a = context.obtainStyledAttributes(R.styleable.View);
initializeScrollbars(a);
a.recycle();
Следующий шаг — переопределить три простейших метода, в которых мы будем управлять размерами и позициями скроллбаров:
protected int computeHorizontalScrollExtent() {
return canvasWidth;
}
@Override
protected int computeHorizontalScrollOffset() {
return xOffset;
}
@Override
protected int computeHorizontalScrollRange() {
return instrumentWidth;
}
Код говорит сам за себя — в первом методе мы указываем ширину нашего компонента, во втором, текущий оффсет скроллинга, в третьем размер всей клавиатуры (который выходит за границы экрана). Теперь осталось эти скроллбары “будить” когда нужно. Базовый класс View предоставляет для этого специальный метод awakenScrollBars(). Добавим следующие строки:
if (!awakenScrollBars()) {
invalidate();
}
в методы onScroll и onFling нашего GestureDetectorListener’а.
Результат — стандартные скроллбары радуют наш глаз.
Заключение
Итак, в этой части мы рассмотрели создание компонента, отрисовку при помощи Drawables, различные состояния drawables, визуальный фидбек при взаимодействии, скроллинг, fling-жест, создание скроллбаров.
Статья получилась достаточно длинной, поэтому я решил разбить на несколько частей.
В следующей части расскажу про:
- Сохранение состояние компонента при повороте экрана
- добавление подсветки при оверскролле
- передача параметров в XML
- pinch zoom
Также у меня есть планы на третью часть, где я расскажу про оптимизацию, про разницу между использованием готовых битмапов и рисованием на канве (drawCircle, drawText, etc), про избавление от перерисовки и т. п. Третью статью я напишу только, если первые две понравятся читателям и будет интерес в появлении третьей :)
Исходники готового проекта для этой серии статей находятся на моем github по адресу: goo.gl/VDeuw
Автор: evilduck