Skip to content

Building a List Replacement (SwiftUI)

A SwiftUI guide for coding agents. Also covers custom list scrollview, divided card container, navigationlink chevron button style, lazy stack instead of list, sectioned scroll screen, card based settings screen.

List earns its keep on huge uniform datasets, mailboxes and to-do lists. For a screen of mixed cards, ScrollView plus a lazy stack gives full control of the look, and the container view APIs rebuild List's conveniences as small reusable primitives.

ScrollingSurface: the screen root

A thin wrapper picking LazyVStack or LazyHStack by direction, with scrollTargetLayout applied. Every screen uses it as the root.

public struct ScrollingSurface<Content: View>: View {
  public enum Direction {
    case vertical(HorizontalAlignment)
    case horizontal(VerticalAlignment)
  }

  let direction: Direction
  let spacing: CGFloat?
  let content: Content

  public var body: some View {
    switch direction {
    case .vertical(let alignment):
      ScrollView(.vertical) {
        LazyVStack(alignment: alignment, spacing: spacing) { content }
          .scrollTargetLayout()
          .padding()
      }
    case .horizontal(let alignment):
      ScrollView(.horizontal) {
        LazyHStack(alignment: alignment, spacing: spacing) { content }
          .scrollTargetLayout()
          .padding()
      }
    }
  }
}

DividedCard: grouped rows via Group(subviews:)

Group(subviews:) decomposes the caller's children, so the card can put a Divider between rows, skipping the last, then wrap everything in a rounded material background.

public struct DividedCard<Content: View>: View {
  let content: Content

  public var body: some View {
    Group(subviews: content) { subviews in
      if !subviews.isEmpty {
        VStack(alignment: .leading) {
          ForEach(subviews) { subview in
            subview
            if subviews.last?.id != subview.id {
              Divider().padding(.vertical, 8)
            }
          }
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .topLeading)
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 32))
      }
    }
  }
}

SectionedSurface and the navigation chevron

ForEach(sections:) renders each Section's header, content, and footer, skipping empty sections. The one List behavior worth recreating by hand is the NavigationLink chevron: a custom ButtonStyle adds it, with contentShape(.rect) keeping the whole row tappable.

public struct NavigationButtonStyle: ButtonStyle {
  public func makeBody(configuration: Configuration) -> some View {
    HStack {
      configuration.label.opacity(configuration.isPressed ? 0.7 : 1)
      Spacer()
      Image(systemName: "chevron.right").foregroundStyle(.tertiary)
    }
    .contentShape(.rect)
  }
}

Compose them like the List API: ScrollingSurface { SectionedSurface { ...sections... } } with .buttonStyle(.navigation) on the screen. A screen without sections just drops SectionedSurface. Remember that listRowBackground, listItemTint, and listRowInsets do nothing outside a List, which is why these primitives exist.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-building-list-replacement" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems