회고

UIViewRepresentable View 레이아웃 업데이트 (UILabel Height 조절), SwiftUI lineBreakMode

elisha0103 2024. 2. 5. 19:34

SwiftUI로 프로젝트를 진행하면서 SwiftUI에서 제공해주는 기본 UIComponent로는 커스텀할 수 없는 영역도 분명 존재한다.

그래서 종종 UIKit에서 사용하는 컴포넌트를 SwiftUI View 구조체에 삽입하고는 하는데, 이때 발생한 트러블슈팅에 관련된 내용을 게시하려고 한다.

 

SwiftUI에 UIKit의 UIView를 사용하려면 UIViewRepresentable 프로토콜을 채택한 struct를 사용해야한다.

https://developer.apple.com/documentation/swiftui/uiviewrepresentable

 

기본적으로 UIViewRepresentable을 채택하면 해당 구조체는 다음 두 함수를 필수로 구현해야 한다.

 

public protocol UIViewRepresentable : View where Self.Body == Never {
	func makeUIView(context: Self.Context) -> Self.UIViewType
	func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)
}

 

- makeUIView(context: Self.Context) -> Self.UIViewType: UIKit의 UIView 타입 객체를 반환한다.

- updateUIView(_: Self.UIViewType, context: Self.Context): SwiftUI에서 View가 업데이트 될 때 UIViewRepresentable을 채택한 View에서 자동으로 호출되는 함수

 

나는 SwiftUI에서 Text View에서 글자 단위의 줄바꿈 (lineBreakMode = .byCharWrapping)을 사용하려고 했다. 하지만 SwiftUI에서는 해당 기능을 지원하지 않으므로 UIViewRepresentable을 사용하여 UILabel에 lineBreakMode를 적용하여 원하는 기능을 나타내고자 했다.

 

struct SUILabel: UIViewRepresentable {
    @Binding var labelHeight: CGFloat
    let text: String
    let color: Color
    let fontStyle: StyleType
    
    private(set) var preferredMaxLayoutWidth: CGFloat = 0
    
    func makeUIView(context: UIViewRepresentableContext<SUILabel>) -> UILabel {
        let label = UILabel()
        label.text = " Text"
        label.numberOfLines = 0
        label.preferredMaxLayoutWidth = preferredMaxLayoutWidth
        label.textColor = UIColor(color)
        label.font = UIFont.init(name: fontStyle.fontWeight, size: fontStyle.fontSize)
        label.lineBreakMode = .byCharWrapping
        label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        return label
    }
    
    func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext< SUILabel>) {
        uiView.preferredMaxLayoutWidth = preferredMaxLayoutWidth
        uiView.text = text
        DispatchQueue.main.async {
            labelHeight = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height - 12
            print("DEBUG - UILabel View labelHeight: \(labelHeight)")
        }
    }
}

 

struct ContentView: View {
    @State var labelHeight: CGFloat = .zero
    var text: String
    var fontStyle: StyleType = .caption
    var color: Color = .ppDarkGray1
    var spacing: CGFloat = 4
    
    var body: some View {
        HStack(alignment: .top, spacing: spacing) {
            Text("•")
                .foregroundColor(color)
                .pretendard(fontStyle)
            let _  = print("DEBUG - SwiftUI View labelHeight: \(labelHeight)")
            GeometryReader(content: { geometry in
                SUILabel(labelHeight: $labelHeight, text: text, color: color, fontStyle: fontStyle, preferredMaxLayoutWidth: geometry.size.width)
                    .fixedSize(horizontal: true, vertical: false)
            })
        }
        .padding(.bottom, labelHeight)
    }
}

 

makeUIView 함수에서 label을 생성하고 해당 label에 lineBreakMode를 적용했고 이를 반환하는 로직을 작성했다.

또한 SwiftUI에서 View가 업데이트될 때 실행될 updateUIView 함수를 작성했는데, UIKit에서는 UILabel에 오토레이아웃을 적용하면 입력된 text에 따라 자동으로 높이가 조절된다.

하지만 여기선 UIKit 기반의 오토레이아웃이 적용되지 않으므로 sizeThatFits을 사용하여 고정된 width 값에 따라 변경되는 height 값을 추출하고 이를 바인딩된 height 변수에 전달하여 상위 뷰인 SwiftUI View를 Redraw 하고자 했다.

 

하지만 그 결과..

정상적인 Layout 업데이트

 

잘못된 Layout 업데이트

SwiftUI에서 UILabel의 높이를 제대로 업데이트 하지 않고 있다.

아예 안된건 아니고 화면을 여러번 실행시킨 결과, 6/10의 경우는 높이가 제대로 업데이트되고 나머지는 제대로 업데이트 되지 않은 현상이 나타났다. (이거 외않되...)

 

View 구조체에서 View의 업데이트를 기록하기 위해 View에서 한번 print를 찍고

UIViewRepresentable에서 업데이트 기록에 대한 로그를 확인하기 위해 updateUIView 함수에서 한번 더 print를 찍어봤다.

정상적인 Layout 업데이트 로그
잘못된 Layout 업데이트 로그

 

비동기 코드가 있는 것도 아니고, 잘못된 코드라면 모든 경우에서 UILabel의 frame 업데이트가 적용되어야 하지 않을텐데, 일부의 경우는 적용되고 일부는 안되는게 이상했다.

 

그리고 로그를 보면 

View 업데이트(초기, 10개) -> UILabel 업데이트(10개) -> UILabel 업데이트(10개) -> SwiftUI 업데이트(10개) -> UILabel 업데이트(10개) 순으로 진행된다.

 

내가 기대하는 로그는

View 업데이트(초기) -> UILabel 업데이트(생성 및 높이 업데이트) -> SwiftUI(변경된 높이 업데이트) 순으로 로그가 나타나야할 것이다.

따라서 정상적인 Layout이 출력되어도 로그를 확인해보면 이상적이지 않다는 것을 알 수 있다.

 

 

다양한 방법을 시도한 결과,

updateUIView 함수에서 바인딩 변수의 데이터 변경을 시도할 때 해당 내용이 상위 뷰인 SwiftUI View에 제대로 전달되지 않는다는 것을 알게 됐다. 

 

updateUIView 함수는 SwiftUI에서 해당 UIView가 포함된 View가 업데이트될 때만 실행된다.

하지만 SwiftUI에서는 상태 변화가 있는 View 컴포넌트만 View를 다시 그리게 된다.

 

그리고 SwiftUI는 상태 값에 따라서 View가 자동으로 랜더링되는데 이를 수동으로 제어할 수 없다...

여기에는 심지어 SwiftUI의 redraw 방식과 UIView의 redraw 방식에 대한 차이점이 존재한다.

https://sujinnaljin.medium.com/swiftui-view를-redraw-하는-조건은-어떻게-될까-db3d7551df2

 

여기서는 내 추측이지만, UIKit에서 상태를 변경하고 변경된 것을 SwiftUI에 전달(Binding)하고 SwiftUI는 변경된 상태 변수가 포함된 View의 업데이트 주기를 관리한다. 여기서 UIKit이 먼저 랜더링 되는지, SwiftUI가 먼저 랜더링되는지 등에 대한 차이에 따라 Binding 값이 제대로 Layout에 적용 될 수도, 안될 수도 있다고 생각했다.

 

그래서 이를 해결하고자 공식문서를 다시 살펴보고 힌트를 얻어냈다.

 

UIViewRepresentable Coordinator

 

바로 Coordinator이다.

해석하면, View의 변경사항을 SwiftUI 인터페이스에 전달하는 데 사용한다는 것이다.

 

많은 예제 자료에서는 해당 Coordinator를 통해 delegate 함수를 정의해서 사용한다.

그렇다면 명시적으로 UIKit의 변경사항을 SwiftUI View에 신뢰성 있게 전달할 수 있을 거라고 생각했다.

 

따라서, 나는 UIViewRepresentable을 다음과 같이 수정했다.

 

struct SUILabel: UIViewRepresentable {
    @Binding var labelHeight: CGFloat
    let text: String
    let color: Color
    let fontStyle: StyleType

    private(set) var preferredMaxLayoutWidth: CGFloat = 0
    
    func makeUIView(context: UIViewRepresentableContext<SUILabel>) -> CustomLabel {
        let label = CustomLabel()
        label.text = "게시글 작성 시 클로버 적립은 1일 1회만 인정됩니다.산책/모임 카테고리에 산책 기록 공유 게시글 작성 시 클로버가 적립됩니다. "
        label.numberOfLines = 0
        label.preferredMaxLayoutWidth = preferredMaxLayoutWidth
        label.textColor = UIColor(color)
        label.font = UIFont.init(name: fontStyle.fontWeight, size: fontStyle.fontSize)
        label.lineBreakMode = .byCharWrapping
        label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        label.onLayout = {
            context.coordinator.updateLabelHiehgt(label)
        }
        return label
    }
    
    func updateUIView(_ uiView: CustomLabel, context: UIViewRepresentableContext< SUILabel>) {
        uiView.preferredMaxLayoutWidth = preferredMaxLayoutWidth
        uiView.text = text
        
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject {
        var parent: SUILabel
        
        init(_ parent: SUILabel) {
            self.parent = parent
        }
        
        @objc
        func updateLabelHiehgt(_ uiView: UILabel) {
            print("DEBUG - updateLabelHeight Call", #function)
            DispatchQueue.main.async {
                self.parent.labelHeight = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height - 12
                print("DEBUG - UILabel: \(self.parent.labelHeight)")
            }
        }
    }
    
    class CustomLabel: UILabel {
        var onLayout: (() -> Void)?

        override func layoutSubviews() {
            super.layoutSubviews()
            onLayout?()
        }
    }
}

 

먼저, CustomLabel(UILabel)을 사용하여 Label의 하위뷰(디폴트)가 업데이트될 때 실행할 코드를 전달할 수 있도록 했다.

업데이트될 때 실행할 코드는 외부에서 주입할 수 있도록 했는데, 바로 Coordinator의 updateLabelHeight이다.

 

Coordinator의 선언 방법, 사용방법은 구글링을 통해 다양한 예제로 알 수 있는데,

makeCoordinator() -> Coordinator 함수를 통해 SwiftUI에 View 업데이트 내용을 전달할 객체를 반환하고,

업데이트 내용을 전달할 사용자 코드를 Coordinator class를 선언하여 작성한다.

 

Coordinator에서는 updateLabelHeight(_: UILabel) 함수를 만들었는데, 내가 사용하기 위한 함수를 만들어 사용하는 것이다.

여기에 기존 사용했던 updateUIView 함수의 코드 내용을 옮겨왔다.

 

UILabel에 text를 새로 업데이트하면 layoutSubViews() 함수가 자동으로 실행되고, makeUIView 함수에서 layoutSubviews에서 실행될 onLayout? 클로저를 할당했으므로 레이아웃이 새로 업데이트될 때마다 내가 전달한 Coordinator의 updateLabelHeight가 실행될 것이다. 그리고 이에 대한 변경된 상태 값이 명시적으로 SwiftUI에 전달될 것이다.

 

이때, SwiftUI 업데이트 -> UILabel 업데이트 -> Coordinator 함수 실행 -> SwiftUI 업데이트의 순으로 시스템이 진행될 것이다.

UILabel의 레이아웃이 정상적으로 랜더링되고 나서(View 관련 레이아웃이 모두 결정되고 나서) Coordinator가 실행되어 labelHeight의 변경사항이 적용될 것이고, SwiftUI는 상태의 변화를 감지하여 본인 View를 업데이트 할 것이다.

(여기서 UIViewRepresentable 객체의 labelHeight과 SwiftUI의 labelHeight는 차이가 없으므로 다시 랜더링되지 않는다.)

 

아까와 동일하게 SwiftUI의 View 업데이트를 확인할 print 로그와 Coordinator의 updateLabelHeight 함수에서의 print 로그를 비교했을 때

Coordinator height 변경 로그

위와 같이 내가 처음에 생각했던 로그 출력을 확인할 수 있다.

SwiftUI 초기 height -> UILabel 업데이트 height -> 변경된 상태를 반영한 SwiftUI 업데이트

 

이전처럼 다시 화면을 여러번 실행시켰을 때 모든 경우 UIViewRepresentable의 Layout이 제대로 설정되고 로그도 정상적으로 출력되는 것을 확인할 수 있었다. (ContentView가 10개 생성되기 때문에 로그는 항상 10개가 연속으로 나오는게 정상)

 

 

결과

SwiftUI에서 View의 레이아웃을 조절하든, UIKit에서 레이아웃을 조절하든 하나의 프레임워크를 사용할 때는 큰 문제없을 지라도, 서로 다른 프레임워크를 동시에 사용할 때 예측하지 못한 문제를 자주 접한다.

지금 진행하는 프로젝트에서 UIKit의 컴포넌트를 SwiftUI에 삽입하여 사용하는 경우가 많은데, 아마 다른 사람들도 SwiftUI에서 커스텀할 수 없기 때문에 UIKit의 컴포넌트를 사용하는 경우가 종종 있을 것이다.

SwiftUI에서 View를 다시 그리는 방식과 UIKit에서 View가 업데이트되는 방식이 다르기 때문에 항상 이를 염두에 두고 UILabel 뿐만 아니라 다른 UIComponent의 Layout 업데이트 적용 방법을 숙지할 필요가 있다.

 

 


출처

https://developer.apple.com/documentation/swiftui/uiviewrepresentable

https://sujinnaljin.medium.com/swiftui-view를-redraw-하는-조건은-어떻게-될까-db3d7551df2