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

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

Комментарии (0)