Skip to the content.

AttributeString의 AttributeScopes에 대해

iOS 15.0에서 발표된 AttributedStringAttributeScopes에 대한 글입니다.

NSAttributedString와 AttributedString의 차이점

NSAttributedString은 Foundation의 일부이지만, NSFontAttributeNameNSForegroundColorAttributeName은 UIKit 및 AppKit에서 구현된 로직입니다.

예를 들어 Font의 경우 UIKit에서는 UIFont이고 AppKit에서는 NSFont이며, ForegroundColor의 경우 UIKit에서는 UIColor, AppKit에서는 NSColor로 되어 있습니다. 이러한 특정 UI Framework에 의존적인 Key는 Foundation에서 구현하고 있지 않습니다.

NSAttributedString에서는 UIKit과 AppKit만 지원했다면, AttributedString은 UIKit, AppKit, SwiftUI를 모두 지원합니다. 이때문에 구조가 전반적으로 바뀌어야 합니다.

예를 들어 UIKit과 AppKit만을 동시에 지원하는 API를 설계한다고 하면, 아래처럼 간단하게 할 수 있습니다.

#if canImport(UIKit)
import UIKit
typealias OSColorType = UIColor
#elseif canImport(AppKit)
import AppKit
typealias OSColorType = NSColor
#endif

struct MyText {
    let text: String
    let osColor: OSColorType
}

let myText: MyText

let uiLabel: UILabel
uiLabel.textColor = myText.osColor

let nsTextField: NSTextField
nsTextField.textColor = myText.osColor

하지만 UIKit, AppKit, SwiftUI를 동시에 지원하는 API를 설계한다면 아래처럼 될 것입니다.

#if canImport(UIKit)
import UIKit
typealias OSColorType = UIColor
#elseif canImport(AppKit)
import AppKit
typealias OSColorType = NSColor
#endif

import SwiftUI

struct MyText {
    let text: String
    let osColor: OSColorType
    let swiftUIColor: SwiftUI.Color
}

let myText: MyText

let uiLabel: UILabel
uiLabel.textColor = myText.osColor

let nsTextField: NSTextField
nsTextField.textColor = myText.osColor

let swiftUIText: SwiftUI.Text = SwiftUI.Text("Test")
    .foregroundStyle(myText.swiftUIColor)

AttributeScopes 때문에 발생하는 문제

AttributeScopesConversion라는 샘플 프로젝트를 준비했습니다.

이런 구조의 샘플 프로젝트 입니다. 분명 빨간색을 할당해주고 있고 AttributeScopesConversion 내에서는 빨간색으로 정상적으로 할당된 것이 확인되지만 printForegroundColor(attributedString:)에서는 nil이 나오는 문제가 발생합니다.

하지만 Target에 import SwiftUI를 추가하면 print에 빨간색이 제대로 찍히는 기현상을 볼 수 있습니다.

이는 AttributeContainer의 내부 원리를 이해해야 합니다.

AttributeContainer의 내부 원리

만약에 제가 이런 코드를 짰다고 가정하면

var container: AttributeContainer = .init()
container.foregroundColor = .red

해당 subscript dynamicMember로 foregroundColor 같은 값을 설정할 경우 AttributeContainer.contents (또는 storage)의 값을 변경하게 됩니다. 이 동작은 AttributeDynamicLookup에서 이뤄지지만 이 부분의 소스코드는 공개되어 있지 않습니다.

storage의 값이 설정되는 것은 아래처럼 Mirror를 통해 확인할 수 있습니다.

var container: AttributeContainer = try! .init(
    [
        .font: UIFont.systemFont(ofSize: 30.0),
        .foregroundColor: UIColor.red,
    ],
    including: \.uiKit
)

container.swiftUI.foregroundColor = .red
container.swiftUI.kern = 30.0

/*
 (label: Optional("storage"), value: {
     NSColor = UIExtendedSRGBColorSpace 1 0 0 1
     NSFont = <UICTFont: 0x10270a7d0> font-family: ".SFUI-Regular"; font-weight: normal; font-style: normal; font-size: 30.00pt
     SwiftUI.ForegroundColor = red
     SwiftUI.Kern = 30.0
 })
 */
Mirror(reflecting: container)
    .children
    .forEach { child in
        print(child)
    }

이렇게 storage에 Dictionary 형태로 값이 저장된 것을 볼 수 있으며, 아까 보여드렸던 샘플 프로젝트의 Target에서는 SwiftUI가 import되지 않았기 때문에 subscript<T>(dynamicMember keyPath: KeyPath<AttributeScopes.SwiftUIAttributes, T>) -> T의 symbol이 없어서, SwiftUI.ForegroundColor의 값을 가져오지 못해서 nil이 나왔던 것입니다.

따라서 import SwiftUI를 해주면 위 storage에서 subscript를 통해 SwiftUI.ForegroundColor의 값을 가져올 수 있게 됩니다.

또한 위의 storage의 key들 (NSColor, NSFont, SwiftUI.ForegroundColor, SwiftUI.Kern… 등)은 _loadDefaultAttributes()에서 모두 불러와지며,

var container: AttributeContainer = try! .init(
    [
        .font: UIFont.systemFont(ofSize: 30.0),
        .foregroundColor: UIColor.red,
    ],
    including: \.uiKit
)

이 코드는 AttributedString.init(_:scope:otherAttributeTypes:options)를 통해 NSAttributedString.Key의 Key들이 위에서 언급한 Key로 변환되는 구조입니다.