WWDC Quick Look 💓 By SwiftGGTeam
Beyond scroll views

Beyond scroll views

Watch original video

Highlight

SwiftUI introduces five new APIs for ScrollView in iOS 17:safeAreaPaddingandcontentMarginsPrecisely control margins,scrollTargetBehaviorCustom scroll alignment,containerRelativeFrameContainer-based size calculation,scrollPositionProgrammatic scrolling control that replaces ScrollViewReader, andscrollTransitionVisual transition effects based on scroll position.

Core Content

Basics of ScrollView

Harry started from the SwiftUI team and first reviewed the basic concepts of ScrollView:

  • ScrollView has axes to define the scroll direction
  • When the content exceeds the size of the ScrollView, it will be cropped and the user needs to scroll to view it.
  • ScrollView parses the safe area into the margins of content
  • By default ScrollView will calculate all contents immediately, you can use lazy stack to change this behavior
  • The precise position of the content in ScrollView is called content offset

Safe Area and Content Margins

(02:29) When adding margins to ScrollView, use directly.padding()will cause the content to be cropped. The correct approach is to use.safeAreaPadding()

ScrollView(.horizontal) {
    LazyHStack(spacing: hSpacing) {
        ForEach(palettes) { palette in
            GalleryHeroView(palette: palette)
        }
    }
}
.safeAreaPadding(.horizontal, hMargin)

Key points:

  • .safeAreaPadding()Add padding to the safe area and make the ScrollView fill the width
  • Content has margins, but the next element can “peep” out
  • Scroll indicator will also be placed correctly

(04:00) If you need to control the margins of the content and scroll indicator separately, usecontentMargins

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

Key points:

  • contentMarginsCan individually inset content or scroll indicators
  • Solved the problem that safe area cannot configure different insets for different content

Scroll Target Behavior

(04:46) By default, ScrollView uses the standard deceleration rate to calculate the scroll stop position. New APIs can change this behavior:

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

Key points:

  • .paging: Full page flip, based on the container size of ScrollView -.viewAligned: Aligned to subview, needs to be matched.scrollTargetLayout()use -.scrollTargetLayout()Mark each subview in the Lazy Stack as a scroll target
  • on iPad.viewAlignedCompare.pagingMore suitable because the page won’t be too big

CustomizeScrollTargetBehavior

struct MyScrollTargetBehavior: ScrollTargetBehavior {
    func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
        if target.rect.minY < 50 && context.velocity.dy > 0 {
            target.rect.origin.y = 0
        }
    }
}

Key points:

  • RealizeupdateTargetMethod to modify scroll target
  • SwiftUI calls this method when calculating the scroll stop position
  • Also suitable for other scenarios such as ScrollView size changes

Container Relative Frame

07:42.containerRelativeFrameMake the view size based on the nearest container:

GalleryHeroView(palette: palette)
    .aspectRatio(heroRatio, contentMode: .fit)
    .containerRelativeFrame(
        [.horizontal], count: columns, spacing: hSpacing
    )

Key points:

  • The container can be a ScrollView, a column of NavigationSplitView, or a window -countandspacingParameters to create grid layout
  • When the width of the container changes, the view automatically updates the size -horizontalSizeClassEnvironment properties are now available on all platforms

Scroll Position

09:46scrollPositionReplaces ScrollViewReader and provides simpler programmatic scrolling control:

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)
    }
}

Key points:

  • scrollPositionReceives a binding to an optional ID
  • When writing binding, ScrollView scrolls to the view corresponding to the ID
  • When scrolling, the binding is automatically updated to the current frontmost view ID
  • Requires cooperation.scrollTargetLayout()use

Add page turning buttons for mouse users on macOS:

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
    }
}

Key points: -ModifymainIDStatus can trigger scrolling

  • Can be wrapped inwithAnimationAchieve smooth scrolling in

Scroll Transitions

12:34scrollTransitionApply visual changes based on the view’s visible position in the ScrollView:

GalleryHeroView(palette: palette)
    .scrollTransition(axis: .horizontal) { content, phase in
        content
            .scaleEffect(
                x: phase.isIdentity ? 1.0 : 0.80,
                y: phase.isIdentity ? 1.0 : 0.80)
    }

Key points:

  • phaseThere are three states:identity(in the center),topLeading(close to the edge),bottomTrailing(off the edge)
  • only supportsVisualEffectModifiers in the protocol (scaleEffect, rotation, offset, etc.)
  • Modifiers that change the content size (such as font) are not supported because this will affect the layout of the ScrollView
  • Defaults to identity phase when the view is in the center of the visible area

Detailed Content

Basic structure of ScrollView

(00:46) A basic ScrollView structure:

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)
    }
}

Key points:

  • LazyVStackDelay the creation of invisible content -ScrollViewThe axes parameter specifies the scroll direction
  • Automatically enable scrolling when content exceeds range

(02:29) A complete gallery implementation showing the combined use of all new APIs:

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 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 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)
    }
}

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
    }
}

Key points:

  • GalleryHeroViewusecontainerRelativeFrameAutomatically calculate dimensions based on container width -columnsaccording tohorizontalSizeClassShows 1 column on iPhone, 2 columns on iPad -scrollTransitionMake the off-center view shrink to 80% to create a focus effect -scrollPositionLet the buttons in the header control scrolling

Smart behavior for scroll indicators

08:54scrollIndicators(.hidden)The default behavior is to hide the indicator when using multi-touch input such as a trackpad, and show the indicator when a mouse is connected. This is because it is difficult for mouse users to perform swipe gestures.

If you really need to hide the indicator at all times (while providing an alternative scrolling method), use.scrollIndicators(.never)

Core Takeaways

  1. Card style carousel

    • What to do: Implement a carousel that can slide left and right and automatically align to a single card
    • Why it’s worth doing:.scrollTargetBehavior(.viewAligned)Cooperate.scrollTargetLayout()Let the carousel chart implementation change from “manual calculation” to “declarative configuration”
    • How to start: UseLazyHStackpackage card, add.scrollTargetLayout()and.scrollTargetBehavior(.viewAligned)
  2. Cross-device adaptive grid

    • What to do: A layout that displays a grid of 1 column on iPhone and 2-3 columns on iPad
    • Why it’s worth doing:containerRelativeFrameCooperatehorizontalSizeClassAchieve responsive layout without GeometryReader
    • How to start: Use.containerRelativeFrame([.horizontal], count: columns, spacing: spacing),according tohorizontalSizeClassset upcolumns
  3. Scroll-driven visual feedback

    • What it does: Cards in the list zoom in when scrolling to the center of the screen and shrink when scrolled away
    • Why it’s worth doing:scrollTransitionMake this effect a few lines of code, no need to manually track content offset
    • How to start: Add on list item view.scrollTransition(axis: .vertical) { content, phase in content.scaleEffect(phase.isIdentity ? 1.0 : 0.9) }
  4. Programmed scroll navigation

    • What to do: Click the “Previous”/“Next” buttons to control the horizontal scroll view
    • Why it’s worth doing:scrollPositionSimpler than ScrollViewReader and supports two-way binding
    • How to start: Definition@State var currentID: Item.ID?, bound to.scrollPosition(id: $currentID), button modificationcurrentIDThat’s it
  5. Customized scroll adsorption behavior

    • What to do: Make the ScrollView automatically snap to the top when it is close to the top
    • Why it’s worth doing:ScrollTargetBehaviorProtocols make customizing scrolling behavior easy
    • How to get started: Create a followScrollTargetBehaviorThe structure ofupdateTargetModified according to speed and target positiontarget.rect

Comments

GitHub Issues · utterances