최근 SwiftUI로 View를 구현하는 작업을 하고 있는데, 화면 전환을 구현하다 보니 Coordinator를 사용 못하는 순간이 때때로 발생했다.
이런 상황에서 pop동작을 실행하기 위해 dismiss를 사용했는데, sheet로 띄운 창을 닫을 때나 NavigationStack에서 이전 화면으로 돌아갈 때 모두 동일하게 @Environment(\.dismiss)를 사용하는 것을 보고, "왜 이게 둘 다 작동하지?"라는 의문이 생겼다.
그 궁금증을 해결하기 위해 @Environment의 개념부터 dismiss의 내부 작동 원리까지 제대로 정리해보자.
dismiss 하나로 모두 처리가 가능@Environment는 SwiftUI에서 뷰 계층 구조를 통해 전달되는 데이터에 접근할 때 사용한다. 부모 뷰가 자식 뷰에게 일일이 주입(Injection)하지 않아도, 시스템이 관리하는 환경 변수 저장소에서 필요한 값을 꺼내 쓸 수 있게 해준다.
| 주요 환경 변수 | 역할 |
|---|---|
colorScheme | 다크 모드 / 라이트 모드 상태 확인 |
locale | 사용자의 지역 및 언어 설정 |
editMode | 현재 리스트 등이 편집 모드인지 확인 |
dismiss | 현재의 프레젠테이션 컨텍스트를 닫는 액션 |
@Environment(\.dismiss)를 통해 얻게 되는 변수의 실제 타입은 DismissAction이라는 이름의 구조체이다.
SwiftUI 내부적으로는 대략 아래와 같이 정의되어 있다.
@MainActor @preconcurrency public struct DismissAction {
@MainActor @preconcurrency public func callAsFunction()
}
이는 DismissAction이 가지고 있는 callAsFunction() 메서드와 연관이 있다.
Swift에서는 구조체나 클래스 내부에 callAsFunction() 이라는 이름의 메서드를 정의하면, 해당 타입의 인스턴스를 변수 이름 뒤에 괄호()를 붙여 직접 호출할 수 있다.
즉, @Environment(\.dismiss) var dismiss라는 이름의 인스턴스가 있을 때, 이는 DismissAction 타입이고 내부적으로 callAsFunction()메서드가 구현되어 있기 때문에 dismiss() 같은 식으로 메서드를 사용할 수 있게 된다.
// dismiss()를 호출하는 것은 내부적으로는 아래와 같다.
dismiss() == dismiss.callAsFunction()
단순히 present된 뷰를 닫기만 하는 역할이라면 함수나 클로저로 구현해도 될텐데, 굳이 구조체로 만든 이유가 무엇일까 생각해 보았다.
추정하기론 확장성과 명확성 때문이라고 생각한다.
dismiss에 닫기 기능 외에 추가 속성(닫기가 가능한지 확인 등)을 구현하고 싶어진다면, 프로퍼티만 추가하면 되기 때문에 함수나 클로저 형태보다 구조체로 구현하는 것이 유리하다.왜 Sheet와 NavigationStack 모두에서 @Environment(\.dismiss)가 정상작동 하는지 알려면
먼저 @Environment(\.dismiss)를 호출하면 어떤 일이 벌어지는지 이해해야 한다.
dismiss 액션이 실행되면 SwiftUI는 현재 뷰가 어떤 'Presentation Context' 안에 있는지 확인한다.sheet로 띄워진 것인지, NavigationStack에 의해 쌓인(Pushed) 것인지 파악한다.isPresented 바인딩 값을 false로 변경하여 모달을 내린다.pop 하여 이전 뷰로 돌아간다.핵심 포인트: SwiftUI 내부적으로
dismiss는 특정 전환 방식에 종속된 도구가 아니라, "지금 보고 있는 이 화면을 치워줘"라는 추상화된 명령을 수행하는 것이다.
궁금했던 "왜 네비게이션에서도 dismiss가 되는가?"에 대한 답은 SwiftUI의 Presentation 환경 변수 관리 방식에 있다.
iOS 15 이전에는 네비게이션을 위해 presentationMode를 사용했으나, iOS 15부터 dismiss로 통합되었다. NavigationStack에서 뷰가 Push될 때, SwiftUI는 해당 자식 뷰의 환경 변수에 "너는 지금 스택에 쌓여 있는 상태야"라는 정보를 주입한다.
따라서 dismiss()를 호출하면 SwiftUI는 "아, 이 뷰는 스택의 최상단에 있구나. 그럼 스택에서 제거(Pop)해야지"라고 판단하여 동작하는 것이다.
즉, Sheet와 Navigation을 별개의 기능이 아닌 '사용자에게 보여지는 상태(Presentation)'라는 큰 틀에서 동일하게 관리하기 때문에 가능한 일이다.
struct ModalView: View {
@Environment(\.dismiss) private var dismiss // 환경 변수에서 꺼내옴
var body: some View {
VStack {
Text("이것은 모달입니다.")
Button("닫기") {
dismiss() // 호출 시 isPresented를 false로 만듦
}
}
}
}
struct DetailView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
Text("상세 화면")
Button("뒤로 가기") {
dismiss() // 호출 시 NavigationStack에서 Pop 처리
}
}
.navigationBarBackButtonHidden(true) // 기본 뒤로가기 버튼 숨김 시 유용
}
}
@Environment(\.dismiss)는 SwiftUI의 강력한 추상화를 보여주는 대표적인 예시다.
개발자가 "이 뷰가 어떻게 띄워졌지?"를 일일이 분기 처리할 필요 없이, 단순히 "현재 화면을 닫아줘"라는 의도만 전달하면 시스템이 상황에 맞춰 적절히 동작한다.
다만, 여러 뎁스의 화면을 한 번에 뛰어넘어야 하는 'Root로 가기' 같은 로직에서는 dismiss만으로는 한계가 있으므로, 이때는 NavigationPath를 직접 관리하는 방식을 사용해야 한다.
popViewController 같은 UIKit 명령어가 그리웠는데, 쓰다 보니 dismiss 하나로 다 되는 게 참 편하다. 역시 SwiftUI는 배우면 배울수록 '선언형'이라는 이름값을 하는 것 같다.