├── .gitignore ├── .swift-version ├── LICENSE.md ├── README.md ├── Screenshots ├── cards_stack.gif ├── events_feed_stack.gif ├── item_title_custom.jpg ├── item_title_none.jpg ├── item_title_standard.jpg ├── paging_stack.gif ├── stack_of_stacks.gif └── two_stacks.gif ├── StackFlowView.podspec ├── StackFlowView ├── StackFlow.swift ├── StackFlowView.swift └── StackItemView.swift └── StackFlowViewDemo ├── StackFlowViewDemo.xcodeproj ├── project.pbxproj └── project.xcworkspace │ └── contents.xcworkspacedata └── StackFlowViewDemo ├── Controllers └── ViewController.swift ├── Extensions ├── Foundation+Extensions.swift ├── SwiftLibrary+Extentions.swift └── UIKit+Extensions.swift ├── Misc ├── AppDelegate.swift ├── Defines.swift └── Info.plist ├── Res ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── back.imageset │ │ ├── Contents.json │ │ ├── back-1.png │ │ ├── back-2.png │ │ └── back.png │ └── fwd.imageset │ │ ├── Contents.json │ │ ├── fwd-1.png │ │ ├── fwd-2.png │ │ └── fwd.png └── UI │ └── bg.jpg ├── Utils └── Utils.swift └── Views ├── CustomStackView └── CustomStackFlowView.swift ├── DemoItemView ├── DemoItemView.swift └── DemoItemView.xib └── Storyboards └── Base.lproj ├── LaunchScreen.storyboard └── Main.storyboard /.gitignore: -------------------------------------------------------------------------------- 1 | *xcuser* -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 4.0 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 0xNSHuman 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

StackFlowView

2 |

3 | Licence 4 | Version 5 | Swift Version 6 | StackFlowView 7 |

8 | 9 |

10 | 📥 Build custom UI flow using stack order 📤 11 |

12 |

13 | 🔗 Enforce sequential user interaction 🔗 14 |

15 |

16 | 🗂 Focus user attention on one flow step at a time 🗂 17 |

18 | 19 |

20 | Multiple stacks at once 21 | Cards stack 22 | Stack of pages 23 |

24 | 25 |

26 | Stack of stacks 27 |

28 | 29 |
30 | 31 | ## How does it work? 32 | 33 | **StackFlowView** is a high-level view capable of hosting a collection of custom `UIView`s. Which is, well, not unique behaviour.. The special thing about it though, is that it *enforces stack flow* behaviour (as the name suggests), which means: 34 | 1. Only the last view in stack allows user interaction. There is no way to affect past or future state of the UI flow; 35 | 36 | 2. No view properties can be pre-determined until the moment before putting one into stack (**push** action). This way, every next stack item considers previous state and can be adjusted to reflect particular flow step; 37 | 38 | 3. It is not possible to go **N** items back without dismissing/destroying those items (**pop** action). This way, going back in time and changing state enforces subsequent flow steps to be revisited. 39 | 40 | > During development, various state-dependent UX cases were kept in mind. For example, this solution perfectly works for all kinds of dynamic input forms where every next set of options depends on previous choices made by user. 41 | 42 | ## Installation 43 | ### CocoaPods 44 | 1. Add `pod 'StackFlowView'` to your `Podfile`; 45 | 2. Run `pod install` or `pod update` in Terminal; 46 | 3. Re-open your project using `.xcworkspace`, put `import StackFlowView` in the swift files you plan to use stack flow in (or use bridging in Obj-C projects); 47 | 4. Rebuild and enjoy. 48 | 49 | ### Old School Way 50 | Drop folder with `.swift` source files to your project and you're done. 51 | 52 | ## Usage 53 | 54 | ### Creation 55 | 56 | Creating `StackFlowView` takes a few lines of code. Basically, you need to: 57 | - Initialize it with any frame (not necessery); 58 | - Add it to superview; 59 | - Set delegate; 60 | - Optionally set up constraints if you want to enjoy autolayout-ready behaviour; 61 | 62 | ```Swift 63 | let stackView = StackFlowView() // StackFlowView(frame: ...) 64 | stackView.delegate = self 65 | 66 | view.addSubview(stackView) 67 | 68 | /* — Optional constraints — */ 69 | 70 | ([ 71 | NSLayoutConstraint(item: stackView, attribute: .top, relatedBy: .equal, toItem: view, attribute: .top, multiplier: 1.0, constant: 0), 72 | NSLayoutConstraint(item: stackView, attribute: .bottom, relatedBy: .equal, toItem: view, attribute: .bottom, multiplier: 1.0, constant: 0), 73 | NSLayoutConstraint(item: stackView, attribute: .leading, relatedBy: .equal, toItem: view, attribute: .leading, multiplier: 1.0, constant: 0), 74 | NSLayoutConstraint(item: stackView, attribute: .trailing, relatedBy: .equal, toItem: view, attribute: .trailing, multiplier: 1.0, constant: 0) 75 | ]).forEach { $0.isActive = true } 76 | 77 | view.setNeedsLayout() 78 | view.layoutIfNeeded() 79 | ``` 80 | 81 | ### Customization 82 | 83 | There are some nice options to define desired behaviour of your stack flow, including its direction of growth, the way it separates items, gestures user can use to move back and forth, and more. Please see the comments below, as well as property references in Xcode. 84 | 85 | ```Swift 86 | // How big should padding next to the stack's last item be? 87 | stackView.headPadding = 0 88 | 89 | // Which direction should new items be pushed in? 90 | stackView.growthDirection = .down 91 | 92 | // Separate by lines, padding or nothing at all? 93 | stackView.separationStyle = .line(thikness: 2.0, color: .black) 94 | // .padding(size: 20.0) 95 | // .none 96 | 97 | // If you want your stack gradually fade away, you can pick any of the styles, or combine them! 98 | stackView.fadingStyle = .combined(styles: 99 | [ 100 | .tint(color: .white, preLastAlpha: 0.9, alphaDecrement: 0.1), 101 | .gradientMask(effectDistance: stackView.bounds.height * 0.7) 102 | ] 103 | ) // Or just .none 104 | 105 | // You can swipe up-down or left-right to control your flow, and/or also tap inactive stack area to pop any number of items (depends on where you tap) 106 | stackView.userNavigationOptions = [.swipe, .tap] 107 | 108 | // Fast hops or sloooooow animation? 109 | stackView.transitionDuration = 0.25 110 | 111 | // Set to false if you don't need automatic safe area detection/adoption 112 | stackView.isSeekingSafeArea = true 113 | 114 | // Set to false to turn off stretch-out behaviour for your content items during autolayout updates 115 | stackView.isAutoresizingItems = true 116 | ``` 117 | 118 | ### Push and Pop items 119 | 120 | > **NOTE**: There is no views reusability mechanism in current version, so whatever you push to `StackFlowView` increments memory usage until you pop it. Therefore, the weak place of this library is a large number of flow steps. It's in TODO list to address this feature. 121 | 122 | #### Push 123 | 124 | There is only one straight-forward to use method to push your view into Stack Flow, but it lets you customize things to the extent you want. 125 | 126 | You can stick to using the same style for all items, or use custom approach for each one. 127 | 128 | ###### Just push the view itself 129 | 130 | Send the view to stack without showing anything else. 131 | 132 |

133 | Item without header 134 |

135 | 136 | ```Swift 137 | stackView.push(myCustomView) 138 | ``` 139 | 140 | ###### Push with header bar (standard appearance) 141 | 142 | Display item's title along with standard-looking buttons navigation, which is a good alternative to gestures in case you need it (for example, as an Accessibility option for users). 143 | 144 |

145 | Item with stanrard header 146 |

147 | 148 | ```Swift 149 | stackView.push(myCustomView, title: "Step ♦️") 150 | ``` 151 | 152 | ###### Push with customised header bar 153 | 154 | Define custom appearance for the item's container, including its header bar background color, title font and color, and navigation buttons appearance. 155 | 156 |

157 | Item with custom appearance 158 |

159 | 160 | ```Swift 161 | // Define custom top bar appearance 162 | 163 | let topBarAppearance: StackItemAppearance.TopBar = { 164 | let popButtonAppearance: StackItemAppearance.TopBar.Button 165 | let pushButtonAppearance: StackItemAppearance.TopBar.Button 166 | 167 | // You can use images or attributed text for navigation buttons 168 | 169 | let preferIconsOverText = false 170 | 171 | if preferIconsOverText { // Use icons 172 | popButtonAppearance = StackItemAppearance.TopBar.Button(icon: UIImage(named: "back")!) 173 | pushButtonAppearance = StackItemAppearance.TopBar.Button(icon: UIImage(named: "forth")!) 174 | } else { // Use text 175 | let popButtonTitle = NSAttributedString(string: "♦️⬅️", attributes: [.foregroundColor : UIColor.blue]) 176 | popButtonAppearance = StackItemAppearance.TopBar.Button(title: popButtonTitle) 177 | 178 | let pushButtonTitle = NSAttributedString(string: "➡️💎", attributes: [.foregroundColor : UIColor.blue]) 179 | pushButtonAppearance = StackItemAppearance.TopBar.Button(title: pushButtonTitle) 180 | } 181 | 182 | let customBarAppearance = StackItemAppearance.TopBar(backgroundColor: Utils.randomPastelColor(), titleFont: .italicSystemFont(ofSize: 17.0), titleTextColor: .white, popButtonIdentity: popButtonAppearance, pushButtonIdentity: pushButtonAppearance) 183 | 184 | return customBarAppearance 185 | }() 186 | 187 | // Set appearence for the whole item, including previously created top bar appearance 188 | 189 | let customAppearance = StackItemAppearance(backgroundColor: Utils.randomPastelColor(), topBarAppearance: topBarAppearance) 190 | 191 | // Push it all to the stack! 192 | 193 | stackView.push(myCustomView, title: "Step ♦️", customAppearance: customAppearance) 194 | ``` 195 | 196 | #### Pop 197 | 198 | Pop N items from stack by calling one of the `pop(_:)` method variations. 199 | 200 | ###### Pop one item 201 | 202 | ```Swift 203 | stackView.pop() 204 | ``` 205 | 206 | ###### Pop multiple items 207 | 208 | ```Swift 209 | stackView.pop(numberOfItems) 210 | ``` 211 | 212 | ### Delegate methods 213 | 214 | `StackFlowDelegate` protocol enables control over stack flow by the object implementing it. For example, it delivers **push** and **pop** intention events triggered by user gestures, and lets you decide if StackFlowView should proceed or ignore this action. It also reports about the corresponding actions that are upcoming or just passed. 215 | 216 | ```Swift 217 | func stackFlowViewDidRequestPop(_ stackView: StackFlowView, numberOfItems: Int) { 218 | log(message: "Requested to go \(numberOfItems) steps back", from: self) 219 | stackView.pop(numberOfItems) 220 | } 221 | 222 | func stackFlowViewDidRequestPush(_ stackView: StackFlowView) { 223 | log(message: "Requested next item", from: self) 224 | goToNextStep() 225 | } 226 | 227 | func stackFlowViewWillPop(_ stackView: StackFlowView) { 228 | log(message: "About to go one item back", from: self) 229 | } 230 | 231 | func stackFlowViewDidPop(_ stackView: StackFlowView) { 232 | log(message: "Went one item back", from: self) 233 | } 234 | 235 | func stackFlowView(_ stackView: StackFlowView, willPush view: UIView) { 236 | log(message: "About to to go to the next step", from: self) 237 | } 238 | 239 | func stackFlowView(_ stackView: StackFlowView, didPush view: UIView) { 240 | log(message: "Went to next step with view: \(view)", from: self) 241 | } 242 | ``` 243 | 244 | ## [Optional] Simplest flow logic example 245 | 246 | ```Swift 247 | class MyFlowController: UIViewController { 248 | // MARK: - Flow definition - 249 | 250 | enum MyFlowStep: Int { 251 | case none = -1 252 | case one = 0, two, three, four 253 | 254 | static var count: Int { return 4 } 255 | 256 | var title: String { 257 | switch self { 258 | default: 259 | return "Step \(shortSymbol)" 260 | } 261 | } 262 | 263 | var shortSymbol: String { 264 | switch self { 265 | case .one: 266 | return "♦️" 267 | 268 | case .two: 269 | return "♠️" 270 | 271 | case .three: 272 | return "💎" 273 | 274 | case .four: 275 | return "🔮" 276 | 277 | case .none: 278 | return "❌" 279 | } 280 | } 281 | 282 | var prevStep: FlowStep? { 283 | let prevValue = rawValue - 1 284 | return prevValue >= 0 ? FlowStep(rawValue: prevValue) : nil 285 | } 286 | 287 | var nextStep: FlowStep? { 288 | let nextValue = rawValue + 1 289 | return nextValue < FlowStep.count ? FlowStep(rawValue: nextValue) : nil 290 | } 291 | } 292 | 293 | // MARK: - Properties - 294 | 295 | private let stackView = StackFlowView() 296 | 297 | /* — Whenever this property is set, you can prepare the next view to push — */ 298 | 299 | private var currentStep: MyFlowStep = .none { 300 | didSet { 301 | // Get identity of the current step 302 | 303 | let itemTitle = currentStep.title 304 | 305 | // You can optionall use bounding steps' identity for something like setting custom navigation buttons 306 | 307 | let prevItemSymbol = currentStep.prevStep?.shortSymbol 308 | let nextItemSymbol = currentStep.nextStep?.shortSymbol 309 | 310 | // Here you should construct your custom UIView considering purposes of this particular step 311 | 312 | let itemView = stepView(for: currentStep) 313 | 314 | // Now you can push your custom view using superclass `push()` method! 315 | 316 | stackView.push(itemView, title: itemTitle) 317 | } 318 | } 319 | 320 | // MARK: - View constructor - 321 | 322 | private func stepView(for step: MyFlowStep) -> UIView { 323 | let stepView: UIView 324 | 325 | // Note this `safeSize` property of StackFlowView. You should use it to get info about its available content area, not blocked by any views outside of safe area 326 | 327 | let safeStackFlowViewWidth = stackView.safeSize.width 328 | 329 | // Build custom view for any given step 330 | 331 | switch step { 332 | case .one: 333 | stepView = UIView(frame: CGRect(x: 0, y: 0, width: safeStackFlowViewWidth, height: 100.0)) 334 | 335 | case .two: 336 | stepView = UIView(frame: CGRect(x: 0, y: 0, width: safeStackFlowViewWidth, height: 200.0)) 337 | 338 | default: 339 | stepView = UIView(frame: CGRect(x: 0, y: 0, width: safeStackFlowViewWidth, height: 300.0)) 340 | } 341 | 342 | return stepView 343 | } 344 | } 345 | ``` 346 | 347 | ## TODO 348 | 349 | - [ ] Think about views reusability mechanism 350 | 351 | ## License 352 | StackFlowView is released under an MIT license. See the [LICENSE](https://github.com/0xNSHuman/StackFlowView/blob/master/LICENSE.md) file. 353 | -------------------------------------------------------------------------------- /Screenshots/cards_stack.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/Screenshots/cards_stack.gif -------------------------------------------------------------------------------- /Screenshots/events_feed_stack.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/Screenshots/events_feed_stack.gif -------------------------------------------------------------------------------- /Screenshots/item_title_custom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/Screenshots/item_title_custom.jpg -------------------------------------------------------------------------------- /Screenshots/item_title_none.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/Screenshots/item_title_none.jpg -------------------------------------------------------------------------------- /Screenshots/item_title_standard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/Screenshots/item_title_standard.jpg -------------------------------------------------------------------------------- /Screenshots/paging_stack.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/Screenshots/paging_stack.gif -------------------------------------------------------------------------------- /Screenshots/stack_of_stacks.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/Screenshots/stack_of_stacks.gif -------------------------------------------------------------------------------- /Screenshots/two_stacks.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/Screenshots/two_stacks.gif -------------------------------------------------------------------------------- /StackFlowView.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = "StackFlowView" 4 | s.version = "1.0.2" 5 | s.summary = "Enforcing stack order for custom UI elements" 6 | 7 | s.homepage = "https://github.com/0xNSHuman/StackFlowView" 8 | s.screenshots = "https://raw.githubusercontent.com/0xNSHuman/StackFlowView/master/Screenshots/two_stacks.gif", "https://github.com/0xNSHuman/StackFlowView/raw/master/Screenshots/cards_stack.gif", "https://raw.githubusercontent.com/0xNSHuman/StackFlowView/master/Screenshots/stack_of_stacks.gif" 9 | 10 | s.license = { :type => "MIT", :file => "LICENSE.md" } 11 | 12 | s.platform = :ios 13 | s.ios.deployment_target = "8.0" 14 | 15 | s.source = { :git => "https://github.com/0xNSHuman/StackFlowView.git", :tag => "v1.0.2" } 16 | 17 | s.source_files = "StackFlowView", "StackFlowView/**/*.swift" 18 | 19 | s.frameworks = "UIKit" 20 | 21 | s.requires_arc = true 22 | s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS[config=Debug]' => '-D DEBUG' } 23 | 24 | end 25 | -------------------------------------------------------------------------------- /StackFlowView/StackFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StackFlow.swift 3 | // Created by 0xNSHuman on 11/12/2017. 4 | 5 | /* 6 | The MIT License (MIT) 7 | Copyright © 2017 0xNSHuman 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 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | import Foundation 26 | import UIKit 27 | 28 | // MARK: - Stack item appearance - 29 | 30 | /// Appearance description, typically assigned to newly pushed stack flow item when you want to customize its look. 31 | 32 | public struct StackItemAppearance { 33 | static var defaultPreset: StackItemAppearance { 34 | let popButtonTitle = NSAttributedString(string: "prev", attributes: [.foregroundColor : UIColor.blue]) 35 | let pushButtonTitle = NSAttributedString(string: "next", attributes: [.foregroundColor : UIColor.blue]) 36 | 37 | let topBarAppearance = StackItemAppearance.TopBar(backgroundColor: .white, titleFont: UIFont.systemFont(ofSize: UIFont.systemFontSize), titleTextColor: .darkGray, popButtonIdentity: TopBar.Button(title: popButtonTitle), pushButtonIdentity: TopBar.Button(title: pushButtonTitle)) 38 | 39 | return StackItemAppearance(backgroundColor: .clear, topBarAppearance: topBarAppearance) 40 | } 41 | 42 | /// Appearance description for stack flow item's top (navigation) bar. 43 | 44 | public struct TopBar { 45 | public struct Button { 46 | let icon: UIImage? 47 | let title: NSAttributedString? 48 | 49 | public init(icon: UIImage) { 50 | self.icon = icon 51 | self.title = nil 52 | } 53 | 54 | public init(title: NSAttributedString) { 55 | self.title = title 56 | self.icon = nil 57 | } 58 | } 59 | 60 | let backgroundColor: UIColor 61 | let titleFont: UIFont 62 | let titleTextColor: UIColor 63 | let popButtonIdentity: Button 64 | let pushButtonIdentity: Button 65 | 66 | public init(backgroundColor: UIColor, titleFont: UIFont, titleTextColor: UIColor, popButtonIdentity: Button, pushButtonIdentity: Button) { 67 | 68 | self.backgroundColor = backgroundColor 69 | self.titleFont = titleFont 70 | self.titleTextColor = titleTextColor 71 | self.popButtonIdentity = popButtonIdentity 72 | self.pushButtonIdentity = pushButtonIdentity 73 | } 74 | } 75 | 76 | let backgroundColor: UIColor 77 | let topBarAppearance: TopBar 78 | 79 | public init(backgroundColor: UIColor, topBarAppearance: TopBar? = nil) { 80 | self.backgroundColor = backgroundColor 81 | self.topBarAppearance = topBarAppearance ?? StackItemAppearance.defaultPreset.topBarAppearance 82 | } 83 | } 84 | 85 | // MARK: - Internal notifications - 86 | 87 | /// Internally used events wrapper. Intended for inter-module use only, at least with its current implementation. 88 | 89 | struct StackFlowNotification { 90 | // MARK: - Errors - 91 | 92 | enum StackFlowNotificationError: Error { 93 | case parsingFailed 94 | } 95 | 96 | // MARK: - Notification names - 97 | 98 | enum Name: String { 99 | case itemPushed = "stackFlowViewItemPushed" 100 | case itemPopped = "stackFlowViewItemPopped" 101 | } 102 | 103 | // MARK: - Stored values - 104 | 105 | private let obj: Notification 106 | let name: Name 107 | let stackFlowView: StackFlowView 108 | 109 | // MARK: - Initializers - 110 | 111 | private init(name: Name, stackView: StackFlowView) { 112 | self.name = name 113 | self.stackFlowView = stackView 114 | self.obj = Notification(name: Notification.Name(rawValue: name.rawValue), object: stackView, userInfo: nil) 115 | } 116 | 117 | init(plainNotification: Notification) throws { 118 | guard let name = Name(rawValue: plainNotification.name.rawValue), let stackView = plainNotification.object as? StackFlowView else { 119 | throw StackFlowNotificationError.parsingFailed 120 | } 121 | 122 | self.name = name 123 | self.stackFlowView = stackView 124 | self.obj = plainNotification 125 | } 126 | 127 | // MARK: - Post - 128 | 129 | static func post(name: Name, stackView: StackFlowView) { 130 | let notification = StackFlowNotification(name: name, stackView: stackView) 131 | NotificationCenter.default.post(notification.obj) 132 | } 133 | 134 | // MARK: - Subscribe - 135 | 136 | static func observe(name: Name, by observer: Any, selector: Selector) { 137 | NotificationCenter.default.addObserver(observer, selector: selector, name: Notification.Name(rawValue: name.rawValue), object: nil) 138 | } 139 | 140 | static func forget(observer: Any) { 141 | NotificationCenter.default.removeObserver(observer) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /StackFlowView/StackFlowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StackFlowView.swift 3 | // Created by 0xNSHuman on 05/12/2017. 4 | 5 | /* 6 | The MIT License (MIT) 7 | Copyright © 2017 0xNSHuman 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 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | import UIKit 26 | 27 | /// Protocol used by Stack Flow View consumers to control the flow itself. 28 | 29 | public protocol StackFlowDelegate { 30 | func stackFlowViewDidRequestPop(_ stackView: StackFlowView, numberOfItems: Int) 31 | func stackFlowViewDidRequestPush(_ stackView: StackFlowView) 32 | 33 | func stackFlowViewWillPop(_ stackView: StackFlowView) 34 | func stackFlowViewDidPop(_ stackView: StackFlowView) 35 | 36 | func stackFlowView(_ stackView: StackFlowView, willPush view: UIView) 37 | func stackFlowView(_ stackView: StackFlowView, didPush view: UIView) 38 | } 39 | 40 | /// View that enables visual stack behaviour for subviews it contains. It can be used to stack up any custom UI elements in strict predefined order, or just randomly, in any of the four directions: up, down, left, right. There are a lot of customization options that make a number of different common UI patterns to be set up. 41 | 42 | open class StackFlowView: UIView, StackItemViewDelegate { 43 | // MARK: - Types - 44 | 45 | typealias StackSeparatorView = UIView 46 | 47 | public enum Direction { 48 | case up, down, left, right 49 | 50 | public var isVertical: Bool { return self == .up || self == .down } 51 | } 52 | 53 | public enum SeparationStyle { 54 | case none 55 | case line(thikness: CGFloat, color: UIColor) 56 | case padding(size: CGFloat) 57 | } 58 | 59 | public enum FadingStyle { 60 | case none 61 | case tint(color: UIColor, preLastAlpha: CGFloat, alphaDecrement: CGFloat?) 62 | case gradientMask(effectDistance: CGFloat) 63 | indirect case combined(styles: [FadingStyle]) 64 | } 65 | 66 | public struct NavigationOptions: OptionSet { 67 | public let rawValue: Int 68 | 69 | public static let swipe = NavigationOptions(rawValue: 1 << 1) 70 | public static let tap = NavigationOptions(rawValue: 1 << 2) 71 | 72 | public static let none: [NavigationOptions] = [] 73 | public static let all: [NavigationOptions] = [.swipe, .tap] 74 | 75 | public init(rawValue: Int) { 76 | self.rawValue = rawValue 77 | } 78 | } 79 | 80 | // MARK: - Delegate - 81 | 82 | public var delegate: StackFlowDelegate? = nil 83 | 84 | // MARK: - Subviews - 85 | 86 | private let contentContainer = UIView() 87 | private var items: [StackItemView] = [] 88 | private var separators: [StackItemView : StackSeparatorView] = [:] 89 | 90 | var lastItem: StackItemView? { 91 | return items.last 92 | } 93 | 94 | // MARK: - Public accessors - 95 | 96 | public var numberOfItems: Int { 97 | return items.count 98 | } 99 | 100 | public var lastItemContent: UIView? { 101 | return lastItem?.contentView 102 | } 103 | 104 | /// This property is calculated considering only part of stack flow view that stays inside safe area of its superview. It's highly recommended to always use this property for newly pushed item side definition, to avoid broken layout caused by dynamically shrinked safe area. 105 | 106 | public var safeSize: CGSize { 107 | let width = contentContainer.bounds.width 108 | let height = contentContainer.bounds.height 109 | 110 | let size = CGSize(width: width, height: height) 111 | return size 112 | } 113 | 114 | // MARK: - Appearance - 115 | 116 | open override var frame: CGRect { 117 | didSet { 118 | super.frame = frame 119 | } 120 | } 121 | 122 | /// Whether or not stack flow view should stretch/shrink its items' width (for vertical stacks) or height (for horizontal stacks) to fill the area perpendicular to stack's direction. For example: should it make vertical stack items wider after rotatin from portrait to landscape orientation. 123 | 124 | public var isAutoresizingItems: Bool = true 125 | 126 | /// Whether or not stack flow view should do its best to stay inside superview's safe area. For example: adjust content to avoid being covered by status bar, navigation bar, iPhone X notch, etc. 127 | 128 | public var isSeekingSafeArea: Bool = true { 129 | didSet { 130 | resetContainerConstraints() 131 | layoutIfNeeded() 132 | } 133 | } 134 | 135 | /// Head of stack is the last item pushed. Depending on the direction of stack growth, this property adds padding on top, bottom, left or right side of the stack, right next to its head item. 136 | 137 | public var headPadding: CGFloat = 0.0 { 138 | didSet { 139 | resetContainerConstraints() 140 | layoutIfNeeded() 141 | } 142 | } 143 | 144 | /// The direction stack pushes its items to. For example: .down direction will always keep the last item sticked to the bottom of stack flow view, and all previous elements will be shifted upper side. 145 | 146 | public var growthDirection: Direction = .down { 147 | didSet { 148 | resetChildrenLayoutConstraints() 149 | layoutIfNeeded() 150 | } 151 | } 152 | 153 | /// The way stack items are separated visually. 154 | 155 | public var separationStyle: SeparationStyle = .line(thikness: 1.0, color: .white) { 156 | didSet { 157 | resetChildrenLayoutConstraints() 158 | layoutIfNeeded() 159 | } 160 | } 161 | 162 | /// The way stack item fades out previously pushed elements, focusing on the latest one. Multiple options can be combined and used simultaneously, or none of them, resulting in fade-out functionality turned off. 163 | 164 | public var fadingStyle: FadingStyle = .combined(styles: [.tint(color: .black, preLastAlpha: 0.9, alphaDecrement: nil), .gradientMask(effectDistance: 500)]) { 165 | didSet { 166 | resetFadingSettings() 167 | } 168 | } 169 | 170 | /// Active built-in navigation options. For example: swipe up or down controls flow of vertical stack, or tapping any of the inactive items pops the stack until it becomes active. 171 | 172 | public var userNavigationOptions: NavigationOptions = [.swipe, .tap] 173 | 174 | /// Duration of pop/push transitions 175 | 176 | public var transitionDuration: Double = 0.25 177 | 178 | // MARK: - Meta resolvers - 179 | 180 | func doesOwn(item: StackItemView) -> Bool { 181 | return (items.index(of: item) ?? nil) != nil 182 | } 183 | 184 | private var separationSizeAndColor: (CGFloat, UIColor) { 185 | switch separationStyle { 186 | case .padding(let size): 187 | return (size, .clear) 188 | 189 | case .line(let thikness, let color): 190 | return (thikness, color) 191 | 192 | case .none: 193 | return (0, .clear) 194 | } 195 | } 196 | 197 | // MARK: - Initializers - 198 | 199 | public override init(frame: CGRect) { 200 | super.init(frame: frame) 201 | setUp() 202 | } 203 | 204 | required public init?(coder aDecoder: NSCoder) { 205 | fatalError("init(coder:) has not been implemented") 206 | } 207 | 208 | // MARK: - Life cycle - 209 | 210 | open override func layoutSubviews() { 211 | super.layoutSubviews() 212 | 213 | resetFadingSettings() 214 | resetChildrenLayoutConstraints() 215 | 216 | layoutIfNeeded() 217 | } 218 | 219 | // MARK: - Setup - 220 | 221 | private func setUp() { 222 | backgroundColor = .clear 223 | contentContainer.backgroundColor = .clear 224 | contentContainer.frame = bounds 225 | 226 | contentContainer.translatesAutoresizingMaskIntoConstraints = false 227 | contentContainer.clipsToBounds = true 228 | 229 | addSubview(contentContainer) 230 | 231 | resetContainerConstraints() 232 | layoutIfNeeded() 233 | 234 | // Add navigation gestures 235 | 236 | let swipeGestures = [ 237 | UISwipeGestureRecognizer(target: self, action: #selector(swipeOccured(_:))), 238 | UISwipeGestureRecognizer(target: self, action: #selector(swipeOccured(_:))), 239 | UISwipeGestureRecognizer(target: self, action: #selector(swipeOccured(_:))), 240 | UISwipeGestureRecognizer(target: self, action: #selector(swipeOccured(_:))) 241 | ] 242 | 243 | swipeGestures.forEach { 244 | let directions: [UISwipeGestureRecognizerDirection] = [.up, .down, .left, .right] 245 | $0.direction = directions[swipeGestures.index(of: $0)!] 246 | contentContainer.addGestureRecognizer($0) 247 | } 248 | 249 | let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapOccured(_:))) 250 | contentContainer.addGestureRecognizer(tapGesture) 251 | } 252 | 253 | // MARK: - Items lifecycle - 254 | 255 | /// Pushes given view with custom content to stack. Optionally, navigation bar can be presented once title is provided. Also, said navigation bar's appearance can be customized. 256 | 257 | /// - parameter view: The view with custom content to push 258 | /// - parameter title: Optional title to display along with navigation nar 259 | /// - parameter customAppearance: Optional customization of the presented navigation bar 260 | 261 | public func push(_ view: UIView, title: String? = nil, customAppearance: StackItemAppearance? = nil) { 262 | let stackItemView = StackItemView(contentView: view, title: title, customAppearance: customAppearance) 263 | stackItemView.delegate = self 264 | 265 | delegate?.stackFlowView(self, willPush: view) 266 | 267 | items.append(stackItemView) 268 | 269 | prepareAndPush(stackItemView) 270 | 271 | delegate?.stackFlowView(self, didPush: view) 272 | } 273 | 274 | /// Pops given number of items out of stack flow view. 275 | 276 | /// - parameter numberOfItems: Number of items to pop 277 | 278 | public func pop(_ numberOfItems: Int = 1) { 279 | for _ in 0 ..< numberOfItems { 280 | if let lastChild = items.last { 281 | delegate?.stackFlowViewWillPop(self) 282 | 283 | items.removeLast() 284 | 285 | popAndCleanUp(lastChild) 286 | 287 | delegate?.stackFlowViewDidPop(self) 288 | } 289 | } 290 | } 291 | 292 | /// Pops all the items out of stack flow view. 293 | 294 | public func clean() { 295 | pop(items.count) 296 | } 297 | 298 | // MARK: - StackItemViewDelegate - 299 | 300 | func stackItemRequestedPop(_ stackItemView: StackItemView) { 301 | delegate?.stackFlowViewDidRequestPop(self, numberOfItems: 1) 302 | } 303 | 304 | func stackItemRequestedPush(_ stackItemView: StackItemView) { 305 | delegate?.stackFlowViewDidRequestPush(self) 306 | } 307 | 308 | // MARK: - Transitions - 309 | 310 | private func popAndCleanUp(_ item: StackItemView) { 311 | UIView.animate(withDuration: transitionDuration * 0.5, animations: { 312 | item.alpha = 0.0 313 | self.separators[item]?.alpha = 0.0 314 | }) { (_) in 315 | item.removeFromSuperview() 316 | 317 | self.separators[item]?.removeFromSuperview() 318 | self.separators.removeValue(forKey: item) 319 | 320 | self.resetChildrenLayoutConstraints() 321 | 322 | UIView.animate(withDuration: self.transitionDuration * 0.5) { 323 | self.resetFadingSettings() 324 | self.layoutIfNeeded() 325 | } 326 | 327 | StackFlowNotification.post(name: .itemPopped, stackView: self) 328 | } 329 | } 330 | 331 | private func prepareAndPush(_ item: StackItemView) { 332 | removeChildrenLayoutConstraints() 333 | 334 | var separator: StackSeparatorView? = nil 335 | 336 | // TODO: Revisit this `isFirstInStack` property thing, I'm not fully satisfied with this approach (@0xNSHuman) 337 | 338 | if items.first == item { 339 | item.isFirstInStack = true 340 | } else { 341 | item.isFirstInStack = false 342 | 343 | separator = StackSeparatorView() 344 | separator?.backgroundColor = separationSizeAndColor.1 345 | 346 | separators[item] = separator 347 | } 348 | 349 | // Initial position to kick transition from 350 | 351 | switch growthDirection { 352 | case .up: 353 | item.center = CGPoint(x: contentContainer.bounds.width / 2, y: -(item.bounds.height / 2)) 354 | case .down: 355 | separator?.frame = CGRect(x: 0, y: contentContainer.bounds.height + (separator?.bounds.height ?? 0.0) / 2, width: contentContainer.bounds.width, height: separationSizeAndColor.0) 356 | 357 | if let separator = separator { 358 | item.center = CGPoint(x: contentContainer.bounds.width / 2, y: separator.center.y + separationSizeAndColor.0) 359 | } else { 360 | item.center = CGPoint(x: contentContainer.bounds.width / 2, y: contentContainer.bounds.height + item.bounds.height / 2) 361 | } 362 | 363 | case .left: 364 | item.center = CGPoint(x: -(item.bounds.width / 2), y: contentContainer.bounds.height / 2) 365 | case .right: 366 | item.center = CGPoint(x: contentContainer.bounds.width + item.bounds.width / 2, y: contentContainer.bounds.height / 2) 367 | } 368 | 369 | if let separator = separator { 370 | contentContainer.addSubview(separator) 371 | } 372 | 373 | contentContainer.addSubview(item) 374 | applyChildrenLayoutConstraints() 375 | 376 | UIView.animate(withDuration: transitionDuration) { 377 | self.resetFadingSettings() 378 | self.layoutIfNeeded() 379 | } 380 | 381 | StackFlowNotification.post(name: .itemPushed, stackView: self) 382 | } 383 | 384 | // MARK: - Items fading - 385 | 386 | private func resetFadingSettings() { 387 | guard let lastItem = items.last else { return } 388 | 389 | // Lock up all the elements but the last in stack 390 | 391 | items.forEach { $0.isUserInteractionEnabled = false } 392 | lastItem.isUserInteractionEnabled = true 393 | 394 | // Clean up previous isolation setup 395 | 396 | lastItem.tintView.isHidden = true 397 | lastItem.alpha = 1.0 398 | 399 | let tintResetAction: () -> () = { 400 | for i in 0 ..< self.items.count - 1 { 401 | self.items[i].tintView.isHidden = true 402 | self.items[i].alpha = 1.0 403 | } 404 | } 405 | 406 | let gradientResetAction: () -> () = { 407 | self.contentContainer.layer.mask = nil 408 | } 409 | 410 | if case .gradientMask(_) = fadingStyle { 411 | tintResetAction() 412 | } else if case .tint(_, _, _) = fadingStyle { 413 | gradientResetAction() 414 | } else if case .combined(let styles) = fadingStyle { 415 | if !styles.contains(where: { (style) -> Bool in 416 | if case .tint(_, _, _) = style { 417 | return true 418 | } else { 419 | return false 420 | } 421 | }) { 422 | tintResetAction() 423 | } 424 | 425 | if !styles.contains(where: { (style) -> Bool in 426 | if case .gradientMask(_) = style { 427 | return true 428 | } else { 429 | return false 430 | } 431 | }) { 432 | gradientResetAction() 433 | } 434 | } else if case .none = fadingStyle { 435 | tintResetAction() 436 | gradientResetAction() 437 | } 438 | 439 | // Apply new isolation 440 | 441 | func applyStyle(_ style: FadingStyle) { 442 | switch style { 443 | case .gradientMask(let effectDistance): 444 | let itemDistance = (growthDirection.isVertical ? lastItem.bounds.height : lastItem.bounds.width) 445 | let containerDistance = growthDirection.isVertical ? contentContainer.bounds.height : contentContainer.bounds.width 446 | 447 | let relativeItemEnd = itemDistance / containerDistance 448 | let relativeEffectEnd = relativeItemEnd + (effectDistance / containerDistance) 449 | 450 | let gradientLayer = CAGradientLayer() 451 | gradientLayer.frame = contentContainer.bounds 452 | gradientLayer.colors = [UIColor.black.cgColor, UIColor.clear.cgColor, UIColor.clear.cgColor] 453 | gradientLayer.locations = [relativeItemEnd as NSNumber, relativeEffectEnd as NSNumber, 1.0] 454 | 455 | switch growthDirection { 456 | case .up: 457 | gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.0) 458 | gradientLayer.endPoint = CGPoint(x: 0.5, y: 1.0) 459 | 460 | case .down: 461 | gradientLayer.startPoint = CGPoint(x: 0.5, y: 1.0) 462 | gradientLayer.endPoint = CGPoint(x: 0.5, y: 0.0) 463 | 464 | case .left: 465 | gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) 466 | gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5) 467 | 468 | case .right: 469 | gradientLayer.startPoint = CGPoint(x: 1.0, y: 0.5) 470 | gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.5) 471 | } 472 | 473 | contentContainer.layer.mask = gradientLayer 474 | 475 | case .tint(let color, let alpha, let alphaDecrement): 476 | // The decision to use stored subview instead of sublayer here is made because CALayer lacks ability to be autolayouted on iOS 477 | 478 | for i in 0 ..< items.count - 1 { 479 | let item = items[i] 480 | let tintView = item.tintView 481 | tintView.backgroundColor = color 482 | 483 | var itemAlpha: CGFloat 484 | if let decrement = alphaDecrement { 485 | itemAlpha = alpha - decrement * CGFloat(items.count - i - 1) 486 | itemAlpha = itemAlpha > 0.0 ? itemAlpha : 0.0 487 | } else { 488 | itemAlpha = alpha 489 | } 490 | 491 | item.alpha = itemAlpha 492 | } 493 | 494 | case .combined(let styles): 495 | for style in styles { 496 | applyStyle(style) 497 | } 498 | 499 | break 500 | 501 | case .none: 502 | break 503 | } 504 | } 505 | 506 | applyStyle(fadingStyle) 507 | } 508 | 509 | // MARK: - Layout - 510 | 511 | private func resetContainerConstraints() { 512 | constraints.forEach { 513 | // Only relationships with superview! 514 | 515 | guard ($0.firstItem as? UIView == contentContainer) || ($0.secondItem as? UIView == contentContainer) else { return } 516 | 517 | $0.isActive = false 518 | } 519 | 520 | contentContainer.removeConstraints(contentContainer.constraints) 521 | 522 | var containerConstraints: [NSLayoutConstraint] 523 | 524 | // Here we're trying to consider any potential safe area insets. Although, I believe it's responsibility on developer using this view to position it properly, that's still nice to be backed by this little nice feature. 525 | 526 | if isSeekingSafeArea, #available(iOS 11, *) { 527 | let guide = safeAreaLayoutGuide 528 | 529 | containerConstraints = [ 530 | contentContainer.topAnchor.constraint(equalTo: guide.topAnchor, constant: growthDirection == .up ? headPadding : 0.0), 531 | contentContainer.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: growthDirection == .down ? -headPadding : 0.0), 532 | contentContainer.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: growthDirection == .left ? headPadding : 0.0), 533 | contentContainer.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: growthDirection == .right ? -headPadding : 0.0) 534 | ] 535 | } else { 536 | containerConstraints = [ 537 | NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: contentContainer, attribute: .top, multiplier: 1.0, constant: growthDirection == .up ? headPadding : 0.0), 538 | NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: contentContainer, attribute: .bottom, multiplier: 1.0, constant: growthDirection == .down ? headPadding : 0.0), 539 | NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: contentContainer, attribute: .leading, multiplier: 1.0, constant: growthDirection == .left ? headPadding : 0.0), 540 | NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: contentContainer, attribute: .trailing, multiplier: 1.0, constant: growthDirection == .right ? headPadding : 0.0) 541 | ] 542 | } 543 | 544 | containerConstraints.forEach { $0.isActive = true } 545 | layoutIfNeeded() 546 | } 547 | 548 | private func resetChildrenLayoutConstraints() { 549 | removeChildrenLayoutConstraints() 550 | applyChildrenLayoutConstraints() 551 | } 552 | 553 | private func removeChildrenLayoutConstraints() { 554 | contentContainer.constraints.forEach { 555 | if $0.firstItem is StackItemView || $0.secondItem is StackItemView { 556 | $0.isActive = false 557 | contentContainer.removeConstraint($0) 558 | } 559 | } 560 | } 561 | 562 | private func applyChildrenLayoutConstraints() { 563 | items.forEach { 564 | let childConstraints = layout(for: $0) 565 | childConstraints.forEach { $0.isActive = true } 566 | } 567 | } 568 | 569 | private func layout(for child: StackItemView) -> [NSLayoutConstraint] { 570 | child.translatesAutoresizingMaskIntoConstraints = false 571 | 572 | // Get info about items relative position and separation between them 573 | 574 | let isFirstChild = child == items.first 575 | let isLastChild = child == items.last 576 | 577 | let prevChild: StackItemView? 578 | let separator: StackSeparatorView? 579 | let padding: CGFloat? 580 | 581 | if !isFirstChild { 582 | prevChild = items[items.index(of: child)! - 1] 583 | separator = separators[child]! 584 | 585 | separator?.translatesAutoresizingMaskIntoConstraints = false 586 | 587 | switch separationStyle { 588 | case .padding(let height): 589 | padding = height 590 | separator?.backgroundColor = .clear 591 | 592 | case .line(let width, let color): 593 | padding = width 594 | separator?.backgroundColor = color 595 | 596 | case .none: 597 | padding = 0 598 | separator?.backgroundColor = .clear 599 | } 600 | } else { 601 | prevChild = nil 602 | separator = nil 603 | padding = nil 604 | } 605 | 606 | // Set up necessary constraints 607 | 608 | var constraints: [NSLayoutConstraint] = [] 609 | 610 | if growthDirection.isVertical { 611 | constraints = [ 612 | // Center child content 613 | 614 | NSLayoutConstraint(item: child, attribute: .centerX, relatedBy: .equal, toItem: contentContainer, attribute: .centerX, multiplier: 1.0, constant: 0.0), 615 | ] + (!isFirstChild ? [ 616 | // Stick separator to container bounds 617 | 618 | NSLayoutConstraint(item: separator!, attribute: .leading, relatedBy: .equal, toItem: contentContainer, attribute: .leading, multiplier: 1.0, constant: 0.0), 619 | NSLayoutConstraint(item: separator!, attribute: .trailing, relatedBy: .equal, toItem: contentContainer, attribute: .trailing, multiplier: 1.0, constant: 0.0), 620 | NSLayoutConstraint(item: separator!, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: padding!) 621 | ] : []) 622 | } else { 623 | constraints = [ 624 | // Center child content 625 | 626 | NSLayoutConstraint(item: child, attribute: .centerY, relatedBy: .equal, toItem: contentContainer, attribute: .centerY, multiplier: 1.0, constant: 0.0) 627 | ] + (!isFirstChild ? [ 628 | // Stick separator to container bounds 629 | 630 | NSLayoutConstraint(item: separator!, attribute: .top, relatedBy: .equal, toItem: contentContainer, attribute: .top, multiplier: 1.0, constant: 0.0), 631 | NSLayoutConstraint(item: separator!, attribute: .bottom, relatedBy: .equal, toItem: contentContainer, attribute: .bottom, multiplier: 1.0, constant: 0.0), 632 | NSLayoutConstraint(item: separator!, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: padding!) 633 | ] : []) 634 | } 635 | 636 | if isAutoresizingItems { 637 | let autoResizeAttribute: NSLayoutAttribute = growthDirection.isVertical ? .width : .height 638 | constraints.append(NSLayoutConstraint(item: child, attribute: autoResizeAttribute, relatedBy: .equal, toItem: contentContainer, attribute: autoResizeAttribute, multiplier: 1.0, constant: 0.0)) 639 | } 640 | 641 | // Constraints depending on stack growth direction 642 | 643 | var relAttrConstBuffer: (UIView, NSLayoutAttribute, CGFloat) 644 | 645 | switch growthDirection { 646 | case .up: 647 | if isLastChild { 648 | relAttrConstBuffer = (contentContainer, .top, 0.0) 649 | let topC = NSLayoutConstraint(item: child, attribute: .top, relatedBy: .equal, toItem: relAttrConstBuffer.0, attribute: relAttrConstBuffer.1, multiplier: 1.0, constant: relAttrConstBuffer.2) 650 | 651 | constraints.append(topC) 652 | } 653 | 654 | if !isFirstChild { 655 | let separatorConstraints = [ 656 | NSLayoutConstraint(item: separator!, attribute: .bottom, relatedBy: .equal, toItem: prevChild, attribute: .top, multiplier: 1.0, constant: 0.0), 657 | NSLayoutConstraint(item: separator!, attribute: .top, relatedBy: .equal, toItem: child, attribute: .bottom, multiplier: 1.0, constant: 0.0) 658 | ] 659 | 660 | constraints.append(contentsOf: separatorConstraints) 661 | } 662 | 663 | case .down: 664 | if isLastChild { 665 | relAttrConstBuffer = (contentContainer, .bottom, 0.0) 666 | let bottomC = NSLayoutConstraint(item: child, attribute: .bottom, relatedBy: .equal, toItem: relAttrConstBuffer.0, attribute: relAttrConstBuffer.1, multiplier: 1.0, constant: relAttrConstBuffer.2) 667 | 668 | constraints.append(bottomC) 669 | } 670 | 671 | if !isFirstChild { 672 | let separatorConstraints = [ 673 | NSLayoutConstraint(item: separator!, attribute: .top, relatedBy: .equal, toItem: prevChild, attribute: .bottom, multiplier: 1.0, constant: 0.0), 674 | NSLayoutConstraint(item: separator!, attribute: .bottom, relatedBy: .equal, toItem: child, attribute: .top, multiplier: 1.0, constant: 0.0) 675 | ] 676 | 677 | constraints.append(contentsOf: separatorConstraints) 678 | } 679 | 680 | case .left: 681 | if isLastChild { 682 | relAttrConstBuffer = (contentContainer, .leading, 0.0) 683 | let leadingC = NSLayoutConstraint(item: child, attribute: .leading, relatedBy: .equal, toItem: relAttrConstBuffer.0, attribute: relAttrConstBuffer.1, multiplier: 1.0, constant: relAttrConstBuffer.2) 684 | 685 | constraints.append(leadingC) 686 | } 687 | 688 | if !isFirstChild { 689 | let separatorConstraints = [ 690 | NSLayoutConstraint(item: separator!, attribute: .leading, relatedBy: .equal, toItem: child, attribute: .trailing, multiplier: 1.0, constant: 0.0), 691 | NSLayoutConstraint(item: separator!, attribute: .trailing, relatedBy: .equal, toItem: prevChild, attribute: .leading, multiplier: 1.0, constant: 0.0) 692 | ] 693 | 694 | constraints.append(contentsOf: separatorConstraints) 695 | } 696 | 697 | case .right: 698 | if isLastChild { 699 | relAttrConstBuffer = (contentContainer, .trailing, 0.0) 700 | let trailingC = NSLayoutConstraint(item: child, attribute: .trailing, relatedBy: .equal, toItem: relAttrConstBuffer.0, attribute: relAttrConstBuffer.1, multiplier: 1.0, constant: relAttrConstBuffer.2) 701 | 702 | constraints.append(trailingC) 703 | } 704 | 705 | if !isFirstChild { 706 | let separatorConstraints = [ 707 | NSLayoutConstraint(item: separator!, attribute: .leading, relatedBy: .equal, toItem: prevChild, attribute: .trailing, multiplier: 1.0, constant: 0.0), 708 | NSLayoutConstraint(item: separator!, attribute: .trailing, relatedBy: .equal, toItem: child, attribute: .leading, multiplier: 1.0, constant: 0.0) 709 | ] 710 | 711 | constraints.append(contentsOf: separatorConstraints) 712 | } 713 | } 714 | 715 | return constraints 716 | } 717 | 718 | // MARK: - Navigation controls - 719 | 720 | @objc func swipeOccured(_ swipe: UISwipeGestureRecognizer) { 721 | guard userNavigationOptions.contains(.swipe) else { return } 722 | 723 | switch swipe.direction { 724 | case .up: 725 | if growthDirection == .down { 726 | delegate?.stackFlowViewDidRequestPush(self) 727 | } else if growthDirection == .up { 728 | delegate?.stackFlowViewDidRequestPop(self, numberOfItems: 1) 729 | } 730 | 731 | case .down: 732 | if growthDirection == .down { 733 | delegate?.stackFlowViewDidRequestPop(self, numberOfItems: 1) 734 | } else if growthDirection == .up { 735 | delegate?.stackFlowViewDidRequestPush(self) 736 | } 737 | 738 | case .left: 739 | if growthDirection == .left { 740 | delegate?.stackFlowViewDidRequestPop(self, numberOfItems: 1) 741 | } else if growthDirection == .right { 742 | delegate?.stackFlowViewDidRequestPush(self) 743 | } 744 | 745 | case .right: 746 | if growthDirection == .left { 747 | delegate?.stackFlowViewDidRequestPush(self) 748 | } else if growthDirection == .right { 749 | delegate?.stackFlowViewDidRequestPop(self, numberOfItems: 1) 750 | } 751 | 752 | default: 753 | break 754 | } 755 | } 756 | 757 | @objc func tapOccured(_ swipe: UITapGestureRecognizer) { 758 | guard userNavigationOptions.contains(.tap) else { return } 759 | 760 | let tapPoint = swipe.location(in: contentContainer) 761 | 762 | if let tappedItem = itemAtLocation(tapPoint), let itemIndex = items.index(of: tappedItem) { 763 | delegate?.stackFlowViewDidRequestPop(self, numberOfItems: (items.count - 1) - itemIndex) 764 | } 765 | } 766 | 767 | // MARK: - Resolvers - 768 | 769 | /// Location of stack item in stack view container coordinates 770 | 771 | private func itemAtLocation(_ point: CGPoint) -> StackItemView? { 772 | var item: StackItemView? = nil 773 | 774 | items.forEach { 775 | if $0.frame.contains(point) { 776 | item = $0 777 | } 778 | } 779 | 780 | return item 781 | } 782 | } 783 | -------------------------------------------------------------------------------- /StackFlowView/StackItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StackItemView.swift 3 | // Created by 0xNSHuman on 05/12/2017. 4 | 5 | /* 6 | The MIT License (MIT) 7 | Copyright © 2017 0xNSHuman 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 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | import UIKit 26 | 27 | /// View that is typically presented on top of the stack flow item's content, containing given title, as well as navigation buttons to control stack flow. 28 | 29 | class StackItemViewHeader: UIView { 30 | // MARK: - Constants - 31 | 32 | static let defaultHeight: CGFloat = 44.0 33 | static let margin: CGFloat = 20.0 34 | 35 | // MARK: - Stack Item reference - 36 | 37 | private weak var stackItem: StackItemView? 38 | 39 | // MARK: - Subviews - 40 | 41 | let titleLabel = UILabel() 42 | 43 | var popButton: UIButton? = nil { 44 | didSet { 45 | oldValue?.removeFromSuperview() 46 | if let button = popButton { addSubview(button) } 47 | 48 | popButton?.addTarget(stackItem, action: #selector(StackItemView.popTapped), for: .touchUpInside) 49 | 50 | if let _ = popButton { applyAppearance() } 51 | resetConstraints() 52 | } 53 | } 54 | 55 | var pushButton: UIButton? = nil { 56 | didSet { 57 | oldValue?.removeFromSuperview() 58 | if let button = pushButton { addSubview(button) } 59 | 60 | pushButton?.addTarget(stackItem, action: #selector(StackItemView.pushTapped), for: .touchUpInside) 61 | 62 | if let _ = pushButton { applyAppearance() } 63 | resetConstraints() 64 | } 65 | } 66 | 67 | // MARK: - Appearance - 68 | 69 | override var frame: CGRect { 70 | didSet { 71 | super.frame = frame 72 | } 73 | } 74 | 75 | var appearance: StackItemAppearance.TopBar = StackItemAppearance.defaultPreset.topBarAppearance { 76 | didSet { 77 | applyAppearance() 78 | } 79 | } 80 | 81 | // MARK: - Initializers - 82 | 83 | convenience init(title: String) { 84 | self.init(frame: .zero, title: title) 85 | } 86 | 87 | required init(frame: CGRect, title: String) { 88 | super.init(frame: frame) 89 | 90 | addSubview(titleLabel) 91 | titleLabel.text = title 92 | setUp() 93 | } 94 | 95 | required init?(coder aDecoder: NSCoder) { 96 | fatalError("init(coder:) has not been implemented") 97 | } 98 | 99 | // MARK: - Life cycle - 100 | 101 | override func layoutSubviews() { 102 | super.layoutSubviews() 103 | } 104 | 105 | override func willMove(toSuperview newSuperview: UIView?) { 106 | super.willMove(toSuperview: newSuperview) 107 | 108 | guard let stackItem = newSuperview as? StackItemView else { 109 | return 110 | } 111 | 112 | self.stackItem = stackItem 113 | } 114 | 115 | // MARK: - Setup - 116 | 117 | private func setUp() { 118 | titleLabel.textAlignment = .center 119 | resetConstraints() 120 | } 121 | 122 | private func applyAppearance() { 123 | backgroundColor = appearance.backgroundColor 124 | titleLabel.textColor = appearance.titleTextColor 125 | titleLabel.font = appearance.titleFont 126 | 127 | var buttonWidth: CGFloat = 0 128 | 129 | if let popTitle = appearance.popButtonIdentity.title { 130 | popButton?.setAttributedTitle(popTitle, for: .normal) 131 | 132 | buttonWidth = { 133 | return (popButton?.titleLabel?.sizeThatFits(CGSize(width: bounds.width, height: bounds.height)).width ?? 0) + (StackItemViewHeader.margin * 2) 134 | }() 135 | } else if let popImage = appearance.popButtonIdentity.icon { 136 | popButton?.setImage(popImage, for: .normal) 137 | 138 | buttonWidth = { 139 | let imageRatio = popImage.size.width / popImage.size.height 140 | return (bounds.height * imageRatio) + (StackItemViewHeader.margin * 2) 141 | }() 142 | } 143 | 144 | popButton?.frame = CGRect(origin: .zero, size: CGSize(width: buttonWidth, height: bounds.height)) 145 | 146 | if let pushTitle = appearance.pushButtonIdentity.title { 147 | pushButton?.setAttributedTitle(pushTitle, for: .normal) 148 | 149 | buttonWidth = { 150 | return (pushButton?.titleLabel?.sizeThatFits(CGSize(width: bounds.width, height: bounds.height)).width ?? 0) + (StackItemViewHeader.margin * 2) 151 | }() 152 | } else if let pushImage = appearance.pushButtonIdentity.icon { 153 | pushButton?.setImage(pushImage, for: .normal) 154 | 155 | buttonWidth = { 156 | let imageRatio = pushImage.size.width / pushImage.size.height 157 | return (bounds.height * imageRatio) + (StackItemViewHeader.margin * 2) 158 | }() 159 | } 160 | 161 | pushButton?.frame = CGRect(origin: .zero, size: CGSize(width: buttonWidth, height: bounds.height)) 162 | } 163 | 164 | private func resetConstraints() { 165 | translatesAutoresizingMaskIntoConstraints = false 166 | 167 | constraints.forEach { 168 | guard $0.firstAttribute != .height && $0.firstAttribute != .width else { return } 169 | 170 | $0.isActive = false 171 | removeConstraint($0) 172 | } 173 | 174 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 175 | let longestButtonWidth = max(popButton?.bounds.width ?? 0.0, pushButton?.bounds.width ?? 0.0) 176 | 177 | var newConstraints = [ 178 | NSLayoutConstraint(item: titleLabel, attribute: .leading, relatedBy: .equal, toItem: self, attribute: .leading, multiplier: 1.0, constant: longestButtonWidth), 179 | NSLayoutConstraint(item: titleLabel, attribute: .trailing, relatedBy: .equal, toItem: self, attribute: .trailing, multiplier: 1.0, constant: -(longestButtonWidth)), 180 | NSLayoutConstraint(item: titleLabel, attribute: .top, relatedBy: .equal, toItem: self, attribute: .top, multiplier: 1.0, constant: 0.0), 181 | NSLayoutConstraint(item: titleLabel, attribute: .bottom, relatedBy: .equal, toItem: self, attribute: .bottom, multiplier: 1.0, constant: 0.0) 182 | ] 183 | 184 | if let popButton = popButton { 185 | popButton.translatesAutoresizingMaskIntoConstraints = false 186 | 187 | let buttonConstraints = [ 188 | NSLayoutConstraint(item: popButton, attribute: .leading, relatedBy: .equal, toItem: self, attribute: .leading, multiplier: 1.0, constant: 0.0), 189 | NSLayoutConstraint(item: popButton, attribute: .trailing, relatedBy: .equal, toItem: titleLabel, attribute: .leading, multiplier: 1.0, constant: 0.0), 190 | NSLayoutConstraint(item: popButton, attribute: .top, relatedBy: .equal, toItem: self, attribute: .top, multiplier: 1.0, constant: 0.0), 191 | NSLayoutConstraint(item: popButton, attribute: .bottom, relatedBy: .equal, toItem: self, attribute: .bottom, multiplier: 1.0, constant: 0.0) 192 | ] 193 | 194 | newConstraints.append(contentsOf: buttonConstraints) 195 | } 196 | 197 | if let pushButton = pushButton { 198 | pushButton.translatesAutoresizingMaskIntoConstraints = false 199 | 200 | let buttonConstraints = [ 201 | NSLayoutConstraint(item: pushButton, attribute: .leading, relatedBy: .equal, toItem: titleLabel, attribute: .trailing, multiplier: 1.0, constant: 0.0), 202 | NSLayoutConstraint(item: pushButton, attribute: .trailing, relatedBy: .equal, toItem: self, attribute: .trailing, multiplier: 1.0, constant: 0.0), 203 | NSLayoutConstraint(item: pushButton, attribute: .top, relatedBy: .equal, toItem: self, attribute: .top, multiplier: 1.0, constant: 0.0), 204 | NSLayoutConstraint(item: pushButton, attribute: .bottom, relatedBy: .equal, toItem: self, attribute: .bottom, multiplier: 1.0, constant: 0.0) 205 | ] 206 | 207 | newConstraints.append(contentsOf: buttonConstraints) 208 | } 209 | 210 | newConstraints.forEach { $0.isActive = true } 211 | 212 | setNeedsLayout() 213 | layoutIfNeeded() 214 | } 215 | } 216 | 217 | /// Internally used protocol for delivering events from particular items to containing stack flow view. 218 | 219 | protocol StackItemViewDelegate { 220 | func stackItemRequestedPop(_ stackItemView: StackItemView) 221 | func stackItemRequestedPush(_ stackItemView: StackItemView) 222 | } 223 | 224 | /// Item view containing provided custom UI content along with wrapped up logic that enables pushing/popping this content into/out of stack flow view. 225 | 226 | class StackItemView: UIView { 227 | // MARK: - Types - 228 | 229 | typealias TintView = UIView 230 | 231 | // MARK: - Subviews - 232 | 233 | private var headerView: StackItemViewHeader? = nil 234 | private var contentContainer = UIView() 235 | private(set) var contentView: UIView? 236 | 237 | var tintView = TintView() 238 | 239 | // MARK: - Other Properties - 240 | 241 | var delegate: StackItemViewDelegate? = nil 242 | 243 | // MARK: - Appearance - 244 | 245 | override var frame: CGRect { 246 | didSet { 247 | super.frame = frame 248 | } 249 | } 250 | 251 | var appearance: StackItemAppearance = StackItemAppearance.defaultPreset { 252 | didSet { 253 | applyAppearance() 254 | } 255 | } 256 | 257 | // MARK: Meta 258 | 259 | var isFirstInStack: Bool = true { 260 | didSet { 261 | if isFirstInStack { 262 | headerView?.popButton = nil 263 | } else { 264 | headerView?.popButton = UIButton(type: .custom) 265 | } 266 | } 267 | } 268 | 269 | // MARK: - Initializers - 270 | 271 | required init(contentView: UIView, title: String? = nil, customAppearance: StackItemAppearance? = nil) { 272 | super.init(frame: CGRect.init(x: 0, y: 0, width: contentView.bounds.width, height: contentView.bounds.height + StackItemViewHeader.defaultHeight)) 273 | 274 | if let appearance = customAppearance { 275 | self.appearance = appearance 276 | } 277 | 278 | if let title = title { 279 | self.headerView = StackItemViewHeader(title: title) 280 | self.headerView?.frame = CGRect(x: 0, y: 0, width: bounds.width, height: StackItemViewHeader.defaultHeight) 281 | addSubview(headerView!) 282 | 283 | self.headerView?.pushButton = UIButton(type: .custom) 284 | } 285 | 286 | self.contentView = contentView 287 | contentContainer.addSubview(contentView) 288 | addSubview(contentContainer) 289 | 290 | addSubview(tintView) 291 | 292 | setUp() 293 | } 294 | 295 | required init?(coder aDecoder: NSCoder) { 296 | super.init(coder: aDecoder) 297 | } 298 | 299 | deinit { 300 | StackFlowNotification.forget(observer: self) 301 | } 302 | 303 | // MARK: - Life cycle - 304 | 305 | override func layoutSubviews() { 306 | super.layoutSubviews() 307 | } 308 | 309 | // MARK: - Setup - 310 | 311 | private func setUp() { 312 | resetConstraints() 313 | applyAppearance() 314 | 315 | contentContainer.clipsToBounds = true 316 | 317 | tintView.isUserInteractionEnabled = false 318 | tintView.isHidden = true 319 | 320 | StackFlowNotification.observe(name: .itemPopped, by: self, selector: #selector(stackViewPoppedItem(notification:))) 321 | StackFlowNotification.observe(name: .itemPushed, by: self, selector: #selector(stackViewPushedItem(notification:))) 322 | } 323 | 324 | private func applyAppearance() { 325 | backgroundColor = appearance.backgroundColor 326 | headerView?.appearance = appearance.topBarAppearance 327 | } 328 | 329 | private func resetConstraints() { 330 | // Remove previous constraints 331 | 332 | constraints.forEach { 333 | $0.isActive = false 334 | removeConstraint($0) 335 | } 336 | 337 | // Self constraints 338 | 339 | let totalWidth = contentView?.bounds.width ?? 0.0 340 | let totalHeight = (headerView?.bounds.height ?? 0.0) + (contentView?.bounds.height ?? 0.0) 341 | 342 | let sizeConstraints = [ 343 | NSLayoutConstraint(item: self, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: totalWidth), 344 | NSLayoutConstraint(item: self, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: totalHeight) 345 | ] 346 | 347 | translatesAutoresizingMaskIntoConstraints = false 348 | sizeConstraints.forEach { 349 | $0.priority = .defaultHigh 350 | $0.isActive = true 351 | } 352 | 353 | // Header constraints 354 | 355 | if let header = headerView { 356 | let headerHeight = header.bounds.height 357 | 358 | let headerConstraints = [ 359 | NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: header, attribute: .top, multiplier: 1.0, constant: 0.0), 360 | NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: header, attribute: .leading, multiplier: 1.0, constant: 0.0), 361 | NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: header, attribute: .trailing, multiplier: 1.0, constant: 0.0), 362 | NSLayoutConstraint(item: header, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1.0, constant: headerHeight) 363 | ] 364 | 365 | header.translatesAutoresizingMaskIntoConstraints = false 366 | headerConstraints.forEach { $0.isActive = true } 367 | } 368 | 369 | // Content constraints 370 | 371 | let containerConstraints = [ 372 | NSLayoutConstraint(item: contentContainer, attribute: .top, relatedBy: .equal, toItem: headerView ?? self, attribute: headerView != nil ? .bottom : .top, multiplier: 1.0, constant: 0.0), 373 | NSLayoutConstraint(item: contentContainer, attribute: .leading, relatedBy: .equal, toItem: self, attribute: .leading, multiplier: 1.0, constant: 0.0), 374 | NSLayoutConstraint(item: contentContainer, attribute: .trailing, relatedBy: .equal, toItem: self, attribute: .trailing, multiplier: 1.0, constant: 0.0), 375 | NSLayoutConstraint(item: contentContainer, attribute: .bottom, relatedBy: .equal, toItem: self, attribute: .bottom, multiplier: 1.0, constant: 0.0) 376 | ] 377 | 378 | contentContainer.translatesAutoresizingMaskIntoConstraints = false 379 | containerConstraints.forEach { $0.isActive = true } 380 | 381 | let contentConstraints = [ 382 | NSLayoutConstraint(item: contentContainer, attribute: .top, relatedBy: .equal, toItem: contentView, attribute: .top, multiplier: 1.0, constant: 0.0), 383 | NSLayoutConstraint(item: contentContainer, attribute: .leading, relatedBy: .equal, toItem: contentView, attribute: .leading, multiplier: 1.0, constant: 0.0), 384 | NSLayoutConstraint(item: contentContainer, attribute: .trailing, relatedBy: .equal, toItem: contentView, attribute: .trailing, multiplier: 1.0, constant: 0.0), 385 | NSLayoutConstraint(item: contentContainer, attribute: .bottom, relatedBy: .equal, toItem: contentView, attribute: .bottom, multiplier: 1.0, constant: 0.0) 386 | ] 387 | 388 | contentView?.translatesAutoresizingMaskIntoConstraints = false 389 | contentConstraints.forEach { $0.isActive = true } 390 | 391 | // Tint view 392 | 393 | let tintConstraints = [ 394 | NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: tintView, attribute: .top, multiplier: 1.0, constant: 0.0), 395 | NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: tintView, attribute: .leading, multiplier: 1.0, constant: 0.0), 396 | NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: tintView, attribute: .trailing, multiplier: 1.0, constant: 0.0), 397 | NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: tintView, attribute: .bottom, multiplier: 1.0, constant: 0.0) 398 | ] 399 | 400 | tintView.translatesAutoresizingMaskIntoConstraints = false 401 | tintConstraints.forEach { $0.isActive = true } 402 | 403 | // Existing layout invalidation 404 | 405 | setNeedsLayout() 406 | layoutIfNeeded() 407 | } 408 | 409 | // MARK: - Stack events - 410 | 411 | @objc func stackViewPoppedItem(notification: Notification) { 412 | guard let stackNotification = try? StackFlowNotification(plainNotification: notification) else { 413 | return 414 | } 415 | 416 | guard stackNotification.stackFlowView.doesOwn(item: self) else { 417 | return 418 | } 419 | 420 | let isLastInStack = stackNotification.stackFlowView.lastItem == self 421 | 422 | headerView?.popButton = (!isFirstInStack && isLastInStack) ? UIButton(type: .custom) : nil 423 | headerView?.pushButton = isLastInStack ? UIButton(type: .custom) : nil 424 | } 425 | 426 | @objc func stackViewPushedItem(notification: Notification) { 427 | guard let stackNotification = try? StackFlowNotification(plainNotification: notification) else { 428 | return 429 | } 430 | 431 | guard stackNotification.stackFlowView.doesOwn(item: self) else { 432 | return 433 | } 434 | 435 | let isLastInStack = stackNotification.stackFlowView.lastItem == self 436 | 437 | headerView?.popButton = (!isFirstInStack && isLastInStack) ? UIButton(type: .custom) : nil 438 | headerView?.pushButton = isLastInStack ? UIButton(type: .custom) : nil 439 | } 440 | 441 | // MARK: - Control events - 442 | 443 | @objc func popTapped() { 444 | delegate?.stackItemRequestedPop(self) 445 | } 446 | 447 | @objc func pushTapped() { 448 | delegate?.stackItemRequestedPush(self) 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 48; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | FFE891512011428700947221 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE891502011428700947221 /* AppDelegate.swift */; }; 11 | FFE891532011428700947221 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE891522011428700947221 /* ViewController.swift */; }; 12 | FFE891562011428700947221 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FFE891542011428700947221 /* Main.storyboard */; }; 13 | FFE891582011428700947221 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FFE891572011428700947221 /* Assets.xcassets */; }; 14 | FFE8915B2011428700947221 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FFE891592011428700947221 /* LaunchScreen.storyboard */; }; 15 | FFE89166201142B200947221 /* StackFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE89163201142B200947221 /* StackFlow.swift */; }; 16 | FFE89167201142B200947221 /* StackFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE89164201142B200947221 /* StackFlowView.swift */; }; 17 | FFE89168201142B200947221 /* StackItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE89165201142B200947221 /* StackItemView.swift */; }; 18 | FFE8916E20122FAA00947221 /* DemoItemView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FFE8916D20122FAA00947221 /* DemoItemView.xib */; }; 19 | FFE8917020122FBB00947221 /* DemoItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8916F20122FBB00947221 /* DemoItemView.swift */; }; 20 | FFE89173201231F200947221 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE89172201231F200947221 /* Utils.swift */; }; 21 | FFE8917820123E7D00947221 /* CustomStackFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFE8917720123E7D00947221 /* CustomStackFlowView.swift */; }; 22 | FFF5BA962014AB2100ABAA05 /* bg.jpg in Resources */ = {isa = PBXBuildFile; fileRef = FFF5BA952014AB2100ABAA05 /* bg.jpg */; }; 23 | FFF5BA9E2017CA6E00ABAA05 /* Defines.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF5BA9D2017CA6E00ABAA05 /* Defines.swift */; }; 24 | FFF5BAA32017CB2600ABAA05 /* UIKit+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF5BAA02017CB2600ABAA05 /* UIKit+Extensions.swift */; }; 25 | FFF5BAA42017CB2600ABAA05 /* SwiftLibrary+Extentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF5BAA12017CB2600ABAA05 /* SwiftLibrary+Extentions.swift */; }; 26 | FFF5BAA52017CB2600ABAA05 /* Foundation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF5BAA22017CB2600ABAA05 /* Foundation+Extensions.swift */; }; 27 | /* End PBXBuildFile section */ 28 | 29 | /* Begin PBXFileReference section */ 30 | FFE8914D2011428700947221 /* StackFlowViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StackFlowViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 31 | FFE891502011428700947221 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 32 | FFE891522011428700947221 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 33 | FFE891552011428700947221 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 34 | FFE891572011428700947221 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 35 | FFE8915A2011428700947221 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 36 | FFE8915C2011428700947221 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 37 | FFE89163201142B200947221 /* StackFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StackFlow.swift; sourceTree = ""; }; 38 | FFE89164201142B200947221 /* StackFlowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StackFlowView.swift; sourceTree = ""; }; 39 | FFE89165201142B200947221 /* StackItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StackItemView.swift; sourceTree = ""; }; 40 | FFE8916D20122FAA00947221 /* DemoItemView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DemoItemView.xib; sourceTree = ""; }; 41 | FFE8916F20122FBB00947221 /* DemoItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoItemView.swift; sourceTree = ""; }; 42 | FFE89172201231F200947221 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 43 | FFE8917720123E7D00947221 /* CustomStackFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStackFlowView.swift; sourceTree = ""; }; 44 | FFF5BA952014AB2100ABAA05 /* bg.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = bg.jpg; sourceTree = ""; }; 45 | FFF5BA9D2017CA6E00ABAA05 /* Defines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defines.swift; sourceTree = ""; }; 46 | FFF5BAA02017CB2600ABAA05 /* UIKit+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIKit+Extensions.swift"; sourceTree = ""; }; 47 | FFF5BAA12017CB2600ABAA05 /* SwiftLibrary+Extentions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftLibrary+Extentions.swift"; sourceTree = ""; }; 48 | FFF5BAA22017CB2600ABAA05 /* Foundation+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Foundation+Extensions.swift"; sourceTree = ""; }; 49 | /* End PBXFileReference section */ 50 | 51 | /* Begin PBXFrameworksBuildPhase section */ 52 | FFE8914A2011428700947221 /* Frameworks */ = { 53 | isa = PBXFrameworksBuildPhase; 54 | buildActionMask = 2147483647; 55 | files = ( 56 | ); 57 | runOnlyForDeploymentPostprocessing = 0; 58 | }; 59 | /* End PBXFrameworksBuildPhase section */ 60 | 61 | /* Begin PBXGroup section */ 62 | FFE891442011428700947221 = { 63 | isa = PBXGroup; 64 | children = ( 65 | FFE89162201142B200947221 /* StackFlowView */, 66 | FFE8914F2011428700947221 /* StackFlowViewDemo */, 67 | FFE8914E2011428700947221 /* Products */, 68 | ); 69 | sourceTree = ""; 70 | }; 71 | FFE8914E2011428700947221 /* Products */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | FFE8914D2011428700947221 /* StackFlowViewDemo.app */, 75 | ); 76 | name = Products; 77 | sourceTree = ""; 78 | }; 79 | FFE8914F2011428700947221 /* StackFlowViewDemo */ = { 80 | isa = PBXGroup; 81 | children = ( 82 | FFF5BA9F2017CB2600ABAA05 /* Extensions */, 83 | FFF5BA982014AB3E00ABAA05 /* Misc */, 84 | FFE89171201231E600947221 /* Utils */, 85 | FFE8916C20122F8000947221 /* Controllers */, 86 | FFE8916B20122F7B00947221 /* Views */, 87 | FFF5BA932014AB2100ABAA05 /* Res */, 88 | ); 89 | path = StackFlowViewDemo; 90 | sourceTree = ""; 91 | }; 92 | FFE89162201142B200947221 /* StackFlowView */ = { 93 | isa = PBXGroup; 94 | children = ( 95 | FFE89163201142B200947221 /* StackFlow.swift */, 96 | FFE89164201142B200947221 /* StackFlowView.swift */, 97 | FFE89165201142B200947221 /* StackItemView.swift */, 98 | ); 99 | name = StackFlowView; 100 | path = ../StackFlowView; 101 | sourceTree = ""; 102 | }; 103 | FFE8916B20122F7B00947221 /* Views */ = { 104 | isa = PBXGroup; 105 | children = ( 106 | FFF5BA972014AB3100ABAA05 /* Storyboards */, 107 | FFE8917620123E5B00947221 /* CustomStackView */, 108 | FFF5BA992014AB9100ABAA05 /* DemoItemView */, 109 | ); 110 | path = Views; 111 | sourceTree = ""; 112 | }; 113 | FFE8916C20122F8000947221 /* Controllers */ = { 114 | isa = PBXGroup; 115 | children = ( 116 | FFE891522011428700947221 /* ViewController.swift */, 117 | ); 118 | path = Controllers; 119 | sourceTree = ""; 120 | }; 121 | FFE89171201231E600947221 /* Utils */ = { 122 | isa = PBXGroup; 123 | children = ( 124 | FFE89172201231F200947221 /* Utils.swift */, 125 | ); 126 | path = Utils; 127 | sourceTree = ""; 128 | }; 129 | FFE8917620123E5B00947221 /* CustomStackView */ = { 130 | isa = PBXGroup; 131 | children = ( 132 | FFE8917720123E7D00947221 /* CustomStackFlowView.swift */, 133 | ); 134 | path = CustomStackView; 135 | sourceTree = ""; 136 | }; 137 | FFF5BA932014AB2100ABAA05 /* Res */ = { 138 | isa = PBXGroup; 139 | children = ( 140 | FFE891572011428700947221 /* Assets.xcassets */, 141 | FFF5BA942014AB2100ABAA05 /* UI */, 142 | ); 143 | path = Res; 144 | sourceTree = ""; 145 | }; 146 | FFF5BA942014AB2100ABAA05 /* UI */ = { 147 | isa = PBXGroup; 148 | children = ( 149 | FFF5BA952014AB2100ABAA05 /* bg.jpg */, 150 | ); 151 | path = UI; 152 | sourceTree = ""; 153 | }; 154 | FFF5BA972014AB3100ABAA05 /* Storyboards */ = { 155 | isa = PBXGroup; 156 | children = ( 157 | FFE891542011428700947221 /* Main.storyboard */, 158 | FFE891592011428700947221 /* LaunchScreen.storyboard */, 159 | ); 160 | path = Storyboards; 161 | sourceTree = ""; 162 | }; 163 | FFF5BA982014AB3E00ABAA05 /* Misc */ = { 164 | isa = PBXGroup; 165 | children = ( 166 | FFE891502011428700947221 /* AppDelegate.swift */, 167 | FFE8915C2011428700947221 /* Info.plist */, 168 | FFF5BA9D2017CA6E00ABAA05 /* Defines.swift */, 169 | ); 170 | path = Misc; 171 | sourceTree = ""; 172 | }; 173 | FFF5BA992014AB9100ABAA05 /* DemoItemView */ = { 174 | isa = PBXGroup; 175 | children = ( 176 | FFE8916D20122FAA00947221 /* DemoItemView.xib */, 177 | FFE8916F20122FBB00947221 /* DemoItemView.swift */, 178 | ); 179 | path = DemoItemView; 180 | sourceTree = ""; 181 | }; 182 | FFF5BA9F2017CB2600ABAA05 /* Extensions */ = { 183 | isa = PBXGroup; 184 | children = ( 185 | FFF5BAA02017CB2600ABAA05 /* UIKit+Extensions.swift */, 186 | FFF5BAA12017CB2600ABAA05 /* SwiftLibrary+Extentions.swift */, 187 | FFF5BAA22017CB2600ABAA05 /* Foundation+Extensions.swift */, 188 | ); 189 | path = Extensions; 190 | sourceTree = ""; 191 | }; 192 | /* End PBXGroup section */ 193 | 194 | /* Begin PBXNativeTarget section */ 195 | FFE8914C2011428700947221 /* StackFlowViewDemo */ = { 196 | isa = PBXNativeTarget; 197 | buildConfigurationList = FFE8915F2011428700947221 /* Build configuration list for PBXNativeTarget "StackFlowViewDemo" */; 198 | buildPhases = ( 199 | FFE891492011428700947221 /* Sources */, 200 | FFE8914A2011428700947221 /* Frameworks */, 201 | FFE8914B2011428700947221 /* Resources */, 202 | ); 203 | buildRules = ( 204 | ); 205 | dependencies = ( 206 | ); 207 | name = StackFlowViewDemo; 208 | productName = StackFlowViewDemo; 209 | productReference = FFE8914D2011428700947221 /* StackFlowViewDemo.app */; 210 | productType = "com.apple.product-type.application"; 211 | }; 212 | /* End PBXNativeTarget section */ 213 | 214 | /* Begin PBXProject section */ 215 | FFE891452011428700947221 /* Project object */ = { 216 | isa = PBXProject; 217 | attributes = { 218 | LastSwiftUpdateCheck = 0910; 219 | LastUpgradeCheck = 0910; 220 | ORGANIZATIONNAME = "0xNSHuman"; 221 | TargetAttributes = { 222 | FFE8914C2011428700947221 = { 223 | CreatedOnToolsVersion = 9.1; 224 | ProvisioningStyle = Automatic; 225 | }; 226 | }; 227 | }; 228 | buildConfigurationList = FFE891482011428700947221 /* Build configuration list for PBXProject "StackFlowViewDemo" */; 229 | compatibilityVersion = "Xcode 8.0"; 230 | developmentRegion = en; 231 | hasScannedForEncodings = 0; 232 | knownRegions = ( 233 | en, 234 | Base, 235 | ); 236 | mainGroup = FFE891442011428700947221; 237 | productRefGroup = FFE8914E2011428700947221 /* Products */; 238 | projectDirPath = ""; 239 | projectRoot = ""; 240 | targets = ( 241 | FFE8914C2011428700947221 /* StackFlowViewDemo */, 242 | ); 243 | }; 244 | /* End PBXProject section */ 245 | 246 | /* Begin PBXResourcesBuildPhase section */ 247 | FFE8914B2011428700947221 /* Resources */ = { 248 | isa = PBXResourcesBuildPhase; 249 | buildActionMask = 2147483647; 250 | files = ( 251 | FFF5BA962014AB2100ABAA05 /* bg.jpg in Resources */, 252 | FFE8916E20122FAA00947221 /* DemoItemView.xib in Resources */, 253 | FFE8915B2011428700947221 /* LaunchScreen.storyboard in Resources */, 254 | FFE891582011428700947221 /* Assets.xcassets in Resources */, 255 | FFE891562011428700947221 /* Main.storyboard in Resources */, 256 | ); 257 | runOnlyForDeploymentPostprocessing = 0; 258 | }; 259 | /* End PBXResourcesBuildPhase section */ 260 | 261 | /* Begin PBXSourcesBuildPhase section */ 262 | FFE891492011428700947221 /* Sources */ = { 263 | isa = PBXSourcesBuildPhase; 264 | buildActionMask = 2147483647; 265 | files = ( 266 | FFE8917020122FBB00947221 /* DemoItemView.swift in Sources */, 267 | FFE891532011428700947221 /* ViewController.swift in Sources */, 268 | FFE89166201142B200947221 /* StackFlow.swift in Sources */, 269 | FFE89173201231F200947221 /* Utils.swift in Sources */, 270 | FFF5BAA52017CB2600ABAA05 /* Foundation+Extensions.swift in Sources */, 271 | FFE891512011428700947221 /* AppDelegate.swift in Sources */, 272 | FFE89167201142B200947221 /* StackFlowView.swift in Sources */, 273 | FFF5BAA32017CB2600ABAA05 /* UIKit+Extensions.swift in Sources */, 274 | FFE8917820123E7D00947221 /* CustomStackFlowView.swift in Sources */, 275 | FFF5BA9E2017CA6E00ABAA05 /* Defines.swift in Sources */, 276 | FFF5BAA42017CB2600ABAA05 /* SwiftLibrary+Extentions.swift in Sources */, 277 | FFE89168201142B200947221 /* StackItemView.swift in Sources */, 278 | ); 279 | runOnlyForDeploymentPostprocessing = 0; 280 | }; 281 | /* End PBXSourcesBuildPhase section */ 282 | 283 | /* Begin PBXVariantGroup section */ 284 | FFE891542011428700947221 /* Main.storyboard */ = { 285 | isa = PBXVariantGroup; 286 | children = ( 287 | FFE891552011428700947221 /* Base */, 288 | ); 289 | name = Main.storyboard; 290 | sourceTree = ""; 291 | }; 292 | FFE891592011428700947221 /* LaunchScreen.storyboard */ = { 293 | isa = PBXVariantGroup; 294 | children = ( 295 | FFE8915A2011428700947221 /* Base */, 296 | ); 297 | name = LaunchScreen.storyboard; 298 | sourceTree = ""; 299 | }; 300 | /* End PBXVariantGroup section */ 301 | 302 | /* Begin XCBuildConfiguration section */ 303 | FFE8915D2011428700947221 /* Debug */ = { 304 | isa = XCBuildConfiguration; 305 | buildSettings = { 306 | ALWAYS_SEARCH_USER_PATHS = NO; 307 | CLANG_ANALYZER_NONNULL = YES; 308 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 309 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 310 | CLANG_CXX_LIBRARY = "libc++"; 311 | CLANG_ENABLE_MODULES = YES; 312 | CLANG_ENABLE_OBJC_ARC = YES; 313 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 314 | CLANG_WARN_BOOL_CONVERSION = YES; 315 | CLANG_WARN_COMMA = YES; 316 | CLANG_WARN_CONSTANT_CONVERSION = YES; 317 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 318 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 319 | CLANG_WARN_EMPTY_BODY = YES; 320 | CLANG_WARN_ENUM_CONVERSION = YES; 321 | CLANG_WARN_INFINITE_RECURSION = YES; 322 | CLANG_WARN_INT_CONVERSION = YES; 323 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 324 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 325 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 326 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 327 | CLANG_WARN_STRICT_PROTOTYPES = YES; 328 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 329 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 330 | CLANG_WARN_UNREACHABLE_CODE = YES; 331 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 332 | CODE_SIGN_IDENTITY = "iPhone Developer"; 333 | COPY_PHASE_STRIP = NO; 334 | DEBUG_INFORMATION_FORMAT = dwarf; 335 | ENABLE_STRICT_OBJC_MSGSEND = YES; 336 | ENABLE_TESTABILITY = YES; 337 | GCC_C_LANGUAGE_STANDARD = gnu11; 338 | GCC_DYNAMIC_NO_PIC = NO; 339 | GCC_NO_COMMON_BLOCKS = YES; 340 | GCC_OPTIMIZATION_LEVEL = 0; 341 | GCC_PREPROCESSOR_DEFINITIONS = ( 342 | "DEBUG=1", 343 | "$(inherited)", 344 | ); 345 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 346 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 347 | GCC_WARN_UNDECLARED_SELECTOR = YES; 348 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 349 | GCC_WARN_UNUSED_FUNCTION = YES; 350 | GCC_WARN_UNUSED_VARIABLE = YES; 351 | IPHONEOS_DEPLOYMENT_TARGET = 11.1; 352 | MTL_ENABLE_DEBUG_INFO = YES; 353 | ONLY_ACTIVE_ARCH = YES; 354 | SDKROOT = iphoneos; 355 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 356 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 357 | }; 358 | name = Debug; 359 | }; 360 | FFE8915E2011428700947221 /* Release */ = { 361 | isa = XCBuildConfiguration; 362 | buildSettings = { 363 | ALWAYS_SEARCH_USER_PATHS = NO; 364 | CLANG_ANALYZER_NONNULL = YES; 365 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 366 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 367 | CLANG_CXX_LIBRARY = "libc++"; 368 | CLANG_ENABLE_MODULES = YES; 369 | CLANG_ENABLE_OBJC_ARC = YES; 370 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 371 | CLANG_WARN_BOOL_CONVERSION = YES; 372 | CLANG_WARN_COMMA = YES; 373 | CLANG_WARN_CONSTANT_CONVERSION = YES; 374 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 375 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 376 | CLANG_WARN_EMPTY_BODY = YES; 377 | CLANG_WARN_ENUM_CONVERSION = YES; 378 | CLANG_WARN_INFINITE_RECURSION = YES; 379 | CLANG_WARN_INT_CONVERSION = YES; 380 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 381 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 382 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 383 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 384 | CLANG_WARN_STRICT_PROTOTYPES = YES; 385 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 386 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 387 | CLANG_WARN_UNREACHABLE_CODE = YES; 388 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 389 | CODE_SIGN_IDENTITY = "iPhone Developer"; 390 | COPY_PHASE_STRIP = NO; 391 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 392 | ENABLE_NS_ASSERTIONS = NO; 393 | ENABLE_STRICT_OBJC_MSGSEND = YES; 394 | GCC_C_LANGUAGE_STANDARD = gnu11; 395 | GCC_NO_COMMON_BLOCKS = YES; 396 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 397 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 398 | GCC_WARN_UNDECLARED_SELECTOR = YES; 399 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 400 | GCC_WARN_UNUSED_FUNCTION = YES; 401 | GCC_WARN_UNUSED_VARIABLE = YES; 402 | IPHONEOS_DEPLOYMENT_TARGET = 11.1; 403 | MTL_ENABLE_DEBUG_INFO = NO; 404 | SDKROOT = iphoneos; 405 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 406 | VALIDATE_PRODUCT = YES; 407 | }; 408 | name = Release; 409 | }; 410 | FFE891602011428700947221 /* Debug */ = { 411 | isa = XCBuildConfiguration; 412 | buildSettings = { 413 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 414 | CODE_SIGN_STYLE = Automatic; 415 | DEVELOPMENT_TEAM = T55G7JLVK5; 416 | INFOPLIST_FILE = StackFlowViewDemo/Misc/Info.plist; 417 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 418 | PRODUCT_BUNDLE_IDENTIFIER = com.vladaverin.StackFlowViewDemo; 419 | PRODUCT_NAME = "$(TARGET_NAME)"; 420 | SWIFT_VERSION = 4.0; 421 | TARGETED_DEVICE_FAMILY = "1,2"; 422 | }; 423 | name = Debug; 424 | }; 425 | FFE891612011428700947221 /* Release */ = { 426 | isa = XCBuildConfiguration; 427 | buildSettings = { 428 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 429 | CODE_SIGN_STYLE = Automatic; 430 | DEVELOPMENT_TEAM = T55G7JLVK5; 431 | INFOPLIST_FILE = StackFlowViewDemo/Misc/Info.plist; 432 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 433 | PRODUCT_BUNDLE_IDENTIFIER = com.vladaverin.StackFlowViewDemo; 434 | PRODUCT_NAME = "$(TARGET_NAME)"; 435 | SWIFT_VERSION = 4.0; 436 | TARGETED_DEVICE_FAMILY = "1,2"; 437 | }; 438 | name = Release; 439 | }; 440 | /* End XCBuildConfiguration section */ 441 | 442 | /* Begin XCConfigurationList section */ 443 | FFE891482011428700947221 /* Build configuration list for PBXProject "StackFlowViewDemo" */ = { 444 | isa = XCConfigurationList; 445 | buildConfigurations = ( 446 | FFE8915D2011428700947221 /* Debug */, 447 | FFE8915E2011428700947221 /* Release */, 448 | ); 449 | defaultConfigurationIsVisible = 0; 450 | defaultConfigurationName = Release; 451 | }; 452 | FFE8915F2011428700947221 /* Build configuration list for PBXNativeTarget "StackFlowViewDemo" */ = { 453 | isa = XCConfigurationList; 454 | buildConfigurations = ( 455 | FFE891602011428700947221 /* Debug */, 456 | FFE891612011428700947221 /* Release */, 457 | ); 458 | defaultConfigurationIsVisible = 0; 459 | defaultConfigurationName = Release; 460 | }; 461 | /* End XCConfigurationList section */ 462 | }; 463 | rootObject = FFE891452011428700947221 /* Project object */; 464 | } 465 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Controllers/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // StackFlowViewDemo 4 | // 5 | // Created by 0xNSHuman on 19/01/2018. 6 | // Copyright © 2018 0xNSHuman. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | // Helper function to stick StackFlowView's bounds to its container. You can play with values to test different layouts. 12 | 13 | private func pinBounds(of stackView: StackFlowView, to container: UIView, withInsets insets: (CGFloat, CGFloat, CGFloat, CGFloat)) { 14 | container.constraints.forEach { 15 | guard ($0.firstItem as? UIView) == stackView || ($0.secondItem as? UIView) == stackView else { 16 | return 17 | } 18 | 19 | $0.isActive = false 20 | container.removeConstraint($0) 21 | } 22 | 23 | var stackFlowConstraints: [NSLayoutConstraint] 24 | stackView.translatesAutoresizingMaskIntoConstraints = false 25 | 26 | stackFlowConstraints = [ 27 | NSLayoutConstraint(item: stackView, attribute: .top, relatedBy: .equal, toItem: container, attribute: .top, multiplier: 1.0, constant: insets.1), 28 | NSLayoutConstraint(item: stackView, attribute: .bottom, relatedBy: .equal, toItem: container, attribute: .bottom, multiplier: 1.0, constant: -(insets.3)), 29 | NSLayoutConstraint(item: stackView, attribute: .leading, relatedBy: .equal, toItem: container, attribute: .leading, multiplier: 1.0, constant: insets.0), 30 | NSLayoutConstraint(item: stackView, attribute: .trailing, relatedBy: .equal, toItem: container, attribute: .trailing, multiplier: 1.0, constant: -(insets.2)) 31 | ] 32 | 33 | stackFlowConstraints.forEach { $0.isActive = true } 34 | 35 | container.setNeedsLayout() 36 | container.layoutIfNeeded() 37 | } 38 | 39 | class ViewController: UIViewController { 40 | // MARK: - Outlets - 41 | 42 | @IBOutlet weak var demoLayoutOne: UIView! 43 | @IBOutlet weak var demoLayoutTwo: UIView! 44 | 45 | // MARK: - Properties - 46 | 47 | private let stackView = CustomStackFlowView() 48 | 49 | // MARK: - Life cycle - 50 | 51 | override func viewDidLoad() { 52 | super.viewDidLoad() 53 | setUp() 54 | } 55 | 56 | // MARK: - Setup - 57 | 58 | private func setUp() { 59 | /* — Here is everything you need to present working StackFlowView — */ 60 | 61 | view.addSubview(stackView) 62 | 63 | /* — In this demo we prefer StackFlowView subclassing over composition, so delegate is set inside CustomStackFlowView initialization flow — */ 64 | //stackView.delegate = self 65 | 66 | /* — It's probably nice to set up some constraints though if you plan to change layout dynamically — */ 67 | 68 | pinBounds(of: stackView, to: view, withInsets: (0, 0, 0, 0)) 69 | 70 | /* — Now, OPTIONAL customization time! — */ 71 | 72 | // How big should padding next to the stack head be? 73 | stackView.headPadding = 0 74 | 75 | // Which direction should new items be pushed in? 76 | stackView.growthDirection = .down 77 | 78 | // Separate by lines or padding? 79 | stackView.separationStyle = .line(thikness: 2.0, color: .black) 80 | // .padding(size: 20.0) 81 | // .none 82 | 83 | // If you want your stack gradually fade away, you can pick any of the styles, or combine them! 84 | stackView.fadingStyle = .combined(styles: 85 | [ 86 | .tint(color: .white, preLastAlpha: 0.9, alphaDecrement: 0.1), 87 | .gradientMask(effectDistance: stackView.bounds.height * 0.7) 88 | ] 89 | ) // Or just .none 90 | 91 | // You can swipe up-down or left-right to control your flow, and/or also tap inactive stack area to pop any number of items (depends on where you tap) 92 | stackView.userNavigationOptions = [.swipe, .tap] 93 | 94 | // Fast hops or sloooooow animation? 95 | stackView.transitionDuration = 0.25 96 | 97 | // Set to false if you don't need automatic safe area detection/adoption 98 | stackView.isSeekingSafeArea = true 99 | 100 | // Set to false to turn off stretch-out behaviour for your content items during autolayout updates 101 | stackView.isAutoresizingItems = true 102 | 103 | /* — ——— — */ 104 | 105 | /* — Now let's kick off the flow — */ 106 | 107 | // This is not necessary, we could just use any available controls (like swipe gesture) on StackFlowView to present the first flow item. 108 | stackView.resetFlow() 109 | 110 | /* — Ok, that was to get you onboard with all the features. Now we're going to discard this stackView, and instead of it try a set of predefined demo cases (if you wish) — */ 111 | 112 | let weWantToSeeMoreDemos = false 113 | guard weWantToSeeMoreDemos else { 114 | return 115 | } 116 | 117 | stackView.removeFromSuperview() 118 | 119 | /* — Change to play with different demo cases — */ 120 | 121 | let demoLayoutToUse = demoLayoutOne 122 | 123 | for subview in view.subviews { 124 | guard !subview.isKind(of: UIImageView.self) else { continue } 125 | 126 | guard subview != demoLayoutToUse else { subview.isHidden = false; continue } 127 | subview.isHidden = true 128 | } 129 | 130 | for layoutContainer in demoLayoutToUse!.subviews { 131 | let stackFlowView = CustomStackFlowView() 132 | 133 | let caseToTest: DemoCase = { 134 | // Return any case you want. Note, you may get weird behaviour here, since there are lots of parameters to play with in this or other demo files — it's just a playground. Please refer documentation if you want clear instructions. 135 | 136 | return .verticalCardsStack 137 | 138 | return .pageStack(direction: { 139 | return layoutContainer.bounds.width < layoutContainer.bounds.height ? .down : .right 140 | }()) 141 | 142 | return .stackOfStacks 143 | 144 | return .feedFallingDown 145 | 146 | return .pageStack(direction: .right) 147 | }() 148 | 149 | layoutContainer.addSubview(stackFlowView) 150 | 151 | configure(stackView: stackFlowView, inside: layoutContainer, as: caseToTest) 152 | //stackFlowView.resetFlow() 153 | } 154 | } 155 | } 156 | 157 | extension ViewController { 158 | override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 159 | super.viewWillTransition(to: size, with: coordinator) 160 | } 161 | } 162 | 163 | // MARK: - Demo cases - 164 | 165 | extension ViewController { 166 | enum DemoCase { 167 | case verticalCardsStack 168 | case pageStack(direction: StackFlowView.Direction) 169 | case feedFallingDown 170 | case stackOfStacks 171 | } 172 | 173 | func configure(stackView: StackFlowView, inside container: UIView, as case: DemoCase) { 174 | switch `case` { 175 | case .verticalCardsStack: 176 | pinBounds(of: stackView, to: container, withInsets: (20, 0, 20, 0)) 177 | 178 | stackView.headPadding = 20 179 | stackView.growthDirection = .down 180 | stackView.separationStyle = .padding(size: 20) 181 | stackView.fadingStyle = .combined(styles: 182 | [ 183 | .tint(color: .white, preLastAlpha: 0.9, alphaDecrement: 0.1), 184 | .gradientMask(effectDistance: stackView.bounds.height * 0.7) 185 | ] 186 | ) 187 | 188 | stackView.userNavigationOptions = [.swipe, .tap] 189 | stackView.transitionDuration = 0.25 190 | stackView.isSeekingSafeArea = true 191 | stackView.isAutoresizingItems = true 192 | 193 | case .pageStack(let direction): 194 | pinBounds(of: stackView, to: container, withInsets: (0, 0, 0, 0)) 195 | 196 | stackView.headPadding = 0 197 | stackView.growthDirection = direction 198 | stackView.separationStyle = .line(thikness: 2, color: .white) 199 | stackView.fadingStyle = .combined(styles: 200 | [ 201 | .tint(color: .white, preLastAlpha: 0.9, alphaDecrement: 0.1) 202 | ] 203 | ) 204 | 205 | stackView.userNavigationOptions = [.swipe, .tap] 206 | stackView.transitionDuration = 0.10 207 | stackView.isSeekingSafeArea = true 208 | stackView.isAutoresizingItems = true 209 | 210 | case .feedFallingDown: 211 | pinBounds(of: stackView, to: container, withInsets: (10, 0, 10, 10)) 212 | 213 | stackView.headPadding = 20 214 | stackView.growthDirection = .up 215 | stackView.separationStyle = .line(thikness: 6, color: .white) 216 | stackView.fadingStyle = .tint(color: .white, preLastAlpha: 0.8, alphaDecrement: 0.15) 217 | 218 | stackView.userNavigationOptions = [.swipe, .tap] 219 | stackView.transitionDuration = 0.20 220 | stackView.isSeekingSafeArea = true 221 | stackView.isAutoresizingItems = true 222 | 223 | var timePassed: TimeInterval = 0 224 | 225 | for _ in 0 ..< 24 { 226 | timePassed += 0.05 227 | 228 | Utils.mainQueueTask({ 229 | (stackView as? CustomStackFlowView)?.pushNextItem(to: stackView) 230 | }, after: timePassed) 231 | } 232 | 233 | case .stackOfStacks: 234 | pinBounds(of: stackView, to: container, withInsets: (0, 0, 0, 0)) 235 | 236 | stackView.headPadding = 0 237 | stackView.growthDirection = .right 238 | stackView.separationStyle = .padding(size: 10) 239 | stackView.fadingStyle = .none 240 | 241 | stackView.userNavigationOptions = [.swipe, .tap] 242 | stackView.transitionDuration = 0.4 243 | stackView.isSeekingSafeArea = false 244 | stackView.isAutoresizingItems = true 245 | 246 | if let customStack = stackView as? CustomStackFlowView { 247 | customStack.customPushFunction = { 248 | customStack.push( 249 | { 250 | let substacksPerScreen: CGFloat = 3 251 | let itemTint = Utils.randomPastelColor() 252 | 253 | let substackWidth: CGFloat = { 254 | if case .padding(let size) = stackView.separationStyle { 255 | return ((customStack.safeSize.width - (size * (substacksPerScreen - 1))) / substacksPerScreen) 256 | } 257 | 258 | return 0 259 | }() 260 | 261 | let itemView = UIView(frame: CGRect(x: 0, y: 0, width: substackWidth, height: customStack.safeSize.height)) 262 | itemView.layer.borderWidth = 2.0 263 | itemView.layer.borderColor = itemTint.cgColor 264 | 265 | let subStack = CustomStackFlowView() 266 | itemView.addSubview(subStack) 267 | pinBounds(of: subStack, to: itemView, withInsets: (0, 0, 0, 0)) 268 | 269 | subStack.headPadding = 0 270 | subStack.growthDirection = stackView.numberOfItems % 2 > 0 ? .down : .up 271 | subStack.separationStyle = .line(thikness: 2, color: itemTint) 272 | subStack.fadingStyle = .none 273 | 274 | subStack.userNavigationOptions = [.swipe, .tap] 275 | subStack.transitionDuration = 0.20 276 | subStack.isSeekingSafeArea = true 277 | subStack.isAutoresizingItems = true 278 | 279 | return itemView 280 | }() 281 | ) 282 | } 283 | } 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Extensions/Foundation+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Foundation+Extensions.swift 3 | // Created by 0xNSHuman 4 | // 5 | 6 | import Foundation 7 | 8 | extension Date { 9 | static func unixDate(from string: String) -> Date? { 10 | let formatter = DateFormatter() 11 | formatter.locale = Locale(identifier: "en_US_POSIX") 12 | formatter.timeZone = TimeZone(identifier: "UTC") 13 | 14 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" 15 | 16 | if let date = formatter.date(from: string) { 17 | return date 18 | } else { 19 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" 20 | return formatter.date(from: string) 21 | } 22 | } 23 | 24 | func unixDateString() -> String? { 25 | let formatter = DateFormatter() 26 | formatter.locale = Locale(identifier: "en_US_POSIX") 27 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" 28 | formatter.timeZone = TimeZone(identifier: "UTC") 29 | 30 | return formatter.string(from: self) 31 | } 32 | 33 | func stringRepresentation(format: String) -> String? { 34 | let formatter = DateFormatter() 35 | formatter.dateFormat = format 36 | return formatter.string(from: self) 37 | } 38 | } 39 | 40 | extension Bundle { 41 | func typeFromNib(_ type: T.Type) -> T? { 42 | return Bundle.main.loadNibNamed(String(describing: T.self), owner: nil, options: nil)?.first as? T 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Extensions/SwiftLibrary+Extentions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftLibrary+Extentions.swift 3 | // Created by 0xNSHuman 4 | // 5 | 6 | import Foundation 7 | 8 | private extension String { 9 | var urlEscaped: String { 10 | return self.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)! 11 | } 12 | 13 | var utf8Encoded: Data { 14 | return self.data(using: .utf8)! 15 | } 16 | } 17 | 18 | extension Dictionary { 19 | static func += (left: inout Dictionary, right: Dictionary) { 20 | // Swift 4 way 21 | 22 | left.merge(right) { (_, new) in new } 23 | return; 24 | 25 | // Swift 3 way 26 | 27 | for (key, value) in right { 28 | left[key] = value 29 | } 30 | } 31 | } 32 | 33 | extension Sequence where Iterator.Element: Hashable { 34 | func unique() -> [Iterator.Element] { 35 | var seen: Set = [] 36 | return filter { 37 | if seen.contains($0) { 38 | return false 39 | } else { 40 | seen.insert($0) 41 | return true 42 | } 43 | } 44 | } 45 | } 46 | 47 | // TODO: Not sure about overflow 48 | 49 | extension Int { 50 | static postfix func ++ (_ value: inout Int) { 51 | value = value < Int.max ? value + 1 : Int.max 52 | } 53 | 54 | static postfix func -- (_ value: inout Int) { 55 | value = value > Int.min ? value - 1 : Int.min 56 | } 57 | } 58 | 59 | extension UInt { 60 | static postfix func ++ (_ value: inout UInt) { 61 | value = value < UInt.max ? value + 1 : UInt.max 62 | } 63 | 64 | static postfix func -- (_ value: inout UInt) { 65 | value = value > UInt.min ? value - 1 : UInt.min 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Extensions/UIKit+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIKit+Extensions.swift 3 | // Created by 0xNSHuman 4 | // 5 | 6 | import Foundation 7 | import UIKit 8 | 9 | // MARK: UIImage 10 | 11 | public extension UIImage { 12 | public func applying(tintColor color: UIColor) -> UIImage{ 13 | UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale) 14 | let context: CGContext = UIGraphicsGetCurrentContext()! 15 | context.translateBy(x: 0, y: self.size.height) 16 | context.scaleBy(x: 1.0, y: -1.0) 17 | context.setBlendMode(CGBlendMode.normal) 18 | let rect: CGRect = CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height) 19 | context.clip(to: rect, mask: self.cgImage!) 20 | color.setFill() 21 | context.fill(rect); 22 | let newImage: UIImage = UIGraphicsGetImageFromCurrentImageContext()!; 23 | UIGraphicsEndImageContext(); 24 | return newImage; 25 | } 26 | } 27 | 28 | public extension UIView { 29 | // Shadow 30 | 31 | func dropShadow(scale: Bool = true, offset: CGFloat = 1.0) { 32 | self.layer.masksToBounds = false 33 | self.layer.shadowColor = UIColor.black.cgColor 34 | self.layer.shadowOpacity = 0.5 35 | self.layer.shadowOffset = CGSize(width: 0, height: offset) 36 | self.layer.shadowRadius = 1 37 | 38 | self.layer.shadowPath = (layer.mask as? CAShapeLayer)?.path ?? UIBezierPath(rect: self.bounds).cgPath 39 | } 40 | 41 | // Mask 42 | 43 | enum GeometryMaskType { 44 | case circle 45 | } 46 | 47 | typealias MaskBorderSettings = (CGFloat, UIColor) 48 | 49 | func applyGeometryMask(type: GeometryMaskType, border: MaskBorderSettings? = nil) { 50 | let maskLayer = CAShapeLayer() 51 | maskLayer.frame = bounds 52 | 53 | maskLayer.path = { 54 | let path = CGMutablePath() 55 | 56 | switch type { 57 | case .circle: 58 | path.addArc(center: CGPoint(x: bounds.width / 2, y: bounds.height / 2), radius: bounds.width / 2, startAngle: -(CGFloat.pi / 2.0), endAngle: 3.0 * CGFloat.pi / 2, clockwise: false) 59 | } 60 | 61 | return path 62 | }() 63 | 64 | layer.mask = maskLayer 65 | 66 | layer.sublayers?.filter({ $0.zPosition == 9999 }).first?.removeFromSuperlayer() 67 | 68 | if let (width, color) = border { 69 | let strokeLayer = CAShapeLayer() 70 | strokeLayer.frame = bounds 71 | strokeLayer.path = maskLayer.path 72 | strokeLayer.lineWidth = width * 2 73 | strokeLayer.strokeColor = color.cgColor 74 | strokeLayer.fillColor = UIColor.clear.cgColor 75 | strokeLayer.zPosition = 9999 76 | layer.addSublayer(strokeLayer) 77 | } 78 | } 79 | 80 | // Constraints 81 | 82 | enum IndentConstraintType { 83 | case left, top, right, bottom 84 | } 85 | 86 | private func removeConstraints(_ cs: [NSLayoutConstraint]) { 87 | cs.forEach { 88 | $0.isActive = false 89 | superview?.removeConstraint($0) 90 | } 91 | } 92 | 93 | func removeAllConstraints() { 94 | removeConstraints(superview?.constraints.filter({ ($0.firstItem as? UIView) == self || ($0.secondItem as? UIView) == self }) ?? []) 95 | removeConstraints(constraints.filter({ ($0.firstItem as? UIView) == self || ($0.secondItem as? UIView) == self })) 96 | } 97 | 98 | var widthConstraint: CGFloat? { 99 | set(value) { 100 | (constraints.flatMap({ return (($0.firstItem as? UIView) == self && $0.firstAttribute == .width) ? $0 : nil }).first)?.constant = value ?? 0.0 101 | } 102 | 103 | get { 104 | return (constraints.flatMap({ return (($0.firstItem as? UIView) == self && $0.firstAttribute == .width) ? $0 : nil }).first)?.constant 105 | } 106 | } 107 | 108 | var heightConstraint: CGFloat? { 109 | set(value) { 110 | let constraint = constraints.flatMap({ return (($0.firstItem as? UIView) == self && $0.firstAttribute == .height) ? $0 : nil }).first 111 | constraint?.constant = value ?? 0.0 112 | } 113 | 114 | get { 115 | let constraint = constraints.flatMap({ return (($0.firstItem as? UIView) == self && $0.firstAttribute == .height) ? $0 : nil }).first 116 | return constraint?.constant 117 | } 118 | } 119 | 120 | var indendationConstraints: (CGFloat?, CGFloat?, CGFloat?, CGFloat?) { 121 | set(value) { 122 | setConstraintConstant(value.0 ?? 0, for: .leading) 123 | setConstraintConstant(value.1 ?? 0, for: .top) 124 | setConstraintConstant(value.2 ?? 0, for: .trailing) 125 | setConstraintConstant(value.3 ?? 0, for: .bottom) 126 | } 127 | 128 | get { 129 | return ( 130 | constraintConstant(describing: .leading), 131 | constraintConstant(describing: .top), 132 | constraintConstant(describing: .trailing), 133 | constraintConstant(describing: .bottom) 134 | ) 135 | } 136 | } 137 | 138 | private func constraintConstant(describing attribute: NSLayoutAttribute) -> CGFloat? { 139 | return superview?.constraints.filter({ (($0.firstItem as? UIView) == self && $0.firstAttribute == attribute) || (($0.secondItem as? UIView) == self && $0.secondAttribute == attribute) }).first?.constant 140 | } 141 | 142 | private func setConstraintConstant(_ c: CGFloat, for attribute: NSLayoutAttribute){ 143 | superview?.constraints.filter({ (($0.firstItem as? UIView) == self && $0.firstAttribute == attribute) || (($0.secondItem as? UIView) == self && $0.secondAttribute == attribute) }).first?.constant = c 144 | } 145 | 146 | // Convenient calculators for dynamic size 147 | 148 | var widthToFit: CGFloat { 149 | return sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: heightConstraint ?? bounds.height)).width 150 | } 151 | 152 | var heightToFit: CGFloat { 153 | return sizeThatFits(CGSize(width: widthConstraint ?? bounds.width, height: CGFloat.greatestFiniteMagnitude)).height 154 | } 155 | } 156 | 157 | public extension UIImageView { 158 | public convenience init(frame: CGRect, image: UIImage?, contentMode: UIViewContentMode = .scaleAspectFit) { 159 | 160 | self.init(frame: frame) 161 | self.image = image 162 | self.contentMode = contentMode 163 | } 164 | } 165 | 166 | public extension UITableView { 167 | func dequeueCell(_ type: CellType.Type) -> CellType { 168 | return dequeueReusableCell(withIdentifier: String(describing: type)) as! CellType 169 | } 170 | } 171 | 172 | public extension UICollectionView { 173 | func dequeueCell(_ type: CellType.Type, for indexPath: IndexPath) -> CellType { 174 | return dequeueReusableCell(withReuseIdentifier: String(describing: type), for: indexPath) as! CellType 175 | } 176 | } 177 | 178 | public extension UIViewController { 179 | enum NavigationBarZone { 180 | case left, right 181 | } 182 | 183 | func setNavigationViews(_ views: [UIView], on zone: NavigationBarZone) { 184 | var barButtonItems = [UIBarButtonItem]() 185 | 186 | for view in views { 187 | let barItem = UIBarButtonItem(customView: view) 188 | 189 | if zone == .left, views.first != view { 190 | barButtonItems.append({ 191 | let space = UIBarButtonItem.init(barButtonSystemItem: .fixedSpace, target: nil, action: nil) 192 | space.width = 10.0 193 | 194 | return space 195 | }() 196 | ) 197 | } 198 | 199 | barButtonItems.append(barItem) 200 | } 201 | 202 | if zone == .left { 203 | navigationItem.leftBarButtonItems = barButtonItems 204 | } else { 205 | navigationItem.rightBarButtonItems = barButtonItems 206 | } 207 | } 208 | 209 | func makeKeyboardHidableByTapAround() { 210 | let tap: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(UIViewController.dismissKeyboard)) 211 | tap.cancelsTouchesInView = false 212 | view.addGestureRecognizer(tap) 213 | } 214 | 215 | @objc func dismissKeyboard() { 216 | view.endEditing(true) 217 | } 218 | } 219 | 220 | public extension UINavigationController { 221 | typealias PopCompletion = () -> () 222 | 223 | func pushFromRootViewController(_ vc: UIViewController, animated: Bool) { 224 | guard let rootVC = viewControllers.first else { 225 | setViewControllers([vc], animated: animated) 226 | return 227 | } 228 | 229 | setViewControllers([rootVC, vc], animated: animated) 230 | } 231 | 232 | func popToRootViewController(animated: Bool, completion: PopCompletion? = nil) { 233 | CATransaction.begin() 234 | CATransaction.setCompletionBlock(completion) 235 | 236 | _ = popToRootViewController(animated: animated) 237 | 238 | CATransaction.commit() 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Misc/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // StackFlowViewDemo 4 | // 5 | // Created by 0xNSHuman on 19/01/2018. 6 | // Copyright © 2018 0xNSHuman. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 18 | // Override point for customization after application launch. 19 | return true 20 | } 21 | 22 | func applicationWillResignActive(_ application: UIApplication) { 23 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 24 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 25 | } 26 | 27 | func applicationDidEnterBackground(_ application: UIApplication) { 28 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 29 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 30 | } 31 | 32 | func applicationWillEnterForeground(_ application: UIApplication) { 33 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 34 | } 35 | 36 | func applicationDidBecomeActive(_ application: UIApplication) { 37 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 38 | } 39 | 40 | func applicationWillTerminate(_ application: UIApplication) { 41 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 42 | } 43 | 44 | 45 | } 46 | 47 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Misc/Defines.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defines.swift 3 | // StackFlowViewDemo 4 | // 5 | // Created by 0xNSHuman on 23/01/2018. 6 | // Copyright © 2018 0xNSHuman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | struct Images { 13 | struct Navigation { 14 | static let back = UIImage(named: "back")!.applying(tintColor: .white) 15 | static let forward = UIImage(named: "fwd")!.applying(tintColor: .white) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Misc/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/back.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "back.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "back-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "back-2.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/back.imageset/back-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/back.imageset/back-1.png -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/back.imageset/back-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/back.imageset/back-2.png -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/back.imageset/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/back.imageset/back.png -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/fwd.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "fwd.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "fwd-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "fwd-2.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/fwd.imageset/fwd-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/fwd.imageset/fwd-1.png -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/fwd.imageset/fwd-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/fwd.imageset/fwd-2.png -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/fwd.imageset/fwd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/StackFlowViewDemo/StackFlowViewDemo/Res/Assets.xcassets/fwd.imageset/fwd.png -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Res/UI/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xNSHuman/StackFlowView/60032156606bab7b24dc935348b2afce49902df1/StackFlowViewDemo/StackFlowViewDemo/Res/UI/bg.jpg -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Utils/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utils.swift 3 | // StackFlowViewDemo 4 | // 5 | // Created by 0xNSHuman on 19/01/2018. 6 | // Copyright © 2018 0xNSHuman. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | // MARK: Shortcuts 13 | 14 | var appWindow: UIWindow? { 15 | guard let window = appDelegate?.window else { 16 | log(message: "[WARNING] Trying to access app window, which doesn't exist!", from: #function) 17 | return nil 18 | } 19 | 20 | return window 21 | } 22 | 23 | var appDelegate: AppDelegate? { return UIApplication.shared.delegate as? AppDelegate } 24 | 25 | struct Utils { 26 | 27 | } 28 | 29 | extension Utils { 30 | static func controllerFromStoryboard(_ type: T.Type) -> T { 31 | return UIStoryboard.init(name: "Main", bundle: nil).instantiateViewController(withIdentifier: String(describing: type)) as! T 32 | } 33 | } 34 | 35 | // MARK: Global heirarchy 36 | 37 | extension Utils { 38 | static func setRootViewController(_ rootVC: UIViewController) { 39 | appDelegate?.window?.subviews.forEach { $0.removeFromSuperview() } 40 | appDelegate?.window = UIWindow(frame: UIScreen.main.bounds) 41 | 42 | appWindow?.backgroundColor = .white 43 | appWindow?.rootViewController = rootVC 44 | appWindow?.makeKeyAndVisible() 45 | } 46 | } 47 | 48 | // MARK: Threads 49 | 50 | extension Utils { 51 | static func mainQueueTask(_ task: @escaping () -> (), after interval: TimeInterval = 0.0) { 52 | DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: task) 53 | } 54 | 55 | static func bgQueueTask(_ task: @escaping () -> (), after interval: TimeInterval = 0.0) { 56 | DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + interval, execute: task) 57 | } 58 | } 59 | 60 | // MARK: UIKit Routines 61 | 62 | extension Utils { 63 | static func alert(title: String?, text: String?, handler: (() -> Void)? = nil) -> UIAlertController { 64 | let alert = UIAlertController(title: title, message: text, preferredStyle: .alert) 65 | alert.addAction(UIAlertAction(title: NSLocalizedString("button.ok", value: "Ok", comment: "Alert OK Button"), style: .default, handler: {_ in handler?() })) 66 | return alert 67 | } 68 | 69 | static func animateBlock(_ block: @escaping () -> Void, duration: Double = 0.25, delay: Double = 0.0) { 70 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + delay, execute: { 71 | UIView.animate(withDuration: duration, animations: { 72 | block() 73 | }) 74 | }) 75 | } 76 | } 77 | 78 | // MARK: UI Appearance 79 | 80 | extension Utils { 81 | static func randomPastelColor() -> UIColor { 82 | let randomColorGenerator = { ()-> CGFloat in 83 | CGFloat(arc4random() % 256 ) / 256 84 | } 85 | 86 | let red: CGFloat = randomColorGenerator() 87 | let green: CGFloat = randomColorGenerator() 88 | let blue: CGFloat = randomColorGenerator() 89 | 90 | return UIColor(red: red, green: green, blue: blue, alpha: 1) 91 | } 92 | } 93 | 94 | // MARK: Feedback 95 | 96 | extension Utils { 97 | static func throwAlert(title: String?, text: String?, completion: (() -> ())? = nil) { 98 | appWindow?.rootViewController?.present(alert(title: title, text: text), animated: true, completion: completion) 99 | } 100 | } 101 | 102 | // MARK: Debug 103 | 104 | func log(message: String, from sender: Any) { 105 | print("[\(sender)]: \(message)") 106 | } 107 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Views/CustomStackView/CustomStackFlowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomStackFlowView.swift 3 | // StackFlowViewDemo 4 | // 5 | // Created by 0xNSHuman on 19/01/2018. 6 | // Copyright © 2018 0xNSHuman. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// This is a subclass of StackFlowView, which is one of the possible ways to use it. It lets you build custom behaviour and flow control while enjoying basic navigation features. Another possible way would be making a composition, i.e. using StackFlowView object as a property of another class. It's up tou you and your case which way is better, but for this demo purposes I ended up with subclassing. 12 | 13 | class CustomStackFlowView: StackFlowView { 14 | // MARK: - Types - 15 | 16 | enum FlowStep: Int { 17 | case none = -1 18 | case one = 0, two, three, four, five, six 19 | 20 | static var count: Int { return 6 } 21 | 22 | var title: String { 23 | switch self { 24 | default: 25 | return "Step \(shortSymbol)" 26 | } 27 | } 28 | 29 | var shortSymbol: String { 30 | switch self { 31 | case .one: 32 | return "♦️" 33 | 34 | case .two: 35 | return "♠️" 36 | 37 | case .three: 38 | return "💎" 39 | 40 | case .four: 41 | return "🔮" 42 | 43 | case .five: 44 | return "🎁" 45 | 46 | case .six: 47 | return "📖" 48 | 49 | case .none: 50 | return "❌" 51 | } 52 | } 53 | 54 | var prevStep: FlowStep? { 55 | let prevValue = rawValue - 1 56 | return prevValue >= 0 ? FlowStep(rawValue: prevValue) : nil 57 | } 58 | 59 | var nextStep: FlowStep? { 60 | let nextValue = rawValue + 1 61 | return nextValue < FlowStep.count ? FlowStep(rawValue: nextValue) : nil 62 | } 63 | } 64 | 65 | // MARK: - Properties - 66 | 67 | /* — You can play with finite/infinite stack variations — */ 68 | var isEndlessMode = false 69 | 70 | var customPushFunction: (() -> ())? = nil 71 | 72 | // MARK: - State control - 73 | 74 | private var currentStep: FlowStep = .none { 75 | didSet { 76 | let itemTitle = currentStep.title 77 | 78 | let prevItemSymbol = currentStep.prevStep?.shortSymbol 79 | let nextItemSymbol = currentStep.nextStep?.shortSymbol 80 | 81 | let itemView = stepView(for: currentStep) 82 | 83 | // Subfunction to explain possible ways of pushing new item + customization 84 | 85 | func pushStep() { 86 | /* — You can choose how far you want to go in terms of customization of evety item — */ 87 | 88 | let chosenWay = 3 89 | 90 | switch chosenWay { 91 | case 1: 92 | // 1. Push your custom view "as is". This way it's just sent to stack flow, and navigaton (push next/pop to previous) is available either through StackFlowView's swipes/taps, or whatever you set yourself in your custom view (the idea is to push/pop after user achieves some goal in your view, such as setting text, picking options, pressing buttons, etc.) 93 | push(itemView) 94 | 95 | case 2: 96 | // 2. Same as above, plus a standard navigation bar on top, showing the title you set, as well as button controls for pop/push. Not much but still nice feature to have in case you don't like gestures, or as part of Accessibility implementation for some users. 97 | push(itemView, title: itemTitle) 98 | 99 | case 3: 100 | // 3. Same as above, but now you have options to customize item's navigation bar appearance properties, such as background and foreground color, title font, pop/push icons text or icons. 101 | 102 | 103 | let topBarAppearance: StackItemAppearance.TopBar = { 104 | /* — Change to see different button appearance example: 0 - icon or 1 - text/emoji. See switch below for details — */ 105 | 106 | let buttonsTestOption = 1 107 | 108 | let popButtonAppearance: StackItemAppearance.TopBar.Button 109 | let pushButtonAppearance: StackItemAppearance.TopBar.Button 110 | 111 | if buttonsTestOption == 0 { // Navigation icons playground 112 | popButtonAppearance = StackItemAppearance.TopBar.Button(icon: Images.Navigation.back) 113 | pushButtonAppearance = StackItemAppearance.TopBar.Button(icon: Images.Navigation.forward) 114 | } else { // Navigation text playground 115 | let popButtonTitle = NSAttributedString(string: "\(currentStep.prevStep?.shortSymbol ?? "❌")⬅️", attributes: [.foregroundColor : UIColor.blue]) 116 | popButtonAppearance = StackItemAppearance.TopBar.Button(title: popButtonTitle) 117 | 118 | let pushButtonTitle = NSAttributedString(string: "➡️\(currentStep.nextStep?.shortSymbol ?? "❌")", attributes: [.foregroundColor : UIColor.blue]) 119 | pushButtonAppearance = StackItemAppearance.TopBar.Button(title: pushButtonTitle) 120 | } 121 | 122 | // Setting StackItemAppearance for the item to insert 123 | 124 | let customBarAppearance = StackItemAppearance.TopBar(backgroundColor: Utils.randomPastelColor(), titleFont: .italicSystemFont(ofSize: 17.0), titleTextColor: .white, popButtonIdentity: popButtonAppearance, pushButtonIdentity: pushButtonAppearance) 125 | return customBarAppearance 126 | }() 127 | 128 | let customAppearance = StackItemAppearance(backgroundColor: Utils.randomPastelColor(), topBarAppearance: topBarAppearance) 129 | 130 | push(itemView, title: itemTitle, customAppearance: customAppearance) 131 | 132 | default: 133 | push(itemView) 134 | } 135 | } 136 | 137 | let delta = currentStep.rawValue - oldValue.rawValue 138 | 139 | if delta < 0 { 140 | for _ in 0 ..< abs(delta) { 141 | pop() 142 | } 143 | } else if delta > 0 { 144 | pushStep() 145 | } else { 146 | pop() 147 | pushStep() 148 | } 149 | } 150 | } 151 | 152 | // MARK: Initializers 153 | 154 | override init(frame: CGRect) { 155 | super.init(frame: frame) 156 | self.delegate = self // Don't forget to set delegate! 157 | } 158 | 159 | required public init?(coder aDecoder: NSCoder) { 160 | fatalError("init(coder:) has not been implemented") 161 | } 162 | 163 | // MARK: Goal creation flow 164 | 165 | func resetFlow() { 166 | guard !isEndlessMode else { 167 | return 168 | } 169 | 170 | setStep(FlowStep(rawValue: 0)!, animated: true) 171 | clean() 172 | setStep(FlowStep(rawValue: 0)!, animated: true) 173 | } 174 | 175 | // MARK: Transitions 176 | 177 | private func goToPrevStep() { 178 | guard let prevStep = currentStep.prevStep else { 179 | return 180 | } 181 | 182 | setStep(prevStep, animated: true) 183 | } 184 | 185 | private func goToNextStep() { 186 | guard let nextStep = currentStep.nextStep else { 187 | return 188 | } 189 | 190 | setStep(nextStep, animated: true) 191 | } 192 | 193 | private func setStep(_ step: FlowStep, animated: Bool) { 194 | self.transitionDuration = animated ? 0.25 : 0.0 195 | self.currentStep = step 196 | } 197 | 198 | // MARK: FlowStep views construction 199 | 200 | private func stepView(for step: FlowStep) -> UIView { 201 | let stepView: DemoItemView 202 | 203 | // Here we don't really care but for a real state control we could create different necessary unique views for evety step 204 | 205 | switch step { 206 | default: 207 | // Please look into DemoItemView.bounds(for:) static method to learn more about defining the best frames for your stack flow item views 208 | 209 | stepView = DemoItemView.stackItem(bounds: DemoItemView.bounds(for: self)) 210 | stepView.identityLabel.text = "Any UI"//"Custom UIView #" + String(step.rawValue + 1) 211 | 212 | stepView.popHandler = { 213 | self.goToPrevStep() 214 | } 215 | 216 | stepView.pushHandler = { 217 | self.goToNextStep() 218 | } 219 | } 220 | 221 | return stepView 222 | } 223 | } 224 | 225 | // MARK: - Delegate - 226 | 227 | extension CustomStackFlowView: StackFlowDelegate { 228 | func pushNextItem(to stackView: StackFlowView) { 229 | if let customPush = customPushFunction { 230 | customPush() 231 | return 232 | } 233 | 234 | stackView.push( 235 | { 236 | let item = DemoItemView.stackItem(bounds: DemoItemView.bounds(for: stackView)) 237 | item.identityLabel.text = "Custom UI"//"Custom UIView #" + String(stackView.numberOfItems + 1) 238 | 239 | item.popHandler = { 240 | stackView.pop() 241 | } 242 | 243 | item.pushHandler = { 244 | self.pushNextItem(to: stackView) 245 | } 246 | 247 | return item 248 | }() 249 | ) 250 | } 251 | 252 | func stackFlowViewDidRequestPop(_ stackView: StackFlowView, numberOfItems: Int) { 253 | log(message: "Requested to go \(numberOfItems) steps back", from: self) 254 | 255 | if isEndlessMode { 256 | stackView.pop(numberOfItems) 257 | } else { 258 | for _ in 0 ..< numberOfItems { 259 | goToPrevStep() 260 | } 261 | } 262 | } 263 | 264 | func stackFlowViewDidRequestPush(_ stackView: StackFlowView) { 265 | log(message: "Requested next item", from: self) 266 | 267 | if isEndlessMode { 268 | pushNextItem(to: stackView) 269 | } else { 270 | goToNextStep() 271 | } 272 | } 273 | 274 | func stackFlowViewWillPop(_ stackView: StackFlowView) { 275 | log(message: "About to go one item back", from: self) 276 | } 277 | 278 | func stackFlowViewDidPop(_ stackView: StackFlowView) { 279 | log(message: "Went one item back", from: self) 280 | 281 | if let demoItem = stackView.lastItemContent as? DemoItemView { 282 | demoItem.becameActive() 283 | } 284 | } 285 | 286 | func stackFlowView(_ stackView: StackFlowView, willPush view: UIView) { 287 | log(message: "About to to go to the next step", from: self) 288 | 289 | if let lastItem = stackView.lastItemContent as? DemoItemView { 290 | lastItem.becameInactive() 291 | } 292 | } 293 | 294 | func stackFlowView(_ stackView: StackFlowView, didPush view: UIView) { 295 | log(message: "Went to next step with view: \(view)", from: self) 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Views/DemoItemView/DemoItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DemoItemView.swift 3 | // StackFlowViewDemo 4 | // 5 | // Created by 0xNSHuman on 19/01/2018. 6 | // Copyright © 2018 0xNSHuman. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class DemoItemView: UIView { 12 | // MARK: - Constants - 13 | 14 | static let minimumAllowedWidth: CGFloat = 300 //(appDelegate?.window?.bounds.width ?? 0) / 2 //380.0 15 | static let maximumAllowedWidth: CGFloat = minimumAllowedWidth + 1 16 | 17 | static let minimumAllowedHeight: CGFloat = 97.0 18 | static let maximumAllowedHeight: CGFloat = minimumAllowedHeight + 1 19 | 20 | // MARK: - Outlets - 21 | 22 | @IBOutlet weak var identityLabel: UILabel! 23 | @IBOutlet var navigationLabels: [UILabel]! 24 | 25 | // MARK: - Properties - 26 | 27 | var popHandler: (() -> ())? = nil 28 | var pushHandler: (() -> ())? = nil 29 | 30 | // MARK: - Life cycle - 31 | 32 | override func awakeFromNib() { 33 | super.awakeFromNib() 34 | setUp() 35 | } 36 | 37 | // MARK: - Setup - 38 | 39 | private func setUp() { 40 | // Set bg color 41 | 42 | backgroundColor = Utils.randomPastelColor() 43 | 44 | // Attach gestures 45 | 46 | addGestureRecognizer({ 47 | let tapGesture = UITapGestureRecognizer() 48 | tapGesture.delegate = self 49 | tapGesture.addTarget(self, action: #selector(tapOccured(_:))) 50 | return tapGesture 51 | }()) 52 | 53 | addGestureRecognizer({ 54 | let doubleTapGesture = UITapGestureRecognizer() 55 | doubleTapGesture.delegate = self 56 | doubleTapGesture.numberOfTapsRequired = 2 57 | doubleTapGesture.addTarget(self, action: #selector(doubleTapOccured(_:))) 58 | return doubleTapGesture 59 | }()) 60 | } 61 | 62 | // MARK: - Tap events - 63 | 64 | @objc private func tapOccured(_ gesture: UITapGestureRecognizer) { 65 | pushHandler?() 66 | } 67 | 68 | @objc private func doubleTapOccured(_ gesture: UITapGestureRecognizer) { 69 | popHandler?() 70 | } 71 | } 72 | 73 | // MARK: - Touch Delegate - 74 | 75 | extension DemoItemView: UIGestureRecognizerDelegate { 76 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { 77 | return true 78 | } 79 | 80 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { 81 | guard (gestureRecognizer as? UITapGestureRecognizer)?.numberOfTapsRequired == 1, 82 | (otherGestureRecognizer as? UITapGestureRecognizer)?.numberOfTapsRequired == 2 else { 83 | return false 84 | } 85 | 86 | return true 87 | } 88 | } 89 | 90 | // MARK: - Triggers - 91 | 92 | extension DemoItemView { 93 | func becameActive() { 94 | navigationLabels.forEach { $0.isHidden = false } 95 | } 96 | 97 | func becameInactive() { 98 | navigationLabels.forEach { $0.isHidden = true } 99 | } 100 | } 101 | 102 | // MARK: - Producer for StackFlowView - 103 | 104 | extension DemoItemView { 105 | static func stackItem(bounds: CGRect) -> DemoItemView { 106 | let itemView = Bundle.main.typeFromNib(DemoItemView.self)! // Force unwrap is 100% safe here 107 | itemView.frame = bounds 108 | return itemView 109 | } 110 | 111 | static func bounds(for stackFlowView: StackFlowView) -> CGRect { 112 | // Note this `safeSize` property of StackFlowView. You should use it to get info about its available content area, not blocked by any views outside of safe area 113 | 114 | let safeStackFlowViewWidth = stackFlowView.safeSize.width 115 | let safeStackFlowViewHeight = stackFlowView.safeSize.height 116 | 117 | if stackFlowView.growthDirection.isVertical { 118 | let height = (CGFloat(arc4random()).truncatingRemainder(dividingBy: (maximumAllowedHeight - minimumAllowedHeight))) + minimumAllowedHeight 119 | 120 | return CGRect(x: 0, y: 0, width: safeStackFlowViewWidth, height: { 121 | return min(height, safeStackFlowViewHeight) 122 | }() 123 | ) 124 | } else { 125 | let width = (CGFloat(arc4random()).truncatingRemainder(dividingBy: (maximumAllowedWidth - minimumAllowedWidth))) + minimumAllowedWidth 126 | 127 | return CGRect(x: 0, y: 0, width: { 128 | return min(width, safeStackFlowViewWidth) 129 | }(), height: safeStackFlowViewHeight 130 | ) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Views/DemoItemView/DemoItemView.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 30 | 39 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Views/Storyboards/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /StackFlowViewDemo/StackFlowViewDemo/Views/Storyboards/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | --------------------------------------------------------------------------------