//  swiftui-nanteslabel-uiviewrepresentable.swift
//  ogging.dev / Swift
//  MAY 14, 2026 · 7 min read
//  #swiftui, #uikit, #uiviewrepresentable, #nanteslabel, #bridging

SwiftUI에서 NantesLabel 살리기

SwiftUI Text로는 안 되는 자동 링크 탐지, 줄 수 제한, 더 보기 토글을 UIKit NantesLabel을 UIViewRepresentable로 래핑해서 해결한 패턴과 네 가지 함정 정리.

코멘트 영역을 SwiftUI로 짜고 있었다. 요구사항은 셋이다.

  1. www 링크를 자동 탐지해서 파란색으로 표시하고 탭하면 브라우저를 연다
  2. 4줄까지 노출하고 그 이상은 말줄임 + “더 보기” 토글
  3. 다이나믹 타입 대응

SwiftUI Text로는 셋 다 안 된다. Text 자체에 자동 링크 탐지가 없고, 줄 수 제한과 “더 보기” 토글을 같이 처리하기도 빈약하다. 기존 UIKit 화면에서는 NantesLabel 라이브러리로 다 해결돼 있었다.

새로 짤 시간은 없다. 가져다 쓰자.

UIViewRepresentable로 래핑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct NantesLabelView: UIViewRepresentable {
    let text: String
    let maxLines: Int
    let onTapMore: () -> Void

    func makeUIView(context: Context) -> NantesLabel {
        let label = NantesLabel()
        label.numberOfLines = maxLines
        label.delegate = context.coordinator
        label.enabledTextCheckingTypes = NSTextCheckingResult.CheckingType.link.rawValue
        return label
    }

    func updateUIView(_ label: NantesLabel, context: Context) {
        label.text = text
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(onTapMore: onTapMore)
    }

    final class Coordinator: NSObject, NantesLabelDelegate {
        let onTapMore: () -> Void
        init(onTapMore: @escaping () -> Void) {
            self.onTapMore = onTapMore
        }

        func attributedLabel(_ label: NantesLabel, didSelectLinkWith url: URL) {
            UIApplication.shared.open(url)
        }
    }
}

핵심은 셋이다.

  • makeUIView에서 UIKit 인스턴스를 만들고 초기 설정을 한다
  • updateUIView에서 SwiftUI state 변경을 UIKit 인스턴스에 반영한다
  • delegate는 Coordinator에 둔다. UIViewRepresentable 자체는 struct라 delegate를 직접 채택하지 못한다

함정 1 — intrinsicContentSize

UIKit label의 height는 내용에 따라 변한다. SwiftUI는 UIViewRepresentable이 자기 intrinsic size를 어떻게 표현하는지에 따라 layout을 결정한다. SwiftUI가 width를 정해주고 label의 intrinsic height를 자동으로 받아야 한다.

iOS 16+에서는 UIHostingController에 sizingOptions = .intrinsicContentSize가 있지만, UIViewRepresentable 안에서는 SwiftUI가 자동으로 처리한다. NantesLabel이 표준 intrinsicContentSize를 잘 구현하면 무리 없이 동작한다.

문제가 생기면 .fixedSize(horizontal: false, vertical: true) modifier로 강제할 수 있다.

함정 2 — updateUIView가 자주 호출된다

SwiftUI는 부모 state가 바뀔 때마다 updateUIView를 호출한다. 매번 label의 모든 속성을 재설정하면 비싸다. 비교 후 바뀐 것만 적용하거나, label 속성이 stable하면 makeUIView에서 한 번만 설정하고 updateUIView에서는 text만 갱신한다.

함정 3 — 다이나믹 타입

UIKit label은 adjustsFontForContentSizeCategory = true로 설정하면 다이나믹 타입을 자동으로 따른다. SwiftUI Environment의 \.sizeCategory도 받을 수 있지만 NantesLabel이 자체적으로 처리하게 두는 쪽이 단순하다.

NantesLabel의 link 색상은 linkAttributes로 설정한다.

1
2
3
4
label.linkAttributes = [
    .foregroundColor: UIColor.systemBlue,
    .underlineStyle: NSUnderlineStyle.single.rawValue
]

SwiftUI Color로 받아서 UIColor로 변환하면 다크모드 자동 대응도 가능하다.

문제 코드

처음엔 그냥 SwiftUI Text가 NantesLabel처럼 동작해주길 바라고 써봤다. 당연히 안 된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct CommentView: View {
    let text: String

    var body: some View {
        // SwiftUI Text는 자동 URL 탐지가 없다.
        // lineLimit + "더 보기" 토글도 직접 짜야 한다.
        Text(text)
            .lineLimit(4)
            .truncationMode(.tail)
    }
}

그래서 NantesLabel 자체를 SwiftUI body 안에 직접 써보려 했는데, NantesLabel은 UIView 서브클래스라 View가 아니다. 컴파일도 안 된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct CommentView: View {
    let text: String

    var body: some View {
        // 컴파일 에러: NantesLabel은 SwiftUI View가 아니다.
        // 'NantesLabel' is not convertible to 'some View'
        NantesLabel().apply {
            $0.text = text
            $0.numberOfLines = 4
            $0.enabledTextCheckingTypes = NSTextCheckingResult.CheckingType.link.rawValue
        }
    }
}

UIKit 컴포넌트를 SwiftUI 안에 그대로 끼우려고 한 게 잘못이다. SwiftUI는 UIViewRepresentable이라는 다리를 명시적으로 요구한다.

해결 코드

UIViewRepresentable로 래핑하고, delegate는 Coordinator에 위임한다. 아래는 복붙해서 바로 쓸 수 있는 완성본이다. 링크 색상·다이나믹 타입·width 자동 처리·“더 보기” 콜백까지 다 들어있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import SwiftUI
import Nantes

struct NantesLabelView: UIViewRepresentable {
    let text: String
    let maxLines: Int
    let font: UIFont
    let textColor: UIColor
    let linkColor: UIColor
    let onTapMore: () -> Void

    init(
        text: String,
        maxLines: Int = 4,
        font: UIFont = .preferredFont(forTextStyle: .body),
        textColor: UIColor = .label,
        linkColor: UIColor = .systemBlue,
        onTapMore: @escaping () -> Void = {}
    ) {
        self.text = text
        self.maxLines = maxLines
        self.font = font
        self.textColor = textColor
        self.linkColor = linkColor
        self.onTapMore = onTapMore
    }

    func makeUIView(context: Context) -> NantesLabel {
        let label = NantesLabel()
        label.delegate = context.coordinator

        // stable 속성은 makeUIView에서 한 번만.
        label.numberOfLines = maxLines
        label.font = font
        label.textColor = textColor
        label.adjustsFontForContentSizeCategory = true
        label.enabledTextCheckingTypes = NSTextCheckingResult.CheckingType.link.rawValue
        label.linkAttributes = [
            .foregroundColor: linkColor,
            .underlineStyle: NSUnderlineStyle.single.rawValue
        ]

        // SwiftUI가 width만 잡아주고 height는 라벨이 알아서 측정하도록.
        label.setContentHuggingPriority(.required, for: .vertical)
        label.setContentCompressionResistancePriority(.required, for: .vertical)

        return label
    }

    func updateUIView(_ label: NantesLabel, context: Context) {
        // 매 호출마다 모든 속성을 재설정하지 않는다. text만 바뀔 가능성이 높다.
        if label.text != text {
            label.text = text
        }
        context.coordinator.onTapMore = onTapMore
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(onTapMore: onTapMore)
    }

    final class Coordinator: NSObject, NantesLabelDelegate {
        var onTapMore: () -> Void

        init(onTapMore: @escaping () -> Void) {
            self.onTapMore = onTapMore
        }

        func attributedLabel(_ label: NantesLabel, didSelectLinkWith url: URL) {
            UIApplication.shared.open(url)
        }
    }
}

호출부는 이렇게 쓴다. .fixedSize(horizontal: false, vertical: true)로 height를 라벨에 맡긴다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct CommentView: View {
    let text: String
    @State private var expanded = false

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            NantesLabelView(
                text: text,
                maxLines: expanded ? 0 : 4,
                onTapMore: { expanded.toggle() }
            )
            .fixedSize(horizontal: false, vertical: true)

            if !expanded {
                Button("더 보기") { expanded = true }
                    .font(.footnote)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

이렇게 두면 자동 URL 탐지·다이나믹 타입·다크모드 link 색상까지 다 따라온다. “더 보기"는 SwiftUI @State로 줄 수 제한만 토글하면 끝난다.

더 가벼운 대안

SwiftUI iOS 15+의 AttributedString + Markdown으로 일부 링크 처리는 가능하다. 다만 자동 URL 탐지, “더 보기” 토글, 줄 수 제한을 정밀하게 컨트롤하기 어렵다. 기존 UIKit 라이브러리가 이미 잘 동작하면 가져다 쓰는 쪽이 시간 대비 결과가 좋다.

교훈

  • UIKit 단독 라이브러리를 SwiftUI에서 살리는 가장 단순한 패턴은 UIViewRepresentable 래핑이다. 일주일짜리 자체 구현 대신 한 시간으로 끝난다.
  • delegate가 있는 UIKit 컴포넌트를 래핑할 땐 Coordinator로 묶어라. UIViewRepresentable struct가 직접 delegate를 채택할 수 없다.
  • updateUIView는 자주 호출된다. 매번 모든 속성을 재설정하지 마라. 바뀐 것만 반영하거나 stable한 속성은 makeUIView에서 한 번만 설정해라.
  • intrinsic size가 깨지면 .fixedSize(horizontal: false, vertical: true)로 강제할 수 있다. SwiftUI가 width만 정해주고 height를 자동 측정한다.
  • 모든 UIKit 라이브러리를 새로 SwiftUI 네이티브로 짤 필요는 없다. 마이그레이션 비용을 인정하고 일부는 래핑으로 두는 쪽이 현실적이다.

환경: Xcode 26.4 / iOS 17+