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
- Proposal: FOU-XXXX
- Author(s): Jeremy Schonfeld jschonfeld@apple.com
- Status: Review
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.