Search code examples
iosswiftuikittextkit

Using TextKit 2 to display tables in UITextView


UITextView does not support any kind of tables, but I'm trying to create a text view implementation which would be able to show tables alongside normal text.

Basically: My macOS port uses NSTextTable to render some paragraphs next to each other when creating a PDF. I'm trying to come up with a way to achieve this on iOS.

Documentation and real-world implementations of TextKit 2 are still a bit hazy, but I'm interested if it would be possible to create my own content types using NSTextElement and NSTextElementProvider. If I'm reading it correctly, I'd define a range which is governed by my own implementation/layout?

As the documentation is so sparse, I'm very unsure how to actually implement an NSTextElement which would also be rendered as text when saved as a PDF — or if any of this would even be possible.

Any help or hints would be appreciated.


Solution

  • There is no real way around the actual problem, but you can create a view provider which sort of acts like a table.

    This example just supports one row, so it's basically just a column view, but you should be able to expand it pretty easily. Sizing the cells will be the trickiest part if you want to have a two-dimensional table.

    The actual text attachment, which registers a view provider to display the fake table:

    @objc public class TableAttachment:NSTextAttachment {
        // We need to forward these values to the provider when needed
        var cells:[TextTableCell]?
        var spacing = 0.0
        var margin = 0.0
        
        @objc public init(cells:[TextTableCell], spacing:CGFloat = 0.0, margin:CGFloat = 0.0) {
            NSTextAttachment.registerViewProviderClass(TextTableProvider.self, forFileType: "public.data")
            
            self.cells = cells
            self.spacing = spacing
            self.margin = margin
            
            super.init(data: nil, ofType: "public.data")
        }
        
        required init?(coder: NSCoder) {
            super.init(coder: coder)
        }
    }
    

    A label which acts as a table cell:

    @objc public class TextTableCell:UILabel {
        var width = 0.0
        
        @objc public init(content:NSAttributedString, width: Double, height:Double = 0.0) {
            self.width = width
    
            let rect = CGRect(x: 0.0, y: 0.0, width: width, height: height)
            super.init(frame: rect)
                    
            self.attributedText = content
            
            self.lineBreakMode = .byWordWrapping
            self.numberOfLines = 50 // Arbitrary value
    
            // Make the text field behave correctly in a PDF        
            self.layer.shouldRasterize = false
        }
    
        var height:CGFloat {
            guard var attributedText = self.attributedText  else { return 0.0 }        
            // Calculate frame size
            let rect = attributedText.boundingRect(with: CGSize(width: self.frame.width, height: CGFloat.greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
            
            self.frame.size.height = rect.size.height
            self.frame.origin.y = 0
            
            return self.frame.height
        }
    }
    

    And finally, the text attachment provider, which takes in a bunch of table cells and displays them.

    @objc public class TextTableProvider : NSTextAttachmentViewProvider {
        var cells:[TextTableCell] = []
        var spacing = 0.0
        var margin = 0.0
        
        override init(textAttachment: NSTextAttachment, parentView: UIView?, textLayoutManager: NSTextLayoutManager?, location: NSTextLocation) {
            
            if let attachment = textAttachment as? TableAttachment {
                self.cells = attachment.cells ?? []
                self.spacing = attachment.spacing
                self.margin = attachment.margin
            }
            
            super.init(textAttachment: textAttachment, parentView: parentView, textLayoutManager: textLayoutManager, location: location)
            tracksTextAttachmentViewBounds = true
        }
        
        @objc public override func loadView() {
            super.loadView()
            
            view = UIView()
    
            // Add subviews
            var x = self.margin
            for column in self.cells {
                column.frame.origin.x = x
                view?.addSubview(column)
                
                x = spacing + CGRectGetMaxX(column.frame)
            }
        }
    
        /// Returns height of the tallest column in this view
        @objc public override func attachmentBounds(
            for attributes: [NSAttributedString.Key : Any],
            location: NSTextLocation,
            textContainer: NSTextContainer?,
            proposedLineFragment: CGRect,
            position: CGPoint
        ) -> CGRect {
            var height = 0.0
    
            for column in self.cells {
                let colHeight = column.height 
                if colHeight > height { height = colHeight }
            }
    
            // I have no idea what this - 15.0 is, something for my own purposes, probably
            return CGRect(x: 0, y: 0, width: proposedLineFragment.width - 15.0, height: height)
        }
    }