ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI - 뷰의 높이가 충분치 않을때도 Sticky 유지하기
    SwiftUI 2023. 7. 6. 15:31

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

    이번 포스팅에서는 실제로 겪을 수 있는 SwiftUI의 구현 과정에서 문제를 알아보고 대처해보려 합니다 🙋🏻

     

    Sticky한 스크롤 뷰는 다들 한번쯤 들어보셨을것 같아요!

    만약 Sticky한 뷰가 어떤건지 처음 들어보신다면 아래 포스팅을 먼저 보고 오시면 많은 도움이 됩니다ㅎㅎ

    https://green1229.tistory.com/302

     

    SwiftUI - PinnedScrollableViews (a.k.a Sticky View)

    안녕하세요. 그린입니다🍏 이번 포스팅에서는 SwiftUI에서 PinnedScrollableViews라는것에 대해 알아보겠습니다🙌 이걸 알아보게 된 배경은 프로덕트의 기능을 구현하다 스크롤뷰로 감싸진 VStack에서

    green1229.tistory.com

     

    그런데 이런 스티키 뷰를 내부에 구성하고 그 안에 컨텐츠를 담았을때 그 컨텐츠의 뷰의 높이가 굉장히 적다면 다른 탭 전환 시 스티키는 고정되지 않아요.

     

    음 말로 일단 던져보니 이해가 절대 안갈것 같으니..! 한번 예시를 통해 보겠습니다 🙋🏻

    보시면 Here is Top이라는 영역은 올라가고 First~Third까지가 헤더 뷰로 스크롤이 되어도 고정되어 있습니다.

    해당 헤더 뷰가 고정된 상태, 즉 sticky된 상태에서 스크롤을 하다 Second 탭을 누르면 정상이에요.

    왜냐면 그만큼 스크롤되어 내려간만큼 Second에도 뷰의 높이가 충분하니까요!

    그런데 Third를 탭하면 뷰가 사라져 보이죠!?

    Third의 컨텐츠 뷰의 높이가 First/Second보다 월등히 작아 그렇습니다.

    그리고 또 조금만 스크롤 하고 Third로 넘어가면 고정된 헤더 뷰가 그대로 있어야 하는데 갑자기 Here is Top 즉, 탑뷰가 나타나죠!?

    즉 원치 않는 동작을 일으킬 가능성이 농후합니다 🥲

     

    먼저 해당 기본적인 베이스 코드를 보겠습니다 🙋🏻

     

    해결 전 코드

    import SwiftUI
    
    struct ContentView: View {
      @State var selectedTab: String = "First"
      
      var body: some View {
        VStack {
          Text("Sticky View")
            .font(.title)
            .bold()
          
          ScrollView {
            TopView()
            
            LazyVStack(
              spacing: 10,
              pinnedViews: .sectionHeaders
            ) {
              Section(header: HeaderView(selectedTab: $selectedTab)) {
                switch selectedTab {
                case "First":
                  ItemsView(color: .blue)
                  
                case "Second":
                  ItemsView(color: .yellow)
                  
                default:
                  ItemsView(color: .green, count: 2)
                }
                Spacer()
              }
            }
          }
        }
      }
    }
    
    // MARK: - 탑 타이틀 뷰
    struct TopView: View {
      var body: some View {
        HStack {
          Spacer()
          
          Text("Here is Top")
            .font(.title)
            .bold()
          
          Spacer()
        }
        .background(Color.green.opacity(0.5))
        .frame(height: 50)
      }
    }
    
    // MARK: - 고정될 헤더 뷰
    struct HeaderView: View {
      @Binding var selectedTab: String
      
      var body: some View {
        HStack {
          Spacer()
          
          Button(
            action: {
              selectedTab = "First"
            },
            label: {
              Text("First")
                .font(.title)
                .bold()
                .foregroundColor(selectedTab == "First" ? .black : .gray)
            }
          )
          
          Button(
            action: {
              selectedTab = "Second"
            },
            label: {
              Text("Second")
                .font(.title)
                .bold()
                .foregroundColor(selectedTab == "Second" ? .black : .gray)
            }
          )
          
          Button(
            action: {
              selectedTab = "Third"
            },
            label: {
              Text("Third")
                .font(.title)
                .bold()
                .foregroundColor(selectedTab == "Third" ? .black : .gray)
            }
          )
          
          Spacer()
        }
        .background(.white)
      }
    }
    
    // MARK: - 컨텐츠로 담길 아이템 뷰
    struct ItemsView: View {
      let color: Color
      let count: Int
      
      init(color: Color, count: Int = 20) {
        self.color = color
        self.count = count
      }
      
      var body: some View {
        VStack {
          ForEach(0...count, id: \.self) { _ in
            Rectangle()
              .fill(color)
              .cornerRadius(10)
              .frame(height:100)
          }
        }
        .padding(.horizontal, 20)
      }
    }

    뷰 자체의 높이가 따로 설정되지 않고 속한 컨텐츠에 따라 뷰 높이가 결정되어서 발생하는 문제입니다.

     

    우리가 여기서 해결해볼건 두가지입니다!

     

    1️⃣ 탭 전환 시 헤더 뷰를 고정 시켜보기

    2️⃣ 컨텐츠의 최소 높이를 계산하고 지정해주어 고정된 상태에서 이상없도록 구현하기

     

    자 그럼 1번부터 해결해볼까요?

     

    1️⃣ 탭 전환 시 헤더 뷰를 고정 시켜보기

    아주 간단해요!

    ScrollViewReader와 scrollTo 메서드를 이용하면 됩니다!

    struct ContentView: View {
      @State var selectedTab: String = "First"
      
      var body: some View {
        ...
       🎉 ScrollViewReader { proxy in
            ScrollView {
              TopView()
              
              LazyVStack(
                spacing: 10,
                pinnedViews: .sectionHeaders
              ) {
                Section(
                  header: HeaderView(
                    selectedTab: $selectedTab, 
                 🎉 action: { proxy.scrollTo("Header", anchor: .top)}
                  )
                ) {
                  switch selectedTab {
                  case "First":
                    ItemsView(color: .blue)
                    
                  case "Second":
                    ItemsView(color: .yellow)
                    
                  default:
                    ItemsView(color: .green, count: 2)
                  }
                  Spacer()
                }
              }
            }
          }
        }
      }
    }
    
    // MARK: - 고정될 헤더 뷰
    struct HeaderView: View {
      @Binding var selectedTab: String
      🎉 var action: () -> Void
      
      var body: some View {
        HStack {
          ...
        }
        .background(.white)
     🎉 .id("Header")
      }
    }

    🎉 표시된곳만 추가해줬어요!

    그러면 해당 헤더뷰의 다른 탭이 클릭될때 스크롤을 해당 헤더뷰의 top anchor로 scrollTo 시켜줍니다!

     

    그럼 어떻게 동작하는지 볼까요?

     

     

    이렇게 아무리 스크롤을 하고 다음 탭으로 넘어갈때 컨텐츠 영역의 높이가 적더라도 스크롤을 헤더 뷰 top으로 되었기에 정상적으로 컨텐츠가 나타납니다!

     

    그런데 여기서 탭 전환 시 어색한거 보셨나요?

    바로 First~Third 글자가 간혹 영역을 못잡아 밑에서 애니메이션을 가지고 올라오고 있어요ㅠㅠ

    이건 뷰 랜더링 차이 때문에 발생하기에 현재로썬 이렇게 해결해줄 수 있습니다.

    Button(
      action: {
        🎉 selectedTab = "First"
        🎉 action()
      },
      label: {
        Text("First")
          .font(.title)
          .bold()
          .foregroundColor(selectedTab == "First" ? .black : .gray)
      }
    )

    selectedTab과 action의 순서를 변경해주면 정상적으로 렌더링이 됩니다!

     

    자 그럼 이어서 두번째 문제를 해결해보시죠 😁

     

    2️⃣ 컨텐츠의 최소 높이를 계산하고 지정해주어 고정된 상태에서 이상없도록 구현하기

    // MARK: - 컨텐츠로 담길 아이템 뷰
    struct ItemsView: View {
      let color: Color
      let count: Int
      
      init(color: Color, count: Int = 20) {
        self.color = color
        self.count = count
      }
      
      var body: some View {
        VStack {
          ForEach(0...count, id: \.self) { _ in
            Rectangle()
              .fill(color)
              .cornerRadius(10)
              .frame(height:100)
          }
          
         🎉 Spacer()
        }
        .padding(.horizontal, 20)
     🎉 .frame(minHeight: UIScreen.main.bounds.height - 150)
      }
    }

    🎉 표시된곳을 보면 우선 아래부터 보시죠!

    frame을 minHeight로 최소 높이를 지정해주는데 디바이스의 높이를 계산하고 헤더 영역까지 포함해서 상단부터의 높이를 빼줍니다.

    위 코드의 경우는 타이틀 / 탑 / 헤더의 총 높이가 150이라 150을 빼주면 남은 디바이스 영역의 수치가 해당 컨텐츠의 최소 높이가 되는것이죠!

    이렇게 하면 아주 간단히 해당 문제를 해결할 수 있어요 ㅎㅎ

     

    한번 돌려볼까요?

     

     

    이제 드디어 원하는 구현이 되었네요!

    다른 탭으로 이동하더라도 스티키된 헤더 상태는 최상단으로 유지되었습니다 🎉

     

    마무리

    역시 SwiftUI로 모든것을 쌩으로 구현하는건 만만치가 않네요 🥲

    해당 예제 코드는 아래 제 깃헙 레포에 공개해뒀으니 참고해주세요!

    https://github.com/GREENOVER/playground/tree/main/Sticky

     

    GitHub - GREENOVER/playground: 학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터

    학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터. Contribute to GREENOVER/playground development by creating an account on GitHub.

    github.com

Designed by Tistory.