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

в 8:44, , рубрики: android, android development, Разработка под android, метки: ,

Вступление

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

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

Создание нестандартного компонента с нуля. Часть 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 градусов и правильно ее позиционируем.

Я хочу представить это более наглядно. Давайте уберем трансформации канвы:

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

вот так выглядит эффект по-умолчанию. Всегда вниз. Если мы добавим только поворот на 270, мы увидим, что эффект рисуется в верном направлении, но в самом верхнем углу канвы.

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

И лишь после добавления смещения канвы мы видим, что эффект на месте.

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

Но это я забежал вперед, т. к. пока у нас эффект хоть и рисуется, но он не активируется при скролле.
Вот здесь нам нужно вернуться к нашему детектору жестов и модифицировать 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();

Готово, если сделать раздвигающий жест пальцами, мы увидим, как клавиатура растет в ширину:

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

Заключение

Вот и подошла к концу вторая часть моего цикла статей. Я надеюсь, что это поможет кому-то быстрее разобраться в тонкостях создания кастомных компонентов и улучшить качество своих проектов.
Готовый пример по-прежнему доступен на моем гитхабе: goo.gl/VDeuw.
Также, всячески рекомендую ознакомиться с этой статьей из официальной документации:
developer.android.com/training/gestures/index.html
В третьей статье постараюсь осветить вопросы оптимизации, использования битмапов вместо программного рисования текста, кружков, рассмотреть, какие возникают перерисовки пикселей в нашем компоненте, и как можно от них избавиться.

Автор: evilduck

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js