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 Preferences, ContainerValues 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