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