1 /* 2 * Copyright 2022 Google LLC 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.google.ux.material.libmonet.dynamiccolor; 18 19 import static java.lang.Math.max; 20 import static java.lang.Math.min; 21 22 import androidx.annotation.NonNull; 23 import androidx.annotation.Nullable; 24 import com.google.errorprone.annotations.Var; 25 import com.google.ux.material.libmonet.contrast.Contrast; 26 import com.google.ux.material.libmonet.hct.Hct; 27 import com.google.ux.material.libmonet.palettes.TonalPalette; 28 import com.google.ux.material.libmonet.scheme.DynamicScheme; 29 import com.google.ux.material.libmonet.utils.MathUtils; 30 import java.util.ArrayList; 31 import java.util.HashMap; 32 import java.util.function.Function; 33 34 /** 35 * A color that adjusts itself based on UI state, represented by DynamicScheme. 36 * 37 * <p>This color automatically adjusts to accommodate a desired contrast level, or other adjustments 38 * such as differing in light mode versus dark mode, or what the theme is, or what the color that 39 * produced the theme is, etc. 40 * 41 * <p>Colors without backgrounds do not change tone when contrast changes. Colors with backgrounds 42 * become closer to their background as contrast lowers, and further when contrast increases. 43 * 44 * <p>Prefer the static constructors. They provide a much more simple interface, such as requiring 45 * just a hexcode, or just a hexcode and a background. 46 * 47 * <p>Ultimately, each component necessary for calculating a color, adjusting it for a desired 48 * contrast level, and ensuring it has a certain lightness/tone difference from another color, is 49 * provided by a function that takes a DynamicScheme and returns a value. This ensures ultimate 50 * flexibility, any desired behavior of a color for any design system, but it usually unnecessary. 51 * See the default constructor for more information. 52 */ 53 // Prevent lint for Function.apply not being available on Android before API level 14 (4.0.1). 54 // "AndroidJdkLibsChecker" for Function, "NewApi" for Function.apply(). 55 // A java_library Bazel rule with an Android constraint cannot skip these warnings without this 56 // annotation; another solution would be to create an android_library rule and supply 57 // AndroidManifest with an SDK set higher than 14. 58 @SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"}) 59 public final class DynamicColor { 60 public final String name; 61 public final Function<DynamicScheme, TonalPalette> palette; 62 public final Function<DynamicScheme, Double> tone; 63 public final boolean isBackground; 64 public final Function<DynamicScheme, DynamicColor> background; 65 public final Function<DynamicScheme, DynamicColor> secondBackground; 66 public final ContrastCurve contrastCurve; 67 public final Function<DynamicScheme, ToneDeltaPair> toneDeltaPair; 68 69 public final Function<DynamicScheme, Double> opacity; 70 71 private final HashMap<DynamicScheme, Hct> hctCache = new HashMap<>(); 72 73 /** 74 * A constructor for DynamicColor. 75 * 76 * <p>_Strongly_ prefer using one of the convenience constructors. This class is arguably too 77 * flexible to ensure it can support any scenario. Functional arguments allow overriding without 78 * risks that come with subclasses. 79 * 80 * <p>For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with 81 * its background is principled and matches accessibility guidance. That does not mean it's the 82 * desired approach for _every_ design system, and every color pairing, always, in every case. 83 * 84 * <p>For opaque colors (colors with alpha = 100%). 85 * 86 * @param name The name of the dynamic color. 87 * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is 88 * defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing 89 * a tonal palette, when contrast adjustments are made, intended chroma can be preserved. 90 * @param tone Function that provides a tone, given a DynamicScheme. 91 * @param isBackground Whether this dynamic color is a background, with some other color as the 92 * foreground. 93 * @param background The background of the dynamic color (as a function of a `DynamicScheme`), if 94 * it exists. 95 * @param secondBackground A second background of the dynamic color (as a function of a 96 * `DynamicScheme`), if it exists. 97 * @param contrastCurve A `ContrastCurve` object specifying how its contrast against its 98 * background should behave in various contrast levels options. 99 * @param toneDeltaPair A `ToneDeltaPair` object specifying a tone delta constraint between two 100 * colors. One of them must be the color being constructed. 101 */ DynamicColor( @onNull String name, @NonNull Function<DynamicScheme, TonalPalette> palette, @NonNull Function<DynamicScheme, Double> tone, boolean isBackground, @Nullable Function<DynamicScheme, DynamicColor> background, @Nullable Function<DynamicScheme, DynamicColor> secondBackground, @Nullable ContrastCurve contrastCurve, @Nullable Function<DynamicScheme, ToneDeltaPair> toneDeltaPair)102 public DynamicColor( 103 @NonNull String name, 104 @NonNull Function<DynamicScheme, TonalPalette> palette, 105 @NonNull Function<DynamicScheme, Double> tone, 106 boolean isBackground, 107 @Nullable Function<DynamicScheme, DynamicColor> background, 108 @Nullable Function<DynamicScheme, DynamicColor> secondBackground, 109 @Nullable ContrastCurve contrastCurve, 110 @Nullable Function<DynamicScheme, ToneDeltaPair> toneDeltaPair) { 111 112 this.name = name; 113 this.palette = palette; 114 this.tone = tone; 115 this.isBackground = isBackground; 116 this.background = background; 117 this.secondBackground = secondBackground; 118 this.contrastCurve = contrastCurve; 119 this.toneDeltaPair = toneDeltaPair; 120 this.opacity = null; 121 } 122 123 /** 124 * A constructor for DynamicColor. 125 * 126 * <p>_Strongly_ prefer using one of the convenience constructors. This class is arguably too 127 * flexible to ensure it can support any scenario. Functional arguments allow overriding without 128 * risks that come with subclasses. 129 * 130 * <p>For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with 131 * its background is principled and matches accessibility guidance. That does not mean it's the 132 * desired approach for _every_ design system, and every color pairing, always, in every case. 133 * 134 * <p>For opaque colors (colors with alpha = 100%). 135 * 136 * @param name The name of the dynamic color. 137 * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is 138 * defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing 139 * a tonal palette, when contrast adjustments are made, intended chroma can be preserved. 140 * @param tone Function that provides a tone, given a DynamicScheme. 141 * @param isBackground Whether this dynamic color is a background, with some other color as the 142 * foreground. 143 * @param background The background of the dynamic color (as a function of a `DynamicScheme`), if 144 * it exists. 145 * @param secondBackground A second background of the dynamic color (as a function of a 146 * `DynamicScheme`), if it exists. 147 * @param contrastCurve A `ContrastCurve` object specifying how its contrast against its 148 * background should behave in various contrast levels options. 149 * @param toneDeltaPair A `ToneDeltaPair` object specifying a tone delta constraint between two 150 * colors. One of them must be the color being constructed. 151 * @param opacity A function returning the opacity of a color, as a number between 0 and 1. 152 */ DynamicColor( @onNull String name, @NonNull Function<DynamicScheme, TonalPalette> palette, @NonNull Function<DynamicScheme, Double> tone, boolean isBackground, @Nullable Function<DynamicScheme, DynamicColor> background, @Nullable Function<DynamicScheme, DynamicColor> secondBackground, @Nullable ContrastCurve contrastCurve, @Nullable Function<DynamicScheme, ToneDeltaPair> toneDeltaPair, @Nullable Function<DynamicScheme, Double> opacity)153 public DynamicColor( 154 @NonNull String name, 155 @NonNull Function<DynamicScheme, TonalPalette> palette, 156 @NonNull Function<DynamicScheme, Double> tone, 157 boolean isBackground, 158 @Nullable Function<DynamicScheme, DynamicColor> background, 159 @Nullable Function<DynamicScheme, DynamicColor> secondBackground, 160 @Nullable ContrastCurve contrastCurve, 161 @Nullable Function<DynamicScheme, ToneDeltaPair> toneDeltaPair, 162 @Nullable Function<DynamicScheme, Double> opacity) { 163 this.name = name; 164 this.palette = palette; 165 this.tone = tone; 166 this.isBackground = isBackground; 167 this.background = background; 168 this.secondBackground = secondBackground; 169 this.contrastCurve = contrastCurve; 170 this.toneDeltaPair = toneDeltaPair; 171 this.opacity = opacity; 172 } 173 174 /** 175 * A convenience constructor for DynamicColor. 176 * 177 * <p>_Strongly_ prefer using one of the convenience constructors. This class is arguably too 178 * flexible to ensure it can support any scenario. Functional arguments allow overriding without 179 * risks that come with subclasses. 180 * 181 * <p>For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with 182 * its background is principled and matches accessibility guidance. That does not mean it's the 183 * desired approach for _every_ design system, and every color pairing, always, in every case. 184 * 185 * <p>For opaque colors (colors with alpha = 100%). 186 * 187 * <p>For colors that are not backgrounds, and do not have backgrounds. 188 * 189 * @param name The name of the dynamic color. 190 * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is 191 * defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing 192 * a tonal palette, when contrast adjustments are made, intended chroma can be preserved. 193 * @param tone Function that provides a tone, given a DynamicScheme. 194 */ 195 @NonNull fromPalette( @onNull String name, @NonNull Function<DynamicScheme, TonalPalette> palette, @NonNull Function<DynamicScheme, Double> tone)196 public static DynamicColor fromPalette( 197 @NonNull String name, 198 @NonNull Function<DynamicScheme, TonalPalette> palette, 199 @NonNull Function<DynamicScheme, Double> tone) { 200 return new DynamicColor( 201 name, 202 palette, 203 tone, 204 /* isBackground= */ false, 205 /* background= */ null, 206 /* secondBackground= */ null, 207 /* contrastCurve= */ null, 208 /* toneDeltaPair= */ null); 209 } 210 211 /** 212 * A convenience constructor for DynamicColor. 213 * 214 * <p>_Strongly_ prefer using one of the convenience constructors. This class is arguably too 215 * flexible to ensure it can support any scenario. Functional arguments allow overriding without 216 * risks that come with subclasses. 217 * 218 * <p>For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with 219 * its background is principled and matches accessibility guidance. That does not mean it's the 220 * desired approach for _every_ design system, and every color pairing, always, in every case. 221 * 222 * <p>For opaque colors (colors with alpha = 100%). 223 * 224 * <p>For colors that do not have backgrounds. 225 * 226 * @param name The name of the dynamic color. 227 * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is 228 * defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing 229 * a tonal palette, when contrast adjustments are made, intended chroma can be preserved. 230 * @param tone Function that provides a tone, given a DynamicScheme. 231 * @param isBackground Whether this dynamic color is a background, with some other color as the 232 * foreground. 233 */ 234 @NonNull fromPalette( @onNull String name, @NonNull Function<DynamicScheme, TonalPalette> palette, @NonNull Function<DynamicScheme, Double> tone, boolean isBackground)235 public static DynamicColor fromPalette( 236 @NonNull String name, 237 @NonNull Function<DynamicScheme, TonalPalette> palette, 238 @NonNull Function<DynamicScheme, Double> tone, 239 boolean isBackground) { 240 return new DynamicColor( 241 name, 242 palette, 243 tone, 244 isBackground, 245 /* background= */ null, 246 /* secondBackground= */ null, 247 /* contrastCurve= */ null, 248 /* toneDeltaPair= */ null); 249 } 250 251 /** 252 * Create a DynamicColor from a hex code. 253 * 254 * <p>Result has no background; thus no support for increasing/decreasing contrast for a11y. 255 * 256 * @param name The name of the dynamic color. 257 * @param argb The source color from which to extract the hue and chroma. 258 */ 259 @NonNull fromArgb(@onNull String name, int argb)260 public static DynamicColor fromArgb(@NonNull String name, int argb) { 261 Hct hct = Hct.fromInt(argb); 262 TonalPalette palette = TonalPalette.fromInt(argb); 263 return DynamicColor.fromPalette(name, (s) -> palette, (s) -> hct.getTone()); 264 } 265 266 /** 267 * Returns an ARGB integer (i.e. a hex code). 268 * 269 * @param scheme Defines the conditions of the user interface, for example, whether or not it is 270 * dark mode or light mode, and what the desired contrast level is. 271 */ getArgb(@onNull DynamicScheme scheme)272 public int getArgb(@NonNull DynamicScheme scheme) { 273 int argb = getHct(scheme).toInt(); 274 if (opacity == null) { 275 return argb; 276 } 277 double percentage = opacity.apply(scheme); 278 int alpha = MathUtils.clampInt(0, 255, (int) Math.round(percentage * 255)); 279 return (argb & 0x00ffffff) | (alpha << 24); 280 } 281 282 /** 283 * Returns an HCT object. 284 * 285 * @param scheme Defines the conditions of the user interface, for example, whether or not it is 286 * dark mode or light mode, and what the desired contrast level is. 287 */ 288 @NonNull getHct(@onNull DynamicScheme scheme)289 public Hct getHct(@NonNull DynamicScheme scheme) { 290 Hct cachedAnswer = hctCache.get(scheme); 291 if (cachedAnswer != null) { 292 return cachedAnswer; 293 } 294 // This is crucial for aesthetics: we aren't simply the taking the standard color 295 // and changing its tone for contrast. Rather, we find the tone for contrast, then 296 // use the specified chroma from the palette to construct a new color. 297 // 298 // For example, this enables colors with standard tone of T90, which has limited chroma, to 299 // "recover" intended chroma as contrast increases. 300 double tone = getTone(scheme); 301 Hct answer = palette.apply(scheme).getHct(tone); 302 // NOMUTANTS--trivial test with onerous dependency injection requirement. 303 if (hctCache.size() > 4) { 304 hctCache.clear(); 305 } 306 // NOMUTANTS--trivial test with onerous dependency injection requirement. 307 hctCache.put(scheme, answer); 308 return answer; 309 } 310 311 /** Returns the tone in HCT, ranging from 0 to 100, of the resolved color given scheme. */ getTone(@onNull DynamicScheme scheme)312 public double getTone(@NonNull DynamicScheme scheme) { 313 boolean decreasingContrast = scheme.contrastLevel < 0; 314 315 // Case 1: dual foreground, pair of colors with delta constraint. 316 if (toneDeltaPair != null) { 317 ToneDeltaPair toneDeltaPair = this.toneDeltaPair.apply(scheme); 318 DynamicColor roleA = toneDeltaPair.getRoleA(); 319 DynamicColor roleB = toneDeltaPair.getRoleB(); 320 double delta = toneDeltaPair.getDelta(); 321 TonePolarity polarity = toneDeltaPair.getPolarity(); 322 boolean stayTogether = toneDeltaPair.getStayTogether(); 323 324 DynamicColor bg = background.apply(scheme); 325 double bgTone = bg.getTone(scheme); 326 327 boolean aIsNearer = 328 (polarity == TonePolarity.NEARER 329 || (polarity == TonePolarity.LIGHTER && !scheme.isDark) 330 || (polarity == TonePolarity.DARKER && scheme.isDark)); 331 DynamicColor nearer = aIsNearer ? roleA : roleB; 332 DynamicColor farther = aIsNearer ? roleB : roleA; 333 boolean amNearer = name.equals(nearer.name); 334 double expansionDir = scheme.isDark ? 1 : -1; 335 336 // 1st round: solve to min, each 337 double nContrast = nearer.contrastCurve.getContrast(scheme.contrastLevel); 338 double fContrast = farther.contrastCurve.getContrast(scheme.contrastLevel); 339 340 // If a color is good enough, it is not adjusted. 341 // Initial and adjusted tones for `nearer` 342 double nInitialTone = nearer.tone.apply(scheme); 343 @Var 344 double nTone = 345 Contrast.ratioOfTones(bgTone, nInitialTone) >= nContrast 346 ? nInitialTone 347 : DynamicColor.foregroundTone(bgTone, nContrast); 348 // Initial and adjusted tones for `farther` 349 double fInitialTone = farther.tone.apply(scheme); 350 @Var 351 double fTone = 352 Contrast.ratioOfTones(bgTone, fInitialTone) >= fContrast 353 ? fInitialTone 354 : DynamicColor.foregroundTone(bgTone, fContrast); 355 356 if (decreasingContrast) { 357 // If decreasing contrast, adjust color to the "bare minimum" 358 // that satisfies contrast. 359 nTone = DynamicColor.foregroundTone(bgTone, nContrast); 360 fTone = DynamicColor.foregroundTone(bgTone, fContrast); 361 } 362 363 // If constraint is not satisfied, try another round. 364 if ((fTone - nTone) * expansionDir < delta) { 365 // 2nd round: expand farther to match delta. 366 fTone = MathUtils.clampDouble(0, 100, nTone + delta * expansionDir); 367 // If constraint is not satisfied, try another round. 368 if ((fTone - nTone) * expansionDir < delta) { 369 // 3rd round: contract nearer to match delta. 370 nTone = MathUtils.clampDouble(0, 100, fTone - delta * expansionDir); 371 } 372 } 373 374 // Avoids the 50-59 awkward zone. 375 if (50 <= nTone && nTone < 60) { 376 // If `nearer` is in the awkward zone, move it away, together with 377 // `farther`. 378 if (expansionDir > 0) { 379 nTone = 60; 380 fTone = max(fTone, nTone + delta * expansionDir); 381 } else { 382 nTone = 49; 383 fTone = min(fTone, nTone + delta * expansionDir); 384 } 385 } else if (50 <= fTone && fTone < 60) { 386 if (stayTogether) { 387 // Fixes both, to avoid two colors on opposite sides of the "awkward 388 // zone". 389 if (expansionDir > 0) { 390 nTone = 60; 391 fTone = max(fTone, nTone + delta * expansionDir); 392 } else { 393 nTone = 49; 394 fTone = min(fTone, nTone + delta * expansionDir); 395 } 396 } else { 397 // Not required to stay together; fixes just one. 398 if (expansionDir > 0) { 399 fTone = 60; 400 } else { 401 fTone = 49; 402 } 403 } 404 } 405 406 // Returns `nTone` if this color is `nearer`, otherwise `fTone`. 407 return amNearer ? nTone : fTone; 408 } else { 409 // Case 2: No contrast pair; just solve for itself. 410 @Var double answer = tone.apply(scheme); 411 412 if (background == null) { 413 return answer; // No adjustment for colors with no background. 414 } 415 416 double bgTone = background.apply(scheme).getTone(scheme); 417 418 double desiredRatio = contrastCurve.getContrast(scheme.contrastLevel); 419 420 if (Contrast.ratioOfTones(bgTone, answer) >= desiredRatio) { 421 // Don't "improve" what's good enough. 422 } else { 423 // Rough improvement. 424 answer = DynamicColor.foregroundTone(bgTone, desiredRatio); 425 } 426 427 if (decreasingContrast) { 428 answer = DynamicColor.foregroundTone(bgTone, desiredRatio); 429 } 430 431 if (isBackground && 50 <= answer && answer < 60) { 432 // Must adjust 433 if (Contrast.ratioOfTones(49, bgTone) >= desiredRatio) { 434 answer = 49; 435 } else { 436 answer = 60; 437 } 438 } 439 440 if (secondBackground != null) { 441 // Case 3: Adjust for dual backgrounds. 442 443 double bgTone1 = background.apply(scheme).getTone(scheme); 444 double bgTone2 = secondBackground.apply(scheme).getTone(scheme); 445 446 double upper = max(bgTone1, bgTone2); 447 double lower = min(bgTone1, bgTone2); 448 449 if (Contrast.ratioOfTones(upper, answer) >= desiredRatio 450 && Contrast.ratioOfTones(lower, answer) >= desiredRatio) { 451 return answer; 452 } 453 454 // The darkest light tone that satisfies the desired ratio, 455 // or -1 if such ratio cannot be reached. 456 double lightOption = Contrast.lighter(upper, desiredRatio); 457 458 // The lightest dark tone that satisfies the desired ratio, 459 // or -1 if such ratio cannot be reached. 460 double darkOption = Contrast.darker(lower, desiredRatio); 461 462 // Tones suitable for the foreground. 463 ArrayList<Double> availables = new ArrayList<>(); 464 if (lightOption != -1) { 465 availables.add(lightOption); 466 } 467 if (darkOption != -1) { 468 availables.add(darkOption); 469 } 470 471 boolean prefersLight = 472 DynamicColor.tonePrefersLightForeground(bgTone1) 473 || DynamicColor.tonePrefersLightForeground(bgTone2); 474 if (prefersLight) { 475 return (lightOption == -1) ? 100 : lightOption; 476 } 477 if (availables.size() == 1) { 478 return availables.get(0); 479 } 480 return (darkOption == -1) ? 0 : darkOption; 481 } 482 483 return answer; 484 } 485 } 486 487 /** 488 * Given a background tone, find a foreground tone, while ensuring they reach a contrast ratio 489 * that is as close to ratio as possible. 490 */ foregroundTone(double bgTone, double ratio)491 public static double foregroundTone(double bgTone, double ratio) { 492 double lighterTone = Contrast.lighterUnsafe(bgTone, ratio); 493 double darkerTone = Contrast.darkerUnsafe(bgTone, ratio); 494 double lighterRatio = Contrast.ratioOfTones(lighterTone, bgTone); 495 double darkerRatio = Contrast.ratioOfTones(darkerTone, bgTone); 496 boolean preferLighter = tonePrefersLightForeground(bgTone); 497 498 if (preferLighter) { 499 // "Neglible difference" handles an edge case where the initial contrast ratio is high 500 // (ex. 13.0), and the ratio passed to the function is that high ratio, and both the lighter 501 // and darker ratio fails to pass that ratio. 502 // 503 // This was observed with Tonal Spot's On Primary Container turning black momentarily between 504 // high and max contrast in light mode. PC's standard tone was T90, OPC's was T10, it was 505 // light mode, and the contrast level was 0.6568521221032331. 506 boolean negligibleDifference = 507 Math.abs(lighterRatio - darkerRatio) < 0.1 && lighterRatio < ratio && darkerRatio < ratio; 508 if (lighterRatio >= ratio || lighterRatio >= darkerRatio || negligibleDifference) { 509 return lighterTone; 510 } else { 511 return darkerTone; 512 } 513 } else { 514 return darkerRatio >= ratio || darkerRatio >= lighterRatio ? darkerTone : lighterTone; 515 } 516 } 517 518 /** 519 * Adjust a tone down such that white has 4.5 contrast, if the tone is reasonably close to 520 * supporting it. 521 */ enableLightForeground(double tone)522 public static double enableLightForeground(double tone) { 523 if (tonePrefersLightForeground(tone) && !toneAllowsLightForeground(tone)) { 524 return 49.0; 525 } 526 return tone; 527 } 528 529 /** 530 * People prefer white foregrounds on ~T60-70. Observed over time, and also by Andrew Somers 531 * during research for APCA. 532 * 533 * <p>T60 used as to create the smallest discontinuity possible when skipping down to T49 in order 534 * to ensure light foregrounds. 535 * 536 * <p>Since `tertiaryContainer` in dark monochrome scheme requires a tone of 60, it should not be 537 * adjusted. Therefore, 60 is excluded here. 538 */ tonePrefersLightForeground(double tone)539 public static boolean tonePrefersLightForeground(double tone) { 540 return Math.round(tone) < 60; 541 } 542 543 /** Tones less than ~T50 always permit white at 4.5 contrast. */ toneAllowsLightForeground(double tone)544 public static boolean toneAllowsLightForeground(double tone) { 545 return Math.round(tone) <= 49; 546 } 547 } 548