Skip to content

Instantly share code, notes, and snippets.

@ryanlintott
Last active November 16, 2022 04:54
Show Gist options
  • Save ryanlintott/f1c763829a7b2a6d2892da242cf2b25d to your computer and use it in GitHub Desktop.
Save ryanlintott/f1c763829a7b2a6d2892da242cf2b25d to your computer and use it in GitHub Desktop.
A way to justify text using a single SwiftUI Text view.
//
// JustifiedTextExample2.swift
// FrameUpExample
//
// Created by Ryan Lintott on 2022-11-15.
//
import SwiftUI
import WidgetKit
extension StringProtocol {
func size(using font: UIFont) -> CGSize {
return String(self).size(using: font)
}
}
extension String {
func size(using font: UIFont) -> CGSize {
return (self as NSString).size(withAttributes: [NSAttributedString.Key.font: font])
}
func splitMultilineByCharacter(font: UIFont, maxWidth: CGFloat) -> [String] {
guard self.size(using: font).width > maxWidth else {
return [self]
}
var characters = Array(self).map({String($0)})
var multiline = [characters.removeFirst()]
var index = 0
while !characters.isEmpty {
let character = characters.removeFirst()
let line = multiline[index] + character
if line.size(using: font).width <= maxWidth {
multiline[index] = line
} else {
multiline.append(character)
index += 1
}
}
return multiline
}
func splitMultiline(by separator: Character = " ", font: UIFont, maxWidth: CGFloat) -> [String] {
guard self.size(using: font).width > maxWidth else {
return [self]
}
var parts = self.split(separator: separator)
var multiline = [String]()
while !parts.isEmpty {
let part = String(parts.removeFirst())
let line = [multiline.last, part].compactMap({$0}).joined(separator: String(separator))
if !line.isEmpty && line.size(using: font).width <= maxWidth {
if !multiline.isEmpty {
multiline[multiline.endIndex - 1] = line
} else {
multiline.append(line)
}
} else {
let wordParts = String(part).splitMultilineByCharacter(font: font, maxWidth: maxWidth)
multiline += wordParts
}
}
return multiline.map({String($0)})
}
func justified(font: UIFont, maxWidth: CGFloat) -> String {
let separator: Character = " "
let hairSpace: String = "\u{200A}"
return splitMultiline(font: font, maxWidth: maxWidth)
.map { line in
var words = line.split(separator: separator)
guard words.count > 1 else { return words.joined() }
var justifiedSeparator = String(hairSpace)
var justifiedLine = words.joined(separator: justifiedSeparator)
var hairSpaceCount = 0
while justifiedLine.size(using: font).width < maxWidth {
hairSpaceCount += 1
justifiedLine += hairSpace
}
hairSpaceCount -= 1
let (minCount, extraCount) = hairSpaceCount.quotientAndRemainder(dividingBy: words.count - 1)
let spaces = Array(0..<words.count)
.map { i in
String.init(repeating: hairSpace, count: minCount) + (i < extraCount ? hairSpace : "")
}
return zip(words, spaces)
.map {
String($0 + $1)
}
.joined()
.trimmingCharacters(in: .whitespaces)
}
.joined(separator: "\n")
}
}
struct JustifiedTextExample2: View {
let uiFont: UIFont = .boldSystemFont(ofSize: 16)
let text: String
let maxWidth: CGFloat
var justifiedTest: String {
text.justified(font: uiFont, maxWidth: maxWidth)
}
var body: some View {
Text(justifiedTest)
.font(Font(uiFont))
}
}
struct JustifiedTextExample2_Previews: PreviewProvider {
static var previews: some View {
GeometryReader { proxy in
JustifiedTextExample2(text: "Hello World here is a bunch of text that will take up a few lines. Another sentence with a few more words in it", maxWidth: proxy.size.width)
}
.padding()
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment