├── .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 | --------------------------------------------------------------------------------