• 디바이스는 화면 크기 이상의 것들을 하고 싶고, 그 요구사항을 다루는 방법 중 하나가 스크롤이다.

  • SwiftUI는 앱에 스크롤을 넣을 수 있는 몇가지 방법을 제공하는데, 그 중 하나가 ScrollView다.

  • ScrollView: 컨텐츠에 스크롤을 넣어주는 구성 블록

    • 스크롤 방향을 정해주는 축을 가진다.

    • 컨텐츠를 가진다.

      • 컨텐츠가 스크롤뷰의 크기를 넘어가면 컨텐츠가 잘리고, 사용자는 스크롤을 해서 해당 컨텐츠를 볼 수 있다.
      • safe area를 마진에 반영함으로써 컨텐츠가 safe area안에 보이도록 보장해준다.
      • ScrollView는 컨텐츠를 미리 평가를 해놓는데, LazyStack을 써서 이를 변경할 수 있다.
    • 컨텐츠 안에서 스크롤이 된 위치 값을 content offset이라고 한다.

      • SwiftUI는 ScrollViewReader API를 통해서 이를 컨트롤해왔다.
      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)
          }
      }
      
  • Margins and safe area

    • ex. 헤더에 있는 스크롤뷰에 마진 넣기
      • 시작

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

        스크린샷 2023-06-18 오후 5.26.51.png

      • scrollView 자체에 패딩 넣기

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

        스크린샷 2023-06-18 오후 5.33.43.png

      • safeArea에 패딩넣기(new)

        • 다만 이러면 사용자 컨텐츠 뿐 아니라 스크롤뷰가 자체적인 책임을 가지는 ScrollIndicator등도 영향을 받는다.
        ScrollView(.horizontal) {
            LazyHStack(spacing: hSpacing) {
                ForEach(palettes) { palette in
                    GalleryHeroView(palette: palette)
                }
            }
        }
        .safeAreaPadding(.horizontal, hMargin)
        

        스크린샷 2023-06-18 오후 5.37.01.png

      • 컨텐츠별로 다르게 마진주기(new)

        ScrollView {
        	// content
        }
        .contentMargins(
        	.vertical: 50.0,
        	for: .scrollContent
        )
        

        스크린샷 2023-06-18 오후 5.39.28.png

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

    • 기본적으로 ScrollView는 스크롤 속도에 따라서 표준적인 감속도를 사용해서 스크롤이 끝나는 지점을 계산한다.

      • 스크롤뷰나 컨텐츠의 크기는 고려하지 않는다.
    • 하지만 이게 중요할 때가 있다. 그래서 SwiftUI는 이 계산로직을 변경할 수 있는 ScrollTargetBehavior modifier를 추가했다.

      • ScrollTargetBehavior 프로토콜을 채택하는 타입을 인자로 받는다.

        ScrollView(.horizontal) {
            LazyHStack(spacing: hSpacing) {
                ForEach(palettes) { palette in
                    GalleryHeroView(palette: palette)
                }
            }
        }
        .contentMargins(.horizontal, hMargin)
        .scrollTargetBehavior(.paging) 
        
    • 기본 제공 behavior

      • paging: 커스텀한 감속도를 가지며 ScrollView의 크기를 고려해서 스크롤 위치를 결정한다
      • viewAligned: 스크롤 뷰 안의 개별 뷰들에 맞춰서 스크롤한다.
        • 이를 위해서는 스크롤뷰가 어떤 뷰를 신경써야 할지를 알려줘야 한다.
          • scrollTargetLayout: 레이아웃 컨테이너에 적용해서 내부의 개별 뷰들을 모두 scrollTarget이 되도록 해준다.

            • 기본적으로는 개별 뷰에 scrollTarget을 설정해준 것과 동일하다.
            • 다만 Lazy 계열은 아직 보이지 않은 뷰는 생성조차 되지 않은 상태이므로 scrollTarget으로 적용하면 제대로 동작하지 않는다. 이때는 반드시 scrollTargetLayout을 써야 한다.
            ScrollView(.horizontal) {
                LazyHStack(spacing: hSpacing) {
                    ForEach(palettes) { palette in
                        GalleryHeroView(palette: palette)
                    }
                }.scrollTargetLayout()
            }
            .contentMargins(.horizontal, hMargin)
            .scrollTargetBehavior(.viewAligned) 
            
          • scrollTarget: 해당뷰를 ScrollView가 염두에 두도록 해준다.

    • CustomBehavior

      • 요구사항은 updateTarget 메소드 하나
        • SwiftUI는 스크롤이 끝나는 곳을 판단하기 위해서 이 메소드를 호출한다.

        • 하지만 스크롤뷰 자체가 크기가 변하는 등의 경우도 이 메소드가 호출된다.

        • ex. 갤러리

          struct GalleryScrollTargetBehavior: ScrollTargetBehavior {
          	func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
          		if target.rect.minY < (context.containerSize.height / 3.0),
          				context.velocity.dy < 0.0
          		{
          			target.rect.origin.y = 0.0
          		}
          	}
          }
          
    • ContainerRelativeFrame

      • 컨테이너 크기에 따라서 뷰의 크기를 조절해줄 수 있다.

        • 기존에는 GeometryReader를 써야 했는데, 이제는 더 쉬워졌다.
        • 컨테이너는 스크롤뷰, navigationsplitview의 column, window등이 될 수 있다.
        HeroColrStack(palette: palette)
        	.frame(height: 250.0)
        	.containerRelativeFrame(.horizontal) // 가로 크기는 컨테이너와 일치
        
        HeroColrStack(palette: palette)
        	.frame(height: 250.0)
        	.containerRelativeFrame(
        		.horizontal,
        		count: 2,
        		spacing: 10.0) // 2칸 들어가는 그리드 형태
        
        // sizeClass로 구분
        // 요 environment 값도 이제 모든 플랫폼에서 사용가능
        @Environment(\\.horizontalSizeClass) private var sizeClass 
        
        HeroColrStack(palette: palette)
        	.aspectRatio(16.0 / 9.0, contentMode: .fit) // 비율로 프레임 설정
        	.containerRelativeFrame(
        		.horizontal,
        		count: sizeClass == .regular ? 2 : 1,
        		spacing: 10.0) // 2칸 들어가는 그리드 형태
        
    • scrollPosition

      • ex. 스크롤바 숨기고 커스텀 컨트롤 제공
        • scrollIndicator modifier를 쓰면 indicator를 숨길 수 있다.

        • 기존에도 존재하던 API긴 했지만, 맥에서는 마우스로는 스크롤바가 없으면 스크롤이 거의 불가능하기 때문에 hidden옵션을 주면 마우스를 쓰는 경우에는 뜬다.

        • never를 줘버리면 이 경우도 안뜨게 할 수 있지만, 스크롤을 할 수 있는 다른 수단이 필요하다.

        • 기존에는 ScrollViewReader를 썼겠지만, 이제는 scrollPosition modifier를 쓴다.

          • ID를 지정해서 스크롤을 시킨다.
          @State private var mainID; Palette.ID? = nil
          
          VStack {
          	GallerySectionHeader(mainID: $mainID)
          	ScrollView(.horizontal) { ... }
          		.scrollPosition(id: $mainID)
          }
          
          // GallertySectionHeader
          GalleryPaddle(edge: .leading) {
          	mainID = previousID()
          }
          
  • Scroll transitions

    • 스크롤뷰에서의 위치에 따라서 뷰를 변경하고 싶다.

    • transition: 뷰가 나타나거나 사라질 때 뷰에 적용되어야 하는 변경사항들을 나타낸것

      • 뷰가 보이면 transition의 변경사항이 적용되지 않은 identity 페이즈에 진입하게 된다.
    • 스크롤뷰에서의 transtion도 일반적인 transition과 거의 비슷하다.

      • 스크롤뷰의 가시 영역에 대해서 적용된다는 점만 다를 뿐
      • 뷰가 스크롤뷰의 가시 영역의 중간에 있으면 identity 페이즈에 있게 된다.
    • 이 phase를 보고 뷰에 transtion을 줄 수 있는 scrollTransition modifier가 추가 되었다.

      • 새로 추가된 visualEffect라는 프로토콜을 통해서 접근하게 된다.
      • 일반적인 viewModifier와 비슷하게 쓰지만, 레이아웃에 영향을 주는 것들은 사용할 수 없다.
      HeroView(palette: palette)
      	.scrollTransition(axis: .horizontal)
      	{ content, phase in
      		content
      			.scaleEffect(
      				x: phase.isIdentity ? 1.0 : 0.80,
      				y: phase.isIdentity ? 1.0: 0.80
      			)
      			.rotationEffect(
      				.degrees(phase.isIdentity ? 0.0: 90.0)
      			)
      			.offset(
      				x: phase.isIdentity ? 0.0 : 20.0,
      				y: phase.isIdentity ? 0.0 : 20.0
      			)
      	}