iOS) 어디에서나 사용 할 수 있는 로딩 뷰 만들기

각 화면마다 addSubView를 통해 로딩 뷰 화면을 일일히 붙여주는것은 너무 귀찮아서, 어떤식으로 하면 함수 하나를 호출하는 것만으로 로딩뷰를 띄울수 있을까 고민했습니다.

현재 생각나는 방법은 protocol의 extension을 이용해 현재 화면을 인자로 전달하고, addSubView로 화면을 추가하는 방법과 최상위뷰에 addSubView를 하여 화면을 추가하는 방법을 생각했습니다. 

 

참고로 여기서 말하는 최상위 뷰는 아래의 view hierarchy에서 맨 앞에 위치한 뷰가 아닌, 가장 뒷쪽에 위치한 View를 말합니다.

왜 뒷쪽에 있는 view를 최상위뷰라고 하냐면 뷰를 추가하면 아래와 같이 최상위 뷰에서 뷰를 추가를 하면 아래 사진처럼 탑다운 형식이 되기에 가장 뒤에있는 뷰를 최상위 뷰라고 하는게 아닐까라고 생각한다.

최상위 뷰는 view hierarchy에서 보면 알 수 있듯이 UIWindow입니다. UIWindow는 UIView를 상속 받아서 addSubView를 할 수있습니다. UIWindow의 바로 하위 뷰로 UITransitionView가 있는것을 보면 알 수 있습니다. 

 

따라서 UIWindow에 로딩뷰를 addSubView를 하면 뷰의 순서는 위에서 아래로 향하므로 로딩뷰가 제일 앞에 있는 형태가 될것입니다. 우선은 로딩뷰를 추가할 UIWindow를 가져옵니다.

import UIKit

public class LoadingView {
    
    static let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene

}

그 다음 로딩뷰를 선언해줍니다. 여기서 타입 프로퍼티로 선언한 이유는 로딩뷰는 작동할 동안 다른 작업을 수행하지 않습니다. 여러개의 로딩뷰가 필요 없는거죠. 따라서 인스턴스화 할 필요 없이 타입 메소드 형식으로 구현을 합니다. 로딩 화면이 타입 프로퍼티로 선언됨에 따라 show와 hide 함수 역시 타입 메소드로 정의해줍니다.

import UIKit

public class LoadingView {

    static var spinner: UIActivityIndicatorView?
    
    static let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene

    public static func show() {
    
    }

    public static func hide() {
    
    }
}

show함수부터 구현해봅시다. 로딩뷰가 화면 전체를 덮을 수 있게 window의 bounds를 가져오고, 이를 이용해 UIActivityIndicatorView를 생성합니다. 로딩이 완료되어 애니메이션이 정지 되었을때 화면에서 로딩뷰를 숨기기 위해 hideWhenStopped를 true로 설정해줍니다. 그리고 windowScene에서 keyWindow를 가져와서 화면에 추가하고 애니메이션을 시작해줍니다. 

import UIKit

public class LoadingView {

    static var spinner: UIActivityIndicatorView?
    
    static let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene

    public static func show() {
            if spinner == nil{
                guard let windowScene = windowScene else { return }
                let frame = windowScene.screen.bounds
                let spinner = UIActivityIndicatorView(frame: frame)
                spinner.backgroundColor = UIColor.black.withAlphaComponent(0.5)
                spinner.style = .large
                spinner.color = .white
                spinner.hidesWhenStopped = true
                windowScene.keyWindow?.addSubview(spinner)
                spinner.startAnimating()
                self.spinner = spinner
            } else {
                guard let spinner = spinner else { return }
                spinner.startAnimating()
            }
    }

    public static func hide() {
    
    }
}

이제는 로딩뷰를 화면에서 숨길 hide를 구현해봅시다. 화면에 숨기는 방법은 hideWhenStopped을 이용해 애니메이션이 stop일 때 화면에서 자동으로 hidden 상태로 변하는 것과, removeFromSuperView로 화면 자체를 제거하는 방식이 있습니다. 화면 자체를 제거했을 경우, ARC에 의해 생성해둔 UIActivityIndicatorView는 메모리상에서 지워지게 되므로, 다시 생성해야 합니다. 반면에 hideWhenStopped를 이용하면, 메모리상에 계속해서 남아있게 되지만, 로딩 화면을 불러올때마다 startAnimating만 실행하면 다시 화면에 표시할 수 있습니다. 두 가지 방법 둘다 각자만의 장단점이 있는것 같습니다. 저는 전자의 방법으로 구현했습니다.

import UIKit

public class LoadingView {

    static var spinner: UIActivityIndicatorView?
    
    static let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene

    public static func show() {
            if spinner == nil{
                guard let windowScene = windowScene else {return}
                let frame = windowScene.screen.bounds
                let spinner = UIActivityIndicatorView(frame: frame)
                spinner.backgroundColor = UIColor.black.withAlphaComponent(0.5)
                spinner.style = .large
                spinner.color = .white
                spinner.hidesWhenStopped = true
                windowScene.keyWindow?.addSubview(spinner)
                spinner.startAnimating()
                self.spinner = spinner
            } else {
                guard let spinner = spinner else { return }
                spinner.startAnimating()
            }
    }

    public static func hide() {
            guard let spinner = spinner else { return }
            spinner.stopAnimating()
    }
}

이제 한번 프로젝트에 적용시켜 보겠습니다. 저는 mvvm 패턴의 viewModel에서Rxswift의 BehaviorRelay를 통해 API에서 이미지 리스트를 가져오는 작업이 시작하면 true로 설정하고, 작업이 완료 됬을 경우 false로 값을 변경하는 Relay를 이용해서 바인딩을 해주었습니다.

    private func bind() {
        viewModel?.isLoading.asObservable().subscribe(onNext: { element in
            self.updateLoading(element)
        }).disposed(by: disposeBag)
    }
    
    private func updateLoading(_ loading: Bool) {
        if loading {
            LoadingView.show()
        } else {
            LoadingView.hide()
        }
    }

실행영상