Создание нестандартного компонента с нуля. Часть 2

от автора

Вступление

Вновь приветствую, коллеги.

В своей предыдущей статье я рассказал об основах создания кастомного компонента на примере простенькой, но симпатичной фортепианной клавиатуры.

В этой статье под катом мы продолжим накручивать свистелки и… дополнительные возможности нашей клавиатуре. На повестке дня:

  1. Сохранение состояния компонента при повороте экрана
  2. добавление подсветки при оверскролле
  3. передача параметров в XML
  4. Мультитач зуммирование

Сохранение состояния компонента при повороте экрана

Сейчас мы можем обнаружить такое поведение у нашего компонента. Если мы проскроллим на любую позицию, затем повернем экран, скролл будет на нуле. Очевидно, это происходит потому, что при повороте экрана 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); } 

Здесь мы последовательно:

  1. Сохраняем канву, используя canvas.save()
  2. вычисляем ее высоту минус паддинги и выставляем размер эффекта используя метод leftEdgeEffect.setSize(height, width);
  3. Поворачиваем канву на 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *