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 ofsplitToFitSize
isn't well formatted. - The correct spelling of
complition
iscompletion
. - 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
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 aString.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 thecontents
array shifted (Have a look here). This means that this algotithm shifts the elements of thecontents
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.