mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Utilize HSL to create disabled component via chat color.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* This color's lightness in HSL space, where 0 is black and 1 is white.
|
||||
*/
|
||||
val Color.hslLightness: Float
|
||||
get() {
|
||||
val max = maxOf(red, green, blue)
|
||||
val min = minOf(red, green, blue)
|
||||
|
||||
return (max + min) / 2f
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies this color's HSL saturation by [fraction], leaving hue, lightness, and alpha untouched. A [fraction] below
|
||||
* 1 washes the color out towards gray without changing how light or dark it reads.
|
||||
*
|
||||
* Unlike interpolating towards a gray in RGB space, this shifts every input by the same proportion, so a color that
|
||||
* already starts out pale is muted just as visibly as a vivid one.
|
||||
*/
|
||||
fun Color.scaleSaturation(fraction: Float): Color {
|
||||
val hsl = toHsl()
|
||||
|
||||
return Color.hsl(
|
||||
hue = hsl[0],
|
||||
saturation = (hsl[1] * fraction).coerceIn(0f, 1f),
|
||||
lightness = hsl[2],
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves this color [fraction] of the way to [other]'s lightness, leaving hue, saturation, and alpha untouched. The
|
||||
* result keeps its own hue, so it stays recognizable while being pulled towards how light or dark [other] is.
|
||||
*/
|
||||
fun Color.shiftLightnessToward(other: Color, fraction: Float): Color {
|
||||
val hsl = toHsl()
|
||||
val target = other.hslLightness
|
||||
|
||||
return Color.hsl(
|
||||
hue = hsl[0],
|
||||
saturation = hsl[1],
|
||||
lightness = (hsl[2] + (target - hsl[2]) * fraction).coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This color as `[hue, saturation, lightness]`, matching the ranges [Color.hsl] expects. Alpha is not included.
|
||||
*/
|
||||
private fun Color.toHsl(): FloatArray {
|
||||
val max = maxOf(red, green, blue)
|
||||
val min = minOf(red, green, blue)
|
||||
val delta = max - min
|
||||
val lightness = (max + min) / 2f
|
||||
|
||||
if (delta == 0f) {
|
||||
return floatArrayOf(0f, 0f, lightness)
|
||||
}
|
||||
|
||||
val hue = when (max) {
|
||||
red -> 60f * (((green - blue) / delta) % 6f)
|
||||
green -> 60f * (((blue - red) / delta) + 2f)
|
||||
else -> 60f * (((red - green) / delta) + 4f)
|
||||
}
|
||||
|
||||
return floatArrayOf(
|
||||
if (hue < 0f) hue + 360f else hue,
|
||||
delta / (1f - abs(2f * lightness - 1f)),
|
||||
lightness
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Derives themed color states for controls that are painted in a color the color scheme knows nothing about, such as a
|
||||
* recipient's chat color. Colors from the scheme itself already have `on-` and container roles to pair with; these fill
|
||||
* the same gap for custom ones.
|
||||
*/
|
||||
object ColorProfiles {
|
||||
|
||||
/** How much of a container's saturation survives into its disabled state. */
|
||||
private const val DISABLED_SATURATION = 0.3f
|
||||
|
||||
/** How far a disabled container's lightness is pulled towards the surrounding chrome. */
|
||||
private const val DISABLED_LIGHTNESS_SHIFT = 0.6f
|
||||
|
||||
/** Kept low so disabled content reads as inert against a container that has itself receded towards the chrome. */
|
||||
private const val DISABLED_CONTENT_ALPHA = 0.6f
|
||||
|
||||
/**
|
||||
* A muted version of [color] for a control that is currently disabled, washed out and pulled towards the surrounding
|
||||
* chrome while keeping enough of its hue to stay recognizable.
|
||||
*
|
||||
* The result is opaque rather than translucent so it reads the same whatever it happens to sit on top of, which
|
||||
* matters for controls that float over content such as media or a wallpaper.
|
||||
*
|
||||
* Pair with [disabledContent].
|
||||
*/
|
||||
@Composable
|
||||
fun disabledContainer(color: Color): Color {
|
||||
return color
|
||||
.scaleSaturation(DISABLED_SATURATION)
|
||||
.shiftLightnessToward(MaterialTheme.colorScheme.surfaceVariant, DISABLED_LIGHTNESS_SHIFT)
|
||||
}
|
||||
|
||||
/**
|
||||
* The content color to draw on top of [disabledContainer]. Follows the chrome rather than the container's own hue,
|
||||
* because a container that has been pulled this far towards the chrome no longer reliably contrasts with whatever
|
||||
* the enabled content color was.
|
||||
*/
|
||||
@Composable
|
||||
fun disabledContent(): Color {
|
||||
return MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = DISABLED_CONTENT_ALPHA)
|
||||
}
|
||||
}
|
||||
+34
-6
@@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.ColorProfiles
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.DropdownMenus
|
||||
import org.signal.core.ui.compose.IconButtons
|
||||
@@ -37,9 +38,6 @@ import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.mediasend.R
|
||||
import org.signal.mediasend.test.TestTags
|
||||
|
||||
/** Mirrors the legacy send button's disabled tint, which has no equivalent in the core-ui color scheme. */
|
||||
private val DisabledNextButtonColor = Color(0xFF777777)
|
||||
|
||||
/**
|
||||
* Mirrors the legacy send button's size. Has to be stated rather than left to the [IconButtons.IconButton] default of
|
||||
* 40dp: the default draws the container inside the 48dp of layout that [androidx.compose.material3.minimumInteractiveComponentSize]
|
||||
@@ -146,6 +144,8 @@ fun AddAMessageRow(
|
||||
Box {
|
||||
val scheduleSendMenuController = remember { DropdownMenus.MenuController() }
|
||||
|
||||
val nextButtonColor = recipientChatColor ?: MaterialTheme.colorScheme.primaryContainer
|
||||
|
||||
IconButtons.IconButton(
|
||||
enabled = enabled,
|
||||
onClick = onNextClick,
|
||||
@@ -153,10 +153,10 @@ fun AddAMessageRow(
|
||||
onLongClick = if (canScheduleSend) scheduleSendMenuController::show else null,
|
||||
onLongClickLabel = stringResource(R.string.AddAMessageRow__schedule_send),
|
||||
colors = IconButtons.iconButtonColors(
|
||||
containerColor = recipientChatColor ?: MaterialTheme.colorScheme.primaryContainer,
|
||||
containerColor = nextButtonColor,
|
||||
contentColor = if (recipientChatColor != null) SignalTheme.colors.colorOnCustom else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
disabledContainerColor = DisabledNextButtonColor,
|
||||
disabledContentColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
disabledContainerColor = ColorProfiles.disabledContainer(nextButtonColor),
|
||||
disabledContentColor = ColorProfiles.disabledContent()
|
||||
),
|
||||
modifier = Modifier
|
||||
.testTag(TestTags.ADD_A_MESSAGE_NEXT_BUTTON)
|
||||
@@ -244,6 +244,19 @@ private fun AddAMessageRowKnownRecipientPreview() {
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AddAMessageRowDisabledPreview() {
|
||||
Previews.Preview {
|
||||
AddAMessageRow(
|
||||
message = null,
|
||||
onEvent = {},
|
||||
onNextClick = {},
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AddAMessageRowDisabledKnownRecipientPreview() {
|
||||
Previews.Preview {
|
||||
AddAMessageRow(
|
||||
message = null,
|
||||
@@ -255,6 +268,21 @@ private fun AddAMessageRowDisabledPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
/** A chat color that starts out pale, where a plain interpolation towards the chrome would barely move. */
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AddAMessageRowDisabledPaleRecipientPreview() {
|
||||
Previews.Preview {
|
||||
AddAMessageRow(
|
||||
message = null,
|
||||
onEvent = {},
|
||||
onNextClick = {},
|
||||
enabled = false,
|
||||
recipientChatColor = Color(0xFFF5E0A0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun AddAMessageRowLongContentPreview() {
|
||||
|
||||
Reference in New Issue
Block a user