iOS/UIKit

[Text 시리즈 3] TextKit1

Hamp 2025. 7. 21. 08:54
반응형

👋 들어가기 전

이번 텍스트 시리즈는 TextKit1이다.

 

🙋: TextKit2가 나왔는데 왜 TextKit1을 ?? 뒷북 너무 심하다

 

😀: 1도 모르는데 2를 봐서 뭐함?

 

나는 앞에가 이해가 안되면, 뒤에도 이해를 못한 경험이 정말 많다.

 

그래서, 깊게 빠르게 가는 방법은 순서대로 가는 것이라고 생각한다.

 

그래서 오늘은 TextKit1을 먼저 살펴보자.

WWDC 2013까지 거슬러 올라가보자.. 내가 고등학교 때다.

 

TextKit2가 나오는 시점에 TextKit1 영상은 내려갔는데, 다행히 다운받는 형식으로 학습할 수 있게 남겨둿다.

https://nonstrict.eu/wwdcindex/wwdc2013/210/


🏁 학습할 내용

  • Text Kit이란
    • Primary TextKit Object
      • Text Container
      • Layout Manager
      • Text Storage
  • 유용한 API
    • DataDetector 과 NSLink
    • textAttachment
  • LayoutProcess

📝 TextKit이란

✋ 소개

텍스트 레이아웃 및 렌더링 엔진

  • Core Text위에 구축되어있음
  • UIKit과 완벽하게 통합되어음

 

🏛️ 주요 객체

 

📦 Text Container

텍스트가 표시될 영역을 정의

  • LayoutManager를 위한 좌표계와 구조를 정의한다.
  • 제외 경로는 TextContainer 좌표계 안에 존재한다.
  • Hit testing 또한 TextContainer안에서 동작한다.

 

🧑‍💼 Layout Manager

텍스트 컨테이너에 텍스트의 레이아웃을 수행

 

흠 .. 이렇게 정리하면, 얘는 어디다 써먹을 수 있지?? 고민이된다.

대표적으로 특정 글자를 터치했을 때, 터치 위치의 글자 또는 글리프를 알려줄 수 있다.

 

왜그럴까?

  저장하고 있는 데이터 터치한 위치를 알 수 있나?
과거 텍스트를 화면에 표시하면 내부 정보(글리프 ID, 문자 위치 등)를 다 버리고,
비트맵만 저장

터치한 위치의 문자를 알기 어려웠음.
TextKit1  NSLayoutManager가 텍스트 레이아웃 관련 정보를 모두 저장  터치 위치를 문자 인덱스로 쉽게 변환 가능.

 

이전에 배운 글리프 내용을 가져오면, 더 유용한 기능을 만들 수 있다.

https://hamp.tistory.com/236

 

간단히 정리하면, 글리프는 character와 1:1 매칭이 아니다.

그러면 다음 문자를 클릭했을 때 우리는 어떤 인덱스를 받아야할까?

 

문자가 3개니깐, {0,3}일까, 아니면 1개의 글리프로 취급해서 {0,1}일까??

니가 원하는 것을 던져줄 수 있다.

 

글리프 범위를 원하면 {0,1}, 반대로 문자 범위를 원하면 {0,3}을 받을 수 있다

 

💾 Text Storage

문자열 데이터(내용) + 스타일(속성)을 저장하고, 변경 사항을 LayoutManager에 전달

 

✅ 특징

      • MutableAttributedString의 서브클래스
      • 한 글자만 바뀌었는데도 전체 텍스트를 다시 그리는 것이 아니라, 변경된 부분 다시 처리
      • 변경 알림이 자동으로 NSLayoutManager에 전달되어, 실제로 변경된 부분만 다시 레이아웃

🙋 NSTextStorage 서브클래싱의 필요성

  • 예를 들어 메시지 앱에서 사용자 ID를 강조해야 할 경우, 특정 패턴(예: @username)을 감지하고 해당 텍스트의 스타일을 즉시 변경
  • 이를 위해 NSTextStorage의 텍스트 변경 관련 메서드를 오버라이드하여 사용자가 ID를 입력하는 즉시 색상 등의 스타일을 적용
  • NSTextStorage는 클래스 클러스터이기 때문에 일부 메서드를 직접 구현 필요

 

실제 구현을 통해 다뤄보자.

 

특정 문자열에 대해 매핑된 색깔을 입힌 스타일을 적용한다.

  • Alice는 빨간색
  • Rabbit은 주황색

 // ViewController.swift
 override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = .systemBackground

    let textStorage = CustomTextStorage()
    let layoutManager = NSLayoutManager()
    let container = NSTextContainer(size: view.bounds.size)

	// NSTextContainer의 너비가 UITextView의 너비를 자동으로 따라가게 설정
    container.widthTracksTextView = true 

    // 등록
    layoutManager.addTextContainer(container)
    textStorage.addLayoutManager(layoutManager)
    let textView = UITextView(frame: .zero, textContainer: container)
    
    // 토큰 등록
    textStorage.tokens = [
      "Alice": [NSAttributedString.Key.foregroundColor: UIColor.red],
      "Rabbit": [NSAttributedString.Key.foregroundColor: UIColor.orange],
      "Default": [NSAttributedString.Key.foregroundColor: UIColor.black]
    ]
}

// CustomTextStorage.swift

final class CustomTextStorage: NSTextStorage {
  typealias AttributeDict = [NSAttributedString.Key: Any]

  /// 특정 단어(token)에 적용할 속성(attribute)들을 저장하는 딕셔너리
  public var tokens: [String: AttributeDict] = [:]

  private let defaultTokenKey = "Default"

  /// 실제 문자열과 속성 정보를 저장하는 NSMutableAttributedString 인스턴스
  private var _backingStore: NSMutableAttributedString = .init()

  /// 텍스트 변경 후 동적 속성 업데이트가 필요한지 여부
  private var _dynamicTextNeedUpdate: Bool = false

  /// NSTextStorage 필수 재정의 속성 - 전체 문자열 반환
  override var string: String {
    return _backingStore.string
  }

  /// NSTextStorage 필수 재정의 함수 - 주어진 위치의 속성 반환
  override func attributes(
    at location: Int,
    effectiveRange range: NSRangePointer?
  ) -> [NSAttributedString.Key: Any] {
    return _backingStore.attributes(at: location, effectiveRange: range)
  }

  /// 텍스트 변경 처리 - 지정된 범위의 문자열을 새 문자열로 교체
  override func replaceCharacters(in range: NSRange, with str: String) {
    _backingStore.replaceCharacters(in: range, with: str)

    // 변경 사항 알림 (문자열 및 속성 변경)
    self.edited([.editedCharacters, .editedAttributes], range: range, changeInLength: str.count - range.length)
    _dynamicTextNeedUpdate = true // 변경 이후 속성 갱신 필요 표시
  }

  /// 텍스트에 속성 적용
  override func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) {
    _backingStore.setAttributes(attrs, range: range)
    // 변경 사항 알림 (속성만 변경)
    self.edited([.editedAttributes], range: range, changeInLength: 0)
  }

  /// 텍스트가 편집된 후 호출되는 후처리 함수
  override func processEditing() {
    if _dynamicTextNeedUpdate {
      _dynamicTextNeedUpdate = false
      performReplacements(forCharacterChangeIn: editedRange) // 수정된 범위를 알려주
    }

    super.processEditing()
  }

  /// 변경된 범위 주변까지 포함하여 속성 적용 범위 확장 후 처리
  private func performReplacements(forCharacterChangeIn changedRange: NSRange) {
    guard let text = _backingStore.string as NSString? else { return }

    // 변경된 범위의 앞/뒤 라인을 포함한 확장 범위 계산
    var extendedRange = NSUnionRange(
      changedRange,
      text.lineRange(for: NSRange(location: changedRange.location, length: 0))
    )

    extendedRange = NSUnionRange(
      extendedRange,
      text.lineRange(for: NSRange(location: NSMaxRange(changedRange), length: 0))
    )

    applyTokenAttributes(to: extendedRange)
  }

  /// 토큰 단위로 속성을 적용하는 함수
  private func applyTokenAttributes(to searchRange: NSRange) {
    guard let text = _backingStore.string as NSString? else { return }

    // 기본 속성 (토큰에 매칭되지 않으면 이 속성을 사용)
    let defaultAttributes = tokens[defaultTokenKey] ?? [:]

    // 단어 단위로 순회하면서 속성을 적용
    text.enumerateSubstrings(in: searchRange, options: .byWords) { [weak self] substring, substringRange, _, _ in
      guard let self,
            let word = substring
      else { return }

      // 해당 단어에 대한 속성 가져오기 (없으면 기본 속성)
      let attributesForToken = tokens[word] ?? defaultAttributes

      // 속성 적용
      self.addAttributes(attributesForToken, range: substringRange)
    }
  }
}

 

결과는 다음과 같다.

 

코드는 필요하면 자세히 봐바라, 생각보다 재밌었다.

 


🧰 유용한 API

이 부분부터는 주요 오브젝트에 포함되어있지는 않지만, 너무 유용한 기술들이라 따로 정리하려고한다.

🔗 DataDetector 과 NS Link

게시판 기능을 만든다고하자.

 

게시판에는 일반적인 텍스트 뿐만아니라, 홈페이지 link, 핸드폰 번호등, 다양한 형태의 특별한 텍스트가 있고

보통 그런 형태의 텍스트들은  탭 이벤트를 통해, 특별한 동작을 한다.

 

그러면 우리는 해당 텍스트가 어느 부분부터, 어느 부분까지인지 알야하는데, 매우 번거로운 작업이다.

 

글자가 길면 길수록, 예외처리 부분이 많아진다.

 

이때 우리는 DataDector를 이용하면 굉장히 편하게 처리할 수 있다.

 

🕵️‍♂️ DataDector

iOS에서 텍스트 내에 포함된 특정 유형의 데이터(전화번호, 주소, 날짜, URL 등)를 자동으로 감지하고,
해당 항목에 상호작용 기능을 추가해주는 역할을 담당한다.

역할 설명
🔍 자동 감지 일반 텍스트 안에서 전화번호, URL, 주소, 날짜, 항공편 번호 등을 자동 인식
🔗 링크로 변환 감지된 데이터를 링크처럼 표시하고, 탭할 수 있도록 만듦
🎯 상호작용 제공 사용자가 해당 링크를 탭하거나 길게 누르면 전화 걸기, 지도 열기, Safari 연결, 캘린더 등록 등의 액션 제공
✅ 시스템 내장 개발자가 복잡한 파싱 없이도 사용할 수 있도록 시스템이 처리해줌

 

먼저 감지할 타입을 배열 행태로 넣어준다.

 

나는 link와 핸드폰 번호를 감지할 것, 이후 link 형태는 붉은 글씨 + 밑줄로 표시한다.

override func viewDidLoad() {
    super.viewDidLoad()

    textView.dataDetectorTypes = [.link, .phoneNumber] // 자동 감지할 데이터 타입선언
    textView.linkTextAttributes = [
      .foregroundColor: UIColor.systemRed,
      .underlineStyle: NSUnderlineStyle.single.rawValue
    ] // 링크 형태의 글자들을 표시할 속성

    let fullText = """
    Welcome to WWDC 2025.
    https://developer.apple.com
    For help, call us at 123-456-7890.
    """

    let attributedString = NSMutableAttributedString(string: fullText)

    textView.attributedText = attributedString
    textView.delegate = self
  }

결과를 보면, 정확히 그 부분만 선택해서 링크 표시를 해주고 있다.

링크 눌렀을 때, 이동처리는 다음 델리게이트를 이용한다.

extension ViewController: UITextViewDelegate {
  // ✅ iOS 17 이전
  func textView(
    _ textView: UITextView,
    shouldInteractWith URL: URL,
    in characterRange: NSRange,
    interaction: UITextItemInteraction
  ) -> Bool {
    return true // Safari 등으로 열기 허용
  }

  // ✅ iOS 17 이후
  func textView(_ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction) -> UIAction? {
    if case .link(let url) = textItem.content {
      UIApplication.shared.open(url)
     }

     return nil
  }
}

🖍️ NS Link

NS Link도 DataDector같은 느낌으로 사용하는데, 자동 감지는 하지못하고, 원하는 텍스트에 링크를 넣어줄 수 있다.

 

위 내용과 다른점은 애플 개발자 홈페이지를 링크를 텍스트로 갖고 있는게 아니라

"Apple Developer Site" 텍스트에 링크를 추가하고 있다.

override func viewDidLoad() {
   super.viewDidLoad()
    
   textView.linkTextAttributes = [
      .foregroundColor: UIColor.systemRed,
      .underlineStyle: NSUnderlineStyle.single.rawValue
    ] // 링크 형태의 글자들을 표시할 속성

    let fullText = """
    Welcome to WWDC 2025.
    Apple Developer Site
    For help, call us at 123-456-7890.
    """
    let attributedString = NSMutableAttributedString(string: fullText)

    //// 웹사이트 링크 지정
    if let linkRange = (fullText as NSString).range(of: "Apple Developer Site").toOptional(),
       let url = URL(string: "https://developer.apple.com") {
        attributedString.addAttribute(.link, value: url, range: linkRange)
    }

    // 전화번호 링크 지정
    if let phoneRange = (fullText as NSString).range(of: "123-456-7890").toOptional(),
       let telURL = URL(string: "tel:1234567890") {
        attributedString.addAttribute(.link, value: telURL, range: phoneRange)
    }

    textView.attributedText = attributedString
    textView.delegate = self
  }

 

그러면 결과는 어떻게 될까?

재대로 링크가 적용되고 있다. 

 

정리하면 다음과 같다.

항목 DataDetector NSLink
자동 감지 O (텍스트 안에서 자동 분석) X (직접 지정해야 함)
편리함 매우 간편 직접 설정 필요
사용 대상 UITextView, UILabel NSAttributedString 기반

🦋 textAttachment

 

🔹  NSTextAttachment와 Exclusion Path의 역할

  • NSTextAttachment는 텍스트 흐름 내에 이미지를 삽입
  • ExclusionPath는 특정 경로를 비워서 텍스트가 해당 경로를 피해서 흐르게 만듬

💾 저장 위치

  • NSTextAttachmentNSTextStorage안에 함께 저장되며, 데이터 자체에 포함됨
  • ExclusionPathNSTextContainer에 저장되며, 텍스트 배치 흐름을 바꾼다.

 

두 개를 이용하면, 다음과 같은 효과를  줄 수도 있다.


🏭 LayoutProcess

🧩 레이아웃 처리 과정

 

처리 과정 도식

[ TextStorage (문자) ]
         ↓
[ LayoutManager (글리프 생성 및 배치) ]
         ↓
[ TextContainer (라인 배치, 패딩, exclusion path 등 고려) ]
         ↓
[ TextView (UI로 표시) ]

 

 

두 단계의 레이아웃 처리

  • 글리프 생성(Glyph Generation): 문자를 글리프(시각적 표현)로 변환.
  • 글리프 레이아웃(Glyph Layout): 글리프를 어디에 배치할지 계산.
  • 이 작업은 필요할 때만 수행되는 지연(lazy) 처리이며, 결과는 캐싱되어 재사용됨

 

🔁 캐시와 무효화 처리 (Invalidation)

 

NSLayoutManager는 글리프, 속성, 레이아웃 정보를 캐싱한다.

  • 변경된 범위는 자동 또는 수동으로 무효화(invalidate)됨
    • 자동 무효화: 글리프 생성 또는 레이아웃이 필요한 경우.
    • 수동 무효화: 개발자가 명시적으로 무효화할 수도 있음.
  • 무효화된 범위가 접근되면, 필요한 작업(생성/레이아웃)을 즉시 수행

 

📐 라인 프래그먼트(Rectangle) 생성

기본 개념

  • 텍스트는 NSTextContainer 내부에 라인 단위로 배치된다.
  • Exclusion Path가 존재하면, 해당 경로를 피해서 글자를 배치해야 하므로 줄이 짧아지거나 분할될 수 있다.

 

라인 프래그먼트 조정 흐름

    1. 레이아웃 매니저가 줄의 위치를 제안.
    2. NSTextContainer에게 조정 요청
    3. 이 메서드는 주어진 위치에 대해 가능한 큰 사각형(라인)을 반환하고,
    4. 나머지 공간(홀 또는 gap 반대편 등)도 별도로 반환

 

🎯 Exclusion Paths 지정하기

  • NSTextContainer배치 제외 영역(exclusion paths)UIBezierPath 배열로 유지
  • 레이아웃 매니저가 줄을 제안할 때 이 경로와 겹치면, 텍스트 컨테이너가 해당 부분을 제외한 사각형을 다시 반환


😀 소감 및 마무리

많이 교체된 부분이 있지만, TextKit2를 배울 때, 정말 많은 도움이될 사전 지식을 학습해봤다.


출처

https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/CustomTextProcessing/CustomTextProcessing.html

 

Using Text Kit to Draw and Manage Text

Using Text Kit to Draw and Manage Text The UIKit framework includes several classes whose purpose is to display text in an app’s user interface: UITextView, UITextField, and UILabel, as described in Displaying Text Content in iOS. Text views, created fro

developer.apple.com

https://developer.apple.com/documentation/DataDetection/DataDetector

 

DataDetector | Apple Developer Documentation

An extension to the string protocol that scans strings for semantic entities, such as email addresses, phone numbers, URLs, and flight information.

developer.apple.com

https://developer.apple.com/documentation/uikit/uitextview/textstorage

 

textStorage | Apple Developer Documentation

The text storage object holding the text that displays in the text view.

developer.apple.com

 

반응형