Skip to content

Instantly share code, notes, and snippets.

@groue
Last active February 10, 2025 16:09
Show Gist options
  • Save groue/0593560207c7b3f65a59e3dd6c572ceb to your computer and use it in GitHub Desktop.
Save groue/0593560207c7b3f65a59e3dd6c572ceb to your computer and use it in GitHub Desktop.
A SwiftUI layout for balancing text
import SwiftUI
/// A layout that shrinks the width of its content as much as possible,
/// without increasing its height.
///
/// It is a layout suitable for balancing text. Compare the normal and
/// balanced texts below: they have the same number of lines, but the
/// balanced version is less wide and looks more balanced:
///
/// │Ça a débuté comme ça. Moi, j'avais jamais|
/// |rien dit. Rien. │
/// │ │
/// │Ça a débuté comme ça. Moi, ···········|
/// |j'avais jamais rien dit. Rien.···········|
///
/// │Ça a débuté comme ça. Moi,│
/// │j'avais jamais rien dit. │
/// │Rien. │
/// │ │
/// │Ça a débuté comme ça.·····│
/// │Moi, j'avais jamais ·····│
/// │rien dit. Rien. ·····│
public struct BalancedLayout: Layout {
// If the layout contains multiple subviews, it behaves like a VStack.
// But we must admit that we don't have any clear idea yet about the
// expected behavior when there are multiple subviews. In particular,
// the current implementation does not balance *each* subview. It only
// balances the VStack.
private let vStack: AnyLayout
public init(
alignment: HorizontalAlignment = .center,
spacing: CGFloat? = nil
) {
self.vStack = AnyLayout(VStackLayout(
alignment: alignment,
spacing: spacing))
}
public func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout AnyLayout.Cache
) -> CGSize {
let size = vStack.sizeThatFits(
proposal: proposal,
subviews: subviews,
cache: &cache)
if proposal.height != nil {
let idealSize = vStack.sizeThatFits(
proposal: ProposedViewSize(width: proposal.width, height: nil),
subviews: subviews,
cache: &cache)
if idealSize.height > size.height {
// Don't compress width when we're height-constrained.
return size
}
}
let balancedWidth = balancedWidth(fitting: size.width) { width in
vStack.sizeThatFits(
proposal: ProposedViewSize(width: width, height: nil),
subviews: subviews,
cache: &cache).height
}
return CGSize(width: balancedWidth, height: size.height)
}
public func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout AnyLayout.Cache
) {
var balancedProposal = proposal
if let proposedWidth = proposal.width, proposedWidth > bounds.width {
balancedProposal.width = bounds.width
}
vStack.placeSubviews(
in: bounds,
proposal: balancedProposal,
subviews: subviews,
cache: &cache)
}
public func makeCache(subviews: Subviews) -> AnyLayout.Cache {
vStack.makeCache(subviews: subviews)
}
public func updateCache(_ cache: inout AnyLayout.Cache, subviews: Subviews) {
vStack.updateCache(&cache, subviews: subviews)
}
public func explicitAlignment(
of guide: HorizontalAlignment,
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout AnyLayout.Cache
) -> CGFloat? {
vStack.explicitAlignment(
of: guide,
in: bounds,
proposal: proposal,
subviews: subviews,
cache: &cache)
}
public func explicitAlignment(
of guide: VerticalAlignment,
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout AnyLayout.Cache
) -> CGFloat? {
vStack.explicitAlignment(
of: guide,
in: bounds,
proposal: proposal,
subviews: subviews,
cache: &cache)
}
public func spacing(
subviews: Subviews,
cache: inout AnyLayout.Cache
) -> ViewSpacing {
vStack.spacing(subviews: subviews, cache: &cache)
}
}
extension View {
/// Wraps `self` in a `BalancedLayout`, that shrinks the width of the view
/// as much as possible, without increasing its height.
public func balanced() -> some View {
BalancedLayout { self }
}
}
/// Returns the minimum width that does not increase the height at `maxWidth`.
private func balancedWidth(fitting maxWidth: CGFloat, height: (CGFloat) -> CGFloat) -> CGFloat {
var left: CGFloat = 0
var right = maxWidth
let referenceHeight = height(right)
while true {
if right - left <= 1 {
break
}
let mid = (left + right) / 2
if height(mid) > referenceHeight {
left = mid
} else {
right = mid
}
}
return right
}
// MARK: - Previews
private struct BubbleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background {
RoundedRectangle(cornerRadius: 10)
.foregroundStyle(.background.secondary)
}
}
}
#Preview {
@Previewable @State var xFactor: CGFloat = 1
@Previewable @State var blockHeight: CGFloat = 0
let title = Text(verbatim: "À la recherche du temps perdu")
let paragraph = Text(verbatim: """
Longtemps, je me suis couché de bonne heure. Parfois, \
à peine ma bougie éteinte, mes yeux se fermaient si \
vite que je n’avais pas le temps de me dire : « Je \
m’endors. »
""")
VStack {
VStack {
Slider(value: $xFactor, in: 0.5...1)
Slider(value: $blockHeight, in: 0...700)
}
.padding(.horizontal)
VStack(spacing: 10) {
Rectangle()
.frame(height: 2)
.foregroundStyle(.green)
title
.font(.title)
paragraph
.modifier(BubbleModifier())
Divider()
title
.balanced()
.font(.title)
paragraph
.balanced()
.modifier(BubbleModifier())
Rectangle().frame(height: blockHeight)
Spacer()
}
.containerRelativeFrame(.horizontal, alignment: .center) { width, _ in
width * xFactor
}
.ignoresSafeArea(edges: .bottom)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment