SwiftUI : ScrollToTop 맨위로 버튼
ScrollToTop
스크롤을 내리면 우측 하단에 Top버튼이 나오고, 그 버튼을 누르면 상단으로 이동하는 버튼을 만들어 보겠습니다.
NavigationView 생성
Home struct
를 따로 만들어 놓고 body
부분에 Home
을 호출 한 뒤 NavigationView
로 감싸줍니다.
import SwiftUI
//MARK : Body
struct ContentView: View {
var body: some View {
NavigationView {
Home()
.navigationBarTitle("티스토리")
}
}
}
//Home View
struct Home: View {
var body: some View {
Text("some")
}
}
샘플 로우 생성
ScrollView
를 사용하여 샘플 Row
를 생성해주겠습니다. 지금은 Top
버튼을 만드는 것이 목표이기 때문에 이 뷰는 어느 것이 되든 상관없습니다.
//Home View
struct Home: View {
var body: some View {
ScrollView(.vertical, showsIndicators: false, content: {
VStack(spacing: 20) {
ForEach(1...30, id: \.self) { _ in
HStack(spacing: 10) {
Circle()
.fill(Color.gray.opacity(0.5))
.frame(width: 70, height: 70)
VStack(alignment:.leading, spacing: 10){
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray.opacity(0.5))
.frame(height: 20)
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray.opacity(0.5))
.frame(height: 20)
.padding(.trailing, 100)
}
}
}
}
.padding()
})
}
}
ScrollView offset 가져오기
scrollView
의 offset
값을 구해서 가져와야 합니다. VStack
외부 padding
아래에 .overlay
수정자를 사용하여 아래와 같이 코드를 작성해주겠습니다.
//Home View
struct Home: View {
// 3.
@State private var ScrollViewOffset: CGFloat = 0
// 4. 정확한 값을 위해 startOffset 생성
@State private var StartOffset: CGFloat = 0
var body: some View {
...
VStack { ... }
.padding()
// 5.
.overlay(
GeometryReader { proxy -> Color in
let offset = proxy.frame(in: .global).minY
print(offset)
return Color.clear
}
.frame(width: 0, height: 0)
,alignment: .top
)
}
시뮬레이터를 실행 해보고 디버깅 창을 확인해보겠습니다.
정상적으로 0에서부터 시작되는것을 확인할 수 있죠?
이제 GeometryReader
를 사용하여 ScrollView
의 offset
값을 가져오려고 하는데 비동기 처리 방식인 DispatchQueue
를 사용하고 StartOffset
도 정의해주겠습니다.
//Home View
// 5. GeometrtReader를 사용하여 ScrollView offset 값을 가져옴
.overlay(
GeometryReader { proxy -> Color in
DispatchQueue.main.async {
if StartOffset == 0 {
self.StartOffset = proxy.frame(in: .global).minY
}
let offset = proxy.frame(in: .global).minY
self.ScrollViewOffset = offset - StartOffset
print(self.ScrollViewOffset)
}
return Color.clear
}
.frame(width: 0, height: 0)
,alignment: .top
)
특정 offset 보다 작을때 버튼 생성
만약 offset
이 450보다 작을때 버튼이 나가게 할 수 도 있습니다. 먼저 액션은 나중에 호출하기로 하고 .overy
수정자 안에 button
을 추가하고 label
부분부터 꾸며주도록 하겠습니다.
ScrollView ( ... )
.overlay(
Button(action: {}, label: {
Image(systemName: "arrow.up")
.font(.system(size: 30))
.foregroundColor(.white)
.padding()
.background(Color(#colorLiteral(red: 0.8078431487, green: 0.02745098062, blue: 0.3333333433, alpha: 1)))
.clipShape(Circle())
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 5, y: 5)
})
//오른쪽 하단에 버튼 고정
,alignment: .bottomTrailing
)
}
그리고 패딩을 지정해줄건데 만약 베젤이 없는(iPhone X 이후 모델) 기종의 아래쪽 패딩과 베젤이 있는(iPhone 6) 모델의 패딩을 각자 다르게 적용해줘야 합니다.
//7. safeArea 함수 생성
func getSafeArea() ->UIEdgeInsets {
return UIApplication.shared.windows.first?.safeAreaInsets ?? UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
위처럼 함수를 생성해주고 button
의 패딩에 호출해주겠습니다.
.overlay(
Button(action: {}, label: {
Image(systemName: "arrow.up")
...
})
// 7-1 패딩
.padding(.trailing)
.padding(.bottom, getSafeArea().bottom == 0 ? 12 : 0)
// 7-2 StartOffset이 450보다 작으면 투명도 적용
.opacity(-ScrollViewOffset > 450 ? 1 : 0)
//오른쪽 하단에 버튼 고정
,alignment: .bottomTrailing
)
버튼 클릭시 Top으로 이동
마지막으로 Top
버튼을 클릭하면 최상위로 이동하게 만들어 보겠습니다. 먼저 해야 할 것은 ScrollView
위에 ScrollViewReader
로 감싸줘야 합니다. 그리고 스크롤 위치를 지정해줄 id
를 부여해줘야 하는데 이것은 VStack
외부에 넣어주도록 하겠습니다.
ScrollViewReader { proxyReader in
// 1.
ScrollView(.vertical, showsIndicators: false, content: {
VStack(spacing: 20) { ... }
.padding()
//9. 스크롤위치를 지정해줄 id 부여
.id("Scroll_To_Top")
그런 다음에 Button
의 Action
부분에 withAnimation
과 함께 버튼을 클릭하면 위로 이동할 수 있도록 코드를 작성해야 합니다.
.overlay(
Button(action: {
// 10. withAnimation 과함께 함수 작성
withAnimation(.default) {
// ScrollViewReader의 proxyReader을 넣어줌
proxyReader.scrollTo("Scroll_To_Top", anchor: .top)
}
}, label: {
...
})
...
)
실행
다시 시뮬레이터를 실행하고 확인해보겠습니다.
7. Preview
이제 SafeArea
부분도 제대로 베젤이 들어갔는지 확인하기 위해서 Preview
쪽도 코드를 수정해주겠습니다.
// 베젤없는 기종과 있는 기종 비교를 위한 프리뷰 설정
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone 12 Pro", "iPhone 8"], id: \.self) {
ContentView()
.previewDevice(PreviewDevice(rawValue: $0))
.previewDisplayName($0)
}
}
}
전체 코드
<hide/>
// ContentView.swift
// SwiftUI_ScrollToTop
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
Home()
.navigationTitle("티스토리")
}
}
}
// 베젤없는 기종과 있는 기종 비교를 위한 프리뷰 설정
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone 12 Pro", "iPhone 8"], id: \.self) {
ContentView()
.previewDevice(PreviewDevice(rawValue: $0))
.previewDisplayName($0)
}
}
}
struct Home: View {
// 3.
@State private var ScrollViewOffset: CGFloat = 0
// 5.
// 정확한 오프셋을 얻기 위해 startOffset을 가져옴
@State private var startOffset: CGFloat = 0
var body: some View {
//8.
// Top 스크롤 함수
// scrollview Reader 사용
ScrollViewReader { proxyReader in
// 1.
ScrollView(.vertical, showsIndicators: false, content: {
VStack(spacing: 20) {
ForEach(1...20, id:\.self) { i in
// 샘플 row 생성
HStack(spacing: 10) {
Circle()
.fill(Color.gray.opacity(0.5))
.frame(width: 70, height: 70)
VStack(alignment:.leading, spacing: 5) {
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray.opacity(0.5))
.frame(height: 20)
RoundedRectangle(cornerRadius: 5)
.fill(Color.gray.opacity(0.5))
.frame(height: 20)
.padding(.trailing, 150)
}
}
}
}
.padding()
// 9. 스크롤위치를 지정해줄 id 부여 해줘야함
.id("SCROLL_TO_TOP")
// 2.
//ScrollView offset 가져오기
.overlay(
// 4.
//GeometrtReader를 사용하여 ScrollView offset 값을 가져옴
GeometryReader{ proxy -> Color in
//6.
DispatchQueue.main.async {
//startOffset을 정해줌
if startOffset == 0 {
self.startOffset = proxy.frame(in: .global).minY
}
let offset = proxy.frame(in: .global).minY
self.ScrollViewOffset = offset - startOffset
print(self.ScrollViewOffset)
}
return Color.clear
}
.frame(width: 0, height: 0)
,alignment: .top
)
})
// 7.
// 만약 offset이 450보다 작으면 아래쪽에 버튼을 나타나게함
.overlay(
// 7-1
Button(action: {
// 10. 애니메이션과 함께 스크롤 탑 액션 지정
withAnimation(.default) {
proxyReader.scrollTo("SCROLL_TO_TOP", anchor: .top)
}
}, label: {
Image(systemName: "arrow.up")
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
.padding()
.background(Color(#colorLiteral(red: 0.8078431487, green: 0.02745098062, blue: 0.3333333433, alpha: 1)))
.clipShape(Circle())
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 5, y: 5)
})
// 7-3
.padding(.trailing)
//베젤이 있는 기종이면 패딩 12, 아니라면 0
.padding(.bottom,getSafeArea().bottom == 0 ? 12 : 0)
//이렇게도 사용가능
// .padding(.bottom, UIApplication.shared.windows.first?.safeAreaInsets.bottom == 0 ? 12 : 0)
// 7-4
// 만약 scrollViewOffset이 450보다 작으면 투명도를 적용
.opacity(-ScrollViewOffset > 450 ? 1 : 0)
.animation(.easeIn)
// 버튼을 오른쪽 하단에 고정
,alignment: .bottomTrailing
)
}
}
}
// 7-2
// 베젤이 있는 기종과 없는 고정의 safeAtrea 지정 extension
extension View {
func getSafeArea()->UIEdgeInsets{
return UIApplication.shared.windows.first?.safeAreaInsets ?? UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
}
}
읽어주셔서 감사합니다🤟
본 게시글의 전체 코드 GitHub 👇🏻