From e48f3346b42782580498df5a64c4d24912e086f8 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Tue, 22 Nov 2022 19:19:00 +0100 Subject: [PATCH 01/23] Put Inspector UI into an overlay (resizable and movable with gestures) --- DemoApp/ContentView.swift | 20 +-- Sources/LayoutInspector/DebugLayout.swift | 7 +- Sources/LayoutInspector/DebugLayoutImpl.swift | 160 ++++++++++++++++++ Sources/LayoutInspector/Geometry.swift | 63 +++++++ Sources/LayoutInspector/LogEntriesGrid.swift | 3 +- 5 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 Sources/LayoutInspector/Geometry.swift diff --git a/DemoApp/ContentView.swift b/DemoApp/ContentView.swift index 92b9e1d..b7871d1 100644 --- a/DemoApp/ContentView.swift +++ b/DemoApp/ContentView.swift @@ -51,9 +51,6 @@ struct Inspector: View { @State private var width: CGFloat = 300 @State private var height: CGFloat = 100 - @State private var selectedView: String? = nil - @State private var generation: Int = 0 - @ObservedObject private var logStore = LogStore.shared private var roundedWidth: CGFloat { width.rounded() } private var roundedHeight: CGFloat { height.rounded() } @@ -62,14 +59,14 @@ struct Inspector: View { VStack { VStack { subject - .inspectLayout(selection: selectedView) - .id(generation) + .inspectLayout() .frame(width: roundedWidth, height: roundedHeight) .overlay { Rectangle() .strokeBorder(style: StrokeStyle(dash: [5])) } - .padding(.bottom, 16) + + Spacer() VStack { LabeledContent { @@ -93,20 +90,9 @@ struct Inspector: View { Text("H \(roundedHeight, format: .number.precision(.fractionLength(0)))") .monospacedDigit() } - - Button("Reset layout cache") { - generation += 1 - } } - .buttonStyle(.bordered) } .padding() - - #if os(macOS) - LogEntriesTable(logEntries: logStore.log, highlight: $selectedView) - #else - LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) - #endif } } } diff --git a/Sources/LayoutInspector/DebugLayout.swift b/Sources/LayoutInspector/DebugLayout.swift index dc47dc2..4d1a24a 100644 --- a/Sources/LayoutInspector/DebugLayout.swift +++ b/Sources/LayoutInspector/DebugLayout.swift @@ -3,11 +3,8 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) extension View { /// Inspect the layout for this subtree. - public func inspectLayout(selection: String? = nil) -> some View { - ClearDebugLayoutLog { - self - } - .environment(\.debugLayoutSelectedViewID, selection) + public func inspectLayout() -> some View { + self.modifier(InspectLayout()) } /// Monitor the layout proposals and responses for this view and add them diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 5962bd7..0fc6640 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -1,5 +1,165 @@ import SwiftUI +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct InspectLayout: ViewModifier { + @State private var selectedView: String? = nil + @State private var generation: Int = 0 + @State private var frame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300) + @ObservedObject private var logStore = LogStore.shared + + private static let coordSpaceName = "InspectLayout" + + func body(content: Content) -> some View { + ClearDebugLayoutLog { + content + .id(generation) + .environment(\.debugLayoutSelectedViewID, selectedView) + } + .overlay(alignment: .topLeading) { + LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) + .safeAreaInset(edge: .bottom) { + Button("Reset layout cache") { + generation += 1 + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + .background() + .backgroundStyle(.thickMaterial) + } + .resizableAndDraggable( + frame: $frame, + coordinateSpace: .named(Self.coordSpaceName) + ) + .background { + Rectangle() + .fill(.thickMaterial) + .shadow(radius: 5) + } + .frame(width: frame.width, height: frame.height) + .offset(x: frame.minX, y: frame.minY) + .coordinateSpace(name: Self.coordSpaceName) + } + } +} + +extension View { + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + func resizableAndDraggable( + frame: Binding, + coordinateSpace: CoordinateSpace + ) -> some View { + modifier(ResizableAndDraggableFrame( + frame: frame, + coordinateSpace: coordinateSpace + )) + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct ResizableAndDraggableFrame: ViewModifier { + @Binding var frame: CGRect + var coordinateSpace: CoordinateSpace + + @State private var isDragging: Bool = false + @State private var isResizing: Bool = false + + private static let chromeWidth: CGFloat = 10 + + func body(content: Content) -> some View { + content + .padding(.vertical, 20) + .overlay { + ZStack(alignment: .top) { + Rectangle() + .frame(height: 20) + .foregroundStyle(isDragging ? .pink : .yellow) + .draggable(isDragging: $isDragging, point: $frame.origin, coordinateSpace: coordinateSpace) + + let resizeHandle = Rectangle() + .fill(.green) + .frame(width: 20, height: 20) + resizeHandle + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .draggable(isDragging: $isResizing, point: $frame.topLeading, coordinateSpace: coordinateSpace) + resizeHandle + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .draggable(isDragging: $isResizing, point: $frame.topTrailing, coordinateSpace: coordinateSpace) + resizeHandle + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .draggable(isDragging: $isResizing, point: $frame.bottomLeading, coordinateSpace: coordinateSpace) + resizeHandle + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .draggable(isDragging: $isResizing, point: $frame.bottomTrailing, coordinateSpace: coordinateSpace) + } + } + + } +} + +extension Binding { + func transform( + getter: @escaping (Value) -> Target, + setter: @escaping (inout Value, Target, Transaction) -> Void + ) -> Binding { + Binding( + get: { getter(self.wrappedValue) }, + set: { newValue, transaction in + setter(&self.wrappedValue, newValue, transaction) + } + ) + } +} + +extension View { + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + func draggable(isDragging: Binding, offset: Binding, coordinateSpace: CoordinateSpace) -> some View { + modifier(Draggable(isDragging: isDragging, offset: offset, coordinateSpace: coordinateSpace)) + } + + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + func draggable(isDragging: Binding, point pointBinding: Binding, coordinateSpace: CoordinateSpace) -> some View { + let sizeBinding = pointBinding.transform( + getter: { pt -> CGSize in CGSize(width: pt.x, height: pt.y) }, + setter: { pt, newValue, _ in + pt = CGPoint(x: newValue.width, y: newValue.height) + } + ) + return draggable(isDragging: isDragging, offset: sizeBinding, coordinateSpace: coordinateSpace) + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct Draggable: ViewModifier { + @Binding var isDragging: Bool + @Binding var offset: CGSize + var coordinateSpace: CoordinateSpace + + @State private var lastTranslation: CGSize? = nil + + func body(content: Content) -> some View { + content + .gesture(dragGesture) + } + + private var dragGesture: some Gesture { + DragGesture(coordinateSpace: coordinateSpace) + .onChanged { gv in + isDragging = true + if let last = lastTranslation { + let delta = gv.translation - last + offset = offset + delta + lastTranslation = gv.translation + } else { + lastTranslation = gv.translation + } + } + .onEnded { gv in + lastTranslation = nil + isDragging = false + } + } +} + /// A custom layout that saves the layout proposals and responses for a view /// to a log. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) diff --git a/Sources/LayoutInspector/Geometry.swift b/Sources/LayoutInspector/Geometry.swift new file mode 100644 index 0000000..d658db9 --- /dev/null +++ b/Sources/LayoutInspector/Geometry.swift @@ -0,0 +1,63 @@ +import CoreGraphics + +extension CGPoint { + static func + (lhs: CGPoint, rhs: CGPoint) -> CGSize { + CGSize(width: lhs.x + rhs.x, height: lhs.y + rhs.y) + } + + static func - (lhs: CGPoint, rhs: CGPoint) -> CGSize { + CGSize(width: lhs.x - rhs.x, height: lhs.y - rhs.y) + } +} + +extension CGSize { + static func + (lhs: CGSize, rhs: CGSize) -> CGSize { + CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height) + } + + static func - (lhs: CGSize, rhs: CGSize) -> CGSize { + CGSize(width: lhs.width - rhs.width, height: lhs.height - rhs.height) + } +} + +extension CGRect { + var topLeading: CGPoint { + get { CGPoint(x: minX, y: minY) } + set { + let delta = newValue - CGPoint(x: minX, y: minY) + origin.x += delta.width + origin.y += delta.height + size.width -= delta.width + size.height -= delta.height + } + } + + var topTrailing: CGPoint { + get { CGPoint(x: maxX, y: minY) } + set { + let delta = newValue - CGPoint(x: maxX, y: minY) + origin.y += delta.height + size.width += delta.width + size.height -= delta.height + } + } + + var bottomLeading: CGPoint { + get { CGPoint(x: minX, y: maxY) } + set { + let delta = newValue - CGPoint(x: minX, y: maxY) + origin.x += delta.width + size.width -= delta.width + size.height += delta.height + } + } + + var bottomTrailing: CGPoint { + get { CGPoint(x: maxX, y: minY) } + set { + let delta = newValue - CGPoint(x: maxX, y: minY) + size.width += delta.width + size.height += delta.height + } + } +} diff --git a/Sources/LayoutInspector/LogEntriesGrid.swift b/Sources/LayoutInspector/LogEntriesGrid.swift index 52ad63f..369af10 100644 --- a/Sources/LayoutInspector/LogEntriesGrid.swift +++ b/Sources/LayoutInspector/LogEntriesGrid.swift @@ -19,7 +19,7 @@ public struct LogEntriesGrid: View { } public var body: some View { - ScrollView(.vertical) { + ScrollView([.vertical, .horizontal]) { Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 0, verticalSpacing: 0) { // Table header row GridRow { @@ -72,7 +72,6 @@ public struct LogEntriesGrid: View { } .padding(.vertical, 8) } - .background() } private func indentation(level: Int) -> some View { From b660a51ec00acbf7e7dbc85775d79f3c0375382c Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Tue, 22 Nov 2022 21:02:45 +0100 Subject: [PATCH 02/23] Get rid of LogStore.shared singleton --- Sources/LayoutInspector/DebugLayout.swift | 10 +- Sources/LayoutInspector/DebugLayoutImpl.swift | 119 ++++++++++-------- 2 files changed, 71 insertions(+), 58 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayout.swift b/Sources/LayoutInspector/DebugLayout.swift index 4d1a24a..00d8dd8 100644 --- a/Sources/LayoutInspector/DebugLayout.swift +++ b/Sources/LayoutInspector/DebugLayout.swift @@ -4,7 +4,7 @@ import SwiftUI extension View { /// Inspect the layout for this subtree. public func inspectLayout() -> some View { - self.modifier(InspectLayout()) + modifier(InspectLayout()) } /// Monitor the layout proposals and responses for this view and add them @@ -14,12 +14,6 @@ extension View { file: StaticString = #fileID, line: UInt = #line ) -> some View { - DebugLayout(label: label) { - self - } - .onAppear { - LogStore.shared.registerViewLabelAndWarnIfNotUnique(label, file: file, line: line) - } - .modifier(DebugLayoutSelectionHighlight(viewID: label)) + modifier(DebugLayoutModifier(label: label, file: file, line: line)) } } diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 0fc6640..c2d3f2e 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -2,15 +2,15 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct InspectLayout: ViewModifier { + @StateObject private var logStore: LogStore = .init() @State private var selectedView: String? = nil @State private var generation: Int = 0 @State private var frame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300) - @ObservedObject private var logStore = LogStore.shared private static let coordSpaceName = "InspectLayout" func body(content: Content) -> some View { - ClearDebugLayoutLog { + ClearDebugLayoutLog(logStore: logStore) { content .id(generation) .environment(\.debugLayoutSelectedViewID, selectedView) @@ -39,6 +39,7 @@ struct InspectLayout: ViewModifier { .offset(x: frame.minX, y: frame.minY) .coordinateSpace(name: Self.coordSpaceName) } + .environmentObject(logStore) } } @@ -160,11 +161,30 @@ struct Draggable: ViewModifier { } } +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct DebugLayoutModifier: ViewModifier { + var label: String + var file: StaticString + var line: UInt + @EnvironmentObject private var logStore: LogStore + + func body(content: Content) -> some View { + DebugLayout(label: label, logStore: logStore) { + content + } + .onAppear { + logStore.registerViewLabelAndWarnIfNotUnique(label, file: file, line: line) + } + .modifier(DebugLayoutSelectionHighlight(viewID: label)) + } +} + /// A custom layout that saves the layout proposals and responses for a view /// to a log. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct DebugLayout: Layout { var label: String + var logStore: LogStore func sizeThatFits( proposal: ProposedViewSize, @@ -172,9 +192,9 @@ struct DebugLayout: Layout { cache: inout () ) -> CGSize { assert(subviews.count == 1) - logLayoutStep(label, step: .proposal(proposal)) + logStore.logLayoutStep(label, step: .proposal(proposal)) let response = subviews[0].sizeThatFits(proposal) - logLayoutStep(label, step: .response(response)) + logStore.logLayoutStep(label, step: .response(response)) return response } @@ -192,6 +212,8 @@ struct DebugLayout: Layout { /// placed in the view tree. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct ClearDebugLayoutLog: Layout { + var logStore: LogStore + func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, @@ -199,8 +221,8 @@ struct ClearDebugLayoutLog: Layout { ) -> CGSize { assert(subviews.count == 1) DispatchQueue.main.async { - LogStore.shared.log.removeAll() - LogStore.shared.viewLabels.removeAll() + logStore.log.removeAll() + logStore.viewLabels.removeAll() } return subviews[0].sizeThatFits(proposal) } @@ -216,59 +238,56 @@ struct ClearDebugLayoutLog: Layout { } } -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -func logLayoutStep(_ label: String, step: LogEntry.Step) { - DispatchQueue.main.async { - guard let prevEntry = LogStore.shared.log.last else { - // First log entry → start at indent 0. - LogStore.shared.log.append(LogEntry(label: label, step: step, indent: 0)) - return - } - - var newEntry = LogEntry(label: label, step: step, indent: prevEntry.indent) - let isSameView = prevEntry.label == label - switch (isSameView, prevEntry.step, step) { - case (true, .proposal(let prop), .response(let resp)): - // Response follows immediately after proposal for the same view. - // → We want to display them in a single row. - // → Coalesce both layout steps. - LogStore.shared.log.removeLast() - newEntry = prevEntry - newEntry.step = .proposalAndResponse(proposal: prop, response: resp) - LogStore.shared.log.append(newEntry) - - case (_, .proposal, .proposal): - // A proposal follows a proposal → nested view → increment indent. - newEntry.indent += 1 - LogStore.shared.log.append(newEntry) - - case (_, .response, .response), - (_, .proposalAndResponse, .response): - // A response follows a response → last child returns to parent → decrement indent. - newEntry.indent -= 1 - LogStore.shared.log.append(newEntry) - - default: - // Keep current indentation. - LogStore.shared.log.append(newEntry) - } - } -} - @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public final class LogStore: ObservableObject { - public static let shared: LogStore = .init() - @Published public var log: [LogEntry] = [] var viewLabels: Set = [] func registerViewLabelAndWarnIfNotUnique(_ label: String, file: StaticString, line: UInt) { - DispatchQueue.main.async { - if self.viewLabels.contains(label) { + DispatchQueue.main.async { [self] in + if viewLabels.contains(label) { let message: StaticString = "Duplicate view label '%s' detected. Use unique labels in layoutStep() calls" runtimeWarning(message, [label], file: file, line: line) } - self.viewLabels.insert(label) + viewLabels.insert(label) + } + } + + func logLayoutStep(_ label: String, step: LogEntry.Step) { + DispatchQueue.main.async { [self] in + guard let prevEntry = log.last else { + // First log entry → start at indent 0. + log.append(LogEntry(label: label, step: step, indent: 0)) + return + } + + var newEntry = LogEntry(label: label, step: step, indent: prevEntry.indent) + let isSameView = prevEntry.label == label + switch (isSameView, prevEntry.step, step) { + case (true, .proposal(let prop), .response(let resp)): + // Response follows immediately after proposal for the same view. + // → We want to display them in a single row. + // → Coalesce both layout steps. + log.removeLast() + newEntry = prevEntry + newEntry.step = .proposalAndResponse(proposal: prop, response: resp) + log.append(newEntry) + + case (_, .proposal, .proposal): + // A proposal follows a proposal → nested view → increment indent. + newEntry.indent += 1 + log.append(newEntry) + + case (_, .response, .response), + (_, .proposalAndResponse, .response): + // A response follows a response → last child returns to parent → decrement indent. + newEntry.indent -= 1 + log.append(newEntry) + + default: + // Keep current indentation. + log.append(newEntry) + } } } } From e9ae34217265f3094a3e179da5e4eea9f6212d86 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Tue, 22 Nov 2022 21:04:23 +0100 Subject: [PATCH 03/23] Don't overflow if user resets the layout cache 9 quintillion times --- Sources/LayoutInspector/DebugLayoutImpl.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index c2d3f2e..4e0eeb3 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -19,7 +19,7 @@ struct InspectLayout: ViewModifier { LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) .safeAreaInset(edge: .bottom) { Button("Reset layout cache") { - generation += 1 + generation &+= 1 } .buttonStyle(.bordered) .frame(maxWidth: .infinity) From 9fc5f2b8cc577f500f5562511d0522a703ca76e9 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Tue, 22 Nov 2022 21:28:10 +0100 Subject: [PATCH 04/23] Clean up --- Sources/LayoutInspector/Binding+Util.swift | 15 ++ Sources/LayoutInspector/DebugLayoutImpl.swift | 118 ---------------- .../ResizableAndDraggableView.swift | 129 ++++++++++++++++++ Sources/LayoutInspector/WithState.swift | 48 +++++++ 4 files changed, 192 insertions(+), 118 deletions(-) create mode 100644 Sources/LayoutInspector/Binding+Util.swift create mode 100644 Sources/LayoutInspector/ResizableAndDraggableView.swift create mode 100644 Sources/LayoutInspector/WithState.swift diff --git a/Sources/LayoutInspector/Binding+Util.swift b/Sources/LayoutInspector/Binding+Util.swift new file mode 100644 index 0000000..7f293c0 --- /dev/null +++ b/Sources/LayoutInspector/Binding+Util.swift @@ -0,0 +1,15 @@ +import SwiftUI + +extension Binding { + func transform( + getter: @escaping (Value) -> Target, + setter: @escaping (inout Value, Target, Transaction) -> Void + ) -> Binding { + Binding( + get: { getter(self.wrappedValue) }, + set: { newValue, transaction in + setter(&self.wrappedValue, newValue, transaction) + } + ) + } +} diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 4e0eeb3..e4f0bf7 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -43,124 +43,6 @@ struct InspectLayout: ViewModifier { } } -extension View { - @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - func resizableAndDraggable( - frame: Binding, - coordinateSpace: CoordinateSpace - ) -> some View { - modifier(ResizableAndDraggableFrame( - frame: frame, - coordinateSpace: coordinateSpace - )) - } -} - -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -struct ResizableAndDraggableFrame: ViewModifier { - @Binding var frame: CGRect - var coordinateSpace: CoordinateSpace - - @State private var isDragging: Bool = false - @State private var isResizing: Bool = false - - private static let chromeWidth: CGFloat = 10 - - func body(content: Content) -> some View { - content - .padding(.vertical, 20) - .overlay { - ZStack(alignment: .top) { - Rectangle() - .frame(height: 20) - .foregroundStyle(isDragging ? .pink : .yellow) - .draggable(isDragging: $isDragging, point: $frame.origin, coordinateSpace: coordinateSpace) - - let resizeHandle = Rectangle() - .fill(.green) - .frame(width: 20, height: 20) - resizeHandle - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .draggable(isDragging: $isResizing, point: $frame.topLeading, coordinateSpace: coordinateSpace) - resizeHandle - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .draggable(isDragging: $isResizing, point: $frame.topTrailing, coordinateSpace: coordinateSpace) - resizeHandle - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) - .draggable(isDragging: $isResizing, point: $frame.bottomLeading, coordinateSpace: coordinateSpace) - resizeHandle - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) - .draggable(isDragging: $isResizing, point: $frame.bottomTrailing, coordinateSpace: coordinateSpace) - } - } - - } -} - -extension Binding { - func transform( - getter: @escaping (Value) -> Target, - setter: @escaping (inout Value, Target, Transaction) -> Void - ) -> Binding { - Binding( - get: { getter(self.wrappedValue) }, - set: { newValue, transaction in - setter(&self.wrappedValue, newValue, transaction) - } - ) - } -} - -extension View { - @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - func draggable(isDragging: Binding, offset: Binding, coordinateSpace: CoordinateSpace) -> some View { - modifier(Draggable(isDragging: isDragging, offset: offset, coordinateSpace: coordinateSpace)) - } - - @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - func draggable(isDragging: Binding, point pointBinding: Binding, coordinateSpace: CoordinateSpace) -> some View { - let sizeBinding = pointBinding.transform( - getter: { pt -> CGSize in CGSize(width: pt.x, height: pt.y) }, - setter: { pt, newValue, _ in - pt = CGPoint(x: newValue.width, y: newValue.height) - } - ) - return draggable(isDragging: isDragging, offset: sizeBinding, coordinateSpace: coordinateSpace) - } -} - -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -struct Draggable: ViewModifier { - @Binding var isDragging: Bool - @Binding var offset: CGSize - var coordinateSpace: CoordinateSpace - - @State private var lastTranslation: CGSize? = nil - - func body(content: Content) -> some View { - content - .gesture(dragGesture) - } - - private var dragGesture: some Gesture { - DragGesture(coordinateSpace: coordinateSpace) - .onChanged { gv in - isDragging = true - if let last = lastTranslation { - let delta = gv.translation - last - offset = offset + delta - lastTranslation = gv.translation - } else { - lastTranslation = gv.translation - } - } - .onEnded { gv in - lastTranslation = nil - isDragging = false - } - } -} - @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct DebugLayoutModifier: ViewModifier { var label: String diff --git a/Sources/LayoutInspector/ResizableAndDraggableView.swift b/Sources/LayoutInspector/ResizableAndDraggableView.swift new file mode 100644 index 0000000..fa6a4cb --- /dev/null +++ b/Sources/LayoutInspector/ResizableAndDraggableView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +extension View { + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + func resizableAndDraggable( + frame: Binding, + coordinateSpace: CoordinateSpace + ) -> some View { + modifier(ResizableAndDraggableFrame( + frame: frame, + coordinateSpace: coordinateSpace + )) + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct ResizableAndDraggableFrame: ViewModifier { + @Binding var frame: CGRect + var coordinateSpace: CoordinateSpace + + @State private var isDragging: Bool = false + @State private var isResizing: Bool = false + + private static let titleBarHeight: CGFloat = 20 + + func body(content: Content) -> some View { + content + .padding(.vertical, Self.titleBarHeight) + .overlay { + ZStack(alignment: .top) { + Rectangle() + .frame(height: Self.titleBarHeight) + .foregroundStyle(isDragging ? .pink : .yellow) + .draggable(isDragging: $isDragging, point: $frame.origin, coordinateSpace: coordinateSpace) + + let resizeHandle = Rectangle() + .fill(.green) + .frame(width: 20, height: 20) + resizeHandle + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .draggable(isDragging: $isResizing, point: $frame.topLeading, coordinateSpace: coordinateSpace) + resizeHandle + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .draggable(isDragging: $isResizing, point: $frame.topTrailing, coordinateSpace: coordinateSpace) + resizeHandle + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .draggable(isDragging: $isResizing, point: $frame.bottomLeading, coordinateSpace: coordinateSpace) + resizeHandle + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .draggable(isDragging: $isResizing, point: $frame.bottomTrailing, coordinateSpace: coordinateSpace) + } + } + + } +} + +extension View { + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + func draggable(isDragging: Binding, offset: Binding, coordinateSpace: CoordinateSpace) -> some View { + modifier(Draggable(isDragging: isDragging, offset: offset, coordinateSpace: coordinateSpace)) + } + + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + func draggable(isDragging: Binding, point pointBinding: Binding, coordinateSpace: CoordinateSpace) -> some View { + let sizeBinding = pointBinding.transform( + getter: { pt -> CGSize in CGSize(width: pt.x, height: pt.y) }, + setter: { pt, newValue, _ in + pt = CGPoint(x: newValue.width, y: newValue.height) + } + ) + return draggable(isDragging: isDragging, offset: sizeBinding, coordinateSpace: coordinateSpace) + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct Draggable: ViewModifier { + @Binding var isDragging: Bool + @Binding var offset: CGSize + var coordinateSpace: CoordinateSpace + + @State private var lastTranslation: CGSize? = nil + + func body(content: Content) -> some View { + content + .gesture(dragGesture) + } + + private var dragGesture: some Gesture { + DragGesture(coordinateSpace: coordinateSpace) + .onChanged { gv in + isDragging = true + if let last = lastTranslation { + let delta = gv.translation - last + offset = offset + delta + lastTranslation = gv.translation + } else { + lastTranslation = gv.translation + } + } + .onEnded { gv in + lastTranslation = nil + isDragging = false + } + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct ResizableAndDraggable_Previews: PreviewProvider { + static var previews: some View { + WithState(CGRect(x: 20, y: 20, width: 300, height: 300)) { $frame in + Color.clear + .overlay { + Text("This view is resizable and draggable") + .padding(20) + .multilineTextAlignment(.center) + } + .resizableAndDraggable(frame: $frame, coordinateSpace: .named("coordSpace")) + .background { + Rectangle() + .fill(.ultraThickMaterial) + .shadow(radius: 5) + } + .frame(width: frame.width, height: frame.height) + .offset(x: frame.minX, y: frame.minY) + .coordinateSpace(name: "coordSpace") + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} diff --git a/Sources/LayoutInspector/WithState.swift b/Sources/LayoutInspector/WithState.swift new file mode 100644 index 0000000..12d58d0 --- /dev/null +++ b/Sources/LayoutInspector/WithState.swift @@ -0,0 +1,48 @@ +#if canImport(SwiftUI) + +import SwiftUI + +/// A wrapper view that takes a constant value and provides it to its child as a mutable `Binding`. +/// +/// Useful in Previews for previewing views that require a binding. You can't easily declare a +/// `@State` variable in a PreviewProvider, and a `Binding.constant` doesn’t always cut it if you +/// want to test a view’s dynamic behavior. +/// +/// Example: +/// +/// struct InteractiveStepper_Previews: PreviewProvider { +/// static var previews: some View { +/// WithState(5) { counterBinding in +/// Stepper(value: counterBinding, in: 0...10) { +/// Text("Counter: \(counterBinding.wrappedValue)") +/// } +/// } +/// } +/// } +/// +public struct WithState: View { + @State private var value: Value + let content: (Binding) -> Content + + public init(_ value: Value, @ViewBuilder content: @escaping (Binding) -> Content) { + self._value = State(wrappedValue: value) + self.content = content + } + + public var body: some View { + content($value) + } +} + +struct StatefulWrapper_Previews: PreviewProvider { + static var previews: some View { + WithState(5) { counterBinding in + Stepper(value: counterBinding, in: 0...10) { + Text("Counter: \(counterBinding.wrappedValue)") + } + .padding() + } + } +} + +#endif From 0f4da85041bb344e72e499b7a4233d9f9db57240 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Tue, 22 Nov 2022 21:50:04 +0100 Subject: [PATCH 05/23] Fix CGRect computations --- Sources/LayoutInspector/Geometry.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/LayoutInspector/Geometry.swift b/Sources/LayoutInspector/Geometry.swift index d658db9..c14c5f9 100644 --- a/Sources/LayoutInspector/Geometry.swift +++ b/Sources/LayoutInspector/Geometry.swift @@ -29,6 +29,7 @@ extension CGRect { origin.y += delta.height size.width -= delta.width size.height -= delta.height + self = self.standardized } } @@ -39,6 +40,7 @@ extension CGRect { origin.y += delta.height size.width += delta.width size.height -= delta.height + self = self.standardized } } @@ -49,6 +51,7 @@ extension CGRect { origin.x += delta.width size.width -= delta.width size.height += delta.height + self = self.standardized } } @@ -58,6 +61,7 @@ extension CGRect { let delta = newValue - CGPoint(x: maxX, y: minY) size.width += delta.width size.height += delta.height + self = self.standardized } } } From f579cf71c46d18bc8872cde6df2a416d9651a3cc Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Tue, 22 Nov 2022 22:50:17 +0100 Subject: [PATCH 06/23] Initial position of Inspector UI, info button, make isDragging optional --- Sources/LayoutInspector/DebugLayoutImpl.swift | 55 ++++++++++++---- .../ResizableAndDraggableView.swift | 64 +++++++++++++------ Sources/LayoutInspector/ViewMeasuring.swift | 27 ++++++++ 3 files changed, 114 insertions(+), 32 deletions(-) create mode 100644 Sources/LayoutInspector/ViewMeasuring.swift diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index e4f0bf7..8649385 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -5,7 +5,9 @@ struct InspectLayout: ViewModifier { @StateObject private var logStore: LogStore = .init() @State private var selectedView: String? = nil @State private var generation: Int = 0 - @State private var frame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300) + @State private var inspectorFrame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300) + @State private var contentSize: CGSize? = nil + @State private var isPresentingInfoPanel: Bool = false private static let coordSpaceName = "InspectLayout" @@ -14,33 +16,60 @@ struct InspectLayout: ViewModifier { content .id(generation) .environment(\.debugLayoutSelectedViewID, selectedView) + .measureSize { size in + // Move inspector UI below the inspected view initially + if contentSize == nil { + inspectorFrame.origin.y = size.height + 8 + } + contentSize = size + } } .overlay(alignment: .topLeading) { LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) .safeAreaInset(edge: .bottom) { - Button("Reset layout cache") { - generation &+= 1 - } - .buttonStyle(.bordered) - .frame(maxWidth: .infinity) - .background() - .backgroundStyle(.thickMaterial) + toolbar } .resizableAndDraggable( - frame: $frame, + frame: $inspectorFrame, coordinateSpace: .named(Self.coordSpaceName) ) .background { - Rectangle() - .fill(.thickMaterial) + Rectangle().fill(.thickMaterial) .shadow(radius: 5) } - .frame(width: frame.width, height: frame.height) - .offset(x: frame.minX, y: frame.minY) + .frame(width: inspectorFrame.width, height: inspectorFrame.height) + .offset(x: inspectorFrame.minX, y: inspectorFrame.minY) .coordinateSpace(name: Self.coordSpaceName) } .environmentObject(logStore) } + + @ViewBuilder private var toolbar: some View { + HStack { + Button("Reset layout cache") { + generation &+= 1 + } + Spacer() + Button { + isPresentingInfoPanel.toggle() + } label: { + Image(systemName: "info.circle") + } + .popover(isPresented: $isPresentingInfoPanel) { + VStack(alignment: .leading) { + Text("SwiftUI Layout Inspector") + .font(.headline) + Link("GitHub", destination: URL(string: "https://github.com/ole/swiftui-layout-inspector")!) + } + .padding() + } + .presentationDetents([.medium]) + } + .padding() + .frame(maxWidth: .infinity) + .background() + .backgroundStyle(.thinMaterial) + } } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) diff --git a/Sources/LayoutInspector/ResizableAndDraggableView.swift b/Sources/LayoutInspector/ResizableAndDraggableView.swift index fa6a4cb..4249aa7 100644 --- a/Sources/LayoutInspector/ResizableAndDraggableView.swift +++ b/Sources/LayoutInspector/ResizableAndDraggableView.swift @@ -18,63 +18,89 @@ struct ResizableAndDraggableFrame: ViewModifier { @Binding var frame: CGRect var coordinateSpace: CoordinateSpace - @State private var isDragging: Bool = false - @State private var isResizing: Bool = false - private static let titleBarHeight: CGFloat = 20 func body(content: Content) -> some View { content - .padding(.vertical, Self.titleBarHeight) + .padding(.top, Self.titleBarHeight) .overlay { ZStack(alignment: .top) { Rectangle() .frame(height: Self.titleBarHeight) - .foregroundStyle(isDragging ? .pink : .yellow) - .draggable(isDragging: $isDragging, point: $frame.origin, coordinateSpace: coordinateSpace) + .foregroundStyle(.tertiary) + .draggable(point: $frame.origin, coordinateSpace: coordinateSpace) - let resizeHandle = Rectangle() - .fill(.green) + let resizeHandle = ResizeHandle() + .fill(.secondary) .frame(width: 20, height: 20) resizeHandle .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .draggable(isDragging: $isResizing, point: $frame.topLeading, coordinateSpace: coordinateSpace) + .draggable(point: $frame.topLeading, coordinateSpace: coordinateSpace) resizeHandle + .rotationEffect(.degrees(90)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .draggable(isDragging: $isResizing, point: $frame.topTrailing, coordinateSpace: coordinateSpace) + .draggable(point: $frame.topTrailing, coordinateSpace: coordinateSpace) resizeHandle + .rotationEffect(.degrees(-90)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) - .draggable(isDragging: $isResizing, point: $frame.bottomLeading, coordinateSpace: coordinateSpace) + .draggable(point: $frame.bottomLeading, coordinateSpace: coordinateSpace) resizeHandle + .rotationEffect(.degrees(180)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) - .draggable(isDragging: $isResizing, point: $frame.bottomTrailing, coordinateSpace: coordinateSpace) + .draggable(point: $frame.bottomTrailing, coordinateSpace: coordinateSpace) } } + } +} +struct ResizeHandle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: rect.topLeading) + path.addLine(to: rect.topTrailing) + path.addLine(to: rect.bottomLeading) + path.closeSubpath() + return path } } extension View { @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - func draggable(isDragging: Binding, offset: Binding, coordinateSpace: CoordinateSpace) -> some View { - modifier(Draggable(isDragging: isDragging, offset: offset, coordinateSpace: coordinateSpace)) + func draggable( + isDragging: Binding? = nil, + offset: Binding, + coordinateSpace: CoordinateSpace + ) -> some View { + modifier(Draggable( + isDragging: isDragging, + offset: offset, + coordinateSpace: coordinateSpace + )) } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) - func draggable(isDragging: Binding, point pointBinding: Binding, coordinateSpace: CoordinateSpace) -> some View { + func draggable( + isDragging: Binding? = nil, + point pointBinding: Binding, + coordinateSpace: CoordinateSpace + ) -> some View { let sizeBinding = pointBinding.transform( getter: { pt -> CGSize in CGSize(width: pt.x, height: pt.y) }, setter: { pt, newValue, _ in pt = CGPoint(x: newValue.width, y: newValue.height) } ) - return draggable(isDragging: isDragging, offset: sizeBinding, coordinateSpace: coordinateSpace) + return draggable( + isDragging: isDragging, + offset: sizeBinding, + coordinateSpace: coordinateSpace + ) } } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct Draggable: ViewModifier { - @Binding var isDragging: Bool + var isDragging: Binding? @Binding var offset: CGSize var coordinateSpace: CoordinateSpace @@ -88,7 +114,7 @@ struct Draggable: ViewModifier { private var dragGesture: some Gesture { DragGesture(coordinateSpace: coordinateSpace) .onChanged { gv in - isDragging = true + isDragging?.wrappedValue = true if let last = lastTranslation { let delta = gv.translation - last offset = offset + delta @@ -99,7 +125,7 @@ struct Draggable: ViewModifier { } .onEnded { gv in lastTranslation = nil - isDragging = false + isDragging?.wrappedValue = false } } } diff --git a/Sources/LayoutInspector/ViewMeasuring.swift b/Sources/LayoutInspector/ViewMeasuring.swift new file mode 100644 index 0000000..2f869ba --- /dev/null +++ b/Sources/LayoutInspector/ViewMeasuring.swift @@ -0,0 +1,27 @@ +import SwiftUI + +extension View { + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) + func measureSize(onChange: @escaping (CGSize) -> Void) -> some View { + self + .background { + GeometryReader { geometry in + Color.clear + .preference(key: SizePreferenceKey.self, value: geometry.size) + } + } + .onPreferenceChange(SizePreferenceKey.self) { size in + if let size { + onChange(size) + } + } + } +} + +enum SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize? = nil + + static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { + value = value ?? nextValue() + } +} From 017a590ad0f6155a626145a65a46f026efb566f4 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Wed, 23 Nov 2022 23:00:54 +0100 Subject: [PATCH 07/23] Prettier Inspector UI window chrome --- Sources/LayoutInspector/DebugLayoutImpl.swift | 33 ++++--- Sources/LayoutInspector/Geometry.swift | 8 ++ .../ResizableAndDraggableView.swift | 88 +++++++++++++------ 3 files changed, 91 insertions(+), 38 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 8649385..75eeb08 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -25,18 +25,7 @@ struct InspectLayout: ViewModifier { } } .overlay(alignment: .topLeading) { - LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) - .safeAreaInset(edge: .bottom) { - toolbar - } - .resizableAndDraggable( - frame: $inspectorFrame, - coordinateSpace: .named(Self.coordSpaceName) - ) - .background { - Rectangle().fill(.thickMaterial) - .shadow(radius: 5) - } + inspectorUI .frame(width: inspectorFrame.width, height: inspectorFrame.height) .offset(x: inspectorFrame.minX, y: inspectorFrame.minY) .coordinateSpace(name: Self.coordSpaceName) @@ -44,6 +33,26 @@ struct InspectLayout: ViewModifier { .environmentObject(logStore) } + @ViewBuilder private var inspectorUI: some View { + LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) + .safeAreaInset(edge: .bottom) { + toolbar + } + .resizableAndDraggable( + frame: $inspectorFrame, + coordinateSpace: .named(Self.coordSpaceName) + ) + .background { + Rectangle().fill(.thickMaterial) + .shadow(radius: 5) + } + .cornerRadius(4) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(.quaternary) + } + } + @ViewBuilder private var toolbar: some View { HStack { Button("Reset layout cache") { diff --git a/Sources/LayoutInspector/Geometry.swift b/Sources/LayoutInspector/Geometry.swift index c14c5f9..c6ab2ed 100644 --- a/Sources/LayoutInspector/Geometry.swift +++ b/Sources/LayoutInspector/Geometry.swift @@ -1,4 +1,5 @@ import CoreGraphics +import SwiftUI extension CGPoint { static func + (lhs: CGPoint, rhs: CGPoint) -> CGSize { @@ -64,4 +65,11 @@ extension CGRect { self = self.standardized } } + + func unitPoint(_ unitPoint: UnitPoint) -> CGPoint { + CGPoint( + x: minX + (maxX - minX) * unitPoint.x, + y: minY + (maxY - minY) * unitPoint.y + ) + } } diff --git a/Sources/LayoutInspector/ResizableAndDraggableView.swift b/Sources/LayoutInspector/ResizableAndDraggableView.swift index 4249aa7..0e4bf5d 100644 --- a/Sources/LayoutInspector/ResizableAndDraggableView.swift +++ b/Sources/LayoutInspector/ResizableAndDraggableView.swift @@ -25,41 +25,77 @@ struct ResizableAndDraggableFrame: ViewModifier { .padding(.top, Self.titleBarHeight) .overlay { ZStack(alignment: .top) { - Rectangle() - .frame(height: Self.titleBarHeight) - .foregroundStyle(.tertiary) - .draggable(point: $frame.origin, coordinateSpace: coordinateSpace) - - let resizeHandle = ResizeHandle() - .fill(.secondary) - .frame(width: 20, height: 20) - resizeHandle - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .draggable(point: $frame.topLeading, coordinateSpace: coordinateSpace) - resizeHandle - .rotationEffect(.degrees(90)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .draggable(point: $frame.topTrailing, coordinateSpace: coordinateSpace) - resizeHandle - .rotationEffect(.degrees(-90)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) - .draggable(point: $frame.bottomLeading, coordinateSpace: coordinateSpace) - resizeHandle - .rotationEffect(.degrees(180)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) - .draggable(point: $frame.bottomTrailing, coordinateSpace: coordinateSpace) + titleBar + resizeHandles } } } + + @ViewBuilder private var titleBar: some View { + Rectangle() + .frame(height: Self.titleBarHeight) + .foregroundStyle(.ultraThinMaterial) + .overlay { + Text("Layout Inspector") + .font(.footnote) + } + .overlay(alignment: .bottom) { + Rectangle() + .foregroundStyle(.quaternary) + .frame(height: 1) + } + .draggable(point: $frame.origin, coordinateSpace: coordinateSpace) + .help("Move") + } + + @ViewBuilder private var resizeHandles: some View { + let resizeHandle = TriangleStripes() + .fill(Color(white: 0.5).opacity(0.5)) + .frame(width: 15, height: 15) + .frame(width: Self.titleBarHeight, height: Self.titleBarHeight, alignment: .topLeading) + .contentShape(Rectangle()) + .help("Resize") + resizeHandle + .draggable(point: $frame.topLeading, coordinateSpace: coordinateSpace) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + resizeHandle + .rotationEffect(.degrees(90)) + .draggable(point: $frame.topTrailing, coordinateSpace: coordinateSpace) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + resizeHandle + .rotationEffect(.degrees(-90)) + .draggable(point: $frame.bottomLeading, coordinateSpace: coordinateSpace) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + resizeHandle + .rotationEffect(.degrees(180)) + .draggable(point: $frame.bottomTrailing, coordinateSpace: coordinateSpace) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + } } -struct ResizeHandle: Shape { +struct TriangleStripes: Shape { func path(in rect: CGRect) -> Path { + let stripeCount = 4 + let spacing: CGFloat = 0.15 // in unit points + let stripeWidth = (1 - CGFloat(stripeCount - 1) * spacing) / CGFloat(stripeCount) + var path = Path() + // First stripe is special path.move(to: rect.topLeading) - path.addLine(to: rect.topTrailing) - path.addLine(to: rect.bottomLeading) + path.addLine(to: rect.unitPoint(.init(x: stripeWidth, y: 0))) + path.addLine(to: rect.unitPoint(.init(x: 0, y: stripeWidth))) path.closeSubpath() + + for stripe in 1.. Date: Wed, 23 Nov 2022 23:38:45 +0100 Subject: [PATCH 08/23] Redesign demo app: List of examples, select one to view --- DemoApp/App.swift | 2 +- DemoApp/ContentView.swift | 104 ------------------ DemoApp/FixedSize.swift | 19 ++++ DemoApp/HStack.swift | 21 ++++ .../project.pbxproj | 20 +++- DemoApp/Padding.swift | 19 ++++ DemoApp/RootView.swift | 76 +++++++++++++ 7 files changed, 152 insertions(+), 109 deletions(-) delete mode 100644 DemoApp/ContentView.swift create mode 100644 DemoApp/FixedSize.swift create mode 100644 DemoApp/HStack.swift create mode 100644 DemoApp/Padding.swift create mode 100644 DemoApp/RootView.swift diff --git a/DemoApp/App.swift b/DemoApp/App.swift index b31e21c..776f7c7 100644 --- a/DemoApp/App.swift +++ b/DemoApp/App.swift @@ -4,7 +4,7 @@ import SwiftUI struct LayoutInspectorDemoApp: App { var body: some Scene { WindowGroup { - ContentView() + RootView() } } } diff --git a/DemoApp/ContentView.swift b/DemoApp/ContentView.swift deleted file mode 100644 index b7871d1..0000000 --- a/DemoApp/ContentView.swift +++ /dev/null @@ -1,104 +0,0 @@ -import LayoutInspector -import SwiftUI - -struct ContentView: View { - var body: some View { - /// The view tree whose layout you want to inspect. Add `.layoutStep()` - /// calls at each point where you want to inspect the layout algorithm, - /// i.e. what sizes are being proposed and returned. We call these - /// **inspection points**. - VStack { - Inspector { - hStackExample - } - } - } - - var paddingExample: some View { - Text("Hello world") - .layoutStep("Text") - .padding(10) - .layoutStep("padding") - .border(Color.green) - .layoutStep("border") - } - - var hStackExample: some View { - HStack(spacing: 10) { - Rectangle().fill(.green) - .layoutStep("green") - Text("Hello world") - .layoutStep("Text") - Rectangle().fill(.yellow) - .layoutStep("yellow") - } - .layoutStep("HStack") - } - - var fixedSizeExample: some View { - Text("Lorem ipsum dolor sit amet") - .layoutStep("Text") - .fixedSize() - .layoutStep("fixedSize") - .frame(width: 100) - .layoutStep("frame") - .border(Color.green) - } -} - -struct Inspector: View { - @ViewBuilder var subject: Subject - - @State private var width: CGFloat = 300 - @State private var height: CGFloat = 100 - - private var roundedWidth: CGFloat { width.rounded() } - private var roundedHeight: CGFloat { height.rounded() } - - var body: some View { - VStack { - VStack { - subject - .inspectLayout() - .frame(width: roundedWidth, height: roundedHeight) - .overlay { - Rectangle() - .strokeBorder(style: StrokeStyle(dash: [5])) - } - - Spacer() - - VStack { - LabeledContent { - HStack { - Slider(value: $width, in: 0...500) - Stepper("Width", value: $width) - } - .labelsHidden() - } label: { - Text("W \(roundedWidth, format: .number.precision(.fractionLength(0)))") - .monospacedDigit() - } - - LabeledContent { - HStack { - Slider(value: $height, in: 0...500) - Stepper("Height", value: $height) - } - .labelsHidden() - } label: { - Text("H \(roundedHeight, format: .number.precision(.fractionLength(0)))") - .monospacedDigit() - } - } - } - .padding() - } - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/DemoApp/FixedSize.swift b/DemoApp/FixedSize.swift new file mode 100644 index 0000000..22bcdb3 --- /dev/null +++ b/DemoApp/FixedSize.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct FixedSizeExample: View { + var body: some View { + Text("Lorem ipsum dolor sit amet") + .layoutStep("Text") + .fixedSize() + .layoutStep("fixedSize") + .frame(width: 100) + .layoutStep("frame") + .border(Color.green) + } +} + +struct FixedSizeExample_Previews: PreviewProvider { + static var previews: some View { + FixedSizeExample() + } +} diff --git a/DemoApp/HStack.swift b/DemoApp/HStack.swift new file mode 100644 index 0000000..6d3ea8f --- /dev/null +++ b/DemoApp/HStack.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct HStackExample: View { + var body: some View { + HStack(spacing: 10) { + Rectangle().fill(.green) + .layoutStep("green") + Text("Hello world") + .layoutStep("Text") + Rectangle().fill(.yellow) + .layoutStep("yellow") + } + .layoutStep("HStack") + } +} + +struct HStackExample_Previews: PreviewProvider { + static var previews: some View { + HStackExample() + } +} diff --git a/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj b/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj index 6baa4ef..dd9dbae 100644 --- a/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj +++ b/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj @@ -8,19 +8,25 @@ /* Begin PBXBuildFile section */ 5D2245F028D8FDB400E84C7D /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2245EF28D8FDB400E84C7D /* App.swift */; }; - 5D2245F228D8FDB400E84C7D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2245F128D8FDB400E84C7D /* ContentView.swift */; }; 5D2245F428D8FDB500E84C7D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D2245F328D8FDB500E84C7D /* Assets.xcassets */; }; 5D8410B529212EF600117429 /* LayoutInspector in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8410B429212EF600117429 /* LayoutInspector */; }; + 5DECF062292ED17700261A6B /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF061292ED17700261A6B /* RootView.swift */; }; + 5DECF064292ED47500261A6B /* Padding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF063292ED47500261A6B /* Padding.swift */; }; + 5DECF066292ED4A800261A6B /* HStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF065292ED4A800261A6B /* HStack.swift */; }; + 5DECF068292ED69200261A6B /* FixedSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF067292ED69200261A6B /* FixedSize.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 5D2245EC28D8FDB400E84C7D /* LayoutInspectorDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LayoutInspectorDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5D2245EF28D8FDB400E84C7D /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; - 5D2245F128D8FDB400E84C7D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5D2245F328D8FDB500E84C7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5D2245F528D8FDB500E84C7D /* Entitlements.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.entitlements; sourceTree = ""; }; 5D5B048F2921290A00758B21 /* SwiftUI-LayoutInspector */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "SwiftUI-LayoutInspector"; path = ..; sourceTree = ""; }; 5DB6BE4F28DE4D4E00280F5E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 5DECF061292ED17700261A6B /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + 5DECF063292ED47500261A6B /* Padding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Padding.swift; sourceTree = ""; }; + 5DECF065292ED4A800261A6B /* HStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HStack.swift; sourceTree = ""; }; + 5DECF067292ED69200261A6B /* FixedSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixedSize.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -41,7 +47,10 @@ 5D5B048F2921290A00758B21 /* SwiftUI-LayoutInspector */, 5DB6BE4F28DE4D4E00280F5E /* README.md */, 5D2245EF28D8FDB400E84C7D /* App.swift */, - 5D2245F128D8FDB400E84C7D /* ContentView.swift */, + 5DECF061292ED17700261A6B /* RootView.swift */, + 5DECF063292ED47500261A6B /* Padding.swift */, + 5DECF067292ED69200261A6B /* FixedSize.swift */, + 5DECF065292ED4A800261A6B /* HStack.swift */, 5D2245F328D8FDB500E84C7D /* Assets.xcassets */, 5D2245F528D8FDB500E84C7D /* Entitlements.entitlements */, 5D2245ED28D8FDB400E84C7D /* Products */, @@ -138,8 +147,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5D2245F228D8FDB400E84C7D /* ContentView.swift in Sources */, 5D2245F028D8FDB400E84C7D /* App.swift in Sources */, + 5DECF062292ED17700261A6B /* RootView.swift in Sources */, + 5DECF066292ED4A800261A6B /* HStack.swift in Sources */, + 5DECF068292ED69200261A6B /* FixedSize.swift in Sources */, + 5DECF064292ED47500261A6B /* Padding.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Padding.swift b/DemoApp/Padding.swift new file mode 100644 index 0000000..71a29a6 --- /dev/null +++ b/DemoApp/Padding.swift @@ -0,0 +1,19 @@ +import LayoutInspector +import SwiftUI + +struct PaddingExample: View { + var body: some View { + Text("Hello world") + .layoutStep("Text") + .padding(10) + .layoutStep("padding") + .border(Color.green) + .layoutStep("border") + } +} + +struct PaddingExample_Previews: PreviewProvider { + static var previews: some View { + PaddingExample() + } +} diff --git a/DemoApp/RootView.swift b/DemoApp/RootView.swift new file mode 100644 index 0000000..f647373 --- /dev/null +++ b/DemoApp/RootView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +enum CaseStudy: String, CaseIterable, Identifiable { + case padding = "padding" + case fixedSize = "fixedSize" + case hStack = "HStack" + + var id: Self { + self + } + + var label: String { + rawValue + } +} + +struct RootView: View { + @State private var selection: CaseStudy? + @State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + Sidebar(selection: $selection) + } detail: { + if let caseStudy = selection { + MainContent(caseStudy: caseStudy) + } else { + Text("Select an item in the sidebar") + .foregroundStyle(.secondary) + } + } + + + } +} + +struct Sidebar: View { + @Binding var selection: CaseStudy? + + var body: some View { + List(CaseStudy.allCases, selection: $selection) { caseStudy in + Text(caseStudy.label) + } + .navigationTitle("Layout Inspector") + } +} + +struct MainContent: View { + var caseStudy: CaseStudy + + var body: some View { + ZStack { + switch caseStudy { + case .padding: + PaddingExample() + case .fixedSize: + FixedSizeExample() + case .hStack: + HStackExample() + } + } + .inspectLayout() + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(caseStudy.label) + #if !os(macOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } +} + +struct RootView_Previews: PreviewProvider { + static var previews: some View { + RootView() + } +} From 96a327808b80c7f0ace813d1dae162fe0e2c6b32 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Wed, 23 Nov 2022 23:50:54 +0100 Subject: [PATCH 09/23] Inspector scroll view positioning/sizing --- Sources/LayoutInspector/DebugLayoutImpl.swift | 43 +++++---- Sources/LayoutInspector/LogEntriesGrid.swift | 88 +++++++++---------- 2 files changed, 70 insertions(+), 61 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 75eeb08..455e60c 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -7,6 +7,7 @@ struct InspectLayout: ViewModifier { @State private var generation: Int = 0 @State private var inspectorFrame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300) @State private var contentSize: CGSize? = nil + @State private var tableSize: CGSize? = nil @State private var isPresentingInfoPanel: Bool = false private static let coordSpaceName = "InspectLayout" @@ -34,23 +35,31 @@ struct InspectLayout: ViewModifier { } @ViewBuilder private var inspectorUI: some View { - LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) - .safeAreaInset(edge: .bottom) { - toolbar - } - .resizableAndDraggable( - frame: $inspectorFrame, - coordinateSpace: .named(Self.coordSpaceName) - ) - .background { - Rectangle().fill(.thickMaterial) - .shadow(radius: 5) - } - .cornerRadius(4) - .overlay { - RoundedRectangle(cornerRadius: 4) - .strokeBorder(.quaternary) - } + ScrollView([.vertical, .horizontal]) { + LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) + .measureSize { size in + tableSize = size + } + } + .frame(maxWidth: tableSize?.width, maxHeight: tableSize?.height) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .safeAreaInset(edge: .bottom) { + toolbar + } + .font(.subheadline) + .resizableAndDraggable( + frame: $inspectorFrame, + coordinateSpace: .named(Self.coordSpaceName) + ) + .background { + Rectangle().fill(.thickMaterial) + .shadow(radius: 5) + } + .cornerRadius(4) + .overlay { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(.quaternary) + } } @ViewBuilder private var toolbar: some View { diff --git a/Sources/LayoutInspector/LogEntriesGrid.swift b/Sources/LayoutInspector/LogEntriesGrid.swift index 369af10..72c5f80 100644 --- a/Sources/LayoutInspector/LogEntriesGrid.swift +++ b/Sources/LayoutInspector/LogEntriesGrid.swift @@ -19,59 +19,59 @@ public struct LogEntriesGrid: View { } public var body: some View { - ScrollView([.vertical, .horizontal]) { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 0, verticalSpacing: 0) { - // Table header row - GridRow { - Text("View") - Text("Proposal") - Text("Response") - } - .font(.headline) + Grid( + alignment: .leadingFirstTextBaseline, + horizontalSpacing: 0, + verticalSpacing: 0 + ) { + // Table header row + GridRow { + Text("View") + Text("Proposal") + Text("Response") + } + .bold() + .padding(.vertical, Self.tableRowVerticalPadding) + .padding(.horizontal, Self.tableRowHorizontalPadding) + + // Table header separator line + Rectangle().fill(.secondary) + .frame(height: 1) + .gridCellUnsizedAxes(.horizontal) .padding(.vertical, Self.tableRowVerticalPadding) .padding(.horizontal, Self.tableRowHorizontalPadding) - // Table header separator line - Rectangle().fill(.secondary) - .frame(height: 1) - .gridCellUnsizedAxes(.horizontal) - .padding(.vertical, Self.tableRowVerticalPadding) - .padding(.horizontal, Self.tableRowHorizontalPadding) + // Table rows + ForEach(logEntries) { item in + let isSelected = highlight == item.label + GridRow { + HStack(spacing: 0) { + indentation(level: item.indent) + Text(item.label) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - // Table rows - ForEach(logEntries) { item in - let isSelected = highlight == item.label - GridRow { - HStack(spacing: 0) { - indentation(level: item.indent) - Text(item.label) - .font(.body) - } + Text(item.proposal?.pretty ?? "…") + .monospacedDigit() + .fixedSize() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - Text(item.proposal?.pretty ?? "…") - .monospacedDigit() - .fixedSize() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - - Text(item.response?.pretty ?? "…") - .monospacedDigit() - .fixedSize() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - } - .font(.callout) - .padding(.vertical, Self.tableRowVerticalPadding) - .padding(.horizontal, Self.tableRowHorizontalPadding) - .foregroundColor(isSelected ? .white : nil) - .background(isSelected ? Color.accentColor : .clear) - .contentShape(Rectangle()) - .onTapGesture { - highlight = isSelected ? nil : item.label - } + Text(item.response?.pretty ?? "…") + .monospacedDigit() + .fixedSize() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .padding(.vertical, Self.tableRowVerticalPadding) + .padding(.horizontal, Self.tableRowHorizontalPadding) + .foregroundColor(isSelected ? .white : nil) + .background(isSelected ? Color.accentColor : .clear) + .contentShape(Rectangle()) + .onTapGesture { + highlight = isSelected ? nil : item.label } } - .padding(.vertical, 8) } + .padding(.vertical, 8) } private func indentation(level: Int) -> some View { From ff22a5bf73947bb616ea345a94934a4b3ab7da87 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 24 Nov 2022 23:25:56 +0100 Subject: [PATCH 10/23] Fix bug in CGRect.bottomTrailing --- Sources/LayoutInspector/Geometry.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/LayoutInspector/Geometry.swift b/Sources/LayoutInspector/Geometry.swift index c6ab2ed..4823dcf 100644 --- a/Sources/LayoutInspector/Geometry.swift +++ b/Sources/LayoutInspector/Geometry.swift @@ -57,9 +57,9 @@ extension CGRect { } var bottomTrailing: CGPoint { - get { CGPoint(x: maxX, y: minY) } + get { CGPoint(x: maxX, y: maxY) } set { - let delta = newValue - CGPoint(x: maxX, y: minY) + let delta = newValue - CGPoint(x: maxX, y: maxY) size.width += delta.width size.height += delta.height self = self.standardized From 1b021183aab162d72e2d776bd550ea919844117c Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Mon, 2 Jan 2023 10:36:45 +0100 Subject: [PATCH 11/23] Fix layout log not updating correctly on view changes --- DemoApp/FixedSize.swift | 1 + DemoApp/HStack.swift | 2 ++ DemoApp/Padding.swift | 1 + DemoApp/RootView.swift | 1 - 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/DemoApp/FixedSize.swift b/DemoApp/FixedSize.swift index 22bcdb3..c22e5d3 100644 --- a/DemoApp/FixedSize.swift +++ b/DemoApp/FixedSize.swift @@ -9,6 +9,7 @@ struct FixedSizeExample: View { .frame(width: 100) .layoutStep("frame") .border(Color.green) + .inspectLayout() } } diff --git a/DemoApp/HStack.swift b/DemoApp/HStack.swift index 6d3ea8f..46aa8f5 100644 --- a/DemoApp/HStack.swift +++ b/DemoApp/HStack.swift @@ -11,6 +11,8 @@ struct HStackExample: View { .layoutStep("yellow") } .layoutStep("HStack") + .inspectLayout() + .frame(maxHeight: 200) } } diff --git a/DemoApp/Padding.swift b/DemoApp/Padding.swift index 71a29a6..2cbc835 100644 --- a/DemoApp/Padding.swift +++ b/DemoApp/Padding.swift @@ -9,6 +9,7 @@ struct PaddingExample: View { .layoutStep("padding") .border(Color.green) .layoutStep("border") + .inspectLayout() } } diff --git a/DemoApp/RootView.swift b/DemoApp/RootView.swift index f647373..cf57061 100644 --- a/DemoApp/RootView.swift +++ b/DemoApp/RootView.swift @@ -59,7 +59,6 @@ struct MainContent: View { HStackExample() } } - .inspectLayout() .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle(caseStudy.label) From 16c4a9602b881a4be389c2bdd7fb34bec4953ca0 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Mon, 2 Jan 2023 11:10:07 +0100 Subject: [PATCH 12/23] Show runtime warning when using layoutStep() without inspectLayout() Closes #9 --- Sources/LayoutInspector/DebugLayoutImpl.swift | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 455e60c..5e6c0f8 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -31,6 +31,7 @@ struct InspectLayout: ViewModifier { .offset(x: inspectorFrame.minX, y: inspectorFrame.minY) .coordinateSpace(name: Self.coordSpaceName) } + .environment(\.didCallInspectLayout, true) .environmentObject(logStore) } @@ -90,21 +91,43 @@ struct InspectLayout: ViewModifier { } } +enum DidCallInspectLayout: EnvironmentKey { + static var defaultValue: Bool = false +} + +extension EnvironmentValues { + var didCallInspectLayout: Bool { + get { self[DidCallInspectLayout.self] } + set { self[DidCallInspectLayout.self] = newValue } + } +} + @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct DebugLayoutModifier: ViewModifier { var label: String var file: StaticString var line: UInt + @Environment(\.didCallInspectLayout) private var didCallInspectLayout + /// The log store for the current inspectLayout() subtree. + /// + /// - Important: You must verify that `didCallInspectLayout == true` before accessing + /// this property. Failure to do so will result in a crash as the object won't be + /// in the environment. @EnvironmentObject private var logStore: LogStore func body(content: Content) -> some View { - DebugLayout(label: label, logStore: logStore) { + if didCallInspectLayout { + DebugLayout(label: label, logStore: logStore) { + content + } + .onAppear { + logStore.registerViewLabelAndWarnIfNotUnique(label, file: file, line: line) + } + .modifier(DebugLayoutSelectionHighlight(viewID: label)) + } else { + let _ = runtimeWarning("%@:%llu: Calling .layoutStep() without a matching .inspectLayout() is illegal. Add .inspectLayout() as an ancestor of the view tree you want to inspect.", [String(describing: file), UInt64(line)], file: file, line: line) content } - .onAppear { - logStore.registerViewLabelAndWarnIfNotUnique(label, file: file, line: line) - } - .modifier(DebugLayoutSelectionHighlight(viewID: label)) } } From dff4e789ac0f0d40eb3fa34923ab53e73d09c6de Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Mon, 2 Jan 2023 11:10:35 +0100 Subject: [PATCH 13/23] Show offending file and line in runtime warnings --- Sources/LayoutInspector/DebugLayoutImpl.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 5e6c0f8..801f526 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -198,8 +198,8 @@ public final class LogStore: ObservableObject { func registerViewLabelAndWarnIfNotUnique(_ label: String, file: StaticString, line: UInt) { DispatchQueue.main.async { [self] in if viewLabels.contains(label) { - let message: StaticString = "Duplicate view label '%s' detected. Use unique labels in layoutStep() calls" - runtimeWarning(message, [label], file: file, line: line) + let message: StaticString = "%@:%llu: Duplicate view label '%@' detected. Use unique labels in .layoutStep() calls" + runtimeWarning(message, [String(describing: file), UInt64(line), label], file: file, line: line) } viewLabels.insert(label) } From 81dc86a53c26103723246e6a2a6c14369405f5eb Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Mon, 2 Jan 2023 14:07:16 +0100 Subject: [PATCH 14/23] Add case study for .background --- DemoApp/Background.swift | 21 +++++++++++++++++++ .../project.pbxproj | 4 ++++ DemoApp/RootView.swift | 5 +++-- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 DemoApp/Background.swift diff --git a/DemoApp/Background.swift b/DemoApp/Background.swift new file mode 100644 index 0000000..a41f4ef --- /dev/null +++ b/DemoApp/Background.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct BackgroundExample: View { + var body: some View { + Text("Hello world") + .padding(10) + .layoutStep("Text and padding") + .background { + Color.blue + .layoutStep("background child") + } + .layoutStep("background") + .inspectLayout() + } +} + +struct BackgroundExample_Previews: PreviewProvider { + static var previews: some View { + BackgroundExample() + } +} diff --git a/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj b/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj index dd9dbae..164f242 100644 --- a/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj +++ b/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 5D2245F028D8FDB400E84C7D /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2245EF28D8FDB400E84C7D /* App.swift */; }; 5D2245F428D8FDB500E84C7D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D2245F328D8FDB500E84C7D /* Assets.xcassets */; }; 5D8410B529212EF600117429 /* LayoutInspector in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8410B429212EF600117429 /* LayoutInspector */; }; + 5DD5C0372962E8AE0041B966 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD5C0362962E8AE0041B966 /* Background.swift */; }; 5DECF062292ED17700261A6B /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF061292ED17700261A6B /* RootView.swift */; }; 5DECF064292ED47500261A6B /* Padding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF063292ED47500261A6B /* Padding.swift */; }; 5DECF066292ED4A800261A6B /* HStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF065292ED4A800261A6B /* HStack.swift */; }; @@ -23,6 +24,7 @@ 5D2245F528D8FDB500E84C7D /* Entitlements.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.entitlements; sourceTree = ""; }; 5D5B048F2921290A00758B21 /* SwiftUI-LayoutInspector */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "SwiftUI-LayoutInspector"; path = ..; sourceTree = ""; }; 5DB6BE4F28DE4D4E00280F5E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 5DD5C0362962E8AE0041B966 /* Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Background.swift; sourceTree = ""; }; 5DECF061292ED17700261A6B /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 5DECF063292ED47500261A6B /* Padding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Padding.swift; sourceTree = ""; }; 5DECF065292ED4A800261A6B /* HStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HStack.swift; sourceTree = ""; }; @@ -49,6 +51,7 @@ 5D2245EF28D8FDB400E84C7D /* App.swift */, 5DECF061292ED17700261A6B /* RootView.swift */, 5DECF063292ED47500261A6B /* Padding.swift */, + 5DD5C0362962E8AE0041B966 /* Background.swift */, 5DECF067292ED69200261A6B /* FixedSize.swift */, 5DECF065292ED4A800261A6B /* HStack.swift */, 5D2245F328D8FDB500E84C7D /* Assets.xcassets */, @@ -152,6 +155,7 @@ 5DECF066292ED4A800261A6B /* HStack.swift in Sources */, 5DECF068292ED69200261A6B /* FixedSize.swift in Sources */, 5DECF064292ED47500261A6B /* Padding.swift in Sources */, + 5DD5C0372962E8AE0041B966 /* Background.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/RootView.swift b/DemoApp/RootView.swift index cf57061..971090f 100644 --- a/DemoApp/RootView.swift +++ b/DemoApp/RootView.swift @@ -2,6 +2,7 @@ import SwiftUI enum CaseStudy: String, CaseIterable, Identifiable { case padding = "padding" + case background = "background" case fixedSize = "fixedSize" case hStack = "HStack" @@ -29,8 +30,6 @@ struct RootView: View { .foregroundStyle(.secondary) } } - - } } @@ -53,6 +52,8 @@ struct MainContent: View { switch caseStudy { case .padding: PaddingExample() + case .background: + BackgroundExample() case .fixedSize: FixedSizeExample() case .hStack: From 95e6aa16b3f6fa418be9bf5883920aabedbb62ff Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 10:03:57 +0100 Subject: [PATCH 15/23] Documentation --- Sources/LayoutInspector/DebugLayoutImpl.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 801f526..450f069 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -96,6 +96,10 @@ enum DidCallInspectLayout: EnvironmentKey { } extension EnvironmentValues { + /// Marker to signal that a valid LogStore environment object has been injected. + /// Clients that use `@EnvironmentObject var logStore: LogStore` must verify that + /// this value is true before accessing the environment object because it may be + /// missing. var didCallInspectLayout: Bool { get { self[DidCallInspectLayout.self] } set { self[DidCallInspectLayout.self] = newValue } From aed98031750661dd1a657554b78efab8f78404a6 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 10:39:38 +0100 Subject: [PATCH 16/23] Do not observe LogStore on the top level (InspectLayout) Avoids an infinite view update loop. --- Sources/LayoutInspector/DebugLayout.swift | 2 +- Sources/LayoutInspector/DebugLayoutImpl.swift | 42 +++++++++---------- Sources/LayoutInspector/LogEntriesGrid.swift | 11 ++--- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayout.swift b/Sources/LayoutInspector/DebugLayout.swift index 00d8dd8..9d6a50c 100644 --- a/Sources/LayoutInspector/DebugLayout.swift +++ b/Sources/LayoutInspector/DebugLayout.swift @@ -4,7 +4,7 @@ import SwiftUI extension View { /// Inspect the layout for this subtree. public func inspectLayout() -> some View { - modifier(InspectLayout()) + modifier(InspectLayout(logStore: LogStore())) } /// Monitor the layout proposals and responses for this view and add them diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 450f069..96c88b0 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -2,7 +2,8 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct InspectLayout: ViewModifier { - @StateObject private var logStore: LogStore = .init() + // Don't observe LogStore. Avoids an infinite update loop. + var logStore: LogStore @State private var selectedView: String? = nil @State private var generation: Int = 0 @State private var inspectorFrame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300) @@ -31,13 +32,12 @@ struct InspectLayout: ViewModifier { .offset(x: inspectorFrame.minX, y: inspectorFrame.minY) .coordinateSpace(name: Self.coordSpaceName) } - .environment(\.didCallInspectLayout, true) - .environmentObject(logStore) + .environment(\.logStore, logStore) } @ViewBuilder private var inspectorUI: some View { ScrollView([.vertical, .horizontal]) { - LogEntriesGrid(logEntries: logStore.log, highlight: $selectedView) + LogEntriesGrid(logStore: logStore, highlight: $selectedView) .measureSize { size in tableSize = size } @@ -91,18 +91,16 @@ struct InspectLayout: ViewModifier { } } -enum DidCallInspectLayout: EnvironmentKey { - static var defaultValue: Bool = false +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +enum LogStoreKey: EnvironmentKey { + static var defaultValue: LogStore? = nil } +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) extension EnvironmentValues { - /// Marker to signal that a valid LogStore environment object has been injected. - /// Clients that use `@EnvironmentObject var logStore: LogStore` must verify that - /// this value is true before accessing the environment object because it may be - /// missing. - var didCallInspectLayout: Bool { - get { self[DidCallInspectLayout.self] } - set { self[DidCallInspectLayout.self] = newValue } + var logStore: LogStore? { + get { self[LogStoreKey.self] } + set { self[LogStoreKey.self] = newValue } } } @@ -111,16 +109,11 @@ struct DebugLayoutModifier: ViewModifier { var label: String var file: StaticString var line: UInt - @Environment(\.didCallInspectLayout) private var didCallInspectLayout - /// The log store for the current inspectLayout() subtree. - /// - /// - Important: You must verify that `didCallInspectLayout == true` before accessing - /// this property. Failure to do so will result in a crash as the object won't be - /// in the environment. - @EnvironmentObject private var logStore: LogStore + // Using @Environment rather than @EnvironmentObject because we don't want to observe this. + @Environment(\.logStore) var logStore: LogStore? func body(content: Content) -> some View { - if didCallInspectLayout { + if let logStore { DebugLayout(label: label, logStore: logStore) { content } @@ -196,9 +189,14 @@ struct ClearDebugLayoutLog: Layout { @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public final class LogStore: ObservableObject { - @Published public var log: [LogEntry] = [] + @Published public var log: [LogEntry] var viewLabels: Set = [] + init(log: [LogEntry] = []) { + self.log = log + self.viewLabels = Set(log.map(\.label)) + } + func registerViewLabelAndWarnIfNotUnique(_ label: String, file: StaticString, line: UInt) { DispatchQueue.main.async { [self] in if viewLabels.contains(label) { diff --git a/Sources/LayoutInspector/LogEntriesGrid.swift b/Sources/LayoutInspector/LogEntriesGrid.swift index 72c5f80..774a438 100644 --- a/Sources/LayoutInspector/LogEntriesGrid.swift +++ b/Sources/LayoutInspector/LogEntriesGrid.swift @@ -2,14 +2,14 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) public struct LogEntriesGrid: View { - var logEntries: [LogEntry] + @ObservedObject var logStore: LogStore @Binding var highlight: String? private static let tableRowHorizontalPadding: CGFloat = 8 private static let tableRowVerticalPadding: CGFloat = 4 - public init(logEntries: [LogEntry], highlight: Binding? = nil) { - self.logEntries = logEntries + public init(logStore: LogStore, highlight: Binding? = nil) { + self._logStore = ObservedObject(initialValue: logStore) if let binding = highlight { self._highlight = binding } else { @@ -42,7 +42,7 @@ public struct LogEntriesGrid: View { .padding(.horizontal, Self.tableRowHorizontalPadding) // Table rows - ForEach(logEntries) { item in + ForEach(logStore.log) { item in let isSelected = highlight == item.label GridRow { HStack(spacing: 0) { @@ -92,6 +92,7 @@ public struct LogEntriesGrid: View { @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct LogEntriesGrid_Previews: PreviewProvider { static var previews: some View { - LogEntriesGrid(logEntries: sampleLogEntries) + let logStore = LogStore(log: sampleLogEntries) + LogEntriesGrid(logStore: logStore) } } From 6b7cf846b9220e6563882b80f284ca6e18a964ef Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 10:41:14 +0100 Subject: [PATCH 17/23] Refactoring: move LogStore into its own file --- Sources/LayoutInspector/DebugLayoutImpl.swift | 59 ------------------ Sources/LayoutInspector/LogStore.swift | 61 +++++++++++++++++++ 2 files changed, 61 insertions(+), 59 deletions(-) create mode 100644 Sources/LayoutInspector/LogStore.swift diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 96c88b0..aa0cbe9 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -186,62 +186,3 @@ struct ClearDebugLayoutLog: Layout { subviews[0].place(at: bounds.origin, proposal: proposal) } } - -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -public final class LogStore: ObservableObject { - @Published public var log: [LogEntry] - var viewLabels: Set = [] - - init(log: [LogEntry] = []) { - self.log = log - self.viewLabels = Set(log.map(\.label)) - } - - func registerViewLabelAndWarnIfNotUnique(_ label: String, file: StaticString, line: UInt) { - DispatchQueue.main.async { [self] in - if viewLabels.contains(label) { - let message: StaticString = "%@:%llu: Duplicate view label '%@' detected. Use unique labels in .layoutStep() calls" - runtimeWarning(message, [String(describing: file), UInt64(line), label], file: file, line: line) - } - viewLabels.insert(label) - } - } - - func logLayoutStep(_ label: String, step: LogEntry.Step) { - DispatchQueue.main.async { [self] in - guard let prevEntry = log.last else { - // First log entry → start at indent 0. - log.append(LogEntry(label: label, step: step, indent: 0)) - return - } - - var newEntry = LogEntry(label: label, step: step, indent: prevEntry.indent) - let isSameView = prevEntry.label == label - switch (isSameView, prevEntry.step, step) { - case (true, .proposal(let prop), .response(let resp)): - // Response follows immediately after proposal for the same view. - // → We want to display them in a single row. - // → Coalesce both layout steps. - log.removeLast() - newEntry = prevEntry - newEntry.step = .proposalAndResponse(proposal: prop, response: resp) - log.append(newEntry) - - case (_, .proposal, .proposal): - // A proposal follows a proposal → nested view → increment indent. - newEntry.indent += 1 - log.append(newEntry) - - case (_, .response, .response), - (_, .proposalAndResponse, .response): - // A response follows a response → last child returns to parent → decrement indent. - newEntry.indent -= 1 - log.append(newEntry) - - default: - // Keep current indentation. - log.append(newEntry) - } - } - } -} diff --git a/Sources/LayoutInspector/LogStore.swift b/Sources/LayoutInspector/LogStore.swift new file mode 100644 index 0000000..338c6b5 --- /dev/null +++ b/Sources/LayoutInspector/LogStore.swift @@ -0,0 +1,61 @@ +import Combine +import Dispatch + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +public final class LogStore: ObservableObject { + @Published public var log: [LogEntry] + var viewLabels: Set = [] + + init(log: [LogEntry] = []) { + self.log = log + self.viewLabels = Set(log.map(\.label)) + } + + func registerViewLabelAndWarnIfNotUnique(_ label: String, file: StaticString, line: UInt) { + DispatchQueue.main.async { [self] in + if viewLabels.contains(label) { + let message: StaticString = "%@:%llu: Duplicate view label '%@' detected. Use unique labels in .layoutStep() calls" + runtimeWarning(message, [String(describing: file), UInt64(line), label], file: file, line: line) + } + viewLabels.insert(label) + } + } + + func logLayoutStep(_ label: String, step: LogEntry.Step) { + DispatchQueue.main.async { [self] in + guard let prevEntry = log.last else { + // First log entry → start at indent 0. + log.append(LogEntry(label: label, step: step, indent: 0)) + return + } + + var newEntry = LogEntry(label: label, step: step, indent: prevEntry.indent) + let isSameView = prevEntry.label == label + switch (isSameView, prevEntry.step, step) { + case (true, .proposal(let prop), .response(let resp)): + // Response follows immediately after proposal for the same view. + // → We want to display them in a single row. + // → Coalesce both layout steps. + log.removeLast() + newEntry = prevEntry + newEntry.step = .proposalAndResponse(proposal: prop, response: resp) + log.append(newEntry) + + case (_, .proposal, .proposal): + // A proposal follows a proposal → nested view → increment indent. + newEntry.indent += 1 + log.append(newEntry) + + case (_, .response, .response), + (_, .proposalAndResponse, .response): + // A response follows a response → last child returns to parent → decrement indent. + newEntry.indent -= 1 + log.append(newEntry) + + default: + // Keep current indentation. + log.append(newEntry) + } + } + } +} From ab82502332e116e2ace76a1625f4a754effe65c9 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 10:43:49 +0100 Subject: [PATCH 18/23] Many types don't have to be public --- Sources/LayoutInspector/LogEntriesGrid.swift | 6 +++--- Sources/LayoutInspector/LogEntriesTable.swift | 6 +++--- Sources/LayoutInspector/LogEntry.swift | 16 ++++++++-------- Sources/LayoutInspector/LogStore.swift | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/LayoutInspector/LogEntriesGrid.swift b/Sources/LayoutInspector/LogEntriesGrid.swift index 774a438..9c9f151 100644 --- a/Sources/LayoutInspector/LogEntriesGrid.swift +++ b/Sources/LayoutInspector/LogEntriesGrid.swift @@ -1,14 +1,14 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -public struct LogEntriesGrid: View { +struct LogEntriesGrid: View { @ObservedObject var logStore: LogStore @Binding var highlight: String? private static let tableRowHorizontalPadding: CGFloat = 8 private static let tableRowVerticalPadding: CGFloat = 4 - public init(logStore: LogStore, highlight: Binding? = nil) { + init(logStore: LogStore, highlight: Binding? = nil) { self._logStore = ObservedObject(initialValue: logStore) if let binding = highlight { self._highlight = binding @@ -18,7 +18,7 @@ public struct LogEntriesGrid: View { } } - public var body: some View { + var body: some View { Grid( alignment: .leadingFirstTextBaseline, horizontalSpacing: 0, diff --git a/Sources/LayoutInspector/LogEntriesTable.swift b/Sources/LayoutInspector/LogEntriesTable.swift index d8da785..cc69a05 100644 --- a/Sources/LayoutInspector/LogEntriesTable.swift +++ b/Sources/LayoutInspector/LogEntriesTable.swift @@ -1,12 +1,12 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -public struct LogEntriesTable: View { +struct LogEntriesTable: View { var logEntries: [LogEntry] @Binding var highlight: String? @State private var selectedRow: LogEntry.ID? = nil - public init(logEntries: [LogEntry], highlight: Binding? = nil) { + init(logEntries: [LogEntry], highlight: Binding? = nil) { self.logEntries = logEntries if let binding = highlight { self._highlight = binding @@ -16,7 +16,7 @@ public struct LogEntriesTable: View { } } - public var body: some View { + var body: some View { Table(logEntries, selection: $selectedRow) { TableColumn("View") { item in let shouldHighlight = highlight == item.label diff --git a/Sources/LayoutInspector/LogEntry.swift b/Sources/LayoutInspector/LogEntry.swift index d0382c7..06d65ac 100644 --- a/Sources/LayoutInspector/LogEntry.swift +++ b/Sources/LayoutInspector/LogEntry.swift @@ -1,19 +1,19 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -public struct LogEntry: Identifiable { - public enum Step { +struct LogEntry: Identifiable { + enum Step { case proposal(ProposedViewSize) case response(CGSize) case proposalAndResponse(proposal: ProposedViewSize, response: CGSize) } - public var id: UUID = .init() - public var label: String - public var step: Step - public var indent: Int + var id: UUID = .init() + var label: String + var step: Step + var indent: Int - public var proposal: ProposedViewSize? { + var proposal: ProposedViewSize? { switch step { case .proposal(let p): return p case .response(_): return nil @@ -21,7 +21,7 @@ public struct LogEntry: Identifiable { } } - public var response: CGSize? { + var response: CGSize? { switch step { case .proposal(_): return nil case .response(let r): return r diff --git a/Sources/LayoutInspector/LogStore.swift b/Sources/LayoutInspector/LogStore.swift index 338c6b5..ccb4e4a 100644 --- a/Sources/LayoutInspector/LogStore.swift +++ b/Sources/LayoutInspector/LogStore.swift @@ -2,8 +2,8 @@ import Combine import Dispatch @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -public final class LogStore: ObservableObject { - @Published public var log: [LogEntry] +final class LogStore: ObservableObject { + @Published var log: [LogEntry] var viewLabels: Set = [] init(log: [LogEntry] = []) { From c892115021797afb310b00221b447dd05df9c94b Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 10:43:57 +0100 Subject: [PATCH 19/23] Fix indentation in WithState --- Sources/LayoutInspector/WithState.swift | 50 ++++++++++++------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Sources/LayoutInspector/WithState.swift b/Sources/LayoutInspector/WithState.swift index 12d58d0..3655995 100644 --- a/Sources/LayoutInspector/WithState.swift +++ b/Sources/LayoutInspector/WithState.swift @@ -11,38 +11,38 @@ import SwiftUI /// Example: /// /// struct InteractiveStepper_Previews: PreviewProvider { -/// static var previews: some View { -/// WithState(5) { counterBinding in -/// Stepper(value: counterBinding, in: 0...10) { -/// Text("Counter: \(counterBinding.wrappedValue)") -/// } +/// static var previews: some View { +/// WithState(5) { counterBinding in +/// Stepper(value: counterBinding, in: 0...10) { +/// Text("Counter: \(counterBinding.wrappedValue)") +/// } +/// } /// } -/// } /// } /// -public struct WithState: View { - @State private var value: Value - let content: (Binding) -> Content - - public init(_ value: Value, @ViewBuilder content: @escaping (Binding) -> Content) { - self._value = State(wrappedValue: value) - self.content = content - } +struct WithState: View { + @State private var value: Value + let content: (Binding) -> Content - public var body: some View { - content($value) - } + init(_ value: Value, @ViewBuilder content: @escaping (Binding) -> Content) { + self._value = State(wrappedValue: value) + self.content = content + } + + var body: some View { + content($value) + } } -struct StatefulWrapper_Previews: PreviewProvider { - static var previews: some View { - WithState(5) { counterBinding in - Stepper(value: counterBinding, in: 0...10) { - Text("Counter: \(counterBinding.wrappedValue)") - } - .padding() +struct WithState_Previews: PreviewProvider { + static var previews: some View { + WithState(5) { counterBinding in + Stepper(value: counterBinding, in: 0...10) { + Text("Counter: \(counterBinding.wrappedValue)") + } + .padding() + } } - } } #endif From 037ce49b9bbb57d719f9cd9a9672ae3833090e84 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 11:10:25 +0100 Subject: [PATCH 20/23] Pass only actions to the types that perform the logging, not the entire LogStore This makes it clear that these parts of the view hierarchy only write to the LogStore. They don't need to be re-rendered when the LogStore publishes changes. --- Sources/LayoutInspector/DebugLayoutImpl.swift | 37 ++++++------------- Sources/LayoutInspector/LogStore.swift | 36 +++++++++++++++++- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index aa0cbe9..6404756 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -14,7 +14,7 @@ struct InspectLayout: ViewModifier { private static let coordSpaceName = "InspectLayout" func body(content: Content) -> some View { - ClearDebugLayoutLog(logStore: logStore) { + ClearDebugLayoutLog(actions: logStore.actions) { content .id(generation) .environment(\.debugLayoutSelectedViewID, selectedView) @@ -32,7 +32,7 @@ struct InspectLayout: ViewModifier { .offset(x: inspectorFrame.minX, y: inspectorFrame.minY) .coordinateSpace(name: Self.coordSpaceName) } - .environment(\.logStore, logStore) + .environment(\.debugLayoutActions, logStore.actions) } @ViewBuilder private var inspectorUI: some View { @@ -91,34 +91,20 @@ struct InspectLayout: ViewModifier { } } -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -enum LogStoreKey: EnvironmentKey { - static var defaultValue: LogStore? = nil -} - -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -extension EnvironmentValues { - var logStore: LogStore? { - get { self[LogStoreKey.self] } - set { self[LogStoreKey.self] = newValue } - } -} - @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct DebugLayoutModifier: ViewModifier { var label: String var file: StaticString var line: UInt - // Using @Environment rather than @EnvironmentObject because we don't want to observe this. - @Environment(\.logStore) var logStore: LogStore? + @Environment(\.debugLayoutActions) var actions: DebugLayoutActions? func body(content: Content) -> some View { - if let logStore { - DebugLayout(label: label, logStore: logStore) { + if let actions { + DebugLayout(label: label, actions: actions) { content } .onAppear { - logStore.registerViewLabelAndWarnIfNotUnique(label, file: file, line: line) + actions.registerViewLabelAndWarnIfNotUnique(label, file, line) } .modifier(DebugLayoutSelectionHighlight(viewID: label)) } else { @@ -133,7 +119,7 @@ struct DebugLayoutModifier: ViewModifier { @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct DebugLayout: Layout { var label: String - var logStore: LogStore + var actions: DebugLayoutActions func sizeThatFits( proposal: ProposedViewSize, @@ -141,9 +127,9 @@ struct DebugLayout: Layout { cache: inout () ) -> CGSize { assert(subviews.count == 1) - logStore.logLayoutStep(label, step: .proposal(proposal)) + actions.logLayoutStep(label, .proposal(proposal)) let response = subviews[0].sizeThatFits(proposal) - logStore.logLayoutStep(label, step: .response(response)) + actions.logLayoutStep(label, .response(response)) return response } @@ -161,7 +147,7 @@ struct DebugLayout: Layout { /// placed in the view tree. @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct ClearDebugLayoutLog: Layout { - var logStore: LogStore + var actions: DebugLayoutActions func sizeThatFits( proposal: ProposedViewSize, @@ -170,8 +156,7 @@ struct ClearDebugLayoutLog: Layout { ) -> CGSize { assert(subviews.count == 1) DispatchQueue.main.async { - logStore.log.removeAll() - logStore.viewLabels.removeAll() + actions.clearLog() } return subviews[0].sizeThatFits(proposal) } diff --git a/Sources/LayoutInspector/LogStore.swift b/Sources/LayoutInspector/LogStore.swift index ccb4e4a..08b7893 100644 --- a/Sources/LayoutInspector/LogStore.swift +++ b/Sources/LayoutInspector/LogStore.swift @@ -1,5 +1,4 @@ -import Combine -import Dispatch +import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) final class LogStore: ObservableObject { @@ -11,6 +10,19 @@ final class LogStore: ObservableObject { self.viewLabels = Set(log.map(\.label)) } + var actions: DebugLayoutActions { + DebugLayoutActions( + clearLog: clearLog, + registerViewLabelAndWarnIfNotUnique: registerViewLabelAndWarnIfNotUnique(_:file:line:), + logLayoutStep: logLayoutStep(_:step:) + ) + } + + func clearLog() { + log.removeAll() + viewLabels.removeAll() + } + func registerViewLabelAndWarnIfNotUnique(_ label: String, file: StaticString, line: UInt) { DispatchQueue.main.async { [self] in if viewLabels.contains(label) { @@ -59,3 +71,23 @@ final class LogStore: ObservableObject { } } } + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct DebugLayoutActions { + var clearLog: () -> Void + var registerViewLabelAndWarnIfNotUnique: (_ label: String, _ file: StaticString, _ line: UInt) -> Void + var logLayoutStep: (_ label: String, _ step: LogEntry.Step) -> Void +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +enum DebugLayoutActionsKey: EnvironmentKey { + static var defaultValue: DebugLayoutActions? = nil +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +extension EnvironmentValues { + var debugLayoutActions: DebugLayoutActions? { + get { self[DebugLayoutActionsKey.self] } + set { self[DebugLayoutActionsKey.self] = newValue } + } +} From 8e74e7acf779681554adf56e5724898685d03ea7 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 11:56:57 +0100 Subject: [PATCH 21/23] Split layout steps in .background example --- DemoApp/Background.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DemoApp/Background.swift b/DemoApp/Background.swift index a41f4ef..175b58c 100644 --- a/DemoApp/Background.swift +++ b/DemoApp/Background.swift @@ -3,8 +3,9 @@ import SwiftUI struct BackgroundExample: View { var body: some View { Text("Hello world") + .layoutStep("Text") .padding(10) - .layoutStep("Text and padding") + .layoutStep("padding") .background { Color.blue .layoutStep("background child") From 64ce4b1572ddc8940e3328292299a7cccf8e2c73 Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 11:59:27 +0100 Subject: [PATCH 22/23] Add @MainActor annotations --- Sources/LayoutInspector/DebugLayout.swift | 4 +- Sources/LayoutInspector/DebugLayoutImpl.swift | 10 ++- Sources/LayoutInspector/LogStore.swift | 65 +++++++++---------- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayout.swift b/Sources/LayoutInspector/DebugLayout.swift index 9d6a50c..13e1299 100644 --- a/Sources/LayoutInspector/DebugLayout.swift +++ b/Sources/LayoutInspector/DebugLayout.swift @@ -3,13 +3,13 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) extension View { /// Inspect the layout for this subtree. - public func inspectLayout() -> some View { + @MainActor public func inspectLayout() -> some View { modifier(InspectLayout(logStore: LogStore())) } /// Monitor the layout proposals and responses for this view and add them /// to the log. - public func layoutStep( + @MainActor public func layoutStep( _ label: String, file: StaticString = #fileID, line: UInt = #line diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index 6404756..cff8c48 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -1,6 +1,7 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +@MainActor struct InspectLayout: ViewModifier { // Don't observe LogStore. Avoids an infinite update loop. var logStore: LogStore @@ -92,6 +93,7 @@ struct InspectLayout: ViewModifier { } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +@MainActor struct DebugLayoutModifier: ViewModifier { var label: String var file: StaticString @@ -127,9 +129,13 @@ struct DebugLayout: Layout { cache: inout () ) -> CGSize { assert(subviews.count == 1) - actions.logLayoutStep(label, .proposal(proposal)) + DispatchQueue.main.async { + actions.logLayoutStep(label, .proposal(proposal)) + } let response = subviews[0].sizeThatFits(proposal) - actions.logLayoutStep(label, .response(response)) + DispatchQueue.main.async { + actions.logLayoutStep(label, .response(response)) + } return response } diff --git a/Sources/LayoutInspector/LogStore.swift b/Sources/LayoutInspector/LogStore.swift index 08b7893..5998e06 100644 --- a/Sources/LayoutInspector/LogStore.swift +++ b/Sources/LayoutInspector/LogStore.swift @@ -1,6 +1,7 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +@MainActor final class LogStore: ObservableObject { @Published var log: [LogEntry] var viewLabels: Set = [] @@ -34,49 +35,47 @@ final class LogStore: ObservableObject { } func logLayoutStep(_ label: String, step: LogEntry.Step) { - DispatchQueue.main.async { [self] in - guard let prevEntry = log.last else { - // First log entry → start at indent 0. - log.append(LogEntry(label: label, step: step, indent: 0)) - return - } + guard let prevEntry = log.last else { + // First log entry → start at indent 0. + log.append(LogEntry(label: label, step: step, indent: 0)) + return + } - var newEntry = LogEntry(label: label, step: step, indent: prevEntry.indent) - let isSameView = prevEntry.label == label - switch (isSameView, prevEntry.step, step) { - case (true, .proposal(let prop), .response(let resp)): - // Response follows immediately after proposal for the same view. - // → We want to display them in a single row. - // → Coalesce both layout steps. - log.removeLast() - newEntry = prevEntry - newEntry.step = .proposalAndResponse(proposal: prop, response: resp) - log.append(newEntry) + var newEntry = LogEntry(label: label, step: step, indent: prevEntry.indent) + let isSameView = prevEntry.label == label + switch (isSameView, prevEntry.step, step) { + case (true, .proposal(let prop), .response(let resp)): + // Response follows immediately after proposal for the same view. + // → We want to display them in a single row. + // → Coalesce both layout steps. + log.removeLast() + newEntry = prevEntry + newEntry.step = .proposalAndResponse(proposal: prop, response: resp) + log.append(newEntry) - case (_, .proposal, .proposal): - // A proposal follows a proposal → nested view → increment indent. - newEntry.indent += 1 - log.append(newEntry) + case (_, .proposal, .proposal): + // A proposal follows a proposal → nested view → increment indent. + newEntry.indent += 1 + log.append(newEntry) - case (_, .response, .response), - (_, .proposalAndResponse, .response): - // A response follows a response → last child returns to parent → decrement indent. - newEntry.indent -= 1 - log.append(newEntry) + case (_, .response, .response), + (_, .proposalAndResponse, .response): + // A response follows a response → last child returns to parent → decrement indent. + newEntry.indent -= 1 + log.append(newEntry) - default: - // Keep current indentation. - log.append(newEntry) - } + default: + // Keep current indentation. + log.append(newEntry) } } } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) struct DebugLayoutActions { - var clearLog: () -> Void - var registerViewLabelAndWarnIfNotUnique: (_ label: String, _ file: StaticString, _ line: UInt) -> Void - var logLayoutStep: (_ label: String, _ step: LogEntry.Step) -> Void + var clearLog: @MainActor () -> Void + var registerViewLabelAndWarnIfNotUnique: @MainActor (_ label: String, _ file: StaticString, _ line: UInt) -> Void + var logLayoutStep: @MainActor (_ label: String, _ step: LogEntry.Step) -> Void } @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) From bc5561beca28bbec74970599b728bbd20d0bffba Mon Sep 17 00:00:00 2001 From: Ole Begemann Date: Thu, 5 Jan 2023 12:07:28 +0100 Subject: [PATCH 23/23] Explicitly recreate LogStore on Reset Layout Cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes many (but not all) issues described in #10, particularly on iOS: - On iOS, the padding, background, and HStack examples work correctly now, except that the initial proposed size to the root view of the subtree is equal to the eventual actual size, not the available size of the container view. I don't understand this yet. - On iOS, the fixedSize example is still weirdly broken: the Text child of the .fixedSize() doesn't appear in the layout log. A caching thing? - On macOS it’s still very broken. I don't understand why macOS behaves so differently. --- Sources/LayoutInspector/DebugLayout.swift | 2 +- Sources/LayoutInspector/DebugLayoutImpl.swift | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/LayoutInspector/DebugLayout.swift b/Sources/LayoutInspector/DebugLayout.swift index 13e1299..b1dd019 100644 --- a/Sources/LayoutInspector/DebugLayout.swift +++ b/Sources/LayoutInspector/DebugLayout.swift @@ -4,7 +4,7 @@ import SwiftUI extension View { /// Inspect the layout for this subtree. @MainActor public func inspectLayout() -> some View { - modifier(InspectLayout(logStore: LogStore())) + modifier(InspectLayout()) } /// Monitor the layout proposals and responses for this view and add them diff --git a/Sources/LayoutInspector/DebugLayoutImpl.swift b/Sources/LayoutInspector/DebugLayoutImpl.swift index cff8c48..be15070 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -3,8 +3,8 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) @MainActor struct InspectLayout: ViewModifier { - // Don't observe LogStore. Avoids an infinite update loop. - var logStore: LogStore + // Don't observe LogStore. Avoids an infinite update loop when store publishes changes. + @State private var logStore: LogStore = .init() @State private var selectedView: String? = nil @State private var generation: Int = 0 @State private var inspectorFrame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300) @@ -34,6 +34,9 @@ struct InspectLayout: ViewModifier { .coordinateSpace(name: Self.coordSpaceName) } .environment(\.debugLayoutActions, logStore.actions) + .onChange(of: generation) { newValue in + logStore = LogStore() + } } @ViewBuilder private var inspectorUI: some View {