├── .gitignore ├── LICENSE ├── Package.swift ├── README.md └── Sources └── VCReorderableStackView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .swiftpm/xcode/package.xcworkspace/xcuserdata/vcherepjanko.xcuserdatad/UserInterfaceState.xcuserstate 2 | Sources/.DS_Store 3 | .DS_Store 4 | .swiftpm/xcode/xcuserdata/vcherepjanko.xcuserdatad/xcschemes/xcschememanagement.plist 5 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 6 | .swiftpm/xcode/xcuserdata/Valentin.xcuserdatad/xcschemes/xcschememanagement.plist 7 | .swiftpm/xcode/package.xcworkspace/xcuserdata/a18785272.xcuserdatad/UserInterfaceState.xcuserstate 8 | .swiftpm/xcode/xcuserdata/a18785272.xcuserdatad/xcschemes/xcschememanagement.plist 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Valentin Cherepyanko 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.1 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: "VCReorderableStackView", 8 | platforms: [ 9 | .iOS(.v10), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library( 14 | name: "VCReorderableStackView", 15 | targets: ["VCReorderableStackView"] 16 | ), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 25 | .target( 26 | name: "VCReorderableStackView", 27 | dependencies: [], 28 | path: "Sources" 29 | ), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VCReorderableStackView 2 | A simple implementation of the drag-and-drop stack view 3 | 4 | ## Demo 5 | ![reorderable_demo](https://user-images.githubusercontent.com/5366222/77224811-d1ab7880-6b9b-11ea-8c24-73a9476e7f10.gif) 6 | 7 | ## Installation 8 | Install with SPM 📦 9 | 10 | ## Usage 11 | The `IReorderableStackViewDelegate` protocol fires a callback whenever views are swapped 12 | 13 | ```swift 14 | public protocol IReorderableStackViewDelegate: AnyObject { 15 | func swapped(index: Int, with: Int) 16 | } 17 | ``` 18 | 19 | ## License 20 | This project is released under the [MIT license](https://en.wikipedia.org/wiki/MIT_License). 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/VCReorderableStackView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReorderableStackView.swift 3 | // VCForm 4 | // 5 | // Created by Valentin Cherepyanko on 03.03.2020. 6 | // Copyright © 2020 Valentin Cherepyanko. All rights reserved. 7 | // 8 | // Permission is hereby granted, free of charge, to any person obtaining a copy 9 | // of this software and associated documentation files (the "Software"), to deal 10 | // in the Software without restriction, including without limitation the rights 11 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | // copies of the Software, and to permit persons to whom the Software is 13 | // furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in 16 | // all copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | // THE SOFTWARE. 25 | // 26 | 27 | import UIKit 28 | 29 | @objc public protocol IReorderableStackViewDelegate: AnyObject { 30 | @objc optional func dragWillStart(with view: UIView) 31 | @objc optional func dragDidStart(with view: UIView) 32 | @objc optional func swapped(index: Int, with: Int) 33 | @objc optional func dragWillEnd(with view: UIView) 34 | @objc optional func dragDidEnd(with view: UIView) 35 | } 36 | 37 | @objc public protocol IDraggableView: AnyObject { 38 | @objc optional func dragWillStart() 39 | @objc optional func dragDidStart() 40 | @objc optional func dragWillEnd() 41 | @objc optional func dragDidEnd() 42 | } 43 | 44 | public class ReorderableStackView: UIStackView { 45 | 46 | public var reorderingEnabled: Bool = true { 47 | didSet { self.gestures.forEach { $0.isEnabled = self.reorderingEnabled } } 48 | } 49 | 50 | private var gestures: [UIGestureRecognizer] = [] 51 | 52 | private struct Settings { 53 | static let pressDuration: Double = 0.2 54 | static let animationDuration: Double = 0.2 55 | static let snapshotAlpha: CGFloat = 0.8 56 | 57 | static let otherViewsScale: CGFloat = 0.95 58 | static let snapshotScale: CGFloat = 1.05 59 | } 60 | 61 | private var snapshotView: UIView? 62 | private var originalView: UIView? 63 | private var originalPosition: CGPoint! 64 | private var reorderingPoint: CGPoint! 65 | 66 | public weak var delegate: IReorderableStackViewDelegate? 67 | 68 | override public func addArrangedSubview(_ view: UIView) { 69 | super.addArrangedSubview(view) 70 | view.isUserInteractionEnabled = true 71 | self.addGesture(to: view) 72 | } 73 | 74 | override public func insertArrangedSubview(_ view: UIView, at stackIndex: Int) { 75 | super.insertArrangedSubview(view, at: stackIndex) 76 | view.isUserInteractionEnabled = true 77 | self.addGesture(to: view) 78 | } 79 | } 80 | 81 | private extension ReorderableStackView { 82 | 83 | func addGesture(to view: UIView) { 84 | 85 | let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleGesture(_:))) 86 | gesture.minimumPressDuration = Settings.pressDuration 87 | gesture.isEnabled = self.reorderingEnabled 88 | 89 | view.addGestureRecognizer(gesture) 90 | self.gestures.append(gesture) 91 | } 92 | 93 | @objc 94 | func handleGesture(_ gesture: UILongPressGestureRecognizer) { 95 | switch gesture.state { 96 | case .began: 97 | self.notifyWillStartDragging(with: gesture) 98 | self.makeSnapshot(with: gesture) 99 | self.animateStartDragging() 100 | self.notifyDidStartDragging(with: gesture) 101 | case .changed: 102 | self.moveSnapshot(with: gesture) 103 | self.reorderViews(with: gesture) 104 | case .ended, .cancelled, .failed: 105 | self.notifyWillEndDragging(with: gesture) 106 | self.swapSnapshotWithOriginal() 107 | self.animateEndDragging() 108 | self.notifyDidEndDragging(with: gesture) 109 | default: 110 | () 111 | } 112 | } 113 | } 114 | 115 | private extension ReorderableStackView { 116 | 117 | func makeSnapshot(with gesture: UILongPressGestureRecognizer) { 118 | 119 | guard let gestureView = gesture.view else { return assertionFailure() } 120 | 121 | let snapshot = gestureView.snapshotView(afterScreenUpdates: true) 122 | 123 | snapshot.map { 124 | $0.frame = gestureView.frame 125 | self.addSubview($0) 126 | self.snapshotView = $0 127 | } 128 | 129 | self.originalView = gestureView 130 | self.originalView?.alpha = 0 131 | self.originalPosition = gesture.location(in: self) 132 | self.reorderingPoint = gesture.location(in: self) 133 | } 134 | 135 | func moveSnapshot(with gesture: UILongPressGestureRecognizer) { 136 | let location = gesture.location(in: self) 137 | let xOffset = location.x - self.originalPosition.x 138 | let yOffset = location.y - self.originalPosition.y 139 | let translation = CGAffineTransform(translationX: xOffset, y: yOffset) 140 | 141 | // replicate the scale that was initially applied 142 | let scale = CGAffineTransform(scaleX: Settings.snapshotScale, y: Settings.snapshotScale) 143 | self.snapshotView?.transform = scale.concatenating(translation) 144 | } 145 | 146 | func reorderViews(with gesture: UILongPressGestureRecognizer) { 147 | 148 | var snapshotViewMidPoint: CGFloat? = .zero 149 | var reorderingPoint: CGFloat = .zero 150 | var reorderViewFrame: CGFloat = .zero 151 | 152 | if self.axis == .horizontal { 153 | snapshotViewMidPoint = self.snapshotView?.frame.midX 154 | reorderingPoint = self.reorderingPoint.x 155 | } else { 156 | snapshotViewMidPoint = self.snapshotView?.frame.midY 157 | reorderingPoint = self.reorderingPoint.y 158 | } 159 | 160 | guard 161 | let view = self.originalView, 162 | let midY = snapshotViewMidPoint, 163 | let index = self.index(of: view) 164 | else { return } 165 | 166 | let reorderViewIndex = (midY > reorderingPoint) 167 | ? index + 1 168 | : index - 1 169 | 170 | guard let reorderView = self.view(for: reorderViewIndex) else { return } 171 | 172 | var viewMidPoint: CGFloat = .zero 173 | 174 | if self.axis == .horizontal { 175 | reorderViewFrame = reorderView.frame.midX 176 | viewMidPoint = view.frame.midX 177 | } else { 178 | reorderViewFrame = reorderView.frame.midY 179 | viewMidPoint = view.frame.midY 180 | } 181 | 182 | if midY > max(reorderingPoint, reorderViewFrame) 183 | || midY < min(reorderingPoint, reorderViewFrame) { 184 | 185 | UIView.animate(withDuration: Settings.animationDuration, animations: { 186 | self.insertArrangedSubview(reorderView, at: index) 187 | self.insertArrangedSubview(view, at: reorderViewIndex) 188 | }) 189 | 190 | reorderingPoint = viewMidPoint 191 | 192 | self.delegate?.swapped?(index: index, with: reorderViewIndex) 193 | } 194 | } 195 | 196 | func swapSnapshotWithOriginal() { 197 | 198 | guard let view = self.originalView else { return } 199 | 200 | UIView.animate(withDuration: Settings.animationDuration, animations: { 201 | self.snapshotView?.frame = view.frame 202 | }, completion: { _ in 203 | self.snapshotView?.removeFromSuperview() 204 | self.originalView?.alpha = 1 205 | }) 206 | } 207 | 208 | func animateStartDragging() { 209 | UIView.animate(withDuration: Settings.animationDuration) { 210 | let scale = CGAffineTransform(scaleX: Settings.snapshotScale, y: Settings.snapshotScale) 211 | self.snapshotView?.transform = scale 212 | self.snapshotView?.alpha = Settings.snapshotAlpha 213 | 214 | self.scaleOtherViews(to: Settings.otherViewsScale) 215 | } 216 | } 217 | 218 | func animateEndDragging() { 219 | UIView.animate(withDuration: Settings.animationDuration) { 220 | self.snapshotView?.alpha = 1 221 | self.scaleOtherViews(to: 1) 222 | } 223 | } 224 | 225 | func scaleOtherViews(to scale: CGFloat) { 226 | for subview in self.arrangedSubviews { 227 | if subview != self.originalView { 228 | subview.transform = CGAffineTransform(scaleX: scale, y: scale) 229 | } 230 | } 231 | } 232 | } 233 | 234 | // MARK: - delegate notifications 235 | private extension ReorderableStackView { 236 | 237 | func notifyWillStartDragging(with gesture: UILongPressGestureRecognizer) { 238 | guard let gestureView = gesture.view else { return assertionFailure() } 239 | self.delegate?.dragWillStart?(with: gestureView) 240 | (gestureView as? IDraggableView)?.dragWillStart?() 241 | } 242 | 243 | func notifyDidStartDragging(with gesture: UILongPressGestureRecognizer) { 244 | guard let gestureView = gesture.view else { return assertionFailure() } 245 | self.delegate?.dragDidStart?(with: gestureView) 246 | (gestureView as? IDraggableView)?.dragDidStart?() 247 | } 248 | 249 | func notifyWillEndDragging(with gesture: UILongPressGestureRecognizer) { 250 | guard let gestureView = gesture.view else { return assertionFailure() } 251 | self.delegate?.dragWillEnd?(with: gestureView) 252 | (gestureView as? IDraggableView)?.dragWillEnd?() 253 | } 254 | 255 | func notifyDidEndDragging(with gesture: UILongPressGestureRecognizer) { 256 | guard let gestureView = gesture.view else { return assertionFailure() } 257 | self.delegate?.dragDidEnd?(with: gestureView) 258 | (gestureView as? IDraggableView)?.dragDidEnd?() 259 | } 260 | } 261 | 262 | private extension UIStackView { 263 | 264 | func index(of view: UIView) -> Int? { 265 | for (index, subview) in self.arrangedSubviews.enumerated() { 266 | guard view == subview else { continue } 267 | return index 268 | } 269 | return nil 270 | } 271 | 272 | func view(for index: Int) -> UIView? { 273 | for (currentIndex, subview) in self.arrangedSubviews.enumerated() { 274 | guard currentIndex == index else { continue } 275 | return subview 276 | } 277 | return nil 278 | } 279 | } 280 | --------------------------------------------------------------------------------