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

Scott Andrew
Better Programming
Published in
9 min readDec 20, 2022

Photo by Amador Loureiro on Unsplash

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.

Google Font Preview Application

Project Setup

  1. Create a new Mac SwiftUI Project named GoogleFontPrevew
  2. Enable Outgoing Connections (Client) in the App Sandbox
  3. 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)!)
}
}
}
  1. Decode the family, version, category straight from the font container.
  2. Decode the file map from the JSON into a temporary variable.
  3. 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 to 400 and takes italic and maps it to 400italic. Doing this allows normal and italic to be sorted before medium (500) and after light (300).
  4. After the keys are sorted use compactMap to map create the sorted FontFile array. The use of compactMap allows a nilentry to be ignored when creating the file array. If map was used, a nil entry would inserted into the array.
  5. 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
}
}
  1. Replace the apiKey with the value retrieved from the Google API Console.
  2. Build the URL to the API using URLComponents. This guarantees the URL formatted properly.
  3. Ask the API to sort the list alphabetically.
  4. Call the Google Font API
  5. Return the decoded GoogleFont items.

If there are not values being returned make sure that:

  1. The Google API key is correct
  2. 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
}
}
  1. Fetch the data for the font from the URL
  2. 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.
  3. Create a Core Graphics font.
  4. Create a Core Text font.
  5. Use the Core Text font to create a SwiftUI font.
  6. 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)
}
}
}
}
  1. Create a state variable to hold a list of the GoogleFont items.
  2. Create a state variable to hold the currently selected GoogleFont
  3. Create navigate split view with two columns.
  4. Add the FontList to the first block (left side). Notice that the selection state is bound to the FontList's selection.
  5. If the list is not empty, set the details to an DetailsView. Otherwise, show an EmptyView
  6. 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)
}
}
}
}
  1. Create a binding to update the currently selected font.
  2. Create a variable to hold the fonts to display.
  3. Create a list view to show the fonts and update the selection.
  4. The cells will be a simple vertical stack that shows the name of the font family and the number of styles.
  5. 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()
}
}
  1. Display a header with the name of the selected family.
  2. Display a list of the styles in the font family using a lazy vertical stack that scrolls.
  3. 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()
}
}
  1. Wrap the preview in a group box that has the style’s friendlyName as the title.
  2. 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.
  3. 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.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Scott Andrew
Scott Andrew

Written by Scott Andrew

Swift and SwiftUI progammer who has dabbled in C++, C, Flutter, ReactJS, Typescript and may other technologies over the past 30 years.

No responses yet

What are your thoughts?

Recommended from Medium

Lists

See more recommendations