swiftnstextviewnsscrollviewnslayoutmanagernstextstorage

Scrollable NSTextView with custom NSTextStorage for formatting


I'm trying to make a text editor with formatting for Mac OS. Which I have working using an NSTextView together with a custom NSTextStorage class. Which applies attributes like bold etc to NSAttributableStrings.

This all seems to work fine as seen in screenshot one below. Which is an NSTextView with a custom NSTextStorage class attached to it. Which applies the formatting through attributes on an NSAttributeableString

NSTextView without scrolling and custom TextStorage for formatting

However, having everything the same, but getting a scrollable NSTextView from the Apple supplied function NSTextView.scrollableTextView() it does not display any text at all. Even though you can see in the screenshot that the NStextView is actually visible. Also, moving my mouse over the editor changes the cursor to the editor cursor. But I can't select, type or do anything.

Scrollable text view with customer NSTextStorage, shows nothing

Doing the exact same thing as above, but not supplying a text container to the text view does show that it is wired up correctly, since I do get a scrollable text view then. Where the scrolling actually works, but then of course the formatting is no longer applied.

NSTextView where scrolling works, but formatting is not applied

So I'm confused on what I have to do now.

This is basically my setup:

//
//  TextDocumentViewController.swift
//
//  Created by Matthijn on 15/02/2022.
//  Based on LayoutWithTextKit2 Sample from Apple

import Foundation
import AppKit

class TextDocumentViewController: NSViewController {
    
    // Extends NSTextStorage, applies attributes to NSAttributeAbleString for formatting
    private var textStorage: TextStorage

    // Not custom yet, default implementation - do I need to subclass this specifically and implement something to support the scrolling behaviour? Which seems weird to me since it does work without scrolling support 
    private var layoutManager: NSLayoutManager
    // Also default implementation
    private var textContainer: NSTextContainer
    
    private var textDocumentView: NSTextView
    private var scrollView: NSScrollView

    required init(content: String) {
        textStorage = TextStorage(editorAttributes: MarkdownAttributes)


        layoutManager = NSLayoutManager()
        textStorage.addLayoutManager(layoutManager)
        
        
        textContainer = NSTextContainer()
        // I'm not 100% sure if I need this on false or true or can just do defaults. No combination fixes it
        // textContainer.heightTracksTextView = false
        // textContainer.widthTracksTextView = true
        
        layoutManager.addTextContainer(textContainer)
        
        scrollView = NSTextView.scrollableTextView()
        textDocumentView = (scrollView.documentView as! NSTextView)
    
        // Basically commenting this out, stops applying my NSTextContainer, NSLayoutManager and NSTextContainer, but then of course the formatting is not applied. This one line changes it between it works without formatting, or it doesn't work at all. (Unless I have my text view not embedded in a scroll view) then it works but the scrolling of course then does not work.       
        textDocumentView.textContainer = textContainer
    
        textDocumentView.string = content
        textDocumentView.isVerticallyResizable = true
        textDocumentView.isHorizontallyResizable = false
        
        super.init(nibName: nil, bundle: nil)
    }
    
    override func loadView() {
        view = scrollView
    }
}

Solution

  • You can actually create the NSScrollView instance with scrollableTextView() and you can get the implicitly created documentView (NSTextView).

    Finally, one can assign the existing LayoutManager of the documentView to the own TextStorage class inheriting from NSTextStorage.

    In viewDidLoad you could then add the scrollView traditionally to the view using addSubview:. For AutoLayout, as always, translatesAutoresizingMaskIntoConstraints must be set to false.

    With widthAnchor and a greaterThanOrEqualToConstant you can define a minimum size, so the window around it cannot be made smaller by the user. The structure also allows potential later simple extensions with additional sticky views (e.g. breadcrumb view etc).

    Code

    If you implement it this way, then for a small minimal test it might look something like this.

    import Cocoa
    
    
    class ViewController: NSViewController {
    
        private var scrollView: NSScrollView
        private var textDocumentView: NSTextView
    
        required init(content: String) {
            scrollView = NSTextView.scrollableTextView()
            let textDocumentView = scrollView.documentView as! NSTextView
            self.textDocumentView = textDocumentView
            let textStorage = TextStorage(editorAttributes: MarkdownAttributes())
            textStorage.addLayoutManager(textDocumentView.layoutManager!)
    
            textDocumentView.string = content
    
            super.init(nibName: nil, bundle: nil)
    
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(scrollView)
            NSLayoutConstraint.activate([
                scrollView.topAnchor.constraint(equalTo: view.topAnchor),
                scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
                scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
                scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
                scrollView.widthAnchor.constraint(greaterThanOrEqualToConstant: 200.0),
                scrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: 200.0),
            ])
        }
        
        override func loadView() {
          self.view = NSView()
        }
        
    }
    

    Test

    Here's a quick test showing that both display and scrolling work as expected with the setup:

    demo