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/.
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:
git lfs install
git submodule add https://github.com/chassis-ui/assets.git assets
git -C assets checkout v0.3.1If 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:
pnpm assets --platform ios # Every iOS job
pnpm assets --platform ios --app demo --brand chassis # One jobA 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:
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:
| Type | Formats iOS gets | Formats iOS does not get |
|---|---|---|
fonts/ | .ttf, .otf, and the .txt licenses | WOFF, WOFF2 and the font stylesheets |
images/ | Every format but WebP | .webp |
icons/ | .svg, .pdf, .png | The icon font and its stylesheets |
| Any other folder | Every file | None |
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:
<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:
mdimport -t -d2 dist/ios/demo/chassis/fonts/text_normal.ttf 2>&1 | grep -A 2 name_postscriptThe 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:
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:
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:
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:
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:
pnpm assets --platform ios --asset-catalogThe images move from images/ into Assets.xcassets, the files of one name into one image set, each variant in the slot of its indicator:
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:
import UIKit
let imageView = UIImageView(image: UIImage(named: "chassis_logo_shadow"))
imageView.contentMode = .scaleAspectFitUse the same name in SwiftUI:
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:
| Attribute | Value | Why |
|---|---|---|
| Scales | Single Scale | An SVG has no variants |
| Preserve Vector Data | Checked | The icon stays sharp at any size |
| Render As | Template Image | The 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:
import UIKit
let button = UIButton(type: .system)
button.setImage(UIImage(named: "arrow_right_solid"), for: .normal)
button.tintColor = .labelUse it in SwiftUI:
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:
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/:
#!/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:
"$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:
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:
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 buildCODE_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 ../:
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
endRun 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.ttfinUIAppFontsand 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
@2xand@3xfiles in their slots of an image set. The build does not resize, so a missing variant is fixed by an export intosource/, 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.txtoffonts/along with the font files. - Build one job in CI:
pnpm assets --platform ios --app <app> --brand <brand>writes onlydist/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 insource/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. Changesource/<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:
- Fonts: the roles, the formats and the licenses of the font files.
- Images: the variants and the naming of the images.
- Icons: the files of Chassis Icons in
icons/. - Other assets: any other folder of an app.
- Build system: the commands, the options and the configuration.
- Android applications and web applications: the same assets on the other platforms.