Improved Photo Feed With iOS 15

June 21, 2021

Use the new iOS 15 SDK to simplify PhotoFeed app.

WWDC 2021 introduced some amazing new tools for iOS developers to use. Some examples include async/await, Xcode Cloud, AsyncImage, etc…

We will be picking up from the code built in a previous blog post, iOS Photo Feed Infinite Scrolling, and improving it using iOS 15 SDK.

On top of that, we will be fixing a UX problem introduced in the last blog post due to the current image loading setup.

PhotoView Makeover

Firstly, let’s give our PhotoView.swift a little redesign.

// PhotoView.swift
struct PhotoView: some View {

  ...

  var body: some View {
    ZStack(alignment: .bottom) {
      PhotoSourceView(
        photoSource: photo.src,
        loadingColor: Color(hex: photo.avgColor)
      )
        .scaledToFit()
      HStack {
        Text(photo.photographer)
        Spacer()
      }
      .padding()
      .background(.thinMaterial)
    }
    .background(.thickMaterial)
    .mask(RoundedRectangle(cornerRadius: 8.0))
    .padding([.leading, .trailing, .bottom])
  }

  ...

}

This makes of use of something new in iOS 15 called Material to transform the entire card.

Old                                           New                                          
old new

AsyncImage

AsyncImage is an amazing new SwiftUI component that throws away all the boilerplate image loading logic we made previously.

It’s as simple as replacing our PhotoSourceView with AsyncImage:

// PhotoView.swift
...

  var body: some View {
    ZStack(alignment: .bottom) {
      AsyncImage(url: URL(string: photo.src.medium)) { image in
        image
          .resizable()
          .aspectRatio(contentMode: .fit)
      } placeholder: {
        Rectangle()
          .fill(Color(hex: photo.avgColor))
      }
      ...
    }
  }

...

What this code does, is AsyncImage will take an image URL, and the resulting image will be found in the closure content: (Image) -> I. While it loads, we can put a placeholder inside placeholder: () -> P.

Pretty neat! Now we can throw away a bunch of code we built previously that was used to create a loading image custom view:

  • ImageController
  • PhotoSourceViewModel
  • PhotoSourceView

That’s an entire 67 lines of code removed!

Make Infinite Scrolling Silky Smooth

In the previous blog post about building an infinite scrolling feed, we mentioned that scrolling “looks a little janky right now because of the issues with image loading”.

The reason is because the height of a PhotoView is different before and after an image is loaded. Every time an image finishes loading, the PhotoView increases in height and pushes the entire ScrollView’s offset.

There are 2 approaches to fix this:

  1. Fix the height of PhotoView.
  2. Calculate the expected final height of PhotoView before the image is loaded.

The first solution is the easy one, and probably the better way to do it. But #2 provides better UX since there is less image resizing involved.

GeometryReader

GeometryReader is a container view that is able to provide information about its content’s size. We can leverage this to find the width of each card.

From the Pexel response, we are given an image’s original height and width.

In summary, we have the original image height, the original image width, and the card width. Using these, we can easily calculate the card height.

We can declare a new @State variable called imageWidth, and make use of iOS 15’s View.task(_:) to update the imageWidth.

// PhotoView.swift
struct PhotoView: View {

  @State private var imageWidth = CGFloat()
  private var imageHeight: CGFloat {
    return imageWidth * CGFloat(photo.height) / CGFloat(photo.width)
  }
  private var photo: Photo
  
  var body: some View {
    ZStack(alignment: .bottom) {
      AsyncImage(url: URL(string: photo.src.medium)) { image in
        image
          .resizable()
          .aspectRatio(contentMode: .fit)
      } placeholder: {
        Rectangle()
          .fill(Color(hex: photo.avgColor))
          .frame(width: imageWidth, height: imageHeight)
      }
      HStack {
        Text(photo.photographer)
        Spacer()
      }
      .padding()
      .background(.thinMaterial)
    }
    .overlay(GeometryReader { geometry in
      // This is used to calculate the estimated image size after all the padding
      // for sizing thte loading frame.
      Rectangle()
        .hidden()
        .task {
          self.imageWidth = geometry.size.width
        }
    })
    .background(.thickMaterial)
    .mask(RoundedRectangle(cornerRadius: 8.0))
    .padding([.leading, .trailing, .bottom])
  }

  ...

}

This is slightly hacky. Reason is, we’re creating an invisible overlay on the ZStack card to read the card width. Generally, doing such calculations is non-ideal, and we should purely rely on SwiftUI’s layout magic. Good thing is, the infinite scrolling is as smooth as butter!

Conclusion

iOS 15 has really awesome improvements to iOS development! We have improvements to SwiftUI drawing and animation to make pretty UI simpler, AsyncImage to reduce all the image loading boilerplate, and using View.task(_:) for to do a bunch of tasks instead of using View.onAppear(). To see the full code in action, visit GitHub.

Let's work together

Find out how we can help you grow.