├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── LICENSE ├── Package.swift ├── README.md ├── README_Assets ├── rectangle_preview.gif ├── tabview_preview.gif └── video_preview.gif ├── Sources └── SwiftUIDrag │ ├── GeometryEngine │ ├── Bounds │ │ ├── SDCollapseBounds.swift │ │ └── SDFloatingBounds.swift │ ├── Offset │ │ ├── SDCollapseOffset.swift │ │ ├── SDFloatingOffset.swift │ │ └── SDOffset.swift │ ├── SDDistance.swift │ └── SDGeometryEngine.swift │ ├── Helpers │ ├── SDCoordinateSpaceNames.swift │ └── ViewSizeReader.swift │ ├── Options │ ├── SDCollapseOptions.swift │ └── SDFloatingOptions.swift │ ├── SDView.swift │ └── State │ ├── SDContentState.swift │ └── SDDragState.swift └── Tests ├── LinuxMain.swift └── SwiftUIDragTests ├── SDCollapseBounds+Testable.swift ├── SDCollapseBoundsTests.swift ├── SDCollapseOffsetTests.swift ├── SDDistance+Testable.swift ├── SDDistanceTests.swift ├── SDFloatingBounds+Testable.swift ├── SDFloatingBoundsTests.swift ├── SDFloatingOffsetTests.swift ├── SDGeometryEngineTests.swift └── XCTestManifests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | .swiftpm -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mansur Ahmed 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.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: "SwiftUIDrag", 8 | platforms: [ 9 | .iOS(.v14) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "SwiftUIDrag", 15 | targets: ["SwiftUIDrag"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "SwiftUIDrag", 26 | dependencies: []), 27 | .testTarget( 28 | name: "SwiftUIDragTests", 29 | dependencies: ["SwiftUIDrag"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUIDrag [NO LONGER MAINTAINED] 2 | 3 | A simple, customizable, and intuitive wrapper-view enabling dragging, floating, and/or collapsing for its content. Written entirely in SwiftUI, SwiftUIDrag is inspired by iOS 14's Picture-in-Picture feature. 4 | 5 | ![Floating TabView demo of SDView](README_Assets/tabview_preview.gif) ![VideoPlayer demo of SDView](README_Assets/video_preview.gif) ![Rectangle demo of SDView](README_Assets/rectangle_preview.gif) 6 | 7 | ## Table of Contents 8 | - [Usage](#usage) 9 | - [Key Features](#key-features) 10 | - [Alignment](#alignment) 11 | - [Floating](#floating) 12 | - [Collapse](#collapse) 13 | - [Visible Size](#visible-size) 14 | - [Content](#content) 15 | - [Installation](#installation) 16 | - [Author](#author) 17 | - [License](#license) 18 | 19 | ## Usage 20 | 21 | ```swift 22 | SDView(floating: .leading, collapse: .trailing) { geo, state in 23 | RoundedRectangle(cornerRadius: 25) 24 | .fill(Color.blue) 25 | .frame(width: geo.size.width / 2, height: geo.size.height / 4) 26 | .overlay( 27 | HStack { 28 | Image(systemName: "chevron.left") 29 | .foregroundColor(.white) 30 | .frame(width: 10, height: 20) 31 | .opacity(state.isCollapsed && state.isTrailing ? 1 : 0) 32 | .padding(.leading) 33 | 34 | Spacer() 35 | } 36 | ) 37 | } 38 | ``` 39 | This code enables the capabilities seen in the blue rectangle demo above. 40 | 41 | ## Key Features 42 | 43 | Below is the default initializer which ***requires*** you to enter only one parameter: the content to inherit the SDView drag, floating, and/or collapse properties. The remaining parameters all have default values that can be left as is or can be customized for your use-case. 44 | 45 | ```swift 46 | SDView( 47 | alignment: Alignment = .center, 48 | floating: SDFloatingOptions = [], 49 | collapse: SDCollapseOptions = .horizontal, 50 | visibleSize: CGSize = CGSize(width: 60, height: 60), 51 | @ViewBuilder content: @escaping (GeometryProxy, SDContentState) -> Content 52 | ) 53 | ``` 54 | The quickest way to get started is with the default paramters as such: 55 | 56 | ```swift 57 | SDView { _, _ in 58 | // your content. 59 | } 60 | ``` 61 | 62 | ### Alignment 63 | 64 | The `alignment` parameter allows you to position your content based on SwiftUI's `Alignment` struct. By default, it is set to `center` which positions your content in the center of SDView. Thus, you get access to the following options: 65 | 66 | | Options | 67 | | ----- | 68 | | `topLeading` | 69 | |`top` | 70 | |`topTrailing` | 71 | | `leading` | 72 | |`center` | 73 | |`trailing` | 74 | | `bottomLeading` | 75 | |`bottom` | 76 | |`bottomTrailing` | 77 | 78 | ### Floating 79 | 80 | The `floating` parameter enables you to float your content on the edges of the SDView. By default, it is set to `[]` which disables floating. Customization is at the heart of this package, thus you get access to the following options: 81 | 82 | | Option | Description | 83 | | -------- | ------------ | 84 | | `[]` | disables floating | 85 | | `topLeading` | enables floating content on the top-leading side of SDView | 86 | | `topTrailing` | enables floating content on the top-trailing side of SDView | 87 | | `bottomLeading` | enables floating on the bottom-leading side of SDView | 88 | | `bottomTrailing` | enables floating on the bottom-trailing side of SDView | 89 | | `top` | enables floating content on either the top-leading or top-trailing sides of SDView | 90 | | `bottom` | enables floating content on either the bottom-leading or bottom-trailing sides of SDView | 91 | | `leading `| enables floating content on either the top-leading or bottom-leading sides of SDView | 92 | | `trailing` | enables floating content on either the top-trailing or bottom-trailing sides of SDView | 93 | | `all` | enables floating content on either the top-leading, top-trailing, bottom-leading, bottom-trailing sides of SDView | 94 | 95 | 96 | ### Collapse 97 | 98 | The `collapse` parameter enables you to collapse your content into the sides of the SDView with a set `visibleSize`. By default, it is set to `horizontal` which only enables collapsing on the `leading` and `trailing` sides. Customization is at the heart of this package, thus you get access to the following options: 99 | 100 | | Option | Description | 101 | | -------- | ------------ | 102 | | `[]` | disables collapsing | 103 | | `top` | enables collapsing content on the top side of SDView | 104 | | `bottom` | enables collapsing content on the bottom side of SDView | 105 | | `leading` | enables collapsing on the leading side of SDView | 106 | | `trailing` | enables collapsing on the trailing side of SDView | 107 | | `horizontal` | enables collapsing content on either the leading or trailing sides of SDView | 108 | | `vertical` | enables collapsing content on either the top or bottom sides of SDView | 109 | | `all` | enables collapsing content on either the top, bottom, leading, trailing sides of SDView | 110 | 111 | ### Visible Size 112 | 113 | The `visibleSize` parameter determines how much width or height of your content should be visible upon collapse. By default it is set to `60` for both. 114 | 115 | ### Content 116 | 117 | The `content` parameter is `@escaping`- and `@ViewBuilder`-wrapped which enables escaping into curly braces for you to easily describe your content in. Additionally, you get two callback parameters: `GeometryProxy` and `SDContentState`. 118 | 119 | The `GeometryProxy` enables you to customize any framing, positioning, and/or sizing based on the SDView. 120 | 121 | The `SDContentState` parameter indicates the state of your content. Once again, customization is at the heart of this package, so you get the following state options: 122 | 123 | | Option | Description | 124 | | -------- | ------------ | 125 | | `top` | content is collapsed on the top side of SDView | 126 | | `bottom` | content is collapsed on bottom side of SDView | 127 | | `leading `| content is collapsed on leading side of SDView | 128 | | `trailing` | content is collapsed on trailing side of SDView | 129 | | `topLeading` | content is floating on top-leading side of SDView | 130 | | `topTrailing` | content is floating on top-trailing side of SDView | 131 | | `bottomLeading` | content is floating on the bottom-leading side of SDView | 132 | | `bottomTrailing` | content is floating on the bottom-trailing side of SDView | 133 | | `expanded` | content is neither collapse nor floating on any side of SDView | 134 | 135 | To take it a step further, you also get access to `Bool` variables that allow for *swift* verification of the content state: 136 | 137 | | Option | Description | 138 | | -------- | ------------ | 139 | | `isTop` | content is either collapsed or floating on the top side of SDView | 140 | | `isBottom `| content is either collapsed or floating on the bottom side of SDView | 141 | | `isLeading` | content is either collapsed or floating on the leading side of SDView | 142 | | `isTrailing` | content is either collapsed or floating on the trailing side of SDView | 143 | | `isCollapsed` | content is collapsed in SDView | 144 | | `isFloating` | content is floating in SDView | 145 | | `isExpanded` | content is expanded | 146 | 147 | ## Installation 148 | 149 | SwiftUIDrag can be installed via Swift Package Manager (SPM) in Xcode: 150 | 151 | 1. Navigate to the SPM (**File > Swift Packages > Add Package Dependency...**) 152 | 2. Either enter the URL (**https://github.com/demharusnam/SwiftUIDrag**) or the name of the package in the search bar. If you opted for the latter, select the displayed package with myself (**demharusnam**) as the owner. 153 | 154 | ## Author 155 | 156 | My name is Mansur. At the time of publishing SwiftUIDrag, I am an undergraduate computer engineering student from Toronto, Canada. I love Swift, SwiftUI, and creating software. 157 | 158 | If you have any questions regarding SwiftUIDrag, please feel free to contact [me](https://github.com/demharusnam). 159 | 160 | Happy hacking! 161 | 162 | ## License 163 | 164 | SwiftUIDrag is available under the MIT license. See LICENSE for more information. 165 | -------------------------------------------------------------------------------- /README_Assets/rectangle_preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demharusnam/SwiftUIDrag/bdc2d12f8d90a8a85074a202f8ad9e039f239088/README_Assets/rectangle_preview.gif -------------------------------------------------------------------------------- /README_Assets/tabview_preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demharusnam/SwiftUIDrag/bdc2d12f8d90a8a85074a202f8ad9e039f239088/README_Assets/tabview_preview.gif -------------------------------------------------------------------------------- /README_Assets/video_preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demharusnam/SwiftUIDrag/bdc2d12f8d90a8a85074a202f8ad9e039f239088/README_Assets/video_preview.gif -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/GeometryEngine/Bounds/SDCollapseBounds.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Collapse Bounds 4 | internal extension SDGeometryEngine { 5 | /// An engine determining whether the dragged content is within any set collapse boundaries of the SDView. 6 | struct SDCollapseBounds { 7 | /// The determinant of whether the dragged content is within the *top* collapse boundary. 8 | let top: Bool 9 | 10 | /// The determinant of whether the dragged content is within the *bottom* collapse boundary. 11 | let bottom: Bool 12 | 13 | /// The determinant of whether the dragged content is within the *leading* collapse boundary. 14 | let leading: Bool 15 | 16 | /// The determinant of whether the dragged content is within the *trailing* collapse boundary. 17 | let trailing: Bool 18 | 19 | /// The determinant of whether the dragged content is within the top or bottom collapse boundaries. 20 | var vertical: Bool { top || bottom } 21 | 22 | /// The determinant of whether the dragged content is within the leading or trailing collapse boundaries. 23 | var horizontal: Bool { leading || trailing } 24 | 25 | /// The determinant of whether the dragged content is within any collapse boundaries at all.. 26 | var inBounds: Bool { vertical || horizontal } 27 | 28 | /// The enumerated semantic value of the content state. 29 | var state: SDContentState { 30 | if top { 31 | return .top 32 | } else if bottom { 33 | return .bottom 34 | } else if leading { 35 | return .leading 36 | } else if trailing { 37 | return .trailing 38 | } 39 | 40 | return .expanded 41 | } 42 | 43 | /** 44 | Instantiates an engine to determine whether the dragged content is in any collapse boundaries. 45 | 46 | - Parameters: 47 | - viewSize: The size of the SDView containing the content. 48 | - contentLocation: The *location of the content* in the SDView. 49 | - collapse: The collapse configuration of the content in the SDView. 50 | */ 51 | init(viewSize: CGSize, contentLocation: CGPoint, collapse: SDCollapseOptions) { 52 | let topBound: CGFloat = viewSize.height * 0.15 53 | let bottomBound: CGFloat = viewSize.height - topBound 54 | let trailingBound: CGFloat = viewSize.width * 0.85 55 | let leadingBound: CGFloat = viewSize.width - trailingBound 56 | 57 | let top: Bool = contentLocation.y <= topBound 58 | let bottom: Bool = contentLocation.y >= bottomBound 59 | let leading: Bool = contentLocation.x <= leadingBound 60 | let trailing: Bool = contentLocation.x >= trailingBound 61 | 62 | self.top = top && collapse.contains(.top) 63 | self.bottom = bottom && collapse.contains(.bottom) 64 | self.leading = leading && collapse.contains(.leading) 65 | self.trailing = trailing && collapse.contains(.trailing) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/GeometryEngine/Bounds/SDFloatingBounds.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Floating Bounds 4 | internal extension SDGeometryEngine { 5 | /// An engine determining whether the dragged content is within any set floating boundaries of the SDView. 6 | struct SDFloatingBounds { 7 | /// The determinant of whether the dragged content is within the *top leading* floating boundary. 8 | let topLeading: Bool 9 | 10 | /// The determinant of whether the dragged content is within the *top trailing* floating boundary. 11 | let topTrailing: Bool 12 | 13 | /// The determinant of whether the dragged content is within the *bottom leading* floating boundary. 14 | let bottomLeading: Bool 15 | 16 | /// The determinant of whether the dragged content is within the *bottom trailing* floating boundary. 17 | let bottomTrailing: Bool 18 | 19 | /// The enumerated semantic value of the content state. 20 | var state: SDContentState { 21 | if topLeading { 22 | return .topLeading 23 | } else if topTrailing { 24 | return .topTrailing 25 | } else if bottomLeading { 26 | return .bottomLeading 27 | } else if bottomTrailing { 28 | return .bottomTrailing 29 | } 30 | 31 | return .expanded 32 | } 33 | 34 | /// The determinant of whether the dragged content is within any floating boundaries at all.. 35 | var inBounds: Bool { 36 | return topLeading || topTrailing || bottomLeading || bottomTrailing 37 | } 38 | 39 | /** 40 | Instantiates an engine to determine whether the dragged content is in any floating boundaries. 41 | 42 | - Parameters: 43 | - viewSize: The size of the SDView containing the content. 44 | - contentLocation: The *location of the content* in the SDView. 45 | - floating: The floating configuration of the content in the SDView. 46 | - collapse: The collapse configuration of the content in the SDView. 47 | */ 48 | init(viewSize: CGSize, contentLocation: CGPoint, floating: SDFloatingOptions, collapse: SDCollapseOptions) { 49 | let upperBoundTop: CGFloat = viewSize.height * (collapse.contains(.top) ? 0.15 : -1.5) 50 | let lowerBoundTop: CGFloat = viewSize.height * 0.35 51 | let upperBoundBottom: CGFloat = viewSize.height * (collapse.contains(.bottom) ? 0.85 : 1.5) 52 | let lowerBoundBottom: CGFloat = viewSize.height * 0.65 53 | 54 | let upperBoundLeading: CGFloat = viewSize.width * (collapse.contains(.leading) ? 0.15 : -1.5) 55 | let lowerBoundLeading: CGFloat = viewSize.width * 0.35 56 | let upperBoundTrailing: CGFloat = viewSize.width * (collapse.contains(.trailing) ? 0.85 : 1.5) 57 | let lowerBoundTrailing: CGFloat = viewSize.width * 0.65 58 | 59 | let trailing: Bool = contentLocation.x >= lowerBoundTrailing && contentLocation.x < upperBoundTrailing 60 | let leading: Bool = contentLocation.x <= lowerBoundLeading && contentLocation.x > upperBoundLeading 61 | let top: Bool = contentLocation.y <= lowerBoundTop && contentLocation.y > upperBoundTop 62 | let bottom: Bool = contentLocation.y >= lowerBoundBottom && contentLocation.y < upperBoundBottom 63 | 64 | let topLeading = top && leading 65 | let topTrailing = top && trailing 66 | let bottomLeading = bottom && leading 67 | let bottomTrailing = bottom && trailing 68 | 69 | self.topLeading = topLeading && floating.contains(.topLeading) 70 | self.topTrailing = topTrailing && floating.contains(.topTrailing) 71 | self.bottomLeading = bottomLeading && floating.contains(.bottomLeading) 72 | self.bottomTrailing = bottomTrailing && floating.contains(.bottomTrailing) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/GeometryEngine/Offset/SDCollapseOffset.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Collapse Offset Engine 4 | internal extension SDGeometryEngine { 5 | /// The engine managing the collapsed offset calcluations for the content in the SDView. 6 | struct CollapseOffset: SDOffsets { 7 | /// The collapse offset for the content to translate *to the top* boundary of the SDView. 8 | let toTop: CGFloat 9 | 10 | /// The collapse offset for the content to translate *to the bottom* boundary of the SDView. 11 | let toBottom: CGFloat 12 | 13 | /// The collapse offset for the content to translate *to the leading* boundary of the SDView. 14 | let toLeading: CGFloat 15 | 16 | /// The collapse offset for the content to translate *to the trailing* boundary of the SDView. 17 | let toTrailing: CGFloat 18 | 19 | /** 20 | Instantiates a new engine managing all collapsed offset calculations. 21 | 22 | - Parameters: 23 | - distanceFrom: The distance of the content from the boundaries of the SDView. 24 | - visibleSize: The visible size of the content upon collapse. 25 | */ 26 | init(distanceFrom content: SDDistance, visibleSize: CGSize) { 27 | self.toLeading = content.toLeading + visibleSize.width 28 | self.toTrailing = content.toTrailing - visibleSize.width 29 | self.toTop = content.toTop + visibleSize.height 30 | self.toBottom = content.toBottom - visibleSize.height 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/GeometryEngine/Offset/SDFloatingOffset.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Floating Offset Engine 4 | internal extension SDGeometryEngine { 5 | /// The engine managing the floating offset calcluations for the content in the SDView. 6 | struct SDFloatingOffset: SDOffsets { 7 | /// The floating offset for the content to translate *to the top* boundary of the SDView. 8 | let toTop: CGFloat 9 | 10 | /// The floating offset for the content to translate *to the bottom* boundary of the SDView. 11 | let toBottom: CGFloat 12 | 13 | /// The floating offset for the content to translate *to the leading* boundary of the SDView. 14 | let toLeading: CGFloat 15 | 16 | /// The floating offset for the content to translate *to the trailing* boundary of the SDView. 17 | let toTrailing: CGFloat 18 | 19 | /** 20 | Instantiates a new engine managing all floating offset calculations. 21 | 22 | - Parameters: 23 | - distanceFrom: The distance of the content from the boundaries of the SDView. 24 | */ 25 | init(distanceFrom content: SDDistance) { 26 | let contentHeight: CGFloat = content.contentSize.height 27 | let contentWidth: CGFloat = content.contentSize.width 28 | 29 | self.toTop = content.toTop + contentHeight 30 | self.toBottom = content.toBottom - contentHeight 31 | self.toLeading = content.toLeading + contentWidth 32 | self.toTrailing = content.toTrailing - contentWidth 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/GeometryEngine/Offset/SDOffset.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Offset Engine 4 | 5 | /// The requirements for all offset configurations. 6 | internal protocol SDOffsets { 7 | var toTop: CGFloat { get } 8 | var toBottom: CGFloat { get } 9 | var toLeading: CGFloat { get } 10 | var toTrailing: CGFloat { get } 11 | } 12 | 13 | internal extension SDGeometryEngine { 14 | /// The engine that manages all offset calculations upon drag end of the content. 15 | struct SDOffset: SDOffsets { 16 | /// The computed floating/collapse offset for the content to translate *to the top* boundary of the SDView. 17 | var toTop: CGFloat { 18 | if inFloatingBounds.topLeading || inFloatingBounds.topTrailing { 19 | return floatingOffset.toTop 20 | } else if inCollapseBounds.top { 21 | return collapseOffset.toTop 22 | } 23 | 24 | return 0 25 | } 26 | 27 | /// The computed floating/collapse offset for the content to translate *to the bottom* boundary of the SDView. 28 | var toBottom: CGFloat { 29 | if inFloatingBounds.bottomLeading || inFloatingBounds.bottomTrailing { 30 | return floatingOffset.toBottom 31 | } else if inCollapseBounds.bottom { 32 | return collapseOffset.toBottom 33 | } 34 | 35 | return 0 36 | } 37 | 38 | /// The computed floating/collapse offset for the content to translate *to the leading* boundary of the SDView. 39 | var toLeading: CGFloat { 40 | if inFloatingBounds.topLeading || inFloatingBounds.bottomLeading { 41 | return floatingOffset.toLeading 42 | } else if inCollapseBounds.leading { 43 | return collapseOffset.toLeading 44 | } 45 | 46 | return 0 47 | } 48 | 49 | /// The computed floating/collapse offset for the content to translate *to the trailing* boundary of the SDView. 50 | var toTrailing: CGFloat { 51 | if inFloatingBounds.topTrailing || inFloatingBounds.bottomTrailing { 52 | return floatingOffset.toTrailing 53 | } else if inCollapseBounds.trailing { 54 | return collapseOffset.toTrailing 55 | } 56 | 57 | return 0 58 | } 59 | 60 | /// The computed y-value offset of the content upon drag end. 61 | var y: CGFloat { 62 | if inFloatingBounds.inBounds { 63 | switch inFloatingBounds.state { 64 | case .topLeading, .topTrailing: 65 | return floatingOffset.toTop 66 | case .bottomLeading, .bottomTrailing: 67 | return floatingOffset.toBottom 68 | default: 69 | break 70 | } 71 | } else if inCollapseBounds.inBounds { 72 | switch inCollapseBounds.state { 73 | case .top: 74 | return collapseOffset.toTop 75 | case .bottom: 76 | return collapseOffset.toBottom 77 | default: 78 | return drag.translation.height 79 | } 80 | } 81 | 82 | return 0 83 | } 84 | 85 | /// The computed x-value offset of the content upon drag end. 86 | var x: CGFloat { 87 | if inFloatingBounds.inBounds { 88 | switch inFloatingBounds.state { 89 | case .topLeading, .bottomLeading: 90 | return floatingOffset.toLeading 91 | case .topTrailing, .bottomTrailing: 92 | return floatingOffset.toTrailing 93 | default: 94 | break 95 | } 96 | } else if inCollapseBounds.inBounds { 97 | switch inCollapseBounds.state { 98 | case .leading: 99 | return collapseOffset.toLeading 100 | case .trailing: 101 | return collapseOffset.toTrailing 102 | default: 103 | return drag.translation.width 104 | } 105 | } 106 | 107 | return 0 108 | } 109 | 110 | /// The offset configuration for out-of-bounds content drag. 111 | var unsafe: SDFloatingOffset { floatingOffset } 112 | 113 | /// The engine determining whether the content has ended drag *within floating boundaries*. 114 | private let inFloatingBounds: SDFloatingBounds 115 | 116 | /// The engine determining whether the content has ended drag *within collapse boundaries*. 117 | private let inCollapseBounds: SDCollapseBounds 118 | 119 | /// The engine that manages floating offset calculations upon drag end of the content. 120 | private let floatingOffset: SDFloatingOffset 121 | 122 | /// The engine that manages collapse offset calculations upon drag end of the content. 123 | private let collapseOffset: CollapseOffset 124 | 125 | /// The drag gesture value upon content drag end. 126 | private let drag: DragGesture.Value 127 | 128 | /** 129 | Instantiates a new engine to determine and calculate all applicable offsets for the content in the SDView. 130 | 131 | - Parameters: 132 | - distanceFrom: The distance of the content from the frame boundaries of the SDView. 133 | - visibleSize: The visible size of the content once collapsed. 134 | - contentLocation: The location of the content in the SDView. 135 | - viewSize: The size of the SDView. 136 | - floatingOptions: The set of all valid floating options for the content. 137 | - collapseOptions: The set of all valid collapse options for the content. 138 | */ 139 | init( 140 | distanceFrom content: SDDistance, 141 | visibleSize: CGSize, 142 | contentLocation: CGPoint, 143 | viewSize: CGSize, 144 | floatingOptions: SDFloatingOptions, 145 | collapseOptions: SDCollapseOptions, 146 | drag: DragGesture.Value 147 | ) { 148 | self.inFloatingBounds = SDFloatingBounds(viewSize: viewSize, contentLocation: contentLocation, floating: floatingOptions, collapse: collapseOptions) 149 | self.inCollapseBounds = SDCollapseBounds(viewSize: viewSize, contentLocation: contentLocation, collapse: collapseOptions) 150 | self.floatingOffset = SDFloatingOffset(distanceFrom: content) 151 | self.collapseOffset = CollapseOffset(distanceFrom: content, visibleSize: visibleSize) 152 | self.drag = drag 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/GeometryEngine/SDDistance.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Distance Engine 4 | internal extension SDGeometryEngine { 5 | /// An engine determining the distance of the content from the SDView frame boundaries. 6 | struct SDDistance { 7 | /// The distance to travel from the content location *to the top* boundary of the SDView. 8 | let toTop: CGFloat 9 | 10 | /// The distance to travel from the content location *to the bottom* boundary of the SDView. 11 | let toBottom: CGFloat 12 | 13 | /// The distance to travel from the content location *to the leading* boundary of the SDView. 14 | let toLeading: CGFloat 15 | 16 | /// The distance to travel from the content location *to the trailing* boundary of the SDView. 17 | let toTrailing: CGFloat 18 | 19 | /// The *size of the content*. 20 | let contentSize: CGSize 21 | 22 | /** 23 | Instantiates a new engine determining the distance of the content from the SDView frame boundaries. 24 | 25 | - Parameters: 26 | - content: The rect of the content in the SDView. 27 | - fromBoundsIn: The size of the SDView. 28 | */ 29 | init(_ content: CGRect, fromBoundsIn container: CGSize) { 30 | self.contentSize = content.size 31 | self.toLeading = -content.maxX 32 | self.toTrailing = container.width - content.minX 33 | self.toTop = -content.maxY 34 | self.toBottom = container.height - content.minY 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/GeometryEngine/SDGeometryEngine.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Geometry Engine 4 | /// The geometry engine powering all SwiftUIDrag computations. 5 | internal struct SDGeometryEngine { 6 | /// The *default* initialization of the `GeometryEngine` 7 | static let `default`: SDGeometryEngine = SDGeometryEngine( 8 | for: .zero, 9 | in: .zero, 10 | visibleSize: CGSize(width: 60, height: 60), 11 | floatingOptions: [], 12 | collapseOptions: .horizontal 13 | ) 14 | 15 | /// The *offset* object that determines where the content should stick to once the drag has completed. 16 | var offset: SDOffset { 17 | if let drag = drag { 18 | let contentCenter = SDDistance(contentRect, fromBoundsIn: viewSize) 19 | return SDOffset( 20 | distanceFrom: contentCenter, 21 | visibleSize: visibleSize, 22 | contentLocation: drag.location, 23 | viewSize: viewSize, 24 | floatingOptions: floatingOptions, 25 | collapseOptions: collapseOptions, 26 | drag: drag 27 | ) 28 | } 29 | 30 | fatalError("Invalid Drag Gesture Value") 31 | } 32 | 33 | /// The *state of the content* as described by enumeration cases. 34 | var contentState: SDContentState { 35 | if let location = drag?.location { 36 | let floatingBounds = SDFloatingBounds(viewSize: viewSize, contentLocation: location, floating: floatingOptions, collapse: collapseOptions) 37 | let collapseBounds = SDCollapseBounds(viewSize: viewSize, contentLocation: location, collapse: collapseOptions) 38 | 39 | if floatingBounds.inBounds { 40 | return floatingBounds.state 41 | } else if collapseBounds.inBounds { 42 | return collapseBounds.state 43 | } 44 | 45 | return .expanded 46 | } 47 | 48 | fatalError("Invalid Drag Gesture Value") 49 | } 50 | 51 | /// The *size of the content*. 52 | private var contentSize: CGSize { contentRect.size } 53 | 54 | /// The *visible size*of the content when collapsed. 55 | let visibleSize: CGSize 56 | 57 | /// The *size of the view* containing the content. 58 | internal var viewSize: CGSize 59 | 60 | /// The *rect of the content*. 61 | internal var contentRect: CGRect 62 | 63 | /// The floating options for the content. 64 | internal let floatingOptions: SDFloatingOptions 65 | 66 | /// The collapsing options for the content. 67 | internal let collapseOptions: SDCollapseOptions 68 | 69 | /// The drag data for the content. 70 | internal let drag: DragGesture.Value? 71 | 72 | /** 73 | Instantiates a *geometry engine* to manage the drag and collapse behaviour of the content. 74 | 75 | - Parameters: 76 | - content: The rect of the content to be managed by the engine. 77 | - dragging: The drag gesture value of the content upon drag completion. 78 | - container: The size of the view encapsulating the content. 79 | - visibleSize: The visible size of the content upon collapse. 80 | - floatingOptions: The configuration of all possible *floating options* for the content. 81 | - floatingOptions: The configuration of all possible *collapse options* for the content. 82 | */ 83 | init( 84 | for content: CGRect, 85 | dragging: DragGesture.Value? = nil, 86 | in container: CGSize, 87 | visibleSize: CGSize, 88 | floatingOptions: SDFloatingOptions, 89 | collapseOptions: SDCollapseOptions 90 | ) { 91 | self.contentRect = content 92 | self.drag = dragging 93 | self.viewSize = container 94 | self.visibleSize = visibleSize 95 | self.floatingOptions = floatingOptions 96 | self.collapseOptions = collapseOptions 97 | } 98 | 99 | // MARK: Content Positioning Updates 100 | 101 | /** 102 | Updates the location of the content view's `rect` in its `container`. 103 | 104 | The location of the content `rect` is initially determined as a `CGPoint` which is anchored by the center of the content rect. Then, it is adjusted to match the top leading anchor of the `rect` being updated. 105 | 106 | - Parameters: 107 | - rect: The content *rect* to be updated. 108 | - container: The *size* of the view containing the content. 109 | - alignment: The *alignment* of the content in its container. 110 | */ 111 | mutating func update(rect: inout CGRect, in container: CGSize, with alignment: Alignment) { 112 | var origin: CGPoint = .zero 113 | // returns mid coordinates of rect as min 114 | origin = SDGeometryEngine.location(of: rect, in: container, with: alignment) 115 | // adjust 116 | origin.x -= rect.size.width / 2 117 | origin.y -= rect.size.height / 2 118 | 119 | let updatedRect = CGRect(origin: origin, size: rect.size) 120 | 121 | self.contentRect = updatedRect 122 | self.viewSize = container 123 | 124 | rect = updatedRect 125 | } 126 | 127 | /** 128 | Determines the appropriate location of the content in the SDView. 129 | 130 | The `alignment` of the content in the SDView is used to determine the location of the content based on its `rect` and the size of the SDView. 131 | - Parameters: 132 | - rect: The *rect* of the content view. 133 | - container: The size of the *container* view. 134 | - alignment: The *alignment* of the content in the *container* view. 135 | 136 | - Returns: The location of the content in the `container` view. 137 | */ 138 | static func location(of rect: CGRect, in container: CGSize, with alignment: Alignment) -> CGPoint { 139 | let trailingX: CGFloat = container.width - rect.width / 2 140 | let leadingX: CGFloat = rect.width / 2 141 | let bottomY: CGFloat = container.height - rect.height / 2 142 | let topY: CGFloat = rect.height / 2 143 | 144 | switch alignment { 145 | case .bottomLeading: 146 | return .init(x: leadingX, y: bottomY) 147 | case .bottom: 148 | return .init(x: container.width / 2, y: bottomY) 149 | case .bottomTrailing: 150 | return .init(x: trailingX, y: bottomY) 151 | case .leading: 152 | return .init(x: leadingX, y: container.height / 2) 153 | case .trailing: 154 | return .init(x: trailingX, y: container.height / 2) 155 | case .topLeading: 156 | return .init(x: leadingX, y: topY) 157 | case .top: 158 | return .init(x: container.width / 2, y: topY) 159 | case .topTrailing: 160 | return .init(x: trailingX, y: topY) 161 | default: 162 | return .init(x: container.width / 2, y: container.height / 2) 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/Helpers/SDCoordinateSpaceNames.swift: -------------------------------------------------------------------------------- 1 | /// The collection of coordinate space names. 2 | struct SDCoordinateSpaceNames { 3 | /// The coordinate space name for SDView. 4 | static let SDView: String = "SDView" 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/Helpers/ViewSizeReader.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: View Size Reader Modifier 4 | 5 | /// The protocol defining the value type of the geometry preference key. 6 | internal protocol SDGeometryPreferenceKey: PreferenceKey where Value == CGRect {} 7 | 8 | /// The view modifier used to determine the rect of the content in the SDView. 9 | internal struct SDSizeReader: ViewModifier { 10 | /// The horizontal size class of the device in use. 11 | @Environment(\.horizontalSizeClass) var sizeClass 12 | 13 | /// The rect of the content. 14 | @State private var rect: CGRect = .zero 15 | 16 | /// The body requirement of the ViewModifier protocol. 17 | func body(content: Content) -> some View { 18 | content 19 | .background( 20 | GeometryReader { geo in 21 | Color.clear 22 | .onAppear { 23 | rect = geo.frame(in: .named(SDCoordinateSpaceNames.SDView)) 24 | } 25 | .onChange(of: sizeClass) { value in 26 | rect = geo.frame(in: .named(SDCoordinateSpaceNames.SDView)) 27 | } 28 | .preference(key: Key.self, value: rect) 29 | } 30 | ) 31 | } 32 | } 33 | 34 | internal extension View { 35 | /** 36 | The size-reader of a view encapsulated in another. 37 | 38 | - Parameters: 39 | - key: The accepted type of the preference key. 40 | */ 41 | func readSize(_ key: Key.Type) -> some View { 42 | self.modifier(SDSizeReader()) 43 | } 44 | } 45 | 46 | /// The definition of the geometry preference key. 47 | internal struct SDViewGeometry: SDGeometryPreferenceKey { 48 | /// The default of the rect being read as defined by the PreferenceKey protocol. 49 | static var defaultValue: CGRect = .zero 50 | 51 | /** 52 | Captures any updates of the content rect as defined by the PreferenceKey protocol. 53 | 54 | - Parameters: 55 | - value: The new value of the content rect. 56 | - nextValue: The read value of the content rect. 57 | */ 58 | static func reduce(value _: inout CGRect, nextValue: () -> CGRect) { 59 | _ = nextValue() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/Options/SDCollapseOptions.swift: -------------------------------------------------------------------------------- 1 | // MARK: Collapse Options 2 | 3 | /// Defines all collapse options for content in the SDView. 4 | public struct SDCollapseOptions: OptionSet { 5 | /// The raw value of all collapse options. 6 | public let rawValue: Int 7 | 8 | public init(rawValue: Int) { 9 | self.rawValue = rawValue 10 | } 11 | 12 | /// Allow content to collapse on leading side of the SDView. 13 | public static let leading: SDCollapseOptions = SDCollapseOptions(rawValue: 1 << 0) 14 | 15 | /// Allow content to collapse on trailing side of the SDView. 16 | public static let trailing: SDCollapseOptions = SDCollapseOptions(rawValue: 1 << 1) 17 | 18 | /// Allow content to collapse on top side of the SDView. 19 | public static let top: SDCollapseOptions = SDCollapseOptions(rawValue: 1 << 2) 20 | 21 | /// Allow content to collapse on bottom side of the SDView. 22 | public static let bottom: SDCollapseOptions = SDCollapseOptions(rawValue: 1 << 3) 23 | 24 | /// Allow content to collapse on leading and trailing sides of the SDView. This is the default value. 25 | public static let horizontal: SDCollapseOptions = [.leading, .trailing] 26 | 27 | /// Allow content to collapse on top and bottom sides of the SDView. 28 | public static let vertical: SDCollapseOptions = [.top, .bottom] 29 | 30 | /// Allow content to collapse on all sides of the SDView. 31 | public static let all: SDCollapseOptions = [.horizontal, .vertical] 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/Options/SDFloatingOptions.swift: -------------------------------------------------------------------------------- 1 | // MARK: Floating Options 2 | 3 | /// Determines corner-floating options for content in the SDView. 4 | public struct SDFloatingOptions: OptionSet { 5 | /// The raw value of all floating options. 6 | public let rawValue: Int 7 | 8 | public init(rawValue: Int) { 9 | self.rawValue = rawValue 10 | } 11 | 12 | /// Allow content to float on top leading side in the SDView. 13 | public static let topLeading: SDFloatingOptions = SDFloatingOptions(rawValue: 1 << 0) 14 | 15 | /// Allow content to float on top trailing side in the SDView. 16 | public static let topTrailing: SDFloatingOptions = SDFloatingOptions(rawValue: 1 << 1) 17 | 18 | /// Allow content to float on bottom leading side in the SDView. 19 | public static let bottomLeading: SDFloatingOptions = SDFloatingOptions(rawValue: 1 << 2) 20 | 21 | /// Allow content to float on bottom trailing side in the SDView. 22 | public static let bottomTrailing: SDFloatingOptions = SDFloatingOptions(rawValue: 1 << 3) 23 | 24 | /// Allow content to float on bottom trailing and leading sides in the SDView. 25 | public static let bottom: SDFloatingOptions = [.bottomLeading, .bottomTrailing] 26 | 27 | /// Allow content to float on top trailing and leading sides in the SDView. 28 | public static let top: SDFloatingOptions = [.topLeading, .topTrailing] 29 | 30 | /// Allow content to float on leading top and bottom sides in the SDView. 31 | public static let leading: SDFloatingOptions = [.topLeading, .bottomLeading] 32 | 33 | /// Allow content to float on trailing top and bottom sides in the SDView. 34 | public static let trailing: SDFloatingOptions = [.topTrailing, .bottomTrailing] 35 | 36 | /// Allow content to float on all sides in the SDView. 37 | public static let all: SDFloatingOptions = [.leading, .trailing] // can be configured in various ways 38 | } 39 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/SDView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A wrapper-view enabling drag, float and/or collapse behaviour for its content. 4 | public struct SDView: View { 5 | /// The horizontal size class of the device in use. 6 | @Environment(\.horizontalSizeClass) private var sizeClass 7 | 8 | /// The drag gesture state of the content. 9 | @GestureState private var dragState: SDDragState = SDDragState.inactive 10 | 11 | /// The state of the content as described by enumerated semantic values. 12 | @State internal var contentState: SDContentState = .expanded 13 | 14 | /// The content state after drag completion. 15 | @State internal var contentDrag: CGSize = .zero 16 | 17 | /// The geometry engine managing all geometric behaviour. 18 | @State private(set) var geometryEngine: SDGeometryEngine = .default 19 | 20 | /// The rect for the content. 21 | @State private(set) var contentRect: CGRect = .zero 22 | 23 | /// The content to inherit drag and collapse properties from the view. 24 | private let content: (GeometryProxy, SDContentState) -> Content 25 | 26 | /// Alignment of content in the view. 27 | private let alignment: Alignment 28 | 29 | /// The visible width of the content once collapsed. 30 | private let visibleSize: CGSize 31 | 32 | /// The configuration of collapsible sides for content in view. 33 | private let collapseOptions: SDCollapseOptions 34 | 35 | /// The configuartion of floating corners for content in view. 36 | private let floatingOptions: SDFloatingOptions 37 | 38 | /** 39 | Initializes a new view instance to manage drag and collapse behaviour for its content. 40 | 41 | - Parameters: 42 | - alignment: The alignment of the content in the SDView. 43 | - floating: The floating configuration of the content in the SDView. 44 | - collapse: The collapse configuartion of the content in the SDView. 45 | - content: The content to inherit the SDView's properties. It takes an input of `GeometryProxy` and `ContentViewState` to dictate behaviour difference in the different physical properties and content states relative to the SDView, respectively. 46 | */ 47 | public init( 48 | alignment: Alignment = .center, 49 | floating: SDFloatingOptions = [], 50 | collapse: SDCollapseOptions = .horizontal, 51 | visibleSize: CGSize = CGSize(width: 60, height: 60), 52 | @ViewBuilder content: @escaping (GeometryProxy, SDContentState) -> Content 53 | ) { 54 | self.alignment = alignment 55 | self.floatingOptions = floating 56 | self.collapseOptions = collapse 57 | self.visibleSize = visibleSize 58 | self.content = content 59 | } 60 | 61 | /// The body of the view. 62 | public var body: some View { 63 | GeometryReader { proxy in 64 | content(proxy, contentState) 65 | .readSize(SDViewGeometry.self) 66 | .position(SDGeometryEngine.location(of: contentRect, in: proxy.size, with: alignment)) 67 | .offset( 68 | x: contentDrag.width + dragState.translation.width, 69 | y: contentDrag.height + dragState.translation.height 70 | ) 71 | .gesture( 72 | ExclusiveGesture(TapGesture(), DragGesture()) 73 | .updating($dragState) { value, state, _ in 74 | switch value { 75 | case .second(let drag): 76 | state = .dragging(drag: drag.translation) 77 | default: 78 | break 79 | } 80 | } 81 | .onEnded { value in 82 | switch value { 83 | case .first: 84 | // reset content state 85 | if contentState.isCollapsed || contentState.isFloating { 86 | contentState = .expanded 87 | contentDrag = .zero 88 | } 89 | case .second(let drag): 90 | // update state for drag end calculations 91 | geometryEngine = SDGeometryEngine( 92 | for: contentRect, 93 | dragging: drag, 94 | in: proxy.size, 95 | visibleSize: visibleSize, 96 | floatingOptions: floatingOptions, 97 | collapseOptions: collapseOptions 98 | ) 99 | 100 | contentState = geometryEngine.contentState 101 | 102 | boundToSafeArea(drag, viewSize: proxy.size) 103 | } 104 | } 105 | ) 106 | .onChange(of: sizeClass) { value in 107 | geometryEngine.update(rect: &contentRect, in: proxy.size, with: alignment) 108 | 109 | if contentState.isCollapsed || contentState.isFloating { 110 | contentDrag = CGSize(width: geometryEngine.offset.x, height: geometryEngine.offset.y) 111 | } 112 | } 113 | .onPreferenceChange(SDViewGeometry.self) { rect in 114 | var updatedRect: CGRect = rect 115 | 116 | geometryEngine.update(rect: &updatedRect, in: proxy.size, with: alignment) 117 | 118 | contentRect = updatedRect 119 | } 120 | } 121 | .animation(.default) 122 | .coordinateSpace(name: SDCoordinateSpaceNames.SDView) 123 | } 124 | } 125 | 126 | /// SDView Previews 127 | #if DEBUG 128 | struct DragAndCollapse_Previews: PreviewProvider { 129 | static var previews: some View { 130 | SDView { _, _ in 131 | Rectangle() 132 | } 133 | } 134 | } 135 | #endif 136 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/State/SDContentState.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Content State 4 | 5 | /// The state of the content in the SDView. 6 | public enum SDContentState { 7 | /// The state that indicates the dragged content is collapsed on the *leading* side of the SDView. 8 | case leading 9 | 10 | /// The state that indicates the dragged content is collapsed on the *trailing* side of the SDView. 11 | case trailing 12 | 13 | /// The state that indicates the dragged content is collapsed on the *top* side of the SDView. 14 | case top 15 | 16 | /// The state that indicates the dragged content is collapsed on the *bottom* side of the SDView. 17 | case bottom 18 | 19 | /// The state that indicates the dragged content is floating on the *top leading* side of the SDView. 20 | case topLeading 21 | 22 | /// The state that indicates the dragged content is floating on the *top trailing* side of the SDView. 23 | case topTrailing 24 | 25 | /// The state that indicates the dragged content is floating on the *bottom leading* side of the SDView. 26 | case bottomLeading 27 | 28 | /// The state that indicates the dragged content is floating on the *bottom trailing* side of the SDView. 29 | case bottomTrailing 30 | 31 | /// The state that indicates the dragged content is in its default state, neither floating nor collapsed. 32 | case expanded 33 | 34 | /// Determines whether the content is expanded. 35 | public var isExpanded: Bool { 36 | switch self { 37 | case .expanded: 38 | return true 39 | default: 40 | return false 41 | } 42 | } 43 | 44 | /// Determines whether the content is floating/collapsed on the trailing side of the SDView. 45 | public var isTrailing: Bool { 46 | switch self { 47 | case .trailing, .topTrailing, .bottomTrailing: 48 | return true 49 | default: 50 | return false 51 | } 52 | } 53 | 54 | /// Determines whether the content is floating/collapsed on the leading side of the SDView. 55 | public var isLeading: Bool { 56 | switch self { 57 | case .leading, .topLeading, .bottomLeading: 58 | return true 59 | default: 60 | return false 61 | } 62 | } 63 | 64 | /// Determines whether the content is floating/collapsed on the top side of the SDView. 65 | public var isTop: Bool { 66 | switch self { 67 | case .top, .topLeading, .topTrailing: 68 | return true 69 | default: 70 | return false 71 | } 72 | } 73 | 74 | /// Determines whether the content is floating/collapsed on the bottom side of the SDView. 75 | public var isBottom: Bool { 76 | switch self { 77 | case .bottom, .bottomLeading, .bottomTrailing: 78 | return true 79 | default: 80 | return false 81 | } 82 | } 83 | 84 | /// Determines whether the content is floating in the SDView. 85 | public var isFloating: Bool { 86 | switch self { 87 | case .topLeading, .topTrailing, .bottomLeading, .bottomTrailing: 88 | return true 89 | default: 90 | return false 91 | } 92 | } 93 | 94 | /// Determines whether the content is collapsed in the SDView. 95 | public var isCollapsed: Bool { 96 | switch self { 97 | case .top, .bottom, .leading, .trailing: 98 | return true 99 | default: 100 | return false 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/SwiftUIDrag/State/SDDragState.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: Drag State 4 | internal extension SDView { 5 | /// The drag-state of the content. 6 | enum SDDragState { 7 | /// A `DragState` describing an inactive drag. 8 | case inactive 9 | 10 | /// A `DragState` describing an active tap. 11 | case tap 12 | 13 | /// A `DragState` describing an active drag. 14 | case dragging(drag: CGSize) 15 | 16 | /// The translation of an active drag. 17 | var translation: CGSize { 18 | switch self { 19 | case .inactive, .tap: 20 | return .zero 21 | case .dragging(let translation): 22 | return translation 23 | } 24 | } 25 | 26 | /// Determines whether a tap or drag is active. 27 | var isActive: Bool { 28 | switch self { 29 | case .inactive: 30 | return false 31 | case .tap, .dragging: 32 | return true 33 | } 34 | } 35 | 36 | /// Determines whether a drag is active. 37 | var isDragging: Bool { 38 | switch self { 39 | case .inactive, .tap: 40 | return false 41 | case .dragging: 42 | return true 43 | } 44 | } 45 | } 46 | 47 | /** 48 | Adjusts drag of content to remain within safe view bounds. 49 | 50 | The dragged content is offseted such that only the `visibleWidth` as determined by the user is visible within the SDView. The vertical drag is offseted such that it remains within safe vertical bounds of the SDView. The `contentDrag` is updated as a result. 51 | 52 | - Parameters: 53 | - value: The `DragGesture` *value* of the content upon drag end. 54 | - viewSize: The *size of the view* containing the content drag. 55 | */ 56 | func boundToSafeArea(_ value: DragGesture.Value, viewSize: CGSize) { 57 | let contentHeight = contentRect.size.height 58 | let contentWidth = contentRect.size.width 59 | let dragEndLocation: CGPoint = value.location 60 | let viewHeight: CGFloat = viewSize.height 61 | let viewWidth: CGFloat = viewSize.width 62 | 63 | let atUnsafeTop: Bool = dragEndLocation.y < contentHeight / 2 64 | let atUnsafeBottom: Bool = dragEndLocation.y > viewHeight - contentHeight / 2 65 | let atUnsafeLeading: Bool = dragEndLocation.x - contentWidth / 2 < 0 66 | let atUnsafeTrailing: Bool = dragEndLocation.x > viewWidth - contentWidth / 2 67 | 68 | var offsetX: CGFloat { 69 | switch contentState { 70 | case .top, .bottom: 71 | if atUnsafeTrailing { 72 | return geometryEngine.offset.unsafe.toTrailing 73 | } else if atUnsafeLeading { 74 | return geometryEngine.offset.unsafe.toLeading 75 | } 76 | 77 | return value.translation.width + contentDrag.width 78 | case .expanded: 79 | return 0 80 | default: 81 | return geometryEngine.offset.x 82 | } 83 | } 84 | 85 | var offsetY: CGFloat { 86 | switch contentState { 87 | case .leading, .trailing: 88 | if atUnsafeTop { 89 | return geometryEngine.offset.unsafe.toTop 90 | } else if atUnsafeBottom { 91 | return geometryEngine.offset.unsafe.toBottom 92 | } 93 | 94 | return value.translation.height + contentDrag.height 95 | case .expanded: 96 | return 0 97 | default: 98 | return geometryEngine.offset.y 99 | } 100 | } 101 | 102 | contentDrag = CGSize(width: offsetX, height: offsetY) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SwiftUIDragTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SwiftUIDragTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDCollapseBounds+Testable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDCollapseBounds+Testable.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import SwiftUI 9 | @testable import SwiftUIDrag 10 | 11 | extension SDGeometryEngine.SDCollapseBounds { 12 | /** 13 | Accessory function to simplify testing for the four basic SDCollapseOptions: top, bottom, leading, and trailing. 14 | 15 | - Parameters: 16 | - option: The *option* to be tested. 17 | 18 | - Returns: An instance of `SDCollapseBounds` to be tested based on the appropriate `option`. 19 | */ 20 | static func test(_ option: SDCollapseOptions) -> SDGeometryEngine.SDCollapseBounds { 21 | let viewSize = CGSize(width: 300, height: 200) 22 | let collapseOptions: SDCollapseOptions = .all 23 | var contentLocation: CGPoint { 24 | switch option { 25 | case .top: 26 | return .init(x: 150, y: 0) 27 | case .bottom: 28 | return .init(x: 150, y: 200) 29 | case .leading: 30 | return .init(x: 0, y: 100) 31 | case .trailing: 32 | return .init(x: 300, y: 100) 33 | default: 34 | fatalError("Testing is only for the four basic SDCollapseOptions: top, bottom, leading, or trailing.") 35 | } 36 | } 37 | 38 | return SDGeometryEngine.SDCollapseBounds(viewSize: viewSize, contentLocation: contentLocation, collapse: collapseOptions) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDCollapseBoundsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDCollapseBoundTest.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftUIDrag 10 | 11 | /// SDCollapseBounds tests suite. 12 | final class SDCollapseBoundsTests: XCTestCase { 13 | /// Tests content's validity of collapsing *only* on the `top` side of the screen. 14 | func testSDCollapseBoundsTop() { 15 | let collapseBounds = SDGeometryEngine.SDCollapseBounds.test(.top) 16 | 17 | XCTAssertEqual(collapseBounds.state, .top) 18 | XCTAssertNotEqual(collapseBounds.state, .bottom) 19 | XCTAssertNotEqual(collapseBounds.state, .leading) 20 | XCTAssertNotEqual(collapseBounds.state, .trailing) 21 | } 22 | 23 | /// Tests content's validity of collapsing *only* on the `bottom` side of the screen. 24 | func testSDCollapseBoundsBottom() { 25 | let collapseBounds = SDGeometryEngine.SDCollapseBounds.test(.bottom) 26 | 27 | XCTAssertEqual(collapseBounds.state, .bottom) 28 | XCTAssertNotEqual(collapseBounds.state, .top) 29 | XCTAssertNotEqual(collapseBounds.state, .leading) 30 | XCTAssertNotEqual(collapseBounds.state, .trailing) 31 | } 32 | 33 | /// Tests content's validity of collapsing *only* on the `leading` side of the screen. 34 | func testSDCollapseBoundsLeading() { 35 | let collapseBounds = SDGeometryEngine.SDCollapseBounds.test(.leading) 36 | 37 | XCTAssertEqual(collapseBounds.state, .leading) 38 | XCTAssertNotEqual(collapseBounds.state, .top) 39 | XCTAssertNotEqual(collapseBounds.state, .bottom) 40 | XCTAssertNotEqual(collapseBounds.state, .trailing) 41 | } 42 | 43 | /// Tests content's validity of collapsing *only* on the `trailing` side of the screen. 44 | func testSDCollapseBoundsTrailing() { 45 | let collapseBounds = SDGeometryEngine.SDCollapseBounds.test(.trailing) 46 | 47 | XCTAssertEqual(collapseBounds.state, .trailing) 48 | XCTAssertNotEqual(collapseBounds.state, .top) 49 | XCTAssertNotEqual(collapseBounds.state, .leading) 50 | XCTAssertNotEqual(collapseBounds.state, .bottom) 51 | } 52 | 53 | /// All tests counducted for SDCollapseBounds. 54 | static var allTests = [ 55 | ("testSDCollapseBoundsTop", testSDCollapseBoundsTop), 56 | ("testSDCollapseBoundsBottom", testSDCollapseBoundsBottom), 57 | ("testSDCollapseBoundsLeading", testSDCollapseBoundsLeading), 58 | ("testSDCollapseBoundsTrailing", testSDCollapseBoundsTrailing), 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDCollapseOffsetTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDCollapseOffsetTests.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftUIDrag 10 | 11 | /// SDCollapseOffset tests suite. 12 | final class SDCollapseOffsetTests: XCTestCase { 13 | /// Tests initialization with the selected visible size. 14 | func testSDCollapseOffset() { 15 | let visibleSize = CGSize(width: 30, height: 15) 16 | let collapseOffset = SDGeometryEngine.CollapseOffset(distanceFrom: .test(), visibleSize: visibleSize) 17 | 18 | XCTAssertEqual(collapseOffset.toTop, -135) 19 | XCTAssertEqual(collapseOffset.toBottom, 85) 20 | XCTAssertEqual(collapseOffset.toLeading, -220) 21 | XCTAssertEqual(collapseOffset.toTrailing, 120) 22 | } 23 | 24 | /// All tests conducted for SDCollapseOffset. 25 | static var allTests = [ 26 | ("testSDCollapseOffset", testSDCollapseOffset), 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDDistance+Testable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDDistance+Testable.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import SwiftUI 9 | @testable import SwiftUIDrag 10 | 11 | extension SDGeometryEngine.SDDistance { 12 | /** 13 | Accessory function to simplify testing for the four basic SDCollapseOptions: top, bottom, leading, and trailing. 14 | 15 | - Parameters: 16 | - content: Rect of content to be tested. 17 | - container: Size of content's parent view. 18 | 19 | - Returns: An instance of `SDDistance` to be tested based on the inputted `content` and `container` parameters. 20 | */ 21 | static func test( 22 | _ content: CGRect = .init(x: 150, y: 100, width: 100, height: 50), 23 | in container: CGSize = .init(width: 300, height: 200) 24 | ) -> SDGeometryEngine.SDDistance { 25 | .init(content, fromBoundsIn: container) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDDistanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDDistanceTests.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftUIDrag 10 | 11 | /// SDDistance tests suite. 12 | final class SDDistanceTests: XCTestCase { 13 | /// Tests initialization for SDDistance. 14 | func testSDDistance() { 15 | let distance: SDGeometryEngine.SDDistance = SDGeometryEngine.SDDistance.test() 16 | 17 | XCTAssertEqual(distance.toTop, -150) 18 | XCTAssertEqual(distance.toBottom, 100) 19 | XCTAssertEqual(distance.toLeading, -250) 20 | XCTAssertEqual(distance.toTrailing, 150) 21 | } 22 | 23 | /// All tests conducted for SDDistance. 24 | static var allTests = [ 25 | ("testSDDistance", testSDDistance), 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDFloatingBounds+Testable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDFloatingBounds+Testable.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import SwiftUI 9 | @testable import SwiftUIDrag 10 | 11 | extension SDGeometryEngine.SDFloatingBounds { 12 | /** 13 | Accessory function to simplify testing for the four basic SDFloatingOptions: topLeading, topTrailing, bottomLeading, bottomLeading. 14 | 15 | - Parameters: 16 | - option: The *option* to be tested. 17 | 18 | - Returns: An instance of `SDFloatingBounds` to be tested based on the appropriate `option`. 19 | */ 20 | static func test(_ option: SDFloatingOptions) -> SDGeometryEngine.SDFloatingBounds { 21 | let viewSize = CGSize(width: 300, height: 200) 22 | let floatingOptions: SDFloatingOptions = .all 23 | let collapseOptions: SDCollapseOptions = [] 24 | var contentLocation: CGPoint { 25 | switch option { 26 | case .topLeading: 27 | return .init(x: 0, y: 0) 28 | case .topTrailing: 29 | return .init(x: 300, y: 0) 30 | case .bottomLeading: 31 | return .init(x: 0, y: 200) 32 | case .bottomTrailing: 33 | return .init(x: 300, y: 200) 34 | default: 35 | fatalError("Testing is only for the four basic SDFloatingOptions: topLeadin, topTrailing, bottomLeading, or bottomTrailing.") 36 | } 37 | } 38 | 39 | return SDGeometryEngine.SDFloatingBounds(viewSize: viewSize, contentLocation: contentLocation, floating: floatingOptions, collapse: collapseOptions) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDFloatingBoundsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDFloatingBoundsTest.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftUIDrag 10 | 11 | /// SDFloatingBounds tests suite. 12 | final class SDFloatingBoundsTests: XCTestCase { 13 | /// Tests content's validity of floating *only* on the `topLeading` side of the screen. 14 | func testSDFloatingBoundsTopLeading() { 15 | let floatingBounds = SDGeometryEngine.SDFloatingBounds.test(.topLeading) 16 | 17 | XCTAssertEqual(floatingBounds.state, .topLeading) 18 | XCTAssertNotEqual(floatingBounds.state, .topTrailing) 19 | XCTAssertNotEqual(floatingBounds.state, .bottomLeading) 20 | XCTAssertNotEqual(floatingBounds.state, .bottomTrailing) 21 | } 22 | 23 | /// Tests content's validity of floating *only* on the `topTrailing` side of the screen. 24 | func testSDFloatingBoundsTopTrailing() { 25 | let floatingBounds = SDGeometryEngine.SDFloatingBounds.test(.topTrailing) 26 | 27 | XCTAssertEqual(floatingBounds.state, .topTrailing) 28 | XCTAssertNotEqual(floatingBounds.state, .topLeading) 29 | XCTAssertNotEqual(floatingBounds.state, .bottomLeading) 30 | XCTAssertNotEqual(floatingBounds.state, .bottomTrailing) 31 | } 32 | 33 | /// Tests content's validity of floating *only* on the `bottomLeading` side of the screen. 34 | func testSDFloatingBoundsBottomLeading() { 35 | let floatingBounds = SDGeometryEngine.SDFloatingBounds.test(.bottomLeading) 36 | 37 | XCTAssertEqual(floatingBounds.state, .bottomLeading) 38 | XCTAssertNotEqual(floatingBounds.state, .topTrailing) 39 | XCTAssertNotEqual(floatingBounds.state, .topLeading) 40 | XCTAssertNotEqual(floatingBounds.state, .bottomTrailing) 41 | } 42 | 43 | /// Tests content's validity of floating *only* on the `bottomTrailing` side of the screen. 44 | func testSDFloatingBoundsBottomTrailing() { 45 | let floatingBounds = SDGeometryEngine.SDFloatingBounds.test(.bottomTrailing) 46 | 47 | XCTAssertEqual(floatingBounds.state, .bottomTrailing) 48 | XCTAssertNotEqual(floatingBounds.state, .topTrailing) 49 | XCTAssertNotEqual(floatingBounds.state, .bottomLeading) 50 | XCTAssertNotEqual(floatingBounds.state, .topLeading) 51 | } 52 | 53 | /// All tests conducted for SDFloatingBounds. 54 | static var allTests = [ 55 | ("testSDFloatingBoundsTopLeading", testSDFloatingBoundsTopLeading), 56 | ("testSDFloatingBoundsTopTrailing", testSDFloatingBoundsTopTrailing), 57 | ("testSDFloatingBoundsBottomLeading", testSDFloatingBoundsBottomLeading), 58 | ("testSDFloatingBoundsBottomTrailing", testSDFloatingBoundsBottomTrailing), 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDFloatingOffsetTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SDFloatingOffsetTests.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftUIDrag 10 | 11 | /// SDFloatingOffset tests suite. 12 | final class SDFloatingOffsetTests: XCTestCase { 13 | /// Tests initialization. 14 | func testSDFloatingOffset() { 15 | let floatingOffset = SDGeometryEngine.SDFloatingOffset(distanceFrom: .test()) 16 | 17 | XCTAssertEqual(floatingOffset.toTop, -100) 18 | XCTAssertEqual(floatingOffset.toBottom, 50) 19 | XCTAssertEqual(floatingOffset.toLeading, -150) 20 | XCTAssertEqual(floatingOffset.toTrailing, 50) 21 | } 22 | 23 | /// All tests conducted for SDFloatingOffset. 24 | static var allTests = [ 25 | ("testSDFloatingOffset", testSDFloatingOffset), 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/SDGeometryEngineTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Mansur Ahmed on 2021-01-14. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftUIDrag 10 | import SwiftUI 11 | 12 | /// SDGeometryEngine tests suite. 13 | final class SDGeometryEngineTests: XCTestCase { 14 | /// Size of content's parent view. 15 | private let container = CGSize(width: 300, height: 200) 16 | 17 | /// Rect of content to inherit SDView properties. 18 | private let content = CGRect(x: 150, y: 100, width: 100, height: 50) 19 | 20 | /// All supported alignments in SDView. 21 | private let alignments: [Alignment] = [ 22 | .bottomLeading, 23 | .bottom, 24 | .bottomTrailing, 25 | .leading, 26 | .center, 27 | .trailing, 28 | .topLeading, 29 | .top, 30 | .topTrailing 31 | ] 32 | 33 | /// Tests location of content in SDView matches designation. 34 | func testLocation() { 35 | let expectedResults: [CGPoint] = [ 36 | .init(x: 50, y: 175), // bottomLeading 37 | .init(x: 150, y: 175), // bottom 38 | .init(x: 250, y: 175), // bottomTrailing 39 | .init(x: 50, y: 100), // leading 40 | .init(x: 150, y: 100), // center 41 | .init(x: 250, y: 100), // trailing 42 | .init(x: 50, y: 25), // topLeading 43 | .init(x: 150, y: 25), // top 44 | .init(x: 250, y: 25), // topTrailing 45 | ] 46 | 47 | for i in 0...8 { 48 | let location: CGPoint = SDGeometryEngine.location(of: content, in: container, with: alignments[i]) 49 | 50 | XCTAssertEqual(location, expectedResults[i], "Failed at index: \(i)") 51 | } 52 | } 53 | 54 | /// Tests updating of content location in SDView is done correctly. 55 | func testUpdate() { 56 | var contents: [CGRect] = Array(repeating: content, count: 9) 57 | let visibleSize = CGSize(width: 30, height: 15) 58 | let floatingOptions: SDFloatingOptions = .all 59 | let collapseOptions: SDCollapseOptions = .all 60 | var geometryEngine = SDGeometryEngine( 61 | for: contents[0], in: container, 62 | visibleSize: visibleSize, 63 | floatingOptions: floatingOptions, 64 | collapseOptions: collapseOptions 65 | ) 66 | let expectedResults: [CGRect] = [ 67 | .init(x: 0, y: 150, width: 100, height: 50), // bottomLeading 68 | .init(x: 100, y: 150, width: 100, height: 50), // bottom 69 | .init(x: 200, y: 150, width: 100, height: 50), // bottomTrailing 70 | .init(x: 0, y: 75, width: 100, height: 50), // leading 71 | .init(x: 100, y: 75, width: 100, height: 50), // center 72 | .init(x: 200, y: 75, width: 100, height: 50), // trailing 73 | .init(x: 0, y: 0, width: 100, height: 50), // topLeading 74 | .init(x: 100, y: 0, width: 100, height: 50), // top 75 | .init(x: 200, y: 0, width: 100, height: 50), // topTrailing 76 | ] 77 | 78 | for i in 0...8 { 79 | geometryEngine.update(rect: &contents[i], in: container, with: alignments[i]) 80 | 81 | XCTAssertEqual(contents[i], expectedResults[i], "Failed at index: \(i)") 82 | } 83 | } 84 | 85 | /// All tests conducted for SDGeometryEngine. 86 | static var allTests = [ 87 | ("testLocation", testLocation), 88 | ("testUpdate", testUpdate), 89 | ] 90 | } 91 | -------------------------------------------------------------------------------- /Tests/SwiftUIDragTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SDCollapseBoundsTests.allTests), 7 | testCase(SDFloatingBoundsTests.allTests), 8 | testCase(SDDistanceTests.allTests), 9 | testCase(SDCollapseOffsetTests.allTests), 10 | testCase(SDFloatingOffsetTests.allTests), 11 | testCase(SDGeometryEngineTests.allTests), 12 | ] 13 | } 14 | #endif 15 | --------------------------------------------------------------------------------