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