/* * Copyright 2022 Google LLC * * 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.google.ux.material.libmonet.dynamiccolor; import static java.lang.Math.max; import static java.lang.Math.min; import android.annotation.NonNull; import android.annotation.Nullable; import com.google.errorprone.annotations.Var; import com.google.ux.material.libmonet.contrast.Contrast; import com.google.ux.material.libmonet.hct.Hct; import com.google.ux.material.libmonet.palettes.TonalPalette; import com.google.ux.material.libmonet.utils.MathUtils; import java.util.ArrayList; import java.util.HashMap; import java.util.function.Function; /** * A color that adjusts itself based on UI state, represented by DynamicScheme. * *
This color automatically adjusts to accommodate a desired contrast level, or other adjustments * such as differing in light mode versus dark mode, or what the theme is, or what the color that * produced the theme is, etc. * *
Colors without backgrounds do not change tone when contrast changes. Colors with backgrounds * become closer to their background as contrast lowers, and further when contrast increases. * *
Prefer the static constructors. They provide a much more simple interface, such as requiring * just a hexcode, or just a hexcode and a background. * *
Ultimately, each component necessary for calculating a color, adjusting it for a desired
* contrast level, and ensuring it has a certain lightness/tone difference from another color, is
* provided by a function that takes a DynamicScheme and returns a value. This ensures ultimate
* flexibility, any desired behavior of a color for any design system, but it usually unnecessary.
* See the default constructor for more information.
*/
// Prevent lint for Function.apply not being available on Android before API level 14 (4.0.1).
// "AndroidJdkLibsChecker" for Function, "NewApi" for Function.apply().
// A java_library Bazel rule with an Android constraint cannot skip these warnings without this
// annotation; another solution would be to create an android_library rule and supply
// AndroidManifest with an SDK set higher than 14.
@SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
public final class DynamicColor {
public final String name;
public final Function _Strongly_ prefer using one of the convenience constructors. This class is arguably too
* flexible to ensure it can support any scenario. Functional arguments allow overriding without
* risks that come with subclasses.
*
* For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with
* its background is principled and matches accessibility guidance. That does not mean it's the
* desired approach for _every_ design system, and every color pairing, always, in every case.
*
* For opaque colors (colors with alpha = 100%).
*
* @param name The name of the dynamic color.
* @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is
* defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing
* a tonal palette, when contrast adjustments are made, intended chroma can be preserved.
* @param tone Function that provides a tone, given a DynamicScheme.
* @param isBackground Whether this dynamic color is a background, with some other color as the
* foreground.
* @param background The background of the dynamic color (as a function of a `DynamicScheme`), if
* it exists.
* @param secondBackground A second background of the dynamic color (as a function of a
* `DynamicScheme`), if it exists.
* @param contrastCurve A `ContrastCurve` object specifying how its contrast against its
* background should behave in various contrast levels options.
* @param toneDeltaPair A `ToneDeltaPair` object specifying a tone delta constraint between two
* colors. One of them must be the color being constructed.
*/
public DynamicColor(
@NonNull String name,
@NonNull Function _Strongly_ prefer using one of the convenience constructors. This class is arguably too
* flexible to ensure it can support any scenario. Functional arguments allow overriding without
* risks that come with subclasses.
*
* For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with
* its background is principled and matches accessibility guidance. That does not mean it's the
* desired approach for _every_ design system, and every color pairing, always, in every case.
*
* For opaque colors (colors with alpha = 100%).
*
* @param name The name of the dynamic color.
* @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is
* defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing
* a tonal palette, when contrast adjustments are made, intended chroma can be preserved.
* @param tone Function that provides a tone, given a DynamicScheme.
* @param isBackground Whether this dynamic color is a background, with some other color as the
* foreground.
* @param background The background of the dynamic color (as a function of a `DynamicScheme`), if
* it exists.
* @param secondBackground A second background of the dynamic color (as a function of a
* `DynamicScheme`), if it exists.
* @param contrastCurve A `ContrastCurve` object specifying how its contrast against its
* background should behave in various contrast levels options.
* @param toneDeltaPair A `ToneDeltaPair` object specifying a tone delta constraint between two
* colors. One of them must be the color being constructed.
* @param opacity A function returning the opacity of a color, as a number between 0 and 1.
*/
public DynamicColor(
@NonNull String name,
@NonNull Function _Strongly_ prefer using one of the convenience constructors. This class is arguably too
* flexible to ensure it can support any scenario. Functional arguments allow overriding without
* risks that come with subclasses.
*
* For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with
* its background is principled and matches accessibility guidance. That does not mean it's the
* desired approach for _every_ design system, and every color pairing, always, in every case.
*
* For opaque colors (colors with alpha = 100%).
*
* For colors that are not backgrounds, and do not have backgrounds.
*
* @param name The name of the dynamic color.
* @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is
* defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing
* a tonal palette, when contrast adjustments are made, intended chroma can be preserved.
* @param tone Function that provides a tone, given a DynamicScheme.
*/
@NonNull
public static DynamicColor fromPalette(
@NonNull String name,
@NonNull Function _Strongly_ prefer using one of the convenience constructors. This class is arguably too
* flexible to ensure it can support any scenario. Functional arguments allow overriding without
* risks that come with subclasses.
*
* For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with
* its background is principled and matches accessibility guidance. That does not mean it's the
* desired approach for _every_ design system, and every color pairing, always, in every case.
*
* For opaque colors (colors with alpha = 100%).
*
* For colors that do not have backgrounds.
*
* @param name The name of the dynamic color.
* @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is
* defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing
* a tonal palette, when contrast adjustments are made, intended chroma can be preserved.
* @param tone Function that provides a tone, given a DynamicScheme.
* @param isBackground Whether this dynamic color is a background, with some other color as the
* foreground.
*/
@NonNull
public static DynamicColor fromPalette(
@NonNull String name,
@NonNull Function Result has no background; thus no support for increasing/decreasing contrast for a11y.
*
* @param name The name of the dynamic color.
* @param argb The source color from which to extract the hue and chroma.
*/
@NonNull
public static DynamicColor fromArgb(@NonNull String name, int argb) {
Hct hct = Hct.fromInt(argb);
TonalPalette palette = TonalPalette.fromInt(argb);
return DynamicColor.fromPalette(name, (s) -> palette, (s) -> hct.getTone());
}
/**
* Returns an ARGB integer (i.e. a hex code).
*
* @param scheme Defines the conditions of the user interface, for example, whether or not it is
* dark mode or light mode, and what the desired contrast level is.
*/
public int getArgb(@NonNull DynamicScheme scheme) {
int argb = getHct(scheme).toInt();
if (opacity == null) {
return argb;
}
double percentage = opacity.apply(scheme);
int alpha = MathUtils.clampInt(0, 255, (int) Math.round(percentage * 255));
return (argb & 0x00ffffff) | (alpha << 24);
}
/**
* Returns an HCT object.
*
* @param scheme Defines the conditions of the user interface, for example, whether or not it is
* dark mode or light mode, and what the desired contrast level is.
*/
@NonNull
public Hct getHct(@NonNull DynamicScheme scheme) {
Hct cachedAnswer = hctCache.get(scheme);
if (cachedAnswer != null) {
return cachedAnswer;
}
// This is crucial for aesthetics: we aren't simply the taking the standard color
// and changing its tone for contrast. Rather, we find the tone for contrast, then
// use the specified chroma from the palette to construct a new color.
//
// For example, this enables colors with standard tone of T90, which has limited chroma, to
// "recover" intended chroma as contrast increases.
double tone = getTone(scheme);
Hct answer = palette.apply(scheme).getHct(tone);
// NOMUTANTS--trivial test with onerous dependency injection requirement.
if (hctCache.size() > 4) {
hctCache.clear();
}
// NOMUTANTS--trivial test with onerous dependency injection requirement.
hctCache.put(scheme, answer);
return answer;
}
/** Returns the tone in HCT, ranging from 0 to 100, of the resolved color given scheme. */
public double getTone(@NonNull DynamicScheme scheme) {
boolean decreasingContrast = scheme.contrastLevel < 0;
// Case 1: dual foreground, pair of colors with delta constraint.
if (toneDeltaPair != null) {
ToneDeltaPair toneDeltaPair = this.toneDeltaPair.apply(scheme);
DynamicColor roleA = toneDeltaPair.getRoleA();
DynamicColor roleB = toneDeltaPair.getRoleB();
double delta = toneDeltaPair.getDelta();
TonePolarity polarity = toneDeltaPair.getPolarity();
boolean stayTogether = toneDeltaPair.getStayTogether();
DynamicColor bg = background.apply(scheme);
double bgTone = bg.getTone(scheme);
boolean aIsNearer =
(polarity == TonePolarity.NEARER
|| (polarity == TonePolarity.LIGHTER && !scheme.isDark)
|| (polarity == TonePolarity.DARKER && scheme.isDark));
DynamicColor nearer = aIsNearer ? roleA : roleB;
DynamicColor farther = aIsNearer ? roleB : roleA;
boolean amNearer = name.equals(nearer.name);
double expansionDir = scheme.isDark ? 1 : -1;
// 1st round: solve to min, each
double nContrast = nearer.contrastCurve.get(scheme.contrastLevel);
double fContrast = farther.contrastCurve.get(scheme.contrastLevel);
// If a color is good enough, it is not adjusted.
// Initial and adjusted tones for `nearer`
double nInitialTone = nearer.tone.apply(scheme);
@Var
double nTone =
Contrast.ratioOfTones(bgTone, nInitialTone) >= nContrast
? nInitialTone
: DynamicColor.foregroundTone(bgTone, nContrast);
// Initial and adjusted tones for `farther`
double fInitialTone = farther.tone.apply(scheme);
@Var
double fTone =
Contrast.ratioOfTones(bgTone, fInitialTone) >= fContrast
? fInitialTone
: DynamicColor.foregroundTone(bgTone, fContrast);
if (decreasingContrast) {
// If decreasing contrast, adjust color to the "bare minimum"
// that satisfies contrast.
nTone = DynamicColor.foregroundTone(bgTone, nContrast);
fTone = DynamicColor.foregroundTone(bgTone, fContrast);
}
// If constraint is not satisfied, try another round.
if ((fTone - nTone) * expansionDir < delta) {
// 2nd round: expand farther to match delta.
fTone = MathUtils.clampDouble(0, 100, nTone + delta * expansionDir);
// If constraint is not satisfied, try another round.
if ((fTone - nTone) * expansionDir < delta) {
// 3rd round: contract nearer to match delta.
nTone = MathUtils.clampDouble(0, 100, fTone - delta * expansionDir);
}
}
// Avoids the 50-59 awkward zone.
if (50 <= nTone && nTone < 60) {
// If `nearer` is in the awkward zone, move it away, together with
// `farther`.
if (expansionDir > 0) {
nTone = 60;
fTone = max(fTone, nTone + delta * expansionDir);
} else {
nTone = 49;
fTone = min(fTone, nTone + delta * expansionDir);
}
} else if (50 <= fTone && fTone < 60) {
if (stayTogether) {
// Fixes both, to avoid two colors on opposite sides of the "awkward
// zone".
if (expansionDir > 0) {
nTone = 60;
fTone = max(fTone, nTone + delta * expansionDir);
} else {
nTone = 49;
fTone = min(fTone, nTone + delta * expansionDir);
}
} else {
// Not required to stay together; fixes just one.
if (expansionDir > 0) {
fTone = 60;
} else {
fTone = 49;
}
}
}
// Returns `nTone` if this color is `nearer`, otherwise `fTone`.
return amNearer ? nTone : fTone;
} else {
// Case 2: No contrast pair; just solve for itself.
@Var double answer = tone.apply(scheme);
if (background == null) {
return answer; // No adjustment for colors with no background.
}
double bgTone = background.apply(scheme).getTone(scheme);
double desiredRatio = contrastCurve.get(scheme.contrastLevel);
if (Contrast.ratioOfTones(bgTone, answer) >= desiredRatio) {
// Don't "improve" what's good enough.
} else {
// Rough improvement.
answer = DynamicColor.foregroundTone(bgTone, desiredRatio);
}
if (decreasingContrast) {
answer = DynamicColor.foregroundTone(bgTone, desiredRatio);
}
if (isBackground && 50 <= answer && answer < 60) {
// Must adjust
if (Contrast.ratioOfTones(49, bgTone) >= desiredRatio) {
answer = 49;
} else {
answer = 60;
}
}
if (secondBackground != null) {
// Case 3: Adjust for dual backgrounds.
double bgTone1 = background.apply(scheme).getTone(scheme);
double bgTone2 = secondBackground.apply(scheme).getTone(scheme);
double upper = max(bgTone1, bgTone2);
double lower = min(bgTone1, bgTone2);
if (Contrast.ratioOfTones(upper, answer) >= desiredRatio
&& Contrast.ratioOfTones(lower, answer) >= desiredRatio) {
return answer;
}
// The darkest light tone that satisfies the desired ratio,
// or -1 if such ratio cannot be reached.
double lightOption = Contrast.lighter(upper, desiredRatio);
// The lightest dark tone that satisfies the desired ratio,
// or -1 if such ratio cannot be reached.
double darkOption = Contrast.darker(lower, desiredRatio);
// Tones suitable for the foreground.
ArrayList T60 used as to create the smallest discontinuity possible when skipping down to T49 in order
* to ensure light foregrounds.
*
* Since `tertiaryContainer` in dark monochrome scheme requires a tone of 60, it should not be
* adjusted. Therefore, 60 is excluded here.
*/
public static boolean tonePrefersLightForeground(double tone) {
return Math.round(tone) < 60;
}
/** Tones less than ~T50 always permit white at 4.5 contrast. */
public static boolean toneAllowsLightForeground(double tone) {
return Math.round(tone) <= 49;
}
}