Creating a Google Fonts Browser in SwiftUI for MacOS
Learn how fonts work at a lower level and see how it all ties in with SwiftUI

Google Fonts is the go-to site for free fonts to use when designing user interfaces. This tutorial will show how you can write a simple tool to preview these fonts without having to register each font with the system.
The application contains a split view that has the list of fonts in the left hand panel. The right hand panel will display a preview of the font’s style options.

Project Setup
- Create a new Mac SwiftUI Project named
GoogleFontPrevew
- Enable Outgoing Connections (Client) in the App Sandbox
- Get a Google API key from the Google Developer Console.
Google Font Models
The Google Fonts API lists all the fonts available at Google Fonts. The fonts are retrieved using the Google Fonts API. The response has all styles and a URL to access the font file for the styles. The metadata, like creator and description, are missing. However, we have enough to create a simple preview, which is the goal.
{
"kind": "webfonts#webfontList",
"items": [
{
"family": "ABeeZee",
"variants": [
"regular",
"italic"
],
"subsets": [
"latin",
"latin-ext"
],
"version": "v22",
"lastModified": "2022-09-22",
"files": {
"regular": "http://fonts.gstatic.com/s/abeezee/v22/esDR31xSG-6AGleN6tKukbcHCpE.ttf",
"italic": "http://fonts.gstatic.com/s/abeezee/v22/esDT31xSG-6AGleN2tCklZUCGpG-GQ.ttf"
},
"category": "sans-serif",
"kind": "webfonts#webfont"
}
]
}
Google Font Models
Create a GoogleFont.swift
file. This file will contain the data structures that we will use as the view models.
Create a 1:1 mapping of the top level of the API’s JSON response.
struct GoogleResponse: Decodable {
let kind: String
let items: [GoogleFont]
}
Create GoogleFont structure to represent a font entry. This will be what the ContentView
uses to display a list of fonts. It is predominately a 1:1 mapping of the font items in the response JSON. The files will get mapped to a sorted array from the dictionary that is in the JSON.
To be usable, the font needs to be Decodable
, Hashable
and Identifiable
. Since the family name is unique for each entry, it can be used as the object’s unique identifier.
struct GoogleFont: Hashable, Identifiable, Decodable {
var id : String { family }
let family: String
let files: [GoogleFontStyle]
let version: String
let category: String
}
The FontFile
is what will be used in the details display to preview the font family’s styles. The font’s style and url will be stored for easy reference. To be able to use the style in a list it needs to be Hashable
and Identifiable
. A UUID will be generated to uniquely identify the font file.
struct FontFile: Hashable, Identifiable {
let style: Style
let id = UUID()
let url: URL
}
Create a Style.swift
file. This is where the Style
enum will be defined. Google defines the weights as 100–900. italic
is appended to the value if the weight has an italic variant. However, for normal and normal-italic, Google just uses regular
and italic
respectively.
The enum has a helper function to get a friendly name out of the style. The friendly name will be used as the title of the group box that will wrap the style’s preview.
enum Style: String, Hashable {
case thin = "100"
case thinItalic = "100italic"
case extraLight = "200"
case extraLightItalic = "200italic"
case light = "300"
case lightItalic = "300italic"
case normal = "regular"
case normalItalic = "italic"
case medium = "500"
case mediumItalic = "500italic"
case semiBold = "600"
case semiBoldItalic = "600italic"
case bold = "700"
case boldItalic = "700italic"
case extraBold = "800"
case extraBoldItalic = "800italic"
case black = "900"
case blackItalic = "900italic"
var friendlyName: String {
switch self {
case .thin: return "Thin"
case .thinItalic: return "Thin Italic"
case .extraLight: return "Extra Light"
case .extraLightItalic: return "Extra Light Italic"
case .light: return "Light"
case .lightItalic: return "Light Italic"
case .normal: return "Normal"
case .normalItalic: return "Italic"
case .medium: return "Medium"
case .mediumItalic: return "Medium Italic"
case .semiBold: return "Semi Bold"
case .semiBoldItalic: return "Semi Bold Italic"
case .bold: return "Bold"
case .boldItalic: return "Bold Italic"
case .extraBold: return "Extra Bold"
case .extraBoldItalic: return "Extra Bold Italic"
case .black: return "Black"
case .blackItalic: return "Black Italic"
}
}
}
Now, with all this in place, custom decoding can be implemented in the GoogleFont
. Add Decodable's
required initalizer and coding keys. The nice thing about Xcode 14 is that it will autofill some of this out for you.
struct GoogleFont: Hashable, Decodable, Identifiable {
var id: String { family }
let family: String
let files: [FontFile]
let version: String
let category: String
enum CodingKeys: CodingKey {
case family
case files
case version
case category
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.family = try container.decode(String.self, forKey: .family)
let files = try container.decode([String: String].self, forKey: .files)
self.version = try container.decode(String.self, forKey: .version)
self.category = try container.decode(String.self, forKey: .category)
var keys = Array(files.keys)
// lets do a bit of work to get our files that we want.
keys.sort { item1, item2 in
func itemFixer(_ value: String) -> String {
if value == "regular" {
return "400"
} else if value == "italic" {
return "400Italic"
}
return value
}
let fixedItem1 = itemFixer(item1)
let fixedItem2 = itemFixer(item2)
return fixedItem1 < fixedItem2
}
self.files = keys.compactMap { key in
guard let style = Style(rawValue: key),
let location = files[key]?.replacingOccurrences(of: "http", with: "https")
else {
return nil
}
return FontFile(style: style, url: URL(string: location)!)
}
}
}
- Decode the
family
,version
,category
straight from the font container. - Decode the file map from the JSON into a temporary variable.
- Get the keys and sort them in order from lightest (100) to darkest (900). There is a small local function in the block that takes
regular
and maps it to400
and takesitalic
and maps it to400italic
. Doing this allows normal and italic to be sorted before medium (500) and after light (300). - After the keys are sorted use
compactMap
to map create the sortedFontFile
array. The use ofcompactMap
allows anil
entry to be ignored when creating the file array. Ifmap
was used, anil
entry would inserted into the array. - Replace http with https so the application doesn’t complain about using unsecure web calls.
The Google Font Service
Create a GoogleFontService.swift
file. This file will have a class that makes a network call to the Google Fonts API to retrieve the list of fonts that are displayed in the application.
class GoogleFontService {
let apiKey = "** YOUR KEY HERE **"
func syncFonts() async throws -> [GoogleFont] {
var components = URLComponents()
components.scheme = "https"
components.host = "www.googleapis.com"
components.path = "/webfonts/v1/webfonts"
components.queryItems = [
URLQueryItem(name: "key", value: apiKey),
URLQueryItem(name: "sort", value: "alpha")
]
let url = components.url!
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
let googleResponse = try JSONDecoder()
.decode(GoogleResponse.self, from: data)
return googleResponse.items
}
}
- Replace the
apiKey
with the value retrieved from the Google API Console. - Build the URL to the API using URLComponents. This guarantees the URL formatted properly.
- Ask the API to sort the list alphabetically.
- Call the Google Font API
- Return the decoded GoogleFont items.
If there are not values being returned make sure that:
- The Google API key is correct
- Outgoing connections are enabled for the application.
Creating a Usable Web Font
To create a usable web font, a mixture of Core Graphics and Core Text will be used. Core Graphics is a low-level framework that manages drawing 2D objects, including text. Core Text on the other hand is the low-level text rendering and layout engine that is used by SwiftUI to render text and handle fonts.
SwiftUI’s Font
has a constructor that takes a CTFont
(Core Text font) and returns a SwiftUI Font
. The register function will download the font file and get into SwiftUI Font
. Create a Font+Register.swift
file to put the extension’s code in.
extension Font {
static func register(url: URL, size: CGFloat = 18) async throws -> Font? {
do {
let request = URLRequest(url: url)
let (data, _) = try await URLSession.shared.data(for: request)
guard let provider = CGDataProvider(data: data as CFData),
let cgFont = CGFont(provider)
else {
print("Unsucessfully registered font")
return nil
}
let ctFont = CTFontCreateWithGraphicsFont(cgFont, size, nil, nil)
return Font(ctFont)
} catch {
print(error)
}
return nil
}
}
- Fetch the data for the font from the URL
- Create a Core Graphics data provider from the downloaded data. The code takes advantage that Swift’s Data class can be bridged to CoreFoundation’s CFData.
- Create a Core Graphics font.
- Create a Core Text font.
- Use the Core Text font to create a SwiftUI font.
- If there is an error, print the error and return
nil
.
Create Main View
The ContentView.swift
file contains the main view for the application. The main view will contain a split view with the list on the left and details on the right. The ContentView
owns the list of fonts to display as well as the current selection state.
The task
modifier will be called to load the fonts and set the initial selected font.
struct ContentView: View {
@State var fonts = [GoogleFont]()
@State var font: GoogleFont?
var body: some View {
NavigationSplitView {
FontList(selectedFont: $font, fonts: fonts)
} detail: {
if let font = font {
DetailsView(font: font)
} else {
EmptyView()
}
}
.task {
do {
fonts = try await GoogleFontService().syncFonts()
await MainActor.run {
font = fonts[0]
}
} catch {
print (error)
}
}
}
}
- Create a state variable to hold a list of the
GoogleFont
items. - Create a state variable to hold the currently selected
GoogleFont
- Create navigate split view with two columns.
- Add the
FontList
to the first block (left side). Notice that the selection state is bound to theFontList's
selection. - If the list is not empty, set the details to an
DetailsView
. Otherwise, show anEmptyView
- Add a
task
modifier that retrieves the fonts and sets the current selection to the first font in the list.
FontList
The font list shows all the fonts available and updates the currently selected font. Create a FontList.swift
file.
struct FontList: View {
@Binding var selectedFont: GoogleFont?
var fonts: [GoogleFont]
var body: some View {
List(fonts, selection: $selectedFont) { font in
NavigationLink(value: font) {
VStack(alignment: .leading) {
Text(font.family)
.font(.system(size: 14, weight: .bold))
Text("\(font.files.count) styles")
.font(.system(size: 14))
}
.padding(8)
}
}
}
}
- Create a binding to update the currently selected font.
- Create a variable to hold the fonts to display.
- Create a list view to show the fonts and update the selection.
- The cells will be a simple vertical stack that shows the name of the font family and the number of styles.
- Add a small amount of padding for visual appeal.
Detail View
The DetailsView
presents a preview for each style that is in a font family. The styles will be displayed in a lazy vertical stack. Create a DetailView.swift
struct DetailsView: View {
var font: GoogleFont
var body: some View {
VStack {
Text(font.family)
.font(.system(size: 25, weight: .bold))
Spacer()
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(font.files, id: \.id) { value in
StyleCell(style: value)
}
}
}
.id(font.family)
}
.padding()
}
}
- Display a header with the name of the selected family.
- Display a list of the styles in the font family using a lazy vertical stack that scrolls.
- Give the scroll view a unique ID so it scrolls to the top when created. It seems that SwiftUI wanted to reuse the scroll view which would cause the scroll position to not be the top on each new selection.
StyleCell
The StyleCell
is responsible for previewing a single font style for a selected family. The cell will display A-Z and 0–9 in the given particular style. When the font is loading “Trying to load” is displayed. Since the fonts are temporary, they only exist as long they are displayed.
struct StyleCell: View {
let style: FontFile
@State var font: Font?
var body: some View {
GroupBox(label: Text(style.style.friendlyName)) {
if let font = font {
Text("ABCDEFGHJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz\n1234567890")
.font(font)
.frame(maxWidth: .infinity, alignment: .leading)
}
else {
Text("trying to load")
.font(.system(size: 20))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.task {
font = try? await Font.register(url: style.url, size: 32)
}
.padding()
}
}
- Wrap the preview in a group box that has the style’s
friendlyName
as the title. - If there is a registered font, display the preview. Otherwise, display “Trying to load”. Make sure these are displayed in the maximum space allowed horizontally.
- In the
task
modifier, register the style’s font when the view is created.
Running the project now allow the user to select a font and display its preview.
This was a fun tutorial to write and get to learn a bit more about how fonts work at a lower level and see how it all ties in with SwiftUI. The complete project can be found on GitHub.
Thanks for reading.