Home iOS & Swift Tutorials

Using Redacted Placeholders in SwiftUI

Learn how to apply redaction to views in SwiftUI.

5/5 3 Ratings

Version

  • Swift 5, iOS 14, Xcode 12

Have you ever used a mobile app or website that took a while to load? Slow connection speeds aren’t pleasant to deal with, are they? It’s even worse when you can’t tell if content is loading or if it failed along the way.

Fortunately, there are a few ways to inform a user when something is taking longer than expected. One of the most modern approaches is using redacted placeholders. These were introduced to SwiftUI in iOS 14.

In this tutorial, you’ll learn:

  • How to leverage placeholders in SwiftUI
  • Why loading states are so important
  • Best practices for concealing private user information
  • How to create a widget

Placeholders are a more modern approach that showcase a preview of a UI. This design pattern is commonly used in text fields, where a field displays a prompt that helps the user know what to enter.

Another strength of placeholders is the ability to conceal private information. Financial apps will typically do this when the app enters the background. In SwiftUI, it’s easier to show a placeholder rather than create a separate view to conceal the sensitive information.

So without any further ado, it’s time to learn how to do just that!

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

Inside the ZIP, you’ll find two folders, final and starter. Open the starter folder. The project consists of an app that displays a header with the app’s name, Quotation.

Included with the project is a JSON file containing motivational quotes. This file is located at Supporting Files/quotes.json. Each quote has an ID, date and icon name — along with the quote itself. You’ll find the data model for this data at Shared/Quote.swift. The quotes in this data set are from motivationping.com.

The aim of this tutorial is to showcase how important loading states are in software. It’ll show how to do this in an app and an iOS 14 widget.

Requesting Quotes

The first thing you’ll need is to get the quotes loaded into the app. Open the view model located at App/QuotesViewModel.swift. This is where you’ll load the quotes. Add the following properties to the top of QuotesViewModel:

@Published var isLoading = false
@Published var quotes: [Quote] = []

The first property determines if content is loading. The second is the array of quotes the app will display. Since the app will have a widget, it’s a good idea to share the loading logic.

Open Shared/ModelLoader.swift and change the contents of bundledQuotes to the following:

// 1
guard let url = Bundle.main
  .url(forResource: "quotes", withExtension: "json") 
else {
  return []
}

// 2
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .secondsSince1970

do {
  // 3
  let data = try Data(contentsOf: url)
  return try decoder.decode([Quote].self, from: data)
} catch {
  print(error)
  return []
}

Here’s what the code above is doing:

  1. This is the path to the JSON file.
  2. This creates a JSONdecoder used to parse the quotes.
  3. The decoder attempts to read the data from the file and returns the decoded quote array to bundleQuotes.

Now you have a way to read the bundled data. Head back to QuotesViewModel.swift and add the following to the end of the class:

init() {
  withAnimation {
    self.quotes = ModelLoader.bundledQuotes
  }
}

This initializer takes care of loading the quotes from the disk by calling your new method.

Nothing will show up yet since there isn’t a UI to display the quotes. Don’t worry! You’ll get to that next.

Open App/QuotesView.swift and add the following under body:

private func row(from quote: Quote) -> some View {
  // 1
  HStack(spacing: 12) {
    // 2
    Image(systemName: quote.iconName)
      .resizable()
      .aspectRatio(nil, contentMode: .fit)
      .frame(width: 20)

    // 3
    VStack(alignment: .leading) {
      Text(quote.content)
        .font(
          .system(
            size: 17,
            weight: .medium,
            design: .rounded
          )
        )

      Text(quote.createdDate, style: .date)
        .font(
          .system(
            size: 15,
            weight: .bold,
            design: .rounded
          )
        )
        .foregroundColor(.secondary)
    }
  }
}

Going through the code:

  1. This row view shows an icon on the left and text on the right.
  2. The quote data from the bundled JSON file contains SF Symbols. This Image will display the desired symbol.
  3. The quote and its date are displayed one on top of the other.

Now, add the following to the List block in the body:

ForEach(viewModel.quotes) { quote in
  row(from: quote)
}

This loops through the quotes and loads them into the view. Build and run.

View of quotes.

Great, you can now see the bundled quotes!

Showing Progress

In ideal situations, information loads right away and no errors occur. However, with cellular networks and complex server-side code, something can and will go wrong, so it’s important to try and make these kinds of situations as smooth as possible for users.

Even local information can take some time to load. A database query resulting in more than 100,000 items would take a few seconds at least. Currently, the quotes load without delay or issue. But, for the sake of this tutorial, you’ll add an artificial delay to simulate a slow network connection.

Open QuotesViewModel.swift and add the following below init():

private func delay(interval: TimeInterval, block: @escaping () -> Void) {
  DispatchQueue.main.asyncAfter(deadline: .now() + interval) {
    block()
  }
}

This helper method runs a closure after a specified period of time using Grand Central Dispatch. You’ll use this to delay a few parts of the loading process.

Next, replace the contents of init() with this:

isLoading = true
let simulatedRequestDelay = Double.random(in: 1..<3)

delay(interval: simulatedRequestDelay) {
  withAnimation {
    self.quotes = ModelLoader.bundledQuotes
  }

  let simulatedIngestionDelay = Double.random(in: 1..<3)

  self.delay(interval: simulatedIngestionDelay) {
    self.isLoading = false
  }
}

This code adds two delays to the mix. You update the isLoading property here to add an extra layer of progress. Two random numbers help simulate a real work scenario.

Build and run.

An empty loading screen.

You'll now see an empty view until the quotes load. In a production app, behavior like this is confusing to a user. It isn't clear if something is happening or if anything failed.

One of the most common UI patterns used to communicate that data is loading is a spinner. Before the introduction of iPhone X, the easiest way to show a loading spinner was UIApplication.isNetworkActivityIndicatorVisible. Other popular patterns include loading bars, blurs and placeholders.

The first improvement you'll make is to add a loading indicator. This is known as a UIActivityIndicatorView in UIKit or a ProgressView in SwiftUI. The view model is already set up to do this.

Open QuotesView.swift and, inside QuotesView, replace the contents of body with the following:

ZStack {
  NavigationView {
    List {
      ForEach(viewModel.quotes) { quote in
        row(from: quote)
      }
    }
    .navigationTitle("Quotation")
  }

  if viewModel.quotes.isEmpty {
    ProgressView()
  }
}

The main difference here is the use of a ZStack to position the progress view above the List. If an error occurs, the progress view stops, signaling that something unexpected happened.

More often that not, it takes a number of network requests to retrieve all the required data for a view. Take the example of a view that loads the contents of a shopping cart. This view would need to make requests for each product image or user review. By the time the page has fully loaded, there could have been dozens of requests made.

Build and run.

A view loading with a centered spinner.

This ProgressView goes a long way to show the user something is happening!

Redacted Placeholders

Earlier, you added two delays when loading the quotes. The first delay simulates the initial request to a network. The second you'll use to show a slower loading piece of the view's data. This is where redaction comes in.

In QuotesView, add the following immediately after the closing curly-brace of the ForEach inside body:

.redacted(
  reason: viewModel.isLoading ? .placeholder : []
)

This will make each row in the List appear redacted when isLoading is true.

Build and run.

A view full of placeholder table cells.

It's that easy to conceal parts of a view in SwiftUI: The redacted modifier will conceal the labels until loading is complete. This modifier creates great placeholder views for you.

In some situations, the automatic placeholder view isn't what works best, as you might want to make certain views always show. Fortunately for you, Apple thought of this!

Change the Image in row(from:) to the following:

Image(systemName: quote.iconName)
  .resizable()
  .aspectRatio(nil, contentMode: .fit)
  .frame(width: 20)
  .unredacted()

Build and run.

Icons showing with placeholder text.

Now the icon image always shows.

.unredacted() complements .redacted() perfectly. But in this example, the quote might take longer if it requires an additional network request to fetch its data.

Concealing User Data

While most apps use accounts to store information about users that benefits them, information is private and shouldn't be shared without consent.

For example, a stock trading app that tracks your investments should be secure. It should also be thoughtful in keeping sensitive information from prying eyes. One common trick these kinds of apps use is to hide your information when you close the app.

This will be a nice addition to your app. The quotes are not as critical as your financial records, but for a moment, pretend they are. :]

To achieve this effect, you'll reuse some of the existing logic.

Open QuotesViewModel.swift and add a new property at the top of the class:

@Published var shouldConceal = false

After that, add these three new methods to the class:

private func beginObserving() {
  // 1
  let center = NotificationCenter.default
  center.addObserver(
    self,
    selector: #selector(appMovedToBackground),
    name: UIApplication.willResignActiveNotification,
    object: nil
  )
  center.addObserver(
    self,
    selector: #selector(appMovedToForeground),
    name: UIApplication.didBecomeActiveNotification,
    object: nil
  )
}

@objc private func appMovedToForeground() {
  // 2
  shouldConceal = false
}

@objc private func appMovedToBackground() {
  // 3
  shouldConceal = true
}

Here's what this is doing:

  1. Two NotificationCenter observers will listen for notifications of the app's state.
  2. As the app moves to the foreground, it shows the quotes.
  3. When the app is closing, it conceals the quotes.

Call beginObserving at the top of init():

beginObserving()

Moving along, at the top of QuotesViewModel, add this computed property:

var shouldHideContent: Bool {
  return shouldConceal || isLoading
}

Now you'll use this new property, shouldConceal, along with the existing one, isLoading, to update the view. If either is true, the view will hide the user's content.

Back in QuotesView.swift, change the redacted modifier to use the new computed property:

.redacted(
  reason: viewModel.shouldHideContent ? .placeholder : []
)

Build and run.

Closing the app.

Now, when your app enters the background, no one can see your precious quotes! When you make the app enter the foreground again, your quotes will be restored. :]

Using redacted to create placeholder views works well for a number of situations. Not only does it look good, but it's easy to use and customize. It works well for template views and loading indicators too.

Apple's intention for creating redacted views was to use it for the revamped Home Screen widgets. That's what you'll cover next!

Creating a Widget

Apple reintroduced widgets in iOS 14. Before, they were only shown on the Today View. The old implementation of widgets didn't have a good loading state; they started off blank and often took a while to load.

Now that widgets are front and center on the Home Screen, there's more of a need for a better loading state. The solution is a straightforward modifier that applies to any View. Since any third-party app can offer its own widget, this generic solution is perfect.

The widget in this app shows a new quote each hour. The design will work like the main app. In the case where the system is showing a placeholder, you still want the icon to show up.

Navigate to Widget/QuoteOfTheHour.swift and change the implementation of getTimeline(in:completion:) to the following:

var entries: [QuoteEntry] = []
// 1
var quotes = ModelLoader.bundledQuotes

let calendar = Calendar.current
let currentDate = Date()

for hourOffset in 0..<24 {
  // 2
  guard let entryDate = calendar.date(
    byAdding: .hour, 
    value: hourOffset, 
    to: currentDate) 
  else {
    continue
  }

  // 3
  guard let randomQuote = quotes.randomElement() else {
    continue
  }

  // 4
  if let index = quotes.firstIndex(of: randomQuote) {
    quotes.remove(at: index)
  }

  entries.append(QuoteEntry(model: randomQuote, date: entryDate))
}

// 5
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)

In the code above:

  1. Quotes are pulled from the shared model loader.
  2. A new quote is scheduled for each hour for the next 24 hours.
  3. A quote is chosen at random.
  4. The selected quote is removed so that scheduled quotes are unique.
  5. The timeline is set using the 24 selected quotes.

Now that there's data to work with, the next step is to design the widget.

Replace the body of QuoteOfTheHourEntryView in QuoteOfTheHour.swift with the following:

VStack(alignment: .leading) {
  HStack {
    Image(systemName: entry.model.iconName)
      .resizable()
      .aspectRatio(nil, contentMode: .fit)
      .frame(width: 12)

    Spacer()

    Text(entry.model.createdDate, style: .date)
      .font(
        .system(
          size: 12,
          weight: .bold,
          design: .rounded
        )
      )
      .foregroundColor(.secondary)
      .multilineTextAlignment(.trailing)
  }

  Text(entry.model.content)
    .font(
      .system(
        size: 16,
        weight: .medium,
        design: .rounded
      )
    )

  Spacer()
}
.padding(12)

This is similar to the rows used in the main app, with the difference that the font sizes and the icon are smaller.

Build and run. Close the app and add a widget to the Home Screen like so:

Adding a Home Screen widget.

The final thing is to make sure the icon in the widget is always displayed. Since this app uses SF Symbols for the icons, they're always accessible. The OS loads the quote and date for the widget from the timeline, so they'll be redacted during that time.

Like the main app, a single modifier will ensure the icon will show up.

Replace the Image in the body with the following:

Image(systemName: entry.model.iconName)
  .resizable()
  .aspectRatio(nil, contentMode: .fit)
  .frame(width: 12)
  .unredacted()

Build and run.

A partially redacted widget.

When the system is loading the widget, it'll show a placeholder with the icon present.

Note: The widget should load right away in the simulator. A redacted modifier was added to the widget for this screenshot.

Now the widget looks great in all scenarios.

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

To recap what you learned:

  • Blank loading views cause confusion.
  • Simple spinners are helpful in most situations.
  • Placeholders are even better and look great.
  • Not only do placeholders work well for loading, but they're great for concealing data too.

For more information, refer to Apple's documentation on redaction. Additionally, the Build SwiftUI views for widgets video from WWDC2020 is worth a watch.

To learn more about SwiftUI, check out the other raywenderlich.com tutorials on the subject.

Hopefully you enjoyed this tutorial. If you have any questions or comments, please join the discussion below!

Average Rating

5/5

Add a rating for this content

3 ratings

More like this

Contributors

Comments