Algorithm for split string to fit size - ios

I got a long string, let's call it Story , I fetch this Story from a Database so i don't know how long it is.
I want to display this story in view , But what if the Story is to long that doesn't fit in one view ?
I don't want to adjusts the font size Because it may be a very long story and make the font size smaller is not a good solution.
So i want to separate the Story to more than one view , By Passing the Story and get separated Story as array of String every item in the array can fit in one view .
This is the code , Maybe it give you a hint what i'm trying to do :
extension String {
/// - returns: The height that will fit Self
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [.font: font], context: nil)
return ceil(boundingBox.height)
}
#warning("it's so slow that i can't handle it in main thread , ( 21 second for 15 items in array )")
/// complition contains the separated string as array of string
func splitToFitSize(_ size : CGSize = UIScreen.main.bounds.size ,font : UIFont = UIFont.systemFont(ofSize: 17) , complition : #escaping (([String]) -> Void) ) {
DispatchQueue.global(qos: .background).async {
// contents contains all the words as Array of String
var contents = self.components(separatedBy: .whitespaces)
var values : [String] = []
for content in contents {
// if one word can't fit the size -> remove it , which it's not good, but i don't know what to do with it
guard content.isContentFit(size: size , font : font) else {contents.removeFirst(); continue;}
if values.count > 0 {
for (i , value) in values.enumerated() {
var newValue = value
newValue += " \(content)"
if newValue.isContentFit(size: size, font: font) {
values[i] = newValue
contents.removeFirst()
break;
}else if i == values.count - 1 {
values.append(content)
contents.removeFirst()
break;
}
}
}else {
values.append(content)
contents.removeFirst()
}
}
complition(values)
}
}
/// - returns: if Self can fit the passing size
private func isContentFit(size : CGSize, font : UIFont) -> Bool{
return self.height(withConstrainedWidth: size.width, font: font) < size.height
}
}
This code is working , But it take so long , if i want to split a Story to 15 views it take 20 or more second.
I'm not good in algorithms so i need a batter or a hint to make it execute quicker.
Just Hint in Any programming language, i be very thankful .

I have worked with this some times, for example for implementing an e-book reader, where you want the pages to flow to the right.
The code you posted is essentially correct, but it is slow because it has to rerender everything word-for-word as you measure it's size (assuming that your function isContentFit() is taking up most of your time). If you want to optimize this, you have to make a rough guess at the size of each letter and get an estimate on how many of your words can fit on one page before beginning to render and measure. You could also delay rendering/measuring the forthcoming pages until just before they need to display.
Another problem could also be the way you split up the string into words and then concatenate them back one by one. This can also be slow and time consuming if the string is large. Here it would also benefit to just search through the original string and counting letters and spaces, and then use string slicing when you know where to cut the string into pages.
Another solution I have participated in using successfully multiple times, is using a webview and styling your text so it will display in columns, for example by using styling like in this example: https://www.w3schools.com/cssref/tryit.asp?filename=trycss3_column-width
The web rendering engine does something like your code does, but blazingly fast.
On iOS, you should use the css property -webkit-column-width (since webviews on iOS is essentially rendered like in Safari) and set -webkit-column-width to the width of your webview, and then render the entire text into the webview. The result will then be that you see one 'page' at a time, and can scroll to the right to see the next page.
If you want to use a page controller or some other scrolling control on top of this, you have to inject some css/javascript magic, which is not easy I must admit, but it can be done.
Here is an example html string to display in the webview. Just insert your entire story string into the div:
<html>
<head>
<style>
.multicolumn {
-webkit-column-width: 500px; /* set this to the width of your webview */
height: 300px; /* set this to the height of your webview */
overflow-x: scroll;
}
</style>
</head>
<body>
<div class="multicolumn">
Insert long long text here...
</div>
</body>
</html>

Related

iOS 13: How can I tweak the leading / descend / line height of a custom font in UIKit / SwiftUI

I'm using a custom font and somehow the rendering screws up the line height, potentially because of misconfigured descent or leading (?), so that g's and j's are cut off in the last line of the rendered text. I think it might be a problem with this particular font, because Sketch is also exposing similar issues with the font in question, but I feel like I don't understand quite enough about typographic measurements or fonts. I found this Apple documentation page on Typographic Concepts quite insightful.
I looked into the font itself with the test version of FontLab, which I have used for the first time btw - so I've little clue really what I'm looking at. It does seem like the g is going below the configured descent, which seems to be what the last line is. (?) (See: Character view in FontLab, showing the descend of the g)
Via lineSpacing I could adjust the distance between just the lines itself to fix this in the first few lines. I know iOS 14 is going to bring a way to modify the leading of a Text in SwiftUI. But I need to target iOS 13, so that doesn't help.
I've also tried SwiftUI's Text, a normal UILabel.text and UILabel.attributedText with a customized paragraph style, but nothing I adjust there seems to mitigate the problem.
The view is not even clipping. Just adding padding to the frame does not help at all. It increases the distance, but the g's and j's are still cut.
What can I do? Subclass UILabel and overwrite the intrinsicContentSize to add some extra space, when there is a g and j in the last line? That feels a) dirty and b) given that padding didn't help, it might not fix the problem?
Is the font itself the problem here? Can I patch the font somehow without making it worse?
Is there any way to modify the leading or the descend height of the font, when I use lower level APIs? Seems like I could go down to CoreText, as CTFontCreateCopyWithAttributes(_:_:_:_:) is a candidate, if I could just modify via attributes the leading, line space or the descend? Can or monkey-patch / swizzle things without shooting myself in the knee? Should I just file a radar a feedback?
You need to use NSAttributedString instead of String to control the line spacing of UILabel. Here is sample code
let style = NSMutableParagraphStyle()
style.lineSpacing = 20
let string = NSMutableAttributedString(string: "The quick brown fox jumps over the lazy dog, The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog")
string.addAttribute(.paragraphStyle, value: style, range: NSMakeRange(0, string.length))
let label = UILabel(frame: CGRect(x: 20, y: 100, width: 300, height: 500))
label.attributedText = string
label.numberOfLines = 0
self.view.addSubview(label)
Out put
I know what you are asking as I have faced the same issues with custom fonts. I am going to offer two solutions. In my own project I went the way of your suggestion in overriding intrinsicContentSize and adding a padding multiplier for height and width. In my case the fonts were user facing so I had a struct that held all the relevant information. FYI Chalkduster is in the system and clips. I also believe that this is all due to the font file itself.
Solution 1:
Example:
struct UserFont{
var name : String
var displayName : String
var widthMultiplier : CGFloat
var heightMultiplier : CGFloat
}
Then in my UILabel I have it subclassed to use both of these metrics
#IBDesignable
class MultiplierUILabel: UILabel {
#IBInspectable var widthPaddingMultiplier : CGFloat = 1
#IBInspectable var heightPaddingMultiplier : CGFloat = 1
override var intrinsicContentSize: CGSize{
return CGSize(width: super.intrinsicContentSize.width * widthPaddingMultiplier, height: super.intrinsicContentSize.height * heightPaddingMultiplier)
}
}
This to me was the simplest implementation as I found the font and multiplier scale accordingly.
Solution 2:
You might be able to get the draw to occur slightly higher by measuring the glyph bounds and adjusting the origin y. For example this fixes the clipping on Chalkduster font that is included in the system.
#IBDesignable
class PaddingUILabel: UILabel {
override func drawText(in rect:CGRect) {
//hello
guard let labelText = text else { return super.drawText(in: rect) }
//just some breathing room
let info = boundsForAttrString(str: labelText, font: self.font!, kern: .leastNormalMagnitude)
let glyph = info.glyph
var newRect = rect
let glyphPadding = -(glyph.origin.y)
if glyphPadding - info.descent > 1 && info.descent != 0{
newRect.origin.y -= glyphPadding/2
}else{
if info.descent != 0{
newRect.origin.y += (info.descent - glyphPadding)/2
}
}
super.drawText(in: newRect)
}
func boundsForAttrString(str:String,font:UIFont,kern:CGFloat)->(glyph:CGRect,descent:CGFloat){
let attr = NSAttributedString(string: str, attributes: [.font:font,.kern:kern])
let line = CTLineCreateWithAttributedString(attr)
var ascent : CGFloat = 0
var descent : CGFloat = 0
var leading : CGFloat = 0
CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
let glyph = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds).integral
return (glyph,leading != 0 ? descent : 0)
}
}
Result of Solution 2:
System
PaddingUILabel using glyph bounds

creating spaces in placeholder fields

my problem is I want to put 2 placeholders for one textField. one of the place holders should be on the left and the other should be on the right.
my code:
func returnPlaceHolder(first: String, second: String, textField: UITextField){
let width: Int = Int(textField.bounds.width * 0.2)
let spaceValue = width - (first.count + second.count)
var temp = "\(first) "
let tempCount = spaceValue - (first.count + second.count)
var value = String()
for _ in 0..<tempCount {
temp.append(" ")
}
value = "\(temp) \(second)"
textField.placeholder = value
textField.setLeftPaddingPoints(10)
textField.setRightPaddingPoints(10)
}
I'm currently using this function to create spaces.. but my problem is the spaces won't be the same for more than one textField, and I want them to be aligned..
just like this picture: https://imgur.com/pZZMoNv
and this is the result I'm getting for my current code: https://imgur.com/a/5AN8EXl
don't mind the textFields format & text I can fix them later.. I just want to be able to align the textFields placeholders.
It would be really hard (if possible) to achieve what you are trying to do by injecting spaces into the text because each character in the font has a different width unless you use a monospaced font.
https://en.wikipedia.org/wiki/Monospaced_font
Instead, I would recommend a different approach. Override the text field, provide two UILabels and adjust their position using Autolayout.

Update dynamic height of UICollectionViewCells

What's a recommended approach to determine the height of UICollectionViewCell which contains several text views whose height depends on the text content set in them?
Since UICollectionView's sizeForItemAt is called before cellForItemAt the definitive text content isn't known at the time the cell size should be calculated.
One possible way would be to calculate the text height with something like
func calculateEstimatedCellFrame(_ text:String) -> CGRect
{
let size = CGSize(width: 210, height: 1000)
let options = NSStringDrawingOptions.usesFontLeading.union(.usesLineFragmentOrigin)
return NSString(string: text).boundingRect(with: size, options: options, attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14)], context: nil)
}
But that seems impractical in my current case, especially since the texts for the cells is at least in part assembled from various other sources (in part from JSON data, and from localization strings).
Using constraints seems very complex and I'm not sure how I would apply them to have several text views that are placed between other text views to set their proper constraint values.
Is there any better way to figure out the required height for each cell?

How to split NSAttributedString across equal bounds views with correct word wrap

I have been grappling with this since a while. There are APIs that give us bounds size for given attributes of NSAttributedString.
But there is no direct way to get string range that would fit within given bounds.
My requirement is to fit very long string across a few paged views (PDF is not an option, neither is scrolling). Hence I have to figure out string size for each view (same bounds).
Upon research I found that CTFramesetterSuggestFrameSizeWithConstraints and its friends in Core Text maybe of help. I tried the approach described here, but the resulting ranges have one ugly problem:
It ignores word breaks (a different problem unrelated to Core Text, but I would really like to see if there was some solution to that as well).
Basically I want paging of text across number of UITextView objects, but not getting the right attributed string splits.
NOTE:
My NSAttributedString attributes are as follows:
let attributes: [NSAttributedString.Key : Any] = [.foregroundColor : textColor, .font : font, .paragraphStyle : titleParagraphStyle]
(titleParagraphStyle has lineBreakMode set to byWordWrapping)
extension UITextView
{
func getStringSplits (fullString: String, attributes: [NSAttributedString.Key:Any]) -> [String]
{
let attributeString = NSAttributedString(string: fullString, attributes: attributes)
let frameSetterRef = CTFramesetterCreateWithAttributedString(attributeString as CFAttributedString)
var initFitRange:CFRange = CFRangeMake(0, 0)
var finalRange:CFRange = CFRangeMake(0, fullString.count)
var ranges: [Int] = []
repeat
{
CTFramesetterSuggestFrameSizeWithConstraints(frameSetterRef, initFitRange, attributes as CFDictionary, CGSize(width: bounds.size.width, height: bounds.size.height), &finalRange)
initFitRange.location += finalRange.length
ranges.append(finalRange.length)
}
while (finalRange.location < attributeString.string.count)
var stringSplits: [String] = []
var startIndex: String.Index = fullString.startIndex
for n in ranges
{
let endIndex = fullString.index(startIndex, offsetBy: n, limitedBy: fullString.endIndex) ?? fullString.endIndex
let theSubString = fullString[startIndex..<endIndex]
stringSplits.append(String(theSubString))
startIndex = endIndex
}
return stringSplits
}
}
My requirement is to fit very long string across a few paged views (PDF is not an option, neither is scrolling). Hence I have to figure out string size for each view (same bounds).
No, you don't have to figure that out. The text kit stack will do it for you.
In fact, UITextView will flow long text from one text view to another automatically. It's just a matter of configuring the text kit stack — one layout manager with multiple text containers.

How to make multi-line UILabel text fit within predefined width without wrapping mid-word

I have a UILabel carefully laid out in Interface Builder with proper height and width constraints. The number of lines is set to 4. The wrapping is set to word wrap. The text is "CHECKED". The font size is very large and thus it only fits "CHECKE" and the "D" is on the second line. Writing "Checked" instead of "CHECKED" lets the font shrink (as intended) so that the whole word fits. But (the text is user given and it can be expected that the user writes fully uppercase words) having uppercase words the label does not break it/shrink the font as expected.
Do you have a suggestion as to what I might have missed? Capitalising the words (thusly only having the first letter uppercase) does work, but is not what the client wants.
Updated question
The problem seems to be unrelated to having uppercase or lowercase text. My problem could be solved by an answer to the following question:
How to make (ideally with the help of only Interface Builder) the UILabel text shrink trying to fit full words within all available lines without wrapping the text mid-word?
If the text "CHECKED" is too wide for a label (with more than 1 line available) it should shrink the font size instead of breaking the "D" and wrapping the single letter to the next line.
If the text is "one CHECKED two" and the single word "CHECKED" is already too wide for a label (with more than 1 line available) it should break between all words and shrinking the font size so that "CHECKED" still fits the middle line.
Avoiding:
one
CHECKE
D two
Thank you very much!
Here is a UILabel subclass that will find the largest word in the labels text, use the boundingRect function of NSString to see how large that one word will be with the current font, and drop the font size until it fits the width.
class AutosizingMultilineLabel: UILabel {
override func layoutSubviews() {
super.layoutSubviews()
self.adjustFontToFitWidth()
}
func adjustFontToFitWidth() {
guard let currentFont = self.font else { return }
let minimumFontSize: CGFloat = floor(self.minimumScaleFactor * currentFont.pointSize)
var newFontSize = currentFont.pointSize
var theNewFont = currentFont
if let text = self.text, let longestWord = text.components(separatedBy: " ").max(by: {$1.count > $0.count})?.replacingOccurrences(of: "\n", with: "") {
let nsString = longestWord as NSString
while newFontSize > minimumFontSize {
theNewFont = currentFont.withSize(newFontSize)
let boundingRect = nsString.boundingRect(with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
options: NSStringDrawingOptions.usesLineFragmentOrigin,
attributes: [.font: theNewFont],
context: nil)
if ceil(boundingRect.size.width) <= self.bounds.size.width {
break
}
newFontSize -= 1
}
self.font = theNewFont
}
}
}
When the word is bigger than the line, word wrap doesn't work. If it doesn't fit on this line, it won't fit on the next line. (same word, same size, same line size). To make it fit, the label will start putting letters on the next line.
If you allow multiple lines on your label, the OS will try to fill the lines before adjusting the font size.
I think you're just running into a limitation on Autoshrink.
In Interface Builder:
add a new UILabel with Width: 230 and Height: 280
set the Font to System 44.0
set Line Break: Truncate Tail
set Autoshrink: Minimum Font Scale at 0.15
set the text of the label to test CHECKED lines
Now, drag the handle on the right edge of the label left and right... when it gets too narrow, the word CHECKED will break onto the next line.
Change CHECKED to checked and do the same thing. You should see the same behavior.
Now, try dragging the Bottom edge up and down. With either CHECKED or checked, you should see the Font Size auto shrink.
So... to do what you're trying to do, you might have to skip Autoshrink and instead do some code calculations.
Edit: further visual of what goes on...
Start with above values, but set the Height of the label to 170 - gives it just a little vertical padding.
Now, drag the left edge to make it narrower.
When you reach the end of the word CHECKED, and keep going, you will see the font shrink until it gets small enough that there is space for it to wrap to a 4th line.
I think you're going to need some code to get exactly what you need.

Resources