스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
스크롤 뷰 너머
SwiftUI의 최신 API를 이용해 여러분의 스크롤 뷰를 한 단계 더 발전시키는 방법을 알아보세요. 스크롤 뷰를 전에 없던 방식으로 사용자화하는 방법을 알려드리겠습니다. 안전 영역과 스크롤 뷰 여백의 관계를 살펴보고, 스크롤 뷰의 콘텐츠 오프셋과 상호 작용하는 법을 배우며, 스크롤 전환으로 콘텐츠에 멋을 내는 방법을 알아보세요.
챕터
- 0:00 - Introduction to scroll views
- 2:01 - Margins and safe area
- 4:14 - Targets and positions
- 11:33 - Scroll transitions
리소스
관련 비디오
WWDC23
-
다운로드
♪ ♪
안녕하세요, SwiftUI 팀 엔지니어 해리입니다 오늘 '스크롤 뷰 너머' 세션에 오신 여러분께 SwiftUI 스크롤 뷰에서 개선한 점을 말씀드리고자 합니다 기기에서 하려는 일을 정해진 화면 크기 내에서만 실행할 수 있는 경우는 거의 없는데 이 복잡한 문제를 해결하기 위해 도입한 방법이 바로 스크롤입니다 그 덕에 화면에 다 들어가지 않는 내용을 전부 볼 수 있으니까요 SwiftUI에서 제공하는 컴포넌트 몇 가지를 활용하면 여러분의 앱에도 스크롤을 넣을 수 있는데 오늘은 그 컴포넌트 중 하나를 설명하겠습니다 바로 ScrollView입니다 ScrollView는 콘텐츠가 스크롤되게 하는 구성 요소입니다 스크롤 뷰에 있는 축은 스크롤을 할 수 있는 방향을 규정합니다 스크롤 뷰에는 콘텐츠가 있는데 콘텐츠가 ScrollView의 크기를 넘어가면 콘텐츠의 일부는 잘려 나가고 잘려 나간 콘텐츠를 보려면 스크롤을 해야 하죠 스크롤 뷰는 안전 영역을 콘텐츠 바깥 여백으로 분할하여 콘텐츠가 안전 영역 안으로 확실히 들어갈 수 있게 합니다 ScrollView는 기본적으로 콘텐츠를 적극 실행하는데 레이지 스택을 사용하면 이러지 않게 할 수 있습니다
ScrollView가 콘텐츠 내부에서 스크롤되는 정확한 위치를 콘텐츠 오프셋이라고 하는데 SwiftUI에서는 ScrollViewReader API를 제공해 콘텐츠 오프셋을 조정할 수 있도록 했습니다 SwiftUI는 ScrollView로 관리하는 콘텐츠 오프셋에 영향을 미치고 대응할 방법을 올해 몇 가지 더 도입했죠 이 세션에서는 먼저 ScrollView의 여백에 영향을 미치는 방법은 무엇이며 그게 안전 영역과 어떤 관계가 있는지 설명하겠습니다 다음으로 스크롤 타깃과 스크롤 위치를 이용해 ScrollView의 콘텐츠 오프셋을 관리하는 법을 설명하고 마지막으로 스크롤 전환을 이용해 앱에 제대로 멋을 내는 법을 보여드리겠습니다 제가 Colors 앱을 만들고 나서 사용자들은 제게 자기 마음에 드는 색 조합을 즐겨 보여주더군요 전 이 조합 중 몇 가지를 선보이고 싶습니다 다른 사람들도 보고 즐길 수 있게요 그래서 저는 Colors 앱에 갤러리 기능을 추가하려고 합니다 갤러리를 추가하는 작업은 이미 어느 정도 진척됐는데 이번 세션을 통해 갤러리 특별 섹션의 헤더와 콘텐츠를 모두 다듬도록 하겠습니다
제 갤러리에선 가로 ScrollView가 레이지 스택을 래핑하고 있는데 먼저 여백을 좀 추가해서 뷰를 좀 더 예쁘게 만들겠습니다 일단 ScrollView에 패딩을 넣자는 생각이 드실 수도 있겠습니다 그러면 ScrollView가 인셋되긴 하지만 보시다시피 스크롤할 때 콘텐츠가 잘리죠 ScrollView 자체를 인셋하는 대신 저는 ScrollView의 콘텐츠 여백을 확장하려고 합니다 새로 생긴 안전 영역 패딩 수정자를 쓰면 되죠 이건 일반 패딩 수정자와 똑같이 동작하기는 하지만 콘텐츠가 아니라 안전 영역에 패딩을 넣기 때문에 ScrollView가 전체 너비에 적용되어 다음 아이템이 살짝 엿보이게 됩니다 더 깊이 들어가기 전에 안전 영역과 ScrollView의 관계에 대해 잠시 얘기할까 합니다 안전 영역은 앱이 작동하는 기기에 주로 있고 안전 영역 패딩이나 안전 영역 인셋 수정자 같은 API에도 있을 수 있습니다 ScrollView는 안전 영역을 분할해 콘텐츠에 적용하는 여백으로 만듭니다 여기에는 여러분이 담당하는 콘텐츠도 포함되지만 ScrollView가 담당하는 추가 콘텐츠도 포함됩니다 스크롤 인디케이터가 그 예죠 즉, 안전 영역을 변경함으로써 다양한 콘텐츠에 대응하는 다양한 인셋을 설정하는 건 불가능하다는 뜻입니다
인셋을 각기 다르게 적용하고 싶다면 새로 생긴 contentMargins API를 사용하면 됩니다 이 API로 ScrollView의 콘텐츠를 스크롤 인디케이터와 별도로 인셋할 수 있거든요 아니면 인디케이터를 콘텐츠와 별도로 인셋할 수도 있고요 다시 제 갤러리로 돌아가 안전 영역 패딩 수정자를 업데이트해 contentMargins API를 쓸 수 있게 하겠습니다 이제 제 뷰에 여백이 약간 적용됐으니 손가락을 뗐을 때 ScrollView가 어느 콘텐츠 오프셋까지 스크롤할지 컨트롤하려 합니다
기본적으로 ScrollView는 표준 감속률과 스크롤 속도를 함께 사용하여 스크롤이 끝나야 할 타깃 콘텐츠 오프셋을 계산합니다 ScrollView의 크기나 콘텐츠는 고려하지 않죠 하지만 그런 게 중요할 때도 있습니다 최신 SwiftUI에서는 ScrollView가 타깃 콘텐츠 오프셋을 계산하는 방식을 바꿀 수 있습니다 scrollTargetBehavior 수정자를 사용해서요 이 수정자가 사용하는 타입은 scrollTargetBehavior 프로토콜을 준수합니다 여기서 전 페이징 동작을 지정했죠 이제 제 ScrollView는 한 번에 한 페이지씩 넘어갑니다 이 페이징 동작은 특수합니다 감속률을 사용자가 지정했고 ScrollView 자체의 컨테이너 크기에 맞춰 어디로 스크롤할지 정하거든요 iOS에서는 이게 효과적이지만 화면이 큰 iPadOS에서는 다소 과한 느낌이 듭니다 ScrollView의 컨테이너 크기에 맞춰 정렬하는 대신 개별 뷰에 맞춰 정렬하고 싶군요
viewAligned 동작은 뷰에 맞춰 ScrollView를 정렬하므로 ScrollView는 어떤 뷰를 정렬 기준으로 삼아야 할지를 알아야 합니다 이러한 뷰를 스크롤 타깃이라고 하는데 특정 뷰를 스크롤 타깃으로 지정할 수 있게 하는 수정자들이 새로 등장했습니다 여기서는 scrollTargetLayout 수정자를 사용해 레이지 스택에 있는 히어로 뷰를 각각 스크롤 타깃으로 삼습니다 각각의 뷰를 타깃으로 지정할 수도 있습니다 스크롤 타깃 수정자를 사용해서요 하지만 레이지 스택을 사용할 때는 scrollTargetLayout 수정자를 사용하는 것이 중요합니다 가시 영역 바깥의 뷰는 아직 만들어지지 않았지만 레이아웃은 자신이 어떤 뷰를 만들게 될지 압니다 그래야 ScrollView가 옳은 위치로 스크롤하게 할 수 있으니까요
이제 제 ScrollView가 iPad에서 훨씬 나아 보이네요 페이징 동작 및 뷰 정렬 동작은 새로 생긴 ScrollTargetBehavior 프로토콜을 토대로 빌드했습니다 SwiftUI는 흔히 사용되는 이런 동작을 제공하면서도 여러분의 타입이 이 프로토콜을 준수하고 여러분이 맞춤형 동작을 구현할 수 있게 합니다 전에 소개한 레이아웃 프로토콜을 채택하셨을 때와 마찬가지로요 여러분의 타입이 ScrollTargetBehavior를 준수하게 하려면 유일하게 필수인 메서드인 updateTarget을 구현해야 합니다 SwiftUI는 스크롤을 어디서 끝낼지 계산할 때 이 메서드를 호출하지만 ScrollView 크기가 바뀔 때 등 다른 경우에도 호출합니다 이 동작을 사용자화하는 건 간단합니다 여기를 보시면 타깃이 ScrollView 위쪽에 가깝고 스크롤이 위로 휙 넘어갈 때 저는 주어진 타깃을 변경하여 ScrollView의 꼭대기까지 스크롤이 올라가게 하고 싶습니다 이렇게 하면 ScrollView는 다른 콘텐츠 오프셋을 선택해 스크롤의 끝점으로 삼게 됩니다 ScrollView로 스크롤할 범위에 영향을 미치려면 맞춤형 코드를 삽입해 이렇게 하기만 하면 됩니다
다시 제 갤러리 뷰로 돌아가죠 레이아웃에 관해 이야기하겠습니다 보시다시피 제 히어로 뷰의 크기는 기기 전체의 너비에 비례합니다 그리고 iPad에서는 뷰 두 개가 기기 너비에 고르게 맞춰져 있고요 예전에는 이렇게 하려면 GeometryReader를 써야 했지만 올해는 SwiftUI 덕분에 훨씬 쉬워졌습니다 containerRelativeFrame 수정자란 API가 새로 생겼기 때문입니다
제 히어로 뷰가 이 API를 어떻게 사용하는지 보여드리죠 먼저 컬러 뷰 스택과 고정 높이를 지정하는 프레임 수정자를 만듭니다 그다음 containerRelativeFrame 수정자를 뷰에 추가하겠습니다 여기서 축을 가로로 지정해서 뷰의 너비가 컨테이너 너비에 맞춰지게 합니다 여기선 주위를 둘러싼 ScrollView가 컨테이너지만 내비게이션 Split View에서 가장 가까운 열이나 앱의 창도 컨테이너가 될 수 있죠 컨테이너의 너비가 바뀌면 제 뷰의 크기도 자동으로 업데이트됩니다 count와 spacing을 지정하면 뷰의 레이아웃을 그리드 모양으로 만들 수 있습니다 가로 sizeClass에 따라 count를 조건부로 지정해서 iPad에서는 2열로 iPhone에서는 1열로 할 수도 있죠 더 좋은 점은 OS 조건문을 제거할 수 있다는 겁니다 가로 sizeClass 환경 프로퍼티를 이제는 모든 플랫폼에서 쓸 수 있으니까요 마지막으로 aspectRatio 수정자를 사용해 높이를 너비에 비례하도록 조정하겠습니다 고정된 높이를 하드코딩하지 않고요 제 갤러리의 레이아웃과 스크롤 동작은 다 만들었지만 몇 가지를 더 바꿔 보고 싶습니다 스크롤 인디케이터가 눈에 띄는데 이걸 없애고 싶네요
기존의 scrollIndicators API로 없앨 수는 있습니다 iPad를 손가락으로 밀 때는 괜찮아 보이지만 전 Mac에서도 갤러리를 자주 쓰는데 Mac에서 마우스나 다른 입력 기기를 사용한다면 가로로 미는 제스처를 취하기 어려울 수 있습니다 게다가 마우스를 연결하면 인디케이터가 드러나고 맙니다 인디케이터를 감춰 달라고 요청했는데도요 스크롤 인디케이터가 없으면 마우스 스크롤이 어렵거나 불가능할 수 있습니다 그래서 scrollIndicator 수정자의 기본값은 트랙패드처럼 유연한 입력 기기를 사용할 때는 인디케이터를 숨기고 마우스를 연결했을 때는 인디케이터를 보이는 겁니다 scrollIndicators 수정자값을 never로 지정해서 입력 장치와 무관하게 인디케이터를 숨겨도 되지만 제 앱은 마우스를 쓰는 사용자도 지원해야 하니 갤러리를 스크롤할 수 있는 다른 방법을 제시해 줘야겠죠 전 스크롤 인디케이터 대신 뷰 몇 개를 렌더링하여 사용자가 클릭하기만 하면 이전 또는 다음 뷰로 스크롤할 수 있도록 하겠습니다 빌드를 시작해야 하니 ScrollView를 좀 정리하겠습니다 ScrollView를 헤더 뷰와 함께 VStack으로 옮기고
헤더 뷰에 집중하겠습니다
헤더 뷰에 사용자 설정 패들 뷰 몇 개를 추가하겠습니다 SwiftUI 이전 버전이었다면 ScrollViewReader를 써서 패들로 패스한 다음 알맞은 뷰로 스크롤하게 했겠지만 최신 SwiftUI에는 scrollPosition 수정자가 있습니다 이 수정자는 바인딩을 식별자를 래핑하는 상태와 결부시킵니다 이걸 scrollPosition 수정자로 패스하면 ScrollView가 수정자를 판독하겠죠 그리고 헤더 뷰로도 패스하겠습니다 헤더 뷰의 패들 안에서 다른 상태에서 하듯이 바인딩에 쓰기를 할 수 있습니다 바인딩에 쓰기를 마치면 ScrollView는 그 ID에 해당하는 뷰로 스크롤하게 됩니다 뷰에 맞춰 정렬된 ScrollTargetBehavior처럼 스크롤 위치 수정자도 스크롤 타깃 레이아웃 수정자를 사용하여 어떤 뷰를 고려하여야 아이덴티티값을 쿼리할 수 있는지 알아냅니다
또한 스크롤 포지션 수정자는 지금 스크롤되고 있는 뷰의 아이덴티티를 알 수 있게 해 주므로 지금 스크롤되는 히어로 이미지의 값을 나타내는 텍스트를 헤더 뷰에 추가할 수도 있습니다 ScrollView에서 가장 앞에 나오는 뷰가 바뀌면 바인딩도 자동으로 업데이트됩니다 이제 마우스 사용자도 제 갤러리를 스크롤할 수 있게 됐죠 마지막으로 이 뷰에서 한 가지를 더 손질하겠습니다 어떤 뷰가 스크롤되고 있는지를 아는 게 유용한 것처럼 때로 전 ScrollView 안에서 뷰가 어디 있느냐에 따라 뷰의 모습을 시각적으로 바꾸고 싶은데 SwiftUI에 새로 생긴 ScrollTransition API를 사용하면 손쉽게 바꿀 수 있습니다 ScrollTransition은 일반적인 전환과 비슷합니다 전환은 뷰가 나타나거나 사라질 때 뷰에 일어나야 하는 변화를 묘사하죠 뷰가 나타날 때 뷰는 아이덴티티 phase에 있기에 어떤 사용자 설정도 적용해서는 안 됩니다 ScrollTransition은 전환과 비슷한 일련의 변화를 묘사하지만 뷰가 ScrollView의 가시 영역에 들어오는 순간과 나가는 순간에 이러한 변화를 적용합니다
기본적으로 뷰는 가시 영역 한가운데 있을 때 ScrollTransition의 아이덴티티 phase에 있습니다 제 히어로 뷰를 예로 들어 살펴보죠 ScrollTransitions에 집중하게 정리를 좀 하겠습니다
뷰가 ScrollView의 가장자리에 가까워질수록 뷰의 크기를 살짝 줄이고 싶습니다 먼저 scrollTransition 수정자를 추가하겠습니다 이 API에는 content와 phase가 들어가며 phase를 근거로 콘텐츠에 시각적 변화를 지정할 수 있습니다 전 뷰가 아이덴티티 phase에 있지 않을 때 크기를 줄이겠다고 지정하겠습니다
보기 좋네요 ScrollTransition은 새 프로토콜인 VisualEffect와 함께 작동합니다 이 프로토콜로 뷰 콘텐츠에 할 수 있는 사용자 설정은 레이아웃의 함수로서 안전하게 사용할 수 있습니다 ScrollView의 콘텐츠 오프셋처럼요 익숙한 것들이 많이 보일 겁니다 scaleEffect는 이미 아시죠 회전이나 오프셋도 사용자화할 수 있습니다 뷰 수정자로 할 때와 비슷하죠 하지만 모든 뷰 수정자를 scrollTransition 내에서 안전하게 쓸 수 있는 건 아닙니다 예를 들어 서체 사용자 설정은 지원되지 않고 빌드되지 않습니다 ScrollView 콘텐츠 전체의 크기를 바꾸는 것들은 scrollTransition 수정자 안에서 쓸 수 없거든요 많은 내용을 다뤘으니 간단히 복습해 봅시다
안전 영역과 콘텐츠 여백의 차이는 무엇이며 그게 ScrollView와 어떤 관계가 있는지 얘기했습니다 페이징 및 뷰에 맞게 정렬한 scrollTargetBehaviors를 이용해 ScrollView의 동작에 영향을 미치는 방법을 보여드렸고 scrollTargetBehavior 프로토콜을 준수하는 내용을 직접 작성하는 법도 보여드렸죠 containerRelativeFrame 수정자로 컨테이너에 비례하는 레이아웃을 얼마나 쉽게 만들 수 있는지도 알려드렸고요 저는 scrollPosition 수정자로 ScrollView의 상태에 들어가 프로그램 차원에서 스크롤을 하는 동시에 어떤 뷰가 스크롤되고 있는지 정보를 얻을 수 있었죠 마지막으로 scrollTransition API를 이용해 ScrollView의 콘텐츠 오프셋을 토대로 시각 효과를 만들어 냈습니다 ScrollView에서 개선된 점들을 즐겁게 배우셨기를 바랍니다 감사합니다 WWDC에서 즐거운 시간 보내세요 ♪ ♪
-
-
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() } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.