Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

Section I: Your first app: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your second app: Cards

Section 2: 9 chapters
Show chapters Hide chapters

5. Organizing Your App's Data
Written by Audrey Tam

In this chapter, you’ll use structures and enumerations to organize your app’s data. The compiler can then help you avoid errors like using the wrong type value or misspelling a string.

Your app needs sample data during development. You’ll use a compiler directive to create this data only during development. And you’ll store your development-only code and data in Preview Content to exclude them from the release version of your app.

You’ll learn how to localize your app to expand its audience. You’ll replace user-facing text with NSLocalizedString instances, generate the development language (English) Localizable.strings file, then use this as the reference language resource file for adding another language.

➤ Continue with your project from the previous chapter or open the project in this chapter’s starter folder.

Creating the Exercise structure

Skills you’ll learn in this section: how to use enumeration, computed property, extension, static property

For the initial layout of HIITFit, you used two arrays of strings for the exercise names and video file names. This minimalist approach helped you see exactly what data each view needs, and this helped to keep the previews manageable.

But you had to manually ensure the strings matched up across the two arrays. It’s safer to encapsulate them as properties of a named type.

First, you’ll create an Exercise structure with the properties you need. Then, you’ll create an array of Exercise instances and loop over this array to create the ExerciseView pages of the TabView.

➤ Create a new Swift file and name it Exercise.swift. Add the following code below import Foundation:

struct Exercise {
  let exerciseName: String
  let videoName: String
    
  enum ExerciseEnum: String {
    case squat = "Squat"
    case stepUp = "Step Up"
    case burpee = "Burpee"
    case sunSalute = "Sun Salute"
  }
}

In the previous chapters, you’ve created and used structures that conform to the View protocol. This Exercise structure models your app’s data, encapsulating the exerciseName and videoName properties.

Enumerating exercise names

enum is short for enumeration. A Swift enumeration is a named type and can have methods and computed properties. It’s useful for grouping related values so the compiler can help you avoid mistakes like misspelling a string.

Swift Tip: A stored property is one you declare with a type and/or an initial value, like let name: String or let name = "Audrey". You declare a computed property with a type and a closure where you compute its value, like var body: some View { ... }.

Here, you create an enumeration for the four exercise names. The case names are camelCase: If you start typing ExerciseEnum.sunSalute, Xcode will suggest the auto-completion.

Because this enumeration has String type, you can specify a String as the raw value of each case. Here, you specify the title-case version of the exercise name: ”Sun Salute” for sunSalute, for example. You access this String with ExerciseEnum.sunSalute.rawValue, for example.

Creating an array of Exercise instances

You get to use your enumeration right away to create your exercises array.

Below Exercise, completely outside its braces, add this code:

extension Exercise {
  static let exercises = [
    Exercise(
      exerciseName: ExerciseEnum.squat.rawValue,
      videoName: "squat"),
    Exercise(
      exerciseName: ExerciseEnum.stepUp.rawValue,
      videoName: "step-up"),
    Exercise(
      exerciseName: ExerciseEnum.burpee.rawValue,
      videoName: "burpee"),
    Exercise(
      exerciseName: ExerciseEnum.sunSalute.rawValue,
      videoName: "sun-salute")
  ]
}

In an extension to the Exercise structure, you initialize the exercises array as a type property.

exerciseName and videoName are instance properties: Each Exercise instance has its own values for these properties. A type property belongs to the type, and you declare it with the static keyword. The exercises array doesn’t belong to an Exercise instance. There’s just one exercises array no matter how many Exercise instances you create. You use the type name to access it: Exercise.exercises.

You create the exercises array with an array literal: a comma-separated list of values, enclosed in square brackets. Each value is an instance of Exercise, supplying the raw value of an enumeration case and the corresponding video file name.

As the word suggests, an extension extends a named type. The starter project includes two extensions, in DateExtension.swift and ImageExtension.swift. Date and Image are built-in SwiftUI types but, using extension, you can add methods and computed or type properties.

Here, Exercise is your own custom type, so why do you have an extension? In this case, it’s just for housekeeping, to keep this particular task — initializing an array of Exercise values — separate from the core definition of your structure — stored properties and any custom initializers.

Developers also use extensions to encapsulate the requirements for protocols, one for each protocol. When you organize your code like this, you can more easily see where to add features or look for bugs.

Refactoring ContentView and ExerciseView

Now, you’ll modify ContentView and ExerciseView to use your new Exercise.exercises array.

➤ In ContentView.swift, replace the ForEach loop range with this:

ForEach(0 ..< Exercise.exercises.count) { index in

Instead of a magic number, you use the number of exercises elements as the upper bound of the ForEach range.

Note: You could pass the whole Exercise item to ExerciseView but, in the next chapter, you’ll use index to decide when to show SuccessView.

➤ In ExerciseView.swift, delete the videoNames and exerciseNames arrays. The Exercise.exercises array contains the same data. The error flags tell you where you need to use this array.

Replace exerciseNames[index] with this:

Exercise.exercises[index].exerciseName

And replace videoNames[index], in two places, with this:

Exercise.exercises[index].videoName

➤ Run live preview in ContentView.swift to check everything still works:

Exercise views work after refactoring.
Exercise views work after refactoring.

Refactoring ContentView and ExerciseView is almost everything you need to do. You don’t need to modify any of the other views, except HistoryView.

Structuring HistoryView data

Skills you’ll learn in this section: Identifiable, mutating func, initializer, compiler directive / conditional compilation, debug/release build config, Preview Content, ForEach with an array of Identifiable values

HistoryView currently uses hard-coded dates and exercise lists to mock up its display. You need a data structure for storing your user’s activity. And, in the next chapter, you’ll implement the Done button to add completed exercise names to this data structure.

Creating HistoryStore

➤ Create a new Swift file and name it HistoryStore.swift. Group it with Exercise.swift and name the group folder Model:

Model group with Exercise and HistoryStore
Model group with Exercise and HistoryStore

➤ Add the following code below import Foundation:

struct ExerciseDay: Identifiable {
  let id = UUID()
  let date: Date
  var exercises: [String] = []
}

struct HistoryStore {
  var exerciseDays: [ExerciseDay] = []
}

An ExerciseDay has properties for the date and a list of exercise names completed by your user on that date.

ExerciseDay conforms to Identifiable. This protocol is useful for named types that you plan to use as elements of a collection, because you usually want to loop over these elements or display them in a list.

When you loop over a collection with ForEach, it must have a way to uniquely identify each of the collection’s elements. The easiest way is to make the element’s type conform to Identifiable and include id: UUID as a property.

UUID is a basic Foundation type, and UUID() is the easiest way to create a unique identifier whenever you create an ExerciseDay instance.

The only property in HistoryStore is an array of ExerciseDay values you’ll loop over in HistoryView.

In Chapter 9, “Saving History Data”, you’ll extend HistoryStore with a method to save the user’s history to persistent storage and another method to load the history. Soon, you’ll add a HistoryStore property to HistoryView, which will initialize it.

In the meantime, you need some sample history data and an initializer to create it.

Below HistoryStore, completely outside its braces, add this code:

extension HistoryStore {
  mutating func createDevData() {
    // Development data
    exerciseDays = [
      ExerciseDay(
        date: Date().addingTimeInterval(-86400),
        exercises: [
          Exercise.exercises[0].exerciseName,
          Exercise.exercises[1].exerciseName,
          Exercise.exercises[2].exerciseName
        ]),
      ExerciseDay(
        date: Date().addingTimeInterval(-86400 * 2),
        exercises: [
          Exercise.exercises[1].exerciseName,
          Exercise.exercises[0].exerciseName
        ])
    ]
  }
}

This is pretty much the same sample data you had before, now stored in your new Exercise and ExerciseDay structures. In the next chapter, you’ll add a new ExerciseDay item, so I’ve moved the development data to yesterday and the day before yesterday.

You create this sample data in a method named createDevData. This method changes, or mutates, the exerciseDays property, so you must mark it with the mutating keyword.

And you create this method in an extension because it’s not part of the core definition. But there’s another reason, too – coming up soon!

➤ Now, in the main HistoryStore, create an initializer for HistoryStore that calls createDevData():

init() {
  #if DEBUG
  createDevData()
  #endif
}

You don’t want to call createDevData() in the release version of your app, so you use a compiler directive to check whether the current Build Configuration is Debug:

Debug build configuration
Debug build configuration

Note: To see this window, click the toolbar button labeled HIITFit. It also opens the run destination menu alongside. Select Edit Scheme…, then select the Info tab.

Moving development code into Preview Content

In fact, you don’t want createDevData() to ship in your release version at all. Xcode provides a place for development code and data: Preview Content. Anything you put into this group will not be included in your release version.

➤ In the Preview Content group, create a new Swift file named HistoryStoreDevData.swift and move the HistoryStore extension into it:

HistoryStore extension in Preview Content
HistoryStore extension in Preview Content

And this is the other reason createDevData() is in an extension: You can store extensions in separate files. This means you never have to scroll through very long files.

Refactoring HistoryView

➤ In HistoryView.swift, delete the Date properties and the exercise arrays, then add this property:

let history = HistoryStore()

HistoryStore now encapsulates all the information in the stored properties today, yesterday and the exercises arrays.

The Form closure currently displays each day in a Section. Now that you have an exerciseDays array, you should loop over the array.

➤ Replace the Form closure with the following:

Form {
  ForEach(history.exerciseDays) { day in
    Section(
      header:
        Text(day.date.formatted(as: "MMM d"))
        .font(.headline)) {
      ForEach(day.exercises, id: \.self) { exercise in
        Text(exercise)
      }
    }
  }
}

Instead of today and yesterday, you use day.date. And instead of the named exercises arrays, you use day.exercises.

The code you just replaced looped over exercises1 and exercises2 arrays of String. The id: \.self argument told ForEach to use the instance itself as the unique identifier. The exercises array also contains String instances, so you still need to specify this id value.

➤ Refresh the preview to make sure it still looks the same:

History view works after refactoring.
History view works after refactoring.

Congratulations, you’ve set up your data structures and refactored your views to use them. The final project up to this point is in the final-no-loc folder. The rest of this chapter shows you how to localize your app.

Localizing your app

Skills you’ll learn in this section: how to localize your app; how to use CustomStringConvertible and genstrings; how to change app language

You surely want to maximize the audience for your app. A good way to do this is to translate it into languages other than English. This is called localization.

You need to complete the following tasks. You can do these in a different order, but this workflow will save you some time:

  1. Set up localization for the project’s development language (English).
  2. Decide which user-facing strings you want to localize and replace these with NSLocalizedString instances.
  3. Generate the contents of Localizable.strings from these NSLocalizedString instances.
  4. Add another language, choosing the existing English Localizable.strings as the reference language resource file.
  5. In the Localizable.strings file for the new language, replace English strings with translated strings.

Getting started

➤ In the Project navigator, select the top-level HIITFit folder. This opens the project page in the editor:

Open the project page.
Open the project page.

If you don’t see the projects and targets list, click the button in the upper left corner.

➤ Select the HIITFit Project, then its Info tab:

Project Info: Localizations
Project Info: Localizations

The Localizations section lists Base and English — Development Language, both with 0 Files Localized.

UIKit projects have a Base.lproj folder containing .storyboard and/or .xib files. SwiftUI projects with a LaunchScreen.storyboard file also have this folder. These files are already marked as localized in the development language (English). When you add another language, they appear in a checklist of resources you want to localize in the new language. So projects like these have at least one base-localized file.

If you don’t do anything to localize your app, you won’t have any development-language-localized files. All the user-facing text in your app just appears the way you write it. As soon as you decide to add another language, you’ll replace this text with NSLocalizedString instances. For this mechanism to work, you’ll also have to localize in the development language.

Note: To change Development Language to another language, edit project.pbxproj in a text editor and, for both developmentRegion and knownRegions, change en to the language ID for your preferred development language.

You could add another language now, but the workflow you’ll follow below saves you some time.

Creating en.lproj/Localizable.strings

This step sets up localization for the project’s development language (English). First, you’ll create a Strings file named Localizable.strings.

➤ To create this file in the HIITFit group, but not in the Views group, select Assets.xcassets or HIITFitApp.swift in the Project navigator.

➤ Press Command-N to open the new file window, search for string, then select Strings File:

New Strings File
New Strings File

➤ Name this file Localizable. This is the default name iOS uses. Don’t let your auto-correct change it to Localisable, or you’ll have to type the name of this file every time you reference a localized string.

File name must be Localizable.strings.
File name must be Localizable.strings.

Naming this file “Localizable” doesn’t make it so. You must explicitly localize it.

➤ Select Localizable.strings in the Project navigator and open the File inspector (Option-Command-1).

Localizable.strings: File inspector
Localizable.strings: File inspector

Notice the file’s pathname is:

HIITFit/HIITFit/Localizable.strings

➤ Click Localize…. Something very quick happens! If the file inspector goes blank, select Localizable.strings in the Project navigator again:

Localizable.strings is now localized.
Localizable.strings is now localized.

A Localization section has replaced the button. And now the file’s pathname has a new subdirectory:

HIITFit/HIITFit/en.lproj/Localizable.strings

This new en.lproj folder doesn’t appear in the Project navigator, but here it is in Finder:

New en.lproj folder
New en.lproj folder

Whenever you localize a resource, Xcode stores it in a folder named xx.lproj, where xx is the language ID (en for English).

That’s the project-level setup done. The next two steps will populate this Localizable.strings file with lines like "Start" = "Start";, one for each string you want to translate into another language.

Which strings?

The next step starts with deciding which user-facing strings you want to localize.

➤ Now scan your app to find all the text the user sees:

  • WelcomeView text: Welcome, Get fit ….
  • Exercise names used as ExerciseView titles and in HistoryView lists.
  • Button labels Get Started, Start/Done and History.
  • SuccessView text: High Five, Good job ….

Creating NSLocalizedString instances

Next, you’ll replace these strings with instances of NSLocalizedString(_:comment:), where the first argument is the English text and comment provides information to clarify the string’s context. If you don’t know the other language well enough to translate your text, you’ll usually ask someone else to provide translations. The comment should help a translator make a translation that’s accurate for your context.

➤ Start in WelcomeView.swift. Replace "Welcome" with this:

NSLocalizedString("Welcome", comment: "greeting")

Here, “Welcome” is a greeting, not a verb.

➤ Replace most of the other strings in this view:

NSLocalizedString("History", comment: "view user activity")
NSLocalizedString("Get Fit", comment: "invitation to exercise")
NSLocalizedString("Get Started", comment: "invitation")

➤ Leave “with high intensity interval training” as it is, for now.

➤ In ExerciseView.swift, reuse the "History" NSLocalizedString and replace "Start/Done" with this:

NSLocalizedString(
  "Start/Done",
  comment: "begin exercise / mark as finished")

➤ In HistoryView.swift, again reuse the "History" NSLocalizedString. DateFormatter automatically localizes dates, so you don’t have to do anything to day.date.

➤ Leave SuccessView.swift as it is.

That takes care of everything except the exercise names. You create these in Exercise.swift, so that’s where you’ll set up the localized strings.

Localizing the Exercise structure

In Exercise.swift, you’ve been using raw values of ExerciseEnum for exerciseName. Now you need to use NSLocalizedString("Squat", comment: "exercise") instead. But an enumeration’s raw value must be a literal string, so you can’t just replace the raw values with NSLocalizedString instances.

You need to refactor Exercise to use localized strings instead of enumeration raw values.

➤ Delete the raw values, then make the enumeration conform to CustomStringConvertible. This simply requires each case to have a description string. And a description string can be an NSLocalizedString instance.

enum ExerciseEnum: CustomStringConvertible {
  case squat 
  case stepUp 
  case burpee 
  case sunSalute
  
  var description: String {
    switch self {
    case .squat:
      return NSLocalizedString("Squat", comment: "exercise")
    case .stepUp:
      return NSLocalizedString("Step Up", comment: "exercise")
    case .burpee:
      return NSLocalizedString("Burpee", comment: "exercise")
    case .sunSalute:
      return NSLocalizedString(
        "Sun Salute", comment: "yoga stretch")
    }
  }
}

➤ Now, in the exercises array, use the description string for exerciseName instead of the literal string:

extension Exercise {
  static let exercises = [
    Exercise(
      exerciseName: String(describing: ExerciseEnum.squat),
      videoName: "squat"),
    Exercise(
      exerciseName: String(describing: ExerciseEnum.stepUp),
      videoName: "step-up"),
    Exercise(
      exerciseName: String(describing: ExerciseEnum.burpee),
      videoName: "burpee"),
    Exercise(
      exerciseName: String(describing: ExerciseEnum.sunSalute),
      videoName: "sun-salute")
  ]
}

Now, when ContentView.swift initializes an ExerciseView with an exerciseName, ExerciseView will be able to display that name in Spanish.

Note: Why not ExerciseEnum.squat.description instead of String(describing:)? Well, the CustomStringConvertible documentation says “Accessing a type’s description property directly … is discouraged.”

Generating Localizable.strings content

Here’s the first time-saving step.

Your Localizable.strings file needs to contain lines like "Start" = "Start";, but it’s currently blank. You could type every line yourself, but fortunately Xcode provides a tool to generate these from your NSLocalizedString instances.

➤ In Finder, locate the HIITFit folder that contains the Assets.xcassets and en.lproj subfolders:

HIITFit folder to drag into Terminal
HIITFit folder to drag into Terminal

➤ Open Terminal, type cd followed by a space, then drag this HIITFit folder into Terminal:

cd <drag HIITFit folder here>

➤ Press Return. You changed directory to the folder that contains Assets.xcassets and en.lproj. Enter this command to check:

ls

You should see something like this:

Assets.xcassets       Info.plist        Views
DateExtension.swift   Model             en.lproj
HIITFitApp.swift      Preview Content   
ImageExtension.swift  Videos

➤ Now enter this command:

genstrings -o en.lproj Views/*.swift Model/*.swift

You use the Xcode command line tool genstrings to scan files in Views and Model for NSLocalizedString. It generates the necessary strings for the key values and stores these in your Localizable.strings file.

➤ Back in Xcode, select Localizable.strings in the Project navigator. It contains lines like these:

/* view user activity */
"History" = "History";

That’s your comment in comments and the key string assigned to itself. Aren’t you glad you didn’t have to type all that out yourself? ;]

Adding a language

And here’s the other time-saving step. You’ll add another language, choosing the existing English Localizable.strings as the reference language resource file. And automagic happens!

➤ In the Project navigator, select the top-level HIITFit folder, then the project in the projects and target list. In the Localizations section, click the + button and select another language:

Add Spanish localization.
Add Spanish localization.

This chapter uses Spanish.

Now you get to choose the file and reference language to create your localization:

Choose English Localizable.strings.
Choose English Localizable.strings.

➤ Click Finish.

This produces several changes. The Localizations section now has a Spanish item, which already has 1 File Localized.

Project Info: Localizations with Spanish
Project Info: Localizations with Spanish

Sure enough, the Project navigator shows Localizable.strings is now a group containing two Localizable.strings files.

Localizable.strings group
Localizable.strings group

And the Spanish file has the same contents as the English file!

Translating

Now for the final step: In the Localizable.strings file for the alternate language, you need to replace English strings with translated strings.

➤ Open Localizable.strings (Spanish) and replace the right-hand-side strings with translations:

/* exercise */
"Burpee" = "Burpee";

/* invitation to exercise */
"Get Fit" = "Ponte en forma";

/* invitation */
"Get Started" = "Empieza";

/* view user activity */
"History" = "Historia";

/* exercise */
"Squat" = "Sentadilla";

/* begin exercise / mark as finished */
"Start/Done" = "Empieza/Hecho";

/* exercise */
"Step Up" = "Step Up";

/* warm up stretch */
"Sun Salute" = "Saludo al Sol";

/* greeting */
"Welcome" = "Bienvenid@";

Note: Often, Spanish-speakers just use the English exercise names. And using ’@’ to mean ’a or o’ is a convenient way to be gender-inclusive.

Exporting for localization (Optional)

If you use a localization service to translate your strings, Xcode has commands to export Localizable.strings files to XLIFF (XML Localization Interchange File Format) and import XLIFF translations.

Before you export, localize any media resources or assets that provide useful context information to translators.

Resources like the .mp4 files have a Localize button in their file inspector.

Localize resource in file inspector
Localize resource in file inspector

Select the Base menu option:

Select Base localization.
Select Base localization.

This moves the .mp4 file into a new Base.lproj folder in Videos.

Resource moved into new Base.lproj folder
Resource moved into new Base.lproj folder

The Localize button for an Assets.xcassets item is in its Attributes inspector (Option-Command-4). Check the box for Spanish:

Localize asset in Attributes inspector.
Localize asset in Attributes inspector.

To export for localization, select the project in the Project navigator, then select Editor ▸ Export for Localization…:

Editor ▸ Export for Localization..
Editor ▸ Export for Localization..

Check the languages you want translations for and choose where to save the exported folder.

Export options
Export options

Note: The final-loc project exported to Preview Content to keep it with the project.

See what you got:

Exported folder
Exported folder

The exported folder has the same name as your project and contains .xcloc folders for the languages you checked. For each language, the .xliff file is in Localized Contents, and localized assets and resources are in Source Contents. You can supply additional context information in the Notes folder.

Note: I had mixed results exporting videos. If you don’t see these in the exported folder, just copy resources and assets directly to the exported Source Contents folder.

Testing your localization

To test your localization, you simply need to set the project’s App Language to Spanish.

➤ Edit the scheme and select Run ▸ Options ▸ App Language ▸ Spanish

App Language: Spanish
App Language: Spanish

➤ Now check your previews:

Localized app views
Localized app views

Note: The first three letters of November are the same in Spanish, so I changed the date format to "MMMM d" to display the full month name.

Key points

  • To use a collection in a ForEach loop, it needs to have a way to uniquely identify each of its elements. The easiest way is to make it conform to Identifiable and include id: UUID as a property.

  • An enumeration is a named type, useful for grouping related values so the compiler can help you avoid mistakes like misspelling a string.

  • Use compiler directives to create development data only while you’re developing and not in the release version of your app.

  • Preview Content is a convenient place to store code and data you use only while developing. Its contents won’t be included in the release version of your app.

  • Localize your app to create a larger audience for your app. Replace user-facing text with NSLocalizedString instances, generate the English Localizable.strings file, then use this as the reference language resource file for adding other languages.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.