/* * Copyright (C) 2017 JRummy Apps Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.jrummyapps.android.colorpicker import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.graphics.Color import android.graphics.PorterDuff import android.text.InputFilter import android.text.InputFilter.LengthFilter import android.util.TypedValue import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.ImageView import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import android.widget.TextView import androidx.annotation.ColorInt import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.graphics.ColorUtils import androidx.core.view.children import androidx.core.widget.addTextChangedListener import com.jrummyapps.android.colorpicker.databinding.CpvDialogColorPickerBinding import com.jrummyapps.android.colorpicker.databinding.CpvDialogPresetsBinding import jp.juggler.util.log.LogCategory import jp.juggler.util.systemService import jp.juggler.util.ui.dismissSafe import jp.juggler.util.ui.gone import kotlinx.coroutines.CancellationException import kotlinx.coroutines.suspendCancellableCoroutine import java.util.Locale import kotlin.coroutines.resumeWithException import kotlin.math.roundToInt private val log = LogCategory("ColorPickerDialog") enum class ColorPickerDialogType { Custom, Presets, } internal const val ALPHA_THRESHOLD = 165 /** * Material design colors used as the default color presets */ val MATERIAL_COLORS = intArrayOf( -0xbbcca, // RED 500 -0x16e19d, // PINK 500 -0xd36d, // LIGHT PINK 500 -0x63d850, // PURPLE 500 -0x98c549, // DEEP PURPLE 500 -0xc0ae4b, // INDIGO 500 -0xde690d, // BLUE 500 -0xfc560c, // LIGHT BLUE 500 -0xff432c, // CYAN 500 -0xff6978, // TEAL 500 -0xb350b0, // GREEN 500 -0x743cb6, // LIGHT GREEN 500 -0x3223c7, // LIME 500 -0x14c5, // YELLOW 500 -0x3ef9, // AMBER 500 -0x6800, // ORANGE 500 -0x86aab8, // BROWN 500 -0x9f8275, // BLUE GREY 500 -0x616162 ) internal fun unshiftIfNotExists(array: IntArray, value: Int): IntArray { if (array.any { it == value }) return array val newArray = IntArray(array.size + 1) newArray[0] = value System.arraycopy(array, 0, newArray, 1, newArray.size - 1) return newArray } internal fun pushIfNotExists(array: IntArray, value: Int): IntArray { if (array.any { it == value }) { return array } val newArray = IntArray(array.size + 1) newArray[newArray.size - 1] = value System.arraycopy(array, 0, newArray, 0, newArray.size - 1) return newArray } internal fun shadeColor(@ColorInt color: Int, percent: Double): Int { val hex = "#%06X".format(color and 0xFFFFFF) val f = hex.substring(1).toLong(16) val t = (if (percent < 0) 0 else 255).toDouble() val p = if (percent < 0) percent * -1 else percent val cR = f shr 16 val cG = f shr 8 and 0x00FF val cB = f and 0x0000FF return Color.argb( Color.alpha(color), ((t - cR) * p).roundToInt() + cR.toInt(), ((t - cG) * p).roundToInt() + cG.toInt(), ((t - cB) * p).roundToInt() + cB.toInt(), ) } internal fun getColorShades(@ColorInt color: Int) = intArrayOf( shadeColor(color, 0.9), shadeColor(color, 0.7), shadeColor(color, 0.5), shadeColor(color, 0.333), shadeColor(color, 0.166), shadeColor(color, -0.125), shadeColor(color, -0.25), shadeColor(color, -0.375), shadeColor(color, -0.5), shadeColor(color, -0.675), shadeColor(color, -0.7), shadeColor(color, -0.775), ) internal fun View.showSoftInput(show: Boolean) { val imm: InputMethodManager = systemService(context) ?: return if (show) { imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } else { imm.hideSoftInputFromWindow(this.windowToken, 0) } } fun Context.getTextColorPrimary() = try { val value = TypedValue() val typedArray = obtainStyledAttributes( value.data, intArrayOf(android.R.attr.textColorPrimary) ) try { typedArray.getColor(0, Color.BLACK) } finally { typedArray.recycle() } } catch (_: Throwable) { null } internal fun loadPresets(from: IntArray, newColor: Int): IntArray { var presets = from.copyOf() val isMaterialColors = presets.contentEquals(MATERIAL_COLORS) val alpha = Color.alpha(newColor) // don't update the original array when modifying alpha if (alpha != 255) { // add alpha to the presets for (i in presets.indices) { val color = presets[i] val red = Color.red(color) val green = Color.green(color) val blue = Color.blue(color) presets[i] = Color.argb(alpha, red, green, blue) } } presets = unshiftIfNotExists(presets, newColor) if (isMaterialColors && presets.size == 19) { // Add black to have a total of 20 colors if the current color is in the material color palette presets = pushIfNotExists( presets, Color.argb(alpha, 0, 0, 0) ) } return presets } @SuppressLint("ClickableViewAccessibility") suspend fun Activity.dialogColorPicker( // the original color. @ColorInt colorInitial: Int?, // the alpha slider // true to show the alpha slider. // Currently only supported with the ColorPickerView. alphaEnabled: Boolean, ): Int = suspendCancellableCoroutine { cont -> // dialog title string resource id. @StringRes val dialogTitle = R.string.cpv_default_title val initialDialogType = ColorPickerDialogType.Custom // the colors used for the presets. val initialPresets: IntArray = MATERIAL_COLORS // true if showing a neutral button to switch preset/custom. val dialogTypeSwitcher = true // Show/Hide the color shades in the presets picker // false to hide the color shades. val useColorShade = true // the shape of the color panel view. // Either [ColorShape.CIRCLE] or [ColorShape.SQUARE]. val panelShape = ColorShape.Circle val activity = this val rootView = FrameLayout(activity) var currentColor = colorInitial ?: Color.BLACK var dialogType = initialDialogType var presets = initialPresets var dialog: AlertDialog? = null var colorPaletteAdapter: ColorPaletteAdapter? = null var shadesLayout: ViewGroup? = null var tvPercent: TextView? = null fun dismiss() = dialog?.dismissSafe() fun complete() { if (cont.isActive) cont.resume(currentColor) {} dismiss() } fun addPickerView() { CpvDialogColorPickerBinding.inflate(layoutInflater, rootView, true).apply { // ColorPickerDialog.colorPicker = contentView.findViewById(R.id.cpv_color_picker_view) // ColorPickerDialog.newColorPanel = contentView.findViewById(R.id.cpv_color_panel_new) // ColorPickerDialog.hexEditText = contentView.findViewById(R.id.cpv_hex) // val oldColorPanel: ColorPanelView = contentView.findViewById(R.id.cpv_color_panel_old) // val arrowRight = contentView.findViewById(R.id.cpv_arrow_right) activity.getTextColorPrimary() ?.let { cpvArrowRight.setColorFilter(it) } var fromEditText = false fun setHex(color: Int, fromColorPicker: Boolean = false) { if (fromColorPicker) { if (fromEditText) { fromEditText = false return } if (cpvHex.hasFocus()) { cpvHex.showSoftInput(false) cpvHex.clearFocus() } } val hexText = when { alphaEnabled -> "%08X".format(color) else -> "%06X".format(color and 0xFFFFFF) } cpvHex.setText(hexText) } setHex(currentColor) cpvColorPanelOld.color = currentColor cpvColorPickerView.apply { setAlphaSliderVisible(alphaEnabled) setColor(currentColor, true) onColorChangedListener = ColorPickerView.OnColorChangedListener { newColor -> currentColor = newColor cpvColorPanelNew.color = newColor setHex(newColor, fromColorPicker = true) } } cpvColorPanelNew.apply { this.color = currentColor setOnClickListener { if (currentColor == this.color) complete() } } cpvHex.apply { addTextChangedListener { editable -> if (cpvHex.isFocused) { try { currentColor = editable.toString().parseColor() if (currentColor != cpvColorPickerView.color) { fromEditText = true cpvColorPickerView.setColor(currentColor, true) } } catch (_: NumberFormatException) { } catch (ex: Throwable) { log.e(ex, "parseColorString failed.") } } } setOnFocusChangeListener { _, hasFocus -> if (hasFocus) showSoftInput(true) } if (!alphaEnabled) { filters = arrayOf(LengthFilter(6)) } } root.setOnTouchListener { v, _ -> cpvHex.run { when { hasFocus() && v !== this -> { clearFocus() showSoftInput(false) clearFocus() true } else -> false } } } } } fun createColorShades() { val colorShades = getColorShades(currentColor) shadesLayout?.takeIf { it.childCount > 0 }?.run { children.forEachIndexed { i, child -> val layout = child as FrameLayout val cpv: ColorPanelView = layout.findViewById(R.id.cpv_color_panel_view) val iv = layout.findViewById(R.id.cpv_color_image_view) cpv.color = colorShades[i] cpv.tag = false iv.setImageDrawable(null) } return } val horizontalPadding = activity.resources.getDimensionPixelSize(R.dimen.cpv_item_horizontal_padding) for (colorShade in colorShades) { var layoutResId: Int layoutResId = when (panelShape) { ColorShape.Square -> R.layout.cpv_color_item_square ColorShape.Circle -> R.layout.cpv_color_item_circle } val view = View.inflate(activity, layoutResId, null) val colorPanelView: ColorPanelView = view.findViewById(R.id.cpv_color_panel_view) val params = colorPanelView .layoutParams as MarginLayoutParams params.rightMargin = horizontalPadding params.leftMargin = params.rightMargin colorPanelView.layoutParams = params colorPanelView.color = colorShade shadesLayout?.addView(view) colorPanelView.post { // The color is black when rotating the dialog. This is a dirty fix. WTF!? colorPanelView.color = colorShade } colorPanelView.setOnClickListener { v: View -> when { (v.tag as? Boolean) == true -> complete() else -> { currentColor = colorPanelView.color colorPaletteAdapter?.selectNone() shadesLayout?.children?.forEach { child -> val layout = child as FrameLayout val cpv: ColorPanelView = layout.findViewById(R.id.cpv_color_panel_view) val iv = layout.findViewById(R.id.cpv_color_image_view) iv.setImageResource(if (cpv === v) R.drawable.cpv_preset_checked else 0) when { cpv === v && ColorUtils.calculateLuminance(cpv.color) >= 0.65 || Color.alpha(cpv.color) <= ALPHA_THRESHOLD -> { iv.setColorFilter(Color.BLACK, PorterDuff.Mode.SRC_IN) } else -> iv.colorFilter = null } cpv.tag = cpv === v } } } } colorPanelView.setOnLongClickListener { colorPanelView.showHint() true } } } fun handleTransparencyChanged(transparency: Int) { val percentage = (transparency.toDouble() * 100 / 255).toInt() val alpha = 255 - transparency tvPercent?.text = String.format(Locale.ENGLISH, "%d%%", percentage) // update color: val red = Color.red(currentColor) val green = Color.green(currentColor) val blue = Color.blue(currentColor) currentColor = Color.argb(alpha, red, green, blue) // update items in GridView: colorPaletteAdapter?.apply { for (i in colors.indices) { val c = colors[i] colors[i] = Color.argb( alpha, Color.red(c), Color.green(c), Color.blue(c) ) } notifyDataSetChanged() } // update shades: shadesLayout?.children?.forEach { child -> val layout = child as FrameLayout val cpv: ColorPanelView = layout.findViewById(R.id.cpv_color_panel_view) val iv = layout.findViewById(R.id.cpv_color_image_view) if (layout.tag == null) { // save the original border color layout.tag = cpv.borderColor } val c = Color.argb( alpha, Color.red(cpv.color), Color.green(cpv.color), Color.blue(cpv.color) ) if (alpha <= ALPHA_THRESHOLD) { cpv.borderColor = c or -0x1000000 } else { cpv.borderColor = layout.tag as Int } if (cpv.tag != null && cpv.tag as Boolean) { // The alpha changed on the selected shaded color. Update the checkmark color filter. if (alpha <= ALPHA_THRESHOLD) { iv.setColorFilter(Color.BLACK, PorterDuff.Mode.SRC_IN) } else { if (ColorUtils.calculateLuminance(c) >= 0.65) { iv.setColorFilter(Color.BLACK, PorterDuff.Mode.SRC_IN) } else { iv.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN) } } } cpv.color = c } } fun addPresetView() { CpvDialogPresetsBinding.inflate(layoutInflater, rootView, true).apply { shadesLayout = this.shadesLayout tvPercent = this.transparencyText // ColorPickerDialog.shadesLayout = contentView.findViewById(R.id.shades_layout) // ColorPickerDialog.transparencySeekBar = contentView.findViewById(R.id.transparency_seekbar) // ColorPickerDialog.transparencyPercText = contentView.findViewById(R.id.transparency_text) // val gridView = contentView.findViewById(R.id.gridView) presets = loadPresets(presets, currentColor) if (useColorShade) { createColorShades() } else { shadesLayout?.gone() shadesDivider.gone() } ColorPaletteAdapter( presets, presets.indexOf(currentColor), panelShape, listener = { when (it) { currentColor -> { if (cont.isActive) cont.resume(currentColor) {} dismiss() } else -> { currentColor = it if (useColorShade) createColorShades() } } }, ).also { gridView.adapter = it colorPaletteAdapter = it } when { alphaEnabled -> { val transparency = 255 - Color.alpha(currentColor) val percentage = (transparency.toDouble() * 100 / 255).toInt() transparencyText.text = String.format(Locale.ENGLISH, "%d%%", percentage) transparencySeekbar.apply { max = 255 progress = transparency this.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { override fun onStartTrackingTouch(seekBar: SeekBar) {} override fun onStopTrackingTouch(seekBar: SeekBar) {} override fun onProgressChanged( seekBar: SeekBar, progress: Int, fromUser: Boolean, ) { handleTransparencyChanged(transparency = progress) } }) } } else -> { transparencyLayout.gone() transparencyTitle.gone() } } } } fun addViewByType() { when (dialogType) { ColorPickerDialogType.Custom -> addPickerView() ColorPickerDialogType.Presets -> addPresetView() } } addViewByType() dialog = AlertDialog.Builder(activity).apply { setView(rootView) if (dialogTitle != 0) { setTitle(dialogTitle) } setPositiveButton(R.string.cpv_select) { _, _ -> if (cont.isActive) cont.resume(currentColor) {} } val neutralButtonStringRes = when { !dialogTypeSwitcher -> 0 else -> when (dialogType) { ColorPickerDialogType.Custom -> R.string.cpv_presets ColorPickerDialogType.Presets -> R.string.cpv_custom } } if (neutralButtonStringRes != 0) { setNeutralButton(neutralButtonStringRes, null) // ビルダーでボタンを指定するとダイアログを閉じるボタンになってしまうが、 // このボタンではダイアログを閉じないので、リスナは後で設定する。 } }.create() dialog.setOnDismissListener { log.i("onDismissListener. isActive=${cont.isActive}") if (cont.isActive) cont.resumeWithException(CancellationException()) } cont.invokeOnCancellation { dismiss() } // http://stackoverflow.com/a/16972670/1048340 dialog.window?.clearFlags( WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM ) dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) dialog.show() // プリセット切り替えボタンのリスナでダイアログを閉じないようにするため、後から上書きする // dialog.getButton の呼び出しは show()より後に行う必要がある val neutralButton = dialog.getButton(AlertDialog.BUTTON_NEUTRAL) log.i("neutralButton=$neutralButton") neutralButton?.setOnClickListener { log.i("(neutralButton?.setOnClickListener. isActive=${cont.isActive}") rootView.removeAllViews() dialogType = when (dialogType) { ColorPickerDialogType.Custom -> { neutralButton.setText(R.string.cpv_custom) ColorPickerDialogType.Presets } ColorPickerDialogType.Presets -> { neutralButton.setText(R.string.cpv_presets) ColorPickerDialogType.Custom } } addViewByType() log.i(")neutralButton?.setOnClickListener. isActive=${cont.isActive}") } }