ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI - ScrollPosition
    SwiftUI 2024. 10. 14. 18:56

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는 SwiftUI의 ScrollPosition에 대해 알아보겠습니다 🙋🏻

     


    ScrollPosition

    이번 iOS 18로 업데이트 되면서 SwiftUI에서 ScrollPosition 타입이 생기면서 이와 관련된 모디파이어들로 스크롤 뷰의 기능이 더 편리해졌습니다 😃

     

    기존에, SwiftUI에서 ScrollView를 사용할 때, 특정 위치로 스크롤을 이동시키거나 할때 우리는 ScrollViewReader를 활용해서 scrollTo 메서드로 동작 시켜줬습니다.

     

    이와 관련해서는 아래 포스팅을 참고해보면 이번 포스팅에서 다뤄볼 ScrollPosition과 어떤 차이가 있는지 확인해볼 수 있어요!

     

     

    SwiftUI - ScrollViewReader

    안녕하세요. 그린입니다🍏 이번 포스팅에서는 SwiftUI에서 ScrollViewReader라는것에 대해 학습해보겠습니다🙌 SwiftUI를 사용하면서 ScrollView라는 View를 아주 많이 사용하게 됩니다. 이때 현재 스크롤

    green1229.tistory.com

     

    결론적으로 동작시키려는건 동일한데, 이제 ScrollViewReader를 사용하지 않아도 우리는 더 단순히 동일 동작을 ScrollPosition으로 다뤄줄 수 있습니다.

     

    iOS 18 이상에서만 사용 가능한 타입으로 스크롤 뷰가 컨텐츠 내 스크롤되는 위치를 정의하는 타입입니다.

     

    struct ScrollPosition

     

    구조체 타입으로, 아래와 같이 제공된 ID 값으로 뷰에 새로운 스크롤 위치를 담거나 새로운 자동 스크롤 위치를 만드는 등 이니셜라이저들이 존재합니다.

     

     

    또한, x, y 값으로 스크롤 위치도 만들 수 있죠.

     

    즉 정리하자면, 크게 아래 3가지 형태로 해당 타입의 값을 제공하고 스크롤 동작을 해줄 수 있어요.

     

    1️⃣ 제공된 ID로 뷰 스크롤

    2️⃣ 구체적인 오프셋으로 스크롤

    3️⃣ 가장자리로 스크롤

     

    해당 타입은 scrollPosition이라는 뷰 모디파이어와 함께 사용됩니다.

     

    scrollPosition은 뷰 내의 스크롤 뷰와 스크롤 포지션에 대한 바인딩을 연결해줘요.

    즉, ScrollPosition을 ScrollView와 연결시켜주는 모디파이어죠.

     

    마찬가지로 iOS 18 이상에서만 쓸 수 있어요.

     

    nonisolated
    func scrollPosition(
        _ position: Binding<ScrollPosition>,
        anchor: UnitPoint? = nil
    ) -> some View

     

    정의는 이렇게 바인딩 시킬 ScrollPosition 값과 anchor를 받습니다.

     

    그럼 이 ScrollPosition과 scrollPosition 뷰 모디파이어를 통해 사용을 해볼까요?

     

    기본적으로 우선 스크롤뷰의 edge를 통해 이동시켜볼 수 있어요.

     

    import SwiftUI
    
    struct ContentView: View {
      @State var position = ScrollPosition(edge: .top)
      
      var body: some View {
        VStack {
          ScrollView {
            LazyVStack {
              ForEach(0..<100) { index in
                HStack {
                  Spacer()
                  
                  Text("\(index)")
                  
                  Spacer()
                }
                .frame(height: 50)
                .background(Color.green)
                .id(index)
              }
            }
          }
          .scrollPosition($position)
          
          Button(
            action: {
              position.scrollTo(edge: .top)
            },
            label: {
              Text("Click")
            }
          )
        }
      }
    }

     

    여기서 ScrollView에 scrollPosition 모디파이어를 이용해 해당 값을 바인딩 시켜줍니다.

    그리고, 버튼을 클릭하면 해당 스크롤뷰의 최상단으로 이동하게 되는 코드에요.

     

    기존에, ScrollViewReader로 감싸고 처리했던것보다 훨씬 더 간단해졌죠?

     

     

    물론, 전체 스크롤뷰를 기준으로 x, y포지션 값으로도 이동시킬 수 있지만 하위에 1부터 100까지 지정된 뷰의 id 값을 통해서도 스크롤 시킬 수 있습니다.

     

    기본적으로, 대부분 뷰 ID 기반으로 스크롤링을 많이 하실거에요.

     

    import SwiftUI
    
    struct ContentView: View {
      @State var position = ScrollPosition(edge: .bottom)
      
      var body: some View {
        VStack {
          ScrollView {
            LazyVStack {
              ForEach(0..<100) { index in
                HStack {
                  Spacer()
                  
                  Text("\(index)")
                  
                  Spacer()
                }
                .frame(height: 50)
                .background(Color.green)
                .id(index)
              }
            }
          }
          .scrollPosition($position)
          
          Button(
            action: {
              position.scrollTo(id: 20)
            },
            label: {
              Text("Click")
            }
          )
        }
      }
    }

     

    이렇게, 버튼을 누르면 id가 20인 뷰로 스크롤 동작을 되도록 구현할 수 있습니다.

     

     

    다만 여기서 하나 짚어야할 점은 현재 20을 기준으로 위에서나 아래서 스크롤 이동을 할때 20의 포지션이 딱 정해지지 않았다는점이에요.

    즉, 20이 보이도록 스크롤이 되는것이지 20의 뷰가 최상단에 위치할지 최하단에 위치할지는 시스템이 정합니다.

    그래서 맨 위에서 스크롤을 시키면 20이 하단에 나타나기 시작하면 스크롤 동작을 멈추기에 20이 가장 아래에 위치하고 현재 한 35쯤 위치에서 스크롤을 시키면 20이 최상단에 위치하게 되는것이죠.

     

    만약 우리가 이 스크롤 되는 뷰의 포지션을 정해주고 싶다면 anchor를 사용하면 됩니다.

     

    .scrollPosition($position, anchor: .top)

     

     

    그럼 우리의 의도대로 해당 top에 항상 위치하여 스크롤 동작을 타게 됩니다 😃

     

    그런데 여기서 우리는 id에 해당 값을 주었죠.

    근데, 사실 모델링을 하다보면 저렇게 딱 떨어지지 않을 수 있고 간단하지 않을 수 있어요.

    그래서 뷰 ID 값을 내부 프로퍼티로 id 모디파이어를 이용해 담아줄 수도 있지만, ScrollPosition의 타입을 지정하고 단순히 scrollTo를 해줌으로도 그 기능을 할 수 있습니다.

     

    import SwiftUI
    
    struct MyItem: Identifiable {
      let id: UUID
      let name: Int
    }
    
    struct ContentView: View {
      @State var items: [MyItem] = (1...100).map { num in
        MyItem(id: UUID(), name: num)
      }
      @State private var position: ScrollPosition = .init(idType: MyItem.ID.self)
      
      var body: some View {
        VStack {
          ScrollView {
            LazyVStack {
              ForEach(items) { item in
                ItemView(item: item)
              }
            }
          }
          .scrollPosition($position)
         
          Button(
            action: {
              if let firstItemID = items.first?.id {
                position.scrollTo(id: firstItemID)
              }
            },
            label: {
              Text("Click")
            }
          )
        }
      }
    }
    
    struct ItemView: View {
      let item: MyItem
      
      var body: some View {
        HStack {
          Spacer()
          
          Text("\(item.name)")
          
          Spacer()
        }
        .frame(height: 50)
        .background(Color.green)
      }
    }

     

    여기서 보시면, 먼저 MyItem 타입을 가지고 이제 뷰의 데이터를 넣어줄거에요.

    그래서 100개정도 순차적으로 생성합니다.

     

    그리고, position의 초기값을 MyItem의 ID 타입으로 생성합니다.

    초기화 하는 이유는 명시적으로 어떤 타입의 식별자를 사용할지 알려주기 위함입니다.

    즉, 해당 과정에서 스크롤 대상 아이템의 식별자를 추적하고 스크롤할 위치를 결정하는데 필요하죠.

     

    그런데, 여기서 사실 idType을 해당 MyItem 타입이 아닌 AnotherItem이나, String, Bool로 생성한다 하더라도 스크롤 동작은 되긴해요.

    컴파일러가 자체적으로 자동으로 타입이 미스매치가 나더라도 허용하며 처리해줍니다.

    동작은 하지만 사실 타입 안정성은 결여되있는것입니다.

     

    추후 런타임 안정성에서도 문제가 발생할 수 있으니 왠만하면 정확히 타입을 맞춰주는게 좋겠죠? 😃

     

    그리고, 코드를 이어서 보면 버튼의 액션으로 해당 items의 첫번째 요소를 찾아 scrollTo 해주면 동일한 동작을 수행할 수 있습니다!

     

    마지막으로 하나 더 ☝️

     

    공식 문서를 보면 아래와 같이 scrollTargetLayout을 사용하는걸 볼 수 있어요.

     

    ScrollView {
      LazyVStack {
        ForEach(items) { item in
          ItemView(item: item)
        }
      }
      .scrollTargetLayout()
    }
    .scrollPosition($position)

     

    사실, 현재 코드에서는 해당 모디파이어가 없어도 스크롤 동작은 문제 없는데요.

    해당 모디파이어는 아래와 같이 정의되어 있습니다.

     

    nonisolated
    func scrollTargetLayout(isEnabled: Bool = true) -> some View

     

    해당 모디파이어는 스크롤 뷰가 뷰 기반 컨텐츠에 맞춰 정렬되도록 하기 위해 함께 뷰와 작동해요.

    기본 레이아웃 내에 중첩된 모든 타겟 레이아웃이 스크롤 타겟 레이아웃이 되지 않도록 보장하는 역할을 하죠.

     

    LazyHStack { // a scroll target layout
        VStack { ... } // not a scroll target layout
        LazyHStack { ... } // also not a scroll target layout
    }
    .scrollTargetLayout()

     

    즉, 정리하면 스크롤 뷰 안에서 스크롤 대상의 레이아웃을 명확히 정의하는 역할을 해줍니다.

    각각의 아이템을 타겟으로 인식하게 되고 이를 바탕으로 스크롤 이동이 가능하죠.

    간혹 SwiftUI가 어떤 요소가 스크롤 타겟인지 알지 못해 scrollTo 동작이 정상적이지 않음을 방지해줄 수 있습니다.

     

    우리가 계속 다뤘던 예제 코드에선 ScrollView와 LazyVStack이 내부적으로 아이템의 레이아웃을 관리하고 SwiftUI가 아이템의 위치를 인식할 수 있기 때문에 없어도 되는것이에요.

     

    만약 복잡한 레이아웃을 구성해야하거나 애니메이션을 연동할때 또는 성능을 최적화 시켜야 한다면 해당 모디파이어를 사용하는것이 좋습니다 😃

     


    마무리

    이렇게 새롭게 SwiftUI 업데이트 사항 중 스크롤 포지션에 대해서 알아봤습니다.

    가능한 적용 OS 버전이 높긴 하지만, 적용된다면 복잡하던 스크롤 뷰를 다루는 코드들을 효과적으로 줄일 수 있을것 같네요!

    SwiftUI를 사용하면서 스크롤 뷰가 참 문제가 많다고 느끼고 불편했던점이 한두가지가 아니였는데 조금씩 개선되는 모습이 보기 좋습니다 😁


    레퍼런스

     

    ScrollPosition | Apple Developer Documentation

    A type that defines the semantic position of where a scroll view is scrolled within its content.

    developer.apple.com

     

    scrollPosition(_:anchor:) | Apple Developer Documentation

    Associates a binding to a scroll position with a scroll view within this view.

    developer.apple.com

     

    scrollTargetLayout(isEnabled:) | Apple Developer Documentation

    Configures the outermost layout as a scroll target layout.

    developer.apple.com

Designed by Tistory.