├── .gitignore
├── .swiftpm
└── xcode
│ └── xcshareddata
│ └── xcschemes
│ └── LayoutInspector.xcscheme
├── Credits.md
├── DemoApp
├── App.swift
├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── Contents.json
├── Background.swift
├── Entitlements.entitlements
├── FixedSize.swift
├── HStack.swift
├── LayoutInspectorDemo.xcodeproj
│ ├── project.pbxproj
│ └── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
├── Padding.swift
├── README.md
└── RootView.swift
├── LayoutInspector.xcworkspace
├── contents.xcworkspacedata
└── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Package.swift
├── README.md
├── Sources
└── LayoutInspector
│ ├── Binding+Util.swift
│ ├── DebugLayout.swift
│ ├── DebugLayoutImpl.swift
│ ├── Formatting.swift
│ ├── Geometry.swift
│ ├── LogEntriesGrid.swift
│ ├── LogEntriesTable.swift
│ ├── LogEntry.swift
│ ├── LogStore.swift
│ ├── ResizableAndDraggableView.swift
│ ├── RuntimeWarnings.swift
│ ├── SelectedViewHighlight.swift
│ ├── ViewMeasuring.swift
│ └── WithState.swift
└── assets
└── LayoutInspector-screenshot.png
/.gitignore:
--------------------------------------------------------------------------------
1 | # macOS
2 | .DS_Store
3 |
4 | # Xcode
5 | xcuserdata/
6 |
7 | # SwiftPM
8 | .build/
9 |
10 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/LayoutInspector.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
44 |
50 |
51 |
57 |
58 |
59 |
60 |
62 |
63 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/Credits.md:
--------------------------------------------------------------------------------
1 | # Credits/Acknowledgements
2 |
3 | ## objc.io
4 |
5 | Layout inspection code based on [objc.io, Swift Talk 319, Inspecting HStack Layout (2022-08-26)](https://talk.objc.io/episodes/S01E319-inspecting-hstack-layout)
6 |
7 | License:
8 |
9 | MIT License
10 |
11 | Copyright (c) 2022 objc.io
12 |
13 | Permission is hereby granted, free of charge, to any person obtaining a copy
14 | of this software and associated documentation files (the "Software"), to deal
15 | in the Software without restriction, including without limitation the rights
16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17 | copies of the Software, and to permit persons to whom the Software is
18 | furnished to do so, subject to the following conditions:
19 |
20 | The above copyright notice and this permission notice shall be included in all
21 | copies or substantial portions of the Software.
22 |
23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29 | SOFTWARE.
30 |
31 | ---
32 |
33 | ## Point-Free
34 |
35 | Runtime warnings implementation based on [Point-Free, Unobtrusive runtime warnings for libraries (2022-01-03)](https://www.pointfree.co/blog/posts/70-unobtrusive-runtime-warnings-for-libraries))
36 |
37 | Source: [Point-Free, Swift Composable Architecture, RuntimeWarnings.swift](https://github.com/pointfreeco/swift-composable-architecture/blob/399bc83dcfc7bdcee99f7f6cc0a687ca29e8494b/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift)
38 |
39 | License:
40 |
41 | MIT License
42 |
43 | Copyright (c) 2020 Point-Free, Inc.
44 |
45 | Permission is hereby granted, free of charge, to any person obtaining a copy
46 | of this software and associated documentation files (the "Software"), to deal
47 | in the Software without restriction, including without limitation the rights
48 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
49 | copies of the Software, and to permit persons to whom the Software is
50 | furnished to do so, subject to the following conditions:
51 |
52 | The above copyright notice and this permission notice shall be included in all
53 | copies or substantial portions of the Software.
54 |
55 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
56 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
57 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
58 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
59 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
60 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
61 | SOFTWARE.
62 |
--------------------------------------------------------------------------------
/DemoApp/App.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct LayoutInspectorDemoApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | RootView()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/DemoApp/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/DemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | },
8 | {
9 | "idiom" : "mac",
10 | "scale" : "1x",
11 | "size" : "16x16"
12 | },
13 | {
14 | "idiom" : "mac",
15 | "scale" : "2x",
16 | "size" : "16x16"
17 | },
18 | {
19 | "idiom" : "mac",
20 | "scale" : "1x",
21 | "size" : "32x32"
22 | },
23 | {
24 | "idiom" : "mac",
25 | "scale" : "2x",
26 | "size" : "32x32"
27 | },
28 | {
29 | "idiom" : "mac",
30 | "scale" : "1x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "idiom" : "mac",
35 | "scale" : "2x",
36 | "size" : "128x128"
37 | },
38 | {
39 | "idiom" : "mac",
40 | "scale" : "1x",
41 | "size" : "256x256"
42 | },
43 | {
44 | "idiom" : "mac",
45 | "scale" : "2x",
46 | "size" : "256x256"
47 | },
48 | {
49 | "idiom" : "mac",
50 | "scale" : "1x",
51 | "size" : "512x512"
52 | },
53 | {
54 | "idiom" : "mac",
55 | "scale" : "2x",
56 | "size" : "512x512"
57 | }
58 | ],
59 | "info" : {
60 | "author" : "xcode",
61 | "version" : 1
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/DemoApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/DemoApp/Background.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BackgroundExample: View {
4 | var body: some View {
5 | Text("Hello world")
6 | .layoutStep("Text")
7 | .padding(10)
8 | .layoutStep("padding")
9 | .background {
10 | Color.blue
11 | .layoutStep("background child")
12 | }
13 | .layoutStep("background")
14 | .inspectLayout()
15 | }
16 | }
17 |
18 | struct BackgroundExample_Previews: PreviewProvider {
19 | static var previews: some View {
20 | BackgroundExample()
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/DemoApp/Entitlements.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/DemoApp/FixedSize.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct FixedSizeExample: View {
4 | var body: some View {
5 | Text("Lorem ipsum dolor sit amet")
6 | .layoutStep("Text")
7 | .fixedSize()
8 | .layoutStep("fixedSize")
9 | .frame(width: 100)
10 | .layoutStep("frame")
11 | .border(Color.green)
12 | .inspectLayout()
13 | }
14 | }
15 |
16 | struct FixedSizeExample_Previews: PreviewProvider {
17 | static var previews: some View {
18 | FixedSizeExample()
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/DemoApp/HStack.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct HStackExample: View {
4 | var body: some View {
5 | HStack(spacing: 10) {
6 | Rectangle().fill(.green)
7 | .layoutStep("green")
8 | Text("Hello world")
9 | .layoutStep("Text")
10 | Rectangle().fill(.yellow)
11 | .layoutStep("yellow")
12 | }
13 | .layoutStep("HStack")
14 | .inspectLayout()
15 | .frame(maxHeight: 200)
16 | }
17 | }
18 |
19 | struct HStackExample_Previews: PreviewProvider {
20 | static var previews: some View {
21 | HStackExample()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/DemoApp/LayoutInspectorDemo.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 56;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 5D2245F028D8FDB400E84C7D /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2245EF28D8FDB400E84C7D /* App.swift */; };
11 | 5D2245F428D8FDB500E84C7D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D2245F328D8FDB500E84C7D /* Assets.xcassets */; };
12 | 5D8410B529212EF600117429 /* LayoutInspector in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8410B429212EF600117429 /* LayoutInspector */; };
13 | 5DD5C0372962E8AE0041B966 /* Background.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD5C0362962E8AE0041B966 /* Background.swift */; };
14 | 5DECF062292ED17700261A6B /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF061292ED17700261A6B /* RootView.swift */; };
15 | 5DECF064292ED47500261A6B /* Padding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF063292ED47500261A6B /* Padding.swift */; };
16 | 5DECF066292ED4A800261A6B /* HStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF065292ED4A800261A6B /* HStack.swift */; };
17 | 5DECF068292ED69200261A6B /* FixedSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DECF067292ED69200261A6B /* FixedSize.swift */; };
18 | /* End PBXBuildFile section */
19 |
20 | /* Begin PBXFileReference section */
21 | 5D2245EC28D8FDB400E84C7D /* LayoutInspectorDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LayoutInspectorDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
22 | 5D2245EF28D8FDB400E84C7D /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; };
23 | 5D2245F328D8FDB500E84C7D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
24 | 5D2245F528D8FDB500E84C7D /* Entitlements.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.entitlements; sourceTree = ""; };
25 | 5D5B048F2921290A00758B21 /* SwiftUI-LayoutInspector */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "SwiftUI-LayoutInspector"; path = ..; sourceTree = ""; };
26 | 5DB6BE4F28DE4D4E00280F5E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; };
27 | 5DD5C0362962E8AE0041B966 /* Background.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Background.swift; sourceTree = ""; };
28 | 5DECF061292ED17700261A6B /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; };
29 | 5DECF063292ED47500261A6B /* Padding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Padding.swift; sourceTree = ""; };
30 | 5DECF065292ED4A800261A6B /* HStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HStack.swift; sourceTree = ""; };
31 | 5DECF067292ED69200261A6B /* FixedSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixedSize.swift; sourceTree = ""; };
32 | /* End PBXFileReference section */
33 |
34 | /* Begin PBXFrameworksBuildPhase section */
35 | 5D2245E928D8FDB400E84C7D /* Frameworks */ = {
36 | isa = PBXFrameworksBuildPhase;
37 | buildActionMask = 2147483647;
38 | files = (
39 | 5D8410B529212EF600117429 /* LayoutInspector in Frameworks */,
40 | );
41 | runOnlyForDeploymentPostprocessing = 0;
42 | };
43 | /* End PBXFrameworksBuildPhase section */
44 |
45 | /* Begin PBXGroup section */
46 | 5D2245E328D8FDB400E84C7D = {
47 | isa = PBXGroup;
48 | children = (
49 | 5D5B048F2921290A00758B21 /* SwiftUI-LayoutInspector */,
50 | 5DB6BE4F28DE4D4E00280F5E /* README.md */,
51 | 5D2245EF28D8FDB400E84C7D /* App.swift */,
52 | 5DECF061292ED17700261A6B /* RootView.swift */,
53 | 5DECF063292ED47500261A6B /* Padding.swift */,
54 | 5DD5C0362962E8AE0041B966 /* Background.swift */,
55 | 5DECF067292ED69200261A6B /* FixedSize.swift */,
56 | 5DECF065292ED4A800261A6B /* HStack.swift */,
57 | 5D2245F328D8FDB500E84C7D /* Assets.xcassets */,
58 | 5D2245F528D8FDB500E84C7D /* Entitlements.entitlements */,
59 | 5D2245ED28D8FDB400E84C7D /* Products */,
60 | 5D8410B329212EF600117429 /* Frameworks */,
61 | );
62 | indentWidth = 4;
63 | sourceTree = "";
64 | tabWidth = 4;
65 | };
66 | 5D2245ED28D8FDB400E84C7D /* Products */ = {
67 | isa = PBXGroup;
68 | children = (
69 | 5D2245EC28D8FDB400E84C7D /* LayoutInspectorDemo.app */,
70 | );
71 | name = Products;
72 | sourceTree = "";
73 | };
74 | 5D8410B329212EF600117429 /* Frameworks */ = {
75 | isa = PBXGroup;
76 | children = (
77 | );
78 | name = Frameworks;
79 | sourceTree = "";
80 | };
81 | /* End PBXGroup section */
82 |
83 | /* Begin PBXNativeTarget section */
84 | 5D2245EB28D8FDB400E84C7D /* LayoutInspectorDemo */ = {
85 | isa = PBXNativeTarget;
86 | buildConfigurationList = 5D2245FB28D8FDB500E84C7D /* Build configuration list for PBXNativeTarget "LayoutInspectorDemo" */;
87 | buildPhases = (
88 | 5D2245E828D8FDB400E84C7D /* Sources */,
89 | 5D2245E928D8FDB400E84C7D /* Frameworks */,
90 | 5D2245EA28D8FDB400E84C7D /* Resources */,
91 | );
92 | buildRules = (
93 | );
94 | dependencies = (
95 | );
96 | name = LayoutInspectorDemo;
97 | packageProductDependencies = (
98 | 5D8410B429212EF600117429 /* LayoutInspector */,
99 | );
100 | productName = LayoutInspector;
101 | productReference = 5D2245EC28D8FDB400E84C7D /* LayoutInspectorDemo.app */;
102 | productType = "com.apple.product-type.application";
103 | };
104 | /* End PBXNativeTarget section */
105 |
106 | /* Begin PBXProject section */
107 | 5D2245E428D8FDB400E84C7D /* Project object */ = {
108 | isa = PBXProject;
109 | attributes = {
110 | BuildIndependentTargetsInParallel = 1;
111 | LastSwiftUpdateCheck = 1400;
112 | LastUpgradeCheck = 1410;
113 | TargetAttributes = {
114 | 5D2245EB28D8FDB400E84C7D = {
115 | CreatedOnToolsVersion = 14.0;
116 | };
117 | };
118 | };
119 | buildConfigurationList = 5D2245E728D8FDB400E84C7D /* Build configuration list for PBXProject "LayoutInspectorDemo" */;
120 | compatibilityVersion = "Xcode 14.0";
121 | developmentRegion = en;
122 | hasScannedForEncodings = 0;
123 | knownRegions = (
124 | en,
125 | Base,
126 | );
127 | mainGroup = 5D2245E328D8FDB400E84C7D;
128 | productRefGroup = 5D2245ED28D8FDB400E84C7D /* Products */;
129 | projectDirPath = "";
130 | projectRoot = "";
131 | targets = (
132 | 5D2245EB28D8FDB400E84C7D /* LayoutInspectorDemo */,
133 | );
134 | };
135 | /* End PBXProject section */
136 |
137 | /* Begin PBXResourcesBuildPhase section */
138 | 5D2245EA28D8FDB400E84C7D /* Resources */ = {
139 | isa = PBXResourcesBuildPhase;
140 | buildActionMask = 2147483647;
141 | files = (
142 | 5D2245F428D8FDB500E84C7D /* Assets.xcassets in Resources */,
143 | );
144 | runOnlyForDeploymentPostprocessing = 0;
145 | };
146 | /* End PBXResourcesBuildPhase section */
147 |
148 | /* Begin PBXSourcesBuildPhase section */
149 | 5D2245E828D8FDB400E84C7D /* Sources */ = {
150 | isa = PBXSourcesBuildPhase;
151 | buildActionMask = 2147483647;
152 | files = (
153 | 5D2245F028D8FDB400E84C7D /* App.swift in Sources */,
154 | 5DECF062292ED17700261A6B /* RootView.swift in Sources */,
155 | 5DECF066292ED4A800261A6B /* HStack.swift in Sources */,
156 | 5DECF068292ED69200261A6B /* FixedSize.swift in Sources */,
157 | 5DECF064292ED47500261A6B /* Padding.swift in Sources */,
158 | 5DD5C0372962E8AE0041B966 /* Background.swift in Sources */,
159 | );
160 | runOnlyForDeploymentPostprocessing = 0;
161 | };
162 | /* End PBXSourcesBuildPhase section */
163 |
164 | /* Begin XCBuildConfiguration section */
165 | 5D2245F928D8FDB500E84C7D /* Debug */ = {
166 | isa = XCBuildConfiguration;
167 | buildSettings = {
168 | ALWAYS_SEARCH_USER_PATHS = NO;
169 | CLANG_ANALYZER_NONNULL = YES;
170 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
171 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
172 | CLANG_ENABLE_MODULES = YES;
173 | CLANG_ENABLE_OBJC_ARC = YES;
174 | CLANG_ENABLE_OBJC_WEAK = YES;
175 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
176 | CLANG_WARN_BOOL_CONVERSION = YES;
177 | CLANG_WARN_COMMA = YES;
178 | CLANG_WARN_CONSTANT_CONVERSION = YES;
179 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
180 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
181 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
182 | CLANG_WARN_EMPTY_BODY = YES;
183 | CLANG_WARN_ENUM_CONVERSION = YES;
184 | CLANG_WARN_INFINITE_RECURSION = YES;
185 | CLANG_WARN_INT_CONVERSION = YES;
186 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
187 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
188 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
189 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
190 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
191 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
192 | CLANG_WARN_STRICT_PROTOTYPES = YES;
193 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
194 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
195 | CLANG_WARN_UNREACHABLE_CODE = YES;
196 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
197 | COPY_PHASE_STRIP = NO;
198 | DEAD_CODE_STRIPPING = YES;
199 | DEBUG_INFORMATION_FORMAT = dwarf;
200 | ENABLE_STRICT_OBJC_MSGSEND = YES;
201 | ENABLE_TESTABILITY = YES;
202 | GCC_C_LANGUAGE_STANDARD = gnu11;
203 | GCC_DYNAMIC_NO_PIC = NO;
204 | GCC_NO_COMMON_BLOCKS = YES;
205 | GCC_OPTIMIZATION_LEVEL = 0;
206 | GCC_PREPROCESSOR_DEFINITIONS = (
207 | "DEBUG=1",
208 | "$(inherited)",
209 | );
210 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
211 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
212 | GCC_WARN_UNDECLARED_SELECTOR = YES;
213 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
214 | GCC_WARN_UNUSED_FUNCTION = YES;
215 | GCC_WARN_UNUSED_VARIABLE = YES;
216 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
217 | MTL_FAST_MATH = YES;
218 | ONLY_ACTIVE_ARCH = YES;
219 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
220 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
221 | };
222 | name = Debug;
223 | };
224 | 5D2245FA28D8FDB500E84C7D /* Release */ = {
225 | isa = XCBuildConfiguration;
226 | buildSettings = {
227 | ALWAYS_SEARCH_USER_PATHS = NO;
228 | CLANG_ANALYZER_NONNULL = YES;
229 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
230 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
231 | CLANG_ENABLE_MODULES = YES;
232 | CLANG_ENABLE_OBJC_ARC = YES;
233 | CLANG_ENABLE_OBJC_WEAK = YES;
234 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
235 | CLANG_WARN_BOOL_CONVERSION = YES;
236 | CLANG_WARN_COMMA = YES;
237 | CLANG_WARN_CONSTANT_CONVERSION = YES;
238 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
239 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
240 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
241 | CLANG_WARN_EMPTY_BODY = YES;
242 | CLANG_WARN_ENUM_CONVERSION = YES;
243 | CLANG_WARN_INFINITE_RECURSION = YES;
244 | CLANG_WARN_INT_CONVERSION = YES;
245 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
246 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
247 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
248 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
249 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
250 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
251 | CLANG_WARN_STRICT_PROTOTYPES = YES;
252 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
253 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
254 | CLANG_WARN_UNREACHABLE_CODE = YES;
255 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
256 | COPY_PHASE_STRIP = NO;
257 | DEAD_CODE_STRIPPING = YES;
258 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
259 | ENABLE_NS_ASSERTIONS = NO;
260 | ENABLE_STRICT_OBJC_MSGSEND = YES;
261 | GCC_C_LANGUAGE_STANDARD = gnu11;
262 | GCC_NO_COMMON_BLOCKS = YES;
263 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
264 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
265 | GCC_WARN_UNDECLARED_SELECTOR = YES;
266 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
267 | GCC_WARN_UNUSED_FUNCTION = YES;
268 | GCC_WARN_UNUSED_VARIABLE = YES;
269 | MTL_ENABLE_DEBUG_INFO = NO;
270 | MTL_FAST_MATH = YES;
271 | SWIFT_COMPILATION_MODE = wholemodule;
272 | SWIFT_OPTIMIZATION_LEVEL = "-O";
273 | };
274 | name = Release;
275 | };
276 | 5D2245FC28D8FDB500E84C7D /* Debug */ = {
277 | isa = XCBuildConfiguration;
278 | buildSettings = {
279 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
280 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
281 | CODE_SIGN_ENTITLEMENTS = Entitlements.entitlements;
282 | CODE_SIGN_STYLE = Automatic;
283 | CURRENT_PROJECT_VERSION = 1;
284 | DEAD_CODE_STRIPPING = YES;
285 | DEVELOPMENT_TEAM = "";
286 | ENABLE_HARDENED_RUNTIME = YES;
287 | ENABLE_PREVIEWS = YES;
288 | GENERATE_INFOPLIST_FILE = YES;
289 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
290 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
291 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
292 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
293 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
294 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
295 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
296 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
297 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
298 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
299 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
300 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
301 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
302 | MACOSX_DEPLOYMENT_TARGET = 13.0;
303 | MARKETING_VERSION = 1.0;
304 | PRODUCT_BUNDLE_IDENTIFIER = net.oleb.LayoutInspectorDemo;
305 | PRODUCT_NAME = "$(TARGET_NAME)";
306 | SDKROOT = auto;
307 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
308 | SWIFT_EMIT_LOC_STRINGS = YES;
309 | SWIFT_VERSION = 5.0;
310 | TARGETED_DEVICE_FAMILY = "1,2";
311 | };
312 | name = Debug;
313 | };
314 | 5D2245FD28D8FDB500E84C7D /* Release */ = {
315 | isa = XCBuildConfiguration;
316 | buildSettings = {
317 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
318 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
319 | CODE_SIGN_ENTITLEMENTS = Entitlements.entitlements;
320 | CODE_SIGN_STYLE = Automatic;
321 | CURRENT_PROJECT_VERSION = 1;
322 | DEAD_CODE_STRIPPING = YES;
323 | DEVELOPMENT_TEAM = "";
324 | ENABLE_HARDENED_RUNTIME = YES;
325 | ENABLE_PREVIEWS = YES;
326 | GENERATE_INFOPLIST_FILE = YES;
327 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
328 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
329 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
330 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
331 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
332 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
333 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
334 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
335 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
336 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
337 | IPHONEOS_DEPLOYMENT_TARGET = 16.0;
338 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
339 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
340 | MACOSX_DEPLOYMENT_TARGET = 13.0;
341 | MARKETING_VERSION = 1.0;
342 | PRODUCT_BUNDLE_IDENTIFIER = net.oleb.LayoutInspectorDemo;
343 | PRODUCT_NAME = "$(TARGET_NAME)";
344 | SDKROOT = auto;
345 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
346 | SWIFT_EMIT_LOC_STRINGS = YES;
347 | SWIFT_VERSION = 5.0;
348 | TARGETED_DEVICE_FAMILY = "1,2";
349 | };
350 | name = Release;
351 | };
352 | /* End XCBuildConfiguration section */
353 |
354 | /* Begin XCConfigurationList section */
355 | 5D2245E728D8FDB400E84C7D /* Build configuration list for PBXProject "LayoutInspectorDemo" */ = {
356 | isa = XCConfigurationList;
357 | buildConfigurations = (
358 | 5D2245F928D8FDB500E84C7D /* Debug */,
359 | 5D2245FA28D8FDB500E84C7D /* Release */,
360 | );
361 | defaultConfigurationIsVisible = 0;
362 | defaultConfigurationName = Release;
363 | };
364 | 5D2245FB28D8FDB500E84C7D /* Build configuration list for PBXNativeTarget "LayoutInspectorDemo" */ = {
365 | isa = XCConfigurationList;
366 | buildConfigurations = (
367 | 5D2245FC28D8FDB500E84C7D /* Debug */,
368 | 5D2245FD28D8FDB500E84C7D /* Release */,
369 | );
370 | defaultConfigurationIsVisible = 0;
371 | defaultConfigurationName = Release;
372 | };
373 | /* End XCConfigurationList section */
374 |
375 | /* Begin XCSwiftPackageProductDependency section */
376 | 5D8410B429212EF600117429 /* LayoutInspector */ = {
377 | isa = XCSwiftPackageProductDependency;
378 | productName = LayoutInspector;
379 | };
380 | /* End XCSwiftPackageProductDependency section */
381 | };
382 | rootObject = 5D2245E428D8FDB400E84C7D /* Project object */;
383 | }
384 |
--------------------------------------------------------------------------------
/DemoApp/LayoutInspectorDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/DemoApp/LayoutInspectorDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/DemoApp/Padding.swift:
--------------------------------------------------------------------------------
1 | import LayoutInspector
2 | import SwiftUI
3 |
4 | struct PaddingExample: View {
5 | var body: some View {
6 | Text("Hello world")
7 | .layoutStep("Text")
8 | .padding(10)
9 | .layoutStep("padding")
10 | .border(Color.green)
11 | .layoutStep("border")
12 | .inspectLayout()
13 | }
14 | }
15 |
16 | struct PaddingExample_Previews: PreviewProvider {
17 | static var previews: some View {
18 | PaddingExample()
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/DemoApp/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUI Layout Inspector Demo App
2 |
--------------------------------------------------------------------------------
/DemoApp/RootView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | enum CaseStudy: String, CaseIterable, Identifiable {
4 | case padding = "padding"
5 | case background = "background"
6 | case fixedSize = "fixedSize"
7 | case hStack = "HStack"
8 |
9 | var id: Self {
10 | self
11 | }
12 |
13 | var label: String {
14 | rawValue
15 | }
16 | }
17 |
18 | struct RootView: View {
19 | @State private var selection: CaseStudy?
20 | @State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
21 |
22 | var body: some View {
23 | NavigationSplitView(columnVisibility: $columnVisibility) {
24 | Sidebar(selection: $selection)
25 | } detail: {
26 | if let caseStudy = selection {
27 | MainContent(caseStudy: caseStudy)
28 | } else {
29 | Text("Select an item in the sidebar")
30 | .foregroundStyle(.secondary)
31 | }
32 | }
33 | }
34 | }
35 |
36 | struct Sidebar: View {
37 | @Binding var selection: CaseStudy?
38 |
39 | var body: some View {
40 | List(CaseStudy.allCases, selection: $selection) { caseStudy in
41 | Text(caseStudy.label)
42 | }
43 | .navigationTitle("Layout Inspector")
44 | }
45 | }
46 |
47 | struct MainContent: View {
48 | var caseStudy: CaseStudy
49 |
50 | var body: some View {
51 | ZStack {
52 | switch caseStudy {
53 | case .padding:
54 | PaddingExample()
55 | case .background:
56 | BackgroundExample()
57 | case .fixedSize:
58 | FixedSizeExample()
59 | case .hStack:
60 | HStackExample()
61 | }
62 | }
63 | .padding()
64 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
65 | .navigationTitle(caseStudy.label)
66 | #if !os(macOS)
67 | .navigationBarTitleDisplayMode(.inline)
68 | #endif
69 | }
70 | }
71 |
72 | struct RootView_Previews: PreviewProvider {
73 | static var previews: some View {
74 | RootView()
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/LayoutInspector.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
10 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/LayoutInspector.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.7
2 |
3 | // SwiftUI Layout Inspector
4 | //
5 | // A tool for learning how the SwiftUI layout system works,
6 | // and for debugging your own layout code.
7 | //
8 | // https://github.com/ole/swiftui-layout-inspector/
9 | //
10 | // By Ole Begemann, https://oleb.net/
11 |
12 | import PackageDescription
13 |
14 | let package = Package(
15 | name: "LayoutInspector",
16 | // LayoutInspector requires iOS 16/macOS 13. We specify a lower deployment
17 | // target here to allow integrating the library into an app with a lower
18 | // deployment target. The entire LayoutInspector API is availability-gated
19 | // to iOS 16/macOS 13, though, so you can only use it in views that are
20 | // `@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)` or higher.
21 | platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)],
22 | products: [
23 | .library(
24 | name: "LayoutInspector",
25 | targets: ["LayoutInspector"]
26 | ),
27 | ],
28 | dependencies: [],
29 | targets: [
30 | .target(name: "LayoutInspector", dependencies: []),
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUI Layout Inspector
2 |
3 |
4 |
5 | A Swift library (and iOS/Mac app) for learning how the SwiftUI layout system works,
6 | and for debugging your own layout code. Inspect the layout behavior of SwiftUI
7 | views, i.e. what sizes views propose to their children and how a view determines
8 | its own size.
9 |
10 | 
11 |
12 | ## Motivation
13 |
14 | At its core, SwiftUI’s layout algorithm is wonderfully simple:
15 |
16 | 1. The parent view proposes a size to its child view(s). Width and height are
17 | both optional; either one (or both) can be `nil`.
18 |
19 | 2. The child view determines its own size, taking the proposed size into
20 | account, as well as the sizes of its own children (it’s a recursive
21 | process).
22 |
23 | 3. The child reports its size back to the parent. The parent can’t change the
24 | child’s size (in SwiftUI, each view determines its own size).
25 |
26 | 4. The parent view positions its children.
27 |
28 | Complex layouts in SwiftUI can be achieved by composing built-in views and view
29 | modifiers. The tricky part about understanding the layout system is learning the
30 | layout behaviors of the built-in views, many of which are poorly documented (as
31 | of November 2022). The goal of this package is to help you learn.
32 |
33 | ## Components
34 |
35 | Layout Inspector consists of:
36 |
37 | - The `LayoutInspector` library, provided as a SwiftPM package. Add it to your
38 | own SwiftUI app to debug your layout code.
39 |
40 | - The `LayoutInspectorDemo` app, an iOS and Mac app that shows Layout Inspector
41 | in action.
42 |
43 | ## Requirements
44 |
45 | iOS 16.0 or macOS 13.0 (because it requires the `Layout` protocol).
46 |
47 | ## Instructions
48 |
49 | 1. `import LayoutInspector`
50 |
51 | 2. At the top of the view tree you want to inspect, insert `.inspectLayout()`.
52 |
53 | 3. Insert `.layoutStep("View label")` at each point in a view tree where you
54 | want to inspect the layout algorithm (what sizes are being proposed and
55 | returned). This is necessary to inject the helper "views" that observe the
56 | layout process.
57 |
58 | ## Acknowledgements
59 |
60 | Idea and initial code based on: [objc.io, Swift Talk episode 318, Inspecting SwiftUI's Layout Process (2022-08-19)](https://talk.objc.io/episodes/S01E318-inspecting-swiftui-s-layout-process)
61 |
62 | Runtime warnings in Xcode: [Point-Free, Unobtrusive runtime warnings for libraries (2022-01-03)](https://www.pointfree.co/blog/posts/70-unobtrusive-runtime-warnings-for-libraries)
63 |
64 | See also [Credits.md](Credits.md)
65 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/Binding+Util.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension Binding {
4 | func transform(
5 | getter: @escaping (Value) -> Target,
6 | setter: @escaping (inout Value, Target, Transaction) -> Void
7 | ) -> Binding {
8 | Binding(
9 | get: { getter(self.wrappedValue) },
10 | set: { newValue, transaction in
11 | setter(&self.wrappedValue, newValue, transaction)
12 | }
13 | )
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/DebugLayout.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
4 | extension View {
5 | /// Inspect the layout for this subtree.
6 | @MainActor public func inspectLayout() -> some View {
7 | modifier(InspectLayout())
8 | }
9 |
10 | /// Monitor the layout proposals and responses for this view and add them
11 | /// to the log.
12 | @MainActor public func layoutStep(
13 | _ label: String,
14 | file: StaticString = #fileID,
15 | line: UInt = #line
16 | ) -> some View {
17 | modifier(DebugLayoutModifier(label: label, file: file, line: line))
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/DebugLayoutImpl.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
4 | @MainActor
5 | struct InspectLayout: ViewModifier {
6 | // Don't observe LogStore. Avoids an infinite update loop when store publishes changes.
7 | @State private var logStore: LogStore = .init()
8 | @State private var selectedView: String? = nil
9 | @State private var generation: Int = 0
10 | @State private var inspectorFrame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300)
11 | @State private var contentSize: CGSize? = nil
12 | @State private var tableSize: CGSize? = nil
13 | @State private var isPresentingInfoPanel: Bool = false
14 |
15 | private static let coordSpaceName = "InspectLayout"
16 |
17 | func body(content: Content) -> some View {
18 | ClearDebugLayoutLog(actions: logStore.actions) {
19 | content
20 | .id(generation)
21 | .environment(\.debugLayoutSelectedViewID, selectedView)
22 | .measureSize { size in
23 | // Move inspector UI below the inspected view initially
24 | if contentSize == nil {
25 | inspectorFrame.origin.y = size.height + 8
26 | }
27 | contentSize = size
28 | }
29 | }
30 | .overlay(alignment: .topLeading) {
31 | inspectorUI
32 | .frame(width: inspectorFrame.width, height: inspectorFrame.height)
33 | .offset(x: inspectorFrame.minX, y: inspectorFrame.minY)
34 | .coordinateSpace(name: Self.coordSpaceName)
35 | }
36 | .environment(\.debugLayoutActions, logStore.actions)
37 | .onChange(of: generation) { newValue in
38 | logStore = LogStore()
39 | }
40 | }
41 |
42 | @ViewBuilder private var inspectorUI: some View {
43 | ScrollView([.vertical, .horizontal]) {
44 | LogEntriesGrid(logStore: logStore, highlight: $selectedView)
45 | .measureSize { size in
46 | tableSize = size
47 | }
48 | }
49 | .frame(maxWidth: tableSize?.width, maxHeight: tableSize?.height)
50 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
51 | .safeAreaInset(edge: .bottom) {
52 | toolbar
53 | }
54 | .font(.subheadline)
55 | .resizableAndDraggable(
56 | frame: $inspectorFrame,
57 | coordinateSpace: .named(Self.coordSpaceName)
58 | )
59 | .background {
60 | Rectangle().fill(.thickMaterial)
61 | .shadow(radius: 5)
62 | }
63 | .cornerRadius(4)
64 | .overlay {
65 | RoundedRectangle(cornerRadius: 4)
66 | .strokeBorder(.quaternary)
67 | }
68 | }
69 |
70 | @ViewBuilder private var toolbar: some View {
71 | HStack {
72 | Button("Reset layout cache") {
73 | generation &+= 1
74 | }
75 | Spacer()
76 | Button {
77 | isPresentingInfoPanel.toggle()
78 | } label: {
79 | Image(systemName: "info.circle")
80 | }
81 | .popover(isPresented: $isPresentingInfoPanel) {
82 | VStack(alignment: .leading) {
83 | Text("SwiftUI Layout Inspector")
84 | .font(.headline)
85 | Link("GitHub", destination: URL(string: "https://github.com/ole/swiftui-layout-inspector")!)
86 | }
87 | .padding()
88 | }
89 | .presentationDetents([.medium])
90 | }
91 | .padding()
92 | .frame(maxWidth: .infinity)
93 | .background()
94 | .backgroundStyle(.thinMaterial)
95 | }
96 | }
97 |
98 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
99 | @MainActor
100 | struct DebugLayoutModifier: ViewModifier {
101 | var label: String
102 | var file: StaticString
103 | var line: UInt
104 | @Environment(\.debugLayoutActions) var actions: DebugLayoutActions?
105 |
106 | func body(content: Content) -> some View {
107 | if let actions {
108 | DebugLayout(label: label, actions: actions) {
109 | content
110 | }
111 | .onAppear {
112 | actions.registerViewLabelAndWarnIfNotUnique(label, file, line)
113 | }
114 | .modifier(DebugLayoutSelectionHighlight(viewID: label))
115 | } else {
116 | 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)
117 | content
118 | }
119 | }
120 | }
121 |
122 | /// A custom layout that saves the layout proposals and responses for a view
123 | /// to a log.
124 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
125 | struct DebugLayout: Layout {
126 | var label: String
127 | var actions: DebugLayoutActions
128 |
129 | func sizeThatFits(
130 | proposal: ProposedViewSize,
131 | subviews: Subviews,
132 | cache: inout ()
133 | ) -> CGSize {
134 | assert(subviews.count == 1)
135 | DispatchQueue.main.async {
136 | actions.logLayoutStep(label, .proposal(proposal))
137 | }
138 | let response = subviews[0].sizeThatFits(proposal)
139 | DispatchQueue.main.async {
140 | actions.logLayoutStep(label, .response(response))
141 | }
142 | return response
143 | }
144 |
145 | func placeSubviews(
146 | in bounds: CGRect,
147 | proposal: ProposedViewSize,
148 | subviews: Subviews,
149 | cache: inout ()
150 | ) {
151 | subviews[0].place(at: bounds.origin, proposal: proposal)
152 | }
153 | }
154 |
155 | /// A custom layout that clears the DebugLayout log at the point where it's
156 | /// placed in the view tree.
157 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
158 | struct ClearDebugLayoutLog: Layout {
159 | var actions: DebugLayoutActions
160 |
161 | func sizeThatFits(
162 | proposal: ProposedViewSize,
163 | subviews: Subviews,
164 | cache: inout ()
165 | ) -> CGSize {
166 | assert(subviews.count == 1)
167 | DispatchQueue.main.async {
168 | actions.clearLog()
169 | }
170 | return subviews[0].sizeThatFits(proposal)
171 | }
172 |
173 | func placeSubviews(
174 | in bounds: CGRect,
175 | proposal: ProposedViewSize,
176 | subviews: Subviews,
177 | cache: inout ()
178 | ) {
179 | assert(subviews.count == 1)
180 | subviews[0].place(at: bounds.origin, proposal: proposal)
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/Formatting.swift:
--------------------------------------------------------------------------------
1 | import CoreGraphics
2 | import SwiftUI
3 |
4 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
5 | extension CGFloat {
6 | var pretty: String {
7 | String(format: "%.1f", self)
8 | }
9 | }
10 |
11 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
12 | extension CGSize {
13 | var pretty: String {
14 | let thinSpace: Character = "\u{2009}"
15 | return "\(width.pretty)\(thinSpace)×\(thinSpace)\(height.pretty)"
16 | }
17 | }
18 |
19 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
20 | extension Optional where Wrapped == CGFloat {
21 | var pretty: String {
22 | self?.pretty ?? "nil"
23 | }
24 | }
25 |
26 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
27 | extension ProposedViewSize {
28 | var pretty: String {
29 | let thinSpace: Character = "\u{2009}"
30 | return "\(width.pretty)\(thinSpace)×\(thinSpace)\(height.pretty)"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/Geometry.swift:
--------------------------------------------------------------------------------
1 | import CoreGraphics
2 | import SwiftUI
3 |
4 | extension CGPoint {
5 | static func + (lhs: CGPoint, rhs: CGPoint) -> CGSize {
6 | CGSize(width: lhs.x + rhs.x, height: lhs.y + rhs.y)
7 | }
8 |
9 | static func - (lhs: CGPoint, rhs: CGPoint) -> CGSize {
10 | CGSize(width: lhs.x - rhs.x, height: lhs.y - rhs.y)
11 | }
12 | }
13 |
14 | extension CGSize {
15 | static func + (lhs: CGSize, rhs: CGSize) -> CGSize {
16 | CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
17 | }
18 |
19 | static func - (lhs: CGSize, rhs: CGSize) -> CGSize {
20 | CGSize(width: lhs.width - rhs.width, height: lhs.height - rhs.height)
21 | }
22 | }
23 |
24 | extension CGRect {
25 | var topLeading: CGPoint {
26 | get { CGPoint(x: minX, y: minY) }
27 | set {
28 | let delta = newValue - CGPoint(x: minX, y: minY)
29 | origin.x += delta.width
30 | origin.y += delta.height
31 | size.width -= delta.width
32 | size.height -= delta.height
33 | self = self.standardized
34 | }
35 | }
36 |
37 | var topTrailing: CGPoint {
38 | get { CGPoint(x: maxX, y: minY) }
39 | set {
40 | let delta = newValue - CGPoint(x: maxX, y: minY)
41 | origin.y += delta.height
42 | size.width += delta.width
43 | size.height -= delta.height
44 | self = self.standardized
45 | }
46 | }
47 |
48 | var bottomLeading: CGPoint {
49 | get { CGPoint(x: minX, y: maxY) }
50 | set {
51 | let delta = newValue - CGPoint(x: minX, y: maxY)
52 | origin.x += delta.width
53 | size.width -= delta.width
54 | size.height += delta.height
55 | self = self.standardized
56 | }
57 | }
58 |
59 | var bottomTrailing: CGPoint {
60 | get { CGPoint(x: maxX, y: maxY) }
61 | set {
62 | let delta = newValue - CGPoint(x: maxX, y: maxY)
63 | size.width += delta.width
64 | size.height += delta.height
65 | self = self.standardized
66 | }
67 | }
68 |
69 | func unitPoint(_ unitPoint: UnitPoint) -> CGPoint {
70 | CGPoint(
71 | x: minX + (maxX - minX) * unitPoint.x,
72 | y: minY + (maxY - minY) * unitPoint.y
73 | )
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/LogEntriesGrid.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
4 | struct LogEntriesGrid: View {
5 | @ObservedObject var logStore: LogStore
6 | @Binding var highlight: String?
7 |
8 | private static let tableRowHorizontalPadding: CGFloat = 8
9 | private static let tableRowVerticalPadding: CGFloat = 4
10 |
11 | init(logStore: LogStore, highlight: Binding? = nil) {
12 | self._logStore = ObservedObject(initialValue: logStore)
13 | if let binding = highlight {
14 | self._highlight = binding
15 | } else {
16 | var nirvana: String? = nil
17 | self._highlight = Binding(get: { nirvana }, set: { nirvana = $0 })
18 | }
19 | }
20 |
21 | var body: some View {
22 | Grid(
23 | alignment: .leadingFirstTextBaseline,
24 | horizontalSpacing: 0,
25 | verticalSpacing: 0
26 | ) {
27 | // Table header row
28 | GridRow {
29 | Text("View")
30 | Text("Proposal")
31 | Text("Response")
32 | }
33 | .bold()
34 | .padding(.vertical, Self.tableRowVerticalPadding)
35 | .padding(.horizontal, Self.tableRowHorizontalPadding)
36 |
37 | // Table header separator line
38 | Rectangle().fill(.secondary)
39 | .frame(height: 1)
40 | .gridCellUnsizedAxes(.horizontal)
41 | .padding(.vertical, Self.tableRowVerticalPadding)
42 | .padding(.horizontal, Self.tableRowHorizontalPadding)
43 |
44 | // Table rows
45 | ForEach(logStore.log) { item in
46 | let isSelected = highlight == item.label
47 | GridRow {
48 | HStack(spacing: 0) {
49 | indentation(level: item.indent)
50 | Text(item.label)
51 | }
52 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
53 |
54 | Text(item.proposal?.pretty ?? "…")
55 | .monospacedDigit()
56 | .fixedSize()
57 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
58 |
59 | Text(item.response?.pretty ?? "…")
60 | .monospacedDigit()
61 | .fixedSize()
62 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
63 | }
64 | .padding(.vertical, Self.tableRowVerticalPadding)
65 | .padding(.horizontal, Self.tableRowHorizontalPadding)
66 | .foregroundColor(isSelected ? .white : nil)
67 | .background(isSelected ? Color.accentColor : .clear)
68 | .contentShape(Rectangle())
69 | .onTapGesture {
70 | highlight = isSelected ? nil : item.label
71 | }
72 | }
73 | }
74 | .padding(.vertical, 8)
75 | }
76 |
77 | private func indentation(level: Int) -> some View {
78 | ForEach(0 ..< level, id: \.self) { _ in
79 | Color.clear
80 | .frame(width: 16)
81 | .overlay(alignment: .leading) {
82 | Rectangle()
83 | .frame(width: 1)
84 | .padding(.leading, 4)
85 | // Compensate for cell padding, we want continuous vertical lines.
86 | .padding(.vertical, -Self.tableRowVerticalPadding)
87 | }
88 | }
89 | }
90 | }
91 |
92 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
93 | struct LogEntriesGrid_Previews: PreviewProvider {
94 | static var previews: some View {
95 | let logStore = LogStore(log: sampleLogEntries)
96 | LogEntriesGrid(logStore: logStore)
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/LogEntriesTable.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
4 | struct LogEntriesTable: View {
5 | var logEntries: [LogEntry]
6 | @Binding var highlight: String?
7 | @State private var selectedRow: LogEntry.ID? = nil
8 |
9 | init(logEntries: [LogEntry], highlight: Binding? = nil) {
10 | self.logEntries = logEntries
11 | if let binding = highlight {
12 | self._highlight = binding
13 | } else {
14 | var nirvana: String? = nil
15 | self._highlight = Binding(get: { nirvana }, set: { nirvana = $0 })
16 | }
17 | }
18 |
19 | var body: some View {
20 | Table(logEntries, selection: $selectedRow) {
21 | TableColumn("View") { item in
22 | let shouldHighlight = highlight == item.label
23 | HStack {
24 | indentation(level: item.indent)
25 | Text(item.label)
26 | Image(systemName: "circle.fill")
27 | .font(Font.caption2)
28 | .foregroundStyle(.tint)
29 | .opacity(shouldHighlight ? 1 : 0)
30 | }
31 | }
32 | TableColumn("Proposal") { item in
33 | Text(item.proposal?.pretty ?? "…")
34 | .monospacedDigit()
35 | .fixedSize()
36 | .foregroundStyle(.primary)
37 | }
38 | TableColumn("Response") { item in
39 | Text(item.response?.pretty ?? "…")
40 | .monospacedDigit()
41 | .fixedSize()
42 | .foregroundStyle(.primary)
43 | }
44 | }
45 | .onChange(of: highlight) { viewLabel in
46 | let selectedLogEntry = logEntries.first { $0.id == selectedRow }
47 | if viewLabel != selectedLogEntry?.label {
48 | selectedRow = nil
49 | }
50 | }
51 | .onChange(of: selectedRow) { rowID in
52 | let selectedLogEntry = logEntries.first { $0.id == rowID }
53 | highlight = selectedLogEntry?.label
54 | }
55 | .font(.callout)
56 | }
57 |
58 | private func indentation(level: Int) -> some View {
59 | ForEach(0 ..< level, id: \.self) { _ in
60 | Color.clear
61 | .frame(width: 12)
62 | }
63 | }
64 | }
65 |
66 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
67 | struct LogEntriesTable_Previews: PreviewProvider {
68 | static var previews: some View {
69 | LogEntriesTable(logEntries: sampleLogEntries)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/LogEntry.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
4 | struct LogEntry: Identifiable {
5 | enum Step {
6 | case proposal(ProposedViewSize)
7 | case response(CGSize)
8 | case proposalAndResponse(proposal: ProposedViewSize, response: CGSize)
9 | }
10 |
11 | var id: UUID = .init()
12 | var label: String
13 | var step: Step
14 | var indent: Int
15 |
16 | var proposal: ProposedViewSize? {
17 | switch step {
18 | case .proposal(let p): return p
19 | case .response(_): return nil
20 | case .proposalAndResponse(proposal: let p, response: _): return p
21 | }
22 | }
23 |
24 | var response: CGSize? {
25 | switch step {
26 | case .proposal(_): return nil
27 | case .response(let r): return r
28 | case .proposalAndResponse(proposal: _, response: let r): return r
29 | }
30 | }
31 | }
32 |
33 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
34 | let sampleLogEntries: [LogEntry] = [
35 | .init(
36 | label: "HStack",
37 | step: .proposal(.init(width: 300, height: 100)),
38 | indent: 0
39 | ),
40 | .init(
41 | label: "Text",
42 | step: .proposalAndResponse(
43 | proposal: .init(width: 0, height: 100),
44 | response: .init(width: 0, height: 86.3)),
45 | indent: 1
46 | ),
47 | .init(
48 | label: "Text",
49 | step: .proposalAndResponse(
50 | proposal: .init(width: .infinity, height: 100),
51 | response: .init(width: 85.3, height: 20.3)
52 | ),
53 | indent: 1
54 | ),
55 | .init(
56 | label: "green",
57 | step: .proposalAndResponse(
58 | proposal: .init(width: 0, height: 100),
59 | response: .init(width: 0, height: 100)
60 | ),
61 | indent: 1
62 | ),
63 | .init(
64 | label: "green",
65 | step: .proposalAndResponse(
66 | proposal: .init(width: .infinity, height: 100),
67 | response: .init(width: CGFloat.infinity, height: 100)
68 | ),
69 | indent: 1
70 | ),
71 | .init(
72 | label: "yellow",
73 | step: .proposalAndResponse(
74 | proposal: .init(width: 0, height: 100),
75 | response: .init(width: 0, height: 100)
76 | ),
77 | indent: 1
78 | ),
79 | .init(
80 | label: "yellow",
81 | step: .proposalAndResponse(
82 | proposal: .init(width: .infinity, height: 100),
83 | response: .init(width: CGFloat.infinity, height: 100)
84 | ),
85 | indent: 1
86 | ),
87 | .init(
88 | label: "Text",
89 | step: .proposalAndResponse(
90 | proposal: .init(width: 93.3, height: 100),
91 | response: .init(width: 85.3, height: 20.3)
92 | ),
93 | indent: 1
94 | ),
95 | .init(
96 | label: "green",
97 | step: .proposalAndResponse(
98 | proposal: .init(width: 97.3, height: 100),
99 | response: .init(width: 97.3, height: 100)
100 | ),
101 | indent: 1
102 | ),
103 | .init(
104 | label: "yellow",
105 | step: .proposalAndResponse(
106 | proposal: .init(width: 97.3, height: 100),
107 | response: .init(width: 97.3, height: 100)
108 | ),
109 | indent: 1
110 | ),
111 | .init(
112 | label: "HStack",
113 | step: .response(.init(width: 300, height: 100)),
114 | indent: 0
115 | ),
116 | ]
117 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/LogStore.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
4 | @MainActor
5 | final class LogStore: ObservableObject {
6 | @Published var log: [LogEntry]
7 | var viewLabels: Set = []
8 |
9 | init(log: [LogEntry] = []) {
10 | self.log = log
11 | self.viewLabels = Set(log.map(\.label))
12 | }
13 |
14 | var actions: DebugLayoutActions {
15 | DebugLayoutActions(
16 | clearLog: clearLog,
17 | registerViewLabelAndWarnIfNotUnique: registerViewLabelAndWarnIfNotUnique(_:file:line:),
18 | logLayoutStep: logLayoutStep(_:step:)
19 | )
20 | }
21 |
22 | func clearLog() {
23 | log.removeAll()
24 | viewLabels.removeAll()
25 | }
26 |
27 | func registerViewLabelAndWarnIfNotUnique(_ label: String, file: StaticString, line: UInt) {
28 | DispatchQueue.main.async { [self] in
29 | if viewLabels.contains(label) {
30 | let message: StaticString = "%@:%llu: Duplicate view label '%@' detected. Use unique labels in .layoutStep() calls"
31 | runtimeWarning(message, [String(describing: file), UInt64(line), label], file: file, line: line)
32 | }
33 | viewLabels.insert(label)
34 | }
35 | }
36 |
37 | func logLayoutStep(_ label: String, step: LogEntry.Step) {
38 | guard let prevEntry = log.last else {
39 | // First log entry → start at indent 0.
40 | log.append(LogEntry(label: label, step: step, indent: 0))
41 | return
42 | }
43 |
44 | var newEntry = LogEntry(label: label, step: step, indent: prevEntry.indent)
45 | let isSameView = prevEntry.label == label
46 | switch (isSameView, prevEntry.step, step) {
47 | case (true, .proposal(let prop), .response(let resp)):
48 | // Response follows immediately after proposal for the same view.
49 | // → We want to display them in a single row.
50 | // → Coalesce both layout steps.
51 | log.removeLast()
52 | newEntry = prevEntry
53 | newEntry.step = .proposalAndResponse(proposal: prop, response: resp)
54 | log.append(newEntry)
55 |
56 | case (_, .proposal, .proposal):
57 | // A proposal follows a proposal → nested view → increment indent.
58 | newEntry.indent += 1
59 | log.append(newEntry)
60 |
61 | case (_, .response, .response),
62 | (_, .proposalAndResponse, .response):
63 | // A response follows a response → last child returns to parent → decrement indent.
64 | newEntry.indent -= 1
65 | log.append(newEntry)
66 |
67 | default:
68 | // Keep current indentation.
69 | log.append(newEntry)
70 | }
71 | }
72 | }
73 |
74 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
75 | struct DebugLayoutActions {
76 | var clearLog: @MainActor () -> Void
77 | var registerViewLabelAndWarnIfNotUnique: @MainActor (_ label: String, _ file: StaticString, _ line: UInt) -> Void
78 | var logLayoutStep: @MainActor (_ label: String, _ step: LogEntry.Step) -> Void
79 | }
80 |
81 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
82 | enum DebugLayoutActionsKey: EnvironmentKey {
83 | static var defaultValue: DebugLayoutActions? = nil
84 | }
85 |
86 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
87 | extension EnvironmentValues {
88 | var debugLayoutActions: DebugLayoutActions? {
89 | get { self[DebugLayoutActionsKey.self] }
90 | set { self[DebugLayoutActionsKey.self] = newValue }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/ResizableAndDraggableView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
5 | func resizableAndDraggable(
6 | frame: Binding,
7 | coordinateSpace: CoordinateSpace
8 | ) -> some View {
9 | modifier(ResizableAndDraggableFrame(
10 | frame: frame,
11 | coordinateSpace: coordinateSpace
12 | ))
13 | }
14 | }
15 |
16 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
17 | struct ResizableAndDraggableFrame: ViewModifier {
18 | @Binding var frame: CGRect
19 | var coordinateSpace: CoordinateSpace
20 |
21 | private static let titleBarHeight: CGFloat = 20
22 |
23 | func body(content: Content) -> some View {
24 | content
25 | .padding(.top, Self.titleBarHeight)
26 | .overlay {
27 | ZStack(alignment: .top) {
28 | titleBar
29 | resizeHandles
30 | }
31 | }
32 | }
33 |
34 | @ViewBuilder private var titleBar: some View {
35 | Rectangle()
36 | .frame(height: Self.titleBarHeight)
37 | .foregroundStyle(.ultraThinMaterial)
38 | .overlay {
39 | Text("Layout Inspector")
40 | .font(.footnote)
41 | }
42 | .overlay(alignment: .bottom) {
43 | Rectangle()
44 | .foregroundStyle(.quaternary)
45 | .frame(height: 1)
46 | }
47 | .draggable(point: $frame.origin, coordinateSpace: coordinateSpace)
48 | .help("Move")
49 | }
50 |
51 | @ViewBuilder private var resizeHandles: some View {
52 | let resizeHandle = TriangleStripes()
53 | .fill(Color(white: 0.5).opacity(0.5))
54 | .frame(width: 15, height: 15)
55 | .frame(width: Self.titleBarHeight, height: Self.titleBarHeight, alignment: .topLeading)
56 | .contentShape(Rectangle())
57 | .help("Resize")
58 | resizeHandle
59 | .draggable(point: $frame.topLeading, coordinateSpace: coordinateSpace)
60 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
61 | resizeHandle
62 | .rotationEffect(.degrees(90))
63 | .draggable(point: $frame.topTrailing, coordinateSpace: coordinateSpace)
64 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
65 | resizeHandle
66 | .rotationEffect(.degrees(-90))
67 | .draggable(point: $frame.bottomLeading, coordinateSpace: coordinateSpace)
68 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
69 | resizeHandle
70 | .rotationEffect(.degrees(180))
71 | .draggable(point: $frame.bottomTrailing, coordinateSpace: coordinateSpace)
72 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
73 | }
74 | }
75 |
76 | struct TriangleStripes: Shape {
77 | func path(in rect: CGRect) -> Path {
78 | let stripeCount = 4
79 | let spacing: CGFloat = 0.15 // in unit points
80 | let stripeWidth = (1 - CGFloat(stripeCount - 1) * spacing) / CGFloat(stripeCount)
81 |
82 | var path = Path()
83 | // First stripe is special
84 | path.move(to: rect.topLeading)
85 | path.addLine(to: rect.unitPoint(.init(x: stripeWidth, y: 0)))
86 | path.addLine(to: rect.unitPoint(.init(x: 0, y: stripeWidth)))
87 | path.closeSubpath()
88 |
89 | for stripe in 1..? = nil,
107 | offset: Binding,
108 | coordinateSpace: CoordinateSpace
109 | ) -> some View {
110 | modifier(Draggable(
111 | isDragging: isDragging,
112 | offset: offset,
113 | coordinateSpace: coordinateSpace
114 | ))
115 | }
116 |
117 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
118 | func draggable(
119 | isDragging: Binding? = nil,
120 | point pointBinding: Binding,
121 | coordinateSpace: CoordinateSpace
122 | ) -> some View {
123 | let sizeBinding = pointBinding.transform(
124 | getter: { pt -> CGSize in CGSize(width: pt.x, height: pt.y) },
125 | setter: { pt, newValue, _ in
126 | pt = CGPoint(x: newValue.width, y: newValue.height)
127 | }
128 | )
129 | return draggable(
130 | isDragging: isDragging,
131 | offset: sizeBinding,
132 | coordinateSpace: coordinateSpace
133 | )
134 | }
135 | }
136 |
137 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
138 | struct Draggable: ViewModifier {
139 | var isDragging: Binding?
140 | @Binding var offset: CGSize
141 | var coordinateSpace: CoordinateSpace
142 |
143 | @State private var lastTranslation: CGSize? = nil
144 |
145 | func body(content: Content) -> some View {
146 | content
147 | .gesture(dragGesture)
148 | }
149 |
150 | private var dragGesture: some Gesture {
151 | DragGesture(coordinateSpace: coordinateSpace)
152 | .onChanged { gv in
153 | isDragging?.wrappedValue = true
154 | if let last = lastTranslation {
155 | let delta = gv.translation - last
156 | offset = offset + delta
157 | lastTranslation = gv.translation
158 | } else {
159 | lastTranslation = gv.translation
160 | }
161 | }
162 | .onEnded { gv in
163 | lastTranslation = nil
164 | isDragging?.wrappedValue = false
165 | }
166 | }
167 | }
168 |
169 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
170 | struct ResizableAndDraggable_Previews: PreviewProvider {
171 | static var previews: some View {
172 | WithState(CGRect(x: 20, y: 20, width: 300, height: 300)) { $frame in
173 | Color.clear
174 | .overlay {
175 | Text("This view is resizable and draggable")
176 | .padding(20)
177 | .multilineTextAlignment(.center)
178 | }
179 | .resizableAndDraggable(frame: $frame, coordinateSpace: .named("coordSpace"))
180 | .background {
181 | Rectangle()
182 | .fill(.ultraThickMaterial)
183 | .shadow(radius: 5)
184 | }
185 | .frame(width: frame.width, height: frame.height)
186 | .offset(x: frame.minX, y: frame.minY)
187 | .coordinateSpace(name: "coordSpace")
188 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
189 | }
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/RuntimeWarnings.swift:
--------------------------------------------------------------------------------
1 | // Source: Point-Free, Swift Composable Architecture, RuntimeWarnings.swift
2 | // https://github.com/pointfreeco/swift-composable-architecture/blob/399bc83dcfc7bdcee99f7f6cc0a687ca29e8494b/Sources/ComposableArchitecture/Internal/RuntimeWarnings.swift
3 | //
4 | // Based on: Point-Free, Unobtrusive runtime warnings for libraries (2022-01-03)
5 | // https://www.pointfree.co/blog/posts/70-unobtrusive-runtime-warnings-for-libraries
6 | //
7 | // ---
8 | //
9 | // License:
10 | //
11 | // MIT License
12 | //
13 | // Copyright (c) 2020 Point-Free, Inc.
14 | //
15 | // Permission is hereby granted, free of charge, to any person obtaining a copy
16 | // of this software and associated documentation files (the "Software"), to deal
17 | // in the Software without restriction, including without limitation the rights
18 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19 | // copies of the Software, and to permit persons to whom the Software is
20 | // furnished to do so, subject to the following conditions:
21 | //
22 | // The above copyright notice and this permission notice shall be included in all
23 | // copies or substantial portions of the Software.
24 | //
25 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31 | // SOFTWARE.
32 | //
33 | // ---
34 | //
35 | // Slightly modified by Ole Begemann
36 |
37 | #if DEBUG
38 | import os
39 |
40 | // NB: Xcode runtime warnings offer a much better experience than traditional assertions and
41 | // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves.
42 | // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead.
43 | //
44 | // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc
45 | private let rw = (
46 | dso: { () -> UnsafeMutableRawPointer in
47 | let count = _dyld_image_count()
48 | for i in 0.. StaticString,
68 | _ args: @autoclosure () -> [CVarArg] = [],
69 | file: StaticString? = nil,
70 | line: UInt? = nil
71 | ) {
72 | #if DEBUG
73 | let message = message()
74 | unsafeBitCast(
75 | os_log as (OSLogType, UnsafeRawPointer, OSLog, StaticString, CVarArg...) -> Void,
76 | to: ((OSLogType, UnsafeRawPointer, OSLog, StaticString, [CVarArg]) -> Void).self
77 | )(.fault, rw.dso, rw.log, message, args())
78 | #endif
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/SelectedViewHighlight.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
4 | struct DebugLayoutSelectedViewID: EnvironmentKey {
5 | static var defaultValue: String? { nil }
6 | }
7 |
8 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
9 | extension EnvironmentValues {
10 | var debugLayoutSelectedViewID: String? {
11 | get { self[DebugLayoutSelectedViewID.self] }
12 | set { self[DebugLayoutSelectedViewID.self] = newValue }
13 | }
14 | }
15 |
16 | /// Draws a highlight (dashed border) around the view that's selected
17 | /// in the DebugLayout log table.
18 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
19 | struct DebugLayoutSelectionHighlight: ViewModifier {
20 | var viewID: String
21 | @Environment(\.debugLayoutSelectedViewID) private var selection: String?
22 |
23 | func body(content: Content) -> some View {
24 | content
25 | .overlay {
26 | let isSelected = viewID == selection
27 | if isSelected {
28 | Rectangle()
29 | .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [5]))
30 | .foregroundColor(.pink)
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/ViewMeasuring.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
5 | func measureSize(onChange: @escaping (CGSize) -> Void) -> some View {
6 | self
7 | .background {
8 | GeometryReader { geometry in
9 | Color.clear
10 | .preference(key: SizePreferenceKey.self, value: geometry.size)
11 | }
12 | }
13 | .onPreferenceChange(SizePreferenceKey.self) { size in
14 | if let size {
15 | onChange(size)
16 | }
17 | }
18 | }
19 | }
20 |
21 | enum SizePreferenceKey: PreferenceKey {
22 | static var defaultValue: CGSize? = nil
23 |
24 | static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) {
25 | value = value ?? nextValue()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/LayoutInspector/WithState.swift:
--------------------------------------------------------------------------------
1 | #if canImport(SwiftUI)
2 |
3 | import SwiftUI
4 |
5 | /// A wrapper view that takes a constant value and provides it to its child as a mutable `Binding`.
6 | ///
7 | /// Useful in Previews for previewing views that require a binding. You can't easily declare a
8 | /// `@State` variable in a PreviewProvider, and a `Binding.constant` doesn’t always cut it if you
9 | /// want to test a view’s dynamic behavior.
10 | ///
11 | /// Example:
12 | ///
13 | /// struct InteractiveStepper_Previews: PreviewProvider {
14 | /// static var previews: some View {
15 | /// WithState(5) { counterBinding in
16 | /// Stepper(value: counterBinding, in: 0...10) {
17 | /// Text("Counter: \(counterBinding.wrappedValue)")
18 | /// }
19 | /// }
20 | /// }
21 | /// }
22 | ///
23 | struct WithState: View {
24 | @State private var value: Value
25 | let content: (Binding) -> Content
26 |
27 | init(_ value: Value, @ViewBuilder content: @escaping (Binding) -> Content) {
28 | self._value = State(wrappedValue: value)
29 | self.content = content
30 | }
31 |
32 | var body: some View {
33 | content($value)
34 | }
35 | }
36 |
37 | struct WithState_Previews: PreviewProvider {
38 | static var previews: some View {
39 | WithState(5) { counterBinding in
40 | Stepper(value: counterBinding, in: 0...10) {
41 | Text("Counter: \(counterBinding.wrappedValue)")
42 | }
43 | .padding()
44 | }
45 | }
46 | }
47 |
48 | #endif
49 |
--------------------------------------------------------------------------------
/assets/LayoutInspector-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ole/swiftui-layout-inspector/bc5561beca28bbec74970599b728bbd20d0bffba/assets/LayoutInspector-screenshot.png
--------------------------------------------------------------------------------