[iOS] - What's new in TextKit and text views
What’s new in TextKit and text views
이전 WWDC21 TextKit 2 영상을 미리 보는 것이 이해에 도움이 된다.
이전 WWDC 영상에서 언급된 TextKit 2의 여러 특징들을 다시 짚어보면 다음과 같다.
- TextKit 2의 viewport-based layout 아키텍처 (noncontiguous layout을 사용하여 viewport에 보이는 부분만 layout 하는 아키텍처)를 통해 대용량 문서를 layout 할 때 좋은 성능을 유지할 수 있다.
- TextKit 2는 glyph를 사용하지 않음으로서 다양한 언어에 대해 더 정확하게 텍스트를 나타낼 수 있었고, OpenType이나 Variable Font와 같은 현대 font 기술들도 지원할 수 있게 됐다.
- 또한 value semantics를 사용한 높은 레벨의 객체를 사용해서 작업하기 때문에 더 적은 코드로 텍스트의 layout을 커스터마이징 할 수 있다.
여기서 더욱 나아가서 TextKit 2 엔진은 모든 Apple 플랫폼의 text layout과 rendering의 기본이 된다. 그리고 앞으로의 성능 향상 업데이트 등의 업데이트는 TextKit 2 엔진에 초점을 맞출 것이라고 한다.
이제 iOS 16과 macOS Ventura에서 UIKIt과 AppKit의 모든 text control은 TextKit 2를 사용한다.
대부분의 앱에서 TextKit 2로 전환할 때 아무런 추가 작업이 필요가 없다고 한다.
What’s new in TextKit 2
TextKit 2는 iOS 15에서 처음으로 UIKit에서 등장했고, iOS 16에서는 완전히 TextKit 2를 사용하게끔 되어있다. 기본적으로 TextKit 2를 사용하게 되어 있는데, textView를 사용할 때 TextKit 2를 바로 사용할 수 없는 몇 가지 케이스는 영상의 뒷부분에서 다룬다.
macOS Ventura에서 모든 text control은 기본적으로 TextKit2를 사용한다. 대부분의 NSTextView도 TextKit 2를 기본적으로 사용하게 되어있다.
TextEdit
NSTextView의 wrapper인 TextEdit도 원래 macOS Big Sur에서는 plain text mode일 때만 TextKit2를 사용했는데, macOS Ventura에서는 rich text mode일때도 TextKit 2를 사용한다.
Convenience initializer
TextKit 2가 새로운 표준이기 때문에, UITextView와 NSTextView를 위한 convenience 이니셜라이저가 추가되었다. 만약 TextKit 2를 사용하는 text view를 만드려면 usingTextLayoutManager
인자에 true를 전달하고, TextKit 1을 사용해야 한다면 false를 전달하면 된다.
Interface Builder option
Interface Builder에서 TextView의 Text Layout을 위한 옵션도 있다. 이 새로운 옵션을 써서 TextKit 1을 사용할 건지, TextKit 2를 사용할 건지 고를 수 있다.
exclusionPaths
TextKit 2는 복잡한 text container도 지원한다. 복잡한 text container는 내부에 구멍이나 여백이 있을 수 있다. 이는 텍스트가 특정 이미지나 내부 컨텐츠를 감싸는 형태로 존재할 수 있게 한다. 이런 복잡한 text container를 만드려면 NSTextContainer의 exclusionPaths 프로퍼티를 사용해서 텍스트가 위치하면 안되는 곳을 정의해야 한다.
Line Breaking
또한 TextKit 2에서 줄 바꿈 엔진을 개선했다. TextKit 2에서는 별도의 작업 없이 왼쪽의 형태가 오른쪽과 같이 개선된 것을 확인할 수 있다.
Text lists
모든 플랫폼에서 TextKit 2에서 text list가 지원된다. Text list로 text view에 출력할 숫자형 / 목록형 리스트를 만들 수 있다. NSTextList는 원래 AppKit에서만 사용할 수 있었지만, iOS 16에서는 UIKit에서도 사용 가능하다.
Text storage 안에 어떤 paragraph가 출력될 때 list형으로 출력되어야 한다면 이를 나타내기 위해 NSTextList
와 NSMutableParagraphStyle
을 함께 사용하면 된다. TextView는 text storage에서 이런 attribute를 가져와서 목록처럼 보이게 paragraph 컨텐츠를 재구성 할 수 있다.
List가 중첩된 아이템들을 가질 수 있기 때문에, 트리형으로 표현하는 것이 자연스럽다. TextKit 2에서는 이런 형태를 트리 형태로 구성해서 자식과 부모 요소에 접근할 수 있게 했다.
NSTextListElement
라는 새로운 element 자식 클래스가 추가됐다. Content manager가 text 컨텐츠에서 NSTextList
를 보게 되면, 이를 리스트 안에 있는 아이템으로 표현하기 위해 NSTextListElement
들을 생성하게 된다.
Attachment
Text attachment API는 UI나 NSView를 text attachment로 사용할 수 있게 해주고, 이 view들이 이벤트를 직접 제어할 수 있게 해준다. 이는 text attachment로 이벤트를 다루는 것을 더 간단하게 해주는데 TextKit 2에서만 가능한 부분이라고 한다.
Compatibility mode
들어가기 앞서 compatibility mode 부분에 나오는 내용들은 이전 WWDC 21 Meet TextKit 2에서 나온 내용과 동일하므로 이전 영상을 본 사람들은 넘어가도 된다.
TextKit1에 강하게 의존하는 것도 TextKit2로 잘 작동할 수 있게 UITextView
와 NSTextView
를 위한 TextKit1 compatibility mode가 추가되었다. (이전 wwdc영상에서도 언급됐다.)
명시적으로 NSLayoutManager
API를 호출하면 text view는 NSTextLayoutManager
를 NSLayoutManager
로 바꾸고 TextKit1을 사용하게 된다. TextView가 TextKit2에 의해 지원되지 않는 attribute를 사용하게 될 때도 이런 fallback이 나타난다.
Check fallback
만약 의도하지 않았는데 TextKit 1으로 런타임 상에서 fallback이 일어난 경우 이런 변경된 내용에 대한 로그를 확인할 수 있다. _UITextViewEnablingCompatibilityMode
심볼에 breakpoint를 걸고 stack trace를 확인해서 디버깅 할 수 있다.
NSTextView의 경우 willSwitchTo~
나 didSwitchTo~
notification을 구독해서 런타임 fallback을 탐지할 수 있다.
Opt out strategies
- TextKit1 layout manager 사용
만약 TextKit 1으로 fallback해야 하는 경우 생성 시점에 코드로 설정하는 것이 제일 좋다. Text container와 TextKit 1 layout manager를 사용해서 TextKit 1을 사용할 수 있다.
- Convenience 생성자 사용
다른 방법은 위의 convenience 생성자를 사용해서 usingTextLayoutManager
에 false를 전달하면 TextKit 1을 사용하게 된다.
- Interface Builder
Interface Builder의 Text Layout
옵션을 TextKit1으로 설정할 수 있다.
만약 생성 이후나 도중 text container의 layout manager를 swap out 시키면 textView는 TextKit 1을 사용하도록 fall back될 것이다. 이후에 버려질 TextKit 2 객체들을 초기화 때 생성하는 것은 비효율적이다.
또한 타이밍에 따라서 사용자가 타이핑하고 있는 도중에 fallback이 일어나게 되면 text view는 focus를 잃고 사용자가 다시 입력하기 위해 text view를 선택해야 하는 상황이 발생할 수도 있다.
따라서 위와 같은 문제가 발생하지 않도록 아예 생성 시점에 textView가 TextKit1을 사용하도록 해야 한다.
Modernization strategies
먼저 text view 하나에는 오로지 하나의 layout manager만 존재할 수 있다. Text view는 NSTextLayoutManager
와 NSLayoutManager
를 함께 가질 수 없다.
Text view가 한 번 TextKit1으로 전환되면 다시 TextKIt2로 돌아갈 수 없다. Layout 시스템을 바꾸는데 많은 작업이 필요하고, 전환이 일어나게 되면 그 때 가지고 있던 모든 UI 상태를 잃게 된다.(Text view의 경우 포커싱이 되어 있는데 전환이 일어났을 때 포커싱을 잃을 것이다.) 그리고 성능과 사용성을 위해 시스템은 TextKit1에서 TextKit2로 전환하지 않는다.
그래서 compatibility mode를 가능한 한 사용하지 않는 것이 중요하다. Compatibility mode로 진입하게 되는 가장 큰 이유는 text view의 layoutManager
프로퍼티에 접근하는 것이다.
또한 textView의 textContainer
를 통해서도 layoutManager
에 접근하지 않도록 한다. 이렇게 사용하는 코드들에 대해 같은 기능을 하면서도 TextKit 2를 사용할 수 있는 코드로 바꿔야 한다.
만약 TextKit 2를 사용하지 않는 오래된 OS 버전에 배포한다면 layoutManager
코드를 완전히 지우기 쉽지 않을 것이다. 이 경우 위와 같이 textView의 NSTextLayoutManager
가 있는지 먼저 확인하면 TextKit 2를 쓸 수 있는 곳에서는 TextKit 2를 쓸 것이고, 그렇지 않은 경우에는 layoutManager
쿼리를 실행하기 때문에 TextKit 1으로 fallback 할 것이다.
1. Updating NSLayoutManager code
NSLayoutManager
에 접근하는 코드를 확인했다면 NSTextLayoutManager
를 사용하는 API들을 TextKit 2 버전으로 바꿔야 한다.
위처럼 TextKit 1과 2버전에서 API들은 비슷한 이름을 갖고 있다.
하지만 TextKit 1 의 어떤 API들은 TextKit 2로 직접 변환할 수 없다. 그 이유는 TextKit 1에서는 glyph를 사용한 glyph 기반의 API를 사용하는데, glyph들을 기반으로 텍스트의 범위를 알기 어려운 언어들이 있기 때문이다.
위와 같이 glyph는 분리되거나 재정렬 되거나 다양한 작업을 통해 다르게 쓰여질 수 있고, 따라서 glyph를 기반으로 glyph range를 찾기는 어렵다. TextKit 2에서는 glyph API 가 전혀 없다. TextKit 1에서 사용하던 glyph 기반의 API들을 다른 방법을 통해 사용해야 한다.
Updating glyph-based code
Glyph 기반의 코드를 업데이트 하는 방법은 다음과 같다.
- 어떤 glyph API를 사용하고 있는지 확인한다.
- 이 API들을 어떻게 사용하고 있는지 보고 높은 레벨에서 무엇을 하려는지 정의한다. Glyph 기반의 코드는 굉장히 낮은 레벨이기 때문에 높은 레벨의 관점으로 봤을 때 작업과 관련 없는 많은 디테일이 있을 것이다.
위의 예시 코드에서는 TextKit 1을 사용해서 문서 내의 모든 glyph를 iterate한 후 line fragment rect를 세고 있다. 높은 레벨의 작업은 text view가 감싸고 있는 줄 수를 세는 것이 될 것이다.
- 높은 레벨 작업을 정의했다면 layout fragment, line fragment, text selection 과 같이 TextKit 2에서 사용할 수 있는 객체를 찾는다.
위의 예시 코드를 봤을 때 line fragment rect를 가지고 무언가 하고 있기 때문에 TextKit 2를 사용한다면 NSTextLineFragment
와 NSTextLayoutFragment
를 사용할 수 있을 것이다.
그리고 앞선 3단계를 거치고 나서 TextKit 2를 사용하게끔 바꾼 코드는 위와 같다. Glyph를 iterating하는 것 대신에 문서 내의 text layout fragment를 enumerating하고 각 layout fragment 내의 text line fragment를 모두 세는 클로저를 제공했다.
2. Updating NSRange code
NSRange
TextKit 1은 NSRange
를 사용해서 텍스트 컨텐츠에 인덱스를 붙인다. NSRange
는 문자의 linear index다. 위의 예시를 보면 “TextKit 2!”에 대한 NSRange
는 location 6과 length 10을 갖고 있는데, 6번째 문자에서 시작해서 10 글자이기 때문이다. 이런 linear model은 이해하기 쉽고, 문자열에 인덱스를 붙이기 좋다.
하지만 linear model은 단순 문자열이 아닌 구조에 인덱스를 붙이기에는 적합하지 않다. 위의 예시를 보면 HTML 문서는 트리 구조로 표현되어 있다. NSRange
로는 “TextKit 2!”의 위치가 span tag안에 3 단계 하위에 중첩되어 있는 위치에 있다고 표현할 수 있는 방법이 없다.
NSTextRange
HTML 문서와 같이 중첩된 구조를 가지고 있는 곳에서는 NSRange
로 인덱스를 표현하기가 어렵기 때문에 TextKit 2에서는 텍스트 컨텐츠의 범위를 표현하기 위한 새로운 타입을 추가했다.
NSTextLocation
: Text 컨텐츠 내의 한 위치를 표현하는 객체NSTextRange
: 시작, 끝 지점으로 구성되어 있고, 맨 마지막 위치는 범위에 포함시키지 않는다.
위의 새로운 타입을 사용해서 중첩된 구조를 가진 문서의 DOM node와 문자 offset으로서 위치를 표현할 수 있다.
NSTextLocation
이 프로토콜이기 때문에, 프로토콜을 채택하는 커스텀 객체를 만들 수 있다. 이는 모델에서 구조화된 데이터를 가진 다양한 타입의 backing store를 지원할 때 중요하다.
하지만 NSAttributedString
backing store를 기반으로 만들어진 text view는 이런 구조를 가지고 있지 않다. 그래서 selectedRange
나 scrollRangeToVisible
과 같은 textView API를 사용하면 NSRange
를 써야 한다. TextKit 2 layout manager나 content manager를 사용할 때 NSRange
와 NSTextRange
를 서로 변환하는 작업을 해야 한다.
Converting
NSRange ➡️ NSTextRange
Text view의 NSRange
를 NSTextRange
로 변경하려면 location
을 시작 location, length
를 NSRange
의 location
과 length
를 더해서 변환한다.
실제로 코드를 작성하면 NSTextLocation
은 객체여야 하기 때문에 위와 같이 될 것이다.
NSTextRange ➡️ NSRange
반대로 변환하기 위해서는 textContentManager
를 사용해서 두 개의 offset을 받는다.
UITextRange ↔️ NSTextRange
UITextView와 UITextField는 UITextInput 프로토콜을 채택하고 있는데, UITextInput 프로토콜은 UITextPosition
과 UITextRange
를 사용한다. 대부분의 경우 UITextView나 UITextField를 사용할 때 UITextRange
를 NSTextRange
로 직접 변환하지는 않지만 만약 하게 된다면 정수 offset을 사용해서 변환한다.
만약 UITextInput을 사용하는 커스텀 view가 있다면 뷰 안의 UITextPosition
, UITextRange
하위 클래스를 직접 통제할 수 있을 것이다. 이때 UITextPosition
하위 클래스가 NSTextLocation
을 채택하게 해서 NSTextRange
를 바로 생성할 수 있게 할 수 있다.
참고로 다양한 뷰에서 UITextPosition
객체를 재사용하는 것을 피해야 한다고 한다. 내부의 컨텐츠가 비슷해도, UITextPosition
은 그것을 생성한 뷰에서만 정상적으로 동작하게 되어 있다.