カスタムなSF SymbolsをSVGから自動生成する

(English version here)

明けましておめでとうございます。モバイル基盤部のヴァンサン(@vincentisambart)です。

最近Appleがアプリの画面で使えるシンボルSF Symbolsに力を入れています。SF SymbolsはAppleの用意してくれたシンボルだけではなく、自分の作ったカスタムシンボルも使えます。Appleの紹介しているカスタムシンボルを作るワークフローに従うと手間がかかるので、既存のSVGからカスタムシンボルを自動生成できないか挑戦してみました。

経緯

だいぶ前からiOSクックパッドアプリで色んな画面で使われている単色アイコンはCookpadSymbolsというシンボルのみのフォントが使われていましたが、数ヶ月前デザイナーからシンボルの運用をフォントファイルからSVGに変えたいという要望が挙がりました。

アイコンは元々SVGで作成されていましたが、変更を加える度にSVGや設定ファイルをウェブ上のツールに読み込ませてフォントを生成するステップを省きたかったそうです。今となってはSVGを直接使えるようになった場面が多いですし。

CookpadSymbolsはこんな感じです。

f:id:vincentisambart:20201228112920p:plain

iOSでは、SVGとして用意されたシンボルを使うには以下の3つの方法があるかと思います。

  1. サイズの決まったピクセル画像として使う(実質PNGに変換されたかのように)
  2. ベクターデータのまま画像として使う(Asset CatalogのPreserve Vector Data設定)
    • Xcode 12以上で直接SVGを使えるようになりましたが、iOS 12以下でベクターデータとして扱うにはSVGを事前にPDFに変換する必要があるようです。
  3. カスタムシンボルとして使う(カスタムシンボルは簡単に言いますと自分で用意したカスタムなSF Symbolsのことです)
    • iOS 13以上が必要です。

iOSクックパッドアプリでは、シンボルは今までフォント形式で扱っていて、同じシンボルは画面によって違うサイズで表示されるので、固定サイズ画像として扱うとなるとだいぶ不便になります。元がベクター画像なので簡単に様々なサイズを自動的に用意できるとはいえ。

最近AppleがSF Symbolsを大きくプッシュしているようですし、デザイナーの要望が挙がった当時すぐiOS 12のサポートを終了する予定だったので、方法3でやってみることにしました。もしもiOS 12のサポート終了が大幅に遅れる場合や、実装している途中で大きい問題が発生した場合、最悪方法2にフォールバックすれば良いでしょうし。

結局iOS 12のサポート終了が当初の予定より遅れましたが、方法3のままで進みました。どうやって実装したのか説明しようと思いますが、その前にカスタムシンボルをもう少し説明しておきましょう。

カスタムシンボルとは

カスタムシンボルを紹介するには、まずSF Symbolsの話をしなければいけません。SF SymbolsはiOS 13以上に使える機能で、iOS開発者がアプリで使えるシンボル(色んなサイズで使えるシンプルな単色アイコン)です。普通の固定サイズの画像ではなく、文字と一緒に使えるように設計されています:サイズはフォントのポイントサイズで指定しますし、配置はフォントのベースラインに合わせることができます。

Appleの用意してくれたSF SymbolsはSF Symbolsアプリで以下のようにリストを見たり検索したりできます。

f:id:vincentisambart:20201228112934p:plain

Appleの用意してくれたシンボルだけではなく、自分の用意したカスタムなシンボルも合わせて使えます。カスタムなシンボルを用意するには、公式ガイドに従うと、まず公式のSF Symbolsアプリで追加したいシンボルに一番近いシンボルを選んで、SVGとしてエキスポートします。そのSVGをベクター画像編集ソフト(Illustratorなど)で編集して、Xcodeで使えるシンボルを用意します。

クックパッド内で使われているCookpadSymbolsはシンボルが現状300個近くあります。1つずつ手動で編集するとしたら手間が大きいです。運用変更の主な経緯がデザイナーにとってもっと運用しやすくなるためでしたので、手動でやりたくありません。自動化はプログラマーの大事な役目ですし、SVGは結局XML なので、なんとかなると思って作業を始めました。

SVGをXcodeに読み込ませてみる

SVGは既にデザイナーによって用意されていました。因みにそのSVGはウェブでもAndroidでも使われています。用意されていたSVGの1つが以下の通りでした(中身を細かく理解する必要はありません)。

<svg height="64" width="64" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="12" r="8"/><path d="M52.7 50.941l-7.913-4.396-3.335-8.34-.642-8.994 3.257.723 1.517 6.826a3.504 3.504 0 004.176 2.658 3.5 3.5 0 002.658-4.176l-2-9a3.5 3.5 0 00-2.658-2.658l-9-2a3.416 3.416 0 00-1.276-.037c-.16-.022-.319-.047-.484-.047h-8c-.163 0-.32.031-.479.055a3.48 3.48 0 00-3.254 1.26l-7.163 8.953-4.679.781a3.501 3.501 0 001.15 6.904l6-1a3.513 3.513 0 002.158-1.266l4.18-5.225 1.346 6.279-6.126 8.752A3.503 3.503 0 0021.5 49v9a3.5 3.5 0 107 0v-7.896l5.322-7.604h1.808l3.12 7.801a3.51 3.51 0 001.55 1.76l9 5a3.5 3.5 0 004.759-1.36 3.5 3.5 0 00-1.359-4.76z"/></svg>

ウェブブラウザーやベクター画像編集ソフトに読み込ませてみると、以下のように表示されます。

f:id:vincentisambart:20201228112940p:plain

Xcode 12がSVGを読み込めるので、深く考えずにこのSVGをXcodeでAsset Catalogにドラッグ&ドロップしてみると以下のようになります。

f:id:vincentisambart:20201228113043p:plain

求めているものとだいぶ違います。用意されていたSVGが最適化されている(不要なものが省いてある)ように見えるので、その最適化のどこかがXcodeと相性が悪いのかなと思いました。中身をよく見ると、最適化されているように見えるとはいえ、path004.176のように、数字の冒頭に無駄に見える0がある箇所があるのが少し不自然に感じました。単なるテキスト(XML)ファイルなので、ネット上のSVGの仕様をチラ見してから、試しにテキストエディターですべての不自然な0の後にスペースを入れてみて(004.1760 0 4.176など)、改めてXcodeに読み込ませてみたら以下のようになりました。

f:id:vincentisambart:20201228113052p:plain

まだ完璧ではないが、だいぶよくなりました。やはりXcodeの使っているSVG読み込みコードのSVGの仕様の解釈が不完全なようです。

XcodeのSVGの解釈を自分で補うことにするとしたらSVGの仕様を細かく理解する必要が出てくるので、自分でやる前にやってくれるツールがないでしょうか。

デザイナーに用意されていたSVGのレポジトリをよく見てみたら、SVGはSVGOというツールを使って最適化されていたようです。そのツールの設定を調べてみたら、それらしいpathに関する設定がありました。既にあった設定ファイルsvgo.ymlの最後に以下の2行を足して、SVGOを実行してみたら、なんと用意されたどのSVGも無事にXcodeに読み込まれるようになりました。

  - convertPathData:
      # Xcode doesn't handle properly paths without spaces after flags
      noSpaceAfterFlags: false

f:id:vincentisambart:20201228113102p:plain

1つだけの設定変更で済んで良かったです。

SVGファイルが以前に比べてほんの少し大きくなりますが、プラットフォームごとに設定を変えるとしたら運用が大変なので、どのプラットフォームも上記の設定で最適化されたSVGを使うことにしました。

Xcodeが読み込めるSVGになったのは大事な第一歩ですが、SVGを普通の画像としてではなく、シンボルとして使いたいので、SVGを元にシンボルを用意する必要があります。

シンボルを用意

公式ガイドに従うと、シンボルの用意の第一歩がSF Symbolsアプリから既存のSF Symbolsをまずエキスポートすることです。一番シンプルそうなcircleをエキスポートすると、以下のようなSVGファイルが書き出されます。

f:id:vincentisambart:20201228113116p:plain

シンボルごとにサイズ3つ、ウェイト8つを用意できますし、全部用意できたら一番良いのでしょうが、公式ガイドを読むとRegular Medium(Regular-M)だけが必須です。ひとまずは必須のもののみを用意することにしました。図形の縮尺を変えるだけなら、他のサイズはあとで簡単にできそうですし。

シンボルの運用を楽にしたいので、SF Symbolsアプリからエキスポートしたテンプレートに既存のSVGの中身を入れるのはガイドの説明のように手動ではなく、スクリプトでやることにしました。僕にとって書きやすいからRubyで書きましたが、XMLを扱うライブラリがあれば、どの言語でも簡単にできると思います。以下のコードはシンプルにしてコメントを多めにしたので、Rubyが分からなくてもやっているこを問題なく追えると思います。コード内のセレクターはできるだけCSSセレクターを使っています(#abcdがXML内にidの値がabcdであるノードを示します)。

最初は前準備です。ライブラリを読み込んで、必要な定数を定義して、テンプレートを読み込みます。

require "nokogiri" # XMLライブラリを使います

# SF Symbolsアプリからエキスポートしたファイルへのパス
TEMPLATE_PATH = "path/to/circle.svg"
# 用意されたSVGへのパス
SOURCE_SVG_PATH = "icon.svg"
# 出力されるSVGへのパス
DESTINATION_SVG_PATH = "icon-symbol.svg"

# 期待されているアイコンサイズ
ICON_WIDTH = 64
ICON_HEIGHT = 64
# SF Symbolsに近いサイズになるために必要な倍率(色々試した結果これで良さそうでした)
ADDITIONAL_SCALING = 1.7
# SVG内の#left-marginと#right-marginの幅
MARGIN_LINE_WIDTH = 0.5
# 左右に足している余白
ADDITIONAL_HORIZONTAL_MARGIN = 4

# テンプレートを読み込みます
template_svg = File.open(TEMPLATE_PATH) do |f|
  # もっときれいなSVGを生成するために、ホワイトスペースを無視するように
  Nokogiri::XML(f) { |config| config.noblanks }
end

テンプレートが3つのグループ(#Notes, #Guides, #Symbols)に分かれているXML(SVG)です。

<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 149-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
       "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200">
 <!--glyph: "uni100000.medium", point size: 100.000000, font version: "Version 16.0d18e1", template writer version: "8"-->
 <g id="Notes">
  (中略)
 </g>
 <g id="Guides">
  (中略)
 </g>
 <g id="Symbols">
  (中略)
 </g>
</svg>

#Symbolsグループにシンボルが以下のように入っています

 <g id="Symbols">
  <g id="Black-L" transform="matrix(1 0 0 1 2854.05 1556)">
   <path d="(中略)"/>
  </g>
  <g id="Heavy-L" transform="matrix(1 0 0 1 2558.39 1556)">
   <path d="(中略)"/>
  </g>
  <g id="Bold-L" transform="matrix(1 0 0 1 2262.88 1556)">

必須の#Regular-M以外のシンボルは用意しないので、消しておく必要があります。

TEMPLATE_ICON_SIZES = ["S", "M", "L"]
TEMPLATE_ICON_WEIGHTS = ["Black", "Heavy", "Bold", "Semibold", "Medium", "Regular", "Light", "Thin", "Ultralight"]

# "Regular-M"だけを入れるので、それ以外の図形を消します
TEMPLATE_ICON_SIZES.each do |size|
  TEMPLATE_ICON_WEIGHTS.each do |weight|
    id = "#{weight}-#{size}"
    next if id == "Regular-M" # 必須な図形だけを残します
    template_svg.at_css("##{id}").remove
  end
end

テンプレートの冒頭の#Notesグループが主にベクター画像編集ソフトで見るためにあるテキストです。

 <g id="Notes">
  <rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
  <line id="" style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
  <text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
  <text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
  (中略)
  <text id="template-version" style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.2.0</text>
  (中略)
 </g>

#Notesという名前だから消しても問題ないと最初は思いましたが、まるまる消してはいけません。実は公式ドキュメントをちゃんと読むと書いてありますが、#Notesの中に#template-versionという大事なテキストノードがあります。#template-versionノードを消してしまうと、シンボルSVG内の左右のマージンの位置やその中の図形の水平位置が無視されてしまいます。#artboardを消さないのも推奨されています。 余計なノードを消したいなら、#Notesの子ノードの中でidが空文字列な場合や存在しないノードだけが良いかと思います。

#Notesグループのすぐ下に大事な#Guidesグループがあります。

 <g id="Guides">
  (中略)
  <line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
  <line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
  (中略)
  <line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
  <line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
  (中略)
  <line id="left-margin" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1391.3" x2="1391.3" y1="1030.79" y2="1150.12"/>
  <line id="right-margin" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1508.39" x2="1508.39" y1="1030.79" y2="1150.12"/>
 </g>

Regular-Mシンボルだけを用意するので、そのシンボルの#Baseline-M#Capline-Mに対する垂直位置、#left-margin#right-marginに対する水平位置、が大事になります。それぞれのグループの位置を取得しておきます。

因みにシンボルが文字の横に置かれるように設計されているため、capline(キャップライン)もbaseline(ベースライン)もフォントに関する用語です。上記のテンプレートの画像を見ると、左側に参照用にAがあるのはそのためです。

def get_guide_value(template_svg, axis, xml_id)
  guide_node = template_svg.at_css("##{xml_id}")
  raise "invalid axis" unless %i{x y}.include?(axis)
  val1 = guide_node["#{axis}1"]
  val2 = guide_node["#{axis}2"]
  if val1 == nil || val1 != val2
    raise "invalid #{xml_id} guide"
  end
  val1.to_f
end

# #left-marginノードの"x1"の値("x2"と同じ値のはず)を取得
original_left_margin = get_guide_value(template_svg, :x, "left-margin")
# #right-marginノードの"x1"の値("x2"と同じ値のはず)を取得
original_right_margin = get_guide_value(template_svg, :x, "right-margin")
# #Baseline-Mノードの"y1"の値("y2"と同じ値のはず)を取得
baseline_y = get_guide_value(template_svg, :y, "Baseline-M")
# #Capline-Mノードの"y1"の値("y2"と同じ値のはず)を取得
capline_y = get_guide_value(template_svg, :y, "Capline-M")

SVGアイコンを読み込んで期待しているサイズなのか確認しておきます。

# アイコンのSVGを読み込みます。
icon_svg = File.open(SOURCE_SVG_PATH) do |f|
  # もっときれいなSVGを生成するために、ホワイトスペースを無視するように
  Nokogiri::XML(f) { |config| config.noblanks }
end

# デザイナーに用意されていたSVGはサイズが64x64固定でしたので、後の計算はそれを元に書かれています。
# 期待しているサイズでなければエラーで終了します。
# SVGのwidth/heightは数字だけではなく、パーセントとかも使えるので、もっと幅広いSVGに対応する場合、もっと複雑になります。
if icon_svg.root["width"] != ICON_WIDTH.to_s ||
  icon_svg.root["height"] != ICON_HEIGHT.to_s ||
  icon_svg.root["viewBox"] != "0 0 #{ICON_WIDTH} #{ICON_HEIGHT}"
  raise "expected icon size of #{icon.source_svg_path} to be (#{ICON_WIDTH}, #{ICON_HEIGHT})"
end

用意されたアイコンのサイズをAppleのテンプレートのサイズに合わせる必要があります。

SF Symbolsアプリからエキスポートされるテンプレートは選ばれたシンボルによって左右のマージンの位置が変わりますが、#Baseline-M#Capline-Mが固定なので、サイズを#Baseline-M#Capline-Mの間隔に合わせます。

scale = ((baseline_y - capline_y).abs / ICON_HEIGHT) * ADDITIONAL_SCALING
horizontal_center = (original_left_margin + original_right_margin) / 2

scaled_width = ICON_WIDTH * scale
scaled_height = ICON_HEIGHT * scale

# テンプレートのマージンをそのまま使う場合、出来上がったシンボルの幅が選んだテンプレートによって変わります。
# テンプレートを気にしたくないので、計算したシンボルのサイズを元に左右のマージンの位置を調整します。
horizontal_margin_to_center = scaled_width / 2 + MARGIN_LINE_WIDTH + ADDITIONAL_HORIZONTAL_MARGIN
adjusted_left_margin = horizontal_center - horizontal_margin_to_center
adjusted_right_margin = horizontal_center + horizontal_margin_to_center
left_margin_node = template_svg.at_css("#left-margin")
left_margin_node["x1"] = adjusted_left_margin.to_s
left_margin_node["x2"] = adjusted_left_margin.to_s
right_margin_node = template_svg.at_css("#right-margin")
right_margin_node["x1"] = adjusted_right_margin.to_s
right_margin_node["x2"] = adjusted_right_margin.to_s

全ての計算が終わったので、調整したテンプレートに読み込んだアイコンを正しい位置とサイズで入れてファイルを出力します。

# 元のテンプレートをコピーする。
# 今回シンボル1つしか生成しないが、一気にいくつものシンボルを生成する場合コピーを編集した方が安全です。
symbol_svg = template_svg.dup

# ついに肝心の#Regular-Mノードに手をつける時が来ました。
regular_m_node = symbol_svg.at_css("#Regular-M")

# 図形がガイドの中央になるよう移動させます。
translation_x = horizontal_center - scaled_width / 2
translation_y = (baseline_y + capline_y) / 2 - scaled_height / 2
# 上記に計算された移動や倍率を元に変換行列を用意します。
transform_matrix = [
  scale, 0,
  0, scale,
  translation_x, translation_y,
].map {|x| "%f" % x } # 文字列に変換
regular_m_node["transform"] = "matrix(#{transform_matrix.join(" ")})"

# #Regular-Mノードの中身を用意されていたアイコンに置き換えます。
regular_m_node.children = icon_svg.root.children.dup

# 最後に生成したシンボルを書き出します。
File.open(DESTINATION_SVG_PATH, "w") do |f|
  symbol_svg.write_to(f)
end

実装中に起きた問題

もちろん上記のコードが出来上がるまでは、スクリプトを実行して、ベクター画像編集ソフトやXcodeで確認して、スクリプトの修正する、の繰り返しでした。実装が進んでいたら、Xcodeでの確認はAsset Catalog内だけではなく、普通のXcodeプロジェクトに取り込んで使ってみるのも含んでいました。

問題の1つは、色んなSVGからシンボルを生成したら、一部の生成されたシンボルファイルに元の図形の横に別の図形がありました。よく見たら、用意されていたSVGの一部に(0, 0, 64, 64)枠の外に図形が入っていました。viewport0 0 64 64だったのでその外が見えていなくて誰も気づいていませんでした。デザイナーにその枠外図形を消してもらいました。

もう1つは実装の説明でも書きましたが、#Notesノードが要らないだろうと思って消してしまったが間違いでした。それで図形を左右マージンの間にどこに置いても(中央寄りでも左寄りでも右寄りでも)、左右マージンをもっと幅広くしても、生成されたシンボルが変わりませんでした。#Notesに入っている#template-versionが残るように修正することで期待通りに動くようになりました。

幅固定のよしあし

上記のスクリプトで生成されたシンボルはAsset Catalogを入れて問題なく使えます。ただし、幅をすべてのシンボル共通にしましたが、それに良し悪しがあります。枠の幅が共通でも、その中の図形自体の幅がそれぞれなので、左右の余白がバラバラです。iOSのカスタムシンボルはシンボルごとに幅を変えられますし、実際Appleの用意したSF Symbolsの幅が様々です。そうした主な理由が2つあります。

  • いくつかのシンボルを同じ画面内で配置する場合、幅が共通だった方が配置しやすいと思います。
  • 図形の形を解析して本当の幅を計算するのとなると複雑になりますし、もっと細かく確認する必要があるからです。また、その道を歩み始めると、本当のサイズと目に見えるサイズ(光学的サイズ)がちょっと違ったりしますし、シンボルによって微調整したくなったりします。

どうするのかユースケースによると思いますが、シンプルでいくことにしました。

因みに幅を共通にしましたが、なぜかコードでシンボルから生成された画像はシンボルによって幅に0.5~1.0 ptの差があります。iOS 13よりiOS 14の方がましのようだけど、iOS 14でも起きています。まぁpixel perfectを求めるなら、ベクター画像ではなく、ピクセル画像を用意することですね。

もう少し便利に

上記のスクリプトは分かりやすさのためSVG 1つだけを生成するものです。社内で用意したスクリプトはそれより少し強力です。

元はファイル1つではなく、特定なディレクトリーのすべてのSVGファイルを処理していきますし、生成しているのはSVGだけではなく、Asset Catalog(xcassetsディレクトリー)を丸々生成していますし、シンボルのリストのSwift enumのコードも生成しています。

Asset Catalogは形式がとても簡単です。Asset Catalogはフォルダーの「Provides Namespace」にチェックを入れるとその中身がネームスペースに入るので便利です。

以下のような enum のコードを生成しています。

public enum CookpadSymbol: String, CaseIterable {
    public enum Package {
        // Asset Catalogにカスタムシンボルを入れたネームスペース
        public static let namespace = "cookpad"
        public static let version = "2.0.0"
    }

    case access
    case clip
    case clipAdd = "clip_add"
    case clipAdded = "clip_added"
    case clipRemove = "clip_remove"
    case lock

    // Asset Catalog内の名前
    public var imageName: String { "\(Package.namespace)/\(rawValue)" }
}

カスタムシンボルの使い方

シンボルを生成したのは良いが、Asset Catalogに入れたらアプリ内でどうやって使えるのでしょうか。

UIImageView

カスタムシンボルを表示するには基本的にUIImageViewを使います。

let symbolIconView = UIImageView()
// CookpadSymbol.imageNameが上記にenumに定義されたAsset Catalog内の名前です。
symbolIconView.image = UIImage(named: CookpadSymbol.lock.imageName, in: .main)
symbolIconView.tintColor = .red
symbolIconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 10)

preferredSymbolConfigurationでサイズが決まります。ただし、UIImage.SymbolConfiguration(pointSize: 10)を使うとDynamic Type設定の変更が反映されません。Dynamic Type対応が必要な場合、UIImage.SymbolConfiguration(textStyle:)を使うか、Dynamic Typeの設定によってサイズを変えるフォントをUIImage.SymbolConfiguration(font:)に渡すかです。

let symbolConfiguration = UIImage.SymbolConfiguration(font: UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 10)))

UILabelと違ってadjustsFontForContentSizeCategoryのように別途に設定する必要あるプロパティがありません。

NSAttributedString

UIImageViewの他に、NSAttributedStringに入れて、UILabelUITextViewでも表示できます。

let attributedText = NSMutableAttributedString()
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(named: CookpadSymbol.lock.imageName, in: .main)
attributedText.append(NSAttributedString(attachment: imageAttachment))
attributedText.append(NSAttributedString(string: " 非公開"))
label.attributedText = attributedText

UILabel.attributedTextの懸念点はUILabel.textと違って、adjustsFontForContentSizeCategorytrueにしても、Dynamic Typeの設定変更がすぐ反映されないところです。

UIImage

カスタムシンボルをUIImageとして扱いたい場合、サイズをUIImage.SymbolConfigurationで明記して、UIImage(named:in:with:)に渡すか、UIImage.applyingSymbolConfiguration()(またはUIImage.withConfiguration())に渡すかです。

let configuration = UIImage.SymbolConfiguration(pointSize: 12)
let symbolImage = UIImage(named: CookpadSymbol.lock.imageName, in: .main, with: configuration)

色の指定はUIImage.withTintColor()を使います。

let redSymbolImage = symbolImage?.withTintColor(.red)

tintColorを指定しても、シンボルから作成したUIImageUIImageViewに入れるとき、UIImageViewtintColorが優先されるので、どうしても画像自体の色を優先させたい場合は以下のようにできます。

let reallyRedSymbolImage = symbolImage?.withTintColor(.red, renderingMode: .alwaysOriginal)

SwiftUI

SwiftUIでも簡単に使えます。

Image(CookpadSymbol.arrowRight.imageName, bundle: .main)
    .font(.caption)
    .foregroundColor(.green)

ヘルパー

生成されたenumにいくつかのヘルパーを用意するとさらに使いやすくなります。ここでBundleは固定で.mainを渡していますが、自分のユースケースに合わせてください。

// UIKit
extension CookpadSymbol {
    public func makeImage(with configuration: UIImage.Configuration? = nil) -> UIImage? {
        UIImage(named: imageName, in: .main, with: configuration)
    }

    public func makeAttributedString(
        with configuration: UIImage.Configuration? = nil,
        tintColor: UIColor? = nil
    ) -> NSAttributedString {
        var image = makeImage(with: configuration)
        if let tintColor = tintColor {
            image = image?.withTintColor(tintColor)
        }
        let imageAttachment = NSTextAttachment()
        imageAttachment.image = image
        return NSAttributedString(attachment: imageAttachment)
    }
}

// SwiftUI
extension Image {
    public init(_ symbol: CookpadSymbol) {
        self.init(symbol.imageName, bundle: .main)
    }
}

SF Symbols

余談ですが、上記のコードがカスタムシンボルのためですが、UIImage(named:in:)UIImage(systemName:)に変えると、SF Symbolsで使えます。カスタムシンボルがカスタマイズされたSF Symbolsなので、使い方が近いのは自然かと思います。

Interface Builder

Interface Builder(Xcode内インターフェースエディター)内でImage ViewのプロパティでAsset Catalogのように簡単にカスタムシンボルを選ぶことができますし、コードのようにサイズを簡単に選べます(ただしUIFontMetricsを通ったフォントは渡せません)。

f:id:vincentisambart:20201228113122p:plain

やってみてどうだった

カスタムシンボルの作り方の公式ガイドに自動化に関する話はありませんでしたが、SVGはベクター画像編集ソフトでもテキストエディターでも確認できるファイル形式ですし、デザイナーが用意してくれていたSVGがきれいでシンプルでしたので、カスタムシンボルの生成は割りとスムーズにできたと思います。今後もっと幅広く使えるカスタムシンボルを扱うツールが増えたらさらに楽になるかと思います。

カスタムシンボルを使い始めてから時間がまだあまり経っていないので、今後気づく懸念点は出てくるかもれませんが、いまのところ簡単に色んな場面で使えて便利です。

SF SymbolsもカスタムシンボルもiOS 13以上を必要としているのは一番の懸念点だと思いますが、時間が解決してくれます。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/