SwiftUI Container API

For my personal RSS reader app I wanted to match the look and feel of Reeder Classic. This turned out to be not a trivial task as the customization possibilities especially for the List component are quite restricted. Fortunately Apple did provide us with some API to mitigate this, in the form of custom SwiftUI containers.

Previously, the only option available was to use collections of a specific data type and a @ViewBuilder closure. Now with the latest additions, SwiftUI offers new initializers for ForEach and Group that provide greater flexibility:

extension ForEach {
    public init<V>(subviews view: V, @ViewBuilder content: @escaping (Subview) -> Content) where Data == ForEachSubviewCollection<Content>, ID == Subview.ID, Content : View, V : View
}

extension ForEach {
    public init<V>(sections view: V, @ViewBuilder content: @escaping (SectionConfiguration) -> Content) where Data == ForEachSectionCollection<Content>, ID == SectionConfiguration.ID, Content : View, V : View
}

extension Group {
    public init<V>(subviews view: V, @ViewBuilder transform: @escaping (SubviewsCollection) -> Result) where Content == GroupElementsOfContent<Base, Result>, Base : View, Result : View
}

extension Group {
    public init<Base, Result>(sections view: Base, @ViewBuilder transform: @escaping (SectionCollection) -> Result) where Content == GroupSectionsOfContent<Base, Result>, Base : View, Result : View
}

So lets create our custom List component around these new APIs:

struct DSList<Content: View>: View {
    @Environment(\.theme) private var theme
    private let content: Content

    init(
        @ViewBuilder content: () -> Content
    ) {
        self.content = content()
    }

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: theme.tokens.spacing.l) {
                ForEach(sections: content) { section in
                    ThemedSection {
                        section.content
                    } header: {
                        section.header
                    } footer: {
                        section.footer
                    }
                }
            }
            .padding(.horizontal, theme.tokens.spacing.m)
            .padding(.vertical, theme.tokens.spacing.m)
        }
        .scrollBounceBehavior(.always)
        .background(.bg.primary, ignoresSafeAreaEdges: .all)
    }
}

Here you can see the new ForEach(sections:) API in practice. It allows us to extract and manage subviews grouped by Section.

Lets zoom into the implementation of ThemedSection to understand the whole layout:

struct ThemedSection<Content: View, Header: View, Footer: View>: View {
    @Environment(\.theme) private var theme
    @State private var cardStyle: DSSectionCardStyle = .card

    private let content: Content
    private let header: Header
    private let footer: Footer

    init(
        @ViewBuilder content: () -> Content,
        @ViewBuilder header: () -> Header,
        @ViewBuilder footer: () -> Footer
    ) {
        self.content = content()
        self.header = header()
        self.footer = footer()
    }

    var body: some View {
        VStack(alignment: .leading, spacing: theme.tokens.spacing.s) {
            header
                .font(theme.tokens.font.text.headline)
                .foregroundStyle(.text.muted)
                .textCase(.uppercase)

            cardContent

            footer
                .font(theme.tokens.font.text.description)
                .foregroundStyle(.text.muted)
        }
        .onPreferenceChange(DSSectionCardStylePreference.self) { style in
            cardStyle = style
        }
    }
    
    @ViewBuilder
    private var cardContent: some View {
        switch cardStyle {
        case .card:
            ThemedCardContent(content: content)
                .background(.bg.surface)
                .clipShape(
                    RoundedRectangle(
                        cornerRadius: theme.tokens.radius.m,
                        style: .continuous
                    )
                )
        case .transparent:
            ThemedCardContent(content: content)
        }
    }
}

extension ThemedSection where Header == EmptyView, Footer == EmptyView {
    init(@ViewBuilder content: () -> Content) {
        self.init(content: content, header: { EmptyView() }, footer: { EmptyView() })
    }
}

extension ThemedSection where Footer == EmptyView {
    init(
        @ViewBuilder content: () -> Content,
        @ViewBuilder header: () -> Header
    ) {
        self.init(content: content, header: header, footer: { EmptyView() })
    }
}

extension ThemedSection where Header == EmptyView {
    init(
        @ViewBuilder content: () -> Content,
        @ViewBuilder footer: () -> Footer
    ) {
        self.init(content: content, header: { EmptyView() }, footer: footer)
    }
}
struct ThemedCardContent<Content: View>: View {
    @Environment(\.theme) private var theme
    let content: Content

    var body: some View {
        Group(subviews: content) { subviews in
            let rows = Array(subviews)
            VStack(spacing: 0) {
                ForEach(0..<rows.count, id: \.self) { index in
                    subviews[index]

                    let isLast = index == subviews.count - 1
                    let suppressed = subviews[index]
                        .containerValues
                        .dsSuppressTrailingDivider

                    if !isLast && !suppressed {
                        Divider()
                            .foregroundStyle(.border.subtle)
                            .padding(.leading, theme.tokens.spacing.m)
                    }
                }
            }
        }
    }
}

Again we make use of the new API this time Group(subviews:). With this new API we can iterate over each subview inside a given section. You might have noticed another new addition in this implementation: Container Values.

SwiftUI introduces ContainerValues, a powerful tool for creating container-specific modifiers. Unlike EnvironmentValues or PreferencesContainerValues are accessible only by the direct container, making them ideal for container-specific customizations.

By default our new custom list component will draw a separator for every row unless its the last row. However in my implementation I want the possibility to suppress the drawing of the Divider and I can do so by leveraging the new Container Values API:

extension ContainerValues {
    @Entry var dsSuppressTrailingDivider: Bool = false
}

struct DSListItem: View {
	var body: some View {
		Button {
			// ...
		}
		.containerValue(\.dsSuppressTrailingDivider, true)
        .buttonStyle(.listItem)
        .dsSectionCardStyle(.transparent)
	}
}

We now have a custom list component which renders a list using ScrollView and LazyVStack with arbitrary content. We can customize the look and feel however we like. We also can set specific behaviour through Container Values and the API looks very "SwiftUI-y":

DSList {
    Section {
        DSListItem(action: {}) {
            HStack {
                Text("Unread")
                    .font(.headline)
                Spacer()
                Text("20")
                    .font(theme.tokens.font.text.default)
                    .foregroundStyle(.secondary)
            }
            .font(theme.tokens.font.button)
            .foregroundStyle(.text.primary, .text.secondary)
        }
    } header: {
        Text("Folders")
    }
    
    // ...
}

Do you also already worked with this rather new API? What do you think is missing? Let me know.

If you are curious how this component looks in action head over to my article Building an iOS RSS reader which provides a short demo video showcasing it.

Posted in swift