카테고리 없음

Troubleshooting: 모달 중첩 해제

ghnn 2025. 8. 26. 19:50

증상

  • 모달을 단계별 dismiss 체이닝으로 닫을 때
    • 애니메이션이 여러 번 재생.
    • 간헐적으로 닫힘 실패가 발생.

영향

  • 사용자 입장에서는 화면이 들썩이는 느낌 → UX 저하
  • 코드 측면에서도 completion 순서 의존 → 취약한 흐름

원인 분석

  • 단계별 체이닝은 중간 단계의 존재와 completion 타이밍에 강하게 의존
  • 시스템 피커(카메라/포토) 등 시스템 VC가 개입하면 흐름이 더 복잡
  • iPad 멀티-씬, 외부 디스플레이 등 윈도우/씬 분기도 고려 대상

대안 비교

체이닝: presentingViewController를 따라가며 하나씩 닫기

  • 장점: 이해하기 쉽고 바로 구현 가능
  • 단점: 애니메이션 다중 표시, 흐름 취약, 단계 변경에 취약

 

프레젠터 루트에서 일괄 dismiss: 현재 VC의 최상위 presenter를 찾아 한 번에 닫기

  • 장점: 모달 한 번에 종료, 애니메이션 1회
  • 고려: 씬/윈도우 구성이 복잡하면 루트 탐색 로직 필요

 

Coordinator 연계: 일괄 해제 후 루트 네비 상태 복구까지 포함한 구조화

  • 장점: 복잡한 플로우에서 재사용/가시성 높음
  • 단점: 현재 프로젝트 규모/타임라인 대비 과설계

최종 결정

  • 루트 기준 일괄 dismiss로 전환
  • 프로젝트 규모, 시한, 복잡도 대비 가성비 최상
  • iOS 15+ 이후에는 활성 UIWindowScene의 key window 루트를 기준으로 dismiss하는 방식 권장

구현

간단 해법(현재 씬 루트에서 한 번에 닫기)

// 현재 씬의 루트에서 모달 체인을 한 번에 닫기
self.view.window?.rootViewController?.dismiss(animated: true)
  • 장점: 한 줄, 적용이 즉시 가능
  • 주의: 멀티-씬 환경(iPad Split View 등)에서는 해당 씬의 window를 잡고 있는지 확인

 

iOS 15+ 안전 해법(활성 UIWindowScene 기준)

extension UIApplication {
    var activeKeyWindow: UIWindow? {
        connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .first { $0.activationState == .foregroundActive }?
            .windows.first { $0.isKeyWindow }
    }
}

// 프로젝트 전역 유틸로 사용
func dismissAllModals(animated: Bool = true, completion: (() -> Void)? = nil) {
    UIApplication.shared.activeKeyWindow?
        .rootViewController?
        .dismiss(animated: animated, completion: completion)
}

// 사용 예
dismissAllModals()
  • 포인트: “지금 사용자에게 보이는 씬”의 key window 루트에서 일괄 dismiss
  • 효과: 시스템 피커 포함 상황, iPad 멀티-씬에서도 일관된 동작

 

“프레젠터 루트”에서 닫기(대안 B) — 참고용

extension UIViewController {
    /// 나를 포함한 모달 체인을 '나를 띄운 최하단 presenter'까지 거슬러가서 한 번에 닫음
    func dismissToPresentingRoot(animated: Bool = true, completion: (() -> Void)? = nil) {
        var root = self
        while let presenter = root.presentingViewController {
            root = presenter
        }
        root.dismiss(animated: animated, completion: completion)
    }
}
  • 장점: 현재 VC 관점에서 명확
  • 주의: 씬/윈도우 분기가 섞이면 window 기반 접근이 더 안정적

 

Coordinator와 연계(대안 C) — 규모 확대 시

dismissAllModals { [weak self] in
    // 루트 화면 상태 복구(예: 홈 탭으로 이동)
    self?.appCoordinator.switch(to: .home)
    self?.appCoordinator.rootNavigationController.popToRootViewController(animated: false)
}
  • 언제? 큰 앱에서 “모달 해제 + 네비 상태 재구성”이 반복될 때

테스트 & 체크리스트

디바이스/씬

  • iPhone(일반, SE 3세대 등 소형 기기)
  • iPad 11″, 13″ / Split View / Slide Over
  • 외부 디스플레이 연결(가능 시)

 

플로우

  • 피커(카메라/포토) → 편집 → 확인 → 한 번에 닫힘
  • 중간 단계 스킵(예: 확인 없이 취소)에서도 항상 1회 애니메이션
  • 닫힌 뒤 원래 화면 상태 정상 (스크롤 위치, 탭/네비 선택 등)

 

안정성

  • 모든 dismiss 호출이 메인 스레드
  • isModalInPresentation = true인 화면에서 명시적 dismiss만 허용되는 점 확인
  • VoiceOver 사용 시, 닫힌 뒤 초점 이동이 예상대로 동작

결과 & 효과

  • 애니메이션 1회로 단순화 → 화면 전환이 자연스러워짐
  • 닫힘 실패 케이스 제거 → 흐름 신뢰성 상승
  • 구현 난이도 대비 효과가 커, UX/안정성/유지보수성 모두 개선

회고

  • “빨리 만들 수 있는 체이닝”보다 장기적으로 안정적인 루트 기반 dismiss가 유지보수에 유리.
  • iOS 13 이후 멀티-씬 지원 환경에는 window/scene 맥락을 정확히 잡는 습관이 중요.
  • 규모가 커지면 Coordinator에 일괄 dismiss + 상태 복구를 공통화하는 편이 팀 생산성을 높임.