Created
April 22, 2026 12:23
-
-
Save ValentinWalter/8161f3bcce236ce439bb6cdf87db485e to your computer and use it in GitHub Desktop.
This file contains hidden or 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
| // | |
| // BalancedWidth.swift | |
| // SemanticalKit | |
| // | |
| // Created by Valentin Walter on 09.04.23. | |
| // | |
| import SwiftUI | |
| extension View { | |
| /// Reduces the view's width to the smallest size without altering the | |
| /// height. Used with text views to eliminate widows and make line breaks, | |
| /// particularly in titles and headings, more readable. | |
| /// | |
| /// This modifier can be used on any `View`, but it's best to use it near | |
| /// the relevant `Text` view. The modifier is wrapping the view, which may | |
| /// result in unexpected loss of information up the view hierarchy. | |
| /// | |
| /// - Important: This will break layout when used on `Group` or other | |
| /// collector views. | |
| /// | |
| /// - Parameter ratio: Strength of the effect. A value of 0 means no effect. | |
| public func balancedWidth(ratio: Double = 1) -> some View { | |
| BalancedWidthLayout(ratio: ratio) { | |
| self | |
| } | |
| } | |
| } | |
| /// An implementation detail of the ``View.balancedWidth()`` modifier. **Only | |
| /// takes the first subview into account.** | |
| /// | |
| /// Based on [React Wrap Balancer](https://react-wrap-balancer.vercel.app). | |
| private struct BalancedWidthLayout: Layout { | |
| let ratio: Double | |
| init(ratio: Double) { | |
| assert( | |
| (0...1).contains(ratio), | |
| "Balanced Width ratio must not exceed 0 to 1.", | |
| ) | |
| self.ratio = ratio | |
| } | |
| func sizeThatFits( | |
| proposal: ProposedViewSize, | |
| subviews: Subviews, | |
| cache: inout Void, | |
| ) -> CGSize { | |
| let proposedSize = proposal.replacingUnspecifiedDimensions() | |
| guard let subview = subviews.first else { | |
| return proposedSize | |
| } | |
| let size = subview.sizeThatFits( | |
| .init( | |
| width: proposedSize.width, | |
| height: nil, | |
| ) | |
| ) | |
| // Binary search for minimum width without height change | |
| var lower = size.width / 2 - 0.25 | |
| var upper = size.width + 0.5 | |
| var middle: Double | |
| while lower + 1 < upper { | |
| middle = round((lower + upper) / 2.0) | |
| let middleSize = subview.sizeThatFits( | |
| .init( | |
| width: middle, | |
| height: nil, | |
| ) | |
| ) | |
| if middleSize.height == size.height { | |
| upper = middle | |
| } else { | |
| lower = middle | |
| } | |
| } | |
| return CGSize( | |
| width: upper + (size.width - upper) * (1 - ratio), | |
| height: size.height, | |
| ) | |
| } | |
| func placeSubviews( | |
| in bounds: CGRect, | |
| proposal: ProposedViewSize, | |
| subviews: Subviews, | |
| cache: inout Void, | |
| ) { | |
| guard let subview = subviews.first else { return } | |
| let size = sizeThatFits(proposal: proposal, subviews: subviews, cache: &cache) | |
| let point = CGPoint(x: bounds.minX, y: bounds.minY) | |
| subview.place(at: point, proposal: .init(size)) | |
| } | |
| } | |
| // MARK: - Library Content | |
| struct BalancedWidth_LibraryContent: LibraryContentProvider { | |
| @LibraryContentBuilder | |
| @MainActor | |
| func modifiers(base: some View) -> [LibraryItem] { | |
| LibraryItem( | |
| base.balancedWidth(), | |
| category: .layout, | |
| ) | |
| } | |
| } | |
| // MARK: - Previews | |
| #Preview { | |
| @Previewable @State var maxWidth = 280.0 | |
| VStack(spacing: 32) { | |
| Spacer() | |
| Group { | |
| Text("The quick brown fox jumps over the lazy dog") | |
| .balancedWidth() | |
| Text("The quick brown fox jumps over the lazy dog") | |
| .foregroundStyle(.secondary) | |
| } | |
| .border(.pink) | |
| .frame(maxWidth: maxWidth) | |
| .border(.blue) | |
| .font(.title) | |
| .multilineTextAlignment(.center) | |
| Spacer() | |
| Text("max width: \(maxWidth.rounded(), format: .number)") | |
| .contentTransition(.identity) | |
| .monospaced() | |
| Slider(value: $maxWidth, in: 100...400) | |
| } | |
| .animation(.interactiveSpring(), value: maxWidth) | |
| .padding() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment