• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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