Last active
February 10, 2025 16:09
-
-
Save groue/0593560207c7b3f65a59e3dd6c572ceb to your computer and use it in GitHub Desktop.
A SwiftUI layout for balancing text
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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