From 4a83f72c5958220bd7a639b68ad09ba0709514ea Mon Sep 17 00:00:00 2001 From: skydoves Date: Wed, 16 Sep 2026 21:08:21 +0900 Subject: [PATCH 1/5] Support pinch to zoom for a bitmap palette A pinch gesture zooms the palette in and out and a two finger drag moves the zoomed palette, so tiny color spots of an image can be picked more precisely. A single touch still selects a color as before. The gesture is disabled by default and works with only a bitmap palette, not with the default HSV palette. Closes #48 --- .../colorpickerview/ColorPickerView.java | 330 +++++++++++++++++- .../src/main/res/values/attrs_colorpicker.xml | 8 + 2 files changed, 337 insertions(+), 1 deletion(-) diff --git a/colorpickerview/src/main/java/com/skydoves/colorpickerview/ColorPickerView.java b/colorpickerview/src/main/java/com/skydoves/colorpickerview/ColorPickerView.java index ef01ff5..f58a1f8 100644 --- a/colorpickerview/src/main/java/com/skydoves/colorpickerview/ColorPickerView.java +++ b/colorpickerview/src/main/java/com/skydoves/colorpickerview/ColorPickerView.java @@ -25,6 +25,7 @@ import android.graphics.Matrix; import android.graphics.Point; import android.graphics.Rect; +import android.graphics.RectF; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.Build; @@ -32,6 +33,7 @@ import android.util.AttributeSet; import android.view.Gravity; import android.view.MotionEvent; +import android.view.ScaleGestureDetector; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.widget.FrameLayout; @@ -72,6 +74,9 @@ @SuppressWarnings("unused") public class ColorPickerView extends FrameLayout implements LifecycleObserver { + private static final float MIN_ZOOM = 1.0f; + private static final float DEFAULT_MAX_ZOOM = 3.0f; + @ColorInt private int selectedPureColor; @ColorInt @@ -107,6 +112,15 @@ public class ColorPickerView extends FrameLayout implements LifecycleObserver { private boolean selectorPointValidation = true; private boolean resetBrightnessOnLowSaturation = true; private boolean syncSlidersWithPaletteColor = true; + private boolean zoomEnabled = false; + private float maxZoom = DEFAULT_MAX_ZOOM; + private float zoomScale = MIN_ZOOM; + private final Matrix paletteFitMatrix = new Matrix(); + private final Matrix paletteZoomMatrix = new Matrix(); + private ScaleGestureDetector zoomGestureDetector; + private boolean multiTouching = false; + private float lastFocusX; + private float lastFocusY; private final ColorPickerPreferenceManager preferenceManager = ColorPickerPreferenceManager.getInstance(getContext()); @@ -188,6 +202,12 @@ private void getAttrs(AttributeSet attrs) { this.syncSlidersWithPaletteColor = a.getBoolean(R.styleable.ColorPickerView_syncSlidersWithPaletteColor, syncSlidersWithPaletteColor); } + if (a.hasValue(R.styleable.ColorPickerView_zoomEnabled)) { + this.zoomEnabled = a.getBoolean(R.styleable.ColorPickerView_zoomEnabled, zoomEnabled); + } + if (a.hasValue(R.styleable.ColorPickerView_maxZoom)) { + setMaxZoom(a.getFloat(R.styleable.ColorPickerView_maxZoom, maxZoom)); + } } finally { a.recycle(); } @@ -221,6 +241,17 @@ private void onCreate() { addView(selector, selectorParam); selector.setAlpha(selector_alpha); + zoomGestureDetector = + new ScaleGestureDetector( + getContext(), + new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScale(@NonNull ScaleGestureDetector detector) { + zoomPalette(detector.getScaleFactor(), detector.getFocusX(), detector.getFocusY()); + return true; + } + }); + getViewTreeObserver() .addOnGlobalLayoutListener( new ViewTreeObserver.OnGlobalLayoutListener() { @@ -236,10 +267,12 @@ public void onGlobalLayout() { protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { super.onSizeChanged(width, height, oldWidth, oldHeight); - if (palette.getDrawable() == null) { + if (palette.getDrawable() == null && width > 0 && height > 0) { Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); palette.setImageDrawable(new ColorHsvPalette(getResources(), bitmap)); } + + updatePaletteZoom(); } private void onFinishInflated() { @@ -296,6 +329,8 @@ protected void onCreateByBuilder(Builder builder) { this.selectorPointValidation = builder.selectorPointValidation; this.resetBrightnessOnLowSaturation = builder.resetBrightnessOnLowSaturation; this.syncSlidersWithPaletteColor = builder.syncSlidersWithPaletteColor; + this.zoomEnabled = builder.zoomEnabled; + setMaxZoom(builder.maxZoom); } @SuppressLint("ClickableViewAccessibility") @@ -304,6 +339,10 @@ public boolean onTouchEvent(MotionEvent event) { if (!this.isEnabled()) { return false; } + if (isZoomable() && onZoomTouchReceived(event)) { + return true; + } + switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: @@ -357,6 +396,209 @@ private boolean onTouchReceived(final MotionEvent event) { return true; } + /** + * handles a multi touch event for zooming and moving the palette. + * + * @param event {@link MotionEvent}. + * @return the event is consumed by the zoom gesture or not. + */ + private boolean onZoomTouchReceived(MotionEvent event) { + zoomGestureDetector.onTouchEvent(event); + + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + multiTouching = false; + break; + case MotionEvent.ACTION_POINTER_DOWN: + multiTouching = true; + selector.setPressed(false); + updateLastFocus(event, -1); + break; + case MotionEvent.ACTION_MOVE: + if (multiTouching && event.getPointerCount() > 1) { + float focusX = getFocus(event, -1, true); + float focusY = getFocus(event, -1, false); + movePalette(focusX - lastFocusX, focusY - lastFocusY); + lastFocusX = focusX; + lastFocusY = focusY; + } + break; + case MotionEvent.ACTION_POINTER_UP: + // recalculates the focus without the released pointer to avoid jumping of the palette. + updateLastFocus(event, event.getActionIndex()); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + boolean consumed = multiTouching; + multiTouching = false; + return consumed; + default: + break; + } + return multiTouching; + } + + private void updateLastFocus(MotionEvent event, int excludedPointerIndex) { + lastFocusX = getFocus(event, excludedPointerIndex, true); + lastFocusY = getFocus(event, excludedPointerIndex, false); + } + + /** gets the center coordinate of the pointers, which is used as a focus of the zoom gesture. */ + private float getFocus(MotionEvent event, int excludedPointerIndex, boolean isXAxis) { + float sum = 0; + int count = 0; + for (int index = 0; index < event.getPointerCount(); index++) { + if (index == excludedPointerIndex) continue; + sum += isXAxis ? event.getX(index) : event.getY(index); + count++; + } + return count == 0 ? 0 : sum / count; + } + + /** zooms the palette by the given scale factor based on the focus of the gesture. */ + private void zoomPalette(float scaleFactor, float focusX, float focusY) { + float scale = zoomScale * scaleFactor; + if (scale < MIN_ZOOM) { + scaleFactor = MIN_ZOOM / zoomScale; + } else if (scale > maxZoom) { + scaleFactor = maxZoom / zoomScale; + } + if (scaleFactor == 1.0f) return; + + Matrix delta = new Matrix(); + delta.setScale(scaleFactor, scaleFactor, focusX, focusY); + transformPalette(delta); + } + + /** moves the zoomed palette by the given distances. */ + private void movePalette(float dx, float dy) { + if (dx == 0 && dy == 0) return; + + Matrix delta = new Matrix(); + delta.setTranslate(dx, dy); + transformPalette(delta); + } + + /** applies the given transformation to the palette and the selector together. */ + private void transformPalette(Matrix delta) { + paletteZoomMatrix.postConcat(delta); + + Matrix boundary = getPaletteBoundaryMatrix(); + if (boundary != null) { + paletteZoomMatrix.postConcat(boundary); + delta.postConcat(boundary); + } + + float[] values = new float[9]; + paletteZoomMatrix.getValues(values); + zoomScale = values[Matrix.MSCALE_X]; + + applyPaletteMatrix(); + moveSelectorByMatrix(delta); + } + + /** + * gets a translation matrix, which keeps the zoomed palette covering the view. returns null if + * the palette is already in the boundary. + */ + private @Nullable Matrix getPaletteBoundaryMatrix() { + Drawable drawable = palette.getDrawable(); + if (drawable == null) return null; + + Matrix matrix = new Matrix(paletteFitMatrix); + matrix.postConcat(paletteZoomMatrix); + RectF rect = new RectF(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); + matrix.mapRect(rect); + + float dx = 0; + if (rect.width() <= getWidth()) { + dx = (getWidth() - rect.width()) * 0.5f - rect.left; + } else if (rect.left > 0) { + dx = -rect.left; + } else if (rect.right < getWidth()) { + dx = getWidth() - rect.right; + } + + float dy = 0; + if (rect.height() <= getHeight()) { + dy = (getHeight() - rect.height()) * 0.5f - rect.top; + } else if (rect.top > 0) { + dy = -rect.top; + } else if (rect.bottom < getHeight()) { + dy = getHeight() - rect.bottom; + } + + if (dx == 0 && dy == 0) return null; + + Matrix boundary = new Matrix(); + boundary.setTranslate(dx, dy); + return boundary; + } + + /** keeps the selector on the same position of the palette after the palette is transformed. */ + private void moveSelectorByMatrix(Matrix delta) { + if (selectedPoint == null) return; + + float[] points = new float[] {selectedPoint.x, selectedPoint.y}; + delta.mapPoints(points); + selectedPoint = new Point((int) points[0], (int) points[1]); + setCoordinate(selectedPoint.x, selectedPoint.y); + notifyToFlagView(selectedPoint); + } + + private void applyPaletteMatrix() { + Matrix matrix = new Matrix(paletteFitMatrix); + matrix.postConcat(paletteZoomMatrix); + palette.setScaleType(ImageView.ScaleType.MATRIX); + palette.setImageMatrix(matrix); + } + + /** + * calculates the matrix, which draws the palette drawable as the {@link + * ImageView.ScaleType#FIT_CENTER} does. + * + * @return the matrix is calculated or not. + */ + private boolean updatePaletteFitMatrix() { + Drawable drawable = palette.getDrawable(); + if (drawable == null || getWidth() == 0 || getHeight() == 0) return false; + + float width = drawable.getIntrinsicWidth(); + float height = drawable.getIntrinsicHeight(); + if (width <= 0 || height <= 0) return false; + + float scale = Math.min(getWidth() / width, getHeight() / height); + paletteFitMatrix.setScale(scale, scale); + paletteFitMatrix.postTranslate( + (getWidth() - width * scale) * 0.5f, (getHeight() - height * scale) * 0.5f); + return true; + } + + /** restores the zoom state whenever the palette drawable or the size of the view is changed. */ + private void updatePaletteZoom() { + if (!zoomEnabled || palette == null) return; + + if (isZoomable() && updatePaletteFitMatrix()) { + paletteZoomMatrix.reset(); + zoomScale = MIN_ZOOM; + applyPaletteMatrix(); + } else { + restorePaletteScaleType(); + } + } + + private void restorePaletteScaleType() { + paletteZoomMatrix.reset(); + zoomScale = MIN_ZOOM; + palette.setScaleType(ImageView.ScaleType.FIT_CENTER); + palette.setImageMatrix(new Matrix()); + } + + /** the zoom gesture works only with a bitmap palette, not with the default HSV palette. */ + private boolean isZoomable() { + return zoomEnabled && palette.getDrawable() != null && !isHuePalette(); + } + /** * synchronizes the {@link AlphaSlideBar} and the {@link BrightnessSlideBar} with a color, which * is picked from a bitmap palette. @@ -831,6 +1073,7 @@ public void setPaletteDrawable(Drawable drawable) { selectedPureColor = Color.WHITE; notifyToSlideBars(); + updatePaletteZoom(); if (flagView != null) { removeView(flagView); @@ -1092,6 +1335,79 @@ public void setSyncSlidersWithPaletteColor(boolean enabled) { this.syncSlidersWithPaletteColor = enabled; } + /** + * Returns whether the palette can be zoomed by a pinch gesture. + * + * @return true if the zoom gesture is enabled, false otherwise. + */ + public boolean isZoomEnabled() { + return zoomEnabled; + } + + /** + * Sets whether the palette can be zoomed by a pinch gesture. + * + *

When enabled, a pinch gesture zooms the palette in and out, and a two finger drag moves the + * zoomed palette. A single touch still selects a color, so tiny color spots of an image can be + * picked more precisely. + * + *

The zoom gesture works only with a bitmap palette, which is set by {@link + * #setPaletteDrawable(Drawable)}. It does not affect the default HSV palette. + * + * @param enabled true to enable the zoom gesture, false to disable. + */ + public void setZoomEnabled(boolean enabled) { + if (this.zoomEnabled == enabled) return; + + this.zoomEnabled = enabled; + if (palette == null) return; + + if (enabled) { + updatePaletteZoom(); + } else { + restorePaletteScaleType(); + } + } + + /** + * gets the maximum scale of the zoom gesture. + * + * @return the maximum scale of the zoom gesture. + */ + public float getMaxZoom() { + return maxZoom; + } + + /** + * sets the maximum scale of the zoom gesture, which should be bigger than 1.0. + * + * @param maxZoom the maximum scale of the zoom gesture. + */ + public void setMaxZoom(float maxZoom) { + this.maxZoom = Math.max(MIN_ZOOM, maxZoom); + } + + /** + * gets the current scale of the zoomed palette. + * + * @return the current scale of the zoomed palette. + */ + public float getZoomScale() { + return zoomScale; + } + + /** restores the zoomed palette to its original scale and position. */ + public void resetZoom() { + if (!isZoomable()) return; + + Matrix delta = new Matrix(); + if (paletteZoomMatrix.invert(delta)) { + transformPalette(delta); + } else { + updatePaletteZoom(); + } + } + /** * sets the {@link LifecycleOwner}. * @@ -1156,6 +1472,8 @@ public static class Builder { private boolean selectorPointValidation = true; private boolean resetBrightnessOnLowSaturation = true; private boolean syncSlidersWithPaletteColor = true; + private boolean zoomEnabled = false; + private float maxZoom = DEFAULT_MAX_ZOOM; public Builder(Context context) { this.context = context; @@ -1266,6 +1584,16 @@ public Builder setSyncSlidersWithPaletteColor(boolean enabled) { return this; } + public Builder setZoomEnabled(boolean enabled) { + this.zoomEnabled = enabled; + return this; + } + + public Builder setMaxZoom(float maxZoom) { + this.maxZoom = maxZoom; + return this; + } + public ColorPickerView build() { ColorPickerView colorPickerView = new ColorPickerView(context); colorPickerView.onCreateByBuilder(this); diff --git a/colorpickerview/src/main/res/values/attrs_colorpicker.xml b/colorpickerview/src/main/res/values/attrs_colorpicker.xml index c60e93c..02c7f37 100644 --- a/colorpickerview/src/main/res/values/attrs_colorpicker.xml +++ b/colorpickerview/src/main/res/values/attrs_colorpicker.xml @@ -47,6 +47,10 @@ + + + + @@ -71,5 +75,9 @@ + + + + From fa844ed74da565a9a2b207b394bb4a2f201cc242 Mon Sep 17 00:00:00 2001 From: skydoves Date: Wed, 16 Sep 2026 21:08:21 +0900 Subject: [PATCH 2/5] Add a stroke option to AlphaTileView The painted color isn't distinguishable when the background has the same color, so AlphaTileView can draw a stroke on its edge now. Closes #119 --- .../colorpickerview/AlphaTileView.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/colorpickerview/src/main/java/com/skydoves/colorpickerview/AlphaTileView.java b/colorpickerview/src/main/java/com/skydoves/colorpickerview/AlphaTileView.java index 7e83166..991aa00 100644 --- a/colorpickerview/src/main/java/com/skydoves/colorpickerview/AlphaTileView.java +++ b/colorpickerview/src/main/java/com/skydoves/colorpickerview/AlphaTileView.java @@ -26,7 +26,11 @@ import android.os.Build; import android.util.AttributeSet; import android.view.View; +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.DimenRes; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import com.skydoves.colorpickerview.sliders.AlphaTileDrawable; /** AlphaTileView visualizes ARGB color on the canvas using {@link AlphaTileDrawable}. */ @@ -34,7 +38,9 @@ public class AlphaTileView extends View { private Paint colorPaint; + private Paint strokePaint; private Bitmap backgroundBitmap; + private float strokeSize = 0f; private final AlphaTileDrawable.Builder builder = new AlphaTileDrawable.Builder(); public AlphaTileView(Context context) { @@ -64,6 +70,9 @@ public AlphaTileView( private void onCreate() { this.colorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + this.strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + this.strokePaint.setStyle(Paint.Style.STROKE); + this.strokePaint.setColor(Color.TRANSPARENT); this.setBackgroundColor(Color.WHITE); } @@ -81,6 +90,13 @@ private void getAttrs(AttributeSet attrs) { builder.setTileEvenColor( a.getInt(R.styleable.AlphaTileView_tileEvenColor, builder.getTileEvenColor())); } + if (a.hasValue(R.styleable.AlphaTileView_tileStrokeColor)) { + strokePaint.setColor( + a.getColor(R.styleable.AlphaTileView_tileStrokeColor, strokePaint.getColor())); + } + if (a.hasValue(R.styleable.AlphaTileView_tileStrokeSize)) { + setStrokeSize(a.getDimension(R.styleable.AlphaTileView_tileStrokeSize, strokeSize)); + } } finally { a.recycle(); } @@ -110,6 +126,16 @@ protected void onDraw(Canvas canvas) { canvas.drawBitmap(backgroundBitmap, 0, 0, null); } canvas.drawRect(0, 0, getWidth(), getMeasuredHeight(), colorPaint); + drawStroke(canvas); + } + + /** draws the stroke inside of the view, so it isn't covered by the painted color. */ + private void drawStroke(Canvas canvas) { + if (strokeSize <= 0 || Color.alpha(strokePaint.getColor()) == 0) return; + + float half = strokeSize * 0.5f; + canvas.drawRect( + half, half, getWidth() - half, getMeasuredHeight() - half, strokePaint); } public void setPaintColor(int color) { @@ -117,6 +143,46 @@ public void setPaintColor(int color) { invalidate(); } + /** + * sets a color of the stroke, which is drawn on the edge of the view. it can be used for + * distinguishing the painted color from the background. + * + * @param color a color of the stroke. + */ + public void setStrokeColor(@ColorInt int color) { + strokePaint.setColor(color); + invalidate(); + } + + /** + * sets a color resource of the stroke, which is drawn on the edge of the view. + * + * @param resource a color resource of the stroke. + */ + public void setStrokeColorRes(@ColorRes int resource) { + setStrokeColor(ContextCompat.getColor(getContext(), resource)); + } + + /** + * sets a size of the stroke in pixels, which is drawn on the edge of the view. + * + * @param strokeSize a size of the stroke in pixels. + */ + public void setStrokeSize(float strokeSize) { + this.strokeSize = Math.max(0f, strokeSize); + this.strokePaint.setStrokeWidth(this.strokeSize); + invalidate(); + } + + /** + * sets a size of the stroke using a dimension resource. + * + * @param resource a dimension resource of the stroke size. + */ + public void setStrokeSizeRes(@DimenRes int resource) { + setStrokeSize(getContext().getResources().getDimension(resource)); + } + @Override public void setBackgroundColor(int color) { setPaintColor(color); From 5d5d168dfa3ea2493a2321b629b1611b857b7c14 Mon Sep 17 00:00:00 2001 From: skydoves Date: Wed, 16 Sep 2026 21:08:21 +0900 Subject: [PATCH 3/5] Add button text color options to ColorPickerDialog The buttons of the dialog are created when the dialog is shown, so the text colors are applied on showing. Also updates the ABI dump file. Closes #120 --- colorpickerview/api/colorpickerview.api | 18 ++++ .../colorpickerview/ColorPickerDialog.java | 100 +++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/colorpickerview/api/colorpickerview.api b/colorpickerview/api/colorpickerview.api index 8684fe1..fcdedcb 100644 --- a/colorpickerview/api/colorpickerview.api +++ b/colorpickerview/api/colorpickerview.api @@ -14,6 +14,10 @@ public class com/skydoves/colorpickerview/AlphaTileView : android/view/View { protected fun onSizeChanged (IIII)V public fun setBackgroundColor (I)V public fun setPaintColor (I)V + public fun setStrokeColor (I)V + public fun setStrokeColorRes (I)V + public fun setStrokeSize (F)V + public fun setStrokeSizeRes (I)V } public class com/skydoves/colorpickerview/ColorEnvelope { @@ -77,10 +81,14 @@ public class com/skydoves/colorpickerview/ColorPickerDialog$Builder : androidx/a public fun setNegativeButton (ILandroid/content/DialogInterface$OnClickListener;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; public synthetic fun setNegativeButton (Ljava/lang/CharSequence;Landroid/content/DialogInterface$OnClickListener;)Landroidx/appcompat/app/AlertDialog$Builder; public fun setNegativeButton (Ljava/lang/CharSequence;Landroid/content/DialogInterface$OnClickListener;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; + public fun setNegativeButtonTextColor (I)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; + public fun setNegativeButtonTextColorRes (I)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; public synthetic fun setNeutralButton (ILandroid/content/DialogInterface$OnClickListener;)Landroidx/appcompat/app/AlertDialog$Builder; public fun setNeutralButton (ILandroid/content/DialogInterface$OnClickListener;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; public synthetic fun setNeutralButton (Ljava/lang/CharSequence;Landroid/content/DialogInterface$OnClickListener;)Landroidx/appcompat/app/AlertDialog$Builder; public fun setNeutralButton (Ljava/lang/CharSequence;Landroid/content/DialogInterface$OnClickListener;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; + public fun setNeutralButtonTextColor (I)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; + public fun setNeutralButtonTextColorRes (I)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; public synthetic fun setOnCancelListener (Landroid/content/DialogInterface$OnCancelListener;)Landroidx/appcompat/app/AlertDialog$Builder; public fun setOnCancelListener (Landroid/content/DialogInterface$OnCancelListener;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; public synthetic fun setOnDismissListener (Landroid/content/DialogInterface$OnDismissListener;)Landroidx/appcompat/app/AlertDialog$Builder; @@ -95,6 +103,8 @@ public class com/skydoves/colorpickerview/ColorPickerDialog$Builder : androidx/a public synthetic fun setPositiveButton (Ljava/lang/CharSequence;Landroid/content/DialogInterface$OnClickListener;)Landroidx/appcompat/app/AlertDialog$Builder; public fun setPositiveButton (Ljava/lang/CharSequence;Landroid/content/DialogInterface$OnClickListener;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; public fun setPositiveButton (Ljava/lang/CharSequence;Lcom/skydoves/colorpickerview/listeners/ColorPickerViewListener;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; + public fun setPositiveButtonTextColor (I)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; + public fun setPositiveButtonTextColorRes (I)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; public fun setPreferenceName (Ljava/lang/String;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; public synthetic fun setSingleChoiceItems (IILandroid/content/DialogInterface$OnClickListener;)Landroidx/appcompat/app/AlertDialog$Builder; public fun setSingleChoiceItems (IILandroid/content/DialogInterface$OnClickListener;)Lcom/skydoves/colorpickerview/ColorPickerDialog$Builder; @@ -132,22 +142,26 @@ public class com/skydoves/colorpickerview/ColorPickerView : android/widget/Frame protected fun getColorFromBitmap (FF)I public fun getDebounceDuration ()J public fun getFlagView ()Lcom/skydoves/colorpickerview/flag/FlagView; + public fun getMaxZoom ()F public fun getPreferenceName ()Ljava/lang/String; public fun getPureColor ()I public fun getSelectedPoint ()Landroid/graphics/Point; public fun getSelector ()Landroid/widget/ImageView; public fun getSelectorX ()F public fun getSelectorY ()F + public fun getZoomScale ()F public fun isHuePalette ()Z public fun isResetBrightnessOnLowSaturationEnabled ()Z public fun isSelectorPointValidationEnabled ()Z public fun isSyncSlidersWithPaletteColorEnabled ()Z + public fun isZoomEnabled ()Z public fun moveSelectorPoint (III)V protected fun onCreateByBuilder (Lcom/skydoves/colorpickerview/ColorPickerView$Builder;)V public fun onDestroy ()V protected fun onSizeChanged (IIII)V public fun onTouchEvent (Landroid/view/MotionEvent;)Z public fun removeLifecycleOwner (Landroidx/lifecycle/LifecycleOwner;)V + public fun resetZoom ()V public fun selectByHsvColor (I)V public fun selectByHsvColorRes (I)V public fun selectCenter ()V @@ -162,6 +176,7 @@ public class com/skydoves/colorpickerview/ColorPickerView : android/widget/Frame public fun setInitialColor (I)V public fun setInitialColorRes (I)V public fun setLifecycleOwner (Landroidx/lifecycle/LifecycleOwner;)V + public fun setMaxZoom (F)V public fun setPaletteDrawable (Landroid/graphics/drawable/Drawable;)V public fun setPreferenceName (Ljava/lang/String;)V public fun setPureColor (I)V @@ -170,6 +185,7 @@ public class com/skydoves/colorpickerview/ColorPickerView : android/widget/Frame public fun setSelectorPoint (II)V public fun setSelectorPointValidation (Z)V public fun setSyncSlidersWithPaletteColor (Z)V + public fun setZoomEnabled (Z)V } public class com/skydoves/colorpickerview/ColorPickerView$Builder { @@ -187,6 +203,7 @@ public class com/skydoves/colorpickerview/ColorPickerView$Builder { public fun setInitialColor (I)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; public fun setInitialColorRes (I)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; public fun setLifecycleOwner (Landroidx/lifecycle/LifecycleOwner;)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; + public fun setMaxZoom (F)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; public fun setPaletteDrawable (Landroid/graphics/drawable/Drawable;)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; public fun setPreferenceName (Ljava/lang/String;)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; public fun setResetBrightnessOnLowSaturation (Z)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; @@ -196,6 +213,7 @@ public class com/skydoves/colorpickerview/ColorPickerView$Builder { public fun setSelectorSize (I)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; public fun setSyncSlidersWithPaletteColor (Z)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; public fun setWidth (I)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; + public fun setZoomEnabled (Z)Lcom/skydoves/colorpickerview/ColorPickerView$Builder; } public class com/skydoves/colorpickerview/FadeUtils { diff --git a/colorpickerview/src/main/java/com/skydoves/colorpickerview/ColorPickerDialog.java b/colorpickerview/src/main/java/com/skydoves/colorpickerview/ColorPickerDialog.java index 632d208..8371fe3 100644 --- a/colorpickerview/src/main/java/com/skydoves/colorpickerview/ColorPickerDialog.java +++ b/colorpickerview/src/main/java/com/skydoves/colorpickerview/ColorPickerDialog.java @@ -23,10 +23,14 @@ import android.view.LayoutInflater; import android.view.View; import android.widget.AdapterView; +import android.widget.Button; import android.widget.ListAdapter; +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; import com.skydoves.colorpickerview.databinding.ColorpickerviewDialogColorpickerBinding; import com.skydoves.colorpickerview.listeners.ColorEnvelopeListener; @@ -58,6 +62,9 @@ public static class Builder extends AlertDialog.Builder { private boolean shouldAttachAlphaSlideBar = true; private boolean shouldAttachBrightnessSlideBar = true; private int bottomSpace = SizeUtils.dp2Px(getContext(), 10); + private Integer positiveButtonTextColor; + private Integer negativeButtonTextColor; + private Integer neutralButtonTextColor; public Builder(Context context) { super(context); @@ -154,6 +161,69 @@ public Builder setBottomSpace(int bottomSpace) { return this; } + /** + * sets a text color of the positive button. + * + * @param color a text color of the positive button. + * @return {@link Builder}. + */ + public Builder setPositiveButtonTextColor(@ColorInt int color) { + this.positiveButtonTextColor = color; + return this; + } + + /** + * sets a text color resource of the positive button. + * + * @param resource a text color resource of the positive button. + * @return {@link Builder}. + */ + public Builder setPositiveButtonTextColorRes(@ColorRes int resource) { + return setPositiveButtonTextColor(ContextCompat.getColor(getContext(), resource)); + } + + /** + * sets a text color of the negative button. + * + * @param color a text color of the negative button. + * @return {@link Builder}. + */ + public Builder setNegativeButtonTextColor(@ColorInt int color) { + this.negativeButtonTextColor = color; + return this; + } + + /** + * sets a text color resource of the negative button. + * + * @param resource a text color resource of the negative button. + * @return {@link Builder}. + */ + public Builder setNegativeButtonTextColorRes(@ColorRes int resource) { + return setNegativeButtonTextColor(ContextCompat.getColor(getContext(), resource)); + } + + /** + * sets a text color of the neutral button. + * + * @param color a text color of the neutral button. + * @return {@link Builder}. + */ + public Builder setNeutralButtonTextColor(@ColorInt int color) { + this.neutralButtonTextColor = color; + return this; + } + + /** + * sets a text color resource of the neutral button. + * + * @param resource a text color resource of the neutral button. + * @return {@link Builder}. + */ + public Builder setNeutralButtonTextColorRes(@ColorRes int resource) { + return setNeutralButtonTextColor(ContextCompat.getColor(getContext(), resource)); + } + /** * sets positive button with {@link ColorPickerViewListener} on the {@link ColorPickerDialog}. * @@ -248,7 +318,35 @@ public AlertDialog create() { } super.setView(dialogBinding.getRoot()); - return super.create(); + + AlertDialog dialog = super.create(); + applyButtonTextColors(dialog); + return dialog; + } + + /** the buttons are created when the dialog is shown, so the colors are applied on showing. */ + private void applyButtonTextColors(AlertDialog dialog) { + if (positiveButtonTextColor == null + && negativeButtonTextColor == null + && neutralButtonTextColor == null) { + return; + } + + dialog.setOnShowListener( + dialogInterface -> { + setButtonTextColor(dialog, AlertDialog.BUTTON_POSITIVE, positiveButtonTextColor); + setButtonTextColor(dialog, AlertDialog.BUTTON_NEGATIVE, negativeButtonTextColor); + setButtonTextColor(dialog, AlertDialog.BUTTON_NEUTRAL, neutralButtonTextColor); + }); + } + + private void setButtonTextColor(AlertDialog dialog, int whichButton, Integer color) { + if (color == null) return; + + Button button = dialog.getButton(whichButton); + if (button != null) { + button.setTextColor(color); + } } @Override From 162d8853dfa42a45e81ba302c628202d6c9dfa9b Mon Sep 17 00:00:00 2001 From: skydoves Date: Wed, 16 Sep 2026 21:08:21 +0900 Subject: [PATCH 4/5] Use ViewBinding in the demo app Replaces the findViewById calls of MainActivity and adds a Zoom menu for demonstrating the new zoom gesture. Closes #199 --- app/build.gradle.kts | 4 ++ .../colorpickerviewdemo/MainActivity.java | 37 +++++++++++-------- .../colorpickerviewdemo/PowerMenuUtils.java | 1 + 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6c393e..9c02ae2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,6 +32,10 @@ android { versionName = Configuration.versionName } + buildFeatures { + viewBinding = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/app/src/main/java/com/skydoves/colorpickerviewdemo/MainActivity.java b/app/src/main/java/com/skydoves/colorpickerviewdemo/MainActivity.java index 8861297..45d6b13 100644 --- a/app/src/main/java/com/skydoves/colorpickerviewdemo/MainActivity.java +++ b/app/src/main/java/com/skydoves/colorpickerviewdemo/MainActivity.java @@ -25,20 +25,17 @@ import android.net.Uri; import android.os.Bundle; import android.view.View; -import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; -import com.skydoves.colorpickerview.AlphaTileView; import com.skydoves.colorpickerview.ColorEnvelope; import com.skydoves.colorpickerview.ColorPickerDialog; import com.skydoves.colorpickerview.ColorPickerView; import com.skydoves.colorpickerview.flag.BubbleFlag; import com.skydoves.colorpickerview.flag.FlagMode; import com.skydoves.colorpickerview.listeners.ColorEnvelopeListener; -import com.skydoves.colorpickerview.sliders.AlphaSlideBar; -import com.skydoves.colorpickerview.sliders.BrightnessSlideBar; +import com.skydoves.colorpickerviewdemo.databinding.ActivityMainBinding; import com.skydoves.powermenu.OnMenuItemClickListener; import com.skydoves.powermenu.PowerMenu; import com.skydoves.powermenu.PowerMenuItem; @@ -50,6 +47,7 @@ public class MainActivity extends AppCompatActivity { + private ActivityMainBinding binding; private ColorPickerView colorPickerView; private boolean FLAG_PALETTE = false; @@ -75,6 +73,9 @@ public void onItemClick(int position, PowerMenuItem item) { case 3: dialog(); break; + case 4: + zoom(); + break; } powerMenu.dismiss(); } @@ -83,11 +84,12 @@ public void onItemClick(int position, PowerMenuItem item) { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); + binding = ActivityMainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); powerMenu = PowerMenuUtils.getPowerMenu(this, this, powerMenuItemClickListener); - colorPickerView = findViewById(R.id.colorPickerView); + colorPickerView = binding.colorPickerView; BubbleFlag bubbleFlag = new BubbleFlag(this); bubbleFlag.setFlagMode(FlagMode.FADE); colorPickerView.setFlagView(bubbleFlag); @@ -99,12 +101,10 @@ protected void onCreate(Bundle savedInstanceState) { }); // attach alphaSlideBar - final AlphaSlideBar alphaSlideBar = findViewById(R.id.alphaSlideBar); - colorPickerView.attachAlphaSlider(alphaSlideBar); + colorPickerView.attachAlphaSlider(binding.alphaSlideBar); // attach brightnessSlideBar - final BrightnessSlideBar brightnessSlideBar = findViewById(R.id.brightnessSlide); - colorPickerView.attachBrightnessSlider(brightnessSlideBar); + colorPickerView.attachBrightnessSlider(binding.brightnessSlide); colorPickerView.setLifecycleOwner(this); } @@ -115,11 +115,8 @@ protected void onCreate(Bundle savedInstanceState) { */ @SuppressLint("SetTextI18n") private void setLayoutColor(ColorEnvelope envelope) { - TextView textView = findViewById(R.id.textView); - textView.setText("#" + envelope.getHexCode()); - - AlphaTileView alphaTileView = findViewById(R.id.alphaTileView); - alphaTileView.setPaintColor(envelope.getColor()); + binding.textView.setText("#" + envelope.getHexCode()); + binding.alphaTileView.setPaintColor(envelope.getColor()); } /** @@ -162,6 +159,16 @@ private void selector() { FLAG_SELECTOR = !FLAG_SELECTOR; } + /** + * enables or disables the pinch zoom gesture of the palette. the zoom gesture works with only a + * bitmap palette, which can be changed by the Palette menu. + */ + private void zoom() { + boolean enabled = !colorPickerView.isZoomEnabled(); + colorPickerView.setZoomEnabled(enabled); + Timber.d("zoom enabled: %s", enabled); + } + /** * shows ColorPickerDialog */ diff --git a/app/src/main/java/com/skydoves/colorpickerviewdemo/PowerMenuUtils.java b/app/src/main/java/com/skydoves/colorpickerviewdemo/PowerMenuUtils.java index 94582be..32c6219 100644 --- a/app/src/main/java/com/skydoves/colorpickerviewdemo/PowerMenuUtils.java +++ b/app/src/main/java/com/skydoves/colorpickerviewdemo/PowerMenuUtils.java @@ -36,6 +36,7 @@ public static PowerMenu getPowerMenu( .addItem(new PowerMenuItem("Palette(Gallery)", false)) .addItem(new PowerMenuItem("Selector", false)) .addItem(new PowerMenuItem("Dialog", false)) + .addItem(new PowerMenuItem("Zoom", false)) .setLifecycleOwner(lifecycleOwner) .setAnimation(MenuAnimation.SHOWUP_TOP_LEFT) .setCircularEffect(CircularEffect.BODY) From c2a0cb95527623260e6c65cced3c8c92ac7bbcb0 Mon Sep 17 00:00:00 2001 From: skydoves Date: Wed, 16 Sep 2026 21:09:37 +0900 Subject: [PATCH 5/5] Document the new options on the docs and README --- README.md | 2 ++ docs/alphatileview.md | 20 ++++++++++++++++++++ docs/color-picker-dialog.md | 3 +++ docs/colorpickerview.md | 31 +++++++++++++++++++++++++++++++ docs/getting-started.md | 12 ++++++++++-- 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d77ca32..164cfad 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ app:actionMode="last" // sets action mode 'always' or 'last'. app:initialColor="@color/colorPrimary" app:preferenceName="MyColorPicker" // sets a preference name. app:debounceDuration="200" // sets a debounce duration of the invoking color listener. +app:zoomEnabled="true" // enables the pinch zoom gesture. This attribute will work with only a bitmap palette. +app:maxZoom="5.0" // sets the maximum scale of the zoom gesture. ``` ### ColorListener diff --git a/docs/alphatileview.md b/docs/alphatileview.md index 0d6d049..4f72288 100644 --- a/docs/alphatileview.md +++ b/docs/alphatileview.md @@ -31,6 +31,8 @@ When displaying colors with transparency on a regular view, the alpha channel mi | `tileSize` | The size of each repeating tile in dp | 10 | | `tileEvenColor` | The color of even tiles | White | | `tileOddColor` | The color of odd tiles | Light Gray | +| `tileStrokeColor` | The color of the stroke, which is drawn on the edge | Transparent | +| `tileStrokeSize` | The size of the stroke, which is drawn on the edge | 0dp | ## Setting the Color @@ -50,6 +52,24 @@ Set the display color programmatically: alphaTileView.setPaintColor(Color.argb(128, 255, 0, 0)); // Semi-transparent red ``` +## Stroke + +A stroke separates the painted color from the background when both of them have the same color: + +=== "Kotlin" + + ```kotlin + alphaTileView.setStrokeColor(Color.DKGRAY) + alphaTileView.setStrokeSize(4f) // in pixels, setStrokeSizeRes takes a dimension resource. + ``` + +=== "Java" + + ```java + alphaTileView.setStrokeColor(Color.DKGRAY); + alphaTileView.setStrokeSize(4f); // in pixels, setStrokeSizeRes takes a dimension resource. + ``` + ## With ColorEnvelope Use `AlphaTileView` with `ColorEnvelopeListener` to display the selected color: diff --git a/docs/color-picker-dialog.md b/docs/color-picker-dialog.md index 94dbf96..d0fa672 100644 --- a/docs/color-picker-dialog.md +++ b/docs/color-picker-dialog.md @@ -64,6 +64,9 @@ | `attachAlphaSlideBar(Boolean)` | Attaches an alpha slider (default: true) | | `attachBrightnessSlideBar(Boolean)` | Attaches a brightness slider (default: true) | | `setBottomSpace(Int)` | Sets bottom space between the last slider and buttons | +| `setPositiveButtonTextColor(Int)` | Sets a text color of the positive button | +| `setNegativeButtonTextColor(Int)` | Sets a text color of the negative button | +| `setNeutralButtonTextColor(Int)` | Sets a text color of the neutral button | ## Customizing the ColorPickerView diff --git a/docs/colorpickerview.md b/docs/colorpickerview.md index cd0c4e0..8b8e952 100644 --- a/docs/colorpickerview.md +++ b/docs/colorpickerview.md @@ -129,6 +129,30 @@ Or select the center of the palette: colorPickerView.selectCenter() ``` +## Zoom + +A bitmap palette can be zoomed by a pinch gesture, which helps to pick tiny color spots of an image. +The gesture is disabled by default and it doesn't affect the default HSV palette. + +=== "Kotlin" + + ```kotlin + colorPickerView.setPaletteDrawable(drawable) + colorPickerView.isZoomEnabled = true + colorPickerView.maxZoom = 5.0f // the default value is 3.0f. + ``` + +=== "Java" + + ```java + colorPickerView.setPaletteDrawable(drawable); + colorPickerView.setZoomEnabled(true); + colorPickerView.setMaxZoom(5.0f); // the default value is 3.0f. + ``` + +A pinch gesture zooms the palette in and out and a two finger drag moves the zoomed palette, +while a single touch still selects a color. `resetZoom()` restores the original scale and position. + ## Methods Reference | Method | Return | Description | @@ -150,6 +174,13 @@ colorPickerView.selectCenter() | `attachBrightnessSlider(BrightnessSlideBar)` | `void` | Links a BrightnessSlideBar | | `setEnabled(Boolean)` | `void` | Enables or disables the color picker | | `setDebounceDuration(Long)` | `void` | Sets the debounce duration in milliseconds | +| `setSelectorPointValidation(Boolean)` | `void` | Approximates the selected point to the nearest valid color (default: true) | +| `setResetBrightnessOnLowSaturation(Boolean)` | `void` | Resets the brightness slider on a low saturation color (default: true) | +| `setSyncSlidersWithPaletteColor(Boolean)` | `void` | Moves the sliders to the alpha and the brightness of a picked pixel (default: true) | +| `setZoomEnabled(Boolean)` | `void` | Enables the pinch zoom gesture for a bitmap palette (default: false) | +| `setMaxZoom(Float)` | `void` | Sets the maximum scale of the zoom gesture (default: 3.0) | +| `getZoomScale()` | `Float` | Gets the current scale of the zoomed palette | +| `resetZoom()` | `void` | Restores the zoomed palette to its original scale and position | ## Lifecycle Management diff --git a/docs/getting-started.md b/docs/getting-started.md index 721b55a..f7adf80 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -99,7 +99,8 @@ You can simply use `ColorPickerView` by defining it in your XML layout files. Th ```kotlin envelope.color // returns an integer color -envelope.hexCode // returns a hex code string (e.g., "FF5722") +envelope.hexCode // returns an ARGB hex code string (e.g., "FFFF5722") +envelope.rgbHexCode // returns an RGB hex code string without the alpha (e.g., "FF5722") envelope.argb // returns an ARGB integer array [alpha, red, green, blue] ``` @@ -120,7 +121,9 @@ You can customize the palette image, selector, and various options using XML att app:actionMode="last" app:initialColor="@color/colorPrimary" app:preferenceName="MyColorPicker" - app:debounceDuration="200" /> + app:debounceDuration="200" + app:zoomEnabled="true" + app:maxZoom="5.0" /> ``` | Attribute | Description | @@ -134,6 +137,11 @@ You can customize the palette image, selector, and various options using XML att | `initialColor` | Sets an initial position using a specific color | | `preferenceName` | Sets a preference name for state persistence | | `debounceDuration` | Sets debounce duration in milliseconds | +| `selectorPointValidation` | Approximates the selected point to the nearest valid color | +| `resetBrightnessOnLowSaturation` | Resets the brightness slider on a low saturation color | +| `syncSlidersWithPaletteColor` | Moves the sliders to the alpha and the brightness of a picked pixel | +| `zoomEnabled` | Enables the pinch zoom gesture for a bitmap palette | +| `maxZoom` | Sets the maximum scale of the zoom gesture | ## Create using Builder