Swift: SwiftUI에서 Sticky Header 만들기

Heechan
HcleeDev
Published in
9 min readJan 14, 2022
Photo by Joanna Kosinska on Unsplash

최근 프로덕트에 스크롤해서 내리면 헤더가 같이 밀려 올라가는게 아니라 화면 상단에 걸려있도록 하는 UI를 추가해야 했다.

그런걸 보기만 했지 iOS에서는 만들어 본 적이 없었다. Web에서는 CSS의 sticky 옵션을 이용해 만든 기억이 나서, 혹시 비슷한게 있나 검색해보니 역시 사람들이 이런 기능을 Sticky Header라고 칭하고 있었다.

이번 주는 Sticky Header를 어떻게 구현했는지 알아보자.

Sticky Header, 무엇을 만들어야 하나

일단 Sticky Header가 뭔지부터가 묘연할 수 있다. 그래서 이번에는 완성품부터 한번 보여주도록 하겠다.

일단 필수적으로 만들고 싶었던 것은 중간에 보이는 City of Korea, 한국의 도시라고 적혀있는 리스트의 Header였다.

스크롤이 내려가다가도 화면 상단에 닿으면 그대로 올라가는게 아니라 상단에 착 붙어서 유지되는 기능을 기대했다. 이거를 Sticky Header라고 생각할 수 있고, 만드는 법이 생각보다 쉬워서 빠르게 만들어볼 생각이다.

두 번째는 위에 대한민국이라고 적힌 이미지 부분이다. 밑으로 당겨지면 꽤 유려하게 반응하고 있는데, 이 부분도 어떻게 구현했는지 공유해볼 것이다. 생각보다 이 부분을 만드는데 시간을 많이 써서, 코드들이 구구절절 나올 것 같다.

Section의 header를 이용해 Sticky Header 만들기

일단 사용한 사진들은 pixabay와 대구관광재단에서 제공하는 대구뷰 서비스에서 다운로드했다.

기본적으로 가장 위에 대한민국 사진을 세팅하고, 밑에 리스트 아이템들도 한번 만들어보자.

이렇게 만들어두면 이제 별 특징 없는 리스트가 된다.

그러면 여기서 아까 봤던 City of Korea, 한국의 도시 헤더를 넣어줘야 할 것이다.

놀랍게도 LazyVStack에는 pinnedViews 라는 옵션이 있었다. PinnedScrollableViews는 ScrollView의 경계에 pin될 수 있는 View의 타입의 집합으로, 선택할 수 있는 옵션으로 sectionHeaderssectionFooters 가 있다.

이걸 이용하면 우리가 원하는 UI를 구성할 수 있을 것으로 보인다. LazyVStack의 pinnedViewssectionHeaders 를 선택하고, 내부에 Section을 할당하고 우리가 원하는 Header를 설정해주면 될 것 같다. 그래서 한번 바꿔보았다.

위의 MainViewLazyVStack 을 일단 수정해보았다. pinnedView: [.sectionHeaders] 를 설정해 Section의 header를 스크롤뷰 상단에 고정되도록 설정했다. 그리고 LazyVStack 내부에는 Section 을 추가하고 header: Header() 로 설정해줘 Header 가 Section의 헤더 역할은 물론, ScrollView 내에서 Sticky하게 딱 붙도록 설정해줄 수 있다.

이대로 빌드한 결과물을 보자.

우리가 기대한대로 Section의 Header가 스크롤뷰 위에 딱 걸려서 붙어있는 모습이다. Sticky Header를 만들었다고 볼 수 있을 것 같다.

사실 위 코드에서 ScrollView에 .clipped() 를 달아줬는데, 이걸 달아줬기 때문에 위쪽 Safe Area에서 스크롤해서 올라간 서울 카드의 모습이 보이지 않는다. 지금은 그냥 간단하게 clipped 를 달아준 것 뿐이지만 만약 좀 더 색상 같은 것을 관리하고 싶다면, ScrollView의 background를 잘 활용하거나, ZStack을 이용해 Safe Area 높이만큼의 직사각형을 위쪽에 꽂아넣고 관리하는 것이 괜찮을 수도 있다. 간단히 생각해보면 아래 코드 정도가 아닐까.

ZStack {
//리스트들...
VStack {
Rectangle().frame(height: UIApplication.shared.windows.first?.safeAreaInsets.top)
.edgesIgnoringSafeArea(all)
Spacer()
}
}

이런 식으로 해서 조건에 맞춰 Rectangle() 을 조정할 수 있지 않을까 싶다.

대한민국 사진도 Sticky하게 만들기

상단 사진에 대한 계산은 어떻게 하면 좋을지 살펴 보자.

우선 상단 사진의 위치에 대한 정보를 알아내기 위해 GeometryReader를 달아준다.

상단 이미지를 가지고 있었던 ZStack 전체에 GeometryReader를 씌우고, 내부에서는 offset 을 설정하도록 했다. geometry.frame(in: .global).minY 를 이용해 현재 ScrollView 상에서 배정받은 위치의 가장 윗점을 추출할 수 있다.

그런데 개인적으로는 아까처럼 ScrollView에다가 .clipped() 를 사용하지 않고 Safe Area에서도 보이게 하고 싶다. 그래서 아까 말했던 방법을 조금 응용해서 사용해보려고 하는데, 그걸 체크하기 위해 offset 정보를 State로 관리하고 싶다.

하지만 GeometryReader 안에 있는 offset 값을 그 밖에서 관리하기 좀 난감한 점이 있는데, 이를 해결하기 위해 구글을 뒤적이다가 방법을 찾았다.

우선 변경된 점을 조금씩 짚어보자.

첫 번째는 @State private var offsetY 가 생겼다는 점이다. 이 offsetY 를 이용해 어느정도 위치해있는지 체크하려고 한다.

하지만 GeometryReader 안에서 offsetY = offset 같은 설정을 할 수는 없다. 따라서 setOffset(offset: CGFloat)메서드를 만들어냈다.

이 메서드는 EmptyView()를 반환함으로써 View 구성에 영향을 주지 않으면서도 메서드를 실행할 수 있다. setOffset 안에서도 offsetY 를 설정하도록 했다.

그리고 세 번째는 .overlay() 안의 Rectangle이다. 저 위에서 한 번 언급한 방법을 overlay를 이용해 구현했는데, offsetY 가 -250보다 작아지면 보여지도록 했다. 250인 이유는 GeometryReader의 최소 높이를 코드상 250으로 설정해두었기 때문(frame(minHeight: 250))이다.

이렇게 하면 이제 Section Header가 위로 올라갔을 때 아까와 마찬가지로 Safe Area가 흰색으로 막힌다.

그러면 이제 진짜 상단 사진도 Safe Area에 가도록 해보자.

ScrollView만 한번 떼서 와봤다.

여기서 변경된 점은 중간에 .frame.offset 이 추가된 것이다. 그 부분만 다시 써보면,

.frame(
width: geometry.size.width,
height: 250 + (offset > 0 ? offset : 0)
)
.offset(y: (offset > 0 ? -offset : 0))

우선 width는 geometry.size.width 로 설정해줘야 이미지가 중앙 정렬이 되었다. 이유는 정확히 모르겠다…

일단 높이를 250으로 설정했고, offset 이 0보다 클 경우에는 이미지의 크기가 그에 맞춰 커지도록 했다. 250 + (offset > 0 ? offset : 0)

하지만 이렇게 하면 사진의 상단 부분도 같이 끌려서 내려오게 된다. 그래서 사진의 상단 부분을 화면 최상단에 딱 맞춰주기 위해 .offset(y: (offset > 0 ? -offset : 0)) 으로 밑으로 내린 만큼 사진을 위로 올려주도록 설정했다.

이렇게 코딩하면 아래처럼 된다.

잘 되는 모습이다.

일단 이정도로 만족은 하겠지만, 이렇게 만들었을 때 있는 함정 카드와 아쉬운 점이 조금 남아있긴 하다.

첫 번째는 offset 의 값이다. 기본 값이 250 정도라고 기대하고 있을텐데, 사실 Safe Area 때문에 사진의 Y 좌표 offset 값은 47 정도가 된다. 근데 우리가 사진의 높이를 250 + (offset > 0 ? offset : 0) 으로 해두었기 때문에 기본적으로 297이 된다.

하지만 실제로 VStack이 이 사진이 차지하고 있다고 판단하고 있는 공간은 Y 좌표 47 ~ 297 사이고, 사진 사이즈가 커진 것 뿐이라 수치를 이상하게 조정하면 밑의 리스트와 겹치거나 너무 멀어지는 문제가 생기기 쉬웠다. 이게 Section Header와 Safe Area의 높이가 비슷해서 크게 티가 안나는 것 같긴 하다.

또 아쉬운 점은 스크롤을 위로 올릴 때 상단 사진의 영역이 점점 줄어들다가 0까지 사라지는 것을 만들지 못했다는 점이다. 297에서 250까지 사진 높이가 줄어드는 동안은 내가 생각한 것처럼 움직여지지만, 그 이하는 랜더링 문제인지 minHeight 를 더 낮게 설정할 시 리스트와 상단 사진이 겹친다든가 하는 문제가 발생했다.

이런 문제가 있으니 혹시 추가적인 모션을 넣고 싶으신 분들은 좀 더 검색이나 연구를 해보시는 것이 좋겠다.

결론

사실 위 문제도 다 해결해서 올렸으면 좋았을텐데 이번주에 꽤 고생을 해서 적당히 만들다 포스팅하게 됐다. 뭔가 더 좋은 방법을 찾으면 추가 게시물을 작성하도록 하겠다.

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science