Rahul Sharma

Fix empty list row and section after adding ID

The Problem

While I was working on iPad version of my app, I faced a weird issue where after adding .id(_:) to list row, the list row becomes EmptyView() when running the app while the actual contents are missing. The fix to this issue is even more weird.

In my app, I had an array and I had a seperate section for every element of the array. I needed to scroll to a particular list row when a property changes. I used ScrollViewReader and scrollTo method for that. For the scrollTo method to scroll to an item, it identifies rows by ID. So I had to give identity to my list rows using .id(_:).

My code was something like this:

List {
    ForEach(intervals) { interval in
        Section {
            Text(interval.name)
                .id(interval.id)
        }
    }
}

Surprisingly, after giving ID to list row, all the list rows were shown as empty when running the app. Without ID, it worked fine.

Screenshot with empty list sections

Some debugging

I found that having only 1 section works, but I needed one section for every element.

Section {
    ForEach(intervals, id: \.id) { interval in
        Text(interval.name)
            .id(interval.id)
    }
}

Adding each section manually also works, but this can't be done as I had dynamic elements in my app.

Section {
    Text(intervals[0].name)
        .id(intervals[0].id)
}
Section {
    Text(intervals[1].name)
        .id(intervals[1].id)
}

So id is working fine and the problem is either with Section or ForEach.

In Debug View Hierarchy, the row content was shown as EmptyView().



After trying many things, and not finding any solution, I asked this question on StackOverflow. Someone answered to embed the text in a VStack. It worked perfectly in the sample project I made. But in my real project, it fixed the empty cells but broke the ScrollViewReader's scrollTo method.

After that, my friend Rudrank pointed out that adding a header to the section also fixes the issue. But since I don't had any header, I tried giving empty String to the text as header.

Section(header: Text(""))

But that created extra space with empty header between the sections. I tried giving EmptyView() as header, but that again made the row content EmptyView.

The Fix

The fix I found for this issue is very weird. It works, but I exactly don't know how and why it works. I did some deep dive and guesswork, which is written at the end of this article.

I created a new ViewModifier named EmbedInSection that just embeds the content in Section. And then, use .modifier() modifier below the id modifier to use the newly created view modifier.

struct EmbedInSection: ViewModifier {
    func body(content: Content) -> some View {
        Section {
            content
        }
    }
}
var body: some View {
    List {
        ForEach(intervals) { interval in
            Text(interval.name)
                .id(interval.id)
                .modifier(EmbedInSection())
        }
    }
}

How I thought of trying view modifier? I already had this view modifier in my code that I use to quickly embed list content in Section. I tried using that view modifier here, and surprisingly it worked.

Deep dive and guesswork

I did some deep dive on this issue. I used Swift's Mirror API to inspect the SwiftUI View.

I used this extension to print the type of SwiftUI view body. This code for extension is taken from Thinking in SwiftUI book by objc.io.

extension View {
    func debug() -> Self {
        print(Mirror(reflecting: self).subjectType)
        return self
    }
}

Using this debug function on normal section code without ID modifier prints the following text. It has a list, which contains a ForEach for array of type Interval, identified by a UUID, and this ForEach contains a section with 3 properties. First EmptyView is the header of section, second property Text is the content of the section and the third property EmptyView is the footer of the view.

List<Never, ForEach<Array<Interval>, UUID, Section<EmptyView, Text, EmptyView>>>

After adding id(_:) to the text, the debug function prints the following text:

List<Never, ForEach<Array<Interval>, UUID, Section<EmptyView, IDView<Text, UUID>, EmptyView>>>

The only difference is that content of the section Text is changed to IDView<Text, UUID>. The actual content of section is still Text. It is now just wrapped in a IDView that gives ID of type UUID to the text view. So why is the content shown as EmptyView when the app is ran?

When we use the fix provided in the article, i.e. using a view modifier, the view type changes. The body type of ForEach now contains ModifiedContent with EmbedInSection as a modifier. It no longer has a section in the view type.

List<Never, ForEach<Array<Interval>, UUID, ModifiedContent<IDView<Text, UUID>, EmbedInSection>>>

My guess is that, this is a bug in SwiftUI. When section inside ForEach contains IDView as the text and the header of section is empty, for some reason, it uses that EmptyView from header as the content of section. If we add a header to the section, the body type changes and it uses the correct content as the section content.

I have filed a feedback to Apple regarding this. Let's see what they say.

If you have a better explaination to the solution of this problem, please Tweet it to me @rahulrs0029

Tagged with: