
안녕하세요 L3C4Y4입니다. 이번 포스팅에서는 위 영상처럼 SwiftUI앱의 뷰 계층구조에서 가장 위에 오버레이를 띄우고 싶을 때가 있는데요. 그 방법에 대해 정리해보았습니다.
오버레이를 띄우는 방법전에 간단히 어떻게 구조가 이루어지는 지에 대해 간단히 정리해보겠습니다.

iOS 13부터 생겨난 UIWindowScene입니다. UIWindowScene이 여러 개의 UIWindow를 관리할 수 있는데요.
이를 활용해서 현재 앱의 UIWindowScene을 가져와서 새로운 UIWindow를 생성하여 하위 UIView에 오버레이로 띄울 뷰들을 추가해줄겁니다.
앱이 실행되고 View가 나타날 때 오버레이를 띄울 UIWindow를 생성하기 위해 ContentView()를 감싸고 있는 RootView에서 UIWindow 셋업을 진행합니다
// MARK: 오버레이를 띄울 Root View
struct RootView<Content: View>: View {
var content: Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content()
}
var body: some View {
content
.onAppear {
setupWindow()
}
}
private func setupWindow() {
// 윈도우 셋업
}
}
@main
struct AppWideOverlayApp: App {
var body: some Scene {
WindowGroup {
RootView {
ContentView()
}
}
}
}
RootView를 생성한 이유는 App 파일이 다른 변수와 함수들로 복잡해 보이지 않게 하기 위함입니다. RootView를 생성하지 않고 App 파일에서 Window 셋업을 해도 상관없습니다.
먼저 오버레이 Window에 추가할 뷰들과 UIWindow 생성 여부를 알기 위한 @Observable 객체를 생성해줄겁니다.
// MARK: 오버레이 뷰들을 관리
@Observable
final class AppWideOverlayStore {
// 오버레이 뷰들을 관리할 UIWindow
var window: UIWindow?
// 여러 개의 오버레이뷰들을 관리하기 위해 배열로 선언
var views: [OverlayView] = []
struct OverlayView: Identifiable {
var id: String = UUID().uuidString
var view: AnyView
}
}
이후 RootView에서 앞서 생성한 AppWideOverlayStore에 오버레이로 띄울 뷰들을 넣을 AppWideOverlayStore.views를 보여주는 AppWideOverlayViews 뷰로 rootViewController를 설정해줍니다.
struct RootView<Content: View>: View {
var content: Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content()
}
private var appWideOverlayStore = AppWideOverlayStore()
var body: some View {
content
.environment(appWideOverlayStore)
.onAppear {
setupWindow()
}
}
private func setupWindow() {
// window가 nil이라면 최초로 생성하는 상태
guard appWideOverlayStore.window == nil,
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
// UIWindow 생성
let window = UIWindow(windowScene: windowScene)
window.isHidden = false
window.isUserInteractionEnabled = true
// 오버레이 뷰들을 보여주는 뷰들을 rootViewController로 설정
let rootViewController = UIHostingController(rootView: AppWideOverlayViews().environment(appWideOverlayStore))
rootViewController.view.backgroundColor = .clear
window.rootViewController = rootViewController
appWideOverlayStore.window = window
}
}
// MARK: 여러 오버레이 뷰들을 보여주는 뷰
fileprivate struct AppWideOverlayViews: View {
@Environment(AppWideOverlayStore.self) private var appWideOverlayStore
var body: some View {
ZStack {
ForEach(appWideOverlayStore.views) {
$0.view
}
}
}
}
이제 오버레이로 띄울 뷰들을 추가해주기 위해서 ViewModifier를 사용할겁니다.
fileprivate struct AppWideOverlayModifier<ViewContent: View>: ViewModifier {
@Environment(AppWideOverlayStore.self) private var appWideOverlayStore
@Binding var isShowing: Bool
var animation: Animation?
@ViewBuilder var viewContent: ViewContent
@State private var viewID: String?
func body(content: Content) -> some View {
content
.onChange(of: isShowing) { _, newValue in
if newValue {
// 뷰를 오버레이에 추가
addView()
} else {
// 뷰를 오버레이에서 삭제
removeView()
}
}
}
private func addView() {
if appWideOverlayStore.window != nil, viewID == nil {
viewID = UUID().uuidString
guard let viewID else { return }
withAnimation(animation) {
appWideOverlayStore.views.append(
AppWideOverlayStore.OverlayView(id: viewID, view: .init(viewContent))
)
}
}
}
private func removeView() {
if let viewID {
withAnimation(animation) {
appWideOverlayStore.views.removeAll(where: { $0.id == viewID } )
}
self.viewID = nil
}
}
}
extension View {
@ViewBuilder
func appWideOverlay<Content: View>(
isShowing: Binding<Bool>,
animation: Animation? = nil,
@ViewBuilder content: @escaping () -> Content
) -> some View {
self
.modifier(
AppWideOverlayModifier(
isShowing: isShowing,
animation: animation,
viewContent: content
)
)
}
}
// SomeView
Button("오버레이 토글버튼") {
}
.appWideOverlay(isShowing: $show, animation: .snappy) {
OveralyView()
}
위처럼 생성해주고 실행을 하면 터치나 스크롤 이벤트들이 아무것도 안먹힙니다.
새로 생성한 UIWindow가 원래의 Main Window위에 위치하기 때문에 Main Window에 이벤트 전달이 안되기 때문입니다.
이를 해결하기 위해 새로 생성한 Window위에 이벤트를 줘도 아래로 통과할 수 있게 해주는 커스텀 UIWindow를 생성해줍니다.
// MARK: 오버레이 뷰에 이벤트를 전달해주는 UIWIndow
fileprivate class PassThroughWindow: UIWindow {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let hitView = super.hitTest(point, with: event),
let rootView = rootViewController?.view else { return nil }
// 오버레이들 중 이벤트를 받은 곳에 오버레이가 존재하면 그 오버레이에 이벤트 전달
for subView in rootView.subviews.reversed() {
let pointInSubView = subView.convert(point, from: rootView)
if subView.hitTest(pointInSubView, with: event) != nil {
return hitView
}
}
// 존재하지 않을 경우 Main Window로 전달
return nil
}
}
private func setupWindow() {
guard appWideOverlayStore.window == nil,
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
// 이 부분을 UIWindow 대신 PassThroughWindow로 변경
// let window = UIWindow(windowScene: windowScene)
let window = PassThroughWindow로(windowScene: windowScene)
window.isHidden = false
window.isUserInteractionEnabled = true
let rootViewController = UIHostingController(rootView: AppWideOverlayViews().environment(appWideOverlayStore))
rootViewController.view.backgroundColor = .clear
window.rootViewController = rootViewController
appWideOverlayStore.window = window
}
추가할 점이나 틀린 정보가 있다면 얘기해주시면 감사하겠습니다.