Skip to main content
13 of 17
added 65 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: Set<Character> = [" ", "\n", "\t"]
    
    func splitToFit (size: CGSize, using font: UIFont) -> [String] {
        var output: [String] = []
        var (fitted, remaining) = fit(self, in: size, using: font)
        var lastCount = fitted.count
        output.append(fitted)
        
        while remaining != "" {
            (fitted, remaining) = fit(remaining,
                                      in: size,
                                      using: font,
                                      lastCount: lastCount)
            lastCount = fitted.count
            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,
        lastCount: Int = 0) -> (String, String) {
        
        if !str.isTruncated(in: size, using: font) {
            return (str, "")
        }
        
        var low = 0
        var high = str.count - 1
        let lastValidIndex = high
        var substr = ""
        let step = lastCount/10  //Could be adjusted
        
        
        if lastCount != 0 {
            high = min(lastCount, lastValidIndex)
            while !str[0..<high].isTruncated(in: size, using: font) {
                low = high
                high = min(high + step, lastValidIndex)
            }
        }
        
        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
            }
        }
        
        substr = str[0..<low]
        
        while !String.blanks.contains(substr.last!) {
            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, 7402 total characters, generated on this website, it takes 55ms on my local machine to split the story.

If the story was too long, and to avoid blocking the main thread I would recommend adding subviews to the UIScrollView as the fitted strings are calculated one by one.

ielyamani
  • 879
  • 1
  • 5
  • 18