Hi Swift community, I'd like to propose another addition to AttributedString building off of some of the previously pitched APIs. I'd like to pitch adding new APIs that will allow developers to easily track important indices across mutations to an AttributedString. Please let me know if you have any thoughts, suggestions, or concerns and I'd love to discuss adding in these new capabilities to support advanced AttributedString uses!
AttributedString Tracking Indices
- Proposal: SF-NNNN
- Authors: Jeremy Schonfeld
- Review Manager: TBD
- Status: Pitch
Introduction
Similar to many other Collection types in Swift, AttributedString uses an opaque index type (AttributedString.Index) to represent locations within the storage of the text. AttributedString uses an opaque type instead of a trivial type like an integer in order to store a more complex representation of the location within the text. Specifically, AttributedString uses its index to store not only a raw UTF-8 offset into the text storage, but also a "path" through the rope structure that backs the AttributedString. This allows AttributedString to quickly find a location within the text storage given an index without performing linear scans of the underlying text on each access or mutation. However, because this opaque index type stores more complex information, it must be handled very carefully and kept in-sync with the AttributedString itself. Unlike an integer offset (which can often still be a valid index into a collection like Array after a mutation even if it points to a different semantic location), AttributedString.Index currently makes no guarantees about its validity after any mutation to the AttributedString and in many cases will (intentionally) crash when used improperly. As AttributedString is adopted in more advanced use cases throughout our platforms, we'd like to improve upon this developer experience for some common use cases of stored AttributedString.Indexs.
Motivation
In many cases, developers may wish to use these index types to store "pointers" to locations in the AttributedString separately from the text itself. For example, a text editor that uses an AttributedString as its underlying storage would likely want to store a RangeSet<AttributedString.Index> at the view or view model layer to represent a user's selection in the text while still allowing mutations of the text. Alternatively, complex, in-place, mutating operations that process an AttributedString in chunks may wish to temporarily store an AttributedString.Index representing the current processing location while it performs mutations on the text. In these scenarios, it is currently challenging (or in some cases not possible) to keep an opaque AttributedString.Index in-sync with a separate AttributedString while mutations are occuring since every mutation invalidates every previously produced index value. With more complex AttributedString-based APIs, it's important that we provide a mechanism for developers to keep these indices not only valid to prevent unexpected applications crashes but also correctly positioned to ensure they achieve expected end user behavior.
Proposed solution
To accomplish this goal, we will provide a few new APIs to make AttributedString index management and synchronization easy to use. First, we will propose a new API that allows AttributedString to update an index, range, or list of ranges while a mutation is being performed to ensure the indices remain valid and correct post-mutation. Developers will use a new proposed transform(updating:_:) API to do so like the following:
var attrStr = AttributedString("The quick brown fox jumped over the lazy dog")
guard let rangeOfJumped = attrStr.range(of: "jumped") else { ... }
let updatedRangeOfJumped = attrStr.transform(updating: rangeOfJumped) {
$0.insert("Wow!", at: $0.startIndex)
}
if let updatedRangeOfJumped {
print(attrStr[updatedRangeOfJumped]) // "jumped"
}
Note that in the above sample code, the returned Range references the range of "jumped" which is valid for use with the mutated attrStr (it will not crash) and it locates the same text - it does not represent the range of "fox ju" (a range offset by the 4 characters that were inserted at the beginning of the string).
Additionally, we will provide a set of APIs and guarantees to reduce the frequency of crashes caused by invalid indices and allow for dynamically determining whether an index has become out-of-sync with a given AttributedString. For example:
var attrStr = AttributedString("The quick brown fox jumped over the lazy dog")
guard var rangeOfJumped = attrStr.range(of: "jumped") else { ... }
// ... some additional processing ...
guard rangeOfJumped.isValid(within: attrStr) else {
// A mutation has ocurred without correctly updating `rangeOfJumped` - we should not use this range as it may crash or represent the wrong location
}
// `rangeOfJumped` has been correctly kept in-sync with `attrStr` as it has changed, we can use it freely
For the detailed design of these APIs and a wide variety of considerations and rejected alternatives, please check out the full proposal on the swift-foundation repo PR.