HIT해

UIViewRepresentable 프로토콜 ,Coordinator 패턴 이해하기 본문

Swift/UIKit 개발 노트

UIViewRepresentable 프로토콜 ,Coordinator 패턴 이해하기

힛해 2025. 2. 28. 06:38
728x90

SwiftUI에서 UIKit를 함께 사용하는 UIViewRepresentable 프로토콜은 iOS 개발자라면 절대 피해갈 수 없는 관문이라 생각한다.

 

왜 자주 사용하는 걸까.

 

SwiftUI는 선언적이고 상태 관리가 쉬운 이점을 가진 반면

 

UIKit만큼 다양한 라이브러리가 없고 TextField 만 만들어봐도 제약이 많다는 것을 알 수 있다.

 

그럼 SwiftUI에서 UIKit 기능을 사용할 수 있게 해주는 UIViewRepresentable 프로토콜에 대해서 알아보자.

UIViewRepresentable이란?

UIKit뷰를 SwiftUI에서 사용할 수 있게 해주는 프로토콜이다.

 

두가지 메서드가 필요한데

  • makeUIView(context:): UIKit뷰 생성
  • updateUIView(_:context:): SwiftUI상태가 변경될 때 UIKit 뷰를 업데이트

context는 뭔데?

UIViewRepresentable 프로토콜 메서드에 전달되는 객체다. 정식적인 이름은 UIViewRepresentableContext<Self>이며 SwiftUI와 UIKit 사이의 통신을 위한 정보를 담고 있다고 한다.

  1. coordinator : makeCoordinator() 메서드를 통해 생성한 Coordinator 인스턴스에 접근할 수 있다.
  2. enviroment : SwiftUI 환경값들에 접근할 수 있다. ( 라이트/다크모드, 레이아웃 방향, 접근성 설정 등 )
  3. transaction : 현재 업데이트의 트랜잭션 정보를 포함한다. ( 한번도 써본적이 없다. )

예시

struct CustomTextField: UIViewRepresentable {
    @Binding var text: String
    var placeholder: String
    
    class Coordinator: NSObject, UITextFieldDelegate {
        var parent: CustomTextField
        
        init(parent: CustomTextField) {
            self.parent = parent
        }
        
        func textFieldDidChangeSelection(_ textField: UITextField) {
            // 텍스트 변경 시 SwiftUI 상태 업데이트
            parent.text = textField.text ?? ""
        }
        
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            textField.resignFirstResponder()
            return true
        }
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(parent: self)
    }
    
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.placeholder = placeholder
        
        // coordinator를 델리게이트로 설정
        textField.delegate = context.coordinator
        
        // 환경값에 따른 텍스트필드 스타일 조정
        if context.environment.colorScheme == .dark {
            textField.backgroundColor = UIColor.darkGray
            textField.textColor = UIColor.white
        } else {
            textField.backgroundColor = UIColor.lightGray
            textField.textColor = UIColor.black
        }
        
        return textField
    }
    
    func updateUIView(_ textField: UITextField, context: Context) {
        // 바인딩 텍스트가 변경된 경우 필드 업데이트
        textField.text = text
        
        // 환경 변경에 대응
        if context.environment.colorScheme == .dark {
            textField.backgroundColor = UIColor.darkGray
            textField.textColor = UIColor.white
        } else {
            textField.backgroundColor = UIColor.lightGray
            textField.textColor = UIColor.black
        }
    }
}

 

parent: self에서 self는 UIViewRepresentable 프로토콜을 구현한 SwiftUI 뷰 구조체 자체를 의미한다.

 

SwiftUI 부분 : CustomTextField

UIKit 부분 : makeUIView와 updateUIView 메서드 내에서 생성하는 컴포넌트들

 

SwiftUI -> UIKit

func updateUIView(_ textField: UITextField, context: Context) {
    // 바인딩 텍스트가 변경된 경우 필드 업데이트
    textField.text = text
    // ...
}

 

SwiftUI의 @Binding var text 값이 변경되면 UITextField 텍스트를 업데이트한다.

UIKit -> SwiftUI

func textFieldDidChangeSelection(_ textField: UITextField) {
    // 텍스트 변경 시 SwiftUI 상태 업데이트
    parent.text = textField.text ?? ""
}

UITextField에 타이핑을 하면 SwiftUI의 text 바인딩을 업데이트 한다.

 

초기 text와 placeholder 부분은 SwiftUI를 통해서 변수를 받아오지만 TextField의 경우 UIKit 기능을 사용한다.

 

UIViewRepresentable 프로토콜은 이처럼 makeUIView, updateUIView을 함께 구현해 양측의 다리역할을 한다.

 

이번엔 UIKit의 델리게이트나 타깃 액션과 같은 명령형 패턴을 SwiftUI에 연결해주는 Coordinator 패턴에 대해 알아보자.

 

Coordinator 패턴

SwiftUI와 UIKit 간의 상호작용을 관리하기 위해 설계된 디자인 패턴이다.

목적

  1. 이벤트 처리 : 액션, 델리게이트 패턴, 콜백 등을 SwiftUI 선언적 구조에 연결한다.
  2. 상태 유지 : SwiftUI 뷰는 값 타입이라 상태 변화에 따라 재생성되지만, Coordinator는 Class 이므로 뷰의 생명주기 동안 지속된다.
  3. 양방향 통신: UIKit 이벤트를 SwiftUI로 SwiftUI 상태 변화를 UIKit에 반영한다.

구조

  1. makeCoordinator() : Coordinator 인스턴스를 생성하는 메서드
  2. Coordinator 클래스
  3. context.coordinator : makeUIView와 updateUIView 메서드에서 Coordinator에 접근할때 사용한다.

예시

struct MyTextField: UIViewRepresentable {
    @Binding var text: String
    
    // Coordinator 클래스 정의
    class Coordinator: NSObject, UITextFieldDelegate {
        var parent: MyTextField
        
        init(parent: MyTextField) {
            self.parent = parent
        }
        
        // UIKit 델리게이트 메서드
        func textFieldDidChangeSelection(_ textField: UITextField) {
            parent.text = textField.text ?? ""
        }
    }
    
    // SwiftUI가 Coordinator를 생성하도록 요청
    func makeCoordinator() -> Coordinator {
        return Coordinator(parent: self)
    }
    
    // UIKit 뷰 생성
    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField()
        textField.delegate = context.coordinator // Coordinator를 델리게이트로 설정
        return textField
    }
    
    // UIKit 뷰 업데이트
    func updateUIView(_ textField: UITextField, context: Context) {
        textField.text = text
    }
}

 

이런 이해를 바탕으로 내가 겪은 문제를 살펴보겠다.

 

문제 상황

 

나는 SwiftUI에서 현재 속도를 받아오고 그 속도에 따라 UIKit 객체(SceneKit 모델)에 변화를 주려고 했지만 전환이 부드럽지 못했다.

struct SnowmanView: UIViewRepresentable {
    var currentSpeed: Double
    var currentSteps: Int
    
    ...
    let snowRotationSpeed = Float(currentSpeed * -0.0005)

 

SwiftUI 상에서 currentSpeed는 값이 변화하지만 UIKit(SceneKit)에서는 변화가 제대로 반영되지 않았다.

 

나는 TextField 예시와 달리 속도의 변화는 SwiftUI에서만 일어나고 UIKit은 해당 값을 받아서 객체를 빠르게 움직이기만 하면 되는 단방향이라 생각해 Coordinator를 거치지 않아도 된다고 생각했다.

 

그래서 updateUIView 메서드에서 속도 변화를 적용해보았다

func updateUIView(_ uiView: SCNView, context: Context) {
    let snowRotationSpeed = Float(currentSpeed * -0.0005)
    // 속도에 따른 회전 로직...
}

 

하지만 이렇게 했을 때 회전이 중간에 뚝뚝 끊기는 상황이 발생했다.

 

부드러운 연속적인 애니메이션이 아니라 불연속적인 회전이 이루어졌다.

 

문제의 원인

앞서말했듯 SwiftUI는 구조체로 속도 값이 변경될떄마다 뷰가 재생성되어 애니메이션이 초기화 됐다.

 

그리고 updateUIView 메서드는 독립적으로 호출되기때문애 이전에 설정한 애니메이션 참조 유지가 어려웠다.

 

Coordinator 패턴을 통한 해결

struct SnowmanView: UIViewRepresentable {
    var currentSpeed: Double
    var currentSteps: Int
    
    class Coordinator: NSObject {
        var view: SCNView?
        var currentSpeed: Double
        var rotationActionKey: String = "rotationAction"
        
        init(currentSpeed: Double) {
            self.currentSpeed = currentSpeed
            super.init()
        }
        
        func updateRotation() {
            guard let scene = view?.scene else { return }
            
            if let snowNode = scene.rootNode.childNode(withName: "SnowBody", recursively: true) {
                let rotationSpeed = Float(currentSpeed * -0.0005)
                
                // currentSpeed가 0이면 회전 멈춤
                if currentSpeed == 0 {
                    snowNode.removeAction(forKey: rotationActionKey)
                    return
                }
                
                // 새로운 회전 동작 생성
                let rotateAction = SCNAction.rotateBy(x: CGFloat(rotationSpeed), y: 0, z: 0, duration: 1)
                let repeatAction = SCNAction.repeatForever(rotateAction)
                
                // 기존 액션이 있으면 부드럽게 전환
                if snowNode.action(forKey: rotationActionKey) != nil {
                    SCNTransaction.begin()
                    SCNTransaction.animationDuration = 0.3
                    snowNode.removeAction(forKey: rotationActionKey)
                    snowNode.runAction(repeatAction, forKey: rotationActionKey)
                    SCNTransaction.commit()
                } else {
                    snowNode.runAction(repeatAction, forKey: rotationActionKey)
                }
            }
        }
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(currentSpeed: currentSpeed)
    }
    
    func makeUIView(context: Context) -> SCNView {
        let scnView = SCNView()
        let scene = SCNScene(named: "snowman.scn")
        scnView.scene = scene
        
        // Coordinator에 뷰 참조 저장
        context.coordinator.view = scnView
        
        return scnView
    }
    
    func updateUIView(_ uiView: SCNView, context: Context) {
        // Coordinator에 최신 속도 전달
        context.coordinator.currentSpeed = currentSpeed
        
        // 애니메이션 업데이트
        context.coordinator.updateRotation()
    }
}

 

뷰가 변화해도 값을 계속 가지고있는 Coordinator에 변수를 담아 문제를 해결했다.