I'm sharing the code that parses my preview data here. I'm using an NSViewRepresentable
and the call to - paginator.exhibitTheBug()
- is within makeNSView(...)
. But note that this bug occurs whether I call paginator.exhibitTheBug()
there, or in updateNSView(...)
, or from textStorage(:didProcessEditing:...)
via a DispatchQueue.main.async
closure. I.e., I thought at first that it was a threading/runtime issue, but it's not.
class Paginator {
let textStorage: NSTextStorage
let layoutManager: NSLayoutManager
init(textStorage: NSTextStorage,
layoutManager: NSLayoutManager) {
self.textStorage = textStorage
self.layoutManager = layoutManager
self.setupLayout()
}
/// Runs the functions that will exhibit the bug.
func exhibitTheBug() {
// Get char range to measure in container.
let characterRangeToTest = self.getCharacterRangeOfFirstColumnType()
// The bug occurs in this call:
testMeasurement(for: characterRangeToTest)
}
/// Returns the effective range of the text storage over which the column type (`NSAttributedString.Key.columnType`) assigned at location 0 applies. Note: This range of the textStorage includes more than 20 newlines.
private func getCharacterRangeOfFirstColumnType() -> NSRange {
var firstColumnTypeRange: NSRange = NSRange()
let rangeLimit = NSRange(location: 0, length: textStorage.length)
// Get the column type at the text storage start location, then we return the effective range that it applies to.
let columnTypeString = textStorage.attribute(.columnType,
at: 0,
longestEffectiveRange: &firstColumnTypeRange,
in: rangeLimit) as? String
assert(columnTypeString != nil)
return firstColumnTypeRange
}
/// The function that exhibits the bug.
private func testMeasurement(for columnTypeRange: NSRange) {
assert(layoutManager.textContainers.indices.contains(0))
let firstContainer = layoutManager.textContainers[0]
let glyphRange = layoutManager.glyphRange(forCharacterRange: columnTypeRange, actualCharacterRange: nil)
let glyphIndex = NSMaxRange(glyphRange) - 1
var glyphRangeInContainer = NSRange() // Will be updated by layout calls.
// Determine which container the glyph is in.
let glyphContainer = layoutManager.textContainer(forGlyphAt: glyphIndex, effectiveRange: &glyphRangeInContainer)
if glyphContainer === firstContainer {
// The glyph is in the first container.
let textRangeInContainer = layoutManager.characterRange(forGlyphRange: glyphRangeInContainer, actualGlyphRange: nil)
// We have NOT overflowed from
// the container.
// We now check the lineFragmentRect,
// which is **in the firstContainer's
// coordinate system**.
let glyphLineFragmentMaxY = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil, withoutAdditionalLayout: true).maxY
print("This is the bug!")
// !!!!BUG!!!!
// Data reads here:
// glyphIndex = 191
// glyphContainer === firstContainer
// glyphContainer!.size.height = 648.0
// glyphLineFragmentMaxY = 14.0
//
// I have wired the text containers and text
// views to the layout manager correctly.
// There are six of them, yet...
//
// ALL of the text lays out only into the
// first container. Note: if I change my data even
// slightly, e.g., modifying container/view sizes
// by only one point, then this doesn't occur!
}
}
/// Ensures that the layout manager has a container with the proposed `size`, at `index` within the Pagintor's text containers array. Returns the the text container for convenience.
@discardableResult
private func ensureContainer(with size: CGSize,
at index: Int) -> NSTextContainer {
precondition(index >= 0 && index <= layoutManager.textContainers.count)
if index == layoutManager.textContainers.count {
let container = NSTextContainer(size: size)
container.widthTracksTextView = false
container.heightTracksTextView = false
container.lineFragmentPadding = 0.0
layoutManager.addTextContainer(container)
return container
} else {
let container = layoutManager.textContainers[index]
if container.size != size {
container.size = size
}
return container
}
}
/// Creates initial text containers from the hard-coded preview data. PreviewTestData.textViewDataArray is of type [(page: Int, frame: CGRect)]. The NSViewRepresentable's Coordinator uses this same data to create flipped NSTextViews.
func setupLayout() {
var index = 0
for textViewData in PreviewTestData.textViewDataArray {
self.ensureContainer(with: textViewData.frame.size,
at: index)
index += 1
}
}
}
/// A lightweight struct that carries info we use in order to build our containers/views.
struct PlacementInfo: Equatable {
let page: Int
let minY: Double
let segmentNumber: Int
}
/// A lightweight struct used to determine whether (and how) text fits in a given text container.
struct ContainerFitResult {
/// If non-nil, the location/index of the first character of a body of text that didn't fit into a particular text container. If this value is nil, then the container did not overflow and may need to be sized down (by checking the value of `heightUsed`).
let overflowLocation: Int?
/// If non-nil, gives the maximum y position of the last line fragment for the container fit test result. If this value is nil, then the entire container was used and there was overflow.
let usedHeight: Double
}
Preview Data in part includes the below, which is also used by my NSViewRepresentable
's Coordinator
to build the NSTextViews
and connect them to the layout manager's containers. There is only one shared layout manager.
static let textViewDataArray: [(page: Int, frame: CGRect)] = [
(page: 1, frame: CGRect(x: 0, y: 72, width: 612, height: 648)),
(page: 1, frame: CGRect(x: 0, y: 700, width: 324, height: 20)),
(page: 2, frame: CGRect(x: 0, y: 72, width: 324, height: 28)),
(page: 1, frame: CGRect(x: 324, y: 700, width: 288, height: 20)),
(page: 2, frame: CGRect(x: 324, y: 72, width: 288, height: 42)),
(page: 2, frame: CGRect(x: 0, y: 114, width: 612, height: 14))