SECRET OF CSS

SwiftUI Lists Are Broken And Can’t Be Fixed | by Michael Long | Sep, 2022


How an age-old problem has surfaced once more.

0*ufQUvo5dkTola6p2
Photo by Chris Lawton on Unsplash

So over the weekend, I was working on the SwiftUI demo app for RequestBuilder and I ran into a problem.

RequestBuilder, as the name might indicate, uses the Builder design pattern to build and execute URLRequests for Combine and Async/Await. The demo app uses it to fetch a list of users from an API, along with their thumbnails for presentation in a SwiftUI list.

In the application, the thumbnails are fetched from a dedicated service that caches the images for later use. So far, so good.

The app had worked fine before, but now when I ran it and scrolled down I began to see users in the list that were only showing the placeholder image, and not their actual thumbnail.

Needless to say, I was not pleased, and I spent the next couple of hours trying to figure out what was wrong with my library.

Only to discover that it wasn’t my library at all.

The culprit seemed to be SwiftUI itself.

As you might be able to deduce from the screenshot showing the new “Dynamic Island”, I was running iOS 16 on an iPhone 14 simulator using Xcode 14.0.1, a new version of Xcode I’d just installed.

Hmmm.

I’d just run the project the day before, but had done so using Xcode 13.3.1 and with a simulator running iOS 15.6… and SwiftUI 3.

Here’s the code showing the card view in the list.

struct MainListCardView: View {  let user: User  @State private var photo: UIImage?  private let images = Container.userImageCache()  var body: some View {
let _ = Self._printChanges()
HStack(spacing: 12) {
ZStack {
if let image = photo {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image("User-Unknown")
.resizable()
.aspectRatio(contentMode: .fit)
.onReceive(images.thumbnail(forUser: user)) {
photo = $0
}
}
}
.frame(width: 50, height: 50)
.clipShape(Circle())
...

Container.userImageCache is a service provided by Factory, my dependency injection library. The suspicious code is in the onReceive handler, where we ask the service for a thumbnail and it will either return a cached image or fetch one from the API.

When it receives an image from the service it places it into a State object, which changes the state and triggers a refresh, which displays the new image. Simple and straightforward… but it was no longer working.

Let’s work the problem people

I added some debugging code to the handler.

.onReceive(images.requestThumbnail(forUser: user)) {
print("onReceive \(user.picture?.thumbnail)")
photo = $0
}

And added some information to the list display code itself so I could see what image fetch requests were failing

1*X8gVtAdb1F1 9LPBS7MjTg

Here’s a sample of the log.

MainListCardView: _photo changed.
MainListCardView: @self, @identity, _photo changed.
REQ: https://randomuser.me/api/portraits/thumb/men/6.jpg
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
200: https://randomuser.me/api/portraits/thumb/men/6.jpg
onReceive "https://randomuser.me/api/portraits/thumb/men/6.jpg"
MainListCardView: _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.
MainListCardView: @self, @identity, _photo changed.

As you can see, after a while the onReceive handler itself was simply not being called.

I tried moving the location of onReceive handler to different locations in the view just to see what would happen. Same result.

I tried changing the code to give each subview its own view model and switched to calling the view model fromonAppear. Still no joy.

I thought about it for a few minutes, then switched to running the project under Xcode 14.1 beta 2 and iOS 16.1… and it worked! Mostly. Still a few glitches in updating the interface, but overall it was much better.

I then ran the 14.1 version of the code back on the iOS 16.0 simulator and it failed again, as it also did when I ran it directly on my 14 Pro Max.

I then tried another experiment, going from a basic List loop like this…

var body: some View {
List {
ForEach(users) { user in
NavigationLink(destination: DetailsView(user: user)) {
MainListCardView(user: user)
}
}
.navigationTitle("RequestBuilder")
}
}

To using a LazyVStack and emulating a list.

var body: some View {
ScrollView {
LazyVStack {
ForEach(users) { user in
NavigationLink(destination: DetailsView(user: user)) {
HStack {
MainListCardView(user: user)
.accentColor(Color(UIColor.label))
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(16)
.padding(16)
.navigationTitle("RequestBuilder")
}
.background(Color(.systemGroupedBackground))
}

Which gave the following result.

1*aU0dUKz2sqPceBWYOw8aJA

The same exact views used from within a LazyVStack and ScrollView worked. Within a List, however, they did not.

What’s seems to be apparent from all of this is that there’s a bug in the iOS 16.0 implementation of SwiftUI where theonReceive and onAppear handlers for list elements are not always being called.

It’s probably related to the fact that, under iOS 16, List views are no longer backed by table views but by collection views. And in changing the implementation Apple’s engineers introduced a few bugs in the process which seem to have been corrected in iOS 16.1.

Regardless, the end result is that if your application was dependent on that behavior then it will no longer work correctly under iOS 16 and there’s little to nothing you can do about it (short of re-releasing your app with said list views replaced with LazyVStacks).

In an ideal world, you should just be able to go into your Xcode project settings and link your application to a known version of the SwiftUI library and be insulated from such changes like we do with CocoaPods or the SPM… but that’s not how it works.

As I’m sure you’re well aware, each and every version of SwiftUI is explicitly tied to a specific version of iOS. SwiftUI 1.0 to iOS 13, SwiftUI 2.0 to iOS 14, and so on.

Sure, Apple will eventually release iOS 16.1 and that will probably fix the problem — for iOS 16.1 users. But any device not updated and still running iOS 16.0 will be broken. Forever.

I’ve written about this repeatedly, and about how Jetpack Compose on Android does things differently. There you can link your application with the most recent version of the library and use almost all of those features in that library… regardless of how old that device is and what version of Android is running.

But on iOS? Nope. Can’t do that. Want to use some really cool, highly advanced SwiftUI feature like pull-to-refresh?

No problem. Just make sure your entire user base is on iOS 15.

It’s… frustrating.

And completely unnecessary.

Note that the List change from being backed by aUITableView can also cause issues if you were attempting to style that table view using UITableView.appearance modifiers in order to do some other super-advanced features like changing the list background color.

We did that in iOS 13 to work around SwiftUI’s limitations and it worked… until iOS 14 came out and broke the ability to do that. We did get it back again in iOS 15, so great!

Only to lose it again in iOS 16.

But don’t worry! After all of these years, SwiftUI 4 has your back. Just use the background modifier coupled with the new scrollContentBackground modifier…

Which is only available on iOS 16 and better and, of course, isn’t back-ported to earlier versions of iOS and SwiftUI.

But I’m sure you can figure out a way to use the new feature as well as maintaining all of that old code for backwards compatibility. That’s not messy or inconvenient at all.

Right?

Don’t get me wrong. I love SwiftUI and I truly think it’s the future of the iOS platform. And macOS. And elsewhere.

I’d love to use it on all of my platforms, on all of my projects. But as long as highly advanced features like tabbing and field focus or refreshable are restricted to the most current version of the OS… then we have problems.

Yes. We can shim and fall back to UIKit and write our own navigation stacks and text views and use UIViewControllerRepresentables and write UIHostingControllers

But all of that should not be necessary.

Let me repeat that.

All of that should not be necessary.

Hey. Apple!

Android got it right.

Maybe we should think about doing the same.

That’s it for today. As always leave comments and questions below and hit the like button if you want to see more.



News Credit

%d bloggers like this: