-1

I have to split a string into an array based on how many characters can fit on each line. Each object in an array needs to be only one line of text. I am able to calculate the number of lines within the string but I can't figure out how to find out max number of characters on one line.

func lineCount(forText text: String) -> Int {
        let font = UIFont.systemFont(ofSize: 24.0)
        let width: Int = Int(self.tableView.frame.size.width)
        let rect: CGRect = text.boundingRect(with: CGSize(width: CGFloat(width), height: CGFloat(MAXFLOAT)), options: .usesLineFragmentOrigin, attributes: [NSAttributedStringKey.font: font], context: nil)
        return Int(ceil(rect.size.height / font.lineHeight))
    }
user1079052
  • 3,803
  • 4
  • 30
  • 55
  • Just curious. Wouldn't that be different based on what kind of characters you'll use? Say 10 i's width is shorter than 10 A's... iiiiiiiiii : AAAAAAAAAA – sCha Sep 20 '17 at 16:41
  • Yes. I would like to know how many characters of a specific string fit into a row. Essentially, I have to split the string into an array based on the max I can get per row. So if the first like is AAAAA... then it would be less fit then iiii. I need to be able to calculate that – user1079052 Sep 20 '17 at 16:43
  • I believe you want to split a `String` so it will fit a certain rendered length, correct? You might want to clarify that in the question. –  Sep 20 '17 at 18:20
  • I am putting each line into a tableviewcell so standard word wrapping rules would need to apply. Essentially I am going from a tablecell that fits to the height of its content to splitting the content into individual tablecell with just one line of text. The font can't be reduced so I need to split it to fit – user1079052 Sep 20 '17 at 19:25
  • Take a look at this https://stackoverflow.com/questions/28083224/find-the-number-of-characters-that-fits-in-a-uitextview-with-constant-width-and – Sam Ngugi Sep 20 '17 at 22:00

3 Answers3

4

Swift 5 Update to the answer which worked for me.

  1. Added lineBreakMode
extension String {    
    func splittingLinesThatFitIn(width: CGFloat, font: UIFont) -> [String] {
        
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineBreakMode = .byWordWrapping
        
        // set up styled text for the container
        let storage = NSTextStorage(string: self, attributes: [
            NSAttributedString.Key.font: font,
            NSAttributedString.Key.paragraphStyle: paragraphStyle
        ])
        
        // add a layout manage for the storage
        let layout = NSLayoutManager()
        storage.addLayoutManager(layout)
        
        // Set up the size of the container
        // width is what we care about, height is maximum
        let container = NSTextContainer(size: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
        
        // add the container to the layout
        layout.addTextContainer(container)
        
        var lines = [String]()
        
        // generate the layout and add each line to the array
        layout.enumerateLineFragments(forGlyphRange: NSMakeRange(0, storage.length)) { _, _, _, range, _ in
            lines.append(storage.attributedSubstring(from: range).string)
        }
        
        debugPrint(lines)
        
        return lines
    }
}
harsh_v
  • 3,193
  • 3
  • 34
  • 53
1

You can't get a set number of characters in a row with every font since some fonts have characters with variable widths. What you can do is try to break the string up into line-sized chunks:

extension String {
  func split(width: CGFloat, font: UIFont) -> [String] {
    guard !self.isEmpty else { return [String]() }

    var lines = [String]()

    // set up range of the split
    var splitStart = self.startIndex
    var splitEnd = self.startIndex

    repeat {
      // advance the end range for the split
      splitEnd = self.index(after: splitStart)

      // initial split to test
      var line = String(characters[splitStart..<splitEnd])

      // while we're before the end test the rendered width
      while splitEnd < self.endIndex &&
        line.size(attributes: [NSFontAttributeName: font]).width < width {
          // add one more character
          splitEnd = self.index(after: splitEnd)
          line = String(characters[splitStart..<splitEnd])
      }

      // add split to array and set up next split
      lines.append(line)
      splitStart = splitEnd
    } while splitEnd < self.endIndex // don't go past the end of the string


    // add remainder of string to array
    lines.append(String(characters[splitStart..<self.endIndex]))
    return lines
  }
}

This can be optimized a bit by pre-calculating the width of the entire string, dividing by the number of rows, starting with the average width for each line and then try more or less characters until it fits. However, that does make the code much more complex.

If you want to make sure that words don't get split up then you could save the position of the start of each word and when you reach the end of a line make the split end before the word, taking the word to the next split. Of course you would want to also account for words that are too long to split, hyphenating words, and so on.

Another way to do it, since you need more advanced layout, would be to use NSLayoutManager and NSTextContainer:

let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Non est ista, inquam, Piso, magna dissensio. Minime vero istorum quidem, inquit. Graecum enim hunc versum nostis omnes-: Suavis laborum est praeteritorum memoria. Negat enim summo bono afferre incrementum diem. Quasi ego id curem, quid ille aiat aut neget. Semper enim ex eo, quod maximas partes continet latissimeque funditur, tota res appellatur. Duo Reges: constructio interrete."

let font = UIFont.systemFont(ofSize: 24.0)

// set up styled text for the container
let storage = NSTextStorage(string: text, attributes: [NSFontAttributeName: font])

// add a layout manage for the storage
let layout = NSLayoutManager()
storage.addLayoutManager(layout)

// Set up the size of the container
// width is what we care about, height is maximum
let width:CGFloat = 500
let container = NSTextContainer(size: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))

// add the container to the layout
layout.addTextContainer(container)

var lines = [String]()

// generate the layout and add each line to the array
layout.enumerateLineFragments(forGlyphRange: NSMakeRange(0, storage.length)) {
  lines.append(storage.attributedSubstring(from: $0.3).string)
}

lines.forEach { print($0) }

Result:

Lorem ipsum dolor sit amet, consectetur 
adipiscing elit. Non est ista, inquam, Piso, 
magna dissensio. Minime vero istorum 
quidem, inquit. Graecum enim hunc versum 
nostis omnes-: Suavis laborum est 
praeteritorum memoria. Negat enim summo 
bono afferre incrementum diem. Quasi ego id 
curem, quid ille aiat aut neget. Semper enim 
ex eo, quod maximas partes continet 
latissimeque funditur, tota res appellatur. Duo 
Reges: constructio interrete.

You can also provide hyphenation and other behaviors through the NSLayoutManager, if you like.

  • this doesn't take into consideration taking the wold word to the next line, not splitting words up – user1079052 Sep 20 '17 at 18:50
  • 1
    @user1079052 Of course it doesn't, it's just an example. Those behaviors would have to be added. Your question didn't specify that you needed them. –  Sep 20 '17 at 18:52
  • That is my mistake for not stating that I need word wrapping rules to apply. – user1079052 Sep 20 '17 at 19:23
  • This still works. I will add an updated answer to work with swift5 – harsh_v Oct 13 '20 at 11:43
-2

i'm not sure if this is the most efficient way but I got it to work with this:

func getLinesArrayOfString(forText text: String) ->NSArray {
        let font = UIFont.systemFont(ofSize: 24.0)
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: self.tableView.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
        label.numberOfLines = 0
        label.text = text as String
        label.font = font
        label.sizeToFit()
        var linesArray: [Any] = []


        let rect: CGRect = label.frame


        let attStr = NSMutableAttributedString(string: text)
        attStr.addAttribute((NSAttributedStringKey(rawValue: kCTFontAttributeName as String)), value: font, range: NSRange(location: 0, length: attStr.length))
        let frameSetter: CTFramesetter = CTFramesetterCreateWithAttributedString(attStr)
        let path: CGMutablePath = CGMutablePath()
        path.addRect(CGRect(x: 0, y: 0, width: rect.size.width, height: 100000), transform: .identity)
        let frame: CTFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), path, nil)
        let lines = CTFrameGetLines(frame) as? [Any]

        for line: Any in lines! {
            let lineRef = line
            let lineRange: CFRange = CTLineGetStringRange(lineRef as! CTLine)
            let range = NSRange(location: lineRange.location, length: lineRange.length)
            let lineString: String = (text as NSString).substring(with: range)



            CFAttributedStringSetAttribute(attStr, lineRange, kCTKernAttributeName, font)
            CFAttributedStringSetAttribute(attStr, lineRange, kCTKernAttributeName, font)
            linesArray.append(lineString)
        }
        return linesArray as NSArray
    }
user1079052
  • 3,803
  • 4
  • 30
  • 55
  • There's a lot of unnecessary code in here and it's a pretty roundabout way to do it. If you look at the way I answered the question you'll see a more straightforward way to handle this. –  Sep 21 '17 at 00:04