NSTextView with automatic fitting to content size

108 views Asked by At

Main problem:

SwiftUI's Text() have no ability to fill large amounts of text inside. In case of text have too many symbols or in case of too large font it's displaying the following error:

[Window] Warning: Window SwiftUI.AppKitWindow 0x13be8e970 ordered front from a non-active application and may order beneath the active application's windows. 2023-08-19 22:48:49.743611+0300 Videq[83718:3987311] -[<_TtCOCV7SwiftUI11DisplayList11ViewUpdater8PlatformP33_65A81BD07F0108B0485D2E15DE104A7514CGDrawingLayer: 0x6000031d8e40> display]: Ignoring bogus layer size (479.000000, 323046.000000), contentsScale 2.000000, backing store size (958.000000, 646092.000000) 2023-08-19 22:48:49.791437+0300 Videq[83718:3987311] Metal API Validation Enabled

So I need to write custom Text() using NSTextView

myCode is:

import SwiftUI
import Cocoa

@available(OSX 11.0, *)
public struct AttributedText2: View {
    @Binding var text: AttributedString
    
    var nsText: Binding<NSAttributedString> {
        Binding(get: { NSAttributedString(text) }, set: { _ in })
    }
    
    @State var width: CGFloat = 0
    
    public var body: some View {
        AttributedTextInternal(attributedString: nsText)
    }
}

//@available(OSX 11.0, *)
//public struct AttributedText: View {
//    @Binding var text: NSAttributedString
//
//    public init(attributedString: Binding<NSAttributedString>) {
//        _text = attributedString
//    }
//
//    public var body: some View {
//        AttributedTextInternal(attributedString: $text)
//    }
//}

@available(OSX 11.0, *)
public struct AttributedTextInternal: NSViewRepresentable {
    @Binding var text: NSAttributedString
    
    public init(attributedString: Binding<NSAttributedString>) {
        _text = attributedString
    }
    
    public func makeNSView(context: Context) -> CustTextFld {
        let textView = CustTextFld()
        
        textView.setContent(text: text, makeNotEditable: true)
        
        textView.backgroundColor = .clear
        
        textView.layerContentsPlacement = .top
        textView.layerContentsRedrawPolicy = .crossfade
        
        return textView
    }
    
    public func updateNSView(_ textView: CustTextFld, context: Context) {
        textView.setContent(text: text, makeNotEditable: true)
    }
}

public class CustTextFld: NSTextView {
    func setContent(text: NSAttributedString, makeNotEditable: Bool) {
        self.isEditable = true
        
        self.selectAll(nil)
        self.insertText(text, replacementRange: self.selectedRange())
        
        self.isEditable = !makeNotEditable
    }
    
    // remove cursor
    public override func mouseMoved(with event: NSEvent) {
    }
    
    public override func viewWillDraw() {
        isHorizontallyResizable = true
        isVerticallyResizable = true
        isRichText = true
        isSelectable = false
        
//        isRichText = false
    }
    
    public override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        
        let path = NSBezierPath(rect: bounds)
        
        NSColor.white.setStroke()
        
        path.stroke()
    }
}

extension NSAttributedString {
    func height(containerWidth: CGFloat) -> CGFloat {
        let rect = self.boundingRect(with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
                                     options: [.usesLineFragmentOrigin, .usesFontLeading],
                                     context: nil)
        return ceil(rect.size.height)
    }

    func width(containerHeight: CGFloat) -> CGFloat {

        let rect = self.boundingRect(with: CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: containerHeight),
                                     options: [.usesLineFragmentOrigin, .usesFontLeading],
                                     context: nil)
        return ceil(rect.size.width)
    }
}

issue here that I'm to able to force size of AttributedText2 for being sure that it's width and height will be correct:

  • width must be the same as parent's view content
  • height must be calculated from width using NSAttributedString.height(containerWidth: )

I had tried to get content size but didn't have any results. Also I have tried to do textView.sizeToFit() - also no results.

Tried to use Geometry reader to get view width - but it's breaking the view and view still have incorrect size.

So code:

GeometryReader { geometry in
    AttributedTextInternal(attributedString: nsText)
        .frame(width: geometry.size.width, height: nsText.wrappedValue.height(containerWidth: geometry.size.width))
}

doesn't work on large texts. Try to set text with 20 000 words and to locate AttributedText2() inside of ScrollView()

Is anyone have some ideas?

2

There are 2 answers

4
Ben A. On

Attempt #2

This is super simple but it seems from your comment this might be what you are looking for: Text() might actually work.

struct ContentView: View {
    let height = 300.0
    let words: String = (1...10000).map { "Word\($0)" //The 10,000 you mentioned
    }.joined(separator: " ")

    var body: some View {
        ScrollView {
            Text(words)
                .font(.body) // Customize the font if you want to do that
                .padding()
        }.frame(height: height)
    }
}

let height is how tall you want the text area to be.

This gif shows it working.

Ignore below!

If I understand you correctly, then I think this solves it. All you have to do is replace the AttributedText2 struct with the following:

public struct AttributedText2: View {
    @Binding var text: AttributedString
    
    var nsText: Binding<NSAttributedString> {
        Binding(get: { NSAttributedString(text) }, set: { _ in })
    }
    
    public var body: some View {
        GeometryReader { geometry in
            AttributedTextInternal(attributedString: nsText)
                .frame(width: geometry.size.width, height: nsText.wrappedValue.height(containerWidth: geometry.size.width))
        }
    }
}

Notice that it uses GeometryReader for the size data. When integrated, it simply becomes:

import Foundation
import SwiftUI
import Cocoa

@available(OSX 11.0, *)
public struct AttributedText2: View {
    @Binding var text: AttributedString
    
    var nsText: Binding<NSAttributedString> {
        Binding(get: { NSAttributedString(text) }, set: { _ in })
    }
    
    public var body: some View {
        GeometryReader { geometry in
            AttributedTextInternal(attributedString: nsText)
                .frame(width: geometry.size.width, height: nsText.wrappedValue.height(containerWidth: geometry.size.width))
        }
    }
}


@available(OSX 11.0, *)
public struct AttributedTextInternal: NSViewRepresentable {
    @Binding var text: NSAttributedString
    
    public init(attributedString: Binding<NSAttributedString>) {
        _text = attributedString
    }
    
    public func makeNSView(context: Context) -> CustTextFld {
        let textView = CustTextFld()
        
        textView.setContent(text: text, makeNotEditable: true)
        
        textView.backgroundColor = .clear
        
        textView.layerContentsPlacement = .top
        textView.layerContentsRedrawPolicy = .crossfade
        
        return textView
    }
    
    public func updateNSView(_ textView: CustTextFld, context: Context) {
        textView.setContent(text: text, makeNotEditable: true)
    }
}

public class CustTextFld: NSTextView {
    func setContent(text: NSAttributedString, makeNotEditable: Bool) {
        self.isEditable = true
        
        self.selectAll(nil)
        self.insertText(text, replacementRange: self.selectedRange())
        
        self.isEditable = !makeNotEditable
    }
    
    // remove cursor
    public override func mouseMoved(with event: NSEvent) {
    }
    
    public override func viewWillDraw() {
        isHorizontallyResizable = true
        isVerticallyResizable = true
        isRichText = true
        isSelectable = false
        
//        isRichText = false
    }
    
    public override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        
        let path = NSBezierPath(rect: bounds)
        
        NSColor.white.setStroke()
        
        path.stroke()
    }
}

extension NSAttributedString {
    func height(containerWidth: CGFloat) -> CGFloat {
        let rect = self.boundingRect(with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
                                     options: [.usesLineFragmentOrigin, .usesFontLeading],
                                     context: nil)
        return ceil(rect.size.height)
    }

    func width(containerHeight: CGFloat) -> CGFloat {

        let rect = self.boundingRect(with: CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: containerHeight),
                                     options: [.usesLineFragmentOrigin, .usesFontLeading],
                                     context: nil)
        return ceil(rect.size.width)
    }
}

What is looks like:

enter image description here enter image description here

0
Andrew_STOP_RU_WAR_IN_UA On

Resolved limitation of Text() with the following code:

struct ParagraphedText: View {
    @Binding var text: String
    
    var body: some View {
        VStack {
            let paragraphs = text.split(separator: "\n")
            
            ForEach(paragraphs, id:\.self) {
                Text($0)
            }
        }
    }
}

struct ParagraphedText2: View {
    @Binding var paragraphs: [AttributedString]
    
    var body: some View {
        VStack {
            ForEach(paragraphs, id:\.self) {
                Text($0)
            }
        }
    }
}

So error will be throwned only in case of no paragraphs in the text. It's OK for me.