[iOS+Unity] 유니티 뷰 위에 Native iOS UI 얹고 효율적으로 관리하기

Danna 다나·2023년 2월 26일
3

iOS와 Unity3D

목록 보기
2/2
post-thumbnail

유니티에서도 꽤나 괜찮은 크로스플랫폼 빌드를 지원하고 있다. 물론, iOS 플랫폼도 예외는 아니다. 설정도 그닥 어렵지 않고 클릭 몇 번이면 유니티에서 작성한 코드가 iOS 앱으로 빌드된다.

하지만, 하나 무시할 수 없는 문제가 있다.
유니티는 게임엔진이지, UI에 최적화 되어 있는 것은 아니기 때문에 유니티에서 작업한 UI는 어쩔 수 없이 조금 뿌옇게 화면에 보이게 된다. 유니티가 최근 새로 밀고 있는 UI Toolkit도 예외는 아니다.
문제가 되는 건 구현해야 하는 게 게임 형식의 앱이 아닌 일반 앱의 UI를 가져야 할 때다. 3D 뷰가 필요하지만 게임이 아닌 심플한 메타버스 형식의 앱들이 그 예시가 될 수 있을 것 같다. 이렇게 유니티로만 만들어진 게임이 아닌 앱들의 UI는 섬세하지 않은 눈을 가진 사람도 가장자리나 색 표현이 좀 뿌연데? 하는 느낌을 받을 수 있을 정도다.

그래서 Swift UIKit으로 구현한 iOS 앱 베이스에 일부 3D 뷰만 유니티로 구현하며, 유니티 위에 뜨는 버튼도 네이티브에서 구현하기로 결정했다.

Unity VC에 대한 이해

iOS에서 Unity 뷰를 띄울 때는 unity as a library 방식을 이용한다. 간단히 말하면 유니티를 라이브러리처럼 빌드해서 iOS 네이티브 프로젝트에 얹는 방식이다. uaal에 대한 설명은 유니티에서 공식적으로 공개한 uaal example github에 자세히 나와있다.

iOS 네이티브 코드에서 show unity를 하게 되면 Unity ViewController(줄여서 UnityVC라고 하겠습니다)가 생기고 그 위에 유니티에서 구현한 뷰가 그려지는 방식이다. 이렇게 만들어진 뷰컨트롤러는 우리가 네이티브에서 뷰컨트롤러를 사용하듯 사용할 수 있다. 해당 뷰컨트롤러에 뷰를 자유롭게 추가할 수도 있고 없앨 수도 있다는 의미다.

Unity 뷰 위에 네이티브 버튼 얹기

UnityViewController의 존재에 대해 알았으니 이제 그 뷰컨트롤러 위에 네이티브 버튼을 어떻게 얹을지 고민하면 된다.
버튼 하나만 만들어서 UnityVC에 위치를 계산해 넣어줄 수도 있을 거고, 화면 전체 크기의 뷰를 만들어 그 위에 필요한 UI를 모두 얹어준 뒤 화면 전체 크기만큼 그 뷰를 얹어줄 수도 있을 것이다.
어떤 방법을 택하든 생각해줘야 할 것은 같다.
1. UnityVC를 가져올 수 있어야 하고,
2. 그 위에 addSubview를 할 수 있어야 한다.

UnityVC 가져오기

위에서 언급한 uaal-example 코드대로 iOS 네이티브(Swift)+Unity 프로젝트를 세팅했다면, MainViewController.mm 파일을 찾을 수 있을 것이다.

여기 보면 ufw가 정의되어 있는 모습을 볼 수 있는데, 이를 중심으로 유니티 프레임워크가 관리된다.

그리고 타고 들어가다 보면 appController의 이름으로 관리되는 UnityAppController를 볼 수 있고, 또 그 UnityAppControllersms rootViewController를 관리하고 있다.
바로 이 rootViewController를 가져오면 우리는 이 뷰컨트롤러를 UnityViewController로 사용할 수 있게 된다. (그 위에 텍스트, 버튼, 뷰를 얹을 수 있게 된다.)

아래처럼 접근해주면 된다.

- (UIViewController*)getUnityController {
    return [[[self ufw] appController] rootViewController];
}

UnityManager 구축하기

어느 VC에서든 유니티 뷰에 접근할 일이 있는데, 유니티 관련 로직이 uaal example에서처럼 특정 VC 내에서만 관리된다면, 개발에 큰 어려움이 있을 것이다.
그래서 iOS native에 Unity를 통합한 사례들을 보면 UnityManager를 싱글톤으로 구현해 접근을 관리하고 있다. UnityManager를 구축하는 방법은 설명한 블로그가 많기 때문에 여기서는 따로 언급하지 않고, 예시 블로그를 첨부하겠다.

UnityManager를 구축했다면, 그 안에 위에서 정의한 getUnityController 메서드를 위치시키고, 어디서든 유니티 컨트롤러를 부를 수 있게 해주면 된다.
예시 블로그와 다르게 나는 UnityManager을 objc 코드로 구현했고, objective-c와 swift가 통신할 수 있게 브릿지를 구축해 swift 코드에서 사용할 수 있게 했다.
어디서든 아래의 코드를 입력하면 현재 떠있는 UnityVC를 가져올 수 있다.

let unityVC: UIViewController? = UnityManager.sharedInstance().getUnityController()

VC 위에 뷰 추가하기

ViewController까지 가져왔다면 그 위에 뷰를 추가하는 건 일도 아니다.
그냥 항상 하듯이 addSubview()를 이용해주면 된다.
예를 들어, UnityVC 크기만한 뷰를 위에 얹는 건 아래 코드처럼 쓸 수 있다.

let unityVC = UnityManager.sharedInstance().getUnityController()

let view = TempView(frame: (unityVC?.view.bounds)!)
unityVC?.view.addSubview(view)

UnityVC 위 네이티브 뷰 관리하기

버튼 하나씩 UnityVC 위에 추가해줄 수도 있지만, 전체화면 크기의 뷰 단위로 UnityVC 위에 추가하고 삭제하며 관리하는 게 더 효과적이다.
구현해야 하는 UI 특성상 상태에 따라 UI 버튼 구성 전체가 바뀌어야 하는 경우가 많기 때문이다.
그러나, 이 위에 얹어지는 뷰를 뷰컨트롤러로 만들 수는 없는데, 유니티뷰까지 터치이벤트까지 가야 하기 때문이다. UnityVC 위에 overlay로 ViewController를 얹을 경우 유니티에서 터치이벤트를 관리할 수 없는 한계가 있었다.

View로 관리하면 가장 큰 문제가 있는데, 바로 네비게이션 컨트롤러나 modal present, dismiss 같은 뷰컨트롤러 방식대로 VC 스택 관리를 할 수 없다는 것이다.
직접 View를 얹고 삭제하며 관리해주어야 한다.
그래서 선택한 방법은 VC처럼 View의 계층과 생성 삭제를 관리해주는 싱글톤 ViewManager를 만들어 관리하는 방법이었다.

나는 이 관리 모듈을 UnityOverlayViewManager.swift 로 이름 지었다.
이 매니저가 해야 할 일은 크게 3개가 있다.
1. 뷰 전환하기
2. 현재 있는 뷰 위에 뷰 하나 더 쌓기
3. 뷰 삭제하기

UnityOverlayViewManager에서 뷰 전환하기

예를 들어 네비게이션 컨트롤러 위에서 관리되는 ViewController에서의 뷰 전환은 navigationController?.pushViewController(vc, animated: true)와 같은 코드로 간단하게 구현할 수 있다.
UnityOverlayViewManager에서의 뷰 전환도 이처럼 간단하게 호출할 수 있도록 구현했다.

뷰를 전환할 때 홈을 중심으로 버튼을 누르면 UI가 전환 되었다 돌아와야 하는 구조로 되어 있다. 따라서 홈은 계속해서 추가, 삭제 되지 않아야 했다.

다른 뷰로 전환될 때 홈은 hidden 처리 되고, 홈이 아닌 다른 뷰들은 removeFromSuperView 되게 구현했다.
모든 뷰는 싱글톤 UnityOverlayViewManager의 viewStack으로 관리될 수 있도록 설계했다.

private var viewStack: [UIView] = []

우선 홈이 처음 로드될 때 우리 매니저 위에서 관리되어야 했다.

/// 가장 처음 홈이 로드될 때 호출
public func initializeHome() {
    let home = HomeView(frame: (unityVC?.view.bounds)!)
    unityVC?.view.addSubview(home)
    viewStack.append(home)
}

위 코드를 보면 홈뷰를 로드해 유니티에 addSubview 한 후 viewStack에 추가하는 모습을 볼 수 있다.

그리고 전환하는 로직은 아래 코드처럼 쓸 수 있다.
기본적인 메커니즘은 다음과 같다.
1. 홈은 뷰 추가, 삭제하지 않는다. 대신 isHidden true, false 처리를 한다.
2. 홈이 아닌 모든 뷰는 unityVC 위에 추가/삭제 한다.
3. unityVC 위 모든 뷰는 viewStack으로 관리되고 있어야 한다.

/// 홈으로 전환하기
public func presentHome() {
    guard let firstView = viewStack[0] as? HomeView else { return }
    
    firstView.isHidden = false // 홈은 addSubview 대신 숨김 false 처리
    removeAllSubviews() // 홈 이외에 모든 뷰는 삭제
}

/// (홈이 아닌) 아예 새로운 뷰로 전환하기
public func present(_ newView: UIView) {
    if let _ = newView as? HomeView {
        fatalError("HomeView로 전환할 때는 presentHome()을 사용하세요.")
    }
    
    let homeView = viewStack[0]
    homeView.isHidden = true // 홈은 뷰 삭제 대신 숨김 처리
    
    viewStack.append(newView) // 새로운 뷰를 뷰 스택에 추가
    unityVC?.view.addSubview(newView) // 새로운 뷰를 unityVC에 추가
    
    removeAllSubviews(includeLast: false) // 홈과 방금 추가한 뷰를 제외하고 모두 삭제
}
/// [모든 뷰 지우기]
/// includeLast: 다음 뷰 호출 로직이 사라져야 할 뷰 내에 있을 때를 위한 안전장치
private func removeAllSubviews(includeLast: Bool = true) {
    if includeLast {
        for view in viewStack[1...] {
            view.removeFromSuperview()
        }
    } else {
        for view in viewStack[1...].dropLast() {
            view.removeFromSuperview()
        }
    }
}

모든 뷰를 지우는 부분에서 왜 싱글톤 매니저에서 뷰스택을 관리해야 하는지 알 수 있다.
뷰 스택으로 관리되지 않으면 매번 unityVC.view.subviews로 뷰를 가져와야 할 텐데, 뷰를 추가할 때마다 viewStack에 그 reference를 등록해둔 덕에 바로바로 접근해 삭제가 가능하다.

하나 이상해 보일 수 있는 부분이 있을 수 있는데, present 로직에서 왜 모든 뷰를 삭제한 후에 새로운 뷰를 더하지 않고, 새로운 뷰를 더한 후에 나머지 뷰들을 삭제하냐는 것이다.
그 이유는 예를 들어 A라는 뷰가 있고, A라는 뷰가 삭제되고 B라는 뷰로 전환되어야 하는 경우가 있다고 가정한다. 그럼 A라는 뷰에서 B 뷰로 전환하는 과정에서 B 뷰가 추가되기 전에 A 뷰가 삭제되는 경우가 생긴다. 이 경우를 위해 우선 필요한 모든 뷰를 addSubview 한 후 사용하지 않는 뷰를 삭제하는 순서로 로직을 전개했다.

해당 메서드를 사용해 실제로 뷰를 전환하려면 간단하게 아래처럼 쓸 수 있다.

UnityOverlayViewManager.shared.presentHome() // 홈으로 전환
UnityOverlayViewManager.shared.present(TempView()) // 홈 이외의 뷰로 전환

현재 있는 뷰 위에 뷰 하나 더 쌓기

위처럼 present까지 구현했다면 뷰를 하나 더 쌓는 건 일도 아니다.
그냥 unityVC에 뷰를 하나 더해주고 뷰 스택에 추가해주면 된다.

/// 현재 있는 뷰 위에 뷰 쌓기
public func addView(_ newView: UIView) {
    viewStack.append(newView)
    unityVC?.view.addSubview(newView)
}

뷰 삭제하기

뷰 삭제하기도 마찬가지로 복잡하지 않다.
모든 뷰를 삭제하고 viewStack에서도 제거해주면 된다.

profile
요즘은 https://welcometodannas.tistory.com/에 더 많은 글을 씁니다.

0개의 댓글