├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── WavePageControl │ ├── DelegateProtocols.swift │ ├── Reshuffler.swift │ ├── Utils.swift │ └── WavePageControl.swift ├── Tests └── WavePageControlTests │ ├── ReshufflerTests.swift │ └── WavePageControlTests.swift ├── WavePageControl.podspec └── WavePageControlDemo-iOS ├── WavePageControlDemo-iOS.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── WavePageControlDemo-iOS.xcscheme └── WavePageControlDemo-iOS ├── AppDelegate.swift ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ └── Contents.json ├── Contents.json ├── Page Dot Border.colorset │ └── Contents.json └── Page Dot.colorset │ └── Contents.json ├── Base.lproj └── LaunchScreen.storyboard ├── Examples ├── CustomPageControl.swift ├── DefaultPageControl.swift ├── PageControlShowcase.swift └── SimplePageControl.swift ├── Info.plist ├── MainViewController.swift └── SceneDelegate.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Bogdan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "WavePageControl", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "WavePageControl", 15 | targets: ["WavePageControl"]), 16 | ], 17 | targets: [ 18 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 19 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 20 | .target( 21 | name: "WavePageControl", 22 | dependencies: []), 23 | .testTarget( 24 | name: "WavePageControlTests", 25 | dependencies: ["WavePageControl"]), 26 | ], 27 | swiftLanguageVersions: [.v5] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WavePageControl 2 | 3 | ![Release version](https://img.shields.io/github/v/release/mrbodich/WavePageControl) 4 | ![GitHub license](https://img.shields.io/github/license/mrbodich/WavePageControl) 5 | ![Cocoapods platforms](https://img.shields.io/badge/platform-iOS_13.0+-lightgrey) 6 | ![UIKit support](https://img.shields.io/badge/UIKit-compatible-green) 7 | 8 | > WavePageControl allows you to effectively organise the Page Control indicators all at once. 9 | 10 | ## How it works 11 | 12 | Your app has large but limited amount of pages, more than such indicators amounts could fit into most of Page Controls?\ 13 | You still want to have all indicators visible and have ability to navigate using Page Control? 14 | 15 | ## WavePageControl will help you 16 | 17 | * Show all page indicators visible for a large amounts of pages (let's say `40`) 18 | * Easily navigate through all the pages with swipe 19 | * Quickly move to any page with just a single tap 20 | * Clearly see the current selected page indicator among all (`40`) other 21 | * Customise indicators! 22 | * Change default indicator style 23 | * Create your own **custom indicators** 24 | 25 | 26 | 27 | ## Swift Package Manager Install 28 | 29 | Swift Package Manager 30 | 31 | ```swift 32 | dependencies: [ 33 | .package(url: "https://github.com/mrbodich/WavePageControl.git") 34 | ] 35 | ``` 36 | 37 | 38 | ## CocoaPods Install 39 | 40 | Add `pod 'WavePageControl'` to your Podfile. "WavePageControl" is the name of the library. 41 | 42 | ## Help 43 | 44 | If you like what you see here, and want to support the work being done in this repository, you could: 45 | * Contribute code, issues and pull requests 46 | * Let people know this library exists (:rocket: spread the word :rocket:) 47 | 48 | 49 | ## Questions & Issues 50 | 51 | If you are having questions or problems, you should: 52 | 53 | - Make sure you are using the latest version of the library. Check the [**release-section**](https://github.com/mrbodich/WavePageControl/releases). 54 | - Search [**known issues**](https://github.com/mrbodich/WavePageControl/issues) for your problem (open and closed) 55 | - Create new issues (please :fire: **search known issues before** :fire:, do not create duplicate issues) 56 | 57 | 58 | # How to use 59 | 60 | ## Auto Layout 61 | 62 | Subject | Description 63 | --- | --- 64 | Position | Satisfy position layout by creating 1 horizontal and 1 vertical constraint 65 | Size | WavePageControl **controls size by itself**, both vertical and horizontal. So keep it in mind and avoid conflicting/ambiguous constraints 66 | 67 | ## Customising WavePageControl instance 68 | 69 | Parameter | Description 70 | --- | --- 71 | maxNavigationWidth | Maximum width of WavePageControl frame. After reaching this width, all indicators sizes will be recalculated to fit max width. 72 | defaultButtonHeight | Permanent height of active indicator. Other indicators will have this size until they fit into the maxNavigationWidth. Minimum size of inactive indicators is not limited. 73 | defaultSpacing | Spacing between indicators until they fit into the maxNavigationWidth. 74 | minSpacing | Once indicators start getting smaller, spacing is getting smaller too and limited with this value. 75 | updateLayout() | Apply changed parameters and rebuild layout. Animated by default. You can use UIView.performWithoutAnimation(_:) to eliminate animation. 76 | 77 | 78 | ## Using WavePageControl 79 | 80 | WavePageControl is utilising array of **ID**s to build page indicators.\ 81 | WavePageControl is generic, thus `ID` can be any type until it is `Comparable`. 82 | 83 | 84 | Property | Description 85 | --- | --- 86 | pageIDs | Array of ids. Can be changed in any way, you don't need to think about correct arrangement. WavePageControl is smart enough to calculate all movings/insertions/removals between the previous and new state and **animate** them properly. Actually you can even put another random array here and it will just work! 87 | currentPage | ID of an active indicator, optional. You can put any ID here or nil. If pageIDs does not contain such ID, no any error here, WavePageControl will just indicate no selection. Once such ID will appear in pageIDs, corresponding indicator will be automatically set as active. 88 | delegate | Set instance conforming to WavePageControlDelegate here. `See next section for more info` 89 | 90 | 91 | ## Advanced Control 92 | 93 | You can have advanced control over the WavePageControl using the WavePageControlDelegate. 94 | WavePageControlDelegate has 3 methods with existing default implementation, so you can optionally implement the ones you need. 95 | 96 | Method | Description 97 | --- | --- 98 | createCustomPageView(id:) | Requires to return WavePageButtonView (that is UIView itself). Default implementation returns DefaultPageButtonView. `See examples in Demo Project` 99 | didSwipeScroll(_:, id:, isGestureCompleted:) | Called on swipe. It gives you a reference to WavePageControl, ID of indicator to select during all swipe, and a boolean state if gesture is completed. currentPage should be set explicitly here if needed. Default implementation will select it directly. 100 | didTap(_:, id:) | Called on tap. It gives you a reference to WavePageControl and ID of indicator to select. currentPage should be set explicitly here if needed. Default implementation will select it directly. 101 | 102 | 103 | # Visual Examples 104 | 105 | ##### Basic usage without injecting `WavePageControlDelegate` 106 | 107 | ![Default Example](https://user-images.githubusercontent.com/23237473/193407648-afdc083a-3f6a-4a2c-89b2-109a76daa79e.gif) 108 | 109 | ##### Customising `DefaultPageButtonView` in `WavePageControlDelegate`, handling `didSwipeScroll` 110 | 111 | ![Simple Example](https://user-images.githubusercontent.com/23237473/193407654-45683389-1b12-40ba-86f6-a4834de09540.gif) 112 | 113 | ##### Using fully custom `WavePageButtonView`, handling `didSwipeScroll` 114 | 115 | ![Custom Example](https://user-images.githubusercontent.com/23237473/193407661-ffa14672-4cd5-4748-b3df-a40803d0fafa.gif) 116 | 117 | ### Live usage example 118 | You can download the app here in the [**:point_right::iphone: App Store**](https://apps.apple.com/us/app/to-the-shop/id1542572914) 119 | 120 | https://user-images.githubusercontent.com/23237473/193412590-70e9b364-d60d-4cdb-9306-1e66436b722d.mp4 121 | -------------------------------------------------------------------------------- /Sources/WavePageControl/DelegateProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DelegateProtocols.swift 3 | // 4 | // 5 | // Created by Bogdan Chornobryvets on 21.08.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol WavePageControlDelegate: AnyObject { 11 | associatedtype ID: Comparable 12 | func createCustomPageView(for id: ID) -> WavePageButtonView 13 | func didSwipeScroll(_ wavePageControl: UIWavePageControl, toPageWithId id: ID, isGestureCompleted: Bool) 14 | func didTap(_ wavePageControl: UIWavePageControl, onPageWithId id: ID) 15 | } 16 | 17 | public extension WavePageControlDelegate { 18 | func createCustomPageView(for id: ID) -> WavePageButtonView { 19 | DefaultPageButtonView() 20 | } 21 | 22 | func didSwipeScroll(_ wavePageControl: UIWavePageControl, toPageWithId id: ID, isGestureCompleted: Bool) { 23 | wavePageControl.currentPage = id 24 | } 25 | 26 | func didTap(_ wavePageControl: UIWavePageControl, onPageWithId id: ID) { 27 | wavePageControl.currentPage = id 28 | } 29 | } 30 | 31 | final internal class AnyWavePageControlDelegate: WavePageControlDelegate { 32 | let _createCustomPageView: (_ id: ID) -> WavePageButtonView 33 | let _didSwipeScroll: (_ wavePageControl: UIWavePageControl, _ id: ID, _ isGestureCompleted: Bool) -> () 34 | let _didTap: (_ wavePageControl: UIWavePageControl, _ id: ID) -> () 35 | 36 | init(from delegate: Delegate) where Delegate.ID == ID { 37 | _createCustomPageView = { [weak delegate] id in 38 | delegate?.createCustomPageView(for: id) ?? DefaultPageButtonView() 39 | } 40 | _didSwipeScroll = { [weak delegate] pageControl, id, isGestureCompleted in 41 | delegate?.didSwipeScroll(pageControl, toPageWithId: id, isGestureCompleted: isGestureCompleted) 42 | } 43 | _didTap = { [weak delegate] pageControl, id in 44 | delegate?.didTap(pageControl, onPageWithId: id) 45 | } 46 | } 47 | 48 | public func createCustomPageView(for id: ID) -> WavePageButtonView { 49 | _createCustomPageView(id) 50 | } 51 | 52 | public func didSwipeScroll(_ wavePageControl: UIWavePageControl, toPageWithId id: ID, isGestureCompleted: Bool) { 53 | _didSwipeScroll(wavePageControl, id, isGestureCompleted) 54 | } 55 | 56 | func didTap(_ wavePageControl: UIWavePageControl, onPageWithId id: ID) { 57 | _didTap(wavePageControl, id) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/WavePageControl/Reshuffler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reshuffler.swift 3 | // 4 | // 5 | // Created by Bogdan Chornobryvets on 23.07.2022. 6 | // 7 | 8 | import Foundation 9 | 10 | internal struct Reshuffler { 11 | let initialSequence: [Element?] 12 | 13 | func shuffleStrategy(for newItems: [Element?]) -> [Operation] { 14 | 15 | var newItemsMap = newItems.map { (isMarked: false, item: $0) } 16 | let removed = initialSequence.enumerated() 17 | .filter { oldItem in 18 | if let index = newItemsMap.firstIndex(where: { $0 == (false, oldItem.element) }) { 19 | newItemsMap[index].isMarked = true 20 | return false 21 | } 22 | return true 23 | } 24 | 25 | var oldItemsMap = initialSequence.map { (isMarked: false, item: $0) } 26 | var noNewItems = newItems 27 | .filter { newItem in 28 | if let index = oldItemsMap.firstIndex(where: { $0 == (false, newItem) }) { 29 | oldItemsMap[index].isMarked = true 30 | return true 31 | } 32 | return false 33 | } 34 | 35 | for ghost in removed { 36 | noNewItems.insert(ghost.element, at: ghost.offset) 37 | } 38 | 39 | var mutableOld = initialSequence 40 | var replaced: [(from: Int, to: Int)] = [] 41 | 42 | for n in 0..= n 47 | }! 48 | if o != n { 49 | replaced.append((from: o, to: n)) 50 | let element = mutableOld.remove(at: o) 51 | mutableOld.insert(element, at: n) 52 | } 53 | } 54 | 55 | var mutableRemoved = removed 56 | var insertions: [(offset: Int, element: Element?)] = [] 57 | for n in 0..= mutableOld.count || mutableOld[compareIndex] != newItems[n] { 62 | insertions.append((offset: insertIndex, element: newItems[n])) 63 | mutableOld.insert(newItems[n], at: insertIndex) 64 | for r in 0.. Int { 83 | var shiftedIndex = index 84 | var baseShift = 0 85 | var shift = 0 86 | if isInserting { 87 | repeat { 88 | baseShift = shift 89 | shift = pushingIndices.filter { $0 < (shiftedIndex + baseShift) }.count 90 | } while shift != baseShift 91 | } else { 92 | repeat { 93 | baseShift = shift 94 | shift = pushingIndices.filter { $0 <= (shiftedIndex + baseShift) }.count 95 | } while shift != baseShift 96 | } 97 | shiftedIndex += baseShift 98 | 99 | return shiftedIndex 100 | } 101 | 102 | enum Operation { 103 | case reordering(map: [(from: Int, to: Int)]) 104 | case insertion(items: [(offset: Int, element: Element?)]) 105 | case deletion(reversedIndices: [Int]) 106 | } 107 | } 108 | 109 | -------------------------------------------------------------------------------- /Sources/WavePageControl/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utils.swift 3 | // 4 | // 5 | // Created by Bogdan Chornobryvets on 21.08.2022. 6 | // 7 | 8 | import UIKit 9 | 10 | internal struct UIConstants { 11 | static func shortAnimation(animations: @escaping () -> Void, completion: ((Bool) -> Void)? = nil){ 12 | UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut, animations: animations, completion: { (completed) in 13 | completion?(completed) 14 | }) 15 | } 16 | } 17 | 18 | internal extension Collection { 19 | subscript(optional i: Index) -> Iterator.Element? { 20 | return self.indices.contains(i) ? self[i] : nil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/WavePageControl/WavePageControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WavePageControl.swift 3 | // 4 | // 5 | // Created by Bogdan Chornobryvets on 18.06.2022. 6 | // Copyright © 2022 Bogdan Chornobryvets. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public final class UIWavePageControl: UIStackView { 12 | 13 | private var heightConstraint: NSLayoutConstraint! 14 | public var defaultButtonHeight: CGFloat 15 | public var defaultSpacing: CGFloat 16 | public var minSpacing: CGFloat 17 | public var maxNavigationWidth: CGFloat 18 | 19 | private var delegate: AnyWavePageControlDelegate? 20 | private let defaultDelegate = DefaultWavePageViewDelegate() 21 | private let defaultAnyDelagate: AnyWavePageControlDelegate 22 | private var currentDelegate: AnyWavePageControlDelegate { 23 | delegate ?? defaultAnyDelagate 24 | } 25 | 26 | public init(maxNavigationWidth: CGFloat = 200, defaultButtonHeight: CGFloat = 16, defaultSpacing: CGFloat = 16, minSpacing: CGFloat = 4) { 27 | defaultAnyDelagate = AnyWavePageControlDelegate(from: defaultDelegate) 28 | self.maxNavigationWidth = maxNavigationWidth 29 | self.defaultButtonHeight = defaultButtonHeight 30 | self.defaultSpacing = defaultSpacing 31 | self.minSpacing = minSpacing 32 | 33 | super.init(frame: .zero) 34 | heightConstraint = heightAnchor.constraint(equalToConstant: defaultButtonHeight) 35 | spacing = defaultSpacing 36 | alignment = .center 37 | distribution = .equalSpacing 38 | contentMode = .scaleToFill 39 | 40 | let panGesture = UIPanGestureRecognizer(target: self, action: #selector(pagesNavigationDidScroll(recognizer:))) 41 | self.addGestureRecognizer(panGesture) 42 | let tapGesture = UITapGestureRecognizer(target: self, action: #selector(pagesNavigationDidTap(recognizer:))) 43 | self.addGestureRecognizer(tapGesture) 44 | } 45 | 46 | public func setDelegate(_ delegate: Delegate?) where Delegate.ID == ID { 47 | if let delegate = delegate { 48 | self.delegate = AnyWavePageControlDelegate(from: delegate) 49 | } else { 50 | self.delegate = nil 51 | } 52 | } 53 | 54 | public var pageIDs = [ID]() { 55 | didSet { 56 | guard pageIDs != oldValue, superview != nil else { return } 57 | buildButtons() 58 | } 59 | } 60 | 61 | public var currentPage: ID? { 62 | didSet { 63 | guard currentPage != oldValue, superview != nil else { return } 64 | updateCurrentPage() 65 | } 66 | } 67 | 68 | private func buildButtons() { 69 | let initialSequence: [ID?] = allPages.map { $0.isRemoved ? nil : $0.getID() } 70 | var targetSequence: [ID?] = pageIDs 71 | for item in initialSequence.enumerated() where item.element == nil { 72 | let offset = item.offset < targetSequence.count ? item.offset : targetSequence.count 73 | targetSequence.insert(nil, at: offset) 74 | } 75 | 76 | let reshuffler = Reshuffler(initialSequence: initialSequence) 77 | let strategy = reshuffler.shuffleStrategy(for: targetSequence) 78 | 79 | var newButtons: [WavePageButtonView] = [] 80 | 81 | for operation in strategy { 82 | switch operation { 83 | case let .reordering(map): 84 | for (from, to) in map { 85 | self.movePage(from: from, to: to) 86 | } 87 | //Animate items reordering 88 | UIConstants.shortAnimation { 89 | self.setNeedsLayout() 90 | self.layoutIfNeeded() 91 | } 92 | case let .insertion(items): 93 | for (offset, element) in items { 94 | if let element = element { 95 | newButtons.append(addPage(id: element, at: offset)) 96 | } 97 | } 98 | //Layout still hidden items to the correct position in UIStackView 99 | self.setNeedsLayout() 100 | self.layoutIfNeeded() 101 | case let .deletion(reversedIndices): 102 | for index in reversedIndices { 103 | deletePage(at: index) 104 | } 105 | UIConstants.shortAnimation { 106 | self.setNeedsLayout() 107 | self.layoutIfNeeded() 108 | } 109 | } 110 | } 111 | 112 | newButtons.forEach { 113 | $0.isHidden = false 114 | } 115 | //Will animate later 116 | updateCurrentPage() 117 | } 118 | 119 | private func updateCurrentPage() { 120 | for button in pages { 121 | switch button.getID() == currentPage { 122 | case true: 123 | button.isActive = true 124 | bringSubviewToFront(button) 125 | case false: 126 | button.isActive = false 127 | } 128 | } 129 | updateLayout() 130 | } 131 | 132 | public func updateLayout() { 133 | heightConstraint.constant = defaultButtonHeight 134 | let pagesCount = pages.count 135 | var targetSpacing = ( maxNavigationWidth - defaultButtonHeight * CGFloat(pagesCount) ) / CGFloat( pagesCount - 1 ) 136 | let dif = (defaultSpacing - targetSpacing) * 0.67 137 | targetSpacing = defaultSpacing - dif 138 | targetSpacing = targetSpacing > minSpacing ? targetSpacing : minSpacing 139 | switch targetSpacing { 140 | case let spacing where spacing >= defaultSpacing: 141 | self.spacing = defaultSpacing 142 | for button in pages { button.height = defaultButtonHeight } //Set default buttons height 143 | default: 144 | spacing = targetSpacing 145 | switch pages.firstIndex(where: { $0.isActive }) { 146 | case let.some(activeIndex): 147 | rebuildButtonsSizes(with: activeIndex) 148 | case .none: 149 | distributeBuuttonsEvenly() 150 | } 151 | } 152 | UIConstants.shortAnimation { 153 | self.setNeedsLayout() 154 | self.layoutIfNeeded() 155 | } 156 | } 157 | 158 | private func rebuildButtonsSizes(with activeIndex: Int) { 159 | let halfWaveCount = 5 160 | let pagesCount = pages.count 161 | let maxWidth = maxNavigationWidth - spacing * CGFloat( pagesCount - 1 ) 162 | let angleSegment = CGFloat.pi / CGFloat( halfWaveCount - 1 ) 163 | var alphaFactors: [CGFloat] = [] 164 | for index in 0.. WavePageButtonView { 215 | let button = currentDelegate.createCustomPageView(for: id) 216 | button.setup(id: id, withHeight: 0) 217 | self.insertArrangedSubview(button, at: index) 218 | return button 219 | } 220 | 221 | private func deletePage(at index: Int) { 222 | let page = allPages[index] 223 | page.isRemoved = true 224 | UIConstants.shortAnimation { 225 | page.transform = CGAffineTransform(scaleX: 0.05, y: 0.05) 226 | page.isHidden = true 227 | } completion: { _ in 228 | page.removeFromSuperview() 229 | } 230 | } 231 | 232 | private func deletePage(withId id: ID) { 233 | guard let page = pages.first(where: { $0.getID() == id }) else { return } 234 | page.isRemoved = true 235 | UIConstants.shortAnimation { 236 | page.transform = CGAffineTransform(scaleX: 0.05, y: 0.05) 237 | page.isHidden = true 238 | } completion: { _ in 239 | page.removeFromSuperview() 240 | } 241 | } 242 | 243 | public override func didMoveToSuperview() { 244 | translatesAutoresizingMaskIntoConstraints = false 245 | heightConstraint.constant = defaultButtonHeight 246 | heightConstraint.isActive = true 247 | UIView.performWithoutAnimation { 248 | spacing = defaultSpacing 249 | buildButtons() 250 | setNeedsLayout() 251 | layoutIfNeeded() 252 | } 253 | } 254 | 255 | public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { 256 | return self.bounds.insetBy(dx: -bounds.width, dy: -bounds.height).contains(point) 257 | } 258 | 259 | @objc func pagesNavigationDidScroll(recognizer: UIPanGestureRecognizer) { 260 | let isGestureCompleted = ![.began, .changed].contains(recognizer.state) 261 | 262 | let count = pages.count 263 | let location = recognizer.location(in: self).x 264 | var index = Int( location / ( self.bounds.width / CGFloat(count) ) ) 265 | index = index < 0 ? 0 : ( index >= count ? count - 1 : index ) 266 | if let id = pageIDs[optional: index] { 267 | currentDelegate.didSwipeScroll(self, toPageWithId: id, isGestureCompleted: isGestureCompleted) 268 | } 269 | } 270 | 271 | @objc func pagesNavigationDidTap(recognizer: UIPanGestureRecognizer) { 272 | let midLocation = { (view: UIView) -> CGFloat in recognizer.location(in: view).x - view.frame.width / 2 } 273 | let selectedPage = pages.min { midLocation($0).magnitude < midLocation($1).magnitude } 274 | if let selectedID: ID = selectedPage?.getID() { 275 | currentDelegate.didTap(self, onPageWithId: selectedID) 276 | } 277 | } 278 | 279 | @available(*, unavailable) 280 | required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } 281 | } 282 | 283 | fileprivate final class DefaultWavePageViewDelegate: WavePageControlDelegate { 284 | func didSwipeScroll(_ wavePageControl: UIWavePageControl, toPageWithId id: ID, isGestureCompleted: Bool) { 285 | wavePageControl.currentPage = id 286 | } 287 | } 288 | 289 | open class WavePageButtonView: UIView { 290 | private var heightConstraint: NSLayoutConstraint! 291 | private var id: Any! 292 | fileprivate var isRemoved: Bool = false 293 | 294 | private override init(frame: CGRect) { 295 | super.init(frame: frame) 296 | translatesAutoresizingMaskIntoConstraints = false 297 | isHidden = true 298 | alpha = 0 299 | } 300 | 301 | public init() { 302 | super.init(frame: .zero) 303 | translatesAutoresizingMaskIntoConstraints = false 304 | isHidden = true 305 | alpha = 0 306 | } 307 | 308 | fileprivate func setup(id: ID, withHeight height: CGFloat) { 309 | self.id = id 310 | heightConstraint = heightAnchor.constraint(equalToConstant: height) 311 | } 312 | 313 | fileprivate func getID() -> ID { 314 | return id as! ID 315 | } 316 | 317 | fileprivate var height: CGFloat = 0 { 318 | didSet { 319 | UIConstants.shortAnimation { [self] in 320 | heightConstraint.constant = height 321 | didChangeHeight(to: height) 322 | layoutIfNeeded() 323 | } 324 | } 325 | } 326 | 327 | open func didChangeHeight(to height: CGFloat) { 328 | 329 | } 330 | 331 | open func didChangeState(_ state: WavePageButtonState) { 332 | 333 | } 334 | 335 | public override var isHidden: Bool { 336 | didSet { 337 | switch isHidden { 338 | case false: 339 | alpha = 1 340 | updateState() 341 | case true: 342 | alpha = 0 343 | } 344 | } 345 | } 346 | 347 | fileprivate var isActive: Bool! = nil { 348 | didSet { 349 | guard let isActive = isActive, isActive != oldValue else { return } 350 | UIConstants.shortAnimation { [weak self] in 351 | self?.updateState() 352 | } 353 | } 354 | } 355 | 356 | private func updateState() { 357 | guard let isActive = isActive else { return } 358 | let state: WavePageButtonState = isActive ? .active : .default 359 | didChangeState(state) 360 | } 361 | 362 | open override func didMoveToSuperview() { 363 | NSLayoutConstraint.activate([ 364 | heightConstraint, 365 | widthAnchor.constraint(equalTo: heightAnchor) 366 | ]) 367 | isActive = false 368 | } 369 | 370 | public enum WavePageButtonState { 371 | case `default`, active 372 | } 373 | 374 | @available(*, unavailable) 375 | required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } 376 | } 377 | 378 | public final class DefaultPageButtonView: WavePageButtonView { 379 | private let accentColor: UIColor 380 | private let defaultColor: UIColor 381 | 382 | public init(accentColor: UIColor = .yellow, defaultColor: UIColor = .white.withAlphaComponent(0.5), dotBorderColor: UIColor = .black, borderWidth: CGFloat = 1) { 383 | self.accentColor = accentColor 384 | self.defaultColor = defaultColor 385 | 386 | super.init() 387 | layer.borderColor = dotBorderColor.cgColor 388 | layer.borderWidth = borderWidth 389 | } 390 | 391 | public override func didChangeHeight(to height: CGFloat) { 392 | layer.cornerRadius = height / 2 393 | } 394 | 395 | public override func didChangeState(_ state: WavePageButtonState) { 396 | switch state { 397 | case .default: 398 | self.backgroundColor = defaultColor 399 | alpha = 0.5 400 | case .active: 401 | self.backgroundColor = accentColor 402 | alpha = 1 403 | } 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /Tests/WavePageControlTests/ReshufflerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import WavePageControl 3 | 4 | final class ReshufflerTests: XCTestCase { 5 | 6 | func testReshuffler() throws { 7 | var optionalInitial: [String?] = randomChars(length: 1200) 8 | var optionaDesired: [String?] = randomChars(length: 800) 9 | let nilInsertions = (0.. [String] { 75 | let allowedChars = "abcdefghijklmnopqrstuvwxyz" 76 | return (0..(maxNavigationWidth: 300, 15 | defaultButtonHeight: 16, 16 | defaultSpacing: 16, 17 | minSpacing: 2) 18 | 19 | //when 20 | var strongCustomPageProvider: TestCustomWavePageViewProvider? = TestCustomWavePageViewProvider() 21 | weak var weakCustomPageProvider: TestCustomWavePageViewProvider? = strongCustomPageProvider 22 | 23 | //then 24 | pageControl.setDelegate(weakCustomPageProvider) 25 | XCTAssertNotNil(weakCustomPageProvider, "Error: UIWavePageControl did not save CustomWavePageViewProvider reference") 26 | strongCustomPageProvider = nil 27 | XCTAssertNil(weakCustomPageProvider, "Error: UIWavePageControl holds strong reference to the CustomWavePageViewProvider") 28 | } 29 | 30 | private class TestCustomWavePageViewProvider: WavePageControlDelegate { 31 | func createCustomPageView(for id: String) -> WavePageButtonView { 32 | DefaultPageButtonView() 33 | } 34 | func didSwipeScroll(_ wavePageControl: UIWavePageControl, toPageWithId id: String, isGestureCompleted: Bool) { 35 | 36 | } 37 | func didTap(_ wavePageControl: UIWavePageControl, onPageWithId id: String) { 38 | 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /WavePageControl.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'WavePageControl' 3 | s.version = '1.0.0' 4 | s.summary = 'WavePageControl is a powerful & elegant way to organise pages indicators or thumbnails.' 5 | 6 | s.description = <<-DESC 7 | WavePageControl is a Page Control that can organise and show all pages on a single screen. 8 | DESC 9 | 10 | s.homepage = 'https://github.com/mrbodich/WavePageControl' 11 | s.license = { :type => 'MIT', :file => 'LICENSE' } 12 | s.author = { 'Bogdan Chornobryvets' => 'bogdan.chornobryvets@gmail.com' } 13 | s.source = { :git => 'https://github.com/mrbodich/WavePageControl.git', :tag => s.version.to_s } 14 | 15 | s.ios.deployment_target = '13.0' 16 | s.swift_version = '5.0' 17 | s.source_files = 'Sources/WavePageControl/**/*' 18 | end 19 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 55; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | E677A5F8285F05B900D1ED48 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E677A5F7285F05B900D1ED48 /* MainViewController.swift */; }; 11 | E6855D09285F274700DF64C4 /* WavePageControl in Frameworks */ = {isa = PBXBuildFile; productRef = E6855D08285F274700DF64C4 /* WavePageControl */; }; 12 | E6C946CB28B679A300F0EE93 /* CustomPageControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C946CA28B679A300F0EE93 /* CustomPageControl.swift */; }; 13 | E6C946CD28B6806500F0EE93 /* SimplePageControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C946CC28B6806500F0EE93 /* SimplePageControl.swift */; }; 14 | E6C946CF28B6809E00F0EE93 /* PageControlShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C946CE28B6809E00F0EE93 /* PageControlShowcase.swift */; }; 15 | E6C946D228B6839A00F0EE93 /* DefaultPageControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C946D128B6839A00F0EE93 /* DefaultPageControl.swift */; }; 16 | E6D6AF8C285E013E006894EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D6AF8B285E013E006894EE /* AppDelegate.swift */; }; 17 | E6D6AF8E285E013E006894EE /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D6AF8D285E013E006894EE /* SceneDelegate.swift */; }; 18 | E6D6AF95285E013F006894EE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E6D6AF94285E013F006894EE /* Assets.xcassets */; }; 19 | E6D6AF98285E013F006894EE /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E6D6AF96285E013F006894EE /* LaunchScreen.storyboard */; }; 20 | /* End PBXBuildFile section */ 21 | 22 | /* Begin PBXFileReference section */ 23 | E677A5F7285F05B900D1ED48 /* MainViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; 24 | E6855D03285F272500DF64C4 /* WavePageControl */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = WavePageControl; path = ..; sourceTree = ""; }; 25 | E6C946CA28B679A300F0EE93 /* CustomPageControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPageControl.swift; sourceTree = ""; }; 26 | E6C946CC28B6806500F0EE93 /* SimplePageControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplePageControl.swift; sourceTree = ""; }; 27 | E6C946CE28B6809E00F0EE93 /* PageControlShowcase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageControlShowcase.swift; sourceTree = ""; }; 28 | E6C946D128B6839A00F0EE93 /* DefaultPageControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultPageControl.swift; sourceTree = ""; }; 29 | E6D6AF88285E013E006894EE /* WavePageControlDemo-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WavePageControlDemo-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 30 | E6D6AF8B285E013E006894EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 31 | E6D6AF8D285E013E006894EE /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 32 | E6D6AF94285E013F006894EE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 33 | E6D6AF97285E013F006894EE /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 34 | E6D6AF99285E013F006894EE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 35 | /* End PBXFileReference section */ 36 | 37 | /* Begin PBXFrameworksBuildPhase section */ 38 | E6D6AF85285E013E006894EE /* Frameworks */ = { 39 | isa = PBXFrameworksBuildPhase; 40 | buildActionMask = 2147483647; 41 | files = ( 42 | E6855D09285F274700DF64C4 /* WavePageControl in Frameworks */, 43 | ); 44 | runOnlyForDeploymentPostprocessing = 0; 45 | }; 46 | /* End PBXFrameworksBuildPhase section */ 47 | 48 | /* Begin PBXGroup section */ 49 | E6855D02285F272500DF64C4 /* Packages */ = { 50 | isa = PBXGroup; 51 | children = ( 52 | E6855D03285F272500DF64C4 /* WavePageControl */, 53 | ); 54 | name = Packages; 55 | sourceTree = ""; 56 | }; 57 | E6855D07285F274700DF64C4 /* Frameworks */ = { 58 | isa = PBXGroup; 59 | children = ( 60 | ); 61 | name = Frameworks; 62 | sourceTree = ""; 63 | }; 64 | E6C946D028B680A700F0EE93 /* Examples */ = { 65 | isa = PBXGroup; 66 | children = ( 67 | E6C946CE28B6809E00F0EE93 /* PageControlShowcase.swift */, 68 | E6C946D128B6839A00F0EE93 /* DefaultPageControl.swift */, 69 | E6C946CC28B6806500F0EE93 /* SimplePageControl.swift */, 70 | E6C946CA28B679A300F0EE93 /* CustomPageControl.swift */, 71 | ); 72 | path = Examples; 73 | sourceTree = ""; 74 | }; 75 | E6D6AF7F285E013E006894EE = { 76 | isa = PBXGroup; 77 | children = ( 78 | E6855D02285F272500DF64C4 /* Packages */, 79 | E6D6AF8A285E013E006894EE /* WavePageControlDemo-iOS */, 80 | E6D6AF89285E013E006894EE /* Products */, 81 | E6855D07285F274700DF64C4 /* Frameworks */, 82 | ); 83 | sourceTree = ""; 84 | }; 85 | E6D6AF89285E013E006894EE /* Products */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | E6D6AF88285E013E006894EE /* WavePageControlDemo-iOS.app */, 89 | ); 90 | name = Products; 91 | sourceTree = ""; 92 | }; 93 | E6D6AF8A285E013E006894EE /* WavePageControlDemo-iOS */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | E6D6AF8B285E013E006894EE /* AppDelegate.swift */, 97 | E6D6AF8D285E013E006894EE /* SceneDelegate.swift */, 98 | E677A5F7285F05B900D1ED48 /* MainViewController.swift */, 99 | E6C946D028B680A700F0EE93 /* Examples */, 100 | E6D6AF94285E013F006894EE /* Assets.xcassets */, 101 | E6D6AF96285E013F006894EE /* LaunchScreen.storyboard */, 102 | E6D6AF99285E013F006894EE /* Info.plist */, 103 | ); 104 | path = "WavePageControlDemo-iOS"; 105 | sourceTree = ""; 106 | }; 107 | /* End PBXGroup section */ 108 | 109 | /* Begin PBXNativeTarget section */ 110 | E6D6AF87285E013E006894EE /* WavePageControlDemo-iOS */ = { 111 | isa = PBXNativeTarget; 112 | buildConfigurationList = E6D6AF9C285E013F006894EE /* Build configuration list for PBXNativeTarget "WavePageControlDemo-iOS" */; 113 | buildPhases = ( 114 | E6D6AF84285E013E006894EE /* Sources */, 115 | E6D6AF85285E013E006894EE /* Frameworks */, 116 | E6D6AF86285E013E006894EE /* Resources */, 117 | ); 118 | buildRules = ( 119 | ); 120 | dependencies = ( 121 | ); 122 | name = "WavePageControlDemo-iOS"; 123 | packageProductDependencies = ( 124 | E6855D08285F274700DF64C4 /* WavePageControl */, 125 | ); 126 | productName = "WavePageControlDemo-iOS"; 127 | productReference = E6D6AF88285E013E006894EE /* WavePageControlDemo-iOS.app */; 128 | productType = "com.apple.product-type.application"; 129 | }; 130 | /* End PBXNativeTarget section */ 131 | 132 | /* Begin PBXProject section */ 133 | E6D6AF80285E013E006894EE /* Project object */ = { 134 | isa = PBXProject; 135 | attributes = { 136 | BuildIndependentTargetsInParallel = 1; 137 | LastSwiftUpdateCheck = 1340; 138 | LastUpgradeCheck = 1340; 139 | TargetAttributes = { 140 | E6D6AF87285E013E006894EE = { 141 | CreatedOnToolsVersion = 13.4; 142 | }; 143 | }; 144 | }; 145 | buildConfigurationList = E6D6AF83285E013E006894EE /* Build configuration list for PBXProject "WavePageControlDemo-iOS" */; 146 | compatibilityVersion = "Xcode 13.0"; 147 | developmentRegion = en; 148 | hasScannedForEncodings = 0; 149 | knownRegions = ( 150 | en, 151 | Base, 152 | ); 153 | mainGroup = E6D6AF7F285E013E006894EE; 154 | productRefGroup = E6D6AF89285E013E006894EE /* Products */; 155 | projectDirPath = ""; 156 | projectRoot = ""; 157 | targets = ( 158 | E6D6AF87285E013E006894EE /* WavePageControlDemo-iOS */, 159 | ); 160 | }; 161 | /* End PBXProject section */ 162 | 163 | /* Begin PBXResourcesBuildPhase section */ 164 | E6D6AF86285E013E006894EE /* Resources */ = { 165 | isa = PBXResourcesBuildPhase; 166 | buildActionMask = 2147483647; 167 | files = ( 168 | E6D6AF98285E013F006894EE /* LaunchScreen.storyboard in Resources */, 169 | E6D6AF95285E013F006894EE /* Assets.xcassets in Resources */, 170 | ); 171 | runOnlyForDeploymentPostprocessing = 0; 172 | }; 173 | /* End PBXResourcesBuildPhase section */ 174 | 175 | /* Begin PBXSourcesBuildPhase section */ 176 | E6D6AF84285E013E006894EE /* Sources */ = { 177 | isa = PBXSourcesBuildPhase; 178 | buildActionMask = 2147483647; 179 | files = ( 180 | E6D6AF8C285E013E006894EE /* AppDelegate.swift in Sources */, 181 | E6D6AF8E285E013E006894EE /* SceneDelegate.swift in Sources */, 182 | E6C946CB28B679A300F0EE93 /* CustomPageControl.swift in Sources */, 183 | E6C946D228B6839A00F0EE93 /* DefaultPageControl.swift in Sources */, 184 | E6C946CD28B6806500F0EE93 /* SimplePageControl.swift in Sources */, 185 | E677A5F8285F05B900D1ED48 /* MainViewController.swift in Sources */, 186 | E6C946CF28B6809E00F0EE93 /* PageControlShowcase.swift in Sources */, 187 | ); 188 | runOnlyForDeploymentPostprocessing = 0; 189 | }; 190 | /* End PBXSourcesBuildPhase section */ 191 | 192 | /* Begin PBXVariantGroup section */ 193 | E6D6AF96285E013F006894EE /* LaunchScreen.storyboard */ = { 194 | isa = PBXVariantGroup; 195 | children = ( 196 | E6D6AF97285E013F006894EE /* Base */, 197 | ); 198 | name = LaunchScreen.storyboard; 199 | sourceTree = ""; 200 | }; 201 | /* End PBXVariantGroup section */ 202 | 203 | /* Begin XCBuildConfiguration section */ 204 | E6D6AF9A285E013F006894EE /* Debug */ = { 205 | isa = XCBuildConfiguration; 206 | buildSettings = { 207 | ALWAYS_SEARCH_USER_PATHS = NO; 208 | CLANG_ANALYZER_NONNULL = YES; 209 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 210 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 211 | CLANG_ENABLE_MODULES = YES; 212 | CLANG_ENABLE_OBJC_ARC = YES; 213 | CLANG_ENABLE_OBJC_WEAK = YES; 214 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 215 | CLANG_WARN_BOOL_CONVERSION = YES; 216 | CLANG_WARN_COMMA = YES; 217 | CLANG_WARN_CONSTANT_CONVERSION = YES; 218 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 219 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 220 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 221 | CLANG_WARN_EMPTY_BODY = YES; 222 | CLANG_WARN_ENUM_CONVERSION = YES; 223 | CLANG_WARN_INFINITE_RECURSION = YES; 224 | CLANG_WARN_INT_CONVERSION = YES; 225 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 226 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 227 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 228 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 229 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 230 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 231 | CLANG_WARN_STRICT_PROTOTYPES = YES; 232 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 233 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 234 | CLANG_WARN_UNREACHABLE_CODE = YES; 235 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 236 | COPY_PHASE_STRIP = NO; 237 | DEBUG_INFORMATION_FORMAT = dwarf; 238 | ENABLE_STRICT_OBJC_MSGSEND = YES; 239 | ENABLE_TESTABILITY = YES; 240 | GCC_C_LANGUAGE_STANDARD = gnu11; 241 | GCC_DYNAMIC_NO_PIC = NO; 242 | GCC_NO_COMMON_BLOCKS = YES; 243 | GCC_OPTIMIZATION_LEVEL = 0; 244 | GCC_PREPROCESSOR_DEFINITIONS = ( 245 | "DEBUG=1", 246 | "$(inherited)", 247 | ); 248 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 249 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 250 | GCC_WARN_UNDECLARED_SELECTOR = YES; 251 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 252 | GCC_WARN_UNUSED_FUNCTION = YES; 253 | GCC_WARN_UNUSED_VARIABLE = YES; 254 | IPHONEOS_DEPLOYMENT_TARGET = 15.5; 255 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 256 | MTL_FAST_MATH = YES; 257 | ONLY_ACTIVE_ARCH = YES; 258 | SDKROOT = iphoneos; 259 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 260 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 261 | }; 262 | name = Debug; 263 | }; 264 | E6D6AF9B285E013F006894EE /* Release */ = { 265 | isa = XCBuildConfiguration; 266 | buildSettings = { 267 | ALWAYS_SEARCH_USER_PATHS = NO; 268 | CLANG_ANALYZER_NONNULL = YES; 269 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 270 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; 271 | CLANG_ENABLE_MODULES = YES; 272 | CLANG_ENABLE_OBJC_ARC = YES; 273 | CLANG_ENABLE_OBJC_WEAK = YES; 274 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 275 | CLANG_WARN_BOOL_CONVERSION = YES; 276 | CLANG_WARN_COMMA = YES; 277 | CLANG_WARN_CONSTANT_CONVERSION = YES; 278 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 279 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 280 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 281 | CLANG_WARN_EMPTY_BODY = YES; 282 | CLANG_WARN_ENUM_CONVERSION = YES; 283 | CLANG_WARN_INFINITE_RECURSION = YES; 284 | CLANG_WARN_INT_CONVERSION = YES; 285 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 286 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 287 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 288 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 289 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 290 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 291 | CLANG_WARN_STRICT_PROTOTYPES = YES; 292 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 293 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 294 | CLANG_WARN_UNREACHABLE_CODE = YES; 295 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 296 | COPY_PHASE_STRIP = NO; 297 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 298 | ENABLE_NS_ASSERTIONS = NO; 299 | ENABLE_STRICT_OBJC_MSGSEND = YES; 300 | GCC_C_LANGUAGE_STANDARD = gnu11; 301 | GCC_NO_COMMON_BLOCKS = YES; 302 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 303 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 304 | GCC_WARN_UNDECLARED_SELECTOR = YES; 305 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 306 | GCC_WARN_UNUSED_FUNCTION = YES; 307 | GCC_WARN_UNUSED_VARIABLE = YES; 308 | IPHONEOS_DEPLOYMENT_TARGET = 15.5; 309 | MTL_ENABLE_DEBUG_INFO = NO; 310 | MTL_FAST_MATH = YES; 311 | SDKROOT = iphoneos; 312 | SWIFT_COMPILATION_MODE = wholemodule; 313 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 314 | VALIDATE_PRODUCT = YES; 315 | }; 316 | name = Release; 317 | }; 318 | E6D6AF9D285E013F006894EE /* Debug */ = { 319 | isa = XCBuildConfiguration; 320 | buildSettings = { 321 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 322 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 323 | CODE_SIGN_STYLE = Automatic; 324 | CURRENT_PROJECT_VERSION = 1; 325 | DEVELOPMENT_TEAM = ""; 326 | GENERATE_INFOPLIST_FILE = YES; 327 | INFOPLIST_FILE = "WavePageControlDemo-iOS/Info.plist"; 328 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 329 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 330 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 331 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 332 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 333 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 334 | LD_RUNPATH_SEARCH_PATHS = ( 335 | "$(inherited)", 336 | "@executable_path/Frameworks", 337 | ); 338 | MARKETING_VERSION = 1.0; 339 | PRODUCT_BUNDLE_IDENTIFIER = "bogdan.chornobryvets.WavePageControlDemo-iOS"; 340 | PRODUCT_NAME = "$(TARGET_NAME)"; 341 | SWIFT_EMIT_LOC_STRINGS = YES; 342 | SWIFT_VERSION = 5.0; 343 | TARGETED_DEVICE_FAMILY = 1; 344 | }; 345 | name = Debug; 346 | }; 347 | E6D6AF9E285E013F006894EE /* Release */ = { 348 | isa = XCBuildConfiguration; 349 | buildSettings = { 350 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 351 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 352 | CODE_SIGN_STYLE = Automatic; 353 | CURRENT_PROJECT_VERSION = 1; 354 | DEVELOPMENT_TEAM = ""; 355 | GENERATE_INFOPLIST_FILE = YES; 356 | INFOPLIST_FILE = "WavePageControlDemo-iOS/Info.plist"; 357 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 358 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 359 | INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 360 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 361 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 362 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 363 | LD_RUNPATH_SEARCH_PATHS = ( 364 | "$(inherited)", 365 | "@executable_path/Frameworks", 366 | ); 367 | MARKETING_VERSION = 1.0; 368 | PRODUCT_BUNDLE_IDENTIFIER = "bogdan.chornobryvets.WavePageControlDemo-iOS"; 369 | PRODUCT_NAME = "$(TARGET_NAME)"; 370 | SWIFT_EMIT_LOC_STRINGS = YES; 371 | SWIFT_VERSION = 5.0; 372 | TARGETED_DEVICE_FAMILY = 1; 373 | }; 374 | name = Release; 375 | }; 376 | /* End XCBuildConfiguration section */ 377 | 378 | /* Begin XCConfigurationList section */ 379 | E6D6AF83285E013E006894EE /* Build configuration list for PBXProject "WavePageControlDemo-iOS" */ = { 380 | isa = XCConfigurationList; 381 | buildConfigurations = ( 382 | E6D6AF9A285E013F006894EE /* Debug */, 383 | E6D6AF9B285E013F006894EE /* Release */, 384 | ); 385 | defaultConfigurationIsVisible = 0; 386 | defaultConfigurationName = Release; 387 | }; 388 | E6D6AF9C285E013F006894EE /* Build configuration list for PBXNativeTarget "WavePageControlDemo-iOS" */ = { 389 | isa = XCConfigurationList; 390 | buildConfigurations = ( 391 | E6D6AF9D285E013F006894EE /* Debug */, 392 | E6D6AF9E285E013F006894EE /* Release */, 393 | ); 394 | defaultConfigurationIsVisible = 0; 395 | defaultConfigurationName = Release; 396 | }; 397 | /* End XCConfigurationList section */ 398 | 399 | /* Begin XCSwiftPackageProductDependency section */ 400 | E6855D08285F274700DF64C4 /* WavePageControl */ = { 401 | isa = XCSwiftPackageProductDependency; 402 | productName = WavePageControl; 403 | }; 404 | /* End XCSwiftPackageProductDependency section */ 405 | }; 406 | rootObject = E6D6AF80285E013E006894EE /* Project object */; 407 | } 408 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS.xcodeproj/xcshareddata/xcschemes/WavePageControlDemo-iOS.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // WavePageControlDemo-iOS 4 | // 5 | // Created by Bogdan Chornobryvets on 18.06.2022. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | // Override point for customization after application launch. 17 | return true 18 | } 19 | 20 | // MARK: UISceneSession Lifecycle 21 | 22 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 23 | // Called when a new scene session is being created. 24 | // Use this method to select a configuration to create the new scene with. 25 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 26 | } 27 | 28 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 29 | // Called when the user discards a scene session. 30 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 31 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 32 | } 33 | 34 | 35 | } 36 | 37 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Assets.xcassets/Page Dot Border.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.600", 9 | "green" : "0.600", 10 | "red" : "0.600" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.300", 27 | "green" : "0.237", 28 | "red" : "0.162" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Assets.xcassets/Page Dot.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.500", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.500", 26 | "blue" : "0.600", 27 | "green" : "0.476", 28 | "red" : "0.325" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/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 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Examples/CustomPageControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomPageControl.swift 3 | // WavePageControlDemo-iOS 4 | // 5 | // Created by Bogdan Chornobryvets on 24.08.2022. 6 | // 7 | 8 | import SwiftUI 9 | import WavePageControl 10 | 11 | final class CustomPageControl: PageControlShowcase { 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | view.backgroundColor = .black 15 | } 16 | 17 | override func setupPageControl(_ pageControl: UIWavePageControl) { 18 | pageControl.setDelegate(self) 19 | } 20 | 21 | //WavePageControl setup example 22 | override func updatePageControl(_ pageControl: UIWavePageControl, screenWidth: CGFloat) { 23 | //MARK: You can change UIWavePageControl parameters 24 | let screenWidth = screenWidth * 0.8 25 | let itemHeight = min(screenWidth / 10, 50) 26 | 27 | pageControl.maxNavigationWidth = screenWidth 28 | pageControl.defaultButtonHeight = itemHeight 29 | pageControl.defaultSpacing = itemHeight 30 | pageControl.minSpacing = screenWidth / 140 31 | pageControl.updateLayout() //Will animate to the new layout 32 | } 33 | } 34 | 35 | extension CustomPageControl: WavePageControlDelegate { 36 | func createCustomPageView(for id: String) -> WavePageButtonView { 37 | 38 | //Default implementation of this delegate method is DefaultPageButtonView() 39 | 40 | //Here we are creating fully custom view for the page. Must inherit WavePageButtonView. 41 | CustomPageButtonView(with: id) 42 | } 43 | 44 | func didSwipeScroll(_ wavePageControl: UIWavePageControl, toPageWithId id: String, isGestureCompleted: Bool) { 45 | 46 | //You can simply set current page. 47 | //Next row is a default implementation of this delegate method. 48 | //wavePageControl.currentPage = id 49 | 50 | if currentPage != id { 51 | currentPage = id 52 | vibrate() 53 | } 54 | //Animate Page Control size 55 | switch isGestureCompleted { 56 | case false: 57 | if wavePageControl.transform == .identity { 58 | UIView.animate(withDuration: 0.3) { 59 | wavePageControl.transform = CGAffineTransform(scaleX: 1.15, y: 1.15) 60 | } 61 | } 62 | case true: 63 | UIView.animate(withDuration: 0.3) { 64 | wavePageControl.transform = .identity 65 | } 66 | } 67 | } 68 | 69 | func didTap(_ wavePageControl: UIWavePageControl, onPageWithId id: String) { 70 | 71 | //You can simply set current page. 72 | //Next row is a default implementation of this delegate method. 73 | //wavePageControl.currentPage = id 74 | 75 | if currentPage != id { 76 | currentPage = id 77 | vibrate() 78 | } 79 | } 80 | } 81 | 82 | //Custom page view must inherit WavePageButtonView 83 | final class CustomPageButtonView: WavePageButtonView { 84 | private let accentColor: UIColor = .white 85 | private let transparentColor: UIColor = .black 86 | private var currentHeight: CGFloat? = nil 87 | private var isZoomed: Bool = false 88 | 89 | let label = UILabel() 90 | let infoView = UIView() 91 | var offsetConstraint: NSLayoutConstraint! 92 | var widthConstraint: NSLayoutConstraint! 93 | var heightConstraint: NSLayoutConstraint! 94 | 95 | init(with text: String) { 96 | super.init() 97 | infoView.layer.borderColor = accentColor.cgColor 98 | infoView.backgroundColor = accentColor 99 | infoView.layer.borderWidth = 1 100 | infoView.translatesAutoresizingMaskIntoConstraints = false 101 | offsetConstraint = infoView.centerYAnchor.constraint(equalTo: centerYAnchor) 102 | widthConstraint = infoView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1) 103 | heightConstraint = infoView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 1) 104 | 105 | label.translatesAutoresizingMaskIntoConstraints = false 106 | label.text = text.uppercased() 107 | label.font = .systemFont(ofSize: 30, weight: .bold) 108 | label.transform = CGAffineTransform(scaleX: 0.3, y: 0.3) 109 | label.textColor = .red 110 | label.alpha = 0 111 | infoView.addSubview(label) 112 | label.centerXAnchor.constraint(equalTo: infoView.centerXAnchor).isActive = true 113 | label.centerYAnchor.constraint(equalTo: infoView.centerYAnchor).isActive = true 114 | } 115 | 116 | override func didMoveToSuperview() { 117 | super.didMoveToSuperview() 118 | addSubview(infoView) 119 | NSLayoutConstraint.activate([ 120 | infoView.centerXAnchor.constraint(equalTo: centerXAnchor), 121 | offsetConstraint, 122 | widthConstraint, 123 | heightConstraint 124 | ]) 125 | zoomConstraints() 126 | } 127 | 128 | public override func didChangeHeight(to height: CGFloat) { 129 | let newHeight = isZoomed ? height * 1.6 / 2 : height / 4 130 | infoView.layer.cornerRadius = newHeight 131 | } 132 | 133 | public override func didChangeState(_ state: WavePageButtonState) { 134 | switch state { 135 | case .default: 136 | infoView.backgroundColor = transparentColor 137 | label.alpha = 0 138 | label.transform = CGAffineTransform(scaleX: 0.3, y: 0.3) 139 | isZoomed = false 140 | case .active: 141 | infoView.backgroundColor = accentColor 142 | label.alpha = 1 143 | label.transform = CGAffineTransform(scaleX: 1, y: 1) 144 | isZoomed = true 145 | } 146 | zoomConstraints() 147 | } 148 | 149 | private func zoomConstraints() { 150 | offsetConstraint.constant = isZoomed ? -50 : 0 151 | widthConstraint = widthConstraint.setMultiplier(multiplier: isZoomed ? 1.6 : 1) 152 | heightConstraint = heightConstraint.setMultiplier(multiplier: isZoomed ? 2.5 : 1) 153 | } 154 | 155 | } 156 | 157 | fileprivate extension NSLayoutConstraint { 158 | func setMultiplier(multiplier: CGFloat) -> NSLayoutConstraint { 159 | let isActiveState = isActive 160 | isActive = false 161 | let newConstraint = NSLayoutConstraint( 162 | item: firstItem!, 163 | attribute: firstAttribute, 164 | relatedBy: relation, 165 | toItem: secondItem, 166 | attribute: secondAttribute, 167 | multiplier: multiplier, 168 | constant: constant) 169 | 170 | newConstraint.priority = priority 171 | newConstraint.shouldBeArchived = self.shouldBeArchived 172 | newConstraint.identifier = self.identifier 173 | 174 | if isActiveState { 175 | NSLayoutConstraint.activate([newConstraint]) 176 | } 177 | return newConstraint 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Examples/DefaultPageControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultPageControl.swift 3 | // WavePageControlDemo-iOS 4 | // 5 | // Created by Bogdan Chornobryvets on 24.08.2022. 6 | // 7 | 8 | import SwiftUI 9 | import WavePageControl 10 | 11 | final class DefaultPageControl: PageControlShowcase { 12 | //WavePageControl setup example 13 | override func updatePageControl(_ pageControl: UIWavePageControl, screenWidth: CGFloat) { 14 | //MARK: You can change UIWavePageControl parameters 15 | let screenWidth = screenWidth * 0.8 16 | let itemHeight = min(screenWidth / 10, 50) 17 | 18 | pageControl.maxNavigationWidth = screenWidth 19 | pageControl.defaultButtonHeight = itemHeight 20 | pageControl.defaultSpacing = itemHeight 21 | pageControl.minSpacing = screenWidth / 140 22 | pageControl.updateLayout() //Will animate to the new layout 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Examples/PageControlShowcase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PageControlShowcase.swift 3 | // WavePageControlDemo-iOS 4 | // 5 | // Created by Bogdan Chornobryvets on 24.08.2022. 6 | // 7 | 8 | import UIKit 9 | import WavePageControl 10 | import Combine 11 | 12 | class PageControlShowcase: UIViewController { 13 | private let allowedChars: String = "abcdefghijklmnopqrstuvwxyz" 14 | @Published var pageIDs: [String] = [] 15 | @Published var currentPage: String = "" 16 | private let pageControl = UIWavePageControl() 17 | private var bucket = Set() 18 | private let screenWidth = UIScreen.main.bounds.width * 0.8 19 | private let showExtraControls: Bool 20 | 21 | init(showExtraControls: Bool = true) { 22 | self.showExtraControls = showExtraControls 23 | super.init(nibName: nil, bundle: nil) 24 | } 25 | 26 | override func viewDidLoad() { 27 | super.viewDidLoad() 28 | view.backgroundColor = .white 29 | let vStack = UIStackView() 30 | vStack.translatesAutoresizingMaskIntoConstraints = false 31 | vStack.axis = .vertical 32 | vStack.alignment = .center 33 | vStack.spacing = 20 34 | view.addSubview(vStack) 35 | 36 | NSLayoutConstraint.activate([ 37 | vStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), 38 | vStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), 39 | vStack.widthAnchor.constraint(equalTo: view.widthAnchor) 40 | ]) 41 | 42 | let itemsLabel = UILabel() 43 | itemsLabel.translatesAutoresizingMaskIntoConstraints = false 44 | itemsLabel.heightAnchor.constraint(equalToConstant: 30).isActive = true 45 | itemsLabel.alpha = showExtraControls ? 1 : 0 46 | vStack.addArrangedSubview(itemsLabel) 47 | 48 | let separator = UIView() 49 | separator.translatesAutoresizingMaskIntoConstraints = false 50 | separator.heightAnchor.constraint(equalToConstant: 100).isActive = true 51 | vStack.addArrangedSubview(separator) 52 | 53 | vStack.addArrangedSubview(pageControl) 54 | UIView.performWithoutAnimation { 55 | setupPageControl(pageControl) 56 | updatePageControl(pageControl, screenWidth: screenWidth) 57 | pageControl.layoutIfNeeded() 58 | } 59 | 60 | addButtons(to: vStack) 61 | 62 | let startPageIDs = randomChars() 63 | pageIDs = startPageIDs 64 | UIView.performWithoutAnimation { 65 | pageControl.pageIDs = startPageIDs 66 | itemsLabel.attributedText = self.attributedTitle(for: startPageIDs, selectedItem: "") 67 | pageControl.layoutIfNeeded() 68 | itemsLabel.layoutIfNeeded() 69 | } 70 | 71 | $pageIDs 72 | .receive(on: DispatchQueue.main) 73 | .sink { [weak self] newPageIDs in 74 | guard let self = self else { return } 75 | itemsLabel.attributedText = self.attributedTitle(for: newPageIDs, selectedItem: self.pageControl.currentPage) 76 | itemsLabel.layoutIfNeeded() 77 | self.pageControl.pageIDs = newPageIDs 78 | } 79 | .store(in: &bucket) 80 | 81 | $currentPage 82 | .receive(on: DispatchQueue.main) 83 | .sink { [weak self] newCurrentPage in 84 | guard let self = self else { return } 85 | itemsLabel.attributedText = self.attributedTitle(for: self.pageControl.pageIDs, selectedItem: newCurrentPage) 86 | itemsLabel.layoutIfNeeded() 87 | self.pageControl.currentPage = newCurrentPage 88 | } 89 | .store(in: &bucket) 90 | } 91 | 92 | func setupPageControl(_ pageControl: UIWavePageControl) {} 93 | func updatePageControl(_ pageControl: UIWavePageControl, screenWidth: CGFloat) { } 94 | 95 | override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 96 | updatePageControl(pageControl, screenWidth: size.width) 97 | } 98 | 99 | private func addButtons(to stack: UIStackView) { 100 | let buttonsStack = UIStackView() 101 | buttonsStack.translatesAutoresizingMaskIntoConstraints = false 102 | buttonsStack.axis = .horizontal 103 | buttonsStack.spacing = 30 104 | let addButton = getButton(withTitle: "Add", selector: #selector(addPagePressed)) 105 | let removeButton = getButton(withTitle: "Remove", selector: #selector(removePagePressed)) 106 | let customButton = getButton(withTitle: "Custom", selector: #selector(randomisePages)) 107 | let throwActiveButton = getButton(withTitle: "< Move >", selector: #selector(moveActivePage)) 108 | 109 | buttonsStack.addArrangedSubview(addButton) 110 | buttonsStack.addArrangedSubview(removeButton) 111 | buttonsStack.addArrangedSubview(customButton) 112 | if showExtraControls { 113 | buttonsStack.addArrangedSubview(throwActiveButton) 114 | } 115 | stack.addArrangedSubview(buttonsStack) 116 | } 117 | 118 | private func getButton(withTitle title: String, selector: Selector) -> UIButton { 119 | let button = UIButton(type: .system) 120 | button.translatesAutoresizingMaskIntoConstraints = false 121 | button.setTitle(title, for: .normal) 122 | button.titleLabel?.font = .systemFont(ofSize: 18) 123 | button.addTarget(self, action: selector, for: .touchUpInside) 124 | return button 125 | } 126 | 127 | private func attributedTitle(for stringArray: [String], selectedItem: String?) -> NSAttributedString { 128 | let font = UIFont.monospacedSystemFont(ofSize: 20, weight: .bold) 129 | let attributedString = NSMutableAttributedString(string: stringArray.joined()) 130 | attributedString.setAttributes([.font: font, .foregroundColor: UIColor.gray], 131 | range: NSRange(location: 0, length: attributedString.length)) 132 | if let index = stringArray.firstIndex(of: selectedItem ?? "") { 133 | let range = NSRange(location: index,length: 1) 134 | attributedString.setAttributes([.font: font.withSize(25), .foregroundColor: UIColor.systemPink], 135 | range: range) 136 | } 137 | return attributedString 138 | } 139 | 140 | private func randomChars(length: Int? = nil) -> [String] { 141 | let length = length ?? Int.random(in: 5..<20) 142 | let slice = allowedChars.shuffled().map(String.init).prefix(length) 143 | return Array(slice) 144 | } 145 | 146 | private func removePage(at index: Int) { 147 | pageIDs.remove(at: index < pageIDs.count ? index : pageIDs.count - 1) 148 | } 149 | 150 | private func addPage(at index: Int) { 151 | guard let item = Set(allowedChars.map(String.init)) 152 | .subtracting(Set(pageIDs)) 153 | .randomElement() else { return } 154 | pageIDs.insert(item, at: index) 155 | } 156 | 157 | func vibrate() { 158 | let generator = UISelectionFeedbackGenerator() 159 | generator.selectionChanged() 160 | } 161 | 162 | @available(*, unavailable) 163 | required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } 164 | } 165 | 166 | extension PageControlShowcase { 167 | @objc private func addPagePressed() { 168 | vibrate() 169 | let index = pageIDs.count > 0 ? Int.random(in: 0.. 0 else { return } 176 | removePage(at: Int.random(in: 0..= pageIDs.count / 2 ? 1 : 0)) - index 187 | pageIDs.move(fromOffsets: .init(integer: index), toOffset: .init(destinationIndex)) 188 | } 189 | } 190 | 191 | fileprivate extension RandomAccessCollection { 192 | func unique(by id: KeyPath) -> [Element] { 193 | var seen: [ID: Bool] = [:] 194 | return self.filter { 195 | seen.updateValue(true, forKey: $0[keyPath: id]) == nil 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Examples/SimplePageControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimplePageControl.swift 3 | // WavePageControlDemo-iOS 4 | // 5 | // Created by Bogdan Chornobryvets on 24.08.2022. 6 | // 7 | 8 | import SwiftUI 9 | import WavePageControl 10 | 11 | final class SimplePageControl: PageControlShowcase { 12 | override func setupPageControl(_ pageControl: UIWavePageControl) { 13 | pageControl.setDelegate(self) 14 | } 15 | 16 | //WavePageControl setup example 17 | override func updatePageControl(_ pageControl: UIWavePageControl, screenWidth: CGFloat) { 18 | //MARK: You can change UIWavePageControl parameters 19 | let screenWidth = screenWidth * 0.8 20 | let itemHeight = min(screenWidth / 9, 55) 21 | 22 | pageControl.maxNavigationWidth = screenWidth 23 | pageControl.defaultButtonHeight = itemHeight 24 | pageControl.defaultSpacing = itemHeight 25 | pageControl.minSpacing = screenWidth / 140 26 | pageControl.updateLayout() //Will animate to the new layout 27 | } 28 | } 29 | 30 | extension SimplePageControl: WavePageControlDelegate { 31 | func createCustomPageView(for id: String) -> WavePageButtonView { 32 | 33 | //Default implementation of this delegate method is DefaultPageButtonView() 34 | 35 | DefaultPageButtonView(accentColor: .green, 36 | defaultColor: .green.withAlphaComponent(0.2), 37 | dotBorderColor: .gray, 38 | borderWidth: 2) 39 | } 40 | 41 | func didSwipeScroll(_ wavePageControl: UIWavePageControl, toPageWithId id: String, isGestureCompleted: Bool) { 42 | 43 | //You can simply set current page. 44 | //Next row is a default implementation of this delegate method. 45 | //wavePageControl.currentPage = id 46 | 47 | if currentPage != id { 48 | currentPage = id 49 | vibrate() 50 | } 51 | //Animate Page Control size 52 | switch isGestureCompleted { 53 | case false: 54 | if wavePageControl.transform == .identity { 55 | UIView.animate(withDuration: 0.3) { 56 | wavePageControl.transform = CGAffineTransform(scaleX: 1.1, y: 1.1) 57 | } 58 | } 59 | case true: 60 | UIView.animate(withDuration: 0.3) { 61 | wavePageControl.transform = .identity 62 | } 63 | } 64 | } 65 | 66 | func didTap(_ wavePageControl: UIWavePageControl, onPageWithId id: String) { 67 | 68 | //You can simply set current page. 69 | //Next row is a default implementation of this delegate method. 70 | //wavePageControl.currentPage = id 71 | 72 | if currentPage != id { 73 | currentPage = id 74 | vibrate() 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/MainViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // WavePageControlDemo-iOS 4 | // 5 | // Created by Bogdan Chornobryvets on 18.06.2022. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | import Combine 11 | import WavePageControl 12 | 13 | class MainViewController: UINavigationController { 14 | var bucket = Set() 15 | let pageControls: [(description: String, vc: PageControlShowcase)] = [ 16 | ("Default", DefaultPageControl(showExtraControls: false)), 17 | ("Simple", SimplePageControl()), 18 | ("Custom", CustomPageControl()) 19 | ] 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | view.backgroundColor = .white 24 | 25 | let baseVC = UIViewController() 26 | baseVC.view.backgroundColor = .white 27 | pushViewController(baseVC, animated: false) 28 | 29 | let vStack = UIStackView() 30 | vStack.translatesAutoresizingMaskIntoConstraints = false 31 | vStack.axis = .vertical 32 | baseVC.view.addSubview(vStack) 33 | NSLayoutConstraint.activate([ 34 | vStack.centerXAnchor.constraint(equalTo: baseVC.view.centerXAnchor), 35 | vStack.centerYAnchor.constraint(equalTo: baseVC.view.centerYAnchor) 36 | ]) 37 | 38 | for destination in pageControls.enumerated() { 39 | vStack.addArrangedSubview(presentingButton("Show \(destination.element.description)", vcIndex: destination.offset)) 40 | } 41 | } 42 | 43 | private func presentingButton(_ title: String, vcIndex: Int) -> UIButton { 44 | let button = UIButton(type: .system) 45 | button.tag = vcIndex 46 | button.translatesAutoresizingMaskIntoConstraints = false 47 | button.setTitle(title, for: .normal) 48 | button.titleLabel?.font = .systemFont(ofSize: 20) 49 | button.addTarget(self, action: #selector(didTapButton(_:)), for: .touchUpInside) 50 | return button 51 | } 52 | 53 | @objc private func didTapButton(_ sender: UIButton) { 54 | self.pushViewController(pageControls[sender.tag].vc, animated: true) 55 | } 56 | 57 | } 58 | 59 | //MARK: - SwiftUI 60 | 61 | struct MainViewControllerRepresentable: UIViewControllerRepresentable { 62 | func makeUIViewController(context: Context) -> MainViewController { 63 | MainViewController() 64 | } 65 | 66 | func updateUIViewController(_ uiViewController: MainViewController, context: Context) { 67 | 68 | } 69 | } 70 | 71 | #if DEBUG 72 | struct MainViewControllerRepresentable_Preview: PreviewProvider { 73 | static var previews: some View { 74 | MainViewControllerRepresentable() 75 | .edgesIgnoringSafeArea(.all) 76 | } 77 | } 78 | #endif 79 | -------------------------------------------------------------------------------- /WavePageControlDemo-iOS/WavePageControlDemo-iOS/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // WavePageControlDemo-iOS 4 | // 5 | // Created by Bogdan Chornobryvets on 18.06.2022. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 15 | guard let windowScene = (scene as? UIWindowScene) else { return } 16 | 17 | let window = UIWindow(windowScene: windowScene) 18 | window.rootViewController = MainViewController() 19 | self.window = window 20 | window.makeKeyAndVisible() 21 | } 22 | 23 | } 24 | 25 | --------------------------------------------------------------------------------