582 lines
21 KiB
Kotlin
582 lines
21 KiB
Kotlin
/*
|
|
* 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<ImageView>(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<InputFilter>(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<ImageView>(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<ImageView>(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<ImageView>(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<GridView>(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}")
|
|
}
|
|
}
|