├── .gitignore ├── Package.swift ├── Sources └── Containers │ ├── Deprecations.swift │ ├── FittingGeometry.swift │ ├── ScrollableView.swift │ ├── UIKitView.swift │ ├── PageView.swift │ └── LayoutReader.swift ├── LICENSE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Containers", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13) 12 | ], 13 | products: [ 14 | .library( 15 | name: "Containers", 16 | targets: ["Containers"] 17 | ), 18 | ], 19 | targets: [ 20 | .target(name: "Containers") 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /Sources/Containers/Deprecations.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension LayoutProxy { 4 | @available(*, deprecated, renamed: "size(in:)", message: "Please use size(in:) instead") 5 | public func frame(in layout: Layout) -> CGRect { 6 | switch layout { 7 | case .safeArea: 8 | return safeArea 9 | case .content: 10 | return content 11 | case .readable: 12 | return readable 13 | case .container: 14 | return container 15 | } 16 | } 17 | } 18 | 19 | @available(swift, obsoleted: 1.0, renamed: "ScrollableView") 20 | public typealias ScrollView = ScrollableView 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shaps Benkau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Sources/Containers/FittingGeometry.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A geometry reader that automatically sizes its height to 'fit' its content. 4 | public struct FittingGeometryReader: View where Content: View { 5 | 6 | @State private var height: CGFloat = 10 // must be non-zero 7 | private var content: (GeometryProxy) -> Content 8 | 9 | public init(@ViewBuilder content: @escaping (GeometryProxy) -> Content) { 10 | self.content = content 11 | } 12 | 13 | public var body: some View { 14 | GeometryReader { geo in 15 | content(geo) 16 | .fixedSize(horizontal: false, vertical: true) 17 | .modifier(SizeModifier()) 18 | .onPreferenceChange(SizePreferenceKey.self) { 19 | height = $0.height 20 | } 21 | } 22 | .frame(height: height) 23 | } 24 | 25 | } 26 | 27 | private struct SizePreferenceKey: PreferenceKey { 28 | static var defaultValue: CGSize = .zero 29 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { 30 | value = nextValue() 31 | } 32 | } 33 | 34 | private struct SizeModifier: ViewModifier { 35 | func body(content: Content) -> some View { 36 | content.overlay( 37 | GeometryReader { geo in 38 | Color.clear.preference( 39 | key: SizePreferenceKey.self, 40 | value: geo.size 41 | ) 42 | } 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/Containers/ScrollableView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A scrollview that behaves more similarly to a `VStack` when its content size is small enough. 4 | public struct ScrollableView: View { 5 | 6 | private let content: Content 7 | private let showsIndicators: Bool 8 | private let contentMode: ContentMode 9 | 10 | /// A new scrollview 11 | /// - Parameters: 12 | /// - showsIndicators: If true, the scroll view will show indicators when necessary 13 | /// - contentMode: How the content should be sized. Defaults to `fill` which behaves identically to a standard `ScrollView` 14 | /// - content: The content for this scroll view 15 | public init(showsIndicators: Bool = true, contentMode: ContentMode = .fit, @ViewBuilder content: () -> Content) { 16 | self.showsIndicators = showsIndicators 17 | self.contentMode = contentMode 18 | self.content = content() 19 | } 20 | 21 | public var body: some View { 22 | GeometryReader { geo in 23 | ZStack(alignment: .bottom) { 24 | ZStack(alignment: .top) { 25 | SwiftUI.ScrollView(showsIndicators: showsIndicators) { 26 | VStack(spacing: 10) { 27 | content 28 | } 29 | .frame( 30 | maxWidth: contentMode == .fill ? geo.size.width : nil, 31 | minHeight: contentMode == .fill ? geo.size.height : nil 32 | ) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Containers/UIKitView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(iOS) 4 | 5 | /// A SwiftUI view that accepts a single UIView instance to be presented in the hierarchy. 6 | /// 7 | /// Note: Some views do not automatically size correctly and may require special handling. This may provide some convenience however in other cases. 8 | public struct UIKitView: View { 9 | 10 | @State private var height: CGFloat = 10 11 | private let content: Content 12 | 13 | public init(@UIViewBuilder _ content: () -> Content) { 14 | self.content = content() 15 | self.content.backgroundColor = .clear 16 | } 17 | 18 | public var body: some View { 19 | Representable(content: content, height: $height) 20 | .frame(height: height) 21 | } 22 | } 23 | 24 | private extension UIKitView { 25 | 26 | struct Representable: UIViewRepresentable { 27 | let content: Content 28 | let height: Binding 29 | 30 | func makeCoordinator() -> Coordinator { 31 | Coordinator(content: content) 32 | } 33 | 34 | func makeUIView(context: Context) -> Content { 35 | context.coordinator.content 36 | } 37 | 38 | func updateUIView(_ view: Content, context: Context) { 39 | Self.calculateHeight(view: view, result: height) 40 | context.coordinator.update(content: view) 41 | } 42 | 43 | fileprivate static func calculateHeight(view: UIView, result: Binding) { 44 | let newSize = view.sizeThatFits(CGSize(width: view.frame.width, height: .greatestFiniteMagnitude)) 45 | guard result.wrappedValue != newSize.height else { return } 46 | DispatchQueue.main.async { // call in next render cycle. 47 | result.wrappedValue = newSize.height 48 | } 49 | } 50 | } 51 | 52 | } 53 | 54 | private extension UIKitView.Representable { 55 | 56 | final class Coordinator { 57 | let content: Content 58 | 59 | init(content: Content) { 60 | self.content = content 61 | content.setContentHuggingPriority(.defaultLow, for: .horizontal) 62 | content.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 63 | content.autoresizingMask = [.flexibleWidth, .flexibleHeight] 64 | content.backgroundColor = .clear 65 | } 66 | 67 | func update(content: Content) { 68 | content.setNeedsDisplay() 69 | } 70 | } 71 | 72 | } 73 | 74 | @resultBuilder 75 | public struct UIViewBuilder { 76 | public static func buildBlock(_ components: UIView) -> UIView { 77 | components 78 | } 79 | } 80 | 81 | #endif 82 | -------------------------------------------------------------------------------- /Sources/Containers/PageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(iOS) 4 | 5 | /// A SwiftUI view that provides similar behaviour to a `UIPageViewController` that includes automatic sizing options. 6 | @available(iOS 14, *) 7 | public struct PageView: View where SelectionValue: Hashable, Content: View { 8 | 9 | private var selection: Binding? 10 | private var contentMode: ContentMode 11 | private var content: () -> Content 12 | 13 | @State private var height: CGFloat = 10 // must be non-zero 14 | 15 | /// Makes a new page view 16 | /// - Parameters: 17 | /// - selection: A binding to the currently selected page, can be used to keep a separate view in-sync 18 | /// - contentMode: Apply `fit` to have the height automatically resize to fit the content. Defaults to `fill` 19 | /// - content: The content for this page view. Each view will be shown on its own page 20 | public init(selection: Binding, contentMode: ContentMode = .fill, @ViewBuilder content: @escaping () -> Content) { 21 | self.selection = selection 22 | self.contentMode = contentMode 23 | self.content = content 24 | } 25 | 26 | public var body: some View { 27 | TabView(selection: selection) { 28 | content() 29 | .modifier(SizeModifier()) 30 | .onPreferenceChange(SizePreferenceKey.self) { 31 | height = $0.height 32 | } 33 | } 34 | .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) 35 | .frame(height: contentMode == .fit ? height : nil) 36 | } 37 | 38 | } 39 | 40 | @available(iOS 14, *) 41 | extension PageView where SelectionValue == Int { 42 | public init(contentMode: ContentMode = .fill, @ViewBuilder content: @escaping () -> Content) { 43 | self.selection = .constant(0) 44 | self.contentMode = contentMode 45 | self.content = content 46 | } 47 | } 48 | 49 | @available(iOS 14, *) 50 | struct PageView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | PageView(contentMode: .fill) { 53 | Text("Page 1") 54 | Text("Page 2") 55 | Text("Page 3") 56 | } 57 | .previewLayout(.sizeThatFits) 58 | } 59 | } 60 | 61 | private struct SizePreferenceKey: PreferenceKey { 62 | public static var defaultValue: CGSize = .zero 63 | public static func reduce(value: inout CGSize, nextValue: () -> CGSize) { 64 | value = nextValue() 65 | } 66 | } 67 | 68 | private struct SizeModifier: ViewModifier { 69 | public func body(content: Content) -> some View { 70 | content.overlay( 71 | GeometryReader { geo in 72 | Color.clear.preference(key: SizePreferenceKey.self, value: geo.size) 73 | } 74 | ) 75 | } 76 | } 77 | 78 | #endif 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ios](https://img.shields.io/badge/iOS-13-green) 2 | 3 | ---- 4 | 5 | > Most of these containers now available as in a single Backports library, with a LOT more additions. This should simply my efforts and allow me and others to contribute more backports in the near future. 6 | > [SwiftUI Backports](https://github.com/shaps80/SwiftUIBackports) 7 | 8 | ---- 9 | 10 | # Containers 11 | 12 | > Also available as a part of my [SwiftUI+ Collection](https://benkau.com/packages.json) – just add it to Xcode 13+ 13 | 14 | Useful SwiftUI container view's for additional convenience. 15 | 16 | Includes: 17 | 18 | - FittingGeometryReader (auto-sizes its height) 19 | - ScrollableView (support various contentMode options) 20 | - LayoutReader (supports readable and other other guides) 21 | - PageView (TabView in paging style but supports auto-sizing) 22 | - UIKit View (Easily nest UIView's in SwiftUI, including auto-sizing) 23 | 24 | ## FittingGeometry 25 | 26 | A geometry reader that automatically sizes its height to 'fit' its content. 27 | 28 | ```swift 29 | FittingGeometryReader { geo in 30 | Text("The height is now \(geo.size.height)") 31 | } 32 | ``` 33 | 34 | ## LayoutReader 35 | 36 | A container view that provides a layout proxy, allowing you to query various layout properties usually only available via UIKit. 37 | 38 | The most useful example is layout-relative to the `readableContentGuide` 39 | 40 | **Features** 41 | 42 | - Familiar API (similar to GeometryReader) 43 | - SafeArea, content (layoutMargins) and readable content guide layouts 44 | - Responds automatically to dynamic type changes 45 | - Respects interface orientation and other layout changes 46 | 47 | ```swift 48 | LayoutReader { layout in 49 | Rectangle() 50 | .foregroundColor(.red) 51 | .frame(maxWidth: layout.frame(in: .readable).width) 52 | } 53 | ``` 54 | 55 | ## ScrollView 56 | 57 | A scrollview that behaves more similarly to a `VStack` when its content size is small enough. 58 | 59 | ```swift 60 | ScrollView(contentMode: .fit) { 61 | Text("I'm aligned to the top") 62 | Spacer() 63 | Text("I'm aligned to the bottom, until you scroll ;)") 64 | } 65 | ``` 66 | 67 | ## PageView 68 | 69 | A page view that behaves similarly to UIPageViewController but adds auto-sizing configuration. 70 | 71 | ```swift 72 | // Passing `fit` for the contentMode forces the PageView to hug its content. To fill the available space, set this to `fill` (its default value) 73 | PageView(selection: $currentPage, contentMode: .fit) { 74 | Group { 75 | Text("Page 1") 76 | Text("Page 2") 77 | Text("Page 3") 78 | } 79 | } 80 | ``` 81 | 82 | > Note: This view requires iOS 14+ 83 | 84 | ## UIKitView 85 | 86 | A SwiftUI view that accepts a single UIView instance to be presented in the hierarchy. 87 | 88 | ```swift 89 | UIKitView { 90 | let label = UILabel(frame: .zero) 91 | label.text = "foo" 92 | return label 93 | } 94 | ``` 95 | 96 | ## Installation 97 | 98 | The code is packaged as a framework. You can install manually (by copying the files in the `Sources` directory) or using Swift Package Manager (**preferred**) 99 | 100 | To install using Swift Package Manager, add this to the `dependencies` section of your `Package.swift` file: 101 | 102 | `.package(url: "https://github.com/SwiftUI-Plus/Containers.git", .upToNextMinor(from: "1.0.0"))` 103 | 104 | ## Other Packages 105 | 106 | If you want easy access to this and more packages, add the following collection to your Xcode 13+ configuration: 107 | 108 | `https://benkau.com/packages.json` 109 | -------------------------------------------------------------------------------- /Sources/Containers/LayoutReader.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | #if os(iOS) 4 | 5 | /// A container view that provides a layout proxy, allowing you to query various layout properties usually only available via UIKit. 6 | /// The most useful example is layout-relative to the `readableContentGuide` 7 | public struct LayoutReader: View { 8 | 9 | @State private var proxy: LayoutProxy = .zero 10 | private let content: (LayoutProxy) -> Content 11 | 12 | /// A new layout reader 13 | /// - Parameter content: The content for this view, the proxy provides layout-guide-relative frames for convenience 14 | public init(@ViewBuilder _ content: @escaping (LayoutProxy) -> Content) { 15 | self.content = content 16 | } 17 | 18 | public var body: some View { 19 | Representable(proxy: $proxy) { layout in 20 | VStack(spacing: 0) { content(layout) } 21 | .fixedSize(horizontal: false, vertical: true) 22 | .frame(width: proxy.size(in: .container).width) 23 | } 24 | } 25 | 26 | } 27 | 28 | private extension LayoutReader { 29 | 30 | struct Representable: UIViewRepresentable { 31 | 32 | let proxy: Binding 33 | let content: (LayoutProxy) -> Content 34 | 35 | func makeCoordinator() -> Coordinator { 36 | Coordinator(content: content(proxy.wrappedValue), proxy: proxy) 37 | } 38 | 39 | func makeUIView(context: Context) -> UIView { 40 | context.coordinator.controller.view 41 | } 42 | 43 | func updateUIView(_ view: UIView, context: Context) { 44 | context.coordinator.update(content: content(proxy.wrappedValue)) 45 | } 46 | 47 | } 48 | 49 | } 50 | 51 | private extension LayoutReader.Representable { 52 | 53 | final class Coordinator { 54 | let content: Content 55 | let controller: Controller 56 | 57 | init(content: Content, proxy: Binding) { 58 | self.content = content 59 | controller = Controller(proxy: proxy, content: content) 60 | controller.view.setContentHuggingPriority(.required, for: .vertical) 61 | controller.view.setContentCompressionResistancePriority(.required, for: .vertical) 62 | controller.view.setContentHuggingPriority(.defaultLow, for: .horizontal) 63 | controller.view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 64 | controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 65 | controller.view.backgroundColor = .clear 66 | } 67 | 68 | func update(content: Content) { 69 | controller.rootView = content 70 | controller.view.setNeedsDisplay() 71 | } 72 | } 73 | 74 | final class Controller: UIHostingController { 75 | private let proxy: Binding 76 | 77 | init(proxy: Binding, content: Content) { 78 | self.proxy = proxy 79 | super.init(rootView: content) 80 | } 81 | 82 | @objc required dynamic init?(coder aDecoder: NSCoder) { 83 | fatalError("init(coder:) has not been implemented") 84 | } 85 | 86 | override func viewWillLayoutSubviews() { 87 | super.viewWillLayoutSubviews() 88 | 89 | let layout = LayoutProxy( 90 | safeArea: view.safeAreaLayoutGuide.layoutFrame, 91 | content: view.layoutMarginsGuide.layoutFrame, 92 | readable: view.readableContentGuide.layoutFrame, 93 | container: view.bounds 94 | ) 95 | 96 | guard proxy.wrappedValue != layout else { return } 97 | proxy.wrappedValue = layout 98 | } 99 | } 100 | 101 | } 102 | 103 | #else 104 | 105 | /// A container view that provides a layout proxy, allowing you to query various layout properties usually only available via UIKit. 106 | /// The most useful example is layout-relative to the `readableContentGuide` 107 | public struct LayoutReader: View { 108 | 109 | @State private var proxy: LayoutProxy = .zero 110 | private let content: (LayoutProxy) -> Content 111 | 112 | /// A new layout reader 113 | /// - Parameter content: The content for this view, the proxy provides layout-guide-relative frames for convenience 114 | public init(@ViewBuilder _ content: @escaping (LayoutProxy) -> Content) { 115 | self.content = content 116 | } 117 | 118 | public var body: some View { 119 | GeometryReader { geo in 120 | VStack(spacing: 0) { 121 | content( 122 | LayoutProxy( 123 | safeArea: geo.frame(in: .global), 124 | content: geo.frame(in: .global), 125 | readable: geo.frame(in: .global), 126 | container: geo.frame(in: .global) 127 | ) 128 | ) 129 | } 130 | .fixedSize(horizontal: false, vertical: true) 131 | .frame(width: proxy.size(in: .container).width) 132 | } 133 | } 134 | 135 | } 136 | 137 | #endif 138 | 139 | /// A proxy for access to the size of the container view relative to a layout 140 | public struct LayoutProxy: Equatable { 141 | public enum Layout { 142 | /// The safeArea relative layout 143 | case safeArea 144 | /// The layoutMargins relative layout 145 | case content 146 | /// The readableContent relative layout 147 | case readable 148 | /// The container relative layout 149 | case container 150 | } 151 | 152 | internal let safeArea: CGRect 153 | internal let content: CGRect 154 | internal let readable: CGRect 155 | internal let container: CGRect 156 | 157 | /// Returns the container's size, relative to the defined layout 158 | public func size(in layout: Layout) -> CGSize { 159 | switch layout { 160 | case .safeArea: 161 | return safeArea.size 162 | case .content: 163 | return content.size 164 | case .readable: 165 | return readable.size 166 | case .container: 167 | return container.size 168 | } 169 | } 170 | } 171 | 172 | internal extension LayoutProxy { 173 | static var zero = Self(safeArea: .zero, content: .zero, readable: .zero, container: .zero) 174 | } 175 | --------------------------------------------------------------------------------