본 포스팅은 애플 디벨로퍼 아카데미 @POSTECH 테크 포럼 이벤트, '기술 글자랑 대회'의 게시글을 가져와 작성되었습니다.
안녕하세요, 3기 주니어 러너 한톨입니다! 😇
오늘은 제가 SwiftUI에서 Navigation
기능을 구현할 때 자주 마주쳤었던
문제 해결 과정을 간단하게 공유해 보려 합니다.
작은 내용이지만 누군가에게 도움이 되길 바람과 동시에,
더 좋은 해결 방법을 함께 고민해 봤으면 합니다!
SwiftUI는 기본적으로 Navigation
기능을 제공합니다.
NavigationStack
계층 내부에 NavigationLink
를 넣거나,
navigationDestination(for:destination:)
modifier를 추가함으로써
다음 화면에 대한 정의 및 기본 NavigationBar
를 생성할 수 있습니다.
import SwiftUI
struct ContentView: View {
@State private var isButtonViewPresented = false
var body: some View {
NavigationStack {
// 1. NavigationLink 방식
NavigationLink("NavigationLink") {
AView()
}
// 2. navigationDestination(for:destination:) modifier 방식
Button("Button") {
isButtonViewPresented.toggle()
}
.navigationDestination(isPresented: $isButtonViewPresented) {
BView()
}
}
}
}
사용하기 쉽고 모두에게 익숙한 NavigationBar
가 자동으로 생성되었습니다.
그와 함께 Back 버튼과 Swipe 제스처도 함께 구현되었네요.
SwiftUI가 제공하는 기본 NavigationBar
는 기능상으로 큰 문제 없이 사용이 가능합니다.
하지만 우리는, 다르게 사용해야 할 상황에 꽤나 자주 놓이곤 합니다.
UX/UI 디자인과 새로운 기능을 추가하는 등의 요구사항을 반영하려면,
Custom NavigationBar
의 필요성을 자연스레 느끼게 되죠.
저 또한 Custom NavigationBar
의 필요성을 느끼게 되었고
이를 프로젝트에 녹여내며 마주했던 문제를 예제 코드 + 실제 프로젝트와 함께 정리해 보겠습니다.
Custom NavigationBar
를 구현하며 마주한
첫 번째 문제를 아래 코드와 함께 살펴보겠습니다.
struct CustomNavigationBar: View {
let title: String
let backButtonAction: () -> Void
var body: some View {
HStack {
Button {
backButtonAction()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .bold))
}
Spacer()
Text(title)
.font(.system(size: 17, weight: .semibold))
Spacer()
Text("🥰")
}
.frame(height: 56)
.padding(.horizontal, 16)
.foregroundStyle(.white)
.background(.black)
}
}
먼저 뒤로가기 버튼, 네비게이션 제목, 귀여운 이모지로 구성한
Custom NavigationBar
예제 코드입니다.
사용은 아래와 같이 가능합니다.
struct CustomView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
CustomNavigationBar(title: "Custom Navigation") {
dismiss()
}
Spacer()
Text("CustomView")
Spacer()
}
}
}
VStack
을 사용해 CustomNavigationBar
를 최상단에 올려주었고,
뒤로가기 액션을 실행하기 위해 closure 내부에서 dismiss
환경 변수를 사용했습니다.
눈치채셨겠지만 이렇게 작성하게 되면 NavigationBar가 두 개가 생겨버립니다.
NavigationBar
CustomNavigationBar
다행히도 SwiftUI는 이를 해결할 수 있게 modifier를 제공하고 있었습니다.
private struct CustomView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
CustomNavigationBar(title: "Custom Navigation") {
dismiss()
}
Spacer()
Text("CustomView")
Spacer()
}
// ⛳️ NavigationBar의 뒤로가기 버튼을 숨겨줍니다!
.navigationBarBackButtonHidden()
}
}
첫 번째 문제가 쉽게 해결되었습니다.
이제 CustomNavigationBar
만 화면에 잘 출력되고 있네요!
하지만 늘 그렇듯 문제는 여기서 끝나지 않았습니다.
UI 테스트 중 곧바로 부자연스러움을 느끼게 되었는데,
Navigation의 뒤로가기 제스처가 동작하지 않는 문제였습니다.
즉 navigationBarBackButtonHidden
modifier는 NavigationBar와 함께,
뒤로가기 제스처도 함께 비활성화하고 있었습니다.
iOS 사용자 입장에서 친숙한 뒤로가기 제스처가 동작하지 않는 것은
사용자 경험을 떨어트리기에 충분했고, 이는 꼭 해결해야 하는 문제라 판단했습니다.
그렇게 공식 문서와 인터넷을 떠돌다 찾게 된 해결 방법은 아래와 같았습니다. (from StackOverflow)
import UIKit
extension UINavigationController: @retroactive UIGestureRecognizerDelegate {
open override func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
UIKit의 UINavigationController
를 확장(extension)해 제스처를 활성화하는 코드입니다.
위 코드 적용 시, 뒤로가기 제스처가 다시 돌아온 것을 확인할 수 있었습니다!
넘어가기 전, 위 코드의 동작 방식에 대해 명확히 이해하고 넘어가 보려 합니다.
extension UINavigationController: @retroactive UIGestureRecognizerDelegate {
...
}
UINavigationController
확장(extension)UINavigationController
를 관리하고 있습니다.UIGestureRecognizerDelegate
프로토콜 채택@retroactive
키워드의 의미 (번외!)open override func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
viewDidLoad
LifeCycle override(재정의)
UINavigationController
가 메모리에 로드 될 때,뒤로가기 제스처(PopGesture) 인식기 대리자 설정
interactivePopGestureRecognizer
의 대리자(Delegate)를 설정합니다.public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
gestureRecognizerShouldBegin(_:)
Delegate 함수 구현
UIGestureRecognizerDelegate
프로토콜의 옵셔널 메서드입니다.Should
키워드가 들어가게 되면 일반적으로 Bool
타입을 반환합니다.true
를 반환하게 되면 뒤로가기 제스처가 활성화되고, false
를 반환하게 되면 제스처가 비활성화됩니다.viewController
개수에 따른 제스처 활성화 여부 결정
true
를 반환하더라도, 동일하게 뒤로가기 제스처가 활성화됩니다.Push
된 상태여야 하기 때문에,viewController
의 개수가 2개 이상이어야만 합니다.처음엔 모든 NavigationView
에서 뒤로가기 제스처가 필요할 것으로 예상했습니다.
하지만 예상과 다르게 특정 화면에선 뒤로가기 제스처가 필요하지 않았고,
어떤 화면에선 꼭 막아야 하는 상황도 생겨났습니다.
아카데미 러너라면 누구나 사용할 수 있는 익명 기반 커뮤니티 앱,
캐플 프로젝트를 진행하며 (기습 홍보) 마주한 문제 상황은 아래와 같았습니다.
다음
버튼을 탭할 시, 회원가입 요청 API가 전송됩니다.시작하기
버튼을 탭하고 메인 화면으로 진입하는 경우, 문제가 발생하지 않습니다.다음
버튼을 탭 하면 중복 회원가입이 발생할 수 있습니다.X
버튼을 탭할 시, 경고 알림 창을 통해 사용자에게 작업 내용이 소실될 수 있음을 인지시킵니다.즉, 기본적으로 NavigationView의 뒤로가기 제스처는 유지하되,
특정 View에서의 뒤로가기 제스처의 비활성 기능이 필요했습니다.
그렇게 여러 가지 방법을 시도 후 찾게 된 방법은 다음과 같습니다.
final class PopGestureManager {
// Singleton 객체 생성
static let shared = PopGestureManager()
private init() {}
// 뒤로가기 제스처를 허용하는지 확인 변수
private(set) var isAllowPopGesture = true
// 뒤로가기 제스처를 허용하는 변수 업데이트
func updateAllowPopGesture(_ bool: Bool) {
isAllowPopGesture = bool
}
}
isAllowPopGesture
를 선언합니다.gestureRecognizerShouldBegin
Delegate 메서드에서 위 값을 사용(접근)하기 위해, Singleton
객체를 생성합니다.import UIKit
extension UINavigationController: @retroactive UIGestureRecognizerDelegate {
open override func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// ⭐️ 2가지 조건 모두 만족했을 때 뒤로가기 제스처를 활성화 시킵니다!
return PopGestureManager.shared.isAllowPopGesture && viewControllers.count > 1
}
}
gestureRecognizerShouldBegin
Delegate 메서드 내에 뒤로가기 활성화 여부를 나타내는 조건을 추가합니다.viewController
의 개수가 2개 이상일 때 뒤로가기 제스처가 가능해집니다.private struct DisableNavigationView: View {
var body: some View {
VStack {
...
}
.navigationBarBackButtonHidden()
.task {
// ⭐️ 뒤로가기 제스처를 비활성화 시킵니다!
PopGestureManager.shared.updateAllowPopGesture(false)
}
}
}
결과적으로, 특정 View에서 뒤로가기 제스처 비활성화에 성공했습니다!
하지만 걸리는 점이 있다면, 뒤로가기 제스처를 기본적으로 활성화할 View에서도
위 task
modifer를 통해 매번 값을 업데이트 해주어야 한다는 것입니다.
DisableNavigationView()
.task {
PopGestureManager.shared.updateAllowPopGesture(false)
}
NormalNavigationView()
.task {
// 🧐 모든 화면에서 이렇게 활성화 시켜줘야만 할까,,?
PopGestureManager.shared.updateAllowPopGesture(true)
}
이는 개발자가 실수로 modifier 구현을 잊을 수 있다는 말이기도 합니다.
위와 같은 실수를 방지하기 위해선, 뒤로가기 제스처를 비활성화해야 하는 View에서만
사용하는 것이 바람직해 보였습니다.
해당 문제를 해결하기 위해 화면이 사라진 후 호출이 가능한 onDisappear
modifier를 사용해
값을 다시 돌려놓음으로써 뒤로가기 제스처를 활성화시키는 코드를 추가했습니다.
private struct DisableNavigationView: View {
var body: some View {
VStack {
...
}
.navigationBarBackButtonHidden()
.task {
PopGestureManager.shared.updateAllowPopGesture(false)
}
.onDisappear {
// ⭐️ 화면이 사라진 후 뒤로가기 제스처 값을 원래대로 돌려놓습니다!
PopGestureManager.shared.updateAllowPopGesture(true)
}
}
}
마지막으로 일관성 및 재사용을 위해 ViewModifier 와 View 를 확장(extension) 해주었습니다.
// 1️⃣ ViewModifier 생성
struct PopGestureDisabledViewModifier: ViewModifier {
func body(content: Content) -> some View {
content
.task {
PopGestureManager.shared.updateAllowPopGesture(false)
}
.onDisappear {
PopGestureManager.shared.updateAllowPopGesture(true)
}
}
}
// 2️⃣ View 확장
extension View {
func popGestureDisabled() -> some View {
modifier(PopGestureDisabledViewModifier())
}
}
// 3️⃣ View에서 사용
private struct DisableNavigationView: View {
var body: some View {
VStack {
...
}
.navigationBarBackButtonHidden()
.popGestureDisabled() // ⭐️ 간단하게 modifier로 적용이 가능합니다!
}
}
SwiftUI는 선언형 프레임워크로 Naivgation
등의 기본 기능을 쉽게 적용할 수 있습니다.
하지만 기존 기능을 변경하고 싶거나 새로운 기능을 추가하고 싶을 때, UIKit을 이용해 명시적으로
여러 가지 설정을 해주어야 하는 상황에 자주 놓이게 되는 것 같습니다. (아직은 말이죠!)
더 어려운 문제를 해결하기 위해선, 두 가지 프레임워크에 대한
적정 수준의 이해도가 필요함을 다시 한번 느끼게 됩니다.
더 좋은 방법이 있거나, 개선할 수 있는 방향이 있다면 함께 논의하고 싶습니다! 감사합니다! 😃