SWIFTUI/Others

SwiftUI : ScrollToTop 맨위로 버튼

서근 2021. 4. 28. 22:31
반응형

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 가져오기

scrollViewoffset값을 구해서 가져와야 합니다. 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를 사용하여 ScrollViewoffset값을 가져오려고 하는데 비동기 처리 방식인 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 보다 작을때 버튼 생성

만약 offset450보다 작을때 버튼이 나가게 할 수 도 있습니다. 먼저 액션은 나중에 호출하기로 하고 .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")

그런 다음에 ButtonAction 부분에 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 👇🏻

 

Seogun95/SwiftUI_ScrollToTop

ScrollTop 버튼을 구현해보겠습니다. Contribute to Seogun95/SwiftUI_ScrollToTop development by creating an account on GitHub.

github.com