amzn / style-dictionary

A build system for creating cross-platform styles.
https://styledictionary.com
Apache License 2.0
3.88k stars 543 forks source link

Stuck on creating SwiftUI enums with 'dynamic' colors #1198

Open tijs opened 4 months ago

tijs commented 4 months ago

We have a working setup in our app generating design tokens for Android and iOS using style dictionary which is excellent. The only issue i'm running into is that instead of generating SwiftUI enums for light and dark mode separately i would love to just generate enums with a single dynamic color using the dark and light values directly. That seems like a better fit for SwiftUI anyway.

For context our generated tokens (iOS) look something like this now:

public protocol DividerDesignTokens {
    static var dividerColorDefault: Color { get }
    static var dividerColorInverse: Color { get }
    static var dividerSizeHeight: CGFloat { get }
}

public enum DividerLight: DividerDesignTokens {
    public static let dividerColorDefault = Color(red: 0.910, green: 0.910, blue: 0.910, opacity: 1)
    public static let dividerColorInverse = Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1)
    public static let dividerSizeHeight = CGFloat(1)
}

public enum DividerDark: DividerDesignTokens {
    public static let dividerColorDefault = Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1)
    public static let dividerColorInverse = Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1)
    public static let dividerSizeHeight = CGFloat(1)
}

This was pretty easy to setup and works reasonably well but to use it is still need to check the ColorScheme and pick the token from the correct enum each time. With a simple color extension:

public extension Color {
    init(light: Color, dark: Color) {
        self.init(light: UIColor(light), dark: UIColor(dark))
    }
}

i could have enums that look like this instead:

public enum DividerTokens: DividerDesignTokens {
    public static let dividerColorDefault = Color(light: Color(red: 0.910, green: 0.910, blue: 0.910, opacity: 1), dark:  Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1))
    public static let dividerColorInverse = Color(light:Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1), dark: Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1))
    public static let dividerSizeHeight = CGFloat(1)
}

But i'm having a very hard time figuring out how to generate the tokens with a custom color implementation. Anyone have pointers on where i should start or even some examples i could look into?

jorenbroekema commented 4 months ago

What does your token structure look like (e.g. for this color example)? This is quite important to know for me to help you sketch out a custom format that gives you the desired output

tijs commented 4 months ago

ah yes of course, still trying to figure this stuff out. specifically for this divider an entry will look like this:

{
  "divider": {
    "size": {
      "height": {
        "value": "{alias.divider.height}",
        "type": "sizing"
      }
    },
    "color": {
      "default": {
        "value": "{divider.color.mode.default}",
        "type": "color"
      },
      "inverse": {
        "value": "{divider.color.mode.inverse}",
        "type": "color"
      }
    }
  }
}

which is a reference to the light and dark modes in separate light.json and dark.json token files which might make this harder perhaps? in the light.json for instance you would have a matching entry:

"divider": {
    "color": {
      "mode": {
        "default": {
          "value": "{alias.divider.color.default.dark}",
          "type": "color"
        },
        "inverse": {
          "value": "{alias.divider.color.inverse.dark}",
          "type": "color"
        }
      }
    }
  },

where that alias will point to a colors.json file with the correct color. so instead of splitting the output by mode as we do now i will also have to generate the iOS token files with the light and dark values combined somehow instead of a separate enum file for each mode.

Hope this makes sense?

jorenbroekema commented 4 months ago

ah I see so you've got different theme files (dark/light) which are defining the same tokens but with different values. This means you usually have multiple style-dictionary runs, one for each theme, and thus different output files.

Would it be possible in the iOS implementation to have public enum DividerLight: DividerDesignTokens and public enum DividerDark: DividerDesignTokens living in separate files, and importing the correct one based on the theme selection?

tijs commented 4 months ago

Yeah that’s exactly what we do now 😁 my goal was to get rid of the extra overhead of needing some kind of token provider that switches themes based on the color scheme. Especially since you get that behavior for free if you have dynamic colors from the outset. Exporting a combined token file is not really an option since for Android & web the split fits well with how the platforms deal with theming.

jorenbroekema commented 4 months ago

The issue with this format:

public enum DividerTokens: DividerDesignTokens {
    public static let dividerColorDefault = Color(light: Color(red: 0.910, green: 0.910, blue: 0.910, opacity: 1), dark:  Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1))
    public static let dividerColorInverse = Color(light:Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1), dark: Color(red: 0.357, green: 0.357, blue: 0.357, opacity: 1))
    public static let dividerSizeHeight = CGFloat(1)
}

is that a single SD instance is only aware of light OR dark context, so it's hard to output the above format without having this multi-theme context and multiple SD instances. I guess it's similar to this format in CSS:

:root {
  --divider-color-default: rgba(240, 240, 240, 1);
  --divider-color-inverse: rgba(50, 50, 50, 1);
}

html[theme="dark"] {
  --divider-color-default: rgba(50, 50, 50, 1);
  --divider-color-inverse: rgba(240, 240, 240, 1);
}

Which is a common request as well, but also incredibly difficult to do with how Style Dictionary is currently structured to have formats output to file destinations, if you have 2 SD instances (light/dark) writing to the same file output, they'd just overwrite one another.

What you could try is the following approach, circumventing the format hook and creating your own logic for format (I'm using CSS as an example coz I'm more familiar with it):

import StyleDictionary from 'style-dictionary';
import { formattedVariables } from 'style-dictionary/utils';

function getConfig(theme) {
  return {
    source: [
      'tokens/primitives.json',
      `tokens/${theme}.json`,
      // etc.
    ],
    platforms: {
      css: {
        transformGroup: 'css',
      }
    }
  }
}

const sdLight = new StyleDictionary(getConfig('light'));
const sdDark = new StyleDictionary(getConfig('dark'));

const [light, dark] = await Promise.all(sdLight.getPlatform('css'), sdDark.getPlatform('css')];

const lightCSS = `:root {
  ${formattedVariables(
    format: 'css',
    dictionary: light.dictionary
  )}
};
`;

const darkCSS = `html[theme="dark"] {
  ${formattedVariables(
    format: 'css',
    dictionary: dark.dictionary
  )}
};
`;

const output = `// maybe some fileheader here

${lightCSS}
${darkCSS}`;

The caveats with this approach is that:

So what I'd really like to have in the future for Style Dictionary is a method called formatPlatform/formatAllPlatforms which doesn't write to the filesystem but just returns the outputs as strings, or as some other data structure (in your case you'd want an array of objects, light/dark props, containing the iOS UIColor() strings), enabling users to do with that what they want, whether that's writing to the filesystem or something more custom (like combining the strings together from multiple instances, and outputting it as a file themselves, but there are many use cases you can think for this)

jorenbroekema commented 4 months ago

https://github.com/amzn/style-dictionary/issues/1211 created an issue here, and also added your use case there

tijs commented 4 months ago

Thanks man, subscribed to that issue. I’ll play with it a bit more in the current setup but I’m hearing the “here be dragons” loud and clear 😅