| /* |
| * Copyright 2023 The Android Open Source Project |
| * |
| * 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 androidx.wear.compose.material3 |
| |
| import androidx.compose.foundation.text.selection.TextSelectionColors |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.remember |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.graphics.compositeOver |
| import androidx.compose.ui.graphics.luminance |
| import androidx.compose.ui.graphics.takeOrElse |
| import kotlin.math.max |
| import kotlin.math.min |
| |
| /** |
| * Remembers a [TextSelectionColors] based on [colorScheme]. The handle color will be |
| * [ColorScheme.primary] and the background color will be [ColorScheme.primary] with alpha applied. |
| * |
| * See [calculateSelectionBackgroundColor]. |
| */ |
| @Composable |
| internal fun rememberTextSelectionColors(colorScheme: ColorScheme): TextSelectionColors { |
| val primaryColor = colorScheme.primary |
| val backgroundColor = colorScheme.background |
| // Test with ContentAlpha.medium to ensure that the selection background is accessible in the |
| // 'worst case' scenario. We explicitly don't test with ContentAlpha.disabled, as disabled |
| // text shouldn't be selectable / is noted as disabled for accessibility purposes. |
| val textColorWithLowestAlpha = colorScheme.contentColorFor(backgroundColor) |
| .takeOrElse { |
| LocalContentColor.current |
| }.copy( |
| alpha = ContentAlpha.medium |
| ) |
| return remember(primaryColor, backgroundColor, textColorWithLowestAlpha) { |
| TextSelectionColors( |
| handleColor = colorScheme.primary, |
| backgroundColor = calculateSelectionBackgroundColor( |
| selectionColor = primaryColor, |
| textColor = textColorWithLowestAlpha, |
| backgroundColor = backgroundColor |
| ) |
| ) |
| } |
| } |
| |
| /** |
| * Best-effort calculates a color (with alpha) for the selection background that (if possible) |
| * will have at least [DesiredContrastRatio] with [textColor], when the selection background |
| * is on top of [backgroundColor]. |
| * |
| * Since this is a minimum contrast ratio, [textColor] should have the lowest alpha that |
| * may be applied to content so we can ensure that the selection background color is accessible |
| * in that worst-case scenario for contrast. |
| * |
| * @param selectionColor the 'raw' (without alpha) selection color that we should search alpha for |
| * @param textColor the color of text with minimal alpha applied to test for contrast with |
| * @param backgroundColor the color of the background that the selection color will typically be |
| * placed against |
| * |
| * @return a resulting [selectionColor] with alpha applied that results in acceptable contrast |
| * (if possible with the values for [selectionColor], [textColor] and [backgroundColor]). |
| */ |
| /*@VisibleForTesting*/ |
| internal fun calculateSelectionBackgroundColor( |
| selectionColor: Color, |
| textColor: Color, |
| backgroundColor: Color |
| ): Color { |
| val maximumContrastRatio = calculateContrastRatio( |
| selectionColor = selectionColor, |
| selectionColorAlpha = DefaultSelectionBackgroundAlpha, |
| textColor = textColor, |
| backgroundColor = backgroundColor |
| ) |
| |
| val minimumContrastRatio = calculateContrastRatio( |
| selectionColor = selectionColor, |
| selectionColorAlpha = MinimumSelectionBackgroundAlpha, |
| textColor = textColor, |
| backgroundColor = backgroundColor |
| ) |
| |
| val alpha = when { |
| // If the default alpha has enough contrast, use that |
| maximumContrastRatio >= DesiredContrastRatio -> DefaultSelectionBackgroundAlpha |
| // If the minimum alpha still does not have enough contrast, just use the minimum and return |
| minimumContrastRatio < DesiredContrastRatio -> MinimumSelectionBackgroundAlpha |
| else -> binarySearchForAccessibleSelectionColorAlpha( |
| selectionColor = selectionColor, |
| textColor = textColor, |
| backgroundColor = backgroundColor |
| ) |
| } |
| |
| return selectionColor.copy(alpha = alpha) |
| } |
| |
| /** |
| * Binary searches for the highest alpha for selection color that results in a contrast ratio at |
| * least equal to and within 1% of [DesiredContrastRatio]. |
| * |
| * The resulting alpha will be within the range of [MinimumSelectionBackgroundAlpha] and |
| * [DefaultSelectionBackgroundAlpha] - since not all values for [selectionColor], [textColor] and |
| * [backgroundColor] can be guaranteed to produce an accessible contrast ratio, this is a |
| * best-effort attempt and [MinimumSelectionBackgroundAlpha] might still not produce an |
| * accessible contrast ratio. In this case developers are encouraged to manually choose a |
| * different color for selection that _is_ accessible with their chosen content and background |
| * colors. |
| * |
| * Caps the number of attempts at 7 for performance and to avoid infinite searching when there is |
| * no value that results in an accessible contrast ratio. Because alpha is limited to [0,1], 7 |
| * steps results in a precision of ~0.01, since log2(1/0.01) ≈ 7. |
| * |
| * Note: binary searching here is chosen since it is not possible to 'solve' for alpha, since the |
| * transformation from color -> contrast ratio is not linear (the gamma exponent for sRGB colors |
| * is 2.4). We can approximate this to 2, but this results in not that accurate solutions, and we |
| * need to guarantee that they are at least above [DesiredContrastRatio] - falling just below is |
| * not an acceptable result. |
| * |
| * @param selectionColor the 'raw' (without alpha) selection color that we should search alpha for |
| * @param textColor the color of text with minimal alpha applied to test for contrast with |
| * @param backgroundColor the color of the background that the selection color will typically be |
| * placed against |
| */ |
| private fun binarySearchForAccessibleSelectionColorAlpha( |
| selectionColor: Color, |
| textColor: Color, |
| backgroundColor: Color |
| ): Float { |
| var attempts = 0 |
| val maxAttempts = 7 |
| |
| var lowAlpha = MinimumSelectionBackgroundAlpha |
| var alpha = DefaultSelectionBackgroundAlpha |
| var highAlpha = DefaultSelectionBackgroundAlpha |
| |
| while (attempts < maxAttempts) { |
| val contrastRatio = calculateContrastRatio( |
| selectionColor = selectionColor, |
| selectionColorAlpha = alpha, |
| textColor = textColor, |
| backgroundColor = backgroundColor |
| ) |
| |
| // Percentage error of the calculated contrast compared to the actual contrast. Positive |
| // numbers here mean we have higher contrast than needed. |
| val percentageError = (contrastRatio / DesiredContrastRatio) - 1f |
| when { |
| // Contrast is at most 1% above the guideline, return |
| percentageError in 0f..0.01f -> break |
| // Contrast too low, decrease alpha |
| percentageError < 0f -> highAlpha = alpha |
| // Contrast higher than required, increase alpha |
| else -> lowAlpha = alpha |
| } |
| alpha = (highAlpha + lowAlpha) / 2f |
| attempts++ |
| } |
| |
| return alpha |
| } |
| |
| /** |
| * Calculates the contrast ratio of [textColor] against [selectionColor] with |
| * [selectionColorAlpha], all on top of [backgroundColor]. |
| * |
| * Both the [selectionColor] and [textColor] will be composited to handle transparency. |
| * |
| * @param selectionColor the 'raw' (without alpha) selection color that we should search alpha for |
| * @param selectionColorAlpha the alpha for [selectionColor] to test contrast with |
| * @param textColor the color of text with minimal alpha applied to test for contrast with |
| * @param backgroundColor the color of the background that the selection color will typically be |
| * placed against |
| * |
| * @return the contrast ratio as a value between 1 and 21. See [calculateContrastRatio] |
| */ |
| private fun calculateContrastRatio( |
| selectionColor: Color, |
| selectionColorAlpha: Float, |
| textColor: Color, |
| backgroundColor: Color |
| ): Float { |
| val compositeBackground = selectionColor.copy(alpha = selectionColorAlpha) |
| .compositeOver(backgroundColor) |
| val compositeTextColor = textColor.compositeOver(compositeBackground) |
| return calculateContrastRatio(compositeTextColor, compositeBackground) |
| } |
| |
| /** |
| * Calculates the contrast ratio of [foreground] against [background], returning a value between |
| * 1 and 21. (1:1 and 21:1 ratios). |
| * |
| * Formula taken from [WCAG 2.0](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef) |
| * |
| * Note: [foreground] and [background] *must* be opaque. See [Color.compositeOver] to pre-composite |
| * a translucent foreground over the background. |
| * |
| * @return the contrast ratio as a value between 1 and 21. See [calculateContrastRatio] |
| */ |
| /*@VisibleForTesting*/ |
| internal fun calculateContrastRatio(foreground: Color, background: Color): Float { |
| val foregroundLuminance = foreground.luminance() + 0.05f |
| val backgroundLuminance = background.luminance() + 0.05f |
| |
| return max(foregroundLuminance, backgroundLuminance) / |
| min(foregroundLuminance, backgroundLuminance) |
| } |
| |
| /** |
| * Default selection background alpha - we will try and use this if it is accessible and produces |
| * the correct contrast ratio. |
| */ |
| private const val DefaultSelectionBackgroundAlpha = 0.4f |
| |
| /** |
| * Not all combinations of text color and selection color will have a reasonable alpha that |
| * produces a contrast ratio of at least [DesiredContrastRatio] - in this case just pick a low |
| * but still visible alpha so at least the contrast ratio is as good as it can be - this is |
| * preferable to crashing at runtime. |
| */ |
| private const val MinimumSelectionBackgroundAlpha = DefaultSelectionBackgroundAlpha / 2f |
| |
| /** |
| * Material and WCAG 2.0 sc 1.4.3 minimum contrast for AA text |
| */ |
| private const val DesiredContrastRatio = 4.5f |