Вступление
Вновь приветствую, коллеги.
В своей предыдущей статье я рассказал об основах создания кастомного компонента на примере простенькой, но симпатичной фортепианной клавиатуры.
В этой статье под катом мы продолжим накручивать свистелки и... дополнительные возможности нашей клавиатуре. На повестке дня:
- Сохранение состояния компонента при повороте экрана
- добавление подсветки при оверскролле
- передача параметров в XML
- Мультитач зуммирование
Сохранение состояния компонента при повороте экрана
Сейчас мы можем обнаружить такое поведение у нашего компонента. Если мы проскроллим на любую позицию, затем повернем экран, скролл будет на нуле. Очевидно, это происходит потому, что при повороте экрана Activity пересоздается, соответственно, пересоздается и View.
Первое, что здесь приходит на ум, это использовать метод onSaveInstanceState()
нашей активити, вытянуть значение скролла из компонента и сохранить, а позже, при повторном создании, установить скролл в наш компонент. И это будет работать, но это с трудом можно назвать верным подходом. Представим, что у нас не один параметр, который нужно сохранить, а десять, или не один компонент, а десять… с десятью параметрами.
К счастью, внутренние механизмы Android уже предусматривают автоматическое сохранение состояние всех компонентов, имеющих идентификатор. Ведь вам же не надо ничего делать, чтобы сохранился скролл у ListView при повороте, верно? Вот и мы воспользуемся тем, что уже есть во View и будем управлять сохранением состояние компонента изнутри, а не снаружи.
А делается это удивительно просто. Нам надо переопределить методы класса View onSaveInstanceState()
и onRestoreInstanceState(Parcelable state)
. Однако, тут есть маленькое отличие от аналогов в активити. Там мы имеем дело с Bundle
, здесь у нас Parcelable. Нам нужно сделать свой собственный Parcelable класс, который обязательно должен быть наследником android.view.View.BaseSavedState
.
public static class SavedState extends BaseSavedState {
int xOffset;
int instrumentWidth; // Зачем я сохраняю это поле станет понятно чуть ниже :)
SavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeInt(xOffset);
out.writeInt(instrumentWidth);
}
public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() {
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
private SavedState(Parcel in) {
super(in);
xOffset = in.readInt();
instrumentWidth = in.readInt();
}
}
Вот так он выглядит в нашем случае. Теперь осталось только его использовать:
@Override
protected Parcelable onSaveInstanceState() {
SavedState st = new SavedState(super.onSaveInstanceState());
st.xOffset = xOffset;
st.instrumentWidth = xOffset;
return st;
}
protected void onRestoreInstanceState(Parcelable state) {
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
SavedState ss = (SavedState) state;
super.onRestoreInstanceState(ss.getSuperState());
xOffset = ss.xOffset;
xOffset = ss.instrumentWidth;
};
Готово. Если вы теперь повернете экран, скролл не потеряется. Но есть еще одна маленькая косметическая деталь, которую я бы добавил. В нашем компоненте при повороте есть большая вероятность, что ширина клавиатуры изменится, так как у нас изменится его высота (меньше в ландшафном режиме), клавиши станут уже или шире. Поэтому нашу статическую величину xOffset, загруженную после пересоздания, нужно подкорректировать. Делается это очень просто. Во-первых, будем сохранять старую ширину нашей клавиатуры при пересоздании. Именно поэтому в коде выше я также сохраняю поле instrumentWidth в нашем SavedState.
В нашем onDraw() там, где мы инициализируем компонент после изменения его размеров, добавим такие модификации:
if (measurementChanged) {
measurementChanged = false;
keyboard.initializeInstrument(getMeasuredHeight(), getContext());
float oldInstrumentWidth = instrumentWidth;
instrumentWidth = keyboard.getWidth();
float ratio = (float) instrumentWidth / oldInstrumentWidth; // Рассчитываем отношение новой длины к старой и выравниваем значение
xOffset = (int) (xOffset * ratio);
}
Теперь если наш скролл до поворота был, например, на начале второй октавы, он там и останется после поворота.
Итак, мы успешно сохраняем состояние нашего компонента при повороте. Теперь добавим еще визуальной красоты, а именно эффект свечения по бокам при достижении скроллом конца инструмента.
Эффект свечения для оверскролла
Как и следовало ожидать, для нас уже сделан готовый компонент, который эти самые края умеет правильно рисовать, нам же нужно его правильно воткнуть и вуаля. Этот компонент называется EdgeEffect. Но мы его использовать не будем, т. к. он появился только в ICS. Мы же воспользуемся классом EdgeEffectCompat, который доступен compatibility library и является оберткой над EdgeEffect. К сожалению, это означает, что в версиях, где эффект не поддерживается, этот класс будет выполнять роль простой заглушки и ничего не будет происходить.
Итак, нам понадобится два экземпляра — для левого и правого края.
private EdgeEffectCompat leftEdgeEffect;
private EdgeEffectCompat rightEdgeEffect;
Инициализируются они простым образом в activity.
Теперь, отрисовка. Как и скроллбары, рисуется эффект поверх всего контента, поэтому, разумно вынести его в метод draw(). Тут я честно признаюсь, что то, что идет дальше сделано по аналогии с тем, как это реализовано в классе ViewPager. В общем-то, мы можем с точно таким же результатом нарисовать эффект и в onDraw, но, в общем-то, это на мой взгляд даже красивее, т. к. в onDraw мы рисуем свое, в draw — системные эффекты.
public void draw(Canvas canvas) {
super.draw(canvas);
boolean needsInvalidate = false;
final int overScrollMode = ViewCompat.getOverScrollMode(this);
if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS
|| (overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS)) {
if (!leftEdgeEffect.isFinished()) {
final int restoreCount = canvas.save();
final int height = getHeight() - getPaddingTop() - getPaddingBottom();
final int width = getWidth();
canvas.rotate(270);
canvas.translate(-height + getPaddingTop(), 0);
leftEdgeEffect.setSize(height, width);
needsInvalidate |= leftEdgeEffect.draw(canvas);
canvas.restoreToCount(restoreCount);
}
if (!rightEdgeEffect.isFinished()) {
final int restoreCount = canvas.save();
final int width = getWidth();
final int height = getHeight() - getPaddingTop() - getPaddingBottom();
canvas.rotate(90);
canvas.translate(-getPaddingTop(), -width);
rightEdgeEffect.setSize(height, width);
needsInvalidate |= rightEdgeEffect.draw(canvas);
canvas.restoreToCount(restoreCount);
}
} else {
leftEdgeEffect.finish();
rightEdgeEffect.finish();
}
if (needsInvalidate) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
Итак, что здесь происходит. Сначала мы проверяем, поддерживает ли наш компонент оверскролл, и, если да, рисуем последовательно оба эффекта. Дело в том, что EdgeEffect не поддерживает направления, в котором он рисуется, поэтому, чтобы корректно отобразить эффект слева или справа, нам необходимо правильным образом повернуть нашу канву.
if (!leftEdgeEffect.isFinished()) {
final int restoreCount = canvas.save();
final int height = getHeight() - getPaddingTop() - getPaddingBottom();
final int width = getWidth();
canvas.rotate(270);
canvas.translate(-height + getPaddingTop(), 0);
leftEdgeEffect.setSize(height, width);
needsInvalidate |= leftEdgeEffect.draw(canvas);
canvas.restoreToCount(restoreCount);
}
Здесь мы последовательно:
- Сохраняем канву, используя canvas.save()
- вычисляем ее высоту минус паддинги и выставляем размер эффекта используя метод leftEdgeEffect.setSize(height, width);
- Поворачиваем канву на 270 градусов и правильно ее позиционируем.
Я хочу представить это более наглядно. Давайте уберем трансформации канвы:
вот так выглядит эффект по-умолчанию. Всегда вниз. Если мы добавим только поворот на 270, мы увидим, что эффект рисуется в верном направлении, но в самом верхнем углу канвы.
И лишь после добавления смещения канвы мы видим, что эффект на месте.
Но это я забежал вперед, т. к. пока у нас эффект хоть и рисуется, но он не активируется при скролле.
Вот здесь нам нужно вернуться к нашему детектору жестов и модифицировать onScroll.
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
resetTouchFeedback();
xOffset += distanceX;
if (xOffset < 0) {
leftEdgeEffect.onPull(distanceX / (float) getMeasuredWidth());
}
if (xOffset > instrumentWidth - getMeasuredWidth()) {
rightEdgeEffect.onPull(distanceX / (float) getMeasuredWidth());
}
if (!awakenScrollBars()) {
invalidate();
}
return true;
}
Во-первых, мы перестали ограничивать xOffset границами, как мы делали раньше, плюс, мы вызываем метод onPull у соответствующего эффекта.
Важно тут отметить, что раз мы перестали ограничивать здесь переменную xOffset, нам нужно делать это в других местах, где это может вызвать ошибку, например в методе onDraw и computeHorizontalScrollOffset(). Возможно, есть более красивый способ делать это, но мне он пока не пришел в голову.
Последний штрих, который мы хотим добавить — это поглощение скорости прокрутки при достижении края нашим свечением. Для этого добавим в наш onDraw
следующий код:
if (scroller.isOverScrolled()) {
if (xOffset < 0) {
leftEdgeEffect.onAbsorb(getCurrentVelocity());
} else {
rightEdgeEffect.onAbsorb(getCurrentVelocity());
}
}
// ...
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
private int getCurrentVelocity() {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return (int) scroller.getCurrVelocity();
}
return 0;
}
К сожалению, метод Scroller.getCurrVelocity()
доступен нам только начиная с ICS, поэтому я пометил метод как нацеленный на API 14+. Да, это далеко от идеала, но, опять же, это то, что у нас есть.
Теперь, при попытке скроллинга за пределы View, мы получаем красивое свечение в стиле Holo.
Добавление параметров компонента в XML
Перед тем, как приступить непосредственно к добавлению параметров, я добавлю маленькую фичу в наш компонент. Пусть, при клике по клавише у нас отобразится кружочек с именем этой ноты.
Делается это тривиально. Я завел массив объектов Note
private ArrayList<Note> notesToDraw = new ArrayList<Note>();
и каждый раз, когда кликаю по клавише, я определяю ноту по миди коду клавиши и добавляю ее в массив. Подробно как это происходит можно увидеть в коде на гитхабе.
Теперь в класс Keyboard я добавляю метод drawOverlays(ArrayList)
public void drawOverlays(ArrayList<Note> notes, Canvas canvas) {
int firstVisibleKey = getFirstVisibleKey();
int lastVisibleKey = getLastVisibleKey();
for (Note note : notes) {
int midiCode = note.getMidiCode();
if (midiCode >= firstVisibleKey && midiCode <= lastVisibleKey) {
drawNoteFromMidi(canvas, note, midiCode, false);
}
}
}
private void drawNoteFromMidi(Canvas canvas, Note note, int midiCode, boolean replica) {
Key key = keysArray[midiCode - Keyboard.START_MIDI_CODE];
overlayTextPaint.setColor(circleColor);
canvas.drawCircle(key.getOverlayPivotX(), key.getOverlayPivotY(), overlayCircleRadius, overlayTextPaint);
String name = note.toString();
overlayTextPaint.getTextBounds(name, 0, name.length(), bounds);
int width = bounds.right - bounds.left;
int height = bounds.bottom - bounds.top;
overlayTextPaint.setColor(Color.BLACK);
canvas.drawText(name, key.getOverlayPivotX() - width / 2, key.getOverlayPivotY() + height / 2, overlayTextPaint);
}
… и рисую ноту как кружок и текст. Как вы наверное уже догадались, я сделал это для того, чтобы мы могли настраивать параметры этого кружка и текста через XML.
Давайте вынесем настройку цвета, радиуса кружка и размера текста в XML атрибуты нашего View. Для начала, нужно их объявить. Для этого используется тэг <declare-styleable>.
<declare-styleable name="PianoView">
<attr name="overlay_color" format="color"></attr>
<attr name="overlay_circle_radius" format="dimension"></attr>
<attr name="overlay_circle_text_size" format="dimension"></attr>
</declare-styleable>
добавим это определение в attrs.xml. Теперь, мы должны загрузить их в нашем компоненте. В конструкторе добавляем следующий код
TypedArray pianoAttrs = context.obtainStyledAttributes(attrs, R.styleable.PianoView);
int circleColor;
float circleRadius;
float circleTextSize;
try {
circleColor = pianoAttrs.getColor(R.styleable.PianoView_overlay_color, Color.GREEN);
circleRadius = pianoAttrs.getDimension(R.styleable.PianoView_overlay_circle_radius, TypedValue
.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24, context.getResources().getDisplayMetrics()));
circleTextSize = pianoAttrs.getDimension(R.styleable.PianoView_overlay_circle_text_size, TypedValue
.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12, context.getResources().getDisplayMetrics()));
} finally {
pianoAttrs.recycle();
}
с помощью метода getXXX мы получаем значение атрибута типа XXX. Если атрибут отсутсвует, второй аргумент определяет значение по-умолчанию.
Осталось теперь указать их в нашей разметке. Для этого сначала нужно объявить namespace в заголовке: xmlns:piano="http://schemas.android.com/apk/res-auto"
, после чего получим такой файл с разметкой:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:piano="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".PianoDemoActivity" >
<com.evilduck.piano.views.instrument.PianoView
android:id="@+id/instrument_view"
android:layout_width="match_parent"
android:layout_height="300dip"
piano:overlay_circle_radius="18dip"
piano:overlay_circle_text_size="18sp"
piano:overlay_color="#00FF00" />
</RelativeLayout>
Таким образом мы можем делать наши компоненты такими же гибкими, как и стандартные компонеты платформы.
Поддержка мультитач-зума
Последнее, о чем я хотел рассказать сегодня, это базовая поддержка зумирования при помощи мультитач жеста.
Для создания эффекта зума мы воспользуемся компонентом ScaleGestureDetector. Он абсолютно аналогичен GestureDetector в плане использования в коде, отличается, только передаваемый в него listener:
private OnScaleGestureListener scaleGestureListener = new OnScaleGestureListener() {
@Override
public void onScaleEnd(ScaleGestureDetector detector) {
}
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
return true;
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
scaleX *= detector.getScaleFactor();
if (scaleX < 1) {
scaleX = 1;
}
if (scaleX > 2) {
scaleX = 2;
}
ViewCompat.postInvalidateOnAnimation(PianoView.this);
return true;
}
};
мы завели переменную scaleX, которая будет выражать уровень нашего зума и ограничим ее 1 и 2.
Другой вопрос, как мы будем зуммировать нашу клавиатуру. Для этой статьи я выбрал наипростейший вариант — просто преобразовать канву. Да, это не идеально, и приведет к искажению рисунка. Правильно — на основе значения scaleX увеличивать ширину клавиш, радиус кружков и текста. Это специфично именно для моей задачи и не имеет отношения к зуму в целом. Поэтому мы просто отмасштабируем канву:
canvas.save();
// задаем масштаб канвы
canvas.scale(scaleX, 1.0f);
canvas.translate(-localXOffset, 0);
keyboard.updateBounds(localXOffset, canvasWidth + localXOffset);
keyboard.draw(canvas);
if (!notesToDraw.isEmpty()) {
keyboard.drawOverlays(notesToDraw, canvas);
}
canvas.restore();
Готово, если сделать раздвигающий жест пальцами, мы увидим, как клавиатура растет в ширину:
Заключение
Вот и подошла к концу вторая часть моего цикла статей. Я надеюсь, что это поможет кому-то быстрее разобраться в тонкостях создания кастомных компонентов и улучшить качество своих проектов.
Готовый пример по-прежнему доступен на моем гитхабе: goo.gl/VDeuw.
Также, всячески рекомендую ознакомиться с этой статьей из официальной документации:
developer.android.com/training/gestures/index.html
В третьей статье постараюсь осветить вопросы оптимизации, использования битмапов вместо программного рисования текста, кружков, рассмотреть, какие возникают перерисовки пикселей в нашем компоненте, и как можно от них избавиться.
Автор: evilduck