На вкус и цвет 2 – не RGB единым
Приветствую всех читателей. Попробуем продолжить нашу затею, начало которой здесь.Итак, мы имеем кастомную View с разноцветным кружочком, из которого теперь необходимо выдернуть выбранный пользователем цвет. Перед тем как окунуться в дебри расчетов давайте для начала организуем какие-нибудь маркеры-указатели выбранного цвета. Не будем усложнять и сделаем их в виде простых линий — стрелок. Для них нам понадобится новая Paint и размеры. Чтобы не повторяться в дальнейшем, давайте рассчитаем сразу все необходимые параметры. Я сознательно пишу кучу отдельных переменных для наглядности.
Наши объявления и методы приобретают вид:
// Константы, определяющие что именно мы устанавливаем в данный момент protected static final int SET_COLOR = 0; protected static final int SET_SATUR = 1; protected static final int SET_ALPHA = 2; // и флаг, который будет устанавливаться в одну из этих констант. // (как-то непонятно я выразился) private int mMode;
float cx; float cy; float rad_1; // float rad_2; // float rad_3; // float r_centr; // радиусы наших окружностей
float r_sel_c; // float r_sel_s; // float r_sel_a; // границы полей выбора
// всякие краски private Paint p_color = new Paint (Paint.ANTI_ALIAS_FLAG); private Paint p_satur = new Paint (Paint.ANTI_ALIAS_FLAG); private Paint p_alpha = new Paint (Paint.ANTI_ALIAS_FLAG); private Paint p_white = new Paint (Paint.ANTI_ALIAS_FLAG); private Paint p_handl = new Paint (Paint.ANTI_ALIAS_FLAG); private Paint p_centr = new Paint (Paint.ANTI_ALIAS_FLAG);
private float deg_col; // углы поворота private float deg_sat; // указателей — стрелок private float deg_alp; // ********************
private float lc; // private float lm; // отступы и выступы линий private float lw; //
private void calcSizes () { // // cx = size * 0.5f; cy = cx; lm = size * 0.043f; lw = size * 0.035f; rad_1 = size * 0.44f; r_sel_c = size * 0.39f; rad_2 = size * 0.34f; r_sel_s = size * 0.29f; rad_3 = size * 0.24f; r_sel_a = size * 0.19f; r_centr = size * 0.18f;
lc = size * 0.08f; p_color.setStrokeWidth (lc); p_satur.setStrokeWidth (lc); p_alpha.setStrokeWidth (lc); } Для начала надо убедиться, что мы выбираем именно цвет на наружном кольце. Для этого к координатам расстояния от центра по горизонтали и по вертикали (в нашем коде это a и b в ACTION_DOWN), добавляем еще одну — расстояние от центра по прямой. По всем законам геометрии обзовем ее «с». И тут же вычислим, вспомнив труды гражданина Пифагора:
float c = (float) Math.sqrt (a * a + b * b); Теперь остается проверить, что место касания находится на наружном кольце, то есть с больше внутреннего радиуса кольца. Заодно, забегая вперед, выполним эти проверки для остальных еще не существующих колец. И выставим флаги. В конечном итоге:
case MotionEvent.ACTION_DOWN: float a = Math.abs (event.getX () — cx); float b = Math.abs (event.getY () — cy); float c = (float) Math.sqrt (a * a + b * b); if (c > r_sel_c) mode = SET_COLOR; else if (c < r_sel_c && c > r_sel_s) mode = SET_SATUR; else if (c < r_sel_s && c > r_sel_a) mode = SET_ALPHA; else if (c < r_centr) listener.onDismiss(mColor, alpha); break; Заметьте – проверку расстояния от центра мы выполняем только в ACTION_DOWN. То есть ткнув пальцем в наружное кольцо, мы можем потом сколько угодно елозить по нашей View даже за пределами зоны выбора цвета, меняться будет именно цвет. Пока мы не ткнем пальцем повторно и не сменим флаг mode.
Теперь в ACTION_MOVE будем получать новые координаты и определять выбранный цвет, насыщенность или прозрачность. Чтобы не засорять onTouch вынесем математику в отдельные методы. Ну и вызов invalidate () я думаю лучше сюда же поместить. У нас получилось:
case MotionEvent.ACTION_MOVE: float x = event.getX () — cx; float y = event.getY () — cy; switch (mMode) { case SET_COLOR: setColScale (getAngle (x, y)); break;
case SET_SATUR: setSatScale (getAngle (x, y)); break;
case SET_ALPHA: setAlphaScale (getAngle (x, y)); break; } invalidate (); break; } Методы типа два в одном. Рассмотрим подробнее. getAngle (x, y) — на основании координат определяем угол между положением пальца и центром View. Что-то типа такого:
protected float getAngle (float x, float y) { float deg = 0; if (x!= 0) deg = y / x; deg = (float) Math.toDegrees (Math.atan (deg)); if (x < 0) deg += 180; else if (x > 0 && y < 0) deg += 360; return deg; } На выходе получаем угол в градусах, который теперь необходимо как-то связать с цветом в этом секторе нашего градиента. На этом мысль зашла в тупик. Извращенческие идеи вычисления координат пикселов и анализа их цвета я как-то сразу отбросил. В голове вертелись слова пингвина из Мадагаскара – «Ковальски, предложите варианты…». В роли Ковальского выступил Гугл. И вот что он сказал.
Оказывается есть жизнь и на других планетах. И вместо такого родного и понятного ARGB там используют какой-то непонятный HSV. Что это за зверь такой? Например первая его буква? Вики заявляет, что это «Hue — цветовой тон… Варьируется в пределах 0 — 360…». Прикидываете, какое совпадение? А остальные буквы? S — Saturation — да это же наше второе кольцо! А V — Value — это яркость. И Андроид тут же предлагает нам пару функций:
Color.HSVToColor (int, float[]); Color.colorToHSV (int, float[]); Параметр int в первой функции — прозрачность, вспоминаем про наше третье кольцо. Во второй функции int это непосредственно цвет. И в обеих функциях float[] это массив из трех элементов, первый из которых соответственно буквам HSV и есть значение цвета палитры от 0 до 360. Жизнь, похоже, налаживается.
Объявляем массивы argb и hsv для хранения компонентов нашего цвета:
private int[] argb = new int[] { 255, 0, 0, 0};
private float[] hsv = new float[] {0, 1f, 1f}; И просто подставляем полученный ранее угол в градусах в качестве первого элемента массива.
protected void setColScale (float f) { deg_col = f; hsv[0] = f; mColor = Color.HSVToColor (argb[0], hsv); p_center.setColor (mColor); } Теперь у нас есть цвет, угол и полное право рисовать второе кольцо и стрелки. Вот код:
private void drawSaturGradient (Canvas c) {
SweepGradient s = null; int[] sg = new int[] { Color.HSVToColor (new float[] {deg_col, 1, 0}), Color.HSVToColor (new float[] {deg_col, 1, 1}), Color.HSVToColor (new float[] { hsv[0], 0, 1}), Color.HSVToColor (new float[] { hsv[0], 0, 0.5f}), Color.HSVToColor (new float[] {deg_col, 1, 0}) }; s = new SweepGradient (cx, cy, sg, null); p_satur.setShader (s); c.drawCircle (cx, cy, rad_2, p_satur);
} Очень похоже на предыдущий код, тот же массив для шейдера, тот же градиент. Только теперь в нем 5 цветов, каждый из которых мы выдираем из HSV. Причем насыщенность и яркость задаем вручную от 0 до 1, а в первый (в смысле нулевой) элемент массива я почему-то засунул значение угла. Более правильно было бы видеть там имеющееся у нас значение hsv[0], но это ведь одна и та же величина. В качестве доказательства я даже переправил в двух местах. Так что не забываем, что deg_col == hsv[0]. Ну угол мне первый под руку попался, простите.
Результат:
Думаю, всем понятно, что этот метод должен вызываться в onDraw (), как и следующие. Дада, мы вполне уже можем рисовать третье кольцо:
private void drawAlphaGradient (Canvas c) { // три белых линии на черном фоне как бы помогают визуально // оценить уровень прозрачности c.drawCircle (cx, cy, rad_3 — lw, p_white); c.drawCircle (cx, cy, rad_3, p_white); c.drawCircle (cx, cy, rad_3 + lw, p_white); // вытаскиваем компоненты RGB из нашего цвета int ir = Color.red (mColor); int ig = Color.green (mColor); int ib = Color.blue (mColor); // массив из двух цветов — наш и он же полностью прозрачный int e = Color.argb (0, ir, ig, ib); int[] mCol = new int[] {mColor, e}; // Это мы уже проходили Shader sw = new SweepGradient (cx, cy, mCol, null); p_alpha.setShader (sw); c.drawCircle (cx, cy, rad_3, p_alpha); } И стрелочки:
private void drawLines (Canvas c) { float d = deg_col; c.rotate (d, cx, cy); c.drawLine (cx + rad_1 + lm, cy, cx + rad_1 — lm, cy, p_handl); c.rotate (-d, cx, cy); d = deg_sat; c.rotate (d, cx, cy); c.drawLine (cx + rad_2 + lm, cy, cx + rad_2 — lm, cy, p_handl); c.rotate (-d, cx, cy); d = deg_alp; c.rotate (d, cx, cy); c.drawLine (cx + rad_3 + lm, cy, cx + rad_3 — lm, cy, p_handl); c.rotate (-d, cx, cy); } У кого-нибудь возник вопрос — зачем в последнем методе локальная переменная d? Возможно, это признаки моей паранойи. Если использовать непосредственно глобальную переменную deg_col или другие, за время отрисовки юзер может их изменить, водя пальцем по экрану. Понятное дело, что за те микросекунды отрисовки изменения будут ничтожными. Но тем не менее функции
c.rotate (deg_col, cx, cy); и c.rotate (-deg_col, cx, cy); будут поворачивать Canvas на разную величину. И разница эта будет постепенно накапливаться.
Ну не забываем, конечно, задать свойства для наших Paint по вкусу. У меня это как-то так:
private void init (Context context) { setFocusable (true);
p_color.setStyle (Style.STROKE); p_satur.setStyle (Style.STROKE); p_alpha.setStyle (Style.STROKE); p_center.setStyle (Style.FILL_AND_STROKE); p_white.setStrokeWidth (2); p_white.setColor (Color.WHITE); p_white.setStyle (Style.STROKE); p_handl.setStrokeWidth (5); p_handl.setColor (Color.WHITE); p_handl.setStrokeCap (Cap.ROUND); setOnTouchListener (this); } setFocusable (true) я пропустил в прошлой статье.
Возвращаемся к нашим OnTouch.
protected void setSatScale (float f) { deg_sat = f; if (f < 90) { hsv[1] = 1; hsv[2] = f / 90; } else if (f >= 90 && f < 180) { hsv[1] = 1 - (f - 90) / 90; hsv[2] = 1; } else { hsv[1] = 0; hsv[2] = 1 - (f - 180) / 180; } mColor = Color.HSVToColor(argb[0], hsv); p_center.setColor(mColor); }
protected void setAlphaScale (float f) { deg_alp = f; argb[0] = (int) (255 — f / 360×255); mColor = Color.HSVToColor (argb[0], hsv); alpha = (float) Color.alpha (mColor) / 255; p_center.setColor (mColor); } Ну что, нам осталось как-то вывести полученный результат. Тут опять же дело вкуса и конкретного варианта использования. Кому-то удобнее значение в Preference писать, кому-то Intent слать во все стороны. Я предлагаю организовать нашему View интерфейс, как у настоящего взрослого и самостоятельного контрола. Значение цвета мы можем слать однократно по нажатию на центр круга, можем в реалтайме, по мере изменения цвета в OnTouch. Гулять так гулять, сделаем и то, и другое:
private OnColorChangeListener listener;
public interface OnColorChangeListener { public void onDismiss (int val, float alpha); public void onColorChanged (int val, float alpha); }
public void setOnColorChangeListener (OnColorChangeListener l) { this.listener = l; } В OnTouch:
case MotionEvent.ACTION_DOWN: … … else if (c < r_centr) { listener.onDismiss(mColor, alpha); } break;
case MotionEvent.ACTION_MOVE: … … listener.onColorChanged (mColor, alpha); break; } return true; } Надеюсь, ничего не забыл. А, да. Желательно иметь возможность передавать в наш ColorPicker текущее значение цвета. Добавляем:
public void setUsedColor (int color, float a) { mColor = color; Color.colorToHSV (mColor, hsv); setColScale (hsv[0]); float deg = 0; if (hsv[1] == 1) deg = 90 * hsv[2]; else if (hsv[2] == 1) deg = 180 — 90 * hsv[1]; else if (hsv[1] == 0) deg = 360 — 180 * hsv[2]; setSatScale (deg); setAlphaScale (360 — 360 * a); } P.S: Еще один нюанс выяснился при практическом использовании. Попытка применить полученный цвет к картинкам (в виде ColorFilter) не меняет их прозрачность. Или я что-то пропустил? Если да — надеюсь, меня поправят более опытные товарищи. Пришлось использовать метод setAlpha, предварительно получив значение прозрачности методом Color.alpha (mColor). Значение int 0–255, а setAlpha (int) в последнее время deprecated. Требуется float от 0 до 1 (типа setAlpha ((float) Color.alpha (mColor) / 255));
Раз уж мы претендуем на универсальность нашего контрола, есть смысл засунуть эти вычисления в него. И выдавать прозрачность формата float 0–1. Можно отдельным методом в интерфейсе, можно вторым параметром дополнительно у цвету — дело вкуса. Добавил это в код.
Хотя для полной универсальности можно заставить его выдавать раздельно все компоненты — мало ли где понадобится. Не буду это сейчас реализовывать, думаю это не проблема даже для чайника.
Вот теперь все.