ZhgChg.Li

iOS 18 NSAttributedString Attributes Range Merging|Equatable Behavior Change Explained

Discover how iOS 18 updates NSAttributedString attributes range merging by referencing Equatable, resolving inconsistencies and improving attribute handling for developers.

iOS 18 NSAttributedString Attributes Range Merging|Equatable Behavior Change Explained

A Behavior Change in NSAttributedString Attributes Range Merging in iOS ≥ 18

Independent writing, free to read — please support these ads

 

Advertise here →

iOS ≥ 18 Starts Merging NSAttributedString Attributes Ranges Based on Equatable

Photo by C M

Photo by C M

Cause of the Issue

After iOS 18 was released on 2024/9/17, developers reported that the open-source project ZMarkupParser crashes when parsing certain HTML on iOS 18.

Seeing this issue is a bit confusing because the code worked fine before, and it only crashes starting with iOS 18. This is unusual and likely caused by some underlying changes in Foundation in iOS 18.

Crash Trace

After tracing the code, the crash point is located when iterating over .breaklinePlaceholder attributes and performing deletion on the Range:

mutableAttributedString.enumerateAttribute(.breaklinePlaceholder, in: NSMakeRange(0, NSMakeRange(0, mutableAttributedString.string.utf16.count))) { value, range, _ in
  // ...if condition...
  // mutableAttributedString.deleteCharacters(in: preRange)
  // ...if condition...
  // mutableAttributedString.deleteCharacters(in: range)
}
*** Terminating app due to uncaught exception 'NSRangeException', reason: 'NSMutableRLEArray replaceObjectsInRange:withObject:length:: Out of bounds'

.breaklinePlaceholder is a custom NSAttributedString.Key I created to mark HTML tag information and optimize the use of line break characters:

struct BreaklinePlaceholder: OptionSet {
    let rawValue: Int

    static let tagBoundaryPrefix = BreaklinePlaceholder(rawValue: 1)
    static let tagBoundarySuffix = BreaklinePlaceholder(rawValue: 2)
    static let breaklineTag = BreaklinePlaceholder(rawValue: 3)
}

extension NSAttributedString.Key {
    static let breaklinePlaceholder: NSAttributedString.Key = .init("breaklinePlaceholder")
}

But the core issue is not here , because before iOS 17, the input mutableAttributedString could perform the above operations without issues; this means the input data content has changed in iOS 18.

NSAttributedString attributes: [NSAttributedString.Key: Any?]

Before diving deeper into the issue, let’s first introduce the merging mechanism of NSAttributedString attributes.

NSAttributedString attributes will automatically compare adjacent Range Attributes with the same .key to see if they are identical, and merge them into a single Attribute if they are. For example:

let mutableAttributedString = NSMutableAttributedString(string: "", attributes: nil)
mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
mutableAttributedString.append(NSAttributedString(string: "<p>", attributes: [.font: UIFont.systemFont(ofSize: 14)]))
mutableAttributedString.append(NSAttributedString(string: "Test", attributes: [.font: UIFont.systemFont(ofSize: 12)]))

Final Attributes Merging Result:

<div><div><p>{
    NSFont = "<UICTFont: 0x101d13400> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 14.00pt";
}Test{
    NSFont = "<UICTFont: 0x101d13860> font-family: \".SFUI-Regular\"; font-weight: normal; font-style: normal; font-size: 12.00pt";
}

When using enumerateAttribute(.breaklinePlaceholder...), the following results are obtained:

NSRange {0, 13}: <UICTFont: 0x101d13400> font-family: ".SFUI-Regular"; font-weight: normal; font-style: normal; font-size: 14.00pt
NSRange {13, 4}: <UICTFont: 0x101d13860> font-family: ".SFUI-Regular"; font-weight: normal; font-style: normal; font-size: 12.00pt

NSAttributedString attributes Merging — Speculated Underlying Implementation

It is inferred that the underlying implementation uses Set<Hashable> as the container for Attributes, which automatically excludes duplicate Attribute objects.

However, for ease of use, the NSAttributedString attributes: [NSAttributedString.Key: Any?] value object is declared as type Any?, without requiring it to be Hashable.

It is therefore speculated that the system internally performs Conform as? Hashable and then uses a Set to merge and manage objects.

The underlying implementation issue is likely the cause of the changes in iOS ≥ 18.

The following is an example using our custom .breaklinePlaceholder attribute:

struct BreaklinePlaceholder: Equatable {
    let rawValue: Int

    static let tagBoundaryPrefix = BreaklinePlaceholder(rawValue: 1)
    static let tagBoundarySuffix = BreaklinePlaceholder(rawValue: 2)
    static let breaklineTag = BreaklinePlaceholder(rawValue: 3)
}

extension NSAttributedString.Key {
    static let breaklinePlaceholder: NSAttributedString.Key = .init("breaklinePlaceholder")
}

//

let mutableAttributedString = NSMutableAttributedString(string: "", attributes: nil)
mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
mutableAttributedString.append(NSAttributedString(string: "<div>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
mutableAttributedString.append(NSAttributedString(string: "<p>", attributes: [.breaklinePlaceholder: NSAttributedString.Key.BreaklinePlaceholder.tagBoundaryPrefix]))
mutableAttributedString.append(NSAttributedString(string: "Test", attributes: nil))

iOS ≤ 17 will result in the following Attributes merge outcome:

<div>{
    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}<div>{
    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}<p>{
    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}Test{
}

iOS ≥ 18 will result in the following merged Attributes:

<div><div><p>{
    breaklinePlaceholder = "NSAttributedStringCrash.BreaklinePlaceholder(rawValue: 1)";
}Test{
}

You can see that the same code produces different results on different iOS versions, which ultimately causes unexpected behavior in the subsequent enumerateAttribute(.breaklinePlaceholder..) processing logic, leading to a crash.

⭐️ iOS ≥ 18 NSAttributedString attributes: [NSAttributedString.Key: Any?] now also considers Equatable == ⭐️

Comparison of results with or without implementing Equatable/Hashable in iOS 17/18

Comparison of iOS 17/18 Results with or without Implementing Equatable/Hashable

⭐️⭐️ iOS ≥ 18 also considers Equatable, while iOS ≤ 17 does not.⭐️⭐️

Combining the above, the NSAttributedString attributes: [NSAttributedString.Key: Any?] value is declared as type Any?. Based on observations, iOS ≥ 18 first checks equality using Equatable, then uses a Hashable set to merge and manage objects.

Conclusion

Independent writing, free to read — please support these ads

 

Advertise here →

NSAttributedString attributes: [NSAttributedString.Key: Any?] now consider Equatable when merging Range Attributes in iOS ≥ 18, which is different from before.

Additionally, starting from iOS 18, if only Equatable is declared, the XCode Console will also output a Warning:

Obj-C -hash invoked on a Swift value of type BreaklinePlaceholder that is Equatable but not Hashable; this can cause serious performance issues.

Improve this page
Edit on GitHub
Also published on Medium
Read the original
Share this essay
Copy link · share to socials
ZhgChgLi
Author

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

Comments