블로그 이미지

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • iOS 16 UITextView 이슈, TextKit 2
    iOS 2023. 1. 3. 22:57

    iOS 16 TextView TextKit 이슈

    문제 상황

    같은 코드인데 os 버전에 따라 UITextView의 디자인이 다르게 나옴

    아래와 같이 3줄짜리 UITextView가 있다.


    Text view에 경계선을 그리면 아래와 같다.


    밑줄은 UITextView를 상속해서 custom subclass를 만들고, draw(_:) 메서드를 override 해서 그렸다. 이 text view는 줄 높이, 줄 간 간격, 폰트 등 다양한 수치가 각각 다 임의로 정의한 상태다.

    ✅ ~ iOS 15

    iOS 15까지는 설정한 수치대에 맞게 텍스트가 어떻게 입력해도 정상적인 위치에 출력됐다.

    ❗️ iOS 16

    같은 코드를 iOS 16 버전의 기기에서 수행한 화면은 다음과 같다.

    확인된 문제만 해도 아래와 같다.

    1. 텍스트 위치가 제 자리에 있지 않음
    2. 중간에 개행을 해서 빈 줄을 추가한 다음(2번째 줄) 다음 줄(3번째 줄)에 처음 텍스트를 입력할 때 커서가 갑자기 아래로 내려가면서 텍스트가 입력됨

    코드 상 수정된 부분은 없었고, 실행 환경에서 버전만 바뀌었기 때문에 iOS 15와 iOS 16에서 UITextView에서 layout 차이가 있는지 확인했다.

    적용된 수치

    (밑줄은 단순히 줄 내의 텍스트의 위치를 좀 더 쉽게 비교하고 줄 위치를 알아보기 쉽게 위해 그은 것이므로 무시해도 된다.)

    • 줄 높이 : 25
    • 줄 간 간격 : 5
    • 폰트 크기 : 18
    • baselineOffset : UIFont.systemFont(ofSize: 18).ascender - UIFont.systemFont(ofSize: 18).capHeight

    줄 높이는 25인데 비해, 폰트 크기는 18이기 때문에 폰트 크기가 줄에 비해 작았다. 따라서 폰트를 줄의 수직 방향 가운데에 위치시키는 것이 중요했다.


    위 사진을 보면 줄 높이를 극단적으로 100으로 설정했고, 폰트 크기는 18로 설정했다. 보면 텍스트는 기본적으로 줄의 가장 밑 부분에 위치하기 때문에, 텍스트를 현재 위치한 곳보다 더 윗부분에 노출시켜야 했다.

     

    그래서 텍스트의 y축 위치를 조정할 수 있는 baselineOffset으로 조정했다.

     

    text를 많이 다루지 않은 개발자라면 위의 요소 중에 baselineOffset, ascender, capHeight가 생소할 것으로 생각하는데, 개념을 정리하면 다음과 같다.

    개념

    1. baselineOffset : text의 수직 방향의(y축) 위치

    정확하게 얘기하면 baseline으로부터의 문자의 offset을 나타내는 실수 값으로, 기본값은 0이다.

    Baseline은 아래의 그림에서 빨간색 밑줄로 그어져 있는 부분이다.

    어릴 때 사용하는 영어 공책에 그어진 빨간 밑줄을 생각하면, 그 밑줄을 기준으로 j, g와 같은 문자는 문자의 밑부분이 빨간 줄 밑으로 내려가 있고, a, i와 같은 문자는 그 빨간 선 윗부분에 적었을 것이다. 그 기준이 되는 빨간 선을 baseline이라 한다.

     

    baselineOffset은 저 baseline으로부터 문자가 얼마나 떨어져 있는지를 표현한 것이기 때문에 + 값을 주게 되면 텍스트가 더 위로 올라가고, - 값을 주면 텍스트가 더 밑으로 내려가게 된다. 이론은 그런데,

     

    baselineOffset을 10으로 지정했을 때


    텍스트가 baselineOffset이 0일 때에 비해 더 위로 올라간 것을 확인할 수 있다.

     

    baselineOffset을 -100으로 지정했을 때


    baselineOffset을 -100으로 줬을 때(차이를 확연하게 보기 위해서 일부러 큰 값을 줬다)는 달라진 부분이 없었다. SwiftUI에는 baselineOffset을 지정하는 baselineOffset(_:) 메서드가 있는데, 공식문서에서는 - 값을 주면 텍스트가 밑으로 내려간다는데 SwiftUI와 UIKit은 이 부분이 다른 것인지 설명이 나와있는 공식 문서가 없어 정확하게 어떻게 동작하는지는 미지수다.

    2. ascender, capHeight

    • ascender : 전체 줄 높이에서 baseline 윗부분에 해당하는 부분
    • capHeight : 문자의 bounding rectangle의 높이

    Ascneder = CapHeight + 최상단의 일부 여백 이다.

    정리

    텍스트의 크기가 줄 높이보다 많이 작고, 기본적으로 텍스트는 줄의 바닥 부분에 붙어서 입력되기 때문에 이 텍스트를 위로 올려야 했다. 이를 baselineOffset을 사용해서 조정했다.

     

    baselineOffset을 UIFont.systemFont(ofSize: 18).ascender - UIFont.systemFont(ofSize: 18).capHeight 으로 설정한 것은

     

    UIFont.systemFont(ofSize: 18).ascender - UIFont.systemFont(ofSize: 18).capHeight만큼 텍스트를 수직으로 이동 시킨다는 뜻이고,

    font.ascender - font.capHeight는 가장 최상단의 여백, 즉 위 그림에서 표시한 부분의 높이만큼을 의미한다. 저 공간이 - 값이 나올 수가 없으므로 여백만큼 텍스트를 위로 조정하겠다는 뜻이 된다. 그래서 내가 설정한 것은 아주 약간의 여백만큼만 텍스트를 위로 올리는 것이었다.

     

    중요한 것은 아니지만, baselineOffset을 저렇게 설정한 것은 텍스트를 줄의 높이 한가운데에 위치시키는 코드가 아니다. 단순히 폰트의 font.ascender - font.capHeight를 계산해서 baselineOffset에 적용한 결과가 공교롭게도 텍스트가 줄의 중앙에 위치하도록 보이도록 된 것이다.

    코드

    코드 상으로는 위의 수치들을 적용하기 위해 다음의 과정을 거쳤다.

    1. UITextView를 생성
    2. NSMutableParagraphStyle 객체를 생성한 후 줄 높이, 줄 간 간격 설정
    3. NSAttributedString 객체 생성, 앞서 생성한 NSMutableParagraphStylebaselineOffset을 attribute로 적용
    4. 생성한 NSAttributedString 객체를 text view의 attributedText 프로퍼티에 할당
    5. UITextView를 생성
    // `CustomTextView` 클래스는 `draw` 메서드만 overriding 해서 밑줄을 그은 클래스다.
    textView = CustomTextView()
    
    // textContainerInset을 zero로 만들어서 text container가 text view의 content 영역에 inset 없이 딱 맞게 만듬
    textView.textContainerInset = .zero
    1. NSMutableParagraphStyle 객체를 생성한 후 줄 높이, 줄 간 간격 설정
    private func createAttributtedText() -> NSAttributedString {
        let style = NSMutableParagraphStyle()
        let lineHeight: CGFloat = 25
    
        // 줄 높이 설정
        style.maximumLineHeight = lineHeight
        style.minimumLineHeight = lineHeight
        // 줄 간 간격 설정
        style.lineSpacing = 5
    
        ...
    }
    1. NSAttributedString 객체 생성, 앞서 생성한 NSMutableParagraphStylebaselineOffset을 attribute로 적용
    private func createAttributtedText() -> NSAttributedString {
        ...
    
        let attributes: [NSAttributedString.Key : Any] = [
            NSAttributedString.Key.paragraphStyle : style,
            NSAttributedString.Key.foregroundColor : UIColor.black,
            NSAttributedString.Key.font : UIFont.systemFont(ofSize: 18),
            // baselineOffset 설정
            NSAttributedString.Key.baselineOffset : UIFont.systemFont(ofSize: 18).ascender - UIFont.systemFont(ofSize: 18).capHeight
        ]
    
        ...
    }
    1. 생성한 NSAttributedString 객체를 text view의 attributedText 프로퍼티에 할당
    private func createAttributtedText() -> NSAttributedString {
        ...
    
        let attributedText: NSAttributedString = NSAttributedString(string: "테스트", attributes: attributes)
    
        return attributedText
    }
    
    textView.attributedText = createAttributtedText()

    원인

    코드는 수정된 곳이 없었기 때문에, iOS 15와 iOS 16에서 UITextView / AttributedString / ParagraphStyle 에서 달라지는 점이 있는지 확인했다.

    baselineOffset의 문제?

    iOS 버전과 상관없이 폰트의 각 attribute는 동일한 값을 유지하고 있기 때문에 baselineOffset에는 항상 같은 값이 들어가고, 실제로 상수로 baselineOffset에 5를 대입했을 때 iOS 15와 iOS 16에서 문제 상황이 지속되었기 때문에 baselineOffset의 문제는 아닌 것으로 확인했다.

     

    하지만 검색을 통해 텍스트를 줄의 가운데에 위치시키기 위해서는 baselineOffset(줄 높이 - 폰트.lineHeight) / 4으로 설정한다는 것을 알게 되었다. 실제로 b

    os 문제?

    검색을 통해 알게 된 것은 iOS 15까지 UITextView는 Text Kit 1을 사용하고 있는데, iOS 16부터는 UITextView가 기본적으로 Text Kit 2를 사용한다고 한다.

    TextKit 2에서 각 레이아웃 요소를 layout 할 때 정확히 어떻게 하고 있는지 내부 구현을 볼 수 없기 때문에 왜 TextKit 1을 사용했을 때와 달라지는지는 알 수 없다.

    해결

    코드 상 달라진 부분은 없는데 UITextview가 사용하고 있는 TextKit의 버전에 따라 UI가 다르게 나타났기 때문에 OS 문제로 판단, iOS 16 버전에서 자동으로 TextKit 2를 사용하고 있는 부분을 TextKit 1을 사용하도록 fallback 하는 전략을 사용했다.

    TextKit

    TextKit은 모든 Apple 플랫폼의 텍스트 레이아웃을 조정하고 화면에 출력하는 레이아웃 엔진이다.

    TextKit 2가 생긴 이유

    UIKit과 AppKit은 TextKit 1을 사용해서 텍스트의 레이아웃과 저장소를 관리했다. TextKit 1은 iOS 7부터 iOS 14까지 굉장히 오랜 시간 사용됐기 때문에 옛날 원칙에 기반해 구현되어 있었고, 따라서 좋은 성능을 유지하며 새로운 기술과 결합해 API를 제공하기가 점점 어려워졌다. 이 때문에 TextKit 2가 등장했다.

    TextKit 2

    TextKit 2는 새로운 버전의 텍스트 엔진으로, 미래 지향적인 디자인 원칙에 기반해 구현됐다. TextKit 2도 TextKit 1과 마찬가지로 Foundation, Quarts, Core Text를 기반으로 구현되었고, UIKit과 AppKit의 text control도 TextKit2를 기반으로 동작한다.

     

    TextKit 2는 1. 정확성, 2. 안정성, 3. 성능이라는 세 가지 원칙에 기반해 디자인되었는데, 여기에서 자세히 다룰 내용은 아니라 자세한 내용은 WWDC 21 Meet TextKit 2, WWDC 22 What's new in TextKit and text views 영상을 확인하면 된다.

     

    TextKit 2는 iOS 15에서 처음 등장했고, UIKit에서는 UITextField가 TextKit 2를 사용하게 되었다. 그리고 iOS 16에서는 UITextView가 TextKit 2를 사용하게 된다.

    Compatibility mode

    iOS 15, iOS 16 이전에 구현된 여러 앱들에서 TextKit 1에 강하게 의존하는 text control들도 iOS 15, iOS 16에서 정상적으로 동작할 수 있게 TextKit 1 compatibility mode가 추가됐다. Compatibility mode는 특정 상황을 감지한 후 TextKit 2를 사용하지 않고 TextKit 1을 사용하는 것을 말한다. Compatibility mode에 진입하게 되는 상황에는 다음의 상황들이 있다.

    • 명시적으로 layoutManager API를 호출하면 text view는 TextKit 2에서 사용하는 NSTextLayoutManager를 TextKit 1에서 사용하는 NSLayoutManager로 바꾸고 TextKit 1을 사용한다.
    • TextKit 2에 의해 지원되지 않는 attribute를 사용할 때

    한 Text view에는 오로지 하나의 layout manager만 존재할 수 있기 때문에, NSTextLayoutMangerNSLayoutManager를 함께 가질 수 없다. 또한 Layout 시스템을 바꾸는데 많은 작업이 필요하다는 이유 등으로 Text view가 한 번 TextKit 1으로 전환되면 다시 TextKit 2로 돌아갈 수 없다.

     

    성능과 사용성을 위해 시스템이 TextKit1에서 TextKit2로 전환하지 않는데, compatibility mode를 가능한 한 사용하지 않고 애초에 처음에 사용할 때부터 TextKit 1 / TextKit 2 중 무엇을 사용할지 정하는 것이 좋다고 한다.

    Opt out

    TextKit 1을 사용하고자 할 때 Apple에서 추천하는 opt out 전략은 코드로 text view의 생성 시점에 TextKit1을 사용하도록 설정하는 것이다.

    1. 명시적으로 NSLayoutManager 객체 생성 후 UITextView 생성자에 할당

    NSLayoutManager를 사용하게 되면 TextKit1을 사용하도록 fallback 한다.

    let textStorage = NSTextStorage()
    // TextKit1 layout manager 생성
    let layoutManager = NSLayoutManager()
    let textContainer = NSTextContainer()
    
    textStorage.addLayoutManager(layoutManager)
    layoutManager.addTextContainer(textContainer)
    
    textView = CustomTextView(frame: .zero, textContainer: textContainer)
    1. Convenience 생성자 사용

    이 두 번째 방법이 더 간단하다. 다만 iOS 16 이상부터 사용할 수 있는 방법이다.

    textView = CustomTextView(usingTextLayoutManager: false)

    Check Using TextKit 1 / TextKit 2

    TextKit 1, TextKit 2 중 현재 어떤 것을 사용하고 있는지 확인하기 위해서는 아래와 같이 text view가 NSTextLayoutManager (TextKit 2 매니저)를 갖고 있는지 먼저 확인하는 방법으로 사용할 수 있다.

    if #available(iOS 16.0, *), let textlayoutManger = textView.textLayoutManager {
      // TextKit 2
    } else {
      // TextKit 1
    }

    결론

    iOS 15, 16에 거쳐 text control에서 TextKit1을 TextKit2로 대체하면서 여러 UI 이슈들이 존재하는 것으로 보인다. 따라서 높은 버전에서 이전에 사용했던 text control들에 이슈가 있는지 확인한 후, 이슈가 있다면 원인 파악 후에 TextKit 1으로 fallback 하는 전략을 사용해도 좋아 보인다.


    추가

    TextKit1, TextKit2에서 각각 텍스트 블럭을 어떻게 layout 하는지 텍스트 블럭에 경계선을 추가하고 frame 정보를 출력해서 자세히 알아봤다.

    TextKit Version ScreenShot
    1 imageimage
    2 image
    • TextKit 1 : 각 lineFragment들이 정상적인 origin, size(줄 높이 25 + 줄 간 간격 5)를 갖고 있다.
    • TextKit 2 : 각 textLayoutFragment들이 이상한 size(첫 번째 요소 높이 20, 나머지 요소 높이 25)를 갖고 있으며 줄 간 간격이 제대로 반영이 안 된 것으로 보인다.

    TextKit1과 TextKit 2는 다른 요소를 갖고 layout을 하고 있기 때문에 각 TextKit 버전에 맞는 요소에 대한 정보를 출력했다. TextKit1의 경우 NSLayoutManagerDelegatelayoutManager(_:shouldSetLineFragmentRect:lineFragmentUsedRect:baselineOffset:in:forGlyphRange:) 메서드를 사용해서도 lineFragment의 높이를 조정할 수 있는데, TextKit2에서는 해당하는 메서드를 아직 찾지 못했다.

    코드

    if #available(iOS 16.0, *), let textlayoutManger = textView.textLayoutManager {
        print("=======================")
        textView.textLayoutManager?.enumerateTextLayoutFragments(from: textView.textLayoutManager?.documentRange.location, options: [.ensuresLayout], using: { layoutFragment in
            let borderLayer = createBorderLayer(frame: layoutFragment.layoutFragmentFrame)
            textView.layer.addSublayer(borderLayer)
    
            print("🔶 TextKit2 layoutFragment \(n) : \(layoutFragment)")
            print("- layoutFragmentFrame \(n) : \(layoutFragment.layoutFragmentFrame)")
            print("- renderingSurfaceBounds \(n) : \(layoutFragment.renderingSurfaceBounds)")
    
            n += 1
            return true
        })
    } else {
        // Fallback on earlier versions
        let layoutManager = textView.layoutManager
        let range = (text as NSString).range(of: text)
    
        layoutManager.enumerateLineFragments(forGlyphRange: range) { rect, usedRect, textContainer, glyphRange, bool in
            print("🔷 TextKit1 lineFragment \(n)")
            print("- rect : \(rect)")
            let borderLayer = self.createBorderLayer(frame: rect)
            self.textView.layer.addSublayer(borderLayer)
            n += 1
        }
    //            let layoutManager = textView.layoutManager
    //            var numberOfLines: Int = 0, index: Int = 0, numberOfGlyphs = layoutManager.numberOfGlyphs
    //            var range: NSRange = NSRange(location: 0, length: 0)
    //
    //            while index < numberOfGlyphs {
    //                let rect = layoutManager.lineFragmentRect(forGlyphAt: index, effectiveRange: &range)
    //                print(rect)
    //                index = NSMaxRange(range)
    //                numberOfLines += 1
    //            }
    //            print(numberOfLines)
    }
    반응형

    'iOS' 카테고리의 다른 글

    [iOS] ARM Architecture  (0) 2023.01.13
    [iOS] RunLoop 개념  (0) 2023.01.13
    [iOS] Outlet이란?  (0) 2022.08.09
    [iOS] View controller의 역할  (0) 2022.08.09
    [iOS] View Controller Hierarchy - 뷰 컨트롤러 계층 구조  (0) 2022.08.09

    댓글

Designed by Tistory.