🎨

Figma VariablesからSwift, Kotlin, Reactの3プラットフォームにデザイントークンを配信する!

2024/08/01に公開

はじめに

タイミーでプロダクトデザイナーをしております、横田です。
最近、プロダクトデザイン・開発にデザインシステムの導入を推進しています。
タイミーではワーカー様向けのネイティブアプリ(iOS、Android)、事業者様向けの管理画面(Web)といった複数のプラットフォームを運用しており、一貫したデザインと効率的な更新が課題になります。

本記事では、Figmaで管理されたデザイントークンを、Swift(iOS)、Kotlin(Android)、React(Web)の3つのプラットフォームに半自動で配信する仕組みを構築し、デザインワークと開発のシームレスな統合を実現したのでご紹介します。

キーワード: デザインシステム・デザイントークン・Figma Variables・Style Dictionary・Github Workflow

デザイントークンとは?

デザイントークンとは、プロダクトのデザインにおける再利用可能な要素(色、タイポグラフィ、スペーシングなど)を定義したものです。これらを統一的に管理することで一貫性のあるデザインの実現と、デザイン変更の効率化が可能になります。
(タイミーでは現在、色に関する管理のみをおこなっています)
デザイントークンの運用を仕組み化し、次のような状態を目指します。

  1. デザイントークンの一元管理:Figmaで管理されたデザイントークンが、すべてのプラットフォームで統一して使用されます。
  2. コード品質の向上:手動での更新によるミスを防ぎ、一貫性のあるコードを維持できます。
  3. 開発効率の向上:デザイナーとエンジニアの協業がスムーズになり、開発サイクルが短縮されます。

とはいえ、複数のプラットフォームで開発がおこなわれている場合、それぞれを実装面でメンテナンスをし続けることは大きな手間になってしまいます。いかにオペレーション負担を減らして、価値創出に取り組める環境を維持できるかを考えました。

仕組みの概要

今回構築した仕組みは以下の流れで動作します。

  1. デザイナーがFigmaでデザイントークンを更新
  2. Figma Variablesから生成されたJSONファイルを中間リポジトリにコミット・マージ
  3. GitHub Actionsが起動し、各プラットフォーム用のコードを生成
  4. 生成されたコードを各プラットフォームのリポジトリにPRとして自動作成

実装詳細

Figmaでのデザイントークン管理

Figmaでは、Variablesを使用してデザイントークンを管理しはじめました。少し前までは部分的にTokens Studioを使用していましたが、後発のネイティブ機能として登場したVariablesに一気に乗り換えることにしました。

Figma Variablesについては以下をご覧ください。
https://www.youtube.com/watch?v=1ONxxlJnvdM

https://help.figma.com/hc/ja/articles/15339657135383-Figmaでのバリアブルに関するガイド

中間リポジトリの設置

Figma Variablesから直接GitHubにプッシュする機能がないため、デザイナーはプラグインを介してVariablesをjson形式で書き出します。
これを格納するための中間リポジトリを設置し、これをマスタと運用します。デザイナーはここにJSONファイルを更新し、レビュー後にマージします。

Variablesを出力したjsonはこのような構成になっています。

  • Figma運用上は、汎用的なカラーパレットであるglobal tokensを参照してsemantic colorに色を割り当てていますが、出力時にはHEXで出力されています。
  • Variable Modeでlightdarkをわけており、コレクション名の次の階層にModeが入り、その下にvariablesの階層がぶら下がる構造です。
// tokens.json
{
  "colors": {
    "light": {
      "text": {
        "primary": {
          "normal": {
            "value": "#242424"
          },
          "soft": {
            "value": "#919191"
          },
          "softer": {
            "value": "#b5b5b5"
          }
        },
      },
    },
    "dark": {
      ...
    }
  },
}

デザイントークン自体の設計にはまた妙がありますので、別の機会に記事にしようと思います。

Style Dictionaryによるコード生成

Style Dictionaryは、デザイントークンの変換に特化したNode.js製のツールです。
デザイントークンを記述したjsonファイルを複数のプラットフォームで利用可能なフォーマットに変換をします。

https://amzn.github.io/style-dictionary/#/

以下は、そのサンプルコードです。

// index.web.ts
import StyleDictionary from "style-dictionary";

StyleDictionary.registerFormat({
  name: "javascript/module-flat",
  formatter: ({ dictionary, file }) => {
    // 省略: フォーマッタの実装
  },
});

const styleDictionary = StyleDictionary.extend({
  source: ["./src/tokens.json"],
  platforms: {
    ts: {
      transformGroup: "custom/ts",
      buildPath: "./dist/web/",
      files: [
        {
          destination: "globalColors.ts",
          format: "javascript/module-flat",
          filter: (token) => token.path[0] === "global-colors",
        },
        // 他のファイル定義
      ],
    },
  },
});

styleDictionary.buildAllPlatforms();

この実装では、tokens.jsonからTypeScript用のコードを生成しています。実際には、iOS, Android, Web用にそれぞれ実装をおこなっています。
プラットフォームごとに望ましい書き出し分けやフォーマットがあるので、開発者にヒアリングしながらベストな出力になるように調整していきます。
tokens.jsonでは前述のとおり、light/darkのモードが階層の1つとして出力されるため、これをふまえて別々のコードに出力したり、同じコードに同居させたりなど、Style Dictionaryの標準機能では対応できない要件がありました。そのため、実際はもっと複雑な実装をしています。

GitHub Actionsによる自動PR作成

中間リポジトリが更新されたら、GitHub Actionsが起動するようワークフローを作成します。このワークフローでは上述のStyle Dictionaryによる機構で各プラットフォーム用のコードを生成し、それぞれのリポジトリにPRを作成します。
ちょっとした工夫ですが、Github ActionsのMatrix strategyを使用し、変数化した各プラットフォームへの処理を汎用化しています。

name: Update Design Tokens

on:
  push:
    branches: [main]
    paths: ["src/tokens.json"]

env:
  NODE_VERSION: "18"
  PNPM_VERSION: "8"

jobs:
  update-tokens:
    strategy:
      matrix:
        target: [android, ios, web]
        include:
          - target: android
            repo: "YourOrg/your-android-repo"
            branch: "develop"
            build_command: "pnpm build:android"
            file_paths:
              - src: "dist/android/Colors.kt"
                dist: "app/src/main/java/com/yourorg/app/theme/Colors.kt"
          - target: ios
            repo: "YourOrg/your-ios-repo"
            branch: "develop"
            build_command: "pnpm build:ios"
            file_paths:
              - src: "dist/ios/Colors.swift"
                dist: "YourApp/Theme/Colors.swift"
          - target: web
            repo: "YourOrg/your-web-repo"
            branch: "develop"
            build_command: "pnpm build:web"
            file_paths:
              - src: "dist/web/colors.ts"
                dist: "src/styles/colors.ts"
    runs-on: ubuntu-latest
    steps:
      - name: Checkout design-tokens repo
        uses: actions/checkout@v3

      - name: Setup Node.js and pnpm
        uses: pnpm/action-setup@v2
        with:
          version: ${{ env.PNPM_VERSION }}
      - uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"

      - run: pnpm install
      - run: ${{ matrix.build_command }}

      - name: Checkout target repository
        uses: actions/checkout@v3
        with:
          repository: ${{ matrix.repo }}
          token: ${{ secrets.GITHUB_TOKEN }}
          path: target-repo

      - name: Update files and create PR
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # Copy files
          for file in ${{ toJson(matrix.file_paths) }}; do
            src=$(echo $file | jq -r '.src')
            dist=$(echo $file | jq -r '.dist')
            cp "$src" "target-repo/$dist"
          done

          # Create PR
          cd target-repo
          git config user.name "GitHub Actions Bot"
          git config user.email "actions@github.com"

          branch_name="update/design-tokens-$(date +%Y%m%d-%H%M%S)"
          git checkout -b "$branch_name"
          git add .
          git commit -m "Update design tokens"
          git push origin "$branch_name"

          gh pr create --title "Update design tokens" \\
                       --body "This PR updates the design tokens." \\
                       --base ${{ matrix.branch }} \\
                       --head "$branch_name"

各プラットフォームでの利用

iOS (Swift)

生成されたSwiftファイルは以下のような構造になります。
Swift UIでは一行でライトモード・ダークモード2色を指定することができます。

// Colors.swift
import SwiftUI

public class Colors {
  public struct Text {
    public struct Primary {
      static let normal = Color(light: "#242424", dark: "#ffffff")
      static let soft = Color(light: "#919191", dark: "#f1f1f1")
      // 他の色定義
    }
    // 他のカテゴリ
  }
  // 他の大カテゴリ
}

Text("Hello, World!")
    .foregroundColor(Colors.Text.Primary.normal)

Android (Kotlin)

こちらはAndroid用のコードです。別途、変数ごとのinterfaceファイルも書き出ししています。

// AppColors.kt
interface AppColors {
    interface Text {
        interface Primary {
            val normal: Color
            val soft: Color
            // 他の色定義
        }
        val primary: Primary
        // 他のカテゴリ
    }
    val text: Text
    // 他の大カテゴリ
}
// LightAppColors.kt
internal data object LightAppColors : AppColors {
    override val text = object : AppColors.Text {
        override val primary = object : AppColors.Text.Primary {
            override val normal = Color(0xff242424)
            override val soft = Color(0xff919191)
            // 他の色定義
        }
        // 他のカテゴリ
    }
    // 他の大カテゴリ
}

Text(
    text = "Hello, World!",
    color = LocalAppColors.current.text.primary.normal
)

Web (React)

// lightColors.ts
export const lightColors = {
  text: {
    primary: {
      normal: "#242424",
      soft: "#919191",
      // 他の色定義
    },
    // 他のカテゴリ
  },
  // 他の大カテゴリ
} as const;

import { lightColors } from './lightColors';

function MyComponent() {
  return (
    <div style={{ color: lightColors.text.primary.normal }}>
      Hello, World!
    </div>
  );
}

おわりに

上記の仕組みをベースに運用を開始したところではありますが、最小限の認知コストでデザイントークンをプラットフォーム共通で運用できるようになりました。
ご協力いただいた各プラットフォームのエンジニア、デザイントークンの設計に携わったデザイナーにSpecial Thanksです🎉

タイミーでは、デザインシステムを活用したデザイン・開発の仕組み化に興味のあるデザイナーを募集しています(もちろんエンジニアも!)

デザインシステムの運用について話したい!という方はぜひこちらから話しかけていただけたらと思います!

https://youtrust.jp/recruitment_posts/ca03f23d59452dd559dbdb2f130cf2a9

Discussion