NSLayoutManager: как заливать фоновые цвета там, где есть только визуализируемые глифы

Диспетчер компоновки по умолчанию заполняет цвет фона (указанный через атрибут NSAttributedString .backgroundColor) там, где нет текста (кроме последней строки).

введите описание изображения здесь

Мне удалось добиться желаемого эффекта, создав подкласс NSLayoutManager и переопределив func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) следующим образом:

override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
    guard let textContainer = textContainers.first, let textStorage = textStorage else { fatalError() }

    // This just takes the color of the first character assuming the entire container has the same background color.
    // To support ranges of different colours, you'll need to draw each glyph separately, querying the attributed string for the
    // background color attribute for the range of each character.
    guard textStorage.length > 0, let backgroundColor = textStorage.attribute(.backgroundColor, at: 0, effectiveRange: nil) as? UIColor else { return }

    var lineRects = [CGRect]()

    // create an array of line rects to be drawn.
    enumerateLineFragments(forGlyphRange: glyphsToShow) { (_, usedRect, _, range, _) in
        var usedRect = usedRect
        let locationOfLastGlyphInLine = NSMaxRange(range)-1
        // Remove the space at the end of each line (except last).
        if self.isThereAWhitespace(at: locationOfLastGlyphInLine) {
            let lastGlyphInLineWidth = self.boundingRect(forGlyphRange: NSRange(location: locationOfLastGlyphInLine, length: 1), in: textContainer).width
            usedRect.size.width -= lastGlyphInLineWidth
        }
        lineRects.append(usedRect)
    }

    lineRects = adjustRectsToContainerHeight(rects: lineRects, containerHeight: textContainer.size.height)

    for (lineNumber, lineRect) in lineRects.enumerated() {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        context.saveGState()
        context.setFillColor(backgroundColor.cgColor)
        context.fill(lineRect)
        context.restoreGState()
    }
}

private func isThereAWhitespace(at location: Int) -> Bool {
    return propertyForGlyph(at: location) == NSLayoutManager.GlyphProperty.elastic
}

введите описание изображения здесь

Однако при этом не учитывается возможность указания нескольких цветов диапазоном в атрибутивной строке. Как я могу этого добиться? Я посмотрел на fillBackgroundRectArray без особого успеха.


person Aodh    schedule 12.11.2018    source источник
comment
Взгляните на это: github.com/kimikaza/TagFieldDemo blob / master / Таким образом, у вас могут быть разные ключи атрибутов для каждого цвета.   -  person koen    schedule 31.12.2018
comment
Коэн, спасибо, это не совсем подходит для нескольких строк, однако   -  person Aodh    schedule 04.01.2019
comment
Да - в конце концов, я отказался от использования атрибутов, а сам отслеживал выбранные диапазоны и раскрашивал их соответствующим образом. Но у меня нет тех пробелов, которые вы показываете (после слова когда), поэтому в вашем случае это может не сработать.   -  person koen    schedule 04.01.2019


Ответы (2)


Как я могу этого добиться?

Вот как я достиг вашей цели - выделить термин "сидеть" разными цветами внутри знаменитого Lorem ipsum..., который достаточно велик, чтобы протестировать его на нескольких строках.

Все основы, поддерживающие следующий код (Swift 5.1, iOS 13), представлены в этом ответе и не будут скопированы здесь для ясности ⟹ они привели к результату 1 в дальнейшем.

введите описание изображения здесь

В вашем случае вы хотите выделить некоторые определенные части строки, что означает, что эти элементы должны иметь выделенные ключевые атрибуты из-за их содержимого ⟹, на мой взгляд, это дело textStorage.

MyTextStorage.swift

// Sent when a modification appears via the 'replaceCharacters' method.
    override func processEditing() {

        var regEx: NSRegularExpression

        do {
            regEx = try NSRegularExpression.init(pattern: " sit ", options: .caseInsensitive)
            let stringLength = backingStorage.string.distance(from: backingStorage.string.startIndex,
                                                              to: backingStorage.string.endIndex)
            regEx.enumerateMatches(in: backingStorage.string,
                                   options: .reportCompletion,
                                   range: NSRange(location: 1, length: stringLength-1)) { (result, flags, stop) in

                                guard let result = result else { return }
                                self.setAttributes([NSAttributedString.Key.foregroundColor : UIColor.black, //To be seen above every colors.
                                                    NSAttributedString.Key.backgroundColor : UIColor.random()],
                                                   range: result.range)
            }
        } catch let error as NSError {
            print(error.description)
        }

        super.processEditing()
    }
}

//A random color is provided for each " sit " element to highlight the possible different colors in a string.
extension UIColor {
    static func random () -> UIColor {
        return UIColor(red: CGFloat.random(in: 0...1),
                       green: CGFloat.random(in: 0...1),
                       blue: CGFloat.random(in: 0...1),
                       alpha: 1.0)
    }
}

Если вы построите и запустите отсюда, вы получите результат 2, который показывает проблему с каждым цветным фоном "сидеть", найденным в тексте ⟹ есть смещение между lineFragment и цветные прямоугольники фона. ????

Я пошел и посмотрел метод fillBackgroundRectArray, о котором вы упомянули и о котором Apple заявляет, что он 'заполняет прямоугольники фона цветом', а '- это примитивный метод, используемый drawBackground': кажется чтобы быть идеальным здесь, чтобы исправить проблему макета. ????

MyLayoutManager.swift

override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>,
                                      count rectCount: Int,
                                      forCharacterRange charRange: NSRange,
                                      color: UIColor) {

    self.enumerateLineFragments(forGlyphRange: charRange) { (rect, usedRect, textContainer, glyphRange, stop) in

        var newRect = rectArray[0]
        newRect.origin.y = usedRect.origin.y + (usedRect.size.height / 4.0)
        newRect.size.height = usedRect.size.height / 2.0

        let currentContext = UIGraphicsGetCurrentContext()
        currentContext?.saveGState()
        currentContext?.setFillColor(color.cgColor)
        currentContext?.fill(newRect)

        currentContext?.restoreGState()
    }
}

Чтобы получить универсальную формулу, необходимо тщательно изучить настройки параметров, но в данном примере она работает нормально, как есть.

Наконец, мы получаем результат 3, который дает возможность иметь несколько цветов, заданных диапазоном в атрибутированной строке, после адаптации условий регулярного выражения. ????????????

person XLE_22    schedule 09.12.2019

В качестве альтернативы вы можете вообще обойтись без атрибутов, например:

Итак, сначала я определил эту структуру:

struct HighlightBackground {
    let range: NSRange
    let color: NSColor
}

Затем в моем подклассе NSTextView:

var highlightBackgrounds = [HighlightBackground]()

override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting stillSelectingFlag: Bool) {
    if stillSelectingFlag == false {
        return
    }

 // remove old ranges first
    highlightBackgrounds = highlightBackgrounds.filter { $0.color != .green }

    for value in ranges {
        let range = value.rangeValue

        highlightBackgrounds.append(HighlightBackground(range: range, color: .green))
    }

    super.setSelectedRanges(ranges, affinity: affinity, stillSelecting: stillSelectingFlag)
}

А затем вызовите это из своего draw(_ rect: NSRect) метода:

func showBackgrounds() {
    guard
        let context = NSGraphicsContext.current?.cgContext,
        let lm = self.layoutManager
    else { return }

    context.saveGState()
    //        context.translateBy(x: origin.x, y: origin.y)

    for bg in highlightBackgrounds {
        bg.color.setFill()

        let glRange = lm.glyphRange(forCharacterRange: bg.range, actualCharacterRange: nil)    
        for rect in lm.rectsForGlyphRange(glRange) {    
            let path = NSBezierPath(roundedRect: rect, xRadius: selectedTextCornerRadius, yRadius: selectedTextCornerRadius)
            path.fill()
        }
    }

    context.restoreGState()
}

Наконец, вам понадобится это в вашем подклассе NSLayoutManager, хотя вы, вероятно, также можете поместить его в подкласс NSTextView:

func rectsForGlyphRange(_ glyphsToShow: NSRange) -> [NSRect] {

    var rects = [NSRect]()
    guard
        let tc = textContainer(forGlyphAt: glyphsToShow.location, effectiveRange: nil)
    else { return rects }

    enumerateLineFragments(forGlyphRange: glyphsToShow) { _, _, _, effectiveRange, _ in
        let rect = self.boundingRect(forGlyphRange: NSIntersectionRange(glyphsToShow, effectiveRange), in: tc)
        rects.append(rect)
    }

    return rects
}

Надеюсь, это сработает и в вашем случае.

person koen    schedule 04.01.2019