На вкус и цвет 2 – не RGB единым

в 7:00, , рубрики: canvas, colorpicker, Программирование, Разработка под android, метки: , ,

Приветствую всех читателей. Попробуем продолжить нашу затею, начало которой здесь.

Итак, мы имеем кастомную 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]. Ну угол мне первый под руку попался, простите.

Результат:

image

Думаю, всем понятно, что этот метод должен вызываться в 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. Можно отдельным методом в интерфейсе, можно вторым параметром дополнительно у цвету – дело вкуса. Добавил это в код.

Хотя для полной универсальности можно заставить его выдавать раздельно все компоненты – мало ли где понадобится. Не буду это сейчас реализовывать, думаю это не проблема даже для чайника.

Вот теперь все.

Автор: OJV

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js