Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Приветствую всех.
Как-то одним вечером мне в голову забралась идея о создании собственного настраиваемого View
компонента для выбора цвета в обёртке уже готовой к использованию библиотеки. На самом деле, таковых в сети достаточно и без меня, но довольно интересных, с возможностями кастомизации я не нашёл. Опыта в разработке View
компонентов у меня на тот момент не было, а хотелось бы чуть больше, чем ничего. Так я и приступил к написанию кода.
Данная статья в большей степени не является руководством к тому, как стоит делать, соответственно и не претендует на правильность. Однако с помощью этой статьи я решил поделиться своим опытом разработки и описать грабли с костылями, на которые я наступал по мере реализации моей концептуальной идеи.
Перед прочтением моей статьи было бы неплохо ознакомиться с тем, как вообще писать собственные представления, если вы ещё этого не умеете. Ну и в процессе немного вспомним школьную геометрию.
Спойлер
Итоговый результат “по умолчанию” слева, плюс два варианта кастомизации от меня.
Запасаемся гречей чаем, бутербродами и терпением, статья получилась не очень маленькой.
Концепция
Для начала стоит кратко описать составляющие элементы view компонента, так будет проще понимать то, что мы кодим:
Суть библиотеки заключается в её кастомизации. Каждый элемент будет иметь один или несколько атрибутов настройки (размер, виден/не виден, цвет, радиус и т.п.).
Внутренний и внешний указатель цвета могут использоваться в качестве кнопок для произвольных действий. Есть режим "Stepper", когда указатель двигается чётко по меткам.
По центру элемента (поверх указателя цвета) можно установить любую иконку.
В доступе будут 3 слушателя:
ColorChangeListener
- имеет два метода:void onColorChanged(int color)
- вызывается при изменении положения указателя по цветовому кругу, соответственно и цвета.void firstDraw(int color)
- вызывается исключительно при первой отрисовкеView
, таким образом можно понять, какой цвет установлен воView
при первичной отрисовке.
ButtonTouchListener
- имеет два метода:void on_cPointerTouch()
- вызывается при касании на указатель цвета.void on_excPointerTouch()
- вызывается при касании на внешний указатель цвета.
StepperListener
- имеет один метод:void onStep()
- вызывается при каждом перемещении указателя на новую метку в режиме "Stepper".
Приступаем к реализации
Основы
В этом разделе я поверхностно пробегусь по основам создания View
в ОС Android.
Начнём с того, что View
в Android имеет несколько параметризированных конструкторов с различными атрибутами (А если точнее - то их 4, и это поможет нам при расчётах размеров View
в дальнейшем).
public View(Context context) {}
public View(Context context, @Nullable AttributeSet attrs) {}
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {}
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {}
Первый конструктор вызывается в тех случаях, когда нам необходимо создать и инициализировать представление из кода. Параметр
context
в данном случае — это контекст, в котором работает представление. Черезcontext
можно получить доступ к текущей теме, ресурсам и т. п.Второй конструктор вызывается, когда наш пользовательский
View
будет использоваться из файлов макета XML, содержащего атрибутыView
.Третий конструктор аналогичен второму, но также принимает атрибуты по умолчанию.
Четвёртый уже аналогичен третьему, но принимает атрибут темы.
Я, как чаще всего это и делается, переопределил второй конструктор (к нему мы ещё вернёмся). Большего нам пока не понадобится.
Создание модуля
Для начала создадим новый модуль в Android Studio.
Выбираем нужные параметры, такие как, минимальная версия SDK, язык и названия пакетов и модуля
В build.gradle файле модуля приложения подключаем новый модуль строчкой implementation project (":RXColorWheel")
Создаём новый класс RXColorWheel
, и наследуемся от View
, IDE требует добавить вызовы конструкторов суперкласса - выполняем это условие. В итоге, получаем такой код:
public class RXColorWheel extends View {
public RXColorWheel(Context context) {
super(context);
}
public RXColorWheel(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public RXColorWheel(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public RXColorWheel(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
}
Соответственно, вся дальнейшая работа будет происходить в большей степени в этом классе.
Объявляем слушатели, которые я описывал раннее на этапе концепции, и создаём их экземпляры с сеттерами для них.
public interface ColorChangeListener{
void onColorChanged(int color);
void firstDraw(int color);
}
public interface ButtonTouchListener{
void on_cPointerTouch();
void on_excPointerTouch();
}
public interface StepperListener{
void onStep();
}
private ColorChangeListener colorChangeListener;
private ButtonTouchListener buttonTouchListener;
private StepperListener stepperListener;
public void setButtonTouchListener(@NonNull ButtonTouchListener listener){ buttonTouchListener = listener;}
public void setColorChangeListener(@NonNull ColorChangeListener listener){colorChangeListener = listener;}
public void setStepperListener(@NonNull StepperListener listener){ stepperListener = listener;}
При реализации View
я использовал четырьмя инструмента пакета android.graphics:
Paint - содержит цвета, стили и прочую графическую информацию для отрисовки объектов на холсте. У объекта, который будет отрисован, можно выбрать цвет, стиль, шрифт, специальные эффекты и прочие полезные аспекты отображения объекта.
Canvas - эта наш холст, на котором мы рисуем.
BitMap - класс, отвечающий за растровые картинки.
Color - класс, который описывает цвета. Их описывают четырьмя числами в формате ARGB, по одному для каждого канала(Alpha, Red, Green, Blue).
Теперь необходимо создать экземпляры абсолютно всех объектов и переменных, отвечающих за настройку отображения элементов, и хранящих в себе прочую полезную информацию. Код с описанием прилагается ниже:
private ColorChangeListener colorChangeListener;
private ButtonTouchListener buttonTouchListener;
private StepperListener stepperListener;
//ANTI_ALIAS_FLAG включает антиалиасинг
private final Paint p_color = new Paint(Paint.ANTI_ALIAS_FLAG); //Цветовое кольцо
private final Paint p_pointer = new Paint(Paint.ANTI_ALIAS_FLAG); //Указатель
private final Paint p_pStroke = new Paint(Paint.ANTI_ALIAS_FLAG); //Обводка указателя
private final Paint p_background = new Paint();//Задний фон
private final Paint p_pLine = new Paint(Paint.ANTI_ALIAS_FLAG); //Линия указателя
private final Paint p_cPointer = new Paint(); //Цветовой указатель
private final Paint p_excPointer = new Paint(); //Внешний цветовой указатель
private final Paint p_placemarks = new Paint(); //Метки
private double py, px; //Координаты указателя
private float cx, cy; //Центральные координаты View
private float color_rad; //Радиус цветового круга
private float color_rTh; //Толщина цветового круга
private float placemarks_rad; //Радиус меток
private float cPointer_rad; //Радиус указателя цвета
private float excPointer_rad; //Радиус внешнего указателя цвета
private float pointer_rad; //Радиус указателя
private float background_rad; //Радиус заднего фона
private float badge_size; //Размер изображения иконки
private float[] degrees; //Массив, хранящий значения углов для расположения меток
private final float[] hsv = new float[] {0, 1f, 1f};
private int[] color_palette; //Хранит палитру цветов цветового круга
private int color; //Текущий цвет, выбранный пользователем
private int minVsize; //Минимальный размер View (по высоте или ширине)
private int pCount; //Количество меток
/** Булевы переменные для настроек отображения элементов. */
private boolean isBackground;
private boolean isExColorPointer;
private boolean isColorPointerCustomColor;
private boolean isPointerLine;
private boolean isPlacemarks;
private boolean isPlacemarksRound;
private boolean isColorPointer;
private boolean isBadge;
private boolean isRoundBadge;
private boolean isPointerOutline;
private boolean isColorPointerShadow;
private boolean isPointerCustomColor;
private boolean isPointerShadow;
private boolean isShadow;
private boolean stepperMode;
private boolean firstDraw = true;
private Bitmap mainImageBitmap; //Bitmap картинки в середине значка
private TypedArray typedArray; //Хранит значения атрибутов
Проинициализируем часть этих переменных во втором конструкторе, получив значения атрибутов из XML через TypedArray
.
Сначала создадим файл с XML атрибутами.
В модуле библиотеки по директории res/values/
создадим файл attr.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="RXColorWheel">
<attr name="badge" format="reference" />
<attr name="colorPointerRad" format="float" />
<attr name="excPointerRad" format="float" />
<attr name="backgroundRad" format="float" />
<attr name="bgColor" format="color" />
<attr name="pointerRad" format="float" />
<attr name="badgeSize" format="float" />
<attr name="colorRingRad" format="float" />
<attr name="colorRingThickness" format="float" />
<attr name="placemarksRad" format="float" />
<attr name="placemarksCount" format="integer" />
<attr name="colorPointerCustomColor" format="color" />
<attr name="pointerCustomColor" format="color" />
<attr name="isColorPointerCustomColor" format="boolean" />
<attr name="isPointerCustomColor" format="boolean" />
<attr name="isBackground" format="boolean" />
<attr name="isExColorPointer" format="boolean" />
<attr name="isPointerLine" format="boolean" />
<attr name="isPlacemarks" format="boolean" />
<attr name="isPlacemarksRound" format="boolean" />
<attr name="isColorPointer" format="boolean" />
<attr name="isColorPointerShadow" format="boolean" />
<attr name="isBadge" format="boolean" />
<attr name="isRoundBadge" format="boolean" />
<attr name="isPointerOutline" format="boolean" />
<attr name="isPointerShadow" format="boolean" />
<attr name="isShadow" format="boolean" />
<attr name="stepperMode" format="boolean" />
</declare-styleable>
</resources>
Теперь можно получить доступ к этим атрибутам из кода, проинициализировав переменные значениями атрибутов в конструкторе.
public RXColorWheel(Context context, AttributeSet attrs) {
this(context, attrs, 0);
this.setDrawingCacheEnabled(true);
setColorPalette(getResources().getIntArray(R.array.default_color_palette));
typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.RXColorWheel);
isBackground = typedArray.getBoolean(R.styleable.RXColorWheel_isBackground,true);
isExColorPointer = typedArray.getBoolean(R.styleable.RXColorWheel_isExColorPointer,true);
isPointerLine = typedArray.getBoolean(R.styleable.RXColorWheel_isPointerLine,true);
isPlacemarks = typedArray.getBoolean(R.styleable.RXColorWheel_isPlacemarks,true);
isPlacemarksRound = typedArray.getBoolean(R.styleable.RXColorWheel_isPlacemarksRound,true);
isColorPointer = typedArray.getBoolean(R.styleable.RXColorWheel_isColorPointer,true);
isColorPointerShadow = typedArray.getBoolean(R.styleable.RXColorWheel_isColorPointerShadow, true);
isBadge = typedArray.getBoolean(R.styleable.RXColorWheel_isBadge, true);
isRoundBadge = typedArray.getBoolean(R.styleable.RXColorWheel_isRoundBadge, false);
isPointerOutline = typedArray.getBoolean(R.styleable.RXColorWheel_isPointerOutline, true);
isPointerShadow = typedArray.getBoolean(R.styleable.RXColorWheel_isPointerShadow, false);
pCount = even(typedArray.getInt(R.styleable.RXColorWheel_placemarksCount,20));
if(stepperMode) calculate_step_angle(pCount);
p_background.setColor(typedArray.getColor(R.styleable.RXColorWheel_bgColor,
getResources().getColor(R.color.background)));
isShadow = typedArray.getBoolean(R.styleable.RXColorWheel_isShadow, true);
setIsPointerCustomColor(typedArray.getBoolean(R.styleable.RXColorWheel_isPointerCustomColor, false));
setIsColorPointerCustomColor(typedArray.getBoolean(R.styleable.RXColorWheel_isColorPointerCustomColor, false));
if (isPlacemarks) {stepperMode = typedArray.getBoolean(R.styleable.RXColorWheel_stepperMode, false);}
else {stepperMode = false;}
int cp_color = typedArray.getColor(R.styleable.RXColorWheel_colorPointerCustomColor, 0);
if(cp_color != 0) setColorPointerCustomColor(cp_color);
int pColor = typedArray.getColor(R.styleable.RXColorWheel_pointerCustomColor, 0);
if(pColor != 0) setPointerCustomColor(pColor);
mainImageBitmap = getBitmapFromVectorDrawable(context, typedArray.getResourceId(R.styleable.RXColorWheel_badge, R.drawable.ic_baseline_add_24));
}
Помимо атрибутов в конструкторе я использовал стандартную палитру цветов для цветового круга, и буду использовать стандартные цвета в будущих сеттерах, поэтому создадим ещё два файла по той же директории.
arrays.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="default_color_palette">
<item>#FF0000FF</item>
<item>#FF00FF00</item>
<item>#FFFFFF00</item>
<item>#FFFF0000</item>
</array>
</resources>
colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="background">#2C2A31</color>
<color name="color_pointer">#FFFFFFFF</color>
<color name="pointer_line">#FFFFFFFF</color>
<color name="pointer">#F6F6F6</color>
<color name="pointer_outline">#FFFFFFFF</color>
</resources>
После создания всех файлов, переменных и конструкторов можно приступить к измерениям.
Измеряем
Теперь необходимо замерить все размеры View
, делается это в методе onMeasure(int widthMeasureSpec, int heightMeasureSpec)
Следует заметить, т.к. мы работаем с кругами, логичнее будет абстрагироваться от декартовой системы координат и перейти в полярную, где мы оперируем тоже двумя числами - это полярный угол и полярный радиус.
В Canvas из Android SDK началом координат является левый верхний угол. Это не совсем стандарт, который указан на изображении ниже.
Положительная ось X идёт вправо, а положительная ось Y направлена вниз. Запомним это, т.к. центром координат будет середина View
(как на изображении выше), а отрицательные значения Y координаты будут наверху, а не внизу.
Для начала, нужно разметить новое начало координат - это центральная точка View
. Высчитывать его будем как раз в onMasure()
. Сначала нужно получить MeasureSpec
, и декодировать его. measureSpec
хранит в числовом формате данные о размере и режиме отображения View
, которые были переданы нам от родительского View
(контейнера).
Всего есть три режима отображения:
UNSPECIFIED: родительский контейнер не имеет никаких ограничений на представление и дает ему любой размер, который он хочет.
EXACTLY: родительский контейнер определил точный размер представления. В настоящее время размер представления равен значению, заданному параметром size. Он соответствует match_parent и конкретным значениям в LayoutParams.
AT_MOST: родительский контейнер указывает доступный размер, а именно размер, размер дочернего представления не может быть больше этого значения, конкретное значение зависит от реализации vew. Это соответствует wrap_content в LayoutParams.
Cоздадим функцию decodeMeasureSpec()
. Она достаёт размер из measureSpec
и устанавливает размер по умолчанию, в случае, если родительский контейнер не выдвинул требований к размеру нашей View
(режим UNSPECIFIED). Я установил размер по умолчанию равный 350.
private int decodeMeasureSpec(int measureSpec) {
int result;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
if (specMode == MeasureSpec.UNSPECIFIED) result = 350;
else result = specSize;
return result;
}
После того, как мы можем получить необходимые размеры View
, самое время вычислить центр координат и записать это в переменные cx
и cy
. Весь код с пояснениями приложен внизу.
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int mWidth = decodeMeasureSpec(widthMeasureSpec); //Достаём размер View
int mHeight = decodeMeasureSpec(heightMeasureSpec);
minVsize = Math.min(mWidth, mHeight); //Вычисляем минимальный размер (будем рисовать круги по минимальному размеру View в высоте или ширине)
setMeasuredDimension(mWidth, mHeight); //Сохраненяем измеренную ширину и высоту для View
cx = mWidth * 0.5f; //Делим пополам высоту и ширину View
cy = mHeight * 0.5f;
}
Следует заметить, что после вычисления ширины и высоты нужно обязательно вызвать метод setMeasuredDimension()
, иначе будет брошен IllegalStateException
.
Теперь можно перейти к части расчётов.
Считаем
После того, как мы записали центр наших координат в cy
и cx
соответственно, следует приступить к расчётам элементов внутри самого View
.
Высчитываем размеры элементов и инициализируем
Для этого создадим два метода - calculateSizes()
и init()
. Первый высчитывает значения размеров элементов внутри View
, второй инициализирует настройки объектов Paint
для элементов View
.
private void calculateSizes() {
//Тут вычисляем коэффициенты размеров элементов View
//Левый аргумент считывает значения из XML атрибута, правый устанавливает значение по умолчанию, если XML атрибут не был указан
//Значения по умолчанию подбирались методом научного тыка
float color_rad_coef = typedArray.getFloat(R.styleable.RXColorWheel_colorRingRad, 0.41f);
float color_rWidth_coef = typedArray.getFloat(R.styleable.RXColorWheel_colorRingThickness, 0.04f);
float pointer_rad_coef = typedArray.getFloat(R.styleable.RXColorWheel_pointerRad, 0.12f);
float cPointer_rad_coef = typedArray.getFloat(R.styleable.RXColorWheel_colorPointerRad, 0.17f);
float badge_size_coef = typedArray.getFloat(R.styleable.RXColorWheel_badgeSize, 1);
float excPointer_rad_coef = typedArray.getFloat(R.styleable.RXColorWheel_excPointerRad, 0.6f);
float placemarks_rad_coef = typedArray.getFloat(R.styleable.RXColorWheel_placemarksRad, 0.96f);
float background_rad_coef = typedArray.getFloat(R.styleable.RXColorWheel_backgroundRad, 1);
//Тут устанавливаем размеры элементов
//Размер первого элемента равен произведению его коэффициента и минимального размера View по ширине или высоте
//Размер последующих элементов равен произведению его коэффициента и размера предыдущего элемента
color_rad = minVsize * color_rad_coef;
color_rTh = color_rad * color_rWidth_coef;
pointer_rad = color_rad * pointer_rad_coef;
cPointer_rad = color_rad * cPointer_rad_coef;
badge_size = cPointer_rad * badge_size_coef;
excPointer_rad = color_rad * excPointer_rad_coef;
placemarks_rad = color_rad * placemarks_rad_coef - color_rTh * 0.5f;
background_rad = color_rad * background_rad_coef;
px = cx + color_rad; //А это координаты указателя, x координата по центру + радиус цветового круга
py = cy; //y координата указателя равна центру координат
//Так указатель при первой отрисовке будет находится по правому краю цветового круга
}
private void init(){
Shader s_color = new SweepGradient(cx, cy, color_palette, null); //Шейдер для цветового круга, дающий градиент по окружности
p_color.setStyle(Paint.Style.STROKE); //Стиль для цветового круга
p_color.setStrokeWidth(color_rTh);
p_color.setShader(s_color);
p_pointer.setStyle(Paint.Style.FILL); //Указатель
if(isPointerShadow) {
p_pointer.setShadowLayer(15.0f, 0.0f, 0.0f, Color.argb(110, 0, 0, 0));
}
p_pStroke.setStyle(Paint.Style.STROKE); //Обводка указателя
p_pStroke.setColor(getResources().getColor(R.color.pointer_outline));
p_pStroke.setStrokeWidth(pointer_rad * 0.08f);
if(isShadow) {
p_background.setShadowLayer(50.0f, 0.0f, 0.0f, 0xFF000000);
}
p_pLine.setStyle(Paint.Style.STROKE); //Линия, идущая от центра к указателю
p_pLine.setColor(getResources().getColor(R.color.pointer_line));
p_cPointer.setStyle(Paint.Style.FILL); //Указатель цвета
if(isColorPointerShadow) {
p_cPointer.setShadowLayer(90.0f, 0.0f, 0.0f, Color.argb(130, 0, 0, 0));
}
p_excPointer.setStyle(Paint.Style.FILL); //Внешний указатель цвета
p_placemarks.setStyle(Paint.Style.STROKE); //Метки
p_placemarks.setARGB(255, 124,122,129);
if(mainImageBitmap != null) {
mainImageBitmap = Bitmap.createScaledBitmap(mainImageBitmap, (int) badge_size,
(int) badge_size, false); //Устанавливаем размер Bitmap
}
}
Т.к. теперь мы работаем с полярной системой координат, было бы неплохо написать функции для преобразования координат. В целом нам необходимо, чтобы указатель следовал по окружности (по цветовому кругу) за пальцем пользователя, водящим им по экрану.
Находим угол и полярный радиус
Для заданной цели в первую очередь нам нужно узнать угол, на который нужно поворачивать указатель. Для это переопределяем функцию boolean onTouchEvent(MotionEvent event)
и достаём координаты касания через event.getX()
и event.getY()
.
float x = event.getX() - cx; //Из полученных координат вычитаем центр View,
float y = cy - event.getY(); //это сделано из-за особенностей работы с полярными координатами и функции atan2.
Почему пришлось вычитать центральные значения из полученных координат? Поясню более подробно. Для определения угла нам понадобится функция atan2()
. Принцип работы этой функции заключается в вычислении арктангенса для указанных координат. Т.к. для работы этой функции необходимы координаты со всеми значениями X и Y, как положительных, так и отрицательных, нам необходимо из полученных координат касания пользователя вычесть центр View
, делаем это для того, чтобы получить отрицательные значения координат, т.к. в Android на Canvas
отрицательные значения координат уходят за пределы экрана. Нам же нужна вся "палитра" значений. Центр View
равен центру координат.
Например - View
шириной в 250 единиц по оси X, её центр 125-я координата по X - это наш условный ноль, всё что меньше 125, отрицательные координаты. Тоже самое делаем и для Y координаты. Путём таких нехитрых расчётов получаем следующую картину:
Далее преобразуем полученные декартовы координаты в полярные через Math.atan2()
из пакета java.lang. Данная функция принимает в себя два аргумента - y и x координату, и возвращает полярный угол θ - "тета" в радианах, тот самый, который нам нужен. Все углы будут измеряться в радианах. Также необходимо найти расстояние от центра View
до точки касания (полярный радиус), так мы можем в будущем определить до какого элемента коснулся пользователь. Сделаем это так:
float angle = (float) Math.atan2(y,x); //Находим угол относительно центра (коордитната x вправо от центра) и точки касания
double d = Math.sqrt(x*x + y*y); //Находим расстояние от центра View до точки касания, запомним эту переменную
Чтобы найти расстояние от точки до точки, нужно воспользоваться данной формулой:
AB = √(xb - xa)2 + (yb - ya)2
Т.к. второй точкой является центр координат - 0, его можем даже не вписывать в формулу, поэтому получаем данное выражение: Math.sqrt(x*x + y*y);
Крутим-вертим
После того, как мы узнали угол, на который необходимо повернуть указатель, нам следует этот указатель повернуть (ха-ха, тавтология). Сделать это можно с помощью матрицы поворота, весьма распространённая штука в компьютерной графике (в нашем случае это матрица поворота в двумерном пространстве). Если не углубляться в подробности, то простыми словами, данное преобразование позволяет вращать точку вокруг другой точки или вокруг центра координат на определённый угол.
Для поворота точки вокруг центра координат нам будет достаточно для координаты X вычислить косинус, помножить его на нужный радиус (в данном случае радиус цветового круга, т.к. указатель находится на нём) и прибавить cx. Для координаты Y всё тоже самое, но вычисляем синус и прибавляем cy:
px = color_rad * Math.cos(angle) + cx; //Помним, что cx и cy это центр нашей View
py = color_rad * Math.sin(angle) + cy; //px и py это координаты указателя
Если не прибавлять cx
и cy
, указатель будет поворачиваться вокруг левого верхнего угла View
, то бишь начала координат на Canvas
.
Более подробно о повороте точки на координатах можно почитать здесь.
Определяем до чего коснулся пользователь
Создадим enum
с состоянием, которое описывает до какого элемента в данный момент касается пользователь:
private enum Unit{
VOID, //Ничего
EX_CP, //Внешний указатель цвета
CP, //Указатель цвета
P //Указатель
}
У MotionEvent
, который передаётся в onTouchEvent()
имеется несколько констант, обозначающих различные состояния касания пользователя, нам нужно только три:
ACTION_DOWN
- коснулись пальцем экрана.ACTION_UP
- убрали палец с экрана.ACTION_MOVE
- двигаем палец по экрану.
Логично, что первым реагирует ACTION_DOWN
, в нём будем вычислять координаты касания до элемента. Создадим switch
, в котором будем писать всё логику.
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
break;
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
break;
}
Добавим полностью всю логику в этот switch
, код функции onTouchEvent()
с полными пояснениями приведён ниже:
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
float x = event.getX() - cx;
float y = event.getY() - cy;
float nearest;
float angle = (float) Math.atan2(y, x); //Тут находим угол по координатам
double d = Math.sqrt(x*x + y*y); //Тут находим полярный радиус
nearest = stepperMode ? nearest(angle, degrees) : 0; //Если включен режим "stepper", находим ближайшее значение из массива углов меток и
//углом, на который нужно повернуть указатель, функции для этого будут описаны ниже.
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
//В соответствии с тем, на что нажал пользователь, активируем необходимый слушатель
switch (unit){
case EX_CP:
if(buttonTouchListener != null) buttonTouchListener.on_excPointerTouch();
break;
case CP:
if(buttonTouchListener != null) buttonTouchListener.on_cPointerTouch();
break;
}
unit = Unit.VOID;
break;
case MotionEvent.ACTION_DOWN:
//d - Это полярный радиус, если он меньше радиуса внешнего указателя цвета и больше просто указателя цвета,
//при том, что внешний указатель цвета отображается во View, значит мы кликнули на него
if(d < excPointer_rad && d > cPointer_rad && isExColorPointer){ unit = Unit.EX_CP; }
//тут в случае, если указатель цвета отключен, и присутствует только внешний указатель цвета
else if(d < excPointer_rad && !isColorPointer && isExColorPointer){ unit = Unit.EX_CP; }
//Тут по подобной аналогии с указателем цвета
else if(d < cPointer_rad && isColorPointer){ unit = Unit.CP; }
float t = color_rTh * 0.5f + 48;//Тутдобавляем чуть большую границу касания для цветового круга
//Так как, если цветовой круг тонкий, то по нему сложно попасть пальцем
if(d < color_rad + t && d > color_rad - t) {
unit = Unit.P; //В этом случае двигаем указатель
if(stepperMode) { //Если включен режим "stepper"
angle = nearest; //Перезаписываем в угол найденное ближайшее значение
if(Math.abs(nearest_old) != Math.abs(nearest)) {
nearest_old = nearest; //
//Дёргаем слушатель
if (stepperListener != null) stepperListener.onStep();
}
}
//А тут как раз поворачиваем наш указатель на нужный угол
px = color_rad * Math.cos(angle) + cx;
py = color_rad * Math.sin(angle) + cy;
//Передаём цвет в слушатель, который мы получим позже
if(colorChangeListener != null) colorChangeListener.onColorChanged(color);
}
break;
case MotionEvent.ACTION_MOVE:
if (unit.equals(Unit.P)) { //Если указатель цвета можем двигать
if(stepperMode){ //Если режим "stepper"
angle = nearest; //Записываем в угол ближайшее значение
if(Math.abs(nearest_old) != Math.abs(nearest)) {
nearest_old = nearest;
//Дёргаем слушатель
if (stepperListener != null) stepperListener.onStep();
}
}
//И тут поворачиваем наш указатель на нужный угол
px = color_rad * Math.cos(angle) + cx;
py = color_rad * Math.sin(angle) + cy;
//Передаём цвет в слушатель, который мы получим позже
if(colorChangeListener != null) colorChangeListener.onColorChanged(color);
}
break;
}
//Если мы двигаем указатель, перерисовываем View методом invalidate() для отображения изменений
if(angle_old != angle) {
angle_old = angle;
invalidate();
}
return true;
}
Пора бы объяснить значение функции nearest()
и массива degrees. Тут всё просто, метод nearest()
ищет ближайшее значение из массива к числу, переданному в качестве первого аргумента. Этот метод берёт значения из массива degrees
- этот массив хранит значения углов всех меток (начало статьи с описанием всех элементов). Метод nearest()
используется для режима "stepper", для движения к ближайшей метке.
//Данный метод был взят из интернет-источников
static float nearest(float n, float...args) {
float nearest = 0;
float value = 2*Float.MAX_VALUE;
if(args != null){
for(float arg : args){
if (value > Math.abs(n - arg)){
value = Math.abs(n-arg);
nearest = arg;}}
}
return nearest;
}
Массив degrees
заполняется в методе calculateStepAngle()
. Как понятно, функция высчитывает значение угла между каждой меткой.
private void calculateStepAngle(int line_count){
float angle = 0;
float degree = (float) Math.toRadians(360f / line_count);
degrees = new float[line_count + 1];
int half = line_count/2;
degrees[0] = 0;
float[] array = new float[half];
for(int i = 1; i < half+1; i++) {
angle = angle + degree;
degrees[i] = angle;
array[i-1] = degrees[i];
}
for(int i = half+1; i < line_count+1; i++){
degrees[i] = array[i-half-1] * -1;
}
}
Рисуем
После того, как все расчёты были сделаны, можно приступить к отрисовке элементов. Для этого переопределяем метод onDraw()
, и помним, что никаких новых объектов внутри этого метода не создаём. Для того, чтобы определить текущий цвет, над которым находится указатель, просто получаем Bitmap
от View
и смотрим цвет пикселя по координатам указателя.
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
if(isBackground) c.drawCircle(cx, cy, background_rad, p_background); //Рисуем фон
c.drawCircle(cx, cy, color_rad, p_color); //Рисуем цветовое кольцо
color = getDrawingCache().getPixel((int) px,(int) py); //Записываем цвет пикселя по координатам указателя
//Назначаем указателям цвет выбранного пикселя, если им не назначен свой цвет из настроек
if(!isColorPointerCustomColor) p_cPointer.setColor(color);
if(!isPointerCustomColor) p_pointer.setColor(color);
Color.colorToHSV(color, hsv); //Записываем текущий цвет в значения hsv
hsv[2] = hsv[2] * 0.90f; //Затемняем цвет
p_excPointer.setColor(Color.HSVToColor(hsv)); //Назначаем затемнённый цвет внешнему указателю цвета
if(isExColorPointer) c.drawCircle(cx, cy, excPointer_rad, p_excPointer); //Рисуем внешний указатель цвета
if(firstDraw) { //При первой отрисовке оповещаем об этом слушатель и высчитываем углы меток
firstDraw = false;
if(stepperMode) calculateStepAngle(pCount);
if(colorChangeListener != null) colorChangeListener.firstDraw(color);
}
else {
if(isPointerLine) {c.drawLine(cx,cy,(float) px,(float) py, p_pLine);} //Рисуем линию от центра до указателя
if(isColorPointer) { //Рисуем указатель цвета
c.drawCircle(cx, cy, cPointer_rad, p_cPointer);
if(isBadge){ //Рисуем значок на указателе цвета, если такой имеется
c.drawBitmap(
isRoundBadge ? getCircledBitmap(mainImageBitmap) : mainImageBitmap, //Если значок круглый, отрисоываем круглый Bitmap
cx - mainImageBitmap.getWidth() * 0.5f, //Расположение значка по центру указателя цвета, благодаря этим расчётам центр картинки - это центр картинки,
cy - mainImageBitmap.getHeight() * 0.5f, //изначально координата, с которой рисуется картинка - левый верхний угол
p_cPointer //Рисуем Bitmap с такими же настройками, что и указатель цвета
);
}
}
if(isPlacemarks){
drawRadialLines(c, placemarks_rad - 20, 20, pCount); //Метки
if(isPlacemarksRound) c.drawCircle(cx, cy, placemarks_rad, p_placemarks);
}
c.drawCircle((float) px, (float) py, pointer_rad, p_pointer); //Указатель
if (isPointerOutline) { //Обводка указателя
c.drawCircle((float) px, (float) py, pointer_rad, p_pStroke);
}
}
}
Ниже описаны методы получения Bitmap
из вектора и получения круглого Bitmap (их я тоже взял с интернета).
/** Возвращает Bitmap с вектора */
private static Bitmap getBitmapFromVectorDrawable(Context context, int drawableId) {
Drawable drawable = ContextCompat.getDrawable(context, drawableId);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
drawable = (DrawableCompat.wrap(drawable)).mutate();
}
Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
drawable.draw(canvas);
return bitmap;
}
/** Скругляет Bitmap */
private static Bitmap getCircledBitmap(Bitmap bitmap) {
Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
final Paint paint = new Paint();
final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
paint.setAntiAlias(true);
canvas.drawARGB(0, 0, 0, 0);
canvas.drawCircle(bitmap.getWidth() * 0.5f, bitmap.getHeight() * 0.5f, bitmap.getWidth() * 0.5f, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
canvas.drawBitmap(bitmap, rect, rect, paint);
return output;
}
В целом остались только сеттеры и геттеры, с вспомогательными к ним методами.
//Этот метод устанавливает палитру цветов для цветового круга
//В последний элемент массива вставлякм тот же цвет, что и в первом, иначе
//Будет контраст между цветами в цветовом круге
public void setColorPalette(@NonNull int[] colors){
if(colors[0] != colors[colors.length - 1]){
colors = Arrays.copyOf(colors, colors.length + 1); //Создаём новый массив на основе старого с ещё одним элементом
colors[colors.length - 1] = colors[0];
color_palette = colors;
}
else {
color_palette = colors;
}
}
public void setIsColorPointer(boolean isColorPointer){this.isColorPointer = isColorPointer;}
public void setColorPointerCustomColor(int color){this.isColorPointerCustomColor = true; p_cPointer.setColor(color);}
public void setColorPointerCustomColor(String color){this.isColorPointerCustomColor = true; p_cPointer.setColor(Color.parseColor(color));}
public void setIsColorPointerCustomColor(boolean isColorPointerCustomColor){
this.isColorPointerCustomColor = isColorPointerCustomColor;
if(isColorPointerCustomColor){p_cPointer.setColor(getResources().getColor(R.color.color_pointer));}
}
public void setPointerCustomColor(int color){this.isPointerCustomColor = true; p_pointer.setColor(color);}
public void setPointerCustomColor(String color){this.isPointerCustomColor = true; p_pointer.setColor(Color.parseColor(color));}
public void setIsPointerCustomColor(boolean isPointerCustomColor){
this.isPointerCustomColor = isPointerCustomColor;
if(isPointerCustomColor) p_pointer.setColor(getResources().getColor(R.color.pointer));
}
public void setColorPointerRadius(float colorPointerRadius){cPointer_rad = color_rad * colorPointerRadius;}
public void setIsBadge(boolean isBadge){this.isBadge = isBadge;}
public void setIsRoundBadge(boolean isRoundBadge){this.isRoundBadge = isRoundBadge;}
public void setBadgeSize(float badge_size){this.badge_size = cPointer_rad * badge_size;}
public void setImageBitmap(Bitmap bitmap){
mainImageBitmap = bitmap;
mainImageBitmap = Bitmap.createScaledBitmap(mainImageBitmap, (int)cPointer_rad,
(int)cPointer_rad, false); //Устанавливаем размер Bitmap
}
public void setImageById(Context context, int drawableId){
mainImageBitmap = getBitmapFromVectorDrawable(context, drawableId);
mainImageBitmap = Bitmap.createScaledBitmap(mainImageBitmap, (int)cPointer_rad,
(int)cPointer_rad, false); //Устанавливаем размер Bitmap
}
public void setIsExColorPointer(boolean isExColorPointer){this.isExColorPointer = isExColorPointer;}
public void setExColorPointerRadius(float ExColorPointerRadius){this.excPointer_rad = color_rad * ExColorPointerRadius;}
public void setBackgroundColor(int color){this.p_background.setColor(color);}
public void setIsBackground(boolean background){this.isBackground = background;}
public void setIsPointerLine(boolean isPointerLine){this.isPointerLine = isPointerLine;}
public void setIsPointerShadow(boolean isPointerShadow){this.isPointerShadow = isPointerShadow;}
public void setIsPlacemarks(boolean isPlacemarks){this.isPlacemarks = isPlacemarks;}
public void setIsPlacemarksRound(boolean isPlacemarksRound){this.isPlacemarksRound = isPlacemarksRound;}
public void setPlacemarksCount(int count){this.pCount = even(count); calculateStepAngle(pCount);}
public void setColorRingRadius(float colorRingRadius){this.color_rad = minVsize * colorRingRadius;}
public void setColorRingThickness(float colorRingThickness){this.color_rTh = color_rad * colorRingThickness;}
public void setIsColorPointerShadow(boolean isColorPointerShadow){this.isColorPointerShadow = isColorPointerShadow;}
public void setPointerRadius(float pointerRadius){this.pointer_rad = color_rad * pointerRadius;}
public void setIsPointerOutline(boolean isPointerOutline){this.isPointerOutline = isPointerOutline;}
public void setStepperMode(boolean stepperMode){if(isPlacemarks) this.stepperMode = stepperMode; if(this.stepperMode) calculateStepAngle(pCount);}
/** --------- Геттеры --------- */
public int[] getColor_palette() {return this.color_palette;}
public boolean getIsColorPointer(){return this.isColorPointer;}
public boolean getIsColorPointerCustomColor(){return this.isColorPointerCustomColor;}
public int getColorPointerCustomColor(){return this.p_cPointer.getColor();}
public boolean getIsPointerCustomColor(){return this.isPointerCustomColor;}
public int getPointerCustomColor(){return this.p_pointer.getColor();}
public float getColorPointerRadius(){return this.cPointer_rad;}
public boolean getIsBadge(){return this.isBadge;}
public boolean getIsRoundBadge(){return this.isRoundBadge;}
public float getBadgeSize(){return this.badge_size;}
public Bitmap getImageBitmap(){ return this.mainImageBitmap;}
public boolean getIsExColorPointer(){return this.isExColorPointer;}
public float getExColoPointerRadius(){return this.excPointer_rad;}
public int getBackgroundColor(){return this.p_background.getColor();}
public boolean getIsBackground(){return this.isBackground;}
public boolean getIsPointerLine(){return this.isPointerLine;}
public boolean getIsPointerShadow(){return this.isPointerShadow;}
public boolean getIsPlacemarks(){return this.isPlacemarks;}
public boolean getIsPlacemarksRound(){return this.isPlacemarksRound;}
public int getPlacemarksCount(){return this.pCount;}
public float getColorRingRadius(){return this.color_rad;}
public float getColorRingThickness(){return this.color_rTh;}
public boolean getIsColorPointerShadow(){return this.isColorPointerShadow;}
public float getPointerRadius(){return this.pointer_rad;}
public boolean getIsPointerOutline(){return this.isPointerOutline;}
public boolean getStepperMode() {return this.stepperMode;}
Если не использовать метод setColorPalette()
, то получим следующий результат:
Если массив начался с синего цвета, то закончится он тоже должен синим цветом, только так можно избежать такого резкого перехода цветов.
В сеттерах использовалась функция even() для задания только чётного числа меток. Так смотрится более лаконично. Принцип работы этой функции заключается в проверке остатка от деления на 2, если остаток есть, к текущему число просто прибавляем ещё единицу.
private int even(int c){
int cc;
if(c % 2 == 0) { cc = c; }
else{ cc = c + 1; }
return cc;
}
Заключение
Я получил интересный опыт в разработке пользовательских представлений, и очень надеюсь, что смог поделиться этим опытом с такими же новичками как я.
Ниже представлен итоговый результат в различных компоновках:
Ещё спойлер
Конечно же обошлось не без косяков во время разработки. Были достаточно интересные ошибки.
Весь код с подробной документацией доступен в Github проекта на английском языке.