Skip to content

Instantly share code, notes, and snippets.

@ole
Last active October 12, 2024 09:25
Show Gist options
  • Save ole/7577deed8081ef6294f761704cff8a1d to your computer and use it in GitHub Desktop.
Save ole/7577deed8081ef6294f761704cff8a1d 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/
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)
}
}
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)
}
}
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))
}
}
import SwiftUI
struct NestedInStack: View {
var body: some View {
HStack(spacing: 10) {
Color.blue
.debugOverlay(color: .red, alignment: .bottom, offset: 16)
Color.green
.debugOverlay(color: .red, alignment: .bottom, offset: 16)
Color.yellow
.debugOverlay(color: .red, alignment: .bottom, offset: 16)
.relativeProposed(width: 0.5)
// .layoutPriority(1)
}
.border(.primary)
.debugOverlay("HStack", color: .red, alignment: .bottom, offset: 48)
.frame(height: 80)
.frame(width: 620)
.debugOverlay("available", color: .red, alignment: .bottom, offset: 80)
.padding()
.padding(.bottom, 100)
}
}
struct NestedInStack_Previews: PreviewProvider {
static var previews: some View {
NestedInStack()
.debugLayout(true)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment