Skip to main contentSkip to docs navigation

iOS Applications

Where the build writes the fonts, images and icons of an iOS app, and how to add them to an Xcode project for UIKit and SwiftUI.

Introduction

The build writes the files of an iOS app to dist/ios/<app>/<brand>/. The names are in snake_case, and an @2x or @3x indicator stays at the end of a name, which is how UIKit finds the variant of a screen. iOS gets the TTF and OTF fonts with their licenses, every image format but WebP, and the SVG, PDF and PNG files of icons/.

TEXT
dist/ios/<app>/<brand>/
├── fonts/
├── icons/
└── images/

The build copies, renames and sorts the files. It writes an asset catalog only with the --asset-catalog option, does not turn an SVG into a PDF and does not resize an image: an image has the variants the designer exported. This page says what you do with the files in Xcode.

Installation

An app gets the files in one of three ways: from the archive of a release, from a submodule that the app builds, or from a build of a clone. The examples of this page use demo and chassis, the iOS app and a brand of the committed configuration; use the names of your chassis.build block in their place.

Release archive

An archive needs no Node.js and no Git LFS on the machine that builds the app. From version 0.2.0 on, each GitHub release has one archive per job attached, named chassis-assets-<platform>-<app>-<brand>-<version>.zip. The archive of an iOS job holds the content of dist/ios/<app>/<brand>/ at its top level, so it unpacks to fonts/, icons/ and images/.

Submodule

A submodule pins the assets to a commit of the app's repository, and the app builds them. Install Git LFS first, since the fonts and the raster images are Git LFS files, then add the repository and check out the tag of a release:

Shell
git lfs install
git submodule add https://github.com/chassis-ui/assets.git assets
git -C assets checkout v0.3.1

If you build the assets from your own fork, use the URL of your repository in place of this one.

Build from a clone

The build needs Node.js 22.12 or later and pnpm, and nothing installed: it runs without pnpm install. Run it from the root of the clone or of the submodule:

Shell
pnpm assets --platform ios                              # Every iOS job
pnpm assets --platform ios --app demo --brand chassis   # One job

A filtered build keeps the rest of dist/; add --clean to remove the folder of the selected jobs first. The build system page has every option.

A clone made without Git LFS holds pointer files in place of the fonts and the raster images. The build fails and lists them; run git lfs pull.

Output layout

Each job has one folder, dist/ios/<app>/<brand>/, with a folder per type and the subfolders of source/ kept. The build writes this for the demo app and the chassis brand:

TEXT
dist/ios/demo/chassis/
├── fonts/
│   ├── code_license.txt
│   ├── code_normal.ttf
│   ├── display_normal.ttf
│   ├── text_license.txt
│   ├── text_normal.ttf
│   ├── text_strong_italic.ttf
│   └── ...
├── icons/
│   ├── icons/
│   │   └── chassis_icons.svg
│   └── svgs/
│       ├── arrow_right_outline.svg
│       ├── arrow_right_solid.svg
│       └── ...
└── images/
    ├── chassis_logo.svg
    ├── home/
    └── logo/

The type of a file decides whether iOS gets it:

TypeFormats iOS getsFormats iOS does not get
fonts/.ttf, .otf, and the .txt licensesWOFF, WOFF2 and the font stylesheets
images/Every format but WebP.webp
icons/.svg, .pdf, .pngThe icon font and its stylesheets
Any other folderEvery fileNone

The build renames files, not folders: HeroBanner@2x.png becomes hero_banner@2x.png, and a dot inside a name becomes an underscore.

Fonts

The font files are named by role and weight, not by family: text_normal.ttf, display_strong_italic.ttf, code_normal.ttf. Which family a role holds is the choice of the brand, so the app refers to the file of a role and reads the name of the font from it. The roles are those of Chassis Tokens, and the fonts page lists what each platform gets.

Add the fonts

Xcode has to copy the fonts into the app bundle, and iOS has to be told about them. Add the files of dist/ios/demo/chassis/fonts/ to the project with File → Add Files to…, with the app target checked, then list each file under UIAppFonts (Fonts provided by application) in Info.plist, by its file name with the extension. A font that the bundle holds and the list does not name does not load:

XML
<key>UIAppFonts</key>
<array>
    <string>text_normal.ttf</string>
    <string>text_strong.ttf</string>
    <string>display_normal.ttf</string>
    <string>display_strong.ttf</string>
    <string>code_normal.ttf</string>
</array>

Each role has a license, text_license.txt, display_license.txt and code_license.txt. Keep it with the fonts when you copy them, and add it to the app where the license of the family asks for it.

PostScript names

UIKit and SwiftUI load a custom font by its PostScript name, which is inside the file and is not the file name. On a Mac, read it from a file of the output:

Shell
mdimport -t -d2 dist/ios/demo/chassis/fonts/text_normal.ttf 2>&1 | grep -A 2 name_postscript

The command reads the file itself, so it also answers for a file that was just built, which Spotlight has not indexed yet.

The name changes when a brand changes the family of a role. To keep the app free of family names, read the name from the bundled file at run time. Add this function to the app:

SWIFT
import CoreText
import Foundation

/// The PostScript name of a font file of the bundle, such as "text_normal.ttf".
func postScriptName(ofFontFile file: String) -> String? {
    guard
        let url = Bundle.main.url(forResource: file, withExtension: nil),
        let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL)
            as? [CTFontDescriptor],
        let descriptor = descriptors.first
    else { return nil }

    return CTFontDescriptorCopyAttribute(descriptor, kCTFontNameAttribute) as? String
}

Use the fonts

With the PostScript name of a role, the font loads as any custom font does. Use the function of the previous section in UIKit:

SWIFT
import UIKit

let label = UILabel()

if let name = postScriptName(ofFontFile: "text_normal.ttf"),
   let font = UIFont(name: name, size: 17) {
    label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: font)
    label.adjustsFontForContentSizeCategory = true
}

Use the same name in SwiftUI:

SWIFT
import SwiftUI

struct Headline: View {
    private let display = postScriptName(ofFontFile: "display_strong.ttf") ?? ""

    var body: some View {
        Text("Chassis")
            .font(.custom(display, size: 28, relativeTo: .title))
    }
}

Images

The build writes an image to images/ with every variant the designer exported, each under the subfolder it has in source/. The images page has the rules of the type. The build writes these files, among others, for the chassis brand:

TEXT
dist/ios/demo/chassis/images/
├── chassis_logo.svg
├── chassis_logo_shadow.png
├── chassis_logo_shadow@2x.png
├── home/
│   ├── comp_gallery_dark.png
│   ├── comp_gallery_dark@2x.png
│   ├── comp_gallery_light.png
│   ├── comp_gallery_light@2x.png
│   └── ...
└── logo/
    ├── chassis_icon_brand.png
    ├── chassis_icon_brand.svg
    ├── chassis_icon_brand@2x.png
    ├── chassis_icon_brand@3x.png
    └── ...

The iOS output has no WebP file. An image that exists only as WebP in source/ is missing from dist/ios/; export it as PNG or JPEG too.

Asset catalog

The default build does not write Assets.xcassets or a Contents.json. With the --asset-catalog option it does, and needs nothing installed for it:

Shell
pnpm assets --platform ios --asset-catalog

The images move from images/ into Assets.xcassets, the files of one name into one image set, each variant in the slot of its indicator:

TEXT
dist/ios/demo/chassis/
├── Assets.xcassets/
│   ├── Contents.json
│   ├── chassis_logo.imageset/
│   │   ├── Contents.json
│   │   └── chassis_logo.svg
│   ├── chassis_logo_shadow.imageset/
│   │   ├── Contents.json
│   │   ├── chassis_logo_shadow.png
│   │   └── chassis_logo_shadow@2x.png
│   ├── home/
│   └── logo/
├── fonts/
├── icons/
└── images/
    └── logo/
        ├── chassis_icon_brand.svg
        └── ...

Add Assets.xcassets to the project with File → Add Files to…, with the app target checked. An SVG without a PNG of its name is an image set of one file with Preserve Vector Data set. A subfolder is a folder of the catalog with Provides Namespace set, so an image of logo/ is named logo/chassis_icon_brand.

A file without a place in an image set stays in images/, and the build warns about the number of them. In the committed configuration these are the SVG logos of images/logo/, which have PNG variants of the same name. The build does not fill the Dark row of an image set and does not put the icons into the catalog. The release archives are built without the option.

Without the option, you make the image sets in Xcode. Drag the variants of an image into the asset catalog together, and Xcode makes one image set named after the file and puts each variant in the slot of its indicator: chassis_logo_shadow.png in 1x, chassis_logo_shadow@2x.png in 2x. A slot stays empty when the designer exported no variant for it, and iOS scales the nearest one.

An SVG goes into an image set of its own, with Scales set to Single Scale and Preserve Vector Data checked. A logo that the output has as SVG and as PNG, such as logo/chassis_icon_brand.svg beside logo/chassis_icon_brand.png, needs one of the two: both would make an image set of the same name.

A name is unique within its folder only: the output has images/chassis_logo_black.svg and images/logo/chassis_logo_black.svg. Keep the subfolders as folders of the catalog and check Provides Namespace on them, and the second one is logo/chassis_logo_black.

Use the images

The name of an image set is the output name without the indicator and the extension. In a catalog of --asset-catalog, an image of a subfolder has the folder before its name, as in logo/chassis_icon_brand. Use the name in UIKit:

SWIFT
import UIKit

let imageView = UIImageView(image: UIImage(named: "chassis_logo_shadow"))
imageView.contentMode = .scaleAspectFit

Use the same name in SwiftUI:

SWIFT
import SwiftUI

struct Logo: View {
    var body: some View {
        Image("chassis_logo_shadow")
            .resizable()
            .scaledToFit()
    }
}

Dark mode

The build has no rule for appearances: a designer who exports an image for light and for dark names the two files, as home/comp_gallery_light.png and home/comp_gallery_dark.png of the tree above. To let iOS choose, make one image set, set Appearances to Any, Dark in the attributes inspector, and put the *_light variants in the Any Appearance row and the *_dark variants in the Dark row. UIImage(named:) and Image then follow the appearance of the screen with one name.

Icons

The build gives iOS the SVG, PDF and PNG files of icons/. In the committed configuration these are the SVG files of Chassis Icons under icons/svgs/, one per icon and style, such as icons/svgs/arrow_right_solid.svg. The icons page says where they come from.

icons/icons/chassis_icons.svg is a sprite of <symbol> elements, kept because it is an SVG file. An asset catalog takes it as one small image with every icon drawn over the others; use the files of icons/svgs/.

Add the icons

The build does not turn an SVG into a PDF, and Xcode 12 or later needs none: an asset catalog takes an SVG as it is. Drag the icons the app uses from icons/svgs/ into a folder of the catalog, which makes one image set per file, then select them and set these attributes:

AttributeValueWhy
ScalesSingle ScaleAn SVG has no variants
Preserve Vector DataCheckedThe icon stays sharp at any size
Render AsTemplate ImageThe icons have no color of their own (fill="currentcolor"), so the tint of the view colors them

A PDF icon takes the same attributes. A PNG icon of a brand goes into the slots of its indicators, as an image does.

Use the icons

An icon loads by the name of its image set, and takes the tint color of the view. Use it in UIKit:

SWIFT
import UIKit

let button = UIButton(type: .system)
button.setImage(UIImage(named: "arrow_right_solid"), for: .normal)
button.tintColor = .label

Use it in SwiftUI:

SWIFT
import SwiftUI

struct NextButton: View {
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Image("arrow_right_solid")
                .resizable()
                .frame(width: 24, height: 24)
        }
        .foregroundStyle(.tint)
    }
}

Other assets

The build copies any other folder of the app whole, in every format, with the file names in snake_case. The demo app of the committed configuration has no such folder. As an example, a file source/default/demo/data/Onboarding.json would be written to dist/ios/demo/chassis/data/onboarding.json. The other assets page describes the type.

Add such a folder to the target as a folder reference, so that the bundle keeps the folder, and read a file by its output name:

SWIFT
import Foundation

let url = Bundle.main.url(
    forResource: "onboarding",
    withExtension: "json",
    subdirectory: "data"
)

Brand switching

Every brand of an app has the same folders and the same file names, so the code of the app does not change with the brand: what changes is the folder the files come from. In the committed configuration, dist/ios/demo/sinefil/ differs from dist/ios/demo/chassis/ in the fonts and in the logos under images/logo/ that the sinefil brand overrides.

Brand per build

An app that ships once per brand copies the folder of one brand into the project before it builds. Add a script such as scripts/sync-assets.sh to the app's repository, with the assets in the submodule assets/:

Shell
#!/bin/bash
set -euo pipefail

BRAND="${1:-chassis}"
APP="${2:-demo}"

(cd assets && pnpm assets --platform ios --app "$APP" --brand "$BRAND" --clean)
rsync -a --delete "assets/dist/ios/$APP/$BRAND/" Resources/Chassis/

The fonts and a folder reference follow the copy as they are. An image set does not: it holds the files of the brand it was filled from. Keep one asset catalog per brand, with the same image set names, and add each to the target of its brand.

While you work on the assets and the app together, let the build follow the source: pnpm assets --watch --platform ios --app demo builds the iOS jobs of the app again on every change under source/, and the copy is then the rsync line of the script alone.

Build phase

A Run Script build phase can run the copy on each build of a target. Put it before Copy Bundle Resources, and pass it the brand from a build setting of the target, here the user-defined setting CHASSIS_BRAND:

Shell
"$SRCROOT/scripts/sync-assets.sh" "$CHASSIS_BRAND"

A target runs its scripts in a sandbox when User Script Sandboxing is Yes in its build settings, as it is in a new project, and the phase then fails with Operation not permitted: the script writes to Resources/Chassis/ and to the submodule. Set it to No for the target.

Xcode runs the phase with its own PATH. When it does not find pnpm, replace the build line of the script with the copy alone and build the assets before opening Xcode.

Brand at run time

One binary that holds several brands needs the files of each under a name of its own. Give each brand a folder in the asset catalog with Provides Namespace checked, fill it from dist/ios/<app>/<brand>/, and put the brand before the name:

SWIFT
import UIKit

func brandImage(_ name: String, brand: String) -> UIImage? {
    UIImage(named: "\(brand)/\(name)")
}

let logo = brandImage("chassis_logo_shadow", brand: "sinefil")

Continuous integration

A CI job builds the assets of one job, copies them and builds the app. The build needs Node.js and the Git LFS files of the submodule, and no pnpm install.

GitHub Actions

A matrix builds the app once per brand. Add a workflow such as this one to the app's repository, with the brands of your configuration and the project and scheme of your app:

YAML
name: iOS

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: macos-latest
    strategy:
      matrix:
        brand: [chassis, sinefil]

    steps:
      - name: Checkout
        uses: actions/checkout@v7
        with:
          submodules: true

      - name: Pull the Git LFS files of the assets
        run: git -C assets lfs pull

      - name: Setup Node.js
        uses: actions/setup-node@v7
        with:
          node-version-file: assets/.nvmrc

      - name: Build and copy the assets
        run: |
          corepack enable
          ./scripts/sync-assets.sh ${{ matrix.brand }} demo

      - name: Build the app
        run: |
          xcodebuild -project App.xcodeproj -scheme App \
            -destination 'generic/platform=iOS' \
            CODE_SIGNING_ALLOWED=NO build

CODE_SIGNING_ALLOWED=NO builds the app without a certificate, which is enough to check that it builds. Without it, a runner that has no development team fails with Signing for "App" requires a development team. The script and the xcodebuild line of this job were run on a Mac; the workflow itself was not run on GitHub Actions.

A job that takes the archive of a release instead needs neither Node.js nor the submodule: it downloads chassis-assets-ios-<app>-<brand>-<version>.zip and unpacks it into the project.

Fastlane

A lane takes the brand as an option and runs the same script before the build. Add it to the Fastfile; fastlane runs sh from the fastlane/ folder, hence the ../:

RUBY
platform :ios do
  desc "Build the app with the assets of one brand"
  lane :build_brand do |options|
    brand = options[:brand] || "chassis"

    sh("../scripts/sync-assets.sh", brand, "demo")
    build_app(scheme: "App-#{brand}")
  end
end

Run the lane with fastlane ios build_brand brand:sinefil. The lane was not run; the script it calls was.

Best practices

These practices keep the Xcode project in step with dist/ios/, so a new export or another brand needs no change in code.

  • Refer to fonts by role: list text_normal.ttf in UIAppFonts and read the PostScript name from the file, so a brand that changes the family of a role changes no code.
  • Fill every slot the output has: put @2x and @3x files in their slots of an image set. The build does not resize, so a missing variant is fixed by an export into source/, not in Xcode.
  • Take icons from icons/svgs/: add them as single-scale SVG image sets with Preserve Vector Data and Template Image.
  • Keep the license with the font: copy <role>_license.txt of fonts/ along with the font files.
  • Build one job in CI: pnpm assets --platform ios --app <app> --brand <brand> writes only dist/ios/<app>/<brand>/.

Avoid the habits that undo this:

  • Do not rename the files in the project: the names of dist/ios/ are the contract. Rename the file in source/ and build again.
  • Do not commit dist/: commit the submodule pointer, or pin the version of the archive, and build or unpack the files.
  • Do not edit the copied files: a change to a file under Resources/Chassis/ is lost at the next copy. Change source/<brand>/<app>/ instead.

Troubleshooting

Each entry gives the message or the symptom, then the cause and the fix.

Build fails on pointers

The build prints <count> source file(s) are Git LFS pointers, not files: <files>. Run `git lfs pull`, or pass --allow-lfs-pointers to copy the pointers. The clone or the submodule was made without Git LFS, so the fonts and the raster images are pointers of a few lines. Install Git LFS, run git lfs pull in the repository of the assets, and build again.

Unknown brand or app

The build prints Unknown brand "<name>". Configured: <brands>, or the same for an app or a platform. The value of the filter is not in the chassis.build block of package.json. Use a configured name, or add the brand to chassis.build.brands.

Filters select no job

The build prints The filters select no job. Check --brand, --app and --platform together. The app is not built for iOS: in the committed configuration --platform ios --app docs selects nothing, since docs is a web app. Name an app that lists ios in chassis.build.apps.

Font does not appear

UIFont(name:size:) returns nil, or the text shows in the system font. Either the file is not in the bundle, or its file name is missing from UIAppFonts, or the code passes the file name where the PostScript name belongs. Check the target membership of the file, list it in Info.plist as text_normal.ttf, with the extension, and read the PostScript name as PostScript names says.

Image is missing

UIImage(named:) returns nil for an image that is in source/. The source file is a WebP, which the iOS output leaves out, or the image set has another name than the output file, or it sits in a folder with Provides Namespace. Find the file with find dist/ios/<app>/<brand>/images -name '<name>*', and use its name without the indicator and the extension.

Image is blurry

An image looks soft on a 3x screen. The output has no @3x file for it, because the designer exported none and the build does not resize. Export the variant into source/<brand>/<app>/images/, build again, and put the file in the 3x slot.

Icon keeps one color

An icon stays black whatever the tint of the view. Its image set renders as an original image. Set Render As to Template Image in the attributes inspector.

Next steps

Continue with one of these pages: