Dunfey · Hotel WWDC as data, est. 1983
Front desk everything
Years
Topics

2023 SwiftUI & UI Frameworks

WWDC23 · 15 min · SwiftUI & UI Frameworks

Beyond scroll views

Find out how you can take your scroll views to the next level with the latest APIs in SwiftUI. We’ll show you how to customize scroll views like never before. Explore the relationship between safe areas and a scroll view’s margins, learn how to interact with the content offset of a scroll view, and discover how you can add a bit of flair to your content with scroll transitions.

Watch at developer.apple.com ↗

Transcript all transcripts

Chapters

  • 0:00 — Introduction to scroll views
  • 2:01 — Margins and safe area
  • 4:14 — Targets and positions
  • 11:33 — Scroll transitions

Code shown on screen · 6 snippets

ScrollView swift · at 0:46 ↗
struct Item: Identifiable {
    var id: Int
}

struct ContentView: View {
    @State var items: [Item] = (0 ..< 25).map { Item(id: $0) }

    var body: some View {
        ScrollView(.vertical) {
            LazyVStack {
                ForEach(items) { item in
                    ItemView(item: item)
                }
            }
        }
    }
}

struct ItemView: View {
    var item: Item

    var body: some View {
        Text(item, format: .number)
            .padding(.vertical)
            .frame(maxWidth: .infinity)
    }
}
Basic Featured Section swift · at 2:29 ↗
struct ContentView: View {
    @State var palettes: [Palette] = [
        .init(id: UUID(), name: "Example One"),
        .init(id: UUID(), name: "Example Two"),
        .init(id: UUID(), name: "Example Three"),
    ]

    var body: some View {
        ScrollView {
            GalleryHeroSection(palettes: palettes)
        }
    }
}

struct Palette: Identifiable {
    var id: UUID
    var name: String
}

struct GalleryHeroSection: View {
    var palettes: [Palette]

    var body: some View {
        GallerySection(edge: .top) {
            GalleryHeroContent(palettes: palettes)
        } label: {
            GalleryHeroHeader(palettes: palettes)
        }
    }
}

struct GallerySection<Content: View, Label: View>: View {
    var edge: Edge? = nil
    @ViewBuilder var content: Content
    @ViewBuilder var label: Label

    var body: some View {
        VStack(alignment: .leading) {
            label
                .font(.title2.bold())
            content
        }
        .padding(.top, halfSpacing)
        .padding(.bottom, sectionSpacing)
        .overlay(alignment: .bottom) {
            if edge != .bottom {
                Divider().padding(.horizontal, hMargin)
            }
        }
    }

    var halfSpacing: CGFloat {
        sectionSpacing / 2.0
    }

    var sectionSpacing: CGFloat {
        20.0
    }

    var hMargin: CGFloat {
        #if os(macOS)
        40.0
        #else
        20.0
        #endif
    }
}

struct GalleryHeroContent: View {
    var palettes: [Palette]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: hSpacing) {
                ForEach(palettes) { palette in
                    GalleryHeroView(palette: palette)
                }
            }
        }
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroView: View {
    var palette: Palette

    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        colorStack
            .aspectRatio(heroRatio, contentMode: .fit)
            .containerRelativeFrame(
                [.horizontal], count: columns, spacing: hSpacing
            )
            .clipShape(.rect(cornerRadius: 20.0))
    }

    private var columns: Int {
        sizeClass == .compact ? 1 : regularCount
    }

    @ViewBuilder
    private var colorStack: some View {
        let offsetValue = stackPadding
        ZStack {
            Color.red
                .offset(x: offsetValue, y: offsetValue)
            Color.blue
            Color.green
                .offset(x: -offsetValue, y: -offsetValue)
        }
        .padding(stackPadding)
        .background()
    }

    var stackPadding: CGFloat {
        20.0
    }

    var heroRatio: CGFloat {
        16.0 / 9.0
    }

    var regularCount: Int {
        2
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroHeader: View {
    var palettes: [Palette]

    var body: some View {
        Text("Featured")
            .padding(.horizontal, hMargin)
    }

    var hMargin: CGFloat {
        20.0
    }
}
Featured Section with Margins swift · at 4:00 ↗
struct ContentView: View {
    @State var palettes: [Palette] = [
        .init(id: UUID(), name: "Example One"),
        .init(id: UUID(), name: "Example Two"),
        .init(id: UUID(), name: "Example Three"),
    ]

    var body: some View {
        ScrollView {
            GalleryHeroSection(palettes: palettes)
        }
    }
}

struct Palette: Identifiable {
    var id: UUID
    var name: String
}

struct GalleryHeroSection: View {
    var palettes: [Palette]

    var body: some View {
        GallerySection(edge: .top) {
            GalleryHeroContent(palettes: palettes)
        } label: {
            GalleryHeroHeader(palettes: palettes)
        }
    }
}

struct GallerySection<Content: View, Label: View>: View {
    var edge: Edge? = nil
    @ViewBuilder var content: Content
    @ViewBuilder var label: Label

    var body: some View {
        VStack(alignment: .leading) {
            label
                .font(.title2.bold())
            content
        }
        .padding(.top, halfSpacing)
        .padding(.bottom, sectionSpacing)
        .overlay(alignment: .bottom) {
            if edge != .bottom {
                Divider().padding(.horizontal, hMargin)
            }
        }
    }

    var halfSpacing: CGFloat {
        sectionSpacing / 2.0
    }

    var sectionSpacing: CGFloat {
        20.0
    }

    var hMargin: CGFloat {
        #if os(macOS)
        40.0
        #else
        20.0
        #endif
    }
}

struct GalleryHeroContent: View {
    var palettes: [Palette]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: hSpacing) {
                ForEach(palettes) { palette in
                    GalleryHeroView(palette: palette)
                }
            }
        }
        .contentMargins(.horizontal, hMargin)
    }

    var hMargin: CGFloat {
        20.0
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroView: View {
    var palette: Palette

    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        colorStack
            .aspectRatio(heroRatio, contentMode: .fit)
            .containerRelativeFrame(
                [.horizontal], count: columns, spacing: hSpacing
            )
            .clipShape(.rect(cornerRadius: 20.0))
    }

    private var columns: Int {
        sizeClass == .compact ? 1 : regularCount
    }

    @ViewBuilder
    private var colorStack: some View {
        let offsetValue = stackPadding
        ZStack {
            Color.red
                .offset(x: offsetValue, y: offsetValue)
            Color.blue
            Color.green
                .offset(x: -offsetValue, y: -offsetValue)
        }
        .padding(stackPadding)
        .background()
    }

    var stackPadding: CGFloat {
        20.0
    }

    var heroRatio: CGFloat {
        16.0 / 9.0
    }

    var regularCount: Int {
        2
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroHeader: View {
    var palettes: [Palette]

    var body: some View {
        Text("Featured")
            .padding(.horizontal, hMargin)
    }

    var hMargin: CGFloat {
        20.0
    }
}
Featured Section + Container Relative Frame swift · at 7:42 ↗
struct ContentView: View {
    @State var palettes: [Palette] = [
        .init(id: UUID(), name: "Example One"),
        .init(id: UUID(), name: "Example Two"),
        .init(id: UUID(), name: "Example Three"),
    ]

    var body: some View {
        ScrollView {
            GalleryHeroSection(palettes: palettes)
        }
    }
}

struct Palette: Identifiable {
    var id: UUID
    var name: String
}

struct GalleryHeroSection: View {
    var palettes: [Palette]

    var body: some View {
        GallerySection(edge: .top) {
            GalleryHeroContent(palettes: palettes)
        } label: {
            GalleryHeroHeader(palettes: palettes)
        }
    }
}

struct GallerySection<Content: View, Label: View>: View {
    var edge: Edge? = nil
    @ViewBuilder var content: Content
    @ViewBuilder var label: Label

    var body: some View {
        VStack(alignment: .leading) {
            label
                .font(.title2.bold())
            content
        }
        .padding(.top, halfSpacing)
        .padding(.bottom, sectionSpacing)
        .overlay(alignment: .bottom) {
            if edge != .bottom {
                Divider().padding(.horizontal, hMargin)
            }
        }
    }

    var halfSpacing: CGFloat {
        sectionSpacing / 2.0
    }

    var sectionSpacing: CGFloat {
        20.0
    }

    var hMargin: CGFloat {
        #if os(macOS)
        40.0
        #else
        20.0
        #endif
    }
}

struct GalleryHeroContent: View {
    var palettes: [Palette]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: hSpacing) {
                ForEach(palettes) { palette in
                    GalleryHeroView(palette: palette)
                }
            }
            .scrollTargetLayout()
        }
        .contentMargins(.horizontal, hMargin)
        .scrollTargetBehavior(.viewAligned)
    }

    var hMargin: CGFloat {
        20.0
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroView: View {
    var palette: Palette

    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        colorStack
            .aspectRatio(heroRatio, contentMode: .fit)
            .containerRelativeFrame(
                [.horizontal], count: columns, spacing: hSpacing
            )
            .clipShape(.rect(cornerRadius: 20.0))
    }

    private var columns: Int {
        sizeClass == .compact ? 1 : regularCount
    }

    @ViewBuilder
    private var colorStack: some View {
        let offsetValue = stackPadding
        ZStack {
            Color.red
                .offset(x: offsetValue, y: offsetValue)
            Color.blue
            Color.green
                .offset(x: -offsetValue, y: -offsetValue)
        }
        .padding(stackPadding)
        .background()
    }

    var stackPadding: CGFloat {
        20.0
    }

    var heroRatio: CGFloat {
        16.0 / 9.0
    }

    var regularCount: Int {
        2
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroHeader: View {
    var palettes: [Palette]

    var body: some View {
        Text("Featured")
            .padding(.horizontal, hMargin)
    }

    var hMargin: CGFloat {
        20.0
    }
}
Featured Section + Scroll Position swift · at 9:46 ↗
struct ContentView: View {
    @State var palettes: [Palette] = [
        .init(id: UUID(), name: "Example One"),
        .init(id: UUID(), name: "Example Two"),
        .init(id: UUID(), name: "Example Three"),
    ]

    var body: some View {
        ScrollView {
            GalleryHeroSection(palettes: palettes)
        }
    }
}

struct Palette: Identifiable {
    var id: UUID
    var name: String
}

struct GalleryHeroSection: View {
    var palettes: [Palette]
    @State var mainID: Palette.ID? = nil

    var body: some View {
        GallerySection(edge: .top) {
            GalleryHeroContent(palettes: palettes, mainID: $mainID)
        } label: {
            GalleryHeroHeader(palettes: palettes, mainID: $mainID)
        }
    }
}

struct GallerySection<Content: View, Label: View>: View {
    var edge: Edge? = nil
    @ViewBuilder var content: Content
    @ViewBuilder var label: Label

    var body: some View {
        VStack(alignment: .leading) {
            label
                .font(.title2.bold())
            content
        }
        .padding(.top, halfSpacing)
        .padding(.bottom, sectionSpacing)
        .overlay(alignment: .bottom) {
            if edge != .bottom {
                Divider().padding(.horizontal, hMargin)
            }
        }
    }

    var halfSpacing: CGFloat {
        sectionSpacing / 2.0
    }

    var sectionSpacing: CGFloat {
        20.0
    }

    var hMargin: CGFloat {
        #if os(macOS)
        40.0
        #else
        20.0
        #endif
    }
}

struct GalleryHeroContent: View {
    var palettes: [Palette]
    @Binding var mainID: Palette.ID?

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: hSpacing) {
                ForEach(palettes) { palette in
                    GalleryHeroView(palette: palette)
                }
            }
            .scrollTargetLayout()
        }
        .contentMargins(.horizontal, hMargin)
        .scrollTargetBehavior(.viewAligned)
        .scrollPosition(id: $mainID)
        .scrollIndicators(.never)
    }

    var hMargin: CGFloat {
        20.0
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroView: View {
    var palette: Palette

    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        colorStack
            .aspectRatio(heroRatio, contentMode: .fit)
            .containerRelativeFrame(
                [.horizontal], count: columns, spacing: hSpacing
            )
            .clipShape(.rect(cornerRadius: 20.0))
    }

    private var columns: Int {
        sizeClass == .compact ? 1 : regularCount
    }

    @ViewBuilder
    private var colorStack: some View {
        let offsetValue = stackPadding
        ZStack {
            Color.red
                .offset(x: offsetValue, y: offsetValue)
            Color.blue
            Color.green
                .offset(x: -offsetValue, y: -offsetValue)
        }
        .padding(stackPadding)
        .background()
    }

    var stackPadding: CGFloat {
        20.0
    }

    var heroRatio: CGFloat {
        16.0 / 9.0
    }

    var regularCount: Int {
        2
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroHeader: View {
    var palettes: [Palette]
    @Binding var mainID: Palette.ID?

    var body: some View {
        VStack(alignment: .leading, spacing: 2.0) {
            Text("Featured")
            Spacer().frame(maxWidth: .infinity)
        }
        .padding(.horizontal, hMargin)
        #if os(macOS)
        .overlay {
            HStack(spacing: 0.0) {
                GalleryPaddle(edge: .leading) {
                    scrollToPreviousID()
                }
                Spacer().frame(maxWidth: .infinity)
                GalleryPaddle(edge: .trailing) {
                    scrollToNextID()
                }
            }
        }
        #endif
    }

    private func scrollToNextID() {
        guard let id = mainID, id != palettes.last?.id,
              let index = palettes.firstIndex(where: { $0.id == id })
        else { return }

        withAnimation {
            mainID = palettes[index + 1].id
        }
    }

    private func scrollToPreviousID() {
        guard let id = mainID, id != palettes.first?.id,
              let index = palettes.firstIndex(where: { $0.id == id })
        else { return }

        withAnimation {
            mainID = palettes[index - 1].id
        }
    }

    var hMargin: CGFloat {
        20.0
    }
}

struct GalleryPaddle: View {
    var edge: HorizontalEdge
    var action: () -> Void

    var body: some View {
        Button {
            action()
        } label: {
            Label(labelText, systemImage: labelIcon)
        }
        .buttonStyle(.paddle)
        .font(nil)
    }

    var labelText: String {
        switch edge {
        case .leading:
            return "Backwards"
        case .trailing:
            return "Forwards"
        }
    }

    var labelIcon: String {
        switch edge {
        case .leading:
            return "chevron.backward"
        case .trailing:
            return "chevron.forward"
        }
    }
}

private struct PaddleButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .imageScale(.large)
            .labelStyle(.iconOnly)
    }
}

extension ButtonStyle where Self == PaddleButtonStyle {
    static var paddle: Self { .init() }
}
Featured Section + Scroll Transition swift · at 12:34 ↗
struct ContentView: View {
    @State var palettes: [Palette] = [
        .init(id: UUID(), name: "Example One"),
        .init(id: UUID(), name: "Example Two"),
        .init(id: UUID(), name: "Example Three"),
    ]

    var body: some View {
        ScrollView {
            GalleryHeroSection(palettes: palettes)
        }
    }
}

struct Palette: Identifiable {
    var id: UUID
    var name: String
}

struct GalleryHeroSection: View {
    var palettes: [Palette]
    @State var mainID: Palette.ID? = nil

    var body: some View {
        GallerySection(edge: .top) {
            GalleryHeroContent(palettes: palettes, mainID: $mainID)
        } label: {
            GalleryHeroHeader(palettes: palettes, mainID: $mainID)
        }
    }
}

struct GallerySection<Content: View, Label: View>: View {
    var edge: Edge? = nil
    @ViewBuilder var content: Content
    @ViewBuilder var label: Label

    var body: some View {
        VStack(alignment: .leading) {
            label
                .font(.title2.bold())
            content
        }
        .padding(.top, halfSpacing)
        .padding(.bottom, sectionSpacing)
        .overlay(alignment: .bottom) {
            if edge != .bottom {
                Divider().padding(.horizontal, hMargin)
            }
        }
    }

    var halfSpacing: CGFloat {
        sectionSpacing / 2.0
    }

    var sectionSpacing: CGFloat {
        20.0
    }

    var hMargin: CGFloat {
        #if os(macOS)
        40.0
        #else
        20.0
        #endif
    }
}

struct GalleryHeroContent: View {
    var palettes: [Palette]
    @Binding var mainID: Palette.ID?

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: hSpacing) {
                ForEach(palettes) { palette in
                    GalleryHeroView(palette: palette)
                }
            }
            .scrollTargetLayout()
        }
        .contentMargins(.horizontal, hMargin)
        .scrollTargetBehavior(.viewAligned)
        .scrollPosition(id: $mainID)
        .scrollIndicators(.never)
    }

    var hMargin: CGFloat {
        20.0
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroView: View {
    var palette: Palette

    @Environment(\.horizontalSizeClass) private var sizeClass

    var body: some View {
        colorStack
            .aspectRatio(heroRatio, contentMode: .fit)
            .containerRelativeFrame(
                [.horizontal], count: columns, spacing: hSpacing
            )
            .clipShape(.rect(cornerRadius: 20.0))
            .scrollTransition(axis: .horizontal) { content, phase in
                content
                    .scaleEffect(
                        x: phase.isIdentity ? 1.0 : 0.80,
                        y: phase.isIdentity ? 1.0 : 0.80)
            }
    }

    private var columns: Int {
        sizeClass == .compact ? 1 : regularCount
    }

    @ViewBuilder
    private var colorStack: some View {
        let offsetValue = stackPadding
        ZStack {
            Color.red
                .offset(x: offsetValue, y: offsetValue)
            Color.blue
            Color.green
                .offset(x: -offsetValue, y: -offsetValue)
        }
        .padding(stackPadding)
        .background()
    }

    var stackPadding: CGFloat {
        20.0
    }

    var heroRatio: CGFloat {
        16.0 / 9.0
    }

    var regularCount: Int {
        2
    }

    var hSpacing: CGFloat {
        10.0
    }
}

struct GalleryHeroHeader: View {
    var palettes: [Palette]
    @Binding var mainID: Palette.ID?

    var body: some View {
        VStack(alignment: .leading, spacing: 2.0) {
            Text("Featured")
            Spacer().frame(maxWidth: .infinity)
        }
        .padding(.horizontal, hMargin)
        #if os(macOS)
        .overlay {
            HStack(spacing: 0.0) {
                GalleryPaddle(edge: .leading) {
                    scrollToPreviousID()
                }
                Spacer().frame(maxWidth: .infinity)
                GalleryPaddle(edge: .trailing) {
                    scrollToNextID()
                }
            }
        }
        #endif
    }

    private func scrollToNextID() {
        guard let id = mainID, id != palettes.last?.id,
              let index = palettes.firstIndex(where: { $0.id == id })
        else { return }

        withAnimation {
            mainID = palettes[index + 1].id
        }
    }

    private func scrollToPreviousID() {
        guard let id = mainID, id != palettes.first?.id,
              let index = palettes.firstIndex(where: { $0.id == id })
        else { return }

        withAnimation {
            mainID = palettes[index - 1].id
        }
    }

    var hMargin: CGFloat {
        20.0
    }
}

struct GalleryPaddle: View {
    var edge: HorizontalEdge
    var action: () -> Void

    var body: some View {
        Button {
            action()
        } label: {
            Label(labelText, systemImage: labelIcon)
        }
        .buttonStyle(.paddle)
        .font(nil)
    }

    var labelText: String {
        switch edge {
        case .leading:
            return "Backwards"
        case .trailing:
            return "Forwards"
        }
    }

    var labelIcon: String {
        switch edge {
        case .leading:
            return "chevron.backward"
        case .trailing:
            return "chevron.forward"
        }
    }
}

private struct PaddleButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .imageScale(.large)
            .labelStyle(.iconOnly)
    }
}

extension ButtonStyle where Self == PaddleButtonStyle {
    static var paddle: Self { .init() }
}