Multiple lines of text in a scene

Hello, I need to display a paragraph of text in a specific location in a scene when an event is triggered. After looking around it didn't appear using a SKLabelNode supported this. I read it was bad to add a UITextView into my scene. Is there a more appropriate route I should take? Thank you.

Replies

I wasn't able to find anything native in SpriteKit, either. I did find code written by Craig Grummitt that works really well. Hopefully it will work for you, too.

Finally, i found it


let label = self.childNode(withName: "helloLabel") as? SKLabelNode

label.text = "Lorem ~ jaawdawdsawdo8 eyq29p 8rhjp9q8"

label.lineBreakMode = NSLineBreakMode.byWordWrapping

label.numberOfLines = 0

label.preferredMaxLayoutWidth = 500

Yes, there's that (finally). But keep in mind that this API was added in macOS 10.13 and iOS 11. At least for a year or two, an alternate method of doing this on older systems is going to be needed still.

Here's a function for using SKLabelNodes, written in Swift, that will split a long string into two labels....


func splitTextIntoFields(theText:String, firstLabel: SKLabelNode, secondLabel: SKLabelNode) {
      
      
        if ( theText != "") {
          
            let maxInOneLine:Int = 60
            var i:Int = 0
          
            var line1:String = ""
            var line2:String = ""
          
            var useLine2:Bool = false
          
            for letter in theText.characters {
              
                if ( i > maxInOneLine && String(letter)  == " " ){
                  
                    useLine2 = true
                }
              
                if (useLine2 == false){
                  
                    line1 = line1 + String(letter)
                  
                } else {
                  
                    line2 = line2 + String(letter)
                }
              
              
              
                i += 1
              
            }
          
          
          
            firstLabel.text = line1
            secondLabel.text = line2
          
          
        }
      
      
    }



Change maxInOneLine = 60 to whatever works best for you. 60 is max number of characters in the first label.

I used to make a multiline label for SpriteKit by converting a multiline UILabel to a SKSpriteNode. Here is an example using two classes. The first, LabelWithInsets, derives from UILabel and is used to create a multiline UILabel with insets set at 10 on all sides. Not really required, but it is nice to have insets.

class LabelWithInsets: UILabel {

    let insets = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)

    override init(frame: CGRect) {
        super.init(frame: frame)
    
       // make multiline
        numberOfLines = 0
          
        // reduce font size as required to fit
        self.adjustsFontSizeToFitWidth = true
    
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError()
    }

    override func drawText(in rect: CGRect) {
        super.drawText(in: UIEdgeInsetsInsetRect(rect, insets))
    }
}


The other class, MultilineSpritetextNode, is a sprite node initialized with a UILabel (or use LabelWithInsets). It takes the label view layer, renders it in a graphics context, and extract the image to create a texture that is used to initialize the sprite node. After that, the UILabel is no longer required.

class MultilineSpriteTextNode: SKSpriteNode {

    init(withLabel label: UILabel) {
     
        let image = MultilineSpriteTextNode.imageWithView(inView: label)
        let texture: SKTexture = SKTexture(cgImage: (image?.cgImage)!)
        super.init(texture: texture, color: UIColor.clear, size: texture.size())
        position = CGPoint.zero
     
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    class func imageWithView(inView: UIView) -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(inView.bounds.size, false, 1.0)
        if let context = UIGraphicsGetCurrentContext() {
            inView.layer.render(in: context)
            let image = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            return image
        }
        return nil
    }

}


Now we are ready to produce multiline label sprites in any given rect:

let myLabel = LabelWithInsets(frame: someRect)
myLabel.text = "My long text"
myLabel.font = UIFont(name:"NiceFont", size:50.0)
// set other label properties, such as text color and background color)

let myTextSprite = MultilineSpriteTextNode(withLabel: myLabel)

Requiring more lines of readout text, at first I expanded on the existing logic with arbitrary restrictions of 20 chars per line, purging as necessary to give the illusion that the text was wrapping around each line. However, this sliced words up without a thought for human readability:


if (i > 20) {
                   
                    useLine2 = true
                   
                }
               
                if (useLine2 == false) {
                    line1 = line1 + String(letter)
                   
                }
                if (useLine2 == true && (i > 20 && i <= 40)) {
                    line2 = line2 + String(letter)
                    useLine3 = true
                   
                }
                if (useLine3 == true && (i > 40 && i <= 60)) {
                   
                    line3 = line3 + String(letter)
                    useLine4 = true
                
                }


After this I tried using the Split function, Map’s various functions like flatten and sort etc… none of which worked for me. In the end, taking cue from Maxim Shoustin’s work to parse each of the words in the plist into an array with each word as an element allowed me to still use the 20 char restriction to check the length of each element before committing it in a line of text, but not if it would need to be cut to fit i.e. no broken words:


extension String {
   
    func split(regex pattern: String) -> [String] {
       
        guard let re = try? NSRegularExpression(pattern: pattern, options: [])
            else { return [] }
       
        let nsString = self as NSString // needed for range compatibility
        let stop = ""
        let modifiedString = re.stringByReplacingMatches(
            in: self,
            options: [],
            range: NSRange(location: 0, length: nsString.length),
            withTemplate: stop)
        return modifiedString.components(separatedBy: stop)
    }
}




I had one of these for each SKLabel i.e. I needed 5 lines of text so I had 5 SKLabelNode declarations for each one:

var inDepthDescriptionLabel1: SKLabelNode = SKLabelNode()


Simply change the string variables to arrays of type string:

var line1: String = “”
var line1: [String] = []



Use the string extension code above to place each of the words in your plist entry into an array as elements e.g. [“an” “array” “as” “an” “element”], so “an” is counted as 2 chars and “element” is 7 chars:

let textToInterrogate: [String] = inDepthDescriptionOutput.split(regex: "[ ]+")




Here the initial loop will look over the first 20 chars from the array (plist text) for the first line of text of outputted text, which is easy enough, but then then the second will look for 40 chars and chop of the first 20, giving you the second 20:



“itemInArray” takes one element (word) from the textToInterrogate (plist) array (e.g. “an” or “element”) and using the Joined method it will stitch the words back together with empty spaces to give the illusion that it is just plain text, but only those that fit within the 20 char limit (forget not that arrays are an ordered type, so the words will read back the in order that they were written in your plist!). Those first 20 chars are appended to the relevant line array, but the description property makes them look like a string value so it will read nicely when you send it to the SKLabel:


            for itemInArray in textToInterrogate {
               
                if (line1.joined(separator: " ").count < 20) {
                   
                    line1.append(itemInArray.description)
                }
            }
           
           

            for itemInArray in textToInterrogate {
               
                if (line2.joined(separator: " ").count < 40) {
                   
                    line2.append(itemInArray.description)
                }
            }
            line2.removeFirst(line1.endIndex)


The second line of text to find (above) is a little more tricky as it will take the first 20 chars from line1 and deduct them from the beginning of line2 (this must be done outside of the loop) to give the impression than the next 20 words are being displayed, and not the first 40 form the array (you already have the first 20 on the first line of course)



            for itemInArray in textToInterrogate {
               
                if (line3.joined(separator: " ").count < 60) {
                   
                    line3.append(itemInArray.description)
                   
                }
            }
            line3.removeFirst(line1.endIndex)
            line3.removeFirst(line2.endIndex)


line3 is as before, only you are on the third batch of 20 chars (i.e. 60) and now need to deduct the first 20 chars and second 20 chars (i.e. 40 chars) from the first two lines of text to get to the end of line3’s 20 chars that you need form the array. Just keep repeating this until you have covered all the lines of text that you need.


Now to display the text in the SKLabels:


                if let inDepthDescriptionField: SKLabelNode = self.camera?.childNode(withName: "InDepthDescription1") as? SKLabelNode {
                   
                    if (inDepthDescriptionLabel1.text != nil) {
                       
                        inDepthDescriptionLabel1.text = line1.joined(separator: " ")
                   
                 }


The above code (depending on how you decide to send data to your SKLabels) will check if there is an SKLabel present and if it is a SKLabel node, then finally if the text in the field is empty. If all tests pass then the 20 char text your sorted for line1 is sent from it’s array as text to be displayed without the quotation marks, but with a blank space between each of the elements (or words) so it reads like normal text to a human.



It does work, you can use it as a fallback option for the iOS11 introduction of multiline text.