Пишем собственный ColorWheel на основе представления (View) в Android

от автора

Приветствую всех.

Как-то одним вечером мне в голову забралась идея о создании собственного настраиваемого 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/


Комментарии

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

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