[FOU] AttributedString Advanced Attribute Behaviors

Apple's Foundation team is working on adding new capabilities to AttributedString to support some new behaviors when working with advanced attributes. We'd like to get your feedback on the proposed enhancements below. Thanks!

AttributedString Advanced Attribute Behaviors

Revision history

  • v1 Initial version

Introduction / Motivation

In today's text system based on AttributedString/NSAttributedString, you can apply any attribute to any range of text within the string. However, there are certain attributes which have associated rules describing how you should apply and/or mutate the attribute. These rules are currently used to correct and fix-up attributes at render time to address any "invalid" states resulting from mutations that don't follow each attribute's documented rules. As we improve AttributedString, we'd like to take the opportunity to improve this situation to prevent an AttributedString from ever representing an invalid state. We plan to better define the rules that attributes must follow and incorporate these behaviors as part of the attribute's API definition beyond documentation notes. This addition would allow AttributedString to ensure attributes are applied and mutated correctly at the time of mutation rather than requiring fix-ups at render time.

Proposed solution and example

We've generalized the behaviors exhbited by many attributed string attributes into three categories: textual constraining behaviors, attribute inheritance behaviors, and attribute invalidation behaviors. Examples of each of these, including theoretical attributes that could benefit from these behaviors, are as follows:

Textual Constraining Behaviors

Some attributes such as any paragraph style attribute or an attachment attribute require that attribute values are only applied over certain ranges of text. For example, a text alignment attribute (part of a paragraph's style) should always be applied to an entire paragraph (rather than portions of a paragraph) and a text attachment attribute should be applied specifically to the text attachment character U+FFFC (rather than any other characters). When mutating these attributes within an AttributedString, we should guarantee that the attribute will always be applied over the specified type of text. For example:

enum TextAlignmentAttribute : AttributedStringKey {
    ...
    static let contentConstraint: AttributedString.AttributeTextConstraint = .paragraph
}
enum TextAttachmentAttribute : AttributedStringKey {
    ...
    static let contentConstraint: AttributedString.AttributeTextConstraint = .character("\u{FFFC}")
}

var string = AttributedString("Please see this attachment: \u{FFFC}\nContinue reading on the next page")
string.textAttachment = /* some text attachment representing an icon */
let subrange = string.startIndex ..< string.index(afterCharacter: string.startIndex)
string[subrange].textAlignment = .center

In this example, the resulting string would look like:

"Please see this attachment: " "\u{FFFC}" "\n" "Continue reading on the next page"
textAlignment .center .center .center nil
textAttachment nil some text attachment nil nil

Note that the textAttachment attribute was constrained to just the declared character, and the textAlignment attribute was expanded and constrained to the full range of the first paragraph.

Attribute Inheritance Behavior

By default, when inserting characters into an AttributedString, the preceding run is extended to cover the range of newly inserted characters. However, for some attributes this behavior does not necessarily make sense. For example, an attribute indicating spelling correction state should not be extended to newly typed characters because the spelling correction state may be different for those new characters.

enum SpellingStateAttribute : AttributedStringKey {
    ...
    static let inheritedByAddedText = false
}

var string = AttributedString("Hello world.")
string.spellingState = .correct
string.foregroundColor = .blue
string.characters.append(contentsOf: " This portion could be misspelled.")

In this example, the resulting string would look like:

"Hello world." " This portion could be misspelled."
spellingState .correct nil
foregroundColor .blue .blue

Note that a standard attribute like foregroundColor extends to include the new text, but the spellingState attribute does not apply to the inserted subrange indicating that spell-checking has not been performed on this subrange.

Attribute Invalidation Behavior

The final category of attributes includes those whose values depend on other content in the AttributedString. For example, a glyph info attribute that describes how to render certain glyphs would be dependent on the character content of the string and the value of the font attribute. Any change to an attribute's dependencies should invalidate and remove the attribute from the attributed string. For example:

enum GlyphInfoAttribute : AttributedStringKey {
    ...
    static let dependencies: Set<AttributedString.AttributeDependency> = [.characters, .attribute(\.font)]
}

var string = AttributedString("Hello world")
string.font = .body
string.glyphInfo = /* some info to describe how to render "Hello world" */
if let worldRange = string.range(of: "world") {
    string[worldRange].font = .caption
}

In this example, the resulting string would look like:

"Hello " "world"
font .body .caption
glyphInfo some glyph info nil

Note that the glyphInfo attribute was removed from the range of the string in which the font attribute changed since the glyphInfo attribute depends upon the value of the font attribute.

Detailed design

In order to represent these behaviors, we propose adding the following properties and default implementations to AttributedStringKey along with their supporting types:

extension AttributedString {
    @available(macOS 9999, iOS 9999, tvOS 9999, watchOS 9999, *)
    public enum AttributeTextConstraint : Hashable, Codable, Sendable {
        case paragraph
        case character(Character)
    }
    
    @available(macOS 9999, iOS 9999, tvOS 9999, watchOS 9999, *)
    public struct AttributeDependency : Hashable, Sendable {
        /// Declares that any mutation of the character content of the string will cause the entire
        /// range of the attribute at the mutation location to be removed (note: the entire range of
        /// the attribute may extend beyond the range of the mutation)
        public static let characters: AttributeDependency
        
        /// Declares that any mutation of the specified attribute will cause the dependent attribute
        /// to be removed across the range of the mutation
        public static func attribute<T: AttributedStringKey>(_ key: T.Type) -> AttributeDependency
        public static func attribute<T: AttributedStringKey>(_ keyPath: KeyPath<AttributeDynamicLookup, T>) -> AttributeDependency
    }
}

public protocol AttributedStringKey {
    ...
    
    @available(macOS 9999, iOS 9999, tvOS 9999, watchOS 9999, *) 
    static var textConstraint: AttributedString.AttributeTextConstraint? { get }
    
    @available(macOS 9999, iOS 9999, tvOS 9999, watchOS 9999, *)
    static var inheritedByAddedText: Bool { get }
    
    @available(macOS 9999, iOS 9999, tvOS 9999, watchOS 9999, *)
    static var dependencies: Set<AttributedString.AttributeDependency> { get }
}

@available(macOS 9999, iOS 9999, tvOS 9999, watchOS 9999, *)
extension AttributedStringKey {
    static var textConstraint: AttributedString.AttributeTextConstraint? { nil }
    static var inheritableForAddedText: Bool { true }
    static var dependencies: Set<AttributedString.AttributeDependency> { [] }
}

Impact on existing code

There is no impact to existing code. The default implementations of the protocol requirements ensure that the new behaviors are opt-in and all existing code will continue to compile without a source break.

Alternatives considered

AttributeTextConstraint.bounds(CharacterSet)

A previous iteration used a bounds case rather than predefined content constraints that could represent cases such as paragraph or line boundaries. However, we realized that a single CharacterSet boundary did not fully encompass concepts such as paragraph boundaries (which could be bound by "\n" or "\r\n", for example). We decided to move forward with providing a set of more specific options like paragraph and a future line option to satisfy the need of a bounds(CharacterSet) option.

9 Likes

I'm really excited about these additions. I've done a lot of work with attributed strings, and specifically with fixing up attributes on attributed strings. Many of the use cases I've had to deal with are covered by this proposal.

The Alternatives Considered section mentions the a possible future AttributeTextConstraint.line case, alongside the proposed .paragraph one. As I understand it, Foundation considers "\n" to be a paragraph separator; that is, AttributedString("Line one\nLine Two") has two paragraphs. Would the .line case look for all the usual paragraph separators, plus also \u{2028} (Line Separator)?

1 Like

I'm glad to hear this solution would help with a lot of your use cases!

As it stands, the paragraph text constraint would align with the boundaries already defined by Foundation's existing paragraph APIs such as NSString/String's getParagraphStart(_:end:contentsEnd:for:) (which considers the carriage return, newline, and paragraph separator characters as paragraph breaks). Similarly, I think any future directions with additional AttributeTextConstraint values should align with Foundation's existing definitions (when applicable). So in specific, if we were to envision a line text constraint I think that would align with NSString/String's getLineStart(_:end:contentsEnd:for:) which looks for the usual paragraph separators, U+2028 Line Separator, and a few others.

3 Likes