-
-
Save wb-towa/52f79d7d4636de25b9356c9603e511ea to your computer and use it in GitHub Desktop.
A SwiftUI layout and modifier for working with relative sizes ("50 % of your container"). https://oleb.net/2023/swiftui-relative-size/
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 | |
extension View { | |
/// Proposes a percentage of its received proposed size to `self`. | |
/// | |
/// This modifier multiplies the proposed size it receives from its parent | |
/// with the given factors for width and height. | |
/// | |
/// If the parent proposes `nil` or `.infinity` to us in any dimension, | |
/// we’ll forward these values to our child view unchanged. | |
/// | |
/// - Note: The size we propose to `self` will not necessarily be a percentage | |
/// of the parent view’s actual size or of the available space as not all | |
/// views propose the full available space to their children. For example, | |
/// VStack and HStack divide the available space among their subviews and | |
/// only propose a fraction to each subview. | |
public func relativeProposed(width: Double = 1, height: Double = 1) -> some View { | |
RelativeSizeLayout(relativeWidth: width, relativeHeight: height) { | |
// Wrap content view in a container to make sure the layout only | |
// receives a single subview. | |
// See Chris Eidhof, SwiftUI Views are Lists (2023-01-25) | |
// <https://chris.eidhof.nl/post/swiftui-views-are-lists/> | |
VStack { // alternatively: `_UnaryViewAdaptor(self)` | |
self | |
} | |
} | |
} | |
} | |
/// A custom layout that proposes a percentage of its | |
/// received proposed size to its subview. | |
/// | |
/// - Precondition: must contain exactly one subview. | |
fileprivate struct RelativeSizeLayout: Layout { | |
var relativeWidth: Double | |
var relativeHeight: Double | |
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { | |
assert(subviews.count == 1, "RelativeSizeLayout expects a single subview") | |
let resizedProposal = ProposedViewSize( | |
width: proposal.width.map { $0 * relativeWidth }, | |
height: proposal.height.map { $0 * relativeHeight } | |
) | |
return subviews[0].sizeThatFits(resizedProposal) | |
} | |
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { | |
assert(subviews.count == 1, "RelativeSizeLayout expects a single subview") | |
let resizedProposal = ProposedViewSize( | |
width: proposal.width.map { $0 * relativeWidth }, | |
height: proposal.height.map { $0 * relativeHeight } | |
) | |
subviews[0].place(at: CGPoint(x: bounds.midX, y: bounds.midY), anchor: .center, proposal: resizedProposal) | |
} | |
} |
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 | |
@main | |
struct RelativeSizingApp: App { | |
var body: some Scene { | |
WindowGroup { | |
ContentView() | |
} | |
} | |
} | |
struct ContentView: View { | |
@State private var debugLayout: Bool = true | |
var body: some View { | |
NavigationStack { | |
ChatBubblesList(messages: sampleMessages) | |
.debugLayout(debugLayout) | |
.toolbar { | |
ToolbarItem { | |
Button(debugLayout ? "Hide guides" : "Show guides") { | |
debugLayout.toggle() | |
} | |
} | |
} | |
} | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView() | |
} | |
} | |
extension View { | |
func debugLayout(_ enabled: Bool) -> some View { | |
self.environment(\.debugLayout, enabled) | |
} | |
} | |
enum DebugLayoutKey: EnvironmentKey { | |
static var defaultValue: Bool = false | |
} | |
extension EnvironmentValues { | |
var debugLayout: Bool { | |
get { self[DebugLayoutKey.self] } | |
set { self[DebugLayoutKey.self] = newValue } | |
} | |
} | |
extension View { | |
func debugOverlay(_ label: String? = nil, color: Color = .red, alignment: VerticalAlignment = .center, offset: CGFloat = 0) -> some View { | |
self.modifier(DebugOverlay(label: label, color: color, alignment: alignment, offset: offset)) | |
} | |
} | |
struct DebugOverlay: ViewModifier { | |
var label: String? | |
var color: Color | |
var alignment: VerticalAlignment | |
var offset: CGFloat | |
@Environment(\.debugLayout) private var debugLayout: Bool | |
func body(content: Content) -> some View { | |
content.overlay { | |
if debugLayout { | |
GeometryReader { geo in | |
Color.clear.overlay(alignment: Alignment(horizontal: .center, vertical: alignment)) { | |
Color.clear | |
.frame(height: 0) | |
.overlay { | |
HStack(spacing: 0) { | |
color.frame(width: 2, height: 16) | |
color.frame(height: 2) | |
Text("\(label.map { "\($0) — " } ?? "")**\(geo.size.width, format: .number.precision(.fractionLength(0)))**") | |
.padding(.horizontal, 8) | |
.padding(.vertical, 4) | |
.foregroundStyle(.white) | |
.background(color, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) | |
.font(.callout.monospacedDigit()) | |
.layoutPriority(1) | |
color.frame(height: 2) | |
color.frame(width: 2, height: 16) | |
} | |
.fixedSize(horizontal: false, vertical: true) | |
.offset(y: offset) | |
} | |
} | |
} | |
} | |
} | |
.animation(.default, value: debugLayout) | |
} | |
} |
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 | |
struct ChatMessage: Identifiable { | |
var id: UUID = .init() | |
var sender: Sender | |
var content: String | |
enum Sender { | |
case me | |
case other | |
} | |
} | |
let lorem = "Lorem ipsum dolor sit amet. " | |
let sampleMessages: [ChatMessage] = [ | |
.init(sender: .me, content: String(repeating: lorem, count: 10)), | |
.init(sender: .other, content: String(repeating: lorem, count: 5)), | |
.init(sender: .me, content: String(repeating: lorem, count: 3)), | |
.init(sender: .other, content: String(repeating: lorem, count: 8)), | |
] | |
struct ChatBubblesList: View { | |
var messages: [ChatMessage] | |
@ScaledMetric(relativeTo: .body) private var textSize: CGFloat = 18 | |
@Environment(\.debugLayout) private var debugLayout: Bool | |
var body: some View { | |
ScrollView { | |
LazyVStack(spacing: debugLayout ? 80 : 40) { | |
ForEach(messages) { message in | |
ChatBubble(message: message) | |
.textSelection(.enabled) | |
} | |
} | |
.font(.system(size: textSize)) | |
.padding() | |
.padding(.bottom, 96) | |
} | |
.navigationTitle("Chat") | |
.animation(.default, value: debugLayout) | |
} | |
} | |
@MainActor | |
struct ChatBubble: View { | |
var message: ChatMessage | |
@Environment(\.debugLayout) private var debugLayout: Bool | |
var body: some View { | |
VStack { | |
let alignment: Alignment = message.sender == .me ? .trailing : .leading | |
let bubbleColor: Color = message.sender == .me ? Color("chat-bubble-tint") : Color("chat-bubble-neutral") | |
let textColor: Color = message.sender == .me ? .white : .primary | |
let content = Text(message.content) | |
.redacted(reason: .placeholder) | |
.padding(.vertical, 8) | |
.padding(.horizontal, 16) | |
content | |
.foregroundStyle(textColor) | |
.background(bubbleColor, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) | |
.frame(maxWidth: 400) | |
.debugOverlay("maxW=400", color: .orange, alignment: .bottom, offset: -40) | |
.relativeProposed(width: 0.8) | |
.debugOverlay("relW=80 %", color: .red, alignment: .bottom, offset: -16) | |
.frame(maxWidth: .infinity, alignment: alignment) | |
.debugOverlay("maxW=infinity", color: .purple, alignment: .bottom, offset: 16) | |
} | |
} | |
} | |
struct ChatBubblesList_Previews: PreviewProvider { | |
static var previews: some View { | |
ChatBubblesList(messages: sampleMessages) | |
.debugLayout(true) | |
.previewLayout(.fixed(width: 900, height: 1000)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment