Вступление
Вновь приветствую, коллеги.
В своей предыдущей статье я рассказал об основах создания кастомного компонента на примере простенькой, но симпатичной фортепианной клавиатуры.
В этой статье под катом мы продолжим накручивать свистелки и… дополнительные возможности нашей клавиатуре. На повестке дня:
- Сохранение состояния компонента при повороте экрана
- добавление подсветки при оверскролле
- передача параметров в 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
В третьей статье постараюсь осветить вопросы оптимизации, использования битмапов вместо программного рисования текста, кружков, рассмотреть, какие возникают перерисовки пикселей в нашем компоненте, и как можно от них избавиться.
ссылка на оригинал статьи http://habrahabr.ru/post/176919/
Добавить комментарий