SwiftUI) SwiftUI앱 오버레이 띄우기 - App Wide Overlay (Global Overlay)

L3C4Y4 velog·2025년 1월 10일
0

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

UIWindowScene 그리고 UIWindow

오버레이를 띄우는 방법전에 간단히 어떻게 구조가 이루어지는 지에 대해 간단히 정리해보겠습니다.

iOS 13부터 생겨난 UIWindowScene입니다. UIWindowScene이 여러 개의 UIWindow를 관리할 수 있는데요.

이를 활용해서 현재 앱의 UIWindowScene을 가져와서 새로운 UIWindow를 생성하여 하위 UIView에 오버레이로 띄울 뷰들을 추가해줄겁니다.

오버레이를 띄울 UIWindow 생성하기

앱이 실행되고 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 셋업을 해도 상관없습니다.

오버레이 뷰들을 관리할 Observable 객체 생성

먼저 오버레이 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
    }
}

오버레이 뷰들을 관리할 UIWindow 생성

이후 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로 오버레이 관리하기

이제 오버레이로 띄울 뷰들을 추가해주기 위해서 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
}

전체 소스 코드

추가할 점이나 틀린 정보가 있다면 얘기해주시면 감사하겠습니다.

profile
iOS 관련 정보들을 정리해두었습니다.

0개의 댓글