Приветствую всех.
Как-то одним вечером мне в голову забралась идея о создании собственного настраиваемого 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 проекта на английском языке.
ссылка на оригинал статьи https://habr.com/ru/post/694852/
Добавить комментарий