이전 글
소개
UIViewReprestable
로 감싼 웹뷰(WKWebView
)의 스크롤 상태에 따라 하단 툴바 및 주소창 모양이 변하도록 하는 예제입니다.
이전 예제에서 해당 동작을 @State로 구현하는 방법을 설명하였습니다. 이번 포스트는 실제로 웹뷰의 스크롤 상태에 따라 하단 툴바가 확장/축소되기 위한 과정을 진행합니다.
방법
UIViewReprestable
로 감싼 웹뷰(WKWebView
) 생성- 메인 뷰에 웹뷰 추가
- 웹뷰에서 코디네이터(
Coordinator
) 클래스를 만들고, 해당 클래스가UIScrollViewDelegate
를 준수 - 코디네이터 클래스 내에 위임자 메서드를 추가
- 해당 메서드가 실행되면 웹뷰 초기화시에 지정한 콜백 함수를 실행
- 콜백 함수의 파라미터 결과에 따라 하단 상태 결정
(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
- 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개 메서드 구현이 필요합니다.
scrollViewDidScroll
– 스크롤 동작이 진행되고 있을 때마다 실행scrollViewWillBeginDragging
– 스크롤 뷰에서 사용자가 드래그(스와이프)를 하면 화면이 움직이기 바로 직전에 실행scrollViewDidEndDragging
– 드래그 동작이 실질적으로 완료되면 실행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
상태입니다. - 단, 아래로 이동했는데
isScrollViewReachedBottomOnce
가true
이면.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
모드(맨 아래에서 다시 밑으로 스크롤) 일 때엔 툴바를 확장시킵니다.
스크롤 동작 시
확대/축소 동작 시
전체 코드
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() }
0개의 댓글