-
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.
Chapitres
- 0:00 - Introduction to scroll views
- 2:01 - Margins and safe area
- 4:14 - Targets and positions
- 11:33 - Scroll transitions
Ressources
Vidéos connexes
WWDC23
-
Rechercher dans cette vidéo…
-
-
0:46 - ScrollView
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) } } -
2:29 - Basic Featured Section
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 } } -
4:00 - Featured Section with Margins
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 } } -
7:42 - Featured Section + Container Relative Frame
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 } } -
9:46 - Featured Section + Scroll Position
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() } } -
12:34 - Featured Section + Scroll Transition
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() } }
-