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/Background.swift b/DemoApp/Background.swift new file mode 100644 index 0000000..175b58c --- /dev/null +++ b/DemoApp/Background.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct BackgroundExample: View { + var body: some View { + Text("Hello world") + .layoutStep("Text") + .padding(10) + .layoutStep("padding") + .background { + Color.blue + .layoutStep("background child") + } + .layoutStep("background") + .inspectLayout() + } +} + +struct BackgroundExample_Previews: PreviewProvider { + static var previews: some View { + BackgroundExample() + } +} diff --git a/DemoApp/ContentView.swift b/DemoApp/ContentView.swift deleted file mode 100644 index 92b9e1d..0000000 --- a/DemoApp/ContentView.swift +++ /dev/null @@ -1,118 +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 - @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() } - - var body: some View { - VStack { - VStack { - subject - .inspectLayout(selection: selectedView) - .id(generation) - .frame(width: roundedWidth, height: roundedHeight) - .overlay { - Rectangle() - .strokeBorder(style: StrokeStyle(dash: [5])) - } - .padding(.bottom, 16) - - 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() - } - - 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 - } - } -} - -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..c22e5d3 --- /dev/null +++ b/DemoApp/FixedSize.swift @@ -0,0 +1,20 @@ +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) + .inspectLayout() + } +} + +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..46aa8f5 --- /dev/null +++ b/DemoApp/HStack.swift @@ -0,0 +1,23 @@ +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") + .inspectLayout() + .frame(maxHeight: 200) + } +} + +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..164f242 100644 --- a/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj +++ b/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj @@ -8,19 +8,27 @@ /* 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 */; }; + 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 */; }; + 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 = ""; }; + 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 = ""; }; + 5DECF067292ED69200261A6B /* FixedSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixedSize.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -41,7 +49,11 @@ 5D5B048F2921290A00758B21 /* SwiftUI-LayoutInspector */, 5DB6BE4F28DE4D4E00280F5E /* README.md */, 5D2245EF28D8FDB400E84C7D /* App.swift */, - 5D2245F128D8FDB400E84C7D /* ContentView.swift */, + 5DECF061292ED17700261A6B /* RootView.swift */, + 5DECF063292ED47500261A6B /* Padding.swift */, + 5DD5C0362962E8AE0041B966 /* Background.swift */, + 5DECF067292ED69200261A6B /* FixedSize.swift */, + 5DECF065292ED4A800261A6B /* HStack.swift */, 5D2245F328D8FDB500E84C7D /* Assets.xcassets */, 5D2245F528D8FDB500E84C7D /* Entitlements.entitlements */, 5D2245ED28D8FDB400E84C7D /* Products */, @@ -138,8 +150,12 @@ 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 */, + 5DD5C0372962E8AE0041B966 /* Background.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DemoApp/Padding.swift b/DemoApp/Padding.swift new file mode 100644 index 0000000..2cbc835 --- /dev/null +++ b/DemoApp/Padding.swift @@ -0,0 +1,20 @@ +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") + .inspectLayout() + } +} + +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..971090f --- /dev/null +++ b/DemoApp/RootView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +enum CaseStudy: String, CaseIterable, Identifiable { + case padding = "padding" + case background = "background" + 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 .background: + BackgroundExample() + case .fixedSize: + FixedSizeExample() + case .hStack: + HStackExample() + } + } + .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() + } +} 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/DebugLayout.swift b/Sources/LayoutInspector/DebugLayout.swift index dc47dc2..b1dd019 100644 --- a/Sources/LayoutInspector/DebugLayout.swift +++ b/Sources/LayoutInspector/DebugLayout.swift @@ -3,26 +3,17 @@ 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) + @MainActor public func inspectLayout() -> some View { + modifier(InspectLayout()) } /// 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 ) -> 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 5962bd7..be15070 100644 --- a/Sources/LayoutInspector/DebugLayoutImpl.swift +++ b/Sources/LayoutInspector/DebugLayoutImpl.swift @@ -1,10 +1,130 @@ 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 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) + @State private var contentSize: CGSize? = nil + @State private var tableSize: CGSize? = nil + @State private var isPresentingInfoPanel: Bool = false + + private static let coordSpaceName = "InspectLayout" + + func body(content: Content) -> some View { + ClearDebugLayoutLog(actions: logStore.actions) { + 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) { + inspectorUI + .frame(width: inspectorFrame.width, height: inspectorFrame.height) + .offset(x: inspectorFrame.minX, y: inspectorFrame.minY) + .coordinateSpace(name: Self.coordSpaceName) + } + .environment(\.debugLayoutActions, logStore.actions) + .onChange(of: generation) { newValue in + logStore = LogStore() + } + } + + @ViewBuilder private var inspectorUI: some View { + ScrollView([.vertical, .horizontal]) { + LogEntriesGrid(logStore: logStore, 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 { + 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, *) +@MainActor +struct DebugLayoutModifier: ViewModifier { + var label: String + var file: StaticString + var line: UInt + @Environment(\.debugLayoutActions) var actions: DebugLayoutActions? + + func body(content: Content) -> some View { + if let actions { + DebugLayout(label: label, actions: actions) { + content + } + .onAppear { + actions.registerViewLabelAndWarnIfNotUnique(label, file, 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 + } + } +} + /// 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 actions: DebugLayoutActions func sizeThatFits( proposal: ProposedViewSize, @@ -12,9 +132,13 @@ struct DebugLayout: Layout { cache: inout () ) -> CGSize { assert(subviews.count == 1) - logLayoutStep(label, step: .proposal(proposal)) + DispatchQueue.main.async { + actions.logLayoutStep(label, .proposal(proposal)) + } let response = subviews[0].sizeThatFits(proposal) - logLayoutStep(label, step: .response(response)) + DispatchQueue.main.async { + actions.logLayoutStep(label, .response(response)) + } return response } @@ -32,6 +156,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 actions: DebugLayoutActions + func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, @@ -39,8 +165,7 @@ struct ClearDebugLayoutLog: Layout { ) -> CGSize { assert(subviews.count == 1) DispatchQueue.main.async { - LogStore.shared.log.removeAll() - LogStore.shared.viewLabels.removeAll() + actions.clearLog() } return subviews[0].sizeThatFits(proposal) } @@ -55,60 +180,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, *) -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) { - 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) - } - } -} diff --git a/Sources/LayoutInspector/Geometry.swift b/Sources/LayoutInspector/Geometry.swift new file mode 100644 index 0000000..4823dcf --- /dev/null +++ b/Sources/LayoutInspector/Geometry.swift @@ -0,0 +1,75 @@ +import CoreGraphics +import SwiftUI + +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 + self = self.standardized + } + } + + 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 + self = self.standardized + } + } + + 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 + self = self.standardized + } + } + + var bottomTrailing: CGPoint { + get { CGPoint(x: maxX, y: maxY) } + set { + let delta = newValue - CGPoint(x: maxX, y: maxY) + size.width += delta.width + size.height += delta.height + 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/LogEntriesGrid.swift b/Sources/LayoutInspector/LogEntriesGrid.swift index 52ad63f..9c9f151 100644 --- a/Sources/LayoutInspector/LogEntriesGrid.swift +++ b/Sources/LayoutInspector/LogEntriesGrid.swift @@ -1,15 +1,15 @@ import SwiftUI @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -public struct LogEntriesGrid: View { - var logEntries: [LogEntry] +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(logEntries: [LogEntry], highlight: Binding? = nil) { - self.logEntries = logEntries + init(logStore: LogStore, highlight: Binding? = nil) { + self._logStore = ObservedObject(initialValue: logStore) if let binding = highlight { self._highlight = binding } else { @@ -18,61 +18,60 @@ public struct LogEntriesGrid: View { } } - public var body: some View { - ScrollView(.vertical) { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 0, verticalSpacing: 0) { - // Table header row - GridRow { - Text("View") - Text("Proposal") - Text("Response") - } - .font(.headline) + var body: some View { + 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(logStore.log) { 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) } - .background() + .padding(.vertical, 8) } private func indentation(level: Int) -> some View { @@ -93,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) } } 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 new file mode 100644 index 0000000..5998e06 --- /dev/null +++ b/Sources/LayoutInspector/LogStore.swift @@ -0,0 +1,92 @@ +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 = [] + + init(log: [LogEntry] = []) { + self.log = log + 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) { + 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) { + 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) + } + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct DebugLayoutActions { + 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, *) +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 } + } +} diff --git a/Sources/LayoutInspector/ResizableAndDraggableView.swift b/Sources/LayoutInspector/ResizableAndDraggableView.swift new file mode 100644 index 0000000..0e4bf5d --- /dev/null +++ b/Sources/LayoutInspector/ResizableAndDraggableView.swift @@ -0,0 +1,191 @@ +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 + + private static let titleBarHeight: CGFloat = 20 + + func body(content: Content) -> some View { + content + .padding(.top, Self.titleBarHeight) + .overlay { + ZStack(alignment: .top) { + 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 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.unitPoint(.init(x: stripeWidth, y: 0))) + path.addLine(to: rect.unitPoint(.init(x: 0, y: stripeWidth))) + path.closeSubpath() + + for stripe in 1..? = 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? = 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 + ) + } +} + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +struct Draggable: ViewModifier { + var isDragging: Binding? + @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?.wrappedValue = 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?.wrappedValue = 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/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() + } +} diff --git a/Sources/LayoutInspector/WithState.swift b/Sources/LayoutInspector/WithState.swift new file mode 100644 index 0000000..3655995 --- /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)") +/// } +/// } +/// } +/// } +/// +struct WithState: View { + @State private var value: Value + let content: (Binding) -> Content + + init(_ value: Value, @ViewBuilder content: @escaping (Binding) -> Content) { + self._value = State(wrappedValue: value) + self.content = content + } + + var body: some View { + content($value) + } +} + +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