Highlights of SwiftUI Release 3

WWDC 2021 concluded a few weeks ago, and it was held online again with feature-packed content. With each session on SwiftUI delivering the gist of what the team surprised us with, we love the improvements and rich features announced. This year, Apple focused on supporting deeper adoption of the framework for our apps.

We’ll start from the same project created in Build a Music Chat iOS App Using SwiftUI while working through the new features of SwiftUI Release 3. Then, we’ll look at how the SwiftUI team helped me remove the custom code by integrating them into the framework itself.

Note - You need a paid developer account, and Apple Music installed on your device to follow this article. Also, this article requires Xcode 13.0+ and iOS 15.0+

TonesChat

In Build a Music Chat iOS App Using SwiftUI, we created a music chat app where you can listen to your dearest music while sharing and chatting about it. The sample app used SwiftUI Release 2 extensively, with custom views for things that the framework lacked, for example, search bar.

We’ll start off from the same project, adding new features and improvements introduced this year. Please download the starter project and explore the contents under the Initial folder. It consists of helper views, a few extensions and the model for the API data.

Note - You’ll need to follow the previous article to add developer token for Apple Music and API key for Stream.

Open the initial project in Xcode. First, select the project from the Project Navigator, and bump up the deployment target to iOS 15. This ensures we can build the new exciting stuff on the latest iOS version.

Run the app to ensure that it works. Now, let’s get started with what surprise the framework has for us!

AsyncImage

As the name suggests, AsyncImage is used to download and display images asynchronously. Earlier, we ideally used SDWebImageSwiftUI, but with this version of SwiftUI, we can safely remove the dependency for primary use-cases.

First, delete the package from going to Project NavigatorSwift Packages.

Also, remove import SDWebImageSwiftUI from MusicCardView. We’ll get an error regarding WebImage.

We’ll replace it with AsyncImage. There are multiple initialisers provided to us, and the simplest one is -

init(url: URL?, scale: CGFloat = 1) where Content == Image

Looking at the syntax -

struct ArtworkImage: View {
  var url: URL?

  var body: some View {
    AsyncImage(url: url)
  }
}

We provide the view with a URL, and it automatically fetches and displays the remote image with a default placeholder. To have more control over the phase of the asynchronous image loading operation, we use -

init(url: URL?, scale: CGFloat = 1, transaction: Transaction = Transaction(), content: @escaping (AsyncImagePhase) -> Content)

This helps to add a transaction for passing an animation and defining custom views for loading, success and failure.

For the artwork to be loaded asynchronous in our app, we’ll use a handy wrapper over AsyncImage-

struct ArtworkImage<Content>: View where Content: View {
  private let url: URL?
  private var content: (_ image: Image) -> Content

  public init(url: URL?, @ViewBuilder content: @escaping (_ image: Image) -> Content) {
    self.url = url
    self.content = content
  }

  var body: some View {
    if let url = url {
      // 1
      AsyncImage(url: url, transaction: .init(animation: .spring())) { phase in
        switch phase {
        // 2
        case .empty: progressView()
        // 3
        case .success(let image): content(image.resizable())
        // 4
        case .failure(let error as NSError): errorView(with: error)
        // 5
        @unknown default: unknownView()
        }
      }
    } else {
      Text("Wrong URL")
    }
  }

  private func progressView() -> some View {
    ProgressView().transition(.opacity.combined(with: .scale))
  }

  private func errorView(with error: NSError) -> some View {
    ZStack {
      Color.red.transition(.opacity.combined(with: .scale))

      Text(error.localizedDescription).foregroundColor(.white)
    }
    .transition(.opacity.combined(with: .scale))
  }

  private func unknownView() -> some View {
    Color.gray.transition(.opacity.combined(with: .scale))
  }
}

Going through the ArtworkImage -

  1. We start with the AsyncImage that takes the url as the parameter. And transaction for passing the animation while switching between the phases.
  2. In the case of the empty phase, we show a ProgressView().
  3. In the case of a successful image, we pass back the image.
  4. In case of an error, we show red background with the error description.
  5. We add an @unknown default case for an additional unknown value of AsyncImagePhase that maybe added in future versions.

After adding the above code in a new SwiftUI file, head over to MusicCardView. Remove WebImage(url: url) and the modifiers associated with it. Replace it with the following -

ArtworkImage(url: url) { image in
  image
    .scaledToFit()
    .transition(.opacity.combined(with: .scale))
}

Note- As of beta 2, AsyncImage in List sometimes cancels image downloads prematurely.

Run the app to see the images load asynchronously, with a funky animation!

Refreshable

On the home screen, we’re displaying the top songs from the US. Maybe, Olivia drops another hit album, and the chart is full of her songs. For this, we need to add functionality to load content on request for updating the data.

SwiftUI 3.0 has added a new feature (missing past years) for supporting pull-to-refresh on iOS and iPadOS. It can be easily used with the refreshable modifier that configures a refresh action and passes it down through the environment. From the documentation,

Use this action to initiate an update of model data that the modified view displays. Use an await expression inside the action. SwiftUI shows a refresh indicator, which stays visible for the duration of the awaited operation.

func refreshable(action: @escaping () async -> Void) -> some View

Go to HomeView and under onAppear, add the refreshable modifier -

.refreshable {
  viewModel.updateTopSongs()
}

As of Xcode 13.0 beta 1, only List uses this action. So, open MusicGridView, and rename it to MusicListView. Also, replace -

ScrollView(.vertical, showsIndicators: false) {
  // LazyVGrid
}

with -

List {
  LazyVGrid(columns: items, spacing: 4) {
    ForEach(viewModel.songs.shuffled(), id: \.id) { song in
      // Content. Shuffling the songs after every refresh.
    }
  }
  .listRowSeparator(.hidden)
}
.listStyle(.plain)

Earlier, it wasn’t possible to hide the row separator without some hacks. Now, we’ve a dedicated modifier for this purpose.

SwiftUI also has a new modifier task that attaches a task to the lifetime of the view. It performs a particular task when the view appears and is cancelled when it disappears.

Replace onAppear with task in HomeView -

.task {
  viewModel.updateTopSongs()
}

Now, run the app and refresh to load new top-charting songs!

Material View

Quoting from the What’s new in SwiftUI WWDC 2021,

Materials are used across all of Apple’s platforms and apps to create beautiful visual effects that really emphasize their content, and now you can create them directly in SwiftUI!

Earlier, we used UIVisualEffectView inside of a UIViewRepresentable to create a blur effect. Now, we can directly use them inside the background modifier!

We’ll update the individual card for displaying data into a row, with a blurred background of the artwork for the material effect. Something similar is used in Apple Music as well.

Replace MusicCardView with the following -

struct MusicCardView: View {
  var song: SongData

  var body: some View {
    HStack {
      ArtworkImage(url: url) { image in
        image
          .scaledToFit()
          .transition(.opacity.combined(with: .scale))
      }
      .cornerRadius(12.0)
      .frame(width: 100.0, height: 100.0)

      VStack(alignment: .leading, spacing: 4.0) {
        Text(name)
          .fontWeight(.bold)
          .font(.callout)

        Text(artistName)
          .fontWeight(.light)
          .font(.caption)
      }
      .foregroundColor(.white)
      .frame(maxWidth: .infinity, alignment: .leading)
      .multilineTextAlignment(.leading)
      .padding(.horizontal)
    }
  }

  private var name: String {
    song.attributes.name
  }

  private var artistName: String {
    song.attributes.artistName
  }

  private var url: URL? {
    URL(string: song.attributes.artwork.url, width: 300, height: 300)
  }
}

It uses similar code as the previous version, but now we’re enclosing the content inside an HStack instead of a VStack and smaller image.

Create a new SwiftUI file with the name MusicRowView.swift. Add the following into it -

struct MusicRowView: View {
  var song: SongData

  var body: some View {
    ZStack {

      // 1
      ArtworkImage(url: url) { image in
        image
          .scaledToFill()
          .layoutPriority(-1)
          .overlay(Color.black.opacity(0.4))
          .transition(.opacity.combined(with: .scale))
      }

      // 2
      MusicCardView(song: song)
        .background(.thinMaterial)
    }
    .cornerRadius(12.0)
    .padding(4.0)
  }

  private var url: URL? {
    URL(string: song.attributes.artwork.url, width: 300, height: 300)
  }
}

Here’s what this view is doing:

  1. Creates another ArtworkImage that fills the whole row. We’re adding an overlay to darken the image.
  2. Adds a background with thin material. This creates a subtle blur effect on the background. These materials automatically blend with the content on top of them.

This generates a beautiful looking blurred background for each row. Try it out!

The current way of working with search is to create a custom view, where we’ve a TextField, with a cancel button. You can find its implementation in SearchBar.swift.

As the core functionality of TonesChat is to find amazing songs from our friends around the world and search for them, it is critical to have the search feature inbuilt in the framework. This year, a new searchable modifier makes it effortless to add search in our app.

SwiftUI automatically adds a search field to the appropriate location in our app. You can remove the SearchBar from SearchView, and add the searchable modifier to the NavigationView. The new implementation looks like this -

struct SearchView: View {
  @State private var searchText = ""
  @StateObject private var viewModel = SearchSongsViewModel()

  var body: some View {
    NavigationView {
      VStack {
        if searchText.isEmpty {
          Image("empty_search")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .padding()
        } else {
          MusicGridView(viewModel: viewModel)
        }
      }
      .navigationTitle("Search")
    }
    .navigationViewStyle(StackNavigationViewStyle())
    // 1
    .searchable(text: $searchText)
    // 2
    .onSubmit(of: .search) {
      viewModel.updateSearchSongs(for: searchText)
    }
    // 3
    .onChange(of: searchText) { _ in
      if searchText.isEmpty {
        viewModel.songs = []
      }
    }
  }
}

Here’s the breakdown:

  1. We add the searchable modifier with the binding of searchText. It automatically adds a search bar in the view at the top.
  2. On submit of a query, we take the text and update the songs list accordingly.
  3. Whenever the user clicks on the cancel button, we empty the song list to get the default view.

With a few lines of code, we’re able to implement the complete search functionality! That’s a significant improvement!

We can add search suggestions to make it even simpler to search the next song we’re going to binge listen. SwiftUI provides use with another modifier -

func searchable<S>(text: Binding<String>, placement: SearchFieldPlacement = .automatic, suggestions: () -> S) -> some View where S : View

They provide an optional parameter suggestions where we can list the data. Based on every character that is entered, we hit the hint API of Apple Music to get the optimised result. Clicking on any suggestion searches for that particular hint in the Apple Music catalogue.

Replace the code of SearchView with the following -

struct SearchView: View {
  @StateObject private var viewModel = SearchSongsViewModel()
  @State private var searchText = ""
  // 1
  @State private var selectedHint = ""

  var body: some View {
    NavigationView {
      VStack {
        if searchText.isEmpty {
          Image("empty_search")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .padding()
        } else {
          MusicGridView(viewModel: viewModel)
        }
      }
      .navigationTitle("Search")
    }
    .navigationViewStyle(StackNavigationViewStyle())
    .searchable(text: $searchText) {
      // 2
      ForEach(viewModel.searchHints, id: \.self) { hint in
        Button(hint) {
          selectedHint = hint
          searchText = hint
          viewModel.updateSearchSongs(for: hint)
        }
      }
    }
    .onSubmit(of: .search) {
      viewModel.updateSearchSongs(for: searchText)
    }
    .onChange(of: searchText) { _ in
      updateSearchList()
    }
  }

  // 3
  private func updateSearchList() {
    if searchText.isEmpty {
      viewModel.songs = []
      viewModel.searchHints = []
    } else {
      if selectedHint != searchText {
        // 4
        viewModel.updateSearchHints(for: searchText)
      }
    }
  }
}

Here’s what’s happening -

  1. We’ve a @State that keeps track of the selected hint. This is to ensure that we don’t find more hints for the selected hint.
  2. We create a list and loop over the search hints. When a user selects a hint, we directly show the search result.
  3. When the user taps on the cancel or clear text button, we empty the songs and searchHints array.
  4. For every character typed, we fetch the hints to show.

With this addition of a few lines of code, we’ve a fully functional search! Time to find new music using hints!

Buttons

This release has a lot to do with buttons, especially that now we’ve standard bordered buttons on iOS. It is as simple as adding a buttonStyle modifier to the element.

The new modifiers are -

  • buttonStyle for styling the button
  • tint for a specific color appearance
  • controlSize for defining the standard size of a button
  • controlProminence for making them stand out

In LoginView, replace the Login button with the following -

Button(action: login) {
  Text("Log in")
    .bold()
    .frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.brand)
.controlSize(.large)
.controlProminence(.increased)
.padding([.horizontal, .bottom])

The button looks more standardised with the new modifiers.

Many More Changes

There’ve been a lot more changes that couldn’t be covered in the post. I recommend you to watch What’s New in SwiftUI.

Custom Swipe Actions SwiftUI allows us to define custom swipe actions this year, defining it like any other menu. The syntax is similar to a contextMenu, by defining actions using buttons.

For example, we want to mark favorite few songs that we liked. We can add the following code in MusicGridView to define a custom action -

MusicRowView(song: song)
  .swipeActions(edge: .leading) {
    Button {
      withAnimation {
        // Mark the song favorite
      }
    } label: {
      Label("Favorite", systemImage: "star")
    }
    .tint(.brand)
  }

Here are a few more for your appetite -

  • Rendering Markdown - Text got more powerful with Markdown formatting directly inline.
  • New sheet - A new modifier to prevent interactive dismissal of the sheet using interactiveDismissDisabled(_:). Also, there’s a new environment value dismiss that you can use to dismiss the screen.
  • Badge - You can create a badge for displaying in list rows and tab bars. For example, the number of unread messages. Use badge(_:) for achieving this functionality.
  • Accessibility Changes - Many new accessibility changes make it effortless to support accessibility in your SwiftUI app. You can watch SwiftUI Accessibility: Beyond the basics to deliver an exceptional accessibility experience.

Conclusion

With so many changes this year, it’ll be an exciting year ahead to implement them in our app and deliver a smooth experience for our users.

SwiftUI has come a long way from 2019 and iOS 13, but we still have a long way to go. For example, we still require UIKit support for building custom screens and complex apps.

But, SwiftUI is the future, indeed.

I hope you enjoyed this tutorial! Also, if you found this article helpful, let me know!