├── .gitattributes
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
└── Sources
└── SwiftUILayoutGuides
└── SwiftUILayoutGuides.swift
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Thomas Grapperon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.4
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "swiftui-layout-guides",
6 | platforms: [
7 | .iOS(.v13),
8 | .macOS(.v10_15),
9 | .tvOS(.v13),
10 | .watchOS(.v6),
11 | ],
12 | products: [
13 | .library(
14 | name: "SwiftUILayoutGuides",
15 | targets: ["SwiftUILayoutGuides"])
16 | ],
17 | dependencies: [],
18 | targets: [
19 | .target(
20 | name: "SwiftUILayoutGuides",
21 | dependencies: [])
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUI Layout Guides
2 | This micro-library exposes UIKit's layout margins and readable content guides to SwiftUI.
3 |
4 | ## Usage
5 | ### Make a view fit the readable content width
6 | Simply call the `fitToReadableContentWidth()` modifier:
7 | ```swift
8 | List {
9 | ForEach(…) {
10 | Cell()
11 | .fitToReadableContentWidth()
12 | }
13 | }
14 | ```
15 | ### Expose the layout margins in a block
16 | Wrap your view in the `WithLayoutMargins` view. The initializer supports two variants: one closure without argument and one closure with a `EdgeInsets` argument. In this last case, the insets correspond to the layout margins for the content:
17 | ```swift
18 | WithLayoutMargins { layoutMarginsInsets in
19 | Text("ABC")
20 | .padding(.leading, layoutMarginsInsets.leading)
21 | }
22 | ```
23 | ### Expose layout margins and readable content guides in a view
24 | You need two wrap your view in `WithLayoutMargins` (you can use the argument-less closure). This will populate the content's `Environment` with the layout margins and readable content in the form of insets.
25 | ```swift
26 | WithLayoutMargins {
27 | Content()
28 | }
29 |
30 | struct Content: View {
31 | @Environment(\.layoutMarginsInsets) var layoutMarginsInsets
32 | @Environment(\.readableContentInsets) var readableContentInsets
33 | var body: some View {
34 | Text("ABC")
35 | .padding(.leading, layoutMarginsInsets.leading)
36 | …
37 | }
38 | }
39 | ```
40 | These insets are only valid for the bounds of the root content view. Using them deeper in the hierachy may lead to insconsitent results and you should use the `measureLayoutMargins()` modifier if you want to refresh the insets for the target view.
41 |
42 | ## Installation
43 | Add `.package(url: "https://github.com/tgrapperon/swiftui-layout-guides", from: "0.0.1")` to your Package dependencies, and then
44 | ```
45 | .product(name: "SwiftUILayoutGuides", package: "swiftui-layout-guides")
46 | ```
47 | to your target's dependencies.
48 |
49 |
--------------------------------------------------------------------------------
/Sources/SwiftUILayoutGuides/SwiftUILayoutGuides.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// This view populates its content's ``layoutMarginsInsets`` and ``readableContentInsets``.
4 | public struct WithLayoutMargins: View where Content: View {
5 | let content: (EdgeInsets) -> Content
6 |
7 | /// Initialize a ``WithLayoutMargins`` view, populating its content's ``layoutMarginsInsets``
8 | /// and ``readableContentInsets``.
9 | ///
10 | /// - Parameter content: A closure that builds a `Content` view from the layout
11 | /// margins provided in the form of an `EdgeInsets` argument.
12 | public init(@ViewBuilder content: @escaping (EdgeInsets) -> Content) {
13 | self.content = content
14 | }
15 |
16 | /// Initialize a ``WithLayoutMargins`` view, populating its content's ``layoutMarginsInsets``
17 | /// and ``readableContentInsets``.
18 | ///
19 | /// - Parameter content: A closure that builds a `Content` view.
20 | public init(@ViewBuilder content: @escaping () -> Content) {
21 | self.content = { _ in content() }
22 | }
23 |
24 | public var body: some View {
25 | InsetContent(content: content)
26 | .measureLayoutMargins()
27 | }
28 |
29 | private struct InsetContent: View {
30 | let content: (EdgeInsets) -> Content
31 | @Environment(\.layoutMarginsInsets) var layoutMarginsInsets
32 | var body: some View {
33 | content(layoutMarginsInsets)
34 | }
35 | }
36 | }
37 |
38 | /// This view makes its content `View` fit the readable content width.
39 | ///
40 | /// - Note: This modifier is equivalent to calling ``.fitToReadableContentWidth()`` on
41 | /// the content view.
42 | @available(
43 | iOS, deprecated: 9999.0, message: "Use the `.fitToReadableContentWidth` modifier instead."
44 | )
45 | @available(
46 | macOS, deprecated: 9999.0, message: "Use the `.fitToReadableContentWidth` modifier instead."
47 | )
48 | @available(
49 | tvOS, deprecated: 9999.0, message: "Use the `.fitToReadableContentWidth` modifier instead."
50 | )
51 | @available(
52 | watchOS, deprecated: 9999.0, message: "Use the `.fitToReadableContentWidth` modifier instead."
53 | )
54 | public struct FitReadableContentWidth: View where Content: View {
55 | let alignment: Alignment
56 | let content: Content
57 |
58 | /// Initialize some ``FitReadableContentWidth`` view.
59 | ///
60 | /// - Parameters:
61 | /// - alignment: The `Alignment` to use when `content` is smaller than
62 | /// the readable content width.
63 | /// - content: The view that should fit the readable content width.
64 | public init(
65 | alignment: Alignment = .center,
66 | @ViewBuilder content: () -> Content
67 | ) {
68 | self.alignment = alignment
69 | self.content = content()
70 | }
71 |
72 | public var body: some View {
73 | self.modifier(FitLayoutGuidesWidth(alignment: alignment, kind: .readableContent))
74 | }
75 | }
76 |
77 | /// This view makes its content `View` fit the layout margins guide width.
78 | ///
79 | /// - Note: This modifier is equivalent to calling ``.fitToLayoutMarginsWidth()`` on
80 | /// the content view.
81 | @available(iOS, deprecated: 9999.0, message: "Use the `.fitToLayoutMarginsWidth` modifier instead.")
82 | @available(
83 | macOS, deprecated: 9999.0, message: "Use the `.fitToLayoutMarginsWidth` modifier instead."
84 | )
85 | @available(
86 | tvOS, deprecated: 9999.0, message: "Use the `.fitToLayoutMarginsWidth` modifier instead."
87 | )
88 | @available(
89 | watchOS, deprecated: 9999.0, message: "Use the `.fitToLayoutMarginsWidth` modifier instead."
90 | )
91 | public struct FitLayoutMarginsWidth: View where Content: View {
92 | let alignment: Alignment
93 | let content: Content
94 |
95 | /// Initialize some ``FitLayoutMarginsWidth`` view.
96 | ///
97 | /// - Parameters:
98 | /// - alignment: The `Alignment` to use when `content` is smaller than
99 | /// the layout margins guide width.
100 | /// - content: The view that should fit the layout margins guide width.
101 | public init(
102 | alignment: Alignment = .center,
103 | @ViewBuilder content: () -> Content
104 | ) {
105 | self.alignment = alignment
106 | self.content = content()
107 | }
108 |
109 | public var body: some View {
110 | self.modifier(FitLayoutGuidesWidth(alignment: alignment, kind: .layoutMargins))
111 | }
112 | }
113 |
114 | internal struct FitLayoutGuidesWidth: ViewModifier {
115 | enum Kind {
116 | case layoutMargins
117 | case readableContent
118 | }
119 |
120 | let alignment: Alignment
121 | let kind: Kind
122 |
123 | func body(content: Content) -> some View {
124 | switch kind {
125 | case .layoutMargins:
126 | content.modifier(InsetLayoutMargins(alignment: alignment))
127 | .measureLayoutMargins()
128 | case .readableContent:
129 | content.modifier(InsetReadableContent(alignment: alignment))
130 | .measureLayoutMargins()
131 | }
132 | }
133 |
134 | private struct InsetReadableContent: ViewModifier {
135 | let alignment: Alignment
136 | @Environment(\.readableContentInsets) var readableContentInsets
137 | func body(content: Content) -> some View {
138 | content
139 | .frame(maxWidth: .infinity, alignment: alignment)
140 | .padding(.leading, readableContentInsets.leading)
141 | .padding(.trailing, readableContentInsets.trailing)
142 | }
143 | }
144 |
145 | private struct InsetLayoutMargins: ViewModifier {
146 | let alignment: Alignment
147 | @Environment(\.layoutMarginsInsets) var layoutMarginsInsets
148 | func body(content: Content) -> some View {
149 | content
150 | .frame(maxWidth: .infinity, alignment: alignment)
151 | .padding(.leading, layoutMarginsInsets.leading)
152 | .padding(.trailing, layoutMarginsInsets.trailing)
153 | }
154 | }
155 | }
156 |
157 | extension View {
158 | /// Use this modifier to make the view fit the readable content width.
159 | ///
160 | /// - Parameter alignment: The `Alignment` to use when the view is smaller than
161 | /// the readable content width.
162 | /// - Note: You don't have to wrap this view inside a ``WithLayoutMargins`` view.
163 | /// - Note: This modifier is equivalent to wrapping the view inside a
164 | /// ``FitReadableContentWidth`` view.
165 | public func fitToReadableContentWidth(alignment: Alignment = .center) -> some View {
166 | self.modifier(FitLayoutGuidesWidth(alignment: alignment, kind: .readableContent))
167 | }
168 |
169 | /// Use this modifier to make the view fit the layout margins guide width.
170 | ///
171 | /// - Parameter alignment: The `Alignment` to use when the view is smaller than
172 | /// the readable content width.
173 | /// - Note: You don't have to wrap this view inside a ``WithLayoutMargins`` view.
174 | /// - Note: This modifier is equivalent to wrapping the view inside a
175 | /// ``FitLayoutMarginsWidth`` view.
176 | public func fitToLayoutMarginsWidth(alignment: Alignment = .center) -> some View {
177 | self.modifier(FitLayoutGuidesWidth(alignment: alignment, kind: .layoutMargins))
178 | }
179 | /// Use this modifier to populate the ``layoutMarginsInsets`` and ``readableContentInsets``
180 | /// for the target view.
181 | ///
182 | /// - Note: You don't have to wrap this view inside a ``WithLayoutMargins`` view.
183 | public func measureLayoutMargins() -> some View {
184 | self.modifier(LayoutGuidesModifier())
185 | }
186 | }
187 |
188 | private struct LayoutMarginsGuidesKey: EnvironmentKey {
189 | static var defaultValue: EdgeInsets { .init() }
190 | }
191 |
192 | private struct ReadableContentGuidesKey: EnvironmentKey {
193 | static var defaultValue: EdgeInsets { .init() }
194 | }
195 |
196 | extension EnvironmentValues {
197 | /// The `EdgeInsets` corresponding to the layout margins of the nearest
198 | /// ``WithLayoutMargins``'s content.
199 | public var layoutMarginsInsets: EdgeInsets {
200 | get { self[LayoutMarginsGuidesKey.self] }
201 | set { self[LayoutMarginsGuidesKey.self] = newValue }
202 | }
203 |
204 | /// The `EdgeInsets` corresponding to the readable content of the nearest
205 | /// ``WithLayoutMargins``'s content.
206 | public var readableContentInsets: EdgeInsets {
207 | get { self[ReadableContentGuidesKey.self] }
208 | set { self[ReadableContentGuidesKey.self] = newValue }
209 | }
210 | }
211 |
212 | struct LayoutGuidesModifier: ViewModifier {
213 | @State var layoutMarginsInsets: EdgeInsets = .init()
214 | @State var readableContentInsets: EdgeInsets = .init()
215 |
216 | func body(content: Content) -> some View {
217 | content
218 | #if os(iOS) || os(tvOS)
219 | .environment(\.layoutMarginsInsets, layoutMarginsInsets)
220 | .environment(\.readableContentInsets, readableContentInsets)
221 | .background(
222 | LayoutGuides(
223 | onLayoutMarginsGuideChange: {
224 | layoutMarginsInsets = $0
225 | },
226 | onReadableContentGuideChange: {
227 | readableContentInsets = $0
228 | })
229 | )
230 | #endif
231 | }
232 | }
233 |
234 | #if os(iOS) || os(tvOS)
235 | import UIKit
236 | struct LayoutGuides: UIViewRepresentable {
237 | let onLayoutMarginsGuideChange: (EdgeInsets) -> Void
238 | let onReadableContentGuideChange: (EdgeInsets) -> Void
239 |
240 | func makeUIView(context: Context) -> LayoutGuidesView {
241 | let uiView = LayoutGuidesView()
242 | uiView.onLayoutMarginsGuideChange = onLayoutMarginsGuideChange
243 | uiView.onReadableContentGuideChange = onReadableContentGuideChange
244 | return uiView
245 | }
246 |
247 | func updateUIView(_ uiView: LayoutGuidesView, context: Context) {
248 | uiView.onLayoutMarginsGuideChange = onLayoutMarginsGuideChange
249 | uiView.onReadableContentGuideChange = onReadableContentGuideChange
250 | }
251 |
252 | final class LayoutGuidesView: UIView {
253 | var onLayoutMarginsGuideChange: (EdgeInsets) -> Void = { _ in }
254 | var onReadableContentGuideChange: (EdgeInsets) -> Void = { _ in }
255 |
256 | override func layoutMarginsDidChange() {
257 | super.layoutMarginsDidChange()
258 | updateLayoutMargins()
259 | updateReadableContent()
260 | }
261 |
262 | override func layoutSubviews() {
263 | super.layoutSubviews()
264 | updateReadableContent()
265 | }
266 |
267 | // `layoutSubviews` doesn't seem late enough to retrieve an up-to-date `readableContentGuide`
268 | // in some cases, like when toggling the sidebar in a NavigationSplitView on iPad.
269 | // It seems that observing the `frame` is enough to fix this edge case, but a better
270 | // heuristic would be preferable.
271 | override var frame: CGRect {
272 | didSet {
273 | self.updateReadableContent()
274 | }
275 | }
276 |
277 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
278 | super.traitCollectionDidChange(previousTraitCollection)
279 | if traitCollection.layoutDirection != previousTraitCollection?.layoutDirection {
280 | updateReadableContent()
281 | }
282 | }
283 |
284 | var previousLayoutMargins: EdgeInsets? = nil
285 | func updateLayoutMargins() {
286 | let edgeInsets = EdgeInsets(
287 | top: directionalLayoutMargins.top,
288 | leading: directionalLayoutMargins.leading,
289 | bottom: directionalLayoutMargins.bottom,
290 | trailing: directionalLayoutMargins.trailing
291 | )
292 | guard previousLayoutMargins != edgeInsets else { return }
293 | onLayoutMarginsGuideChange(edgeInsets)
294 | previousLayoutMargins = edgeInsets
295 | }
296 |
297 | var previousReadableContentGuide: EdgeInsets? = nil
298 | func updateReadableContent() {
299 | let isRightToLeft = traitCollection.layoutDirection == .rightToLeft
300 | let layoutFrame = readableContentGuide.layoutFrame
301 |
302 | let readableContentInsets =
303 | UIEdgeInsets(
304 | top: layoutFrame.minY - bounds.minY,
305 | left: layoutFrame.minX - bounds.minX,
306 | bottom: -(layoutFrame.maxY - bounds.maxY),
307 | right: -(layoutFrame.maxX - bounds.maxX)
308 | )
309 | let edgeInsets = EdgeInsets(
310 | top: readableContentInsets.top,
311 | leading: isRightToLeft ? readableContentInsets.right : readableContentInsets.left,
312 | bottom: readableContentInsets.bottom,
313 | trailing: isRightToLeft ? readableContentInsets.left : readableContentInsets.right
314 | )
315 | guard previousReadableContentGuide != edgeInsets else { return }
316 | onReadableContentGuideChange(edgeInsets)
317 | previousReadableContentGuide = edgeInsets
318 | }
319 | }
320 | }
321 | #endif
322 |
323 | #if DEBUG
324 | struct Cell: View {
325 | var value: String
326 | var body: some View {
327 | ZStack {
328 | Text(value)
329 | .frame(maxWidth: .infinity)
330 | }
331 | .background(Color.blue.opacity(0.3))
332 | .border(Color.blue) // This view fits in readable content width
333 | .fitToReadableContentWidth()
334 | .border(Color.red) // This view is unconstrained
335 | }
336 | }
337 |
338 | struct ListTest: View {
339 | var body: some View {
340 | List {
341 | ForEach(0..<30) {
342 | Cell(value: "\($0)")
343 | }
344 | }
345 | }
346 | }
347 |
348 | struct ScrollViewTest: View {
349 | var body: some View {
350 | ScrollView {
351 | VStack(spacing: 0) {
352 | ForEach(0..<30) {
353 | Cell(value: "\($0)")
354 | }
355 | }
356 | }
357 | }
358 | }
359 |
360 | #if os(iOS)
361 | @available(iOS 16.0, *)
362 | struct SwiftUILayoutGuides_Previews: PreviewProvider {
363 | static func sample(_ title: String, _ content: () -> Content) -> some View
364 | where Content: View {
365 | VStack(alignment: .leading) {
366 | Text(title)
367 | .font(Font.system(size: 20, weight: .bold))
368 | .padding()
369 | content()
370 | }
371 | .border(Color.primary, width: 2)
372 | }
373 |
374 | static var previews: some View {
375 | NavigationSplitView {
376 | VStack(spacing: 0) {
377 | sample("ScrollView") { ScrollViewTest() }
378 | sample("List.plain") { ListTest().listStyle(.plain) }
379 | #if os(iOS) || os(tvOS)
380 | sample("List.grouped") { ListTest().listStyle(.grouped) }
381 | sample("List.insetGrouped") { ListTest().listStyle(.insetGrouped) }
382 | #endif
383 | }
384 | } detail: {
385 | VStack(spacing: 0) {
386 | sample("ScrollView") { ScrollViewTest() }
387 | sample("List.plain") { ListTest().listStyle(.plain) }
388 | #if os(iOS) || os(tvOS)
389 | sample("List.grouped") { ListTest().listStyle(.grouped) }
390 | sample("List.insetGrouped") { ListTest().listStyle(.insetGrouped) }
391 | #endif
392 | }
393 | }
394 | .previewInterfaceOrientation(.landscapeRight)
395 | .previewDevice(PreviewDevice(rawValue: "iPad Pro (11-inch) (4th generation)"))
396 | }
397 | }
398 | #endif
399 | #endif
400 |
--------------------------------------------------------------------------------