Skip to main content
8 of 17
deleted 8 characters in body
ielyamani
  • 879
  • 1
  • 5
  • 18

General remarks

Here are some general remarks that could make your code look a little cleaner :

  • Avoid using ; at the end of an instruction unless you have to put multiple instructions in the same line.
  • Document the functions properly: The description of the complition parameter of splitToFitSize isn't well formatted.
  • The correct spelling of complition is completion.
  • This is subjective and probably nitpicky: Put a space only where needed, one after the closing curly brace of a scope: } else , and none before a punctuation sign: (size: size, font: font).

Catching the culprits

Dalton Cousins

Sources of slow code:

  • " \(content)": String interpolation is slow as previously stated here.
  • self.components(separatedBy: .whitespaces): This has both a time and space complexity of O(n). Better iterate over the story with a String.Index.
  • DispatchQueue.global(qos: .background) : This is the lowest priority you could give to code and it wastes time by switching from the main thread to a background one, and back.
  • contents.removeFirst(): This is called in multiple places and repeatedly. Each call is O(n) since all the elements of the contents array shifted (Have a look here). This means that this algotithm shifts the elements of the contents array n + (n-1) + (n-2) + ... + 1 times. Knowing that : $$n + (n-1) + (n-2) + ... + 1 = \frac{n(n+1)}{2}$$

it makes this algorithm O(n2), with n being the number of elements in contents. To circumvent this use an index that traverses the array.


Alternative implementation

Here an alternative solution to split a string into a given size using a certain font :

extension String {
    
    static let blanks: [Character] = [" ", "\n", "\t"] //It is also possible to use CharacterSet.whitespacesAndNewlines to check if a character is "blank", but it is a bit slower.
    
    func splitToFit (size: CGSize, using font: UIFont) -> [String] {
        var output: [String] = []
        
        var (fitted, remaining) = fit(self, in: size, using: font)
        
        output.append(fitted)
        
        while remaining != "" {
            (fitted, remaining) = fit(remaining, in: size, using: font)
            output.append(fitted)
        }
        
        //Trim white spaces if needed
        //return output.map { $0.trimmingCharacters(in: .whitespacesAndNewlines)}
        return output
    }
    
    private func fit(
        _ str: String,
        in size: CGSize,
        using font: UIFont) -> (String, String) {
        
        if !str.isTruncated(in: size, using: font) {
            return (str, "")
        }
        
        var low = 0
        var high = str.count - 1
        
        var substr = ""
        
        while low < high - 1 {
            let mid = (low + high)/2
            
            substr = str[0..<mid]
            
            let substrTruncated = substr.isTruncated(in: size, using: font)
            
            if substrTruncated {
                high = mid
            } else  {
                low = mid
            }
        }
        
        var index = str.index(str.startIndex, offsetBy: low)
        
        while index > str.startIndex,
            !String.blanks.contains(str[index]) {
                index = str.index(before: index)
                substr.removeLast()
                low -= 1
        }
        
        let remains = str[low..<str.count]
        return (substr, remains)
    }
}

It calls these other extensions :

extension String {
    func isTruncated(in size: CGSize, using font: UIFont) -> Bool {
        let textSize = (self as NSString)
            .boundingRect(with: CGSize(width: size.width,
                                       height: .greatestFiniteMagnitude),
                          options: .usesLineFragmentOrigin,
                          attributes: [.font: font],
                          context: nil).size
        
        return ceil(textSize.height) > size.height
    }
    
    subscript (range: Range<Int>) -> String {
        let startIndex = self.index(self.startIndex, offsetBy: range.lowerBound)
        let endIndex = self.index(self.startIndex, offsetBy: range.upperBound)
        let range = startIndex..<endIndex
        
        return String(self[range])
    }
}

This code is inspired by this library but twice as fast 🚀. Further improvements are possible.

It was tested using the following code:

class ViewController: UIViewController, UIScrollViewDelegate {
    
    var scrollView: UIScrollView! {
        didSet{
            scrollView.delegate = self
        }
    }
    
    private let font = UIFont.systemFont(ofSize: 17)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let width = view.frame.width
        let height = view.frame.height
        let labelSize = CGSize(width: width - 40.0, height: height - 60.0)
        //Split the story here
        //let start = Date()
        let strings = story.splitToFit(size: labelSize, using: font)
        //let end = Date()
        //print("time =", end.timeIntervalSince(start))

        let scrollViewFrame = CGRect(x: 0,
                                     y: 0,
                                     width: width,
                                     height: height)
        
        scrollView = UIScrollView(frame: scrollViewFrame)
        scrollView.contentSize = CGSize(width: width * CGFloat(strings.count), height: height)
        scrollView.isPagingEnabled = true
        
        let colors: [UIColor] = [.red, .green, .blue]
        
        for i in 0 ..< strings.count {
            let label = UILabel()
            label.numberOfLines = 0
            label.frame = CGRect(origin: CGPoint(x: width * CGFloat(i) + 20.0,
                                                 y: 40.0),
                                 size: labelSize)
            label.backgroundColor = colors[i % 3]
            label.font = font
            label.text = strings[i]
            label.translatesAutoresizingMaskIntoConstraints = false
            scrollView.addSubview(label)
        }
        
        view.addSubview(scrollView)
    }
}

with story being a 10-paragraph string, 1104 total words, generated on this website, it takes 66ms on my local machine to split the story.

ielyamani
  • 879
  • 1
  • 5
  • 18