이전 글

 

소개

UIViewReprestable로 감싼 웹뷰(WKWebView)의 스크롤 상태에 따라 하단 툴바 및 주소창 모양이 변하도록 하는 예제입니다.

Animated GIF - Find & Share on GIPHY

이전 예제에서 해당 동작을 @State로 구현하는 방법을 설명하였습니다. 이번 포스트는 실제로 웹뷰의 스크롤 상태에 따라 하단 툴바가 확장/축소되기 위한 과정을 진행합니다.

 

방법

  1. UIViewReprestable로 감싼 웹뷰(WKWebView) 생성
  2. 메인 뷰에 웹뷰 추가
  3. 웹뷰에서 코디네이터(Coordinator) 클래스를 만들고, 해당 클래스가 UIScrollViewDelegate를 준수
  4. 코디네이터 클래스 내에 위임자 메서드를 추가
  5. 해당 메서드가 실행되면 웹뷰 초기화시에 지정한 콜백 함수를 실행
  6. 콜백 함수의 파라미터 결과에 따라 하단 상태 결정

 

(1) UIViewReprestable로 감싼 웹뷰(WKWebView) 생성
import SwiftUI
import WebKit

struct WebRepresentableView: UIViewRepresentable {
    typealias UIViewType = WKWebView
    typealias WebViewHandler = ((WebViewScrollState) -> Void)
    
    var webViewHandler: WebViewHandler?
    private let webView = WKWebView(frame: .zero)
    
    init(webViewHandler: WebViewHandler? = nil) {
        self.webViewHandler = webViewHandler
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let request = URLRequest(url: .init(string: "https://en.m.wikipedia.org/wiki/Symphony_No._1_(Prokofiev)")!)
        webView.load(request)
        webView.scrollView.delegate = context.coordinator
        
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {}
}
  • webViewHandler
    • 웹 뷰의 상태가 변화되면 해당 상태를 파라미터로 전달받는 클로저 함수로, 여기에 상태 변화후 할 작업을 지정하면 됩니다.
    • WebViewScrollState은 다음과 같습니다.
      enum WebViewScrollState {
          case scrollUp, scrollDown, zoomIn, zoomOut, reachBottom
      }
    • SwiftUI의 뷰에서 Representable로 데이터를 보내는 과정복잡하지만, Representable로부터 데이터를 받는 것은 위와 같이 클로저 함수만으로도 가능하므로 상대적으로 덜 복잡합니다.
  • webView.scrollView.delegate = context.coordinator
    • delegate로 현재 컨텍스트(=>WebRepresentableView)의 coordinator를 지정합니다.
    • 해당 위임자의 메서드를 사용해 스크롤 상태를 감지할 것입니다.

 

(2) 메인 뷰에 웹뷰 추가
/// Body
var body: some View {
    NavigationStack {
        VStack(spacing: 0) {
            WebRepresentableView { state in
                // ... //
            }
            
            if showToolBar {
                urlLargeArea
            } else {
                urlShrinkedArea
            }
        }
        // ... //
    }
}
  • 이전 포스트의 메인 뷰의 body 내에 웹뷰를 추가합니다.

 

(3) 코디네이터 클래스 생성
import SwiftUI
import WebKit

struct WebRepresentableView: UIViewRepresentable {
    // ... //
    
    typealias WebViewHandler = ((WebViewScrollState) -> Void)
    var webViewHandler: WebViewHandler?

    // ... //

    func makeCoordinator() -> Coordinator {
        Coordinator(webViewHandler: webViewHandler)
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        var webViewHandler: WebViewHandler?
        private var isScrollViewReachedBottomOnce = false
        
        init(webViewHandler: WebViewHandler? = nil) {
            self.webViewHandler = webViewHandler
        }
        
        // MARK: - UIScrollViewDelegate
        // ... //
    }
}

#Preview {
    WebRepresentableView()
}
  • 코디네이터 클래스를 만들면 context.coordinator를 통해 접근할 수 있습니다.
  • Coordinator 클래스는 UIScrollViewDelegate를 준수해야 스크롤 상태 변화와 관련된 메서드를 추가할 수 있습니다.
  • isScrollViewReachedBottomOnce는 스크롤 뷰가 바닥을 찍었는지 여부를 판별하는 변수이며 잠시 뒤에 설명합니다.

 

(4) 코디네이터 클래스 내에 위임자 메서드를 추가

이전 포스트에서 하단 주소창 툴바의 상태를 다음과 같이 정의했습니다.

 

이러한 상태를 파악하려면 아래의 4개 메서드 구현이 필요합니다.

  1. scrollViewDidScroll – 스크롤 동작이 진행되고 있을 때마다 실행
  2. scrollViewWillBeginDragging – 스크롤 뷰에서 사용자가 드래그(스와이프)를 하면 화면이 움직이기 바로 직전에 실행
  3. scrollViewDidEndDragging  – 드래그 동작이 실질적으로 완료되면 실행
  4. scrollViewDidZoom – 스크롤 뷰가 확대 또는 축소되고 있을 때마다 실행
    // MARK: - UIScrollViewDelegate
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if(scrollView.panGestureRecognizer.translation(in: scrollView.superview).y > 0) {
            webViewHandler?(.scrollUp)
        } else if isScrollViewReachedBottomOnce {
            webViewHandler?(.reachBottom)
        } else {
            webViewHandler?(.scrollDown)
        }
    }
    
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        if scrollViewReachedBottom(scrollView) {
            isScrollViewReachedBottomOnce = true
        }
    }
    
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        isScrollViewReachedBottomOnce = isScrollViewReachedBottomOnce && scrollViewReachedBottom(scrollView)
    }
    
    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        if scrollView.zoomScale == scrollView.minimumZoomScale {
            webViewHandler?(.zoomOut)
        } else {
            webViewHandler?(.zoomIn)
        }
    }
    
    // MARK: - Utility Methods
    
    /// 스크롤 뷰가 현재 바닥에 있는가?
    func scrollViewReachedBottom(_ scrollView: UIScrollView) -> Bool {
        scrollView.contentOffset.y >= (scrollView.contentSize.height - scrollView.frame.size.height)
    }
}
  • isScrollViewReachedBottomOnce
    • 스크롤뷰가 맨 밑까지 이동해서 끝에 있는 상태라면, 이 상태에서 최초에 또 밑으로 스크롤을 하면 true, 그 외의 상황은 false입니니다.
    • 툴바 상태 중 (3) URL 입력창과 툴바가 숨겨진 상태 + 최하단까지 스크롤된 상태에서 한 번 더 아래로 스크롤 한 경우 에 대응하기 위함입니다.
  •  scrollViewDidScroll
    • 이 메서드는 스크롤이 되고 있을때마다 계속해서 실행됩니다.
    • if문의 조건은 true이면 스크롤이 위로 이동했다는 것이며, false는 아래로 이동했다는 것입니다.
    • 위로 이동했다면 .scrollUp 상태, 반대는 .scrollDown 상태입니다.
    • 단, 아래로 이동했는데 isScrollViewReachedBottomOncetrue이면 .reachBottom이라는 특수한 상태가 됩니다.
  • scrollViewWillBeginDragging
    • scrollViewReachedBottom(_:) – 스크롤 뷰의 컨텐츠가 맨 끝에 있을 때 true를 반환합니다.
  • scrollViewDidEndDragging
    • isScrollViewReachedBottomOnce이 true 상태에서 드래그를 마쳤는데 여전히 똑같이 밑에 있을 경우, scrollViewReachedBottom(_:)true가 되며 계속 상태를 유지합니다.
  • scrollViewDidZoom
    • 스크롤 뷰의 최소 스케일이 현재 스케일과 같다면 .zoomOut, 그 외의 경우는 .zoomIn 입니다.

 

(5) 해당 메서드가 실행되면 웹뷰 초기화시에 지정한 콜백 함수를 실행
  • 4번 섹션 코드를 참고하면 webViewHandler?(.scrollUp) 등으로 특정 조건에서 콜백 함수가 실행되도록 지정하고 있습니다.
  • 이 메서드가 실행되었을 때 액션은 메인 뷰에서 지정합니다.

 

(6) 콜백 함수의 파라미터 결과에 따라 하단 상태 결정
WebRepresentableView { state in
    withAnimation(.bouncy(duration: 0.2, extraBounce: -0.2)) {
        showToolBar = switch state {
        case .scrollDown, .zoomIn:
            false
        case .scrollUp, .zoomOut, .reachBottom:
            true
        }
    }
}
  • 생성자의 파라미터로 WebViewHandler를 요구하고 있으므로 트레일링 클로저 형태로 추가되었습니다.
  • withAnimation
    • showToolBar의 상태가 바뀔 때 애니메이션이 트리거되도록 합니다.
  • showToolbar = switch state {...}
    • 해당 문법은 Swift 5.9 버전에 추가된 switch expressions를 사용하였습니다.
    • state는 코디네이터의 위임자로부터 받아온 상태이며, 이 상태에 따라 하단 툴바를 축소시킬지 확장시킬지 결정합니다.
    • 스크롤을 내리고 있을 때(reachBottom이 아닌 경우) , 확대중인 경우는 축소 모드입니다.
    • 스크롤을 올리고 있을 때, 원래 스케일로 축소된 경우, .reachBottom 모드(맨 아래에서 다시 밑으로 스크롤) 일 때엔 툴바를 확장시킵니다.

 

Animated GIF - Find & Share on GIPHY

스크롤 동작 시

 

Animated GIF - Find & Share on GIPHY

확대/축소 동작 시

 

전체 코드

WebRepresentableView.swift
import SwiftUI
import WebKit

enum WebViewScrollState {
    case scrollUp, scrollDown, zoomIn, zoomOut, reachBottom
}

struct WebRepresentableView: UIViewRepresentable {
    typealias UIViewType = WKWebView
    typealias WebViewHandler = ((WebViewScrollState) -> Void)
    
    var webViewHandler: WebViewHandler?
    private let webView = WKWebView(frame: .zero)
    
    init(webViewHandler: WebViewHandler? = nil) {
        self.webViewHandler = webViewHandler
    }
    
    func makeUIView(context: Context) -> WKWebView {
        let request = URLRequest(url: .init(string: "https://en.m.wikipedia.org/wiki/Symphony_No._1_(Prokofiev)")!)
        webView.load(request)
        webView.scrollView.delegate = context.coordinator
        
        return webView
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator(webViewHandler: webViewHandler)
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        var webViewHandler: WebViewHandler?
        private var isScrollViewReachedBottomOnce = false
        
        init(webViewHandler: WebViewHandler? = nil) {
            self.webViewHandler = webViewHandler
        }
        
        // MARK: - UIScrollViewDelegate
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            if(scrollView.panGestureRecognizer.translation(in: scrollView.superview).y > 0) {
                webViewHandler?(.scrollUp)
            } else if isScrollViewReachedBottomOnce {
                webViewHandler?(.reachBottom)
            } else {
                webViewHandler?(.scrollDown)
            }
        }
        
        func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
            if scrollViewReachedBottom(scrollView) {
                isScrollViewReachedBottomOnce = true
            }
        }
        
        func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
            isScrollViewReachedBottomOnce = isScrollViewReachedBottomOnce && scrollViewReachedBottom(scrollView)
        }
        
        func scrollViewDidZoom(_ scrollView: UIScrollView) {
            if scrollView.zoomScale == scrollView.minimumZoomScale {
                webViewHandler?(.zoomOut)
            } else {
                webViewHandler?(.zoomIn)
            }
        }
        
        // MARK: - Utility Methods
        
        /// 스크롤 뷰가 현재 바닥에 있는가?
        func scrollViewReachedBottom(_ scrollView: UIScrollView) -> Bool {
            scrollView.contentOffset.y >= (scrollView.contentSize.height - scrollView.frame.size.height)
        }
    }
}

#Preview {
    WebRepresentableView()
}

 

LikeSafariView.swift (메인 뷰)
import SwiftUI

struct LikeSafariView: View {
    @State private var showToolBar = true
    
    // 하단 툴바 배경색 강제적용
    init() {
        let toolbarAppearance = UIToolbarAppearance()
        toolbarAppearance.configureWithOpaqueBackground()
        toolbarAppearance.backgroundColor = .clear
        toolbarAppearance.shadowColor = .clear
        
        UIToolbar.appearance().standardAppearance = toolbarAppearance
        UIToolbar.appearance().scrollEdgeAppearance = toolbarAppearance
    }
    
    /// URL 창: 확대된 상태
    private var urlLargeArea: some View {
        VStack(spacing: 0) {
            // 테두리(위)
            Rectangle()
                .fill(Color(white: 228/255))
                .frame(height: 1)
            HStack {
                // 하얀색 둥근 사각형
                RoundedRectangle(cornerRadius: 10)
                    .fill(Color(white: 253/255))
                    .frame(height: 50)
                    .padding()
                    .shadow(color: .init(red: 0.8, green: 0.8, blue: 0.8), radius: 10, y: 5)
                    .overlay {
                        // 둥근 사각형 안의 내용
                        HStack {
                            Image(systemName: "character")
                            Spacer()
                            // URL 부분
                            HStack {
                                Image(systemName: "lock.fill")
                                    .foregroundStyle(.gray)
                                Text("en.m.wikipedia.org")
                            }
                            Spacer()
                            Image(systemName: "arrow.clockwise")
                        }
                        .padding(30)
                    }
            }
            .padding(.bottom, -15)
            .background(Color(white: 247/255))
            .transition(.offset(y: 100))
        }
    }
    
    /// URL 창: 축소된 상태
    private var urlShrinkedArea: some View {
        ZStack(alignment: .center) {
            // 배경색
            Color(red: 247/255, green: 247/255, blue: 247/255).ignoresSafeArea()
            VStack(spacing: 0) {
                // 테두리(위)
                Rectangle()
                    .fill(Color(white: 238/255))
                    .frame(height: 1)
                HStack(alignment: .center) {
                    Image(systemName: "lock.fill")
                        .foregroundStyle(.gray)
                    Text("en.m.wikipedia.org")
                }
                .padding(.top, 10)
                .font(.system(size: 15))
            }
        }
        .frame(height: 10)
        .transition(.offset(y: -75))
    }
    
    /// Body
    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                WebRepresentableView { state in
                    withAnimation(.bouncy(duration: 0.2, extraBounce: -0.2)) {
                        showToolBar = switch state {
                        case .scrollDown, .zoomIn:
                            false
                        case .scrollUp, .zoomOut, .reachBottom:
                            true
                        }
                    }
                }
                
                if showToolBar {
                    urlLargeArea
                } else {
                    urlShrinkedArea
                }
            }
            // 툴바(맨 아래 아이콘 5개 부분) 보이기 여부
            .toolbar(showToolBar ? .visible : .hidden, for: .bottomBar)
            // 툴바 그리기
            .toolbar {
                ToolbarItemGroup(placement: .bottomBar) {
                    HStack {
                        Button {} label: {
                            Image(systemName: "chevron.left")
                        }
                        Spacer()
                        Button {} label: {
                            Image(systemName: "chevron.right")
                        }
                        Spacer()
                        Button {} label: {
                            Image(systemName: "square.and.arrow.up")
                        }
                        Spacer()
                        Button {} label: {
                            Image(systemName: "book")
                        }
                        Spacer()
                        Button {} label: {
                            Image(systemName: "square.on.square")
                        }
                    }
                    .padding(10)
                    .padding(.top, 22)
                }
            }
        }
    }
}

#Preview {
    LikeSafariView()
}

문의 | 코멘트 또는 yoonbumtae@gmail.com


카테고리: Swift


0개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다