├── .gitignore ├── _Media ├── icon.png ├── Pending.PNG ├── Delivered.PNG └── Simulate.PNG ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Tests ├── LinuxMain.swift └── ScheduledNotificationsViewControllerTests │ ├── XCTestManifests.swift │ └── ScheduledNotificationsViewControllerTests.swift ├── Sources └── ScheduledNotificationsViewController │ ├── NiceUI │ ├── View.swift │ ├── Views │ │ ├── Switch.swift │ │ ├── Button.swift │ │ └── ScrollableStackView.swift │ ├── Delegated │ │ └── Delegated.swift │ ├── Stacks.swift │ ├── GestureRecognizers.swift │ └── Align.swift │ ├── ScheduledNotificationsViewController.swift │ └── StaticTableView.swift ├── LICENSE ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /_Media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dreymonde/ScheduledNotificationsViewController/HEAD/_Media/icon.png -------------------------------------------------------------------------------- /_Media/Pending.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dreymonde/ScheduledNotificationsViewController/HEAD/_Media/Pending.PNG -------------------------------------------------------------------------------- /_Media/Delivered.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dreymonde/ScheduledNotificationsViewController/HEAD/_Media/Delivered.PNG -------------------------------------------------------------------------------- /_Media/Simulate.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dreymonde/ScheduledNotificationsViewController/HEAD/_Media/Simulate.PNG -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import ScheduledNotificationsViewControllerTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += ScheduledNotificationsViewControllerTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/ScheduledNotificationsViewControllerTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(ScheduledNotificationsViewControllerTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/ScheduledNotificationsViewControllerTests/ScheduledNotificationsViewControllerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ScheduledNotificationsViewController 3 | 4 | final class ScheduledNotificationsViewControllerTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(1, 1) 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/NiceUI/View.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class View: UIView { 4 | init() { 5 | super.init(frame: .zero) 6 | self.didLoad() 7 | } 8 | 9 | @available(*, unavailable) 10 | required init?(coder: NSCoder) { 11 | fatalError("init(coder:) has not been implemented") 12 | } 13 | 14 | open func didLoad() { 15 | // initialize your view here 16 | } 17 | } 18 | 19 | extension UIView { 20 | func addSubview(_ view: Subview, configure: (Subview) -> Void) { 21 | addSubview(view) 22 | configure(view) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/NiceUI/Views/Switch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Oleg Dreyman on 1/22/21. 6 | // 7 | 8 | import UIKit 9 | 10 | final class Switch: UISwitch { 11 | 12 | @Delegated1 var didToggle: (Switch) -> () 13 | 14 | override func didMoveToSuperview() { 15 | super.didMoveToSuperview() 16 | setTargetAction() 17 | } 18 | 19 | private func setTargetAction() { 20 | addTarget(self, action: #selector(_valueChanged), for: .valueChanged) 21 | } 22 | 23 | @objc 24 | private func _valueChanged() { 25 | didToggle(self) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nice Photon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ScheduledNotificationsViewController", 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: "ScheduledNotificationsViewController", 15 | targets: ["ScheduledNotificationsViewController"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | ], 20 | targets: [ 21 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 22 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 23 | .target( 24 | name: "ScheduledNotificationsViewController", 25 | dependencies: []), 26 | .testTarget( 27 | name: "ScheduledNotificationsViewControllerTests", 28 | dependencies: ["ScheduledNotificationsViewController"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScheduledNotificationsViewController 2 | 3 | 4 | 5 | **ScheduledNotificationsViewController** shows you all of your pending local notifications in one place, with all the information you need. Tapping on a notification will immediately trigger its delivery, making this small tool invaluable for debugging local notifications. 6 | 7 | Medium story [here](https://medium.com/nice-photon-ios/introducing-schedulednotificationsviewcontroller-67c8b73813e3). 8 | 9 | | Scheduled | Delivered | Tap to trigger | 10 | | --- | --- | --- | 11 | | ![Scheduled](_Media/Pending.PNG) | ![Scheduled](_Media/Delivered.PNG) | ![Scheduled](_Media/Simulate.PNG) | 12 | 13 | ## Features 14 | 15 | - See all scheduled notifications in one place: every notification includes content, next trigger date, an identifier and a category name. 16 | - Tap on a notification in a list to immediately trigger its delivery (you can test live how the notification will look). The "real" notification will still arrive in time! 17 | - Scroll down to see recently delivered notifications (tap "Show Delivered Notifications") 18 | - Supports light and dark mode natively 19 | 20 | ## Usage 21 | 22 | ```swift 23 | import UIKit 24 | import ScheduledNotificationsViewController 25 | 26 | // ...somewhere in "Settings" screen: 27 | 28 | #if DEBUG 29 | 30 | let notificationsVC = ScheduledNotificationsViewController() 31 | self.navigationController?.pushViewController(notificationsVC, animated: true) 32 | 33 | #endif 34 | ``` 35 | 36 | ### Troubleshooting 37 | 38 | **Q: My scheduled notifications list is empty** 39 | 40 | A: Make sure you have granted notifications permissions to your app on your device. More here: [ Asking Permission to Use Notifications](https://developer.apple.com/documentation/usernotifications/asking_permission_to_use_notifications) 41 | 42 | **Q: I tap on a notification in a list, but nothing shows up** 43 | 44 | A: Make sure you're using `userNotificationCenter(_:willPresent:withCompletionHandler:)` callback in your `UNUserNotificationCenterDelegate`. More here: [ Handling Notifications and Notification-Related Actions](https://developer.apple.com/documentation/usernotifications/handling_notifications_and_notification-related_actions) (see section *"Handle Notifications While Your App Runs in the Foreground"*) 45 | 46 | ## Installation 47 | 48 | ### Swift Package Manager 49 | 1. Click File → Swift Packages → Add Package Dependency. 50 | 2. Enter `http://github.com/nicephoton/ScheduledNotificationsViewController.git`. 51 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/NiceUI/Views/Button.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Oleg Dreyman on 1/22/21. 6 | // 7 | 8 | import UIKit 9 | 10 | final class Button: UIButton { 11 | 12 | @Delegated1 var didTap: (Button) -> () 13 | 14 | @available(*, deprecated, renamed: "Button()") 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | } 18 | 19 | convenience init() { 20 | self.init(type: .system) 21 | } 22 | 23 | required init?(coder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | static func solidBackground(_ backgroundColor: UIColor = .systemBlue, cornerRadius: CGFloat = 8) -> Button { 28 | let button = Button() 29 | button.layer.cornerRadius = cornerRadius 30 | button.clipsToBounds = true 31 | button.setBackgroundColor(backgroundColor, for: .normal) 32 | return button 33 | } 34 | 35 | override func didMoveToSuperview() { 36 | super.didMoveToSuperview() 37 | setTargetAction() 38 | } 39 | 40 | private func setTargetAction() { 41 | addTarget(self, action: #selector(_didTouchUpInside), for: .touchUpInside) 42 | } 43 | 44 | @objc 45 | private func _didTouchUpInside() { 46 | didTap(self) 47 | } 48 | } 49 | 50 | // Original authors: Kickstarter 51 | // https://github.com/kickstarter/Kickstarter-Prelude/blob/master/Prelude-UIKit/UIButton.swift 52 | 53 | extension Button { 54 | /** 55 | Sets the background color of a button for a particular state. 56 | - parameter backgroundColor: The color to set. 57 | - parameter state: The state for the color to take affect. 58 | */ 59 | func setBackgroundColor(_ backgroundColor: UIColor, for state: UIControl.State) { 60 | Button.setBackgroundColor(backgroundColor, to: self, for: state) 61 | } 62 | 63 | static func setBackgroundColor(_ backgroundColor: UIColor, to button: UIButton, for state: UIControl.State) { 64 | button.setBackgroundImage(NiceImage.pixel(ofColor: backgroundColor), for: state) 65 | } 66 | } 67 | 68 | enum NiceImage { 69 | 70 | /** 71 | - parameter color: A color. 72 | - returns: A 1x1 UIImage of a solid color. 73 | */ 74 | static func pixel(ofColor color: UIColor) -> UIImage { 75 | if #available(iOS 12.0, *) { 76 | let lightModeImage = NiceImage.generatePixel(ofColor: color, userInterfaceStyle: .light) 77 | let darkModeImage = NiceImage.generatePixel(ofColor: color, userInterfaceStyle: .dark) 78 | lightModeImage.imageAsset?.register(darkModeImage, with: UITraitCollection(userInterfaceStyle: .dark)) 79 | return lightModeImage 80 | } else { 81 | return generatePixel(ofColor: color) 82 | } 83 | } 84 | 85 | @available(iOS 12.0, *) 86 | static private func generatePixel(ofColor color: UIColor, userInterfaceStyle: UIUserInterfaceStyle) -> UIImage { 87 | var image: UIImage! 88 | UITraitCollection(userInterfaceStyle: userInterfaceStyle).performAsCurrent { 89 | image = NiceImage.generatePixel(ofColor: color) 90 | } 91 | return image 92 | } 93 | 94 | static private func generatePixel(ofColor color: UIColor) -> UIImage { 95 | let pixel = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0) 96 | 97 | UIGraphicsBeginImageContext(pixel.size) 98 | defer { UIGraphicsEndImageContext() } 99 | 100 | guard let context = UIGraphicsGetCurrentContext() else { 101 | return UIImage() 102 | } 103 | 104 | context.setFillColor(color.cgColor) 105 | context.fill(pixel) 106 | 107 | return UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/NiceUI/Views/ScrollableStackView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollableStackView.swift 3 | // Private Analytics 4 | // 5 | // Created by Oleg Dreyman on 28.08.2020. 6 | // Copyright © 2020 Confirmed, Inc. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class ScrollableStackView: UIView { 12 | 13 | let scrollView: UIScrollView = EnhancedControlTouchScrollView() 14 | let stackView = UIStackView() 15 | 16 | init(axis: NSLayoutConstraint.Axis, contentInset: UIEdgeInsets = .zero) { 17 | stackView.axis = axis 18 | super.init(frame: .zero) 19 | setup(contentInset: contentInset) 20 | } 21 | 22 | @available(*, unavailable) 23 | required init?(coder: NSCoder) { 24 | fatalError("init(coder:) has not been implemented") 25 | } 26 | 27 | // MARK: - Public 28 | 29 | // Note: we should probably remove these? Too hacky for my taste (Oleg) 30 | 31 | @_Proxy(\ScrollableStackView.scrollView.alwaysBounceHorizontal) 32 | var alwaysBounceHorizontal: Bool 33 | 34 | @_Proxy(\ScrollableStackView.scrollView.alwaysBounceVertical) 35 | var alwaysBounceVertical: Bool 36 | 37 | @_Proxy(\ScrollableStackView.scrollView.contentInset) 38 | var contentInset: UIEdgeInsets 39 | 40 | @_Proxy(\ScrollableStackView.stackView.spacing) 41 | var spacing: CGFloat 42 | 43 | @_Proxy(\ScrollableStackView.stackView.alignment) 44 | var alignment: UIStackView.Alignment 45 | 46 | @_Proxy(\ScrollableStackView.stackView.distribution) 47 | var distribution: UIStackView.Distribution 48 | 49 | func addArrangedSubview(_ view: UIView) { 50 | stackView.addArrangedSubview(view) 51 | } 52 | 53 | // MARK: - Private 54 | 55 | private func setup(contentInset: UIEdgeInsets) { 56 | scrollView.addSubview(stackView) 57 | addSubview(scrollView) 58 | 59 | with(stackView) { 60 | $0.anchors.edges.pin() 61 | 62 | switch stackView.axis { 63 | case .vertical: 64 | $0.anchors.width.equal( 65 | scrollView.anchors.width, 66 | constant: -(contentInset.left + contentInset.right) 67 | ) 68 | case .horizontal: 69 | $0.anchors.height.equal( 70 | scrollView.anchors.height, 71 | constant: -(contentInset.top + contentInset.bottom) 72 | ) 73 | default: 74 | assertionFailure("unknown layout axis") 75 | } 76 | } 77 | 78 | with(scrollView) { 79 | $0.anchors.edges.pin() 80 | $0.contentInset.top += contentInset.top 81 | $0.contentInset.left += contentInset.left 82 | $0.contentInset.right += contentInset.right 83 | $0.contentInset.bottom += contentInset.bottom 84 | } 85 | } 86 | } 87 | 88 | final class EnhancedControlTouchScrollView: UIScrollView { 89 | override var delaysContentTouches: Bool { 90 | get { return false } 91 | set { } 92 | } 93 | 94 | override func touchesShouldCancel(in view: UIView) -> Bool { 95 | if view is UIControl { 96 | return true 97 | } 98 | return super.touchesShouldCancel(in: view) 99 | } 100 | } 101 | 102 | // https://swiftbysundell.com/articles/accessing-a-swift-property-wrappers-enclosing-instance/ 103 | @propertyWrapper 104 | struct _Proxy { 105 | typealias ValueKeyPath = ReferenceWritableKeyPath 106 | typealias SelfKeyPath = ReferenceWritableKeyPath 107 | 108 | static subscript( 109 | _enclosingInstance instance: EnclosingType, 110 | wrapped wrappedKeyPath: ValueKeyPath, 111 | storage storageKeyPath: SelfKeyPath 112 | ) -> Value { 113 | get { 114 | let keyPath = instance[keyPath: storageKeyPath].keyPath 115 | return instance[keyPath: keyPath] 116 | } 117 | set { 118 | let keyPath = instance[keyPath: storageKeyPath].keyPath 119 | instance[keyPath: keyPath] = newValue 120 | } 121 | } 122 | 123 | @available(*, unavailable, 124 | message: "@Proxy can only be applied to classes" 125 | ) 126 | var wrappedValue: Value { 127 | get { fatalError() } 128 | set { fatalError() } 129 | } 130 | 131 | private let keyPath: ValueKeyPath 132 | 133 | init(_ keyPath: ValueKeyPath) { 134 | self.keyPath = keyPath 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/NiceUI/Delegated/Delegated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Delegated.swift 3 | // Delegated 4 | // 5 | // Created by Oleg Dreyman on 12/7/2020. 6 | // Copyright © 2020 Oleg Dreyman. All rights reserved. 7 | // 8 | 9 | typealias Delegated = Delegated1 10 | 11 | @propertyWrapper 12 | final class Delegated1 { 13 | 14 | init() { 15 | self.callback = { _ in } 16 | } 17 | 18 | private var callback: (Input) -> Void 19 | 20 | var wrappedValue: (Input) -> Void { 21 | return callback 22 | } 23 | 24 | var projectedValue: Delegated1 { 25 | return self 26 | } 27 | } 28 | 29 | extension Delegated1 { 30 | func delegate( 31 | to target: Target, 32 | with callback: @escaping (Target, Input) -> Void 33 | ) { 34 | self.callback = { [weak target] input in 35 | guard let target = target else { 36 | return 37 | } 38 | return callback(target, input) 39 | } 40 | } 41 | 42 | func manuallyDelegate(with callback: @escaping (Input) -> Void) { 43 | self.callback = callback 44 | } 45 | 46 | func removeDelegate() { 47 | self.callback = { _ in } 48 | } 49 | } 50 | 51 | @propertyWrapper 52 | final class Delegated0 { 53 | 54 | init() { 55 | self.callback = { } 56 | } 57 | 58 | private var callback: () -> Void 59 | 60 | var wrappedValue: () -> Void { 61 | return callback 62 | } 63 | 64 | var projectedValue: Delegated0 { 65 | return self 66 | } 67 | } 68 | 69 | extension Delegated0 { 70 | func delegate( 71 | to target: Target, 72 | with callback: @escaping (Target) -> Void 73 | ) { 74 | self.callback = { [weak target] in 75 | guard let target = target else { 76 | return 77 | } 78 | return callback(target) 79 | } 80 | } 81 | 82 | func manuallyDelegate(with callback: @escaping () -> Void) { 83 | self.callback = callback 84 | } 85 | 86 | func removeDelegate() { 87 | self.callback = { } 88 | } 89 | } 90 | 91 | @propertyWrapper 92 | final class Delegated2 { 93 | 94 | init() { 95 | self.callback = { _, _ in } 96 | } 97 | 98 | private var callback: (Input1, Input2) -> Void 99 | 100 | var wrappedValue: (Input1, Input2) -> Void { 101 | return callback 102 | } 103 | 104 | var projectedValue: Delegated2 { 105 | return self 106 | } 107 | } 108 | 109 | extension Delegated2 { 110 | func delegate( 111 | to target: Target, 112 | with callback: @escaping (Target, Input1, Input2) -> Void 113 | ) { 114 | self.callback = { [weak target] (input1, input2) in 115 | guard let target = target else { 116 | return 117 | } 118 | return callback(target, input1, input2) 119 | } 120 | } 121 | 122 | func manuallyDelegate(with callback: @escaping (Input1, Input2) -> Void) { 123 | self.callback = callback 124 | } 125 | 126 | func removeDelegate() { 127 | self.callback = { _, _ in } 128 | } 129 | } 130 | 131 | @propertyWrapper 132 | final class Delegated3 { 133 | 134 | init() { 135 | self.callback = { _, _, _ in } 136 | } 137 | 138 | private var callback: (Input1, Input2, Input3) -> Void 139 | 140 | var wrappedValue: (Input1, Input2, Input3) -> Void { 141 | return callback 142 | } 143 | 144 | var projectedValue: Delegated3 { 145 | return self 146 | } 147 | } 148 | 149 | extension Delegated3 { 150 | func delegate( 151 | to target: Target, 152 | with callback: @escaping (Target, Input1, Input2, Input3) -> Void 153 | ) { 154 | self.callback = { [weak target] (input1, input2, input3) in 155 | guard let target = target else { 156 | return 157 | } 158 | return callback(target, input1, input2, input3) 159 | } 160 | } 161 | 162 | func manuallyDelegate(with callback: @escaping (Input1, Input2, Input3) -> Void) { 163 | self.callback = callback 164 | } 165 | 166 | func removeDelegate() { 167 | self.callback = { _, _, _ in } 168 | } 169 | } 170 | 171 | @propertyWrapper 172 | final class Delegated4 { 173 | 174 | init() { 175 | self.callback = { _, _, _, _ in } 176 | } 177 | 178 | private var callback: (Input1, Input2, Input3, Input4) -> Void 179 | 180 | var wrappedValue: (Input1, Input2, Input3, Input4) -> Void { 181 | return callback 182 | } 183 | 184 | var projectedValue: Delegated4 { 185 | return self 186 | } 187 | } 188 | 189 | extension Delegated4 { 190 | func delegate( 191 | to target: Target, 192 | with callback: @escaping (Target, Input1, Input2, Input3, Input4) -> Void 193 | ) { 194 | self.callback = { [weak target] (input1, input2, input3, input4) in 195 | guard let target = target else { 196 | return 197 | } 198 | return callback(target, input1, input2, input3, input4) 199 | } 200 | } 201 | 202 | func manuallyDelegate(with callback: @escaping (Input1, Input2, Input3, Input4) -> Void) { 203 | self.callback = callback 204 | } 205 | 206 | func removeDelegate() { 207 | self.callback = { _, _, _, _ in } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/NiceUI/Stacks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stacks.swift 3 | // DailyQuestions 4 | // 5 | // Created by Oleg Dreyman on 9/29/20. 6 | // Copyright © 2020 Oleg Dreyman. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | func Vertically(_ views: [UIView]) -> UIStackView { 12 | return with(UIStackView()) { 13 | $0.axis = .vertical 14 | views.forEach($0.addArrangedSubview(_:)) 15 | } 16 | } 17 | 18 | func Vertically(_ views: UIView...) -> UIStackView { 19 | return Vertically(views) 20 | } 21 | 22 | func Vertically(_ views: [UIView], setup: (UIStackView) -> Void) -> UIStackView { 23 | return with(UIStackView()) { 24 | $0.axis = .vertical 25 | views.forEach($0.addArrangedSubview(_:)) 26 | setup($0) 27 | } 28 | } 29 | 30 | func Vertically(_ views: UIView..., setup: (UIStackView) -> Void) -> UIStackView { 31 | return Vertically(views, setup: setup) 32 | } 33 | 34 | func VerticallyScrollable(_ views: [UIView], contentInset: UIEdgeInsets = .zero) -> ScrollableStackView { 35 | return with(ScrollableStackView(axis: .vertical, contentInset: contentInset)) { 36 | views.forEach($0.addArrangedSubview(_:)) 37 | } 38 | } 39 | 40 | func VerticallyScrollable(_ views: UIView..., contentInset: UIEdgeInsets = .zero) -> ScrollableStackView { 41 | return VerticallyScrollable(views, contentInset: contentInset) 42 | } 43 | 44 | func VerticallyScrollable(_ views: [UIView], contentInset: UIEdgeInsets = .zero, setup: (UIStackView) -> Void) -> ScrollableStackView { 45 | return with(ScrollableStackView(axis: .vertical, contentInset: contentInset)) { 46 | views.forEach($0.addArrangedSubview(_:)) 47 | setup($0.stackView) 48 | } 49 | } 50 | 51 | func VerticallyScrollable(_ views: UIView..., contentInset: UIEdgeInsets = .zero, setup: (UIStackView) -> Void) -> ScrollableStackView { 52 | return VerticallyScrollable(views, contentInset: contentInset, setup: setup) 53 | } 54 | 55 | func VerticalContainer(_ view: View, alignment: Alignmment = .center, insets: UIEdgeInsets = .zero) -> ContainerView { 56 | let container = ContainerView(contentView: view) 57 | container.addSubview(view) { 58 | $0.anchors.edges.pin(insets: insets, axis: .vertical, alignment: alignment) 59 | $0.anchors.edges.pin(insets: insets, axis: .horizontal) 60 | } 61 | 62 | return container 63 | } 64 | 65 | func Horizontally(_ views: [UIView]) -> UIStackView { 66 | return with(UIStackView()) { 67 | $0.axis = .horizontal 68 | views.forEach($0.addArrangedSubview(_:)) 69 | } 70 | } 71 | 72 | func Horizontally(_ views: UIView...) -> UIStackView { 73 | return Horizontally(views) 74 | } 75 | 76 | func Horizontally(_ views: [UIView], setup: (UIStackView) -> Void) -> UIStackView { 77 | return with(UIStackView()) { 78 | $0.axis = .horizontal 79 | views.forEach($0.addArrangedSubview(_:)) 80 | setup($0) 81 | } 82 | } 83 | 84 | func Horizontally(_ views: UIView..., setup: (UIStackView) -> Void) -> UIStackView { 85 | return Horizontally(views, setup: setup) 86 | } 87 | 88 | func HorizontallyScrollable(_ views: [UIView], contentInset: UIEdgeInsets = .zero) -> ScrollableStackView { 89 | return with(ScrollableStackView(axis: .horizontal)) { 90 | views.forEach($0.addArrangedSubview(_:)) 91 | } 92 | } 93 | 94 | func HorizontallyScrollable(_ views: UIView..., contentInset: UIEdgeInsets = .zero) -> ScrollableStackView { 95 | return HorizontallyScrollable(views, contentInset: contentInset) 96 | } 97 | 98 | func HorizontallyScrollable(_ views: [UIView], contentInset: UIEdgeInsets = .zero, setup: (UIStackView) -> Void) -> ScrollableStackView { 99 | return with(ScrollableStackView(axis: .horizontal)) { 100 | views.forEach($0.addArrangedSubview(_:)) 101 | setup($0.stackView) 102 | } 103 | } 104 | 105 | func HorizontallyScrollable(_ views: UIView..., contentInset: UIEdgeInsets = .zero, setup: (UIStackView) -> Void) -> ScrollableStackView { 106 | return HorizontallyScrollable(views, contentInset: contentInset, setup: setup) 107 | } 108 | 109 | func HorizontalContainer(_ view: View, alignment: Alignmment = .center, insets: UIEdgeInsets = .zero) -> ContainerView { 110 | let container = ContainerView(contentView: view) 111 | container.addSubview(view) { 112 | $0.anchors.edges.pin(insets: insets, axis: .horizontal, alignment: alignment) 113 | $0.anchors.edges.pin(insets: insets, axis: .vertical) 114 | } 115 | return container 116 | } 117 | 118 | extension UIEdgeInsets { 119 | static func top(_ topInset: CGFloat) -> UIEdgeInsets { 120 | return .init(top: topInset, left: 0, bottom: 0, right: 0) 121 | } 122 | static func bottom(_ bottomInset: CGFloat) -> UIEdgeInsets { 123 | return .init(top: 0, left: 0, bottom: bottomInset, right: 0) 124 | } 125 | static func left(_ leftInset: CGFloat) -> UIEdgeInsets { 126 | return .init(top: 0, left: leftInset, bottom: 0, right: 0) 127 | } 128 | static func right(_ rightInset: CGFloat) -> UIEdgeInsets { 129 | return .init(top: 0, left: 0, bottom: 0, right: rightInset) 130 | } 131 | static func all(_ inset: CGFloat) -> UIEdgeInsets { 132 | return .init(top: inset, left: inset, bottom: inset, right: inset) 133 | } 134 | } 135 | 136 | @discardableResult 137 | func with(_ object: Obj, _ block: (Obj) -> Void) -> Obj { 138 | block(object) 139 | return object 140 | } 141 | 142 | @discardableResult 143 | func withMany(_ objects: [Obj], _ block: (Obj) -> Void) -> [Obj] { 144 | objects.forEach(block) 145 | return objects 146 | } 147 | 148 | /// Create this by using functions `VerticalContainer` and `HorizontalContainer` 149 | final class ContainerView: View { 150 | let contentView: ContentView 151 | 152 | internal init(contentView: ContentView) { 153 | self.contentView = contentView 154 | super.init() 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/NiceUI/GestureRecognizers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Oleg Dreyman on 1/22/21. 6 | // 7 | 8 | import UIKit 9 | 10 | final class TapGestureRecognizer: UITapGestureRecognizer { 11 | 12 | @Delegated1 var action: (TapGestureRecognizer) -> Void 13 | 14 | init() { 15 | super.init(target: nil, action: nil) 16 | super.addTarget(self, action: #selector(didDetect)) 17 | } 18 | 19 | @available(*, deprecated, renamed: "TapGestureRecognizer()") 20 | override init(target: Any?, action: Selector?) { 21 | super.init(target: target, action: action) 22 | } 23 | 24 | @objc 25 | private func didDetect() { 26 | self.action(self) 27 | } 28 | } 29 | 30 | extension TapGestureRecognizer: Delegating1 { 31 | static var keyPathForDelegatedFunction: KeyPath> { 32 | return \._action 33 | } 34 | } 35 | 36 | final class PinchGestureRecognizer: UIPinchGestureRecognizer { 37 | 38 | @Delegated1 var action: (PinchGestureRecognizer) -> Void 39 | 40 | init() { 41 | super.init(target: nil, action: nil) 42 | super.addTarget(self, action: #selector(didDetect)) 43 | } 44 | 45 | @available(*, deprecated, renamed: "PinchGestureRecognizer()") 46 | override init(target: Any?, action: Selector?) { 47 | super.init(target: target, action: action) 48 | } 49 | 50 | @objc 51 | private func didDetect() { 52 | self.action(self) 53 | } 54 | } 55 | 56 | extension PinchGestureRecognizer: Delegating1 { 57 | static var keyPathForDelegatedFunction: KeyPath> { 58 | return \._action 59 | } 60 | } 61 | 62 | final class RotationGestureRecognizer: UIRotationGestureRecognizer { 63 | 64 | @Delegated1 var action: (RotationGestureRecognizer) -> Void 65 | 66 | init() { 67 | super.init(target: nil, action: nil) 68 | super.addTarget(self, action: #selector(didDetect)) 69 | } 70 | 71 | @available(*, deprecated, renamed: "RotationGestureRecognizer()") 72 | override init(target: Any?, action: Selector?) { 73 | super.init(target: target, action: action) 74 | } 75 | 76 | @objc 77 | private func didDetect() { 78 | self.action(self) 79 | } 80 | } 81 | 82 | extension RotationGestureRecognizer: Delegating1 { 83 | static var keyPathForDelegatedFunction: KeyPath> { 84 | return \._action 85 | } 86 | } 87 | 88 | final class SwipeGestureRecognizer: UISwipeGestureRecognizer { 89 | 90 | @Delegated1 var action: (SwipeGestureRecognizer) -> Void 91 | 92 | init() { 93 | super.init(target: nil, action: nil) 94 | super.addTarget(self, action: #selector(didDetect)) 95 | } 96 | 97 | @available(*, deprecated, renamed: "SwipeGestureRecognizer()") 98 | override init(target: Any?, action: Selector?) { 99 | super.init(target: target, action: action) 100 | } 101 | 102 | @objc 103 | private func didDetect() { 104 | self.action(self) 105 | } 106 | } 107 | 108 | extension SwipeGestureRecognizer: Delegating1 { 109 | static var keyPathForDelegatedFunction: KeyPath> { 110 | return \._action 111 | } 112 | } 113 | 114 | final class PanGestureRecognizer: UIPanGestureRecognizer { 115 | 116 | @Delegated1 var action: (PanGestureRecognizer) -> Void 117 | 118 | init() { 119 | super.init(target: nil, action: nil) 120 | super.addTarget(self, action: #selector(didDetect)) 121 | } 122 | 123 | @available(*, deprecated, renamed: "PanGestureRecognizer()") 124 | override init(target: Any?, action: Selector?) { 125 | super.init(target: target, action: action) 126 | } 127 | 128 | @objc 129 | private func didDetect() { 130 | self.action(self) 131 | } 132 | } 133 | 134 | extension PanGestureRecognizer: Delegating1 { 135 | static var keyPathForDelegatedFunction: KeyPath> { 136 | return \._action 137 | } 138 | } 139 | 140 | final class ScreenEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer { 141 | 142 | @Delegated1 var action: (ScreenEdgePanGestureRecognizer) -> Void 143 | 144 | init() { 145 | super.init(target: nil, action: nil) 146 | super.addTarget(self, action: #selector(didDetect)) 147 | } 148 | 149 | @available(*, deprecated, renamed: "ScreenEdgePanGestureRecognizer()") 150 | override init(target: Any?, action: Selector?) { 151 | super.init(target: target, action: action) 152 | } 153 | 154 | @objc 155 | private func didDetect() { 156 | self.action(self) 157 | } 158 | } 159 | 160 | extension ScreenEdgePanGestureRecognizer: Delegating1 { 161 | static var keyPathForDelegatedFunction: KeyPath> { 162 | return \._action 163 | } 164 | } 165 | 166 | final class LongPressGestureRecognizer: UILongPressGestureRecognizer { 167 | 168 | @Delegated1 var action: (LongPressGestureRecognizer) -> Void 169 | 170 | init() { 171 | super.init(target: nil, action: nil) 172 | super.addTarget(self, action: #selector(didDetect)) 173 | } 174 | 175 | @available(*, deprecated, renamed: "LongPressGestureRecognizer()") 176 | override init(target: Any?, action: Selector?) { 177 | super.init(target: target, action: action) 178 | } 179 | 180 | @objc 181 | private func didDetect() { 182 | self.action(self) 183 | } 184 | } 185 | 186 | extension LongPressGestureRecognizer: Delegating1 { 187 | static var keyPathForDelegatedFunction: KeyPath> { 188 | return \._action 189 | } 190 | } 191 | 192 | @available(iOS 13.0, *) 193 | final class HoverGestureRecognizer: UIHoverGestureRecognizer { 194 | 195 | @Delegated1 var action: (HoverGestureRecognizer) -> Void 196 | 197 | init() { 198 | super.init(target: nil, action: nil) 199 | super.addTarget(self, action: #selector(didDetect)) 200 | } 201 | 202 | @available(*, deprecated, renamed: "HoverGestureRecognizer()") 203 | override init(target: Any?, action: Selector?) { 204 | super.init(target: target, action: action) 205 | } 206 | 207 | @objc 208 | private func didDetect() { 209 | self.action(self) 210 | } 211 | } 212 | 213 | @available(iOS 13.0, *) 214 | extension HoverGestureRecognizer: Delegating1 { 215 | static var keyPathForDelegatedFunction: KeyPath> { 216 | return \._action 217 | } 218 | } 219 | 220 | protocol Delegating1 { 221 | associatedtype DelegatedInput 222 | 223 | static var keyPathForDelegatedFunction: KeyPath> { get } 224 | } 225 | 226 | extension Delegating1 { 227 | @discardableResult 228 | func delegate( 229 | to target: Target, 230 | with callback: @escaping (Target, DelegatedInput) -> Void 231 | ) -> Self { 232 | self[keyPath: Self.keyPathForDelegatedFunction].delegate(to: target, with: callback) 233 | return self 234 | } 235 | 236 | @discardableResult 237 | func manuallyDelegate(with callback: @escaping (DelegatedInput) -> Void) -> Self { 238 | self[keyPath: Self.keyPathForDelegatedFunction].manuallyDelegate(with: callback) 239 | return self 240 | } 241 | 242 | func removeDelegate() { 243 | self[keyPath: Self.keyPathForDelegatedFunction].removeDelegate() 244 | } 245 | } 246 | 247 | extension UIView { 248 | func recognizeTaps( 249 | numberOfTapsRequired: Int = 1, 250 | numberOfTouchesRequired: Int = 1 251 | ) -> TapGestureRecognizer { 252 | let tap = TapGestureRecognizer() 253 | tap.numberOfTapsRequired = numberOfTapsRequired 254 | tap.numberOfTouchesRequired = numberOfTouchesRequired 255 | return recognize(tap) 256 | } 257 | 258 | func recognizeSwipe( 259 | direction: UISwipeGestureRecognizer.Direction, 260 | numberOfTouchesRequired: Int = 1 261 | ) -> SwipeGestureRecognizer { 262 | let swipe = SwipeGestureRecognizer() 263 | swipe.direction = direction 264 | swipe.numberOfTouchesRequired = numberOfTouchesRequired 265 | return recognize(swipe) 266 | } 267 | 268 | func recognizeLongPress( 269 | minimumPressDuration: TimeInterval = 0.5, 270 | numberOfTouchesRequired: Int = 1, 271 | numberOfTapsRequired: Int = 0, 272 | allowableMovement: CGFloat = 10 273 | ) -> LongPressGestureRecognizer { 274 | let longPress = LongPressGestureRecognizer() 275 | longPress.minimumPressDuration = minimumPressDuration 276 | longPress.numberOfTouchesRequired = numberOfTouchesRequired 277 | longPress.numberOfTapsRequired = numberOfTapsRequired 278 | longPress.allowableMovement = allowableMovement 279 | return recognize(longPress) 280 | } 281 | 282 | @available(iOS 13.0, *) 283 | func recognizeHover() -> HoverGestureRecognizer { 284 | return recognize(HoverGestureRecognizer()) 285 | } 286 | 287 | func recognize(_ recognizer: Recognizer) -> Recognizer { 288 | isUserInteractionEnabled = true 289 | addGestureRecognizer(recognizer) 290 | return recognizer 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/ScheduledNotificationsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScheduledNotificationsViewController.swift 3 | // Nice Photon 4 | // 5 | // Created by Oleg Dreyman on 1/26/21. 6 | // 7 | 8 | import UIKit 9 | import UserNotifications 10 | 11 | public final class ScheduledNotificationsViewController: UIViewController { 12 | 13 | enum Mode { 14 | case pending 15 | case delivered 16 | } 17 | 18 | let mode: Mode 19 | 20 | private init(mode: Mode) { 21 | self.mode = mode 22 | super.init(nibName: nil, bundle: nil) 23 | } 24 | 25 | public init() { 26 | self.mode = .pending 27 | super.init(nibName: nil, bundle: nil) 28 | } 29 | 30 | required init?(coder: NSCoder) { 31 | self.mode = .pending 32 | super.init(coder: coder) 33 | } 34 | 35 | static let formatter: DateFormatter = { 36 | let form = DateFormatter() 37 | form.locale = Locale(identifier: "en_US_POSIX") 38 | form.setLocalizedDateFormatFromTemplate("MMddyyyyHHmmss") 39 | return form 40 | }() 41 | 42 | fileprivate var requests: [NotificationToShow] = [] 43 | 44 | let tableView = StaticTableView() 45 | 46 | public override func viewDidLoad() { 47 | super.viewDidLoad() 48 | 49 | title = { 50 | switch mode { 51 | case .pending: 52 | return "Pending" 53 | case .delivered: 54 | return "Delivered" 55 | } 56 | }() 57 | 58 | addKeyboardAwareTableView(tableView) 59 | tableView.separatorStyle = .singleLine 60 | tableView.contentInset.top += 8 61 | tableView.rowHeight = UITableView.automaticDimension 62 | 63 | tableView.addDynamicSegment(provider: self, dataSet: \.requests, style: .default) { (cell) in 64 | let titleLabel = with(UILabel()) { 65 | $0.font = .systemFont(ofSize: 15, weight: .semibold) 66 | $0.numberOfLines = 0 67 | $0.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) 68 | } 69 | let subtitleLabel = with(UILabel()) { 70 | $0.font = .systemFont(ofSize: 15, weight: .semibold) 71 | $0.numberOfLines = 0 72 | $0.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) 73 | } 74 | let bodyLabel = with(UILabel()) { 75 | $0.font = .systemFont(ofSize: 15) 76 | $0.numberOfLines = 0 77 | $0.setContentCompressionResistancePriority(.defaultLow, for: .vertical) 78 | $0.setContentHuggingPriority(.defaultHigh, for: .vertical) 79 | } 80 | let content = Vertically(titleLabel, subtitleLabel, bodyLabel) { 81 | $0.spacing = 2 82 | $0.alignment = .leading 83 | } 84 | let container = VerticalContainer(content, alignment: .fill, insets: .zero) 85 | 86 | let material = UIView() 87 | let visualEffect = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) 88 | material.addSubview(visualEffect) { 89 | $0.anchors.edges.pin() 90 | } 91 | material.addSubview(container) { 92 | $0.anchors.edges.pin(insets: .init(top: 9, left: 12, bottom: 9, right: 14)) 93 | } 94 | 95 | material.layer.cornerRadius = 13 96 | material.layer.masksToBounds = true 97 | 98 | let triggerLabel = with(UILabel()) { 99 | $0.font = .systemFont(ofSize: 13, weight: .regular) 100 | $0.textColor = .systemOrange 101 | $0.numberOfLines = 0 102 | } 103 | 104 | let idLabel = with(UILabel()) { 105 | $0.font = .systemFont(ofSize: 13, weight: .regular) 106 | $0.textColor = .secondaryLabel 107 | $0.numberOfLines = 0 108 | } 109 | let categoryLabel = with(UILabel()) { 110 | $0.font = .systemFont(ofSize: 13, weight: .regular) 111 | $0.textColor = .secondaryLabel 112 | $0.numberOfLines = 0 113 | } 114 | 115 | let detailsStack = Vertically(triggerLabel, idLabel, categoryLabel) { 116 | $0.spacing = 2 117 | } 118 | let detailsContainer = HorizontalContainer(detailsStack, alignment: .fill, insets: .left(13)) 119 | 120 | let finalStack = Vertically(material, detailsContainer) { 121 | $0.spacing = 4 122 | } 123 | 124 | cell.contentView.addSubview(finalStack) { 125 | $0.anchors.edges.marginsPin() 126 | } 127 | 128 | cell.onReuse { (cell, notification) in 129 | titleLabel.text = notification.request.content.title 130 | 131 | let subtitle = notification.request.content.subtitle 132 | subtitleLabel.text = subtitle.isEmpty ? nil : subtitle 133 | 134 | bodyLabel.text = notification.request.content.body 135 | do { 136 | var components: [NSAttributedString.SystemSymbolBuilderComponent] = [] 137 | if notification.request.content.sound == nil { 138 | components.append(.symbol(named: "speaker.slash.fill")) 139 | components.append(" ") 140 | } 141 | components.append(.string(notification._dateLabel)) 142 | triggerLabel.attributedText = .withSymbols(font: triggerLabel.font, components) 143 | } 144 | 145 | idLabel.text = "id: " + notification.request.identifier 146 | categoryLabel.text = "category: " + notification.request.content.categoryIdentifier 147 | 148 | cell.onSelect(to: self) { (self, cell) in 149 | let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.05, repeats: false) 150 | let copy = UNNotificationRequest( 151 | identifier: notification.request.identifier + "___np_notif_simulation", 152 | content: notification.request.content, 153 | trigger: trigger 154 | ) 155 | UNUserNotificationCenter.current().add(copy, withCompletionHandler: nil) 156 | } 157 | } 158 | } 159 | 160 | if mode != .delivered { 161 | tableView.addRowCell { (cell) in 162 | cell.textLabel?.text = "Show Delivered Notifications" 163 | cell.textLabel?.numberOfLines = 0 164 | cell.textLabel?.font = .systemFont(ofSize: 16, weight: .heavy) 165 | cell.textLabel?.text = cell.textLabel?.text?.uppercased() 166 | cell.textLabel?.textColor = .secondaryLabel 167 | cell.indentationLevel = 1 168 | }.withAccessoryType(.disclosureIndicator).onSelect(to: self) { (self, _) in 169 | let delivered = ScheduledNotificationsViewController(mode: .delivered) 170 | self.navigationController?.pushViewController(delivered, animated: true) 171 | } 172 | } 173 | 174 | tableView.onPulledToRefresh(to: self) { (self) in 175 | self.reload() 176 | } 177 | 178 | reload() 179 | } 180 | 181 | func reload() { 182 | switch mode { 183 | case .pending: 184 | UNUserNotificationCenter.current().getPendingNotificationRequests { (requests) in 185 | DispatchQueue.main.async { 186 | self.requests = requests.sorted(by: UNNotificationTrigger.sortByEarliest(left:right:)) 187 | self.tableView.reloadData() 188 | self.tableView.refreshControl?.endRefreshing() 189 | } 190 | } 191 | case .delivered: 192 | UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in 193 | DispatchQueue.main.async { 194 | self.requests = notifications 195 | .filter({ !$0.request.identifier.hasSuffix("___np_notif_simulation") }) 196 | .sorted(by: { UNNotificationTrigger.sortByEarliest(left: $1, right: $0) }) 197 | self.tableView.reloadData() 198 | self.tableView.refreshControl?.endRefreshing() 199 | } 200 | } 201 | } 202 | } 203 | } 204 | 205 | extension UNNotificationTrigger { 206 | fileprivate static func sortByEarliest(left: NotificationToShow, right: NotificationToShow) -> Bool { 207 | let (leftDate, rightDate) = (left._dateToCompare, right._dateToCompare) 208 | switch (leftDate, rightDate) { 209 | case (.none, .none): 210 | return false 211 | case (.some(let l), .some(let r)): 212 | return l < r 213 | case (.some, .none): 214 | return true 215 | case (.none, .some): 216 | return false 217 | } 218 | } 219 | 220 | func _triggerDate() -> Date? { 221 | switch self { 222 | case let calendarBased as UNCalendarNotificationTrigger: 223 | return calendarBased.nextTriggerDate() 224 | case let intervalBased as UNTimeIntervalNotificationTrigger: 225 | return intervalBased.nextTriggerDate() 226 | default: 227 | return nil 228 | } 229 | } 230 | 231 | fileprivate var _dateLabel: String? { 232 | switch self { 233 | case let calendarBased as UNCalendarNotificationTrigger: 234 | if let triggerDate = calendarBased.nextTriggerDate() { 235 | return ScheduledNotificationsViewController.formatter.string(from: triggerDate) 236 | + (calendarBased.repeats ? " (repeats)" : "") 237 | } else { 238 | return nil 239 | } 240 | case let intervalBased as UNTimeIntervalNotificationTrigger: 241 | if let triggerDate = intervalBased.nextTriggerDate() { 242 | return ScheduledNotificationsViewController.formatter.string(from: triggerDate) 243 | + (intervalBased.repeats ? " (repeats)" : "") 244 | } else { 245 | return nil 246 | } 247 | #if !targetEnvironment(macCatalyst) 248 | case is UNLocationNotificationTrigger: 249 | return "location based" 250 | #endif 251 | default: 252 | return nil 253 | } 254 | } 255 | } 256 | 257 | fileprivate protocol NotificationToShow { 258 | var _dateLabel: String { get } 259 | var trigger: UNNotificationTrigger? { get } 260 | var request: UNNotificationRequest { get } 261 | var _dateToCompare: Date? { get } 262 | } 263 | 264 | extension UNNotificationRequest: NotificationToShow { 265 | var _dateLabel: String { 266 | return trigger?._dateLabel ?? "" 267 | } 268 | 269 | var request: UNNotificationRequest { 270 | return self 271 | } 272 | 273 | var _dateToCompare: Date? { 274 | return trigger?._triggerDate() 275 | } 276 | } 277 | 278 | extension UNNotification: NotificationToShow { 279 | var _dateLabel: String { 280 | return ScheduledNotificationsViewController.formatter.string(from: date) 281 | } 282 | 283 | var trigger: UNNotificationTrigger? { 284 | return request.trigger 285 | } 286 | 287 | var _dateToCompare: Date? { 288 | return date 289 | } 290 | } 291 | 292 | // inspired by https://gist.github.com/gk-plastics/f4ad7c3f4ffec57003ea8e2e7b7a7107 293 | extension NSAttributedString { 294 | 295 | enum SystemSymbolBuilderComponent: ExpressibleByStringLiteral { 296 | typealias StringLiteralType = String 297 | 298 | case string(String) 299 | case symbol(named: String) 300 | 301 | init(stringLiteral value: String) { 302 | self = .string(value) 303 | } 304 | } 305 | 306 | static func withSymbols(font: UIFont, _ components: [SystemSymbolBuilderComponent]) -> NSAttributedString { 307 | let symbolConfiguration = UIImage.SymbolConfiguration(font: font) 308 | let attributedText = NSMutableAttributedString() 309 | for component in components { 310 | switch component { 311 | case .string(let string): 312 | attributedText.append(NSAttributedString(string: string)) 313 | case .symbol(named: let symbolName): 314 | let symbolImage = UIImage( 315 | systemName: symbolName, 316 | withConfiguration: symbolConfiguration 317 | )?.withRenderingMode(.alwaysTemplate) 318 | let symbolTextAttachment = NSTextAttachment() 319 | symbolTextAttachment.image = symbolImage 320 | attributedText.append(NSAttributedString(attachment: symbolTextAttachment)) 321 | } 322 | } 323 | return attributedText 324 | } 325 | 326 | } 327 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/StaticTableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StaticTableView.swift 3 | // DailyQuestions 4 | // 5 | // Created by Oleg Dreyman on 9/10/20. 6 | // Copyright © 2020 Oleg Dreyman. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum Segment { 12 | case staticCell(SelectableTableViewCell) 13 | case dynamic(Dynamic) 14 | 15 | func makeRows() -> [Row] { 16 | switch self { 17 | case .staticCell(let cell): 18 | return [.staticCell(cell)] 19 | case .dynamic(let dynamic): 20 | let dataSet = dynamic.dataSet() 21 | return dataSet.map { Row.dynamicCell(reuseIdentifier: dynamic.reuseIdentifier, data: $0) } 22 | } 23 | } 24 | 25 | class Dynamic { 26 | init(dataSet: @escaping () -> [Any], reuseIdentifier: String) { 27 | self.dataSet = dataSet 28 | self.reuseIdentifier = reuseIdentifier 29 | } 30 | 31 | fileprivate var dataSet: () -> [Any] 32 | let reuseIdentifier: String 33 | } 34 | } 35 | 36 | class DynamicSegment: Segment.Dynamic { 37 | func reload(with content: [Content]) { 38 | self.dataSet = { content } 39 | } 40 | } 41 | 42 | struct SectionDescriptor { 43 | var title: String? 44 | var footer: String? 45 | var segments: [Segment] 46 | } 47 | 48 | enum Row { 49 | case staticCell(SelectableTableViewCell) 50 | case dynamicCell(reuseIdentifier: String, data: Any) 51 | } 52 | 53 | final class StaticTableView: UITableView { 54 | 55 | @Delegated var pullToRefreshCallback: (StaticTableView) -> () 56 | 57 | var descriptor: [SectionDescriptor] = [] 58 | private var reuseIdentifiers: [String] = [] 59 | 60 | struct Section { 61 | var title: String? 62 | var footer: String? 63 | var rows: [Row] 64 | } 65 | 66 | private var _sections: [Section] = [] 67 | 68 | private var _rows: [Row] { 69 | return _sections.flatMap(\.rows) 70 | } 71 | 72 | private var dynamicCreators: [String: (() -> DynamicSelectableTableViewCell)] = [:] 73 | // 74 | // private var setups: [String: DynamicSetup] = [:] 75 | 76 | convenience init() { 77 | self.init(frame: .zero, style: .plain) 78 | } 79 | 80 | override init(frame: CGRect, style: UITableView.Style) { 81 | super.init(frame: frame, style: style) 82 | setup() 83 | } 84 | 85 | func setup() { 86 | dataSource = self 87 | delegate = self 88 | separatorStyle = .none 89 | tableFooterView = UIView() 90 | cellLayoutMarginsFollowReadableWidth = true 91 | } 92 | 93 | enum Insert { 94 | case last 95 | case dontInsert 96 | } 97 | 98 | @discardableResult 99 | func addRow(_ configure: (UIView) -> ()) -> SelectableTableViewCell { 100 | let cell = SelectableTableViewCell() 101 | cell.selectionStyle = .none 102 | cell.backgroundColor = nil 103 | configure(cell.contentView) 104 | if deselectCellsWhenSelected { 105 | cell.actions.insert(.deselect) 106 | } 107 | self.insert(segment: .staticCell(cell), insert: .last) 108 | return cell 109 | } 110 | 111 | @discardableResult 112 | func addRowCell(style: UITableViewCell.CellStyle = .default, _ configure: (UITableViewCell) -> ()) -> SelectableTableViewCell { 113 | let cell = SelectableTableViewCell(style: style, reuseIdentifier: nil) 114 | cell.selectionStyle = .none 115 | configure(cell) 116 | if deselectCellsWhenSelected { 117 | cell.actions.insert(.deselect) 118 | } 119 | self.insert(segment: .staticCell(cell), insert: .last) 120 | // self.insert(cell: cell, insert: .last) 121 | return cell 122 | } 123 | 124 | @discardableResult 125 | func addRow(view: UIView, marginInsets: UIEdgeInsets = .zero) -> SelectableTableViewCell { 126 | return addRow { (row) in 127 | row.addSubview(view) 128 | view.anchors.edges.marginsPin(insets: marginInsets) 129 | } 130 | } 131 | 132 | @discardableResult 133 | func addDynamicSegment(initialDataSet: [Content], style: UITableViewCell.CellStyle = .default, _ configure: @escaping (SpecifiedDynamicSelectableTableViewCell) -> ()) -> DynamicSegment { 134 | return insertDynamicSegment(dataSet: { initialDataSet }, style: style, configure) 135 | } 136 | 137 | func addDynamicSegment(provider: Provider, dataSet: @escaping (Provider) -> [Content], style: UITableViewCell.CellStyle = .default, _ configure: @escaping (SpecifiedDynamicSelectableTableViewCell) -> ()) { 138 | let getDataSet: () -> [Content] = { [weak provider] in 139 | if let provider = provider { 140 | return dataSet(provider) 141 | } else { 142 | return [] 143 | } 144 | } 145 | insertDynamicSegment(dataSet: getDataSet, style: style, configure) 146 | } 147 | 148 | @discardableResult 149 | private func insertDynamicSegment(dataSet: @escaping () -> [Content], style: UITableViewCell.CellStyle, _ configure: @escaping (SpecifiedDynamicSelectableTableViewCell) -> ()) -> DynamicSegment { 150 | let newReuseIdentifier = UUID().uuidString 151 | 152 | self.dynamicCreators[newReuseIdentifier] = { 153 | let newCell = SpecifiedDynamicSelectableTableViewCell(style: style, reuseIdentifier: newReuseIdentifier) 154 | newCell.selectionStyle = .none 155 | configure(newCell) 156 | return newCell 157 | } 158 | 159 | let getDataSet: () -> [Content] = dataSet 160 | let dynamicSegment = DynamicSegment(dataSet: getDataSet, reuseIdentifier: newReuseIdentifier) 161 | insert(segment: .dynamic(dynamicSegment), insert: .last) 162 | return dynamicSegment 163 | } 164 | 165 | func startSection(title: String?, footer: String? = nil) { 166 | descriptor.append(.init(title: title, footer: footer, segments: [])) 167 | } 168 | 169 | // func addSeparator(_ insets: UIEdgeInsets = .init(top: 0, left: 15, bottom: 0, right: 0)) { 170 | // if let last = _rows.last { 171 | // Separator.addBottomSeparator(to: last, insets: insets) 172 | // } 173 | // } 174 | 175 | private func insert(segment: Segment, insert: Insert) { 176 | // if deselectCellsWhenSelected { 177 | // cell.actions.insert(.deselect) 178 | // } 179 | 180 | switch insert { 181 | case .dontInsert: 182 | break 183 | case .last: 184 | if descriptor.isEmpty { 185 | let newSection = SectionDescriptor(title: nil, segments: [segment]) 186 | descriptor = [newSection] 187 | // sections = [newSection] 188 | } else { 189 | descriptor[descriptor.count - 1].segments.append(segment) 190 | } 191 | } 192 | 193 | self.render() 194 | } 195 | 196 | func clear() { 197 | descriptor = [] 198 | self.render() 199 | // sections = [] 200 | } 201 | 202 | override func reloadData() { 203 | self.render() 204 | super.reloadData() 205 | } 206 | 207 | func render() { 208 | _sections = descriptor.map({ (sectionDescriptor) in 209 | return Section(title: sectionDescriptor.title, footer: sectionDescriptor.footer, rows: sectionDescriptor.segments.flatMap({ $0.makeRows() })) 210 | }) 211 | } 212 | 213 | @discardableResult 214 | func onPulledToRefresh(to delegate: Delegate, callback: @escaping (Delegate) -> ()) -> Self { 215 | if self.refreshControl == nil { 216 | let new = UIRefreshControl() 217 | new.addTarget(self, action: #selector(refreshControlDidChangeValue), for: .valueChanged) 218 | self.refreshControl = new 219 | } 220 | $pullToRefreshCallback.delegate(to: delegate) { (target, _) in 221 | callback(target) 222 | } 223 | return self 224 | } 225 | 226 | @available(*, unavailable) 227 | required init?(coder: NSCoder) { 228 | fatalError("init(coder:) has not been implemented") 229 | } 230 | 231 | var isEnhancedControlSelectionEnabled: Bool = false { 232 | didSet { 233 | delaysContentTouches = !isEnhancedControlSelectionEnabled 234 | } 235 | } 236 | 237 | override func touchesShouldCancel(in view: UIView) -> Bool { 238 | guard isEnhancedControlSelectionEnabled else { 239 | return super.touchesShouldCancel(in: view) 240 | } 241 | 242 | if view is UIControl { 243 | return true 244 | } 245 | return super.touchesShouldCancel(in: view) 246 | } 247 | 248 | var deselectCellsWhenSelected: Bool = true 249 | var shouldCenterContentIfFits: Bool = false 250 | 251 | @objc 252 | private func refreshControlDidChangeValue() { 253 | pullToRefreshCallback(self) 254 | } 255 | } 256 | 257 | class SelectableTableViewCell: UITableViewCell { 258 | @Delegated var selectionCallback: (SelectableTableViewCell) -> () 259 | 260 | var actions: Set = [] 261 | 262 | enum Action { 263 | case toggleCheckmark 264 | case deselect 265 | } 266 | 267 | @discardableResult 268 | func onSelect(to delegate: Delegate, callback: @escaping (Delegate, SelectableTableViewCell) -> ()) -> Self { 269 | selectionStyle = .default 270 | $selectionCallback.delegate(to: delegate, with: callback) 271 | return self 272 | } 273 | 274 | @discardableResult 275 | func withAccessoryType(_ accessoryType: AccessoryType) -> SelectableTableViewCell { 276 | self.accessoryType = accessoryType 277 | return self 278 | } 279 | 280 | @discardableResult 281 | func onSelect(_ action: Action) -> Self { 282 | if action == .deselect { 283 | selectionStyle = .default 284 | } 285 | actions.insert(action) 286 | return self 287 | } 288 | } 289 | 290 | extension StaticTableView: UITableViewDataSource { 291 | 292 | func numberOfSections(in tableView: UITableView) -> Int { 293 | return _sections.count 294 | } 295 | 296 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 297 | return _sections[section].rows.count 298 | } 299 | 300 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 301 | let row = _sections[indexPath.section].rows[indexPath.row] 302 | switch row { 303 | case .staticCell(let cell): 304 | return cell 305 | case .dynamicCell(reuseIdentifier: let reuseIdentifier, data: let data): 306 | let reusableCell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) 307 | if let existing = reusableCell as? DynamicSelectableTableViewCell { 308 | existing.reuse(with: data) 309 | return existing 310 | } else if let creator = dynamicCreators[reuseIdentifier] { 311 | let new = creator() 312 | new.reuse(with: data) 313 | if self.deselectCellsWhenSelected { 314 | new.actions.insert(.deselect) 315 | } 316 | return new 317 | } else { 318 | preconditionFailure("Dynamic cell was misconfigured somehow") 319 | } 320 | } 321 | } 322 | 323 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 324 | return _sections[section].title 325 | } 326 | 327 | func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { 328 | return _sections[section].footer 329 | } 330 | } 331 | 332 | extension StaticTableView: UITableViewDelegate { 333 | func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { 334 | if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell { 335 | return cell.selectionStyle != .none 336 | } else { 337 | return false 338 | } 339 | } 340 | 341 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 342 | if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell { 343 | cell.selectionCallback(cell) 344 | for action in cell.actions { 345 | switch action { 346 | case .deselect: 347 | tableView.deselectRow(at: indexPath, animated: true) 348 | case .toggleCheckmark: 349 | if cell.accessoryType == .checkmark { 350 | cell.accessoryType = .none 351 | } else { 352 | cell.accessoryType = .checkmark 353 | } 354 | } 355 | } 356 | } 357 | } 358 | } 359 | 360 | extension UIViewController { 361 | func addKeyboardAwareTableView(_ tableView: UITableView) { 362 | let tableViewController = StaticTableViewController() 363 | tableViewController.tableView = tableView 364 | view.addSubview(tableViewController.view) 365 | tableViewController.view.anchors.edges.pin() 366 | addChild(tableViewController) 367 | tableViewController.didMove(toParent: self) 368 | } 369 | } 370 | 371 | final class StaticTableViewController: UITableViewController { 372 | override func viewDidLayoutSubviews() { 373 | super.viewDidLayoutSubviews() 374 | let tableView = self.tableView as! StaticTableView 375 | guard tableView.shouldCenterContentIfFits else { 376 | return 377 | } 378 | 379 | DispatchQueue.main.async { 380 | let totalHeight = tableView.contentSize.height 381 | let screenHeight = self.view.frame.size.height - (self.view.safeAreaInsets.top + self.view.safeAreaInsets.bottom) 382 | let diff = screenHeight - tableView.contentInset.bottom - totalHeight 383 | if diff > 0 { 384 | let topOffset = diff / 2 385 | if topOffset > 80 { 386 | tableView.contentInset.top = topOffset - 30 387 | } else { 388 | tableView.contentInset.top = topOffset 389 | } 390 | } 391 | } 392 | } 393 | } 394 | 395 | enum Separator { 396 | private static func contains(tag: String, inSubviewsOf view: UIView) -> Bool { 397 | return view.subviews.contains { $0.accessibilityIdentifier == tag } 398 | } 399 | 400 | fileprivate static func makeSeparatorView(tag: String, axis: NSLayoutConstraint.Axis) -> UIView { 401 | let separator = UIView() 402 | separator.accessibilityIdentifier = tag 403 | separator.backgroundColor = .separator 404 | switch axis { 405 | case .horizontal: 406 | separator.anchors.height.equal(0.33) 407 | case .vertical: 408 | separator.anchors.width.equal(0.33) 409 | @unknown default: 410 | break 411 | } 412 | return separator 413 | } 414 | 415 | static func topSeparator(for view: UIView) -> UIView? { 416 | return view.subviews.first(where: { $0.accessibilityIdentifier == "top_separator" }) 417 | } 418 | 419 | static func bottomSeparator(for view: UIView) -> UIView? { 420 | return view.subviews.first(where: { $0.accessibilityIdentifier == "bottom_separator" }) 421 | } 422 | 423 | static func addTopSeparator(to view: UIView, insets: UIEdgeInsets) { 424 | if contains(tag: "top_separator", inSubviewsOf: view) { 425 | return 426 | } 427 | 428 | let separator = makeSeparatorView(tag: "top_separator", axis: .horizontal) 429 | view.addSubview(separator) 430 | separator.anchors.leading.marginsPin(inset: insets.left - 8) 431 | separator.anchors.trailing.marginsPin(inset: insets.right - 8) 432 | separator.anchors.top.pin(inset: insets.top) 433 | } 434 | 435 | static func addLeadingSeparator(to view: UIView, insets: UIEdgeInsets) { 436 | if contains(tag: "leading_separator", inSubviewsOf: view) { 437 | return 438 | } 439 | 440 | let separator = makeSeparatorView(tag: "leading_separator", axis: .vertical) 441 | view.addSubview(separator) 442 | separator.anchors.leading.pin(inset: insets.left) 443 | separator.anchors.top.pin(inset: insets.top) 444 | separator.anchors.bottom.pin(inset: insets.top) 445 | } 446 | 447 | static func addTrailingSeparator(to view: UIView, insets: UIEdgeInsets) { 448 | if contains(tag: "trailing_separator", inSubviewsOf: view) { 449 | return 450 | } 451 | 452 | let separator = makeSeparatorView(tag: "trailing_separator", axis: .vertical) 453 | view.addSubview(separator) 454 | separator.anchors.trailing.pin(inset: insets.right) 455 | separator.anchors.top.pin(inset: insets.top) 456 | separator.anchors.bottom.pin(inset: insets.top) 457 | } 458 | 459 | static func addBottomSeparator(to view: UIView, insets: UIEdgeInsets) { 460 | if contains(tag: "bottom_separator", inSubviewsOf: view) { 461 | return 462 | } 463 | 464 | let separator = makeSeparatorView(tag: "bottom_separator", axis: .horizontal) 465 | view.addSubview(separator) 466 | separator.anchors.leading.marginsPin(inset: insets.left - 8) 467 | separator.anchors.trailing.marginsPin(inset: insets.right - 8) 468 | separator.anchors.bottom.pin(inset: insets.bottom) 469 | } 470 | } 471 | 472 | struct Nope: Error { } 473 | 474 | class DynamicSelectableTableViewCell: SelectableTableViewCell { 475 | 476 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 477 | super.init(style: style, reuseIdentifier: reuseIdentifier) 478 | } 479 | 480 | var setup: (Any) -> () = { _ in } 481 | func reuse(with content: Any) { 482 | setup(content) 483 | } 484 | 485 | override func prepareForReuse() { 486 | super.prepareForReuse() 487 | selectionStyle = .none 488 | } 489 | 490 | @available(*, unavailable) 491 | required init?(coder: NSCoder) { 492 | fatalError("init(coder:) has not been implemented") 493 | } 494 | } 495 | 496 | class SpecifiedDynamicSelectableTableViewCell: DynamicSelectableTableViewCell { 497 | 498 | var content: Content? 499 | 500 | func onReuse(perform: @escaping (SelectableTableViewCell, Content) -> ()) { 501 | setup = { [weak self] data in 502 | if let content = data as? Content, let strongSelf = self { 503 | strongSelf.content = content 504 | perform(strongSelf, content) 505 | } else { 506 | assertionFailure("\(data) is not of type \(Content.self)") 507 | } 508 | } 509 | } 510 | 511 | } 512 | -------------------------------------------------------------------------------- /Sources/ScheduledNotificationsViewController/NiceUI/Align.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2017-2020 Alexander Grebenyuk (github.com/kean). 4 | 5 | // This is slightly modified version of an amazing library Aligh 6 | // by Alexander Grebenyuk (github.com/kean) 7 | // https://github.com/kean/Align 8 | 9 | #if os(iOS) || os(tvOS) 10 | import UIKit 11 | 12 | protocol LayoutItem { // `UIView`, `UILayoutGuide` 13 | var superview: UIView? { get } 14 | } 15 | 16 | extension UIView: LayoutItem {} 17 | extension UILayoutGuide: LayoutItem { 18 | var superview: UIView? { owningView } 19 | } 20 | #elseif os(macOS) 21 | import AppKit 22 | 23 | protocol LayoutItem { // `NSView`, `NSLayoutGuide` 24 | var superview: NSView? { get } 25 | } 26 | 27 | extension NSView: LayoutItem {} 28 | extension NSLayoutGuide: LayoutItem { 29 | var superview: NSView? { owningView } 30 | } 31 | #endif 32 | 33 | extension LayoutItem { // Align methods are available via `LayoutAnchors` 34 | @nonobjc var anchors: LayoutAnchors { LayoutAnchors(base: self) } 35 | } 36 | 37 | // MARK: - LayoutAnchors 38 | 39 | struct LayoutAnchors { 40 | let base: Base 41 | } 42 | 43 | extension LayoutAnchors where Base: LayoutItem { 44 | 45 | // MARK: Anchors 46 | 47 | var top: Anchor { Anchor(base, .top) } 48 | var bottom: Anchor { Anchor(base, .bottom) } 49 | var left: Anchor { Anchor(base, .left) } 50 | var right: Anchor { Anchor(base, .right) } 51 | var leading: Anchor { Anchor(base, .leading) } 52 | var trailing: Anchor { Anchor(base, .trailing) } 53 | 54 | var centerX: Anchor { Anchor(base, .centerX) } 55 | var centerY: Anchor { Anchor(base, .centerY) } 56 | 57 | var firstBaseline: Anchor { Anchor(base, .firstBaseline) } 58 | var lastBaseline: Anchor { Anchor(base, .lastBaseline) } 59 | 60 | var width: Anchor { Anchor(base, .width) } 61 | var height: Anchor { Anchor(base, .height) } 62 | 63 | // MARK: Anchor Collections 64 | 65 | var edges: AnchorCollectionEdges { AnchorCollectionEdges(item: base) } 66 | var center: AnchorCollectionCenter { AnchorCollectionCenter(x: centerX, y: centerY) } 67 | var size: AnchorCollectionSize { AnchorCollectionSize(width: width, height: height) } 68 | } 69 | 70 | #if os(iOS) || os(tvOS) 71 | extension LayoutAnchors where Base: UIView { 72 | var margins: LayoutAnchors { base.layoutMarginsGuide.anchors } 73 | var safeArea: LayoutAnchors { base.safeAreaLayoutGuide.anchors } 74 | } 75 | #endif 76 | 77 | // MARK: - Anchors 78 | 79 | // phantom types 80 | enum AnchorAxis { 81 | class Horizontal {} 82 | class Vertical {} 83 | } 84 | 85 | enum AnchorType { 86 | class Dimension {} 87 | class Alignment {} 88 | class Center: Alignment {} 89 | class Edge: Alignment {} 90 | class Baseline: Alignment {} 91 | } 92 | 93 | /// An anchor represents one of the view's layout attributes (e.g. `left`, 94 | /// `centerX`, `width`, etc). Use the anchor’s methods to construct constraints. 95 | struct Anchor { // type and axis are phantom types 96 | let item: LayoutItem 97 | fileprivate let attribute: NSLayoutConstraint.Attribute 98 | fileprivate let offset: CGFloat 99 | fileprivate let multiplier: CGFloat 100 | 101 | fileprivate init(_ item: LayoutItem, _ attribute: NSLayoutConstraint.Attribute, offset: CGFloat = 0, multiplier: CGFloat = 1) { 102 | self.item = item; self.attribute = attribute; self.offset = offset; self.multiplier = multiplier 103 | } 104 | 105 | /// Returns a new anchor offset by a given amount. 106 | /// 107 | /// - note: Consider using a convenience operator instead: `view.anchors.top + 10`. 108 | func offsetting(by offset: CGFloat) -> Anchor { 109 | Anchor(item, attribute, offset: self.offset + offset, multiplier: self.multiplier) 110 | } 111 | 112 | /// Returns a new anchor with a given multiplier. 113 | /// 114 | /// - note: Consider using a convenience operator instead: `view.anchors.height * 2`. 115 | func multiplied(by multiplier: CGFloat) -> Anchor { 116 | Anchor(item, attribute, offset: self.offset * multiplier, multiplier: self.multiplier * multiplier) 117 | } 118 | } 119 | 120 | func + (anchor: Anchor, offset: CGFloat) -> Anchor { 121 | anchor.offsetting(by: offset) 122 | } 123 | 124 | func - (anchor: Anchor, offset: CGFloat) -> Anchor { 125 | anchor.offsetting(by: -offset) 126 | } 127 | 128 | func * (anchor: Anchor, multiplier: CGFloat) -> Anchor { 129 | anchor.multiplied(by: multiplier) 130 | } 131 | 132 | // MARK: - Anchors (AnchorType.Alignment) 133 | 134 | extension Anchor where Type: AnchorType.Alignment { 135 | @discardableResult func equal(_ anchor: Anchor, constant: CGFloat = 0) -> NSLayoutConstraint { 136 | Constraints.constrain(self, anchor, constant: constant, relation: .equal) 137 | } 138 | 139 | @discardableResult func greaterThanOrEqual(_ anchor: Anchor, constant: CGFloat = 0) -> NSLayoutConstraint { 140 | Constraints.constrain(self, anchor, constant: constant, relation: .greaterThanOrEqual) 141 | } 142 | 143 | @discardableResult func lessThanOrEqual(_ anchor: Anchor, constant: CGFloat = 0) -> NSLayoutConstraint { 144 | Constraints.constrain(self, anchor, constant: constant, relation: .lessThanOrEqual) 145 | } 146 | } 147 | 148 | // MARK: - Anchors (AnchorType.Dimension) 149 | 150 | extension Anchor where Type: AnchorType.Dimension { 151 | @discardableResult func equal(_ anchor: Anchor, constant: CGFloat = 0) -> NSLayoutConstraint { 152 | Constraints.constrain(self, anchor, constant: constant, relation: .equal) 153 | } 154 | 155 | @discardableResult func greaterThanOrEqual(_ anchor: Anchor, constant: CGFloat = 0) -> NSLayoutConstraint { 156 | Constraints.constrain(self, anchor, constant: constant, relation: .greaterThanOrEqual) 157 | } 158 | 159 | @discardableResult func lessThanOrEqual(_ anchor: Anchor, constant: CGFloat = 0) -> NSLayoutConstraint { 160 | Constraints.constrain(self, anchor, constant: constant, relation: .lessThanOrEqual) 161 | } 162 | } 163 | 164 | // MARK: - Anchors (AnchorType.Dimension) 165 | 166 | extension Anchor where Type: AnchorType.Dimension { 167 | @discardableResult func equal(_ constant: CGFloat) -> NSLayoutConstraint { 168 | Constraints.constrain(item: item, attribute: attribute, relatedBy: .equal, constant: constant) 169 | } 170 | 171 | @discardableResult func greaterThanOrEqual(_ constant: CGFloat) -> NSLayoutConstraint { 172 | Constraints.constrain(item: item, attribute: attribute, relatedBy: .greaterThanOrEqual, constant: constant) 173 | } 174 | 175 | @discardableResult func lessThanOrEqual(_ constant: CGFloat) -> NSLayoutConstraint { 176 | Constraints.constrain(item: item, attribute: attribute, relatedBy: .lessThanOrEqual, constant: constant) 177 | } 178 | } 179 | 180 | // MARK: - Anchors (AnchorType.Edge) 181 | 182 | extension Anchor where Type: AnchorType.Edge { 183 | /// Pins the edge to the respected edges of the given container. 184 | @discardableResult func pin(to container: LayoutItem?, inset: CGFloat = 0) -> NSLayoutConstraint { 185 | let isInverted = [.trailing, .right, .bottom].contains(attribute) 186 | return Constraints.constrain(self, toItem: container ?? item.superview!, attribute: attribute, constant: (isInverted ? -inset : inset)) 187 | } 188 | 189 | @discardableResult func pin(inset: CGFloat = 0) -> NSLayoutConstraint { 190 | return pin(to: nil, inset: inset) 191 | } 192 | 193 | /// Adds spacing between the current anchors. 194 | @discardableResult func spacing(_ spacing: CGFloat, to anchor: Anchor, relation: NSLayoutConstraint.Relation = .equal) -> NSLayoutConstraint { 195 | let isInverted = (attribute == .bottom && anchor.attribute == .top) || 196 | (attribute == .right && anchor.attribute == .left) || 197 | (attribute == .trailing && anchor.attribute == .leading) 198 | return Constraints.constrain(self, anchor, constant: isInverted ? -spacing : spacing, relation: isInverted ? relation.inverted : relation) 199 | } 200 | } 201 | 202 | // MARK: - Anchors (AnchorType.Center) 203 | 204 | extension Anchor where Type: AnchorType.Center { 205 | /// Aligns the axis with a superview axis. 206 | @discardableResult func align(offset: CGFloat = 0) -> NSLayoutConstraint { 207 | Constraints.constrain(self, toItem: item.superview!, attribute: attribute, constant: offset) 208 | } 209 | } 210 | 211 | // MARK: - AnchorCollectionEdges 212 | 213 | struct Alignmment { 214 | enum Horizontal { 215 | case fill, center, leading, trailing 216 | } 217 | enum Vertical { 218 | case fill, center, top, bottom 219 | } 220 | 221 | let horizontal: Horizontal 222 | let vertical: Vertical 223 | 224 | init(horizontal: Horizontal, vertical: Vertical) { 225 | (self.horizontal, self.vertical) = (horizontal, vertical) 226 | } 227 | 228 | static let fill = Alignmment(horizontal: .fill, vertical: .fill) 229 | static let center = Alignmment(horizontal: .center, vertical: .center) 230 | static let topLeading = Alignmment(horizontal: .leading, vertical: .top) 231 | static let leading = Alignmment(horizontal: .leading, vertical: .fill) 232 | static let bottomLeading = Alignmment(horizontal: .leading, vertical: .bottom) 233 | static let bottom = Alignmment(horizontal: .fill, vertical: .bottom) 234 | static let bottomTrailing = Alignmment(horizontal: .trailing, vertical: .bottom) 235 | static let trailing = Alignmment(horizontal: .trailing, vertical: .fill) 236 | static let topTrailing = Alignmment(horizontal: .trailing, vertical: .top) 237 | static let top = Alignmment(horizontal: .fill, vertical: .top) 238 | } 239 | 240 | struct AnchorCollectionEdges { 241 | fileprivate let item: LayoutItem 242 | fileprivate var isAbsolute = false 243 | 244 | // By default, edges use locale-specific `.leading` and `.trailing` 245 | func absolute() -> AnchorCollectionEdges { 246 | AnchorCollectionEdges(item: item, isAbsolute: true) 247 | } 248 | 249 | #if os(iOS) || os(tvOS) 250 | typealias Axis = NSLayoutConstraint.Axis 251 | #else 252 | typealias Axis = NSLayoutConstraint.Orientation 253 | #endif 254 | 255 | @discardableResult func pin(to item2: LayoutItem? = nil, insets: EdgeInsets = .zero, axis: Axis? = nil, alignment: Alignmment = .fill) -> [NSLayoutConstraint] { 256 | let item2 = item2 ?? item.superview! 257 | let left: NSLayoutConstraint.Attribute = isAbsolute ? .left : .leading 258 | let right: NSLayoutConstraint.Attribute = isAbsolute ? .right : .trailing 259 | var constraints = [NSLayoutConstraint]() 260 | 261 | func constrain(attribute: NSLayoutConstraint.Attribute, relation: NSLayoutConstraint.Relation, constant: CGFloat) { 262 | constraints.append(Constraints.constrain(item: item, attribute: attribute, relatedBy: relation, toItem: item2, attribute: attribute, multiplier: 1, constant: constant)) 263 | } 264 | 265 | if axis == nil || axis == .horizontal { 266 | constrain(attribute: left, relation: alignment.horizontal == .fill || alignment.horizontal == .leading ? .equal : .greaterThanOrEqual, constant: insets.left) 267 | constrain(attribute: right, relation: alignment.horizontal == .fill || alignment.horizontal == .trailing ? .equal : .lessThanOrEqual, constant: -insets.right) 268 | if alignment.horizontal == .center { 269 | constrain(attribute: .centerX, relation: .equal, constant: 0) 270 | } 271 | } 272 | if axis == nil || axis == .vertical { 273 | constrain(attribute: .top, relation: alignment.vertical == .fill || alignment.vertical == .top ? .equal : .greaterThanOrEqual, constant: insets.top) 274 | constrain(attribute: .bottom, relation: alignment.vertical == .fill || alignment.vertical == .bottom ? .equal : .lessThanOrEqual, constant: -insets.bottom) 275 | if alignment.vertical == .center { 276 | constrain(attribute: .centerY, relation: .equal, constant: 0) 277 | } 278 | } 279 | return constraints 280 | } 281 | } 282 | 283 | // MARK: - AnchorCollectionCenter 284 | 285 | struct AnchorCollectionCenter { 286 | fileprivate let x: Anchor 287 | fileprivate let y: Anchor 288 | 289 | /// Centers the view in the superview. 290 | @discardableResult func align() -> [NSLayoutConstraint] { 291 | [x.align(), y.align()] 292 | } 293 | 294 | /// Makes the axis equal to the other collection of axis. 295 | @discardableResult func align(with item: Item) -> [NSLayoutConstraint] { 296 | [x.equal(item.anchors.centerX), y.equal(item.anchors.centerY)] 297 | } 298 | } 299 | 300 | // MARK: - AnchorCollectionSize 301 | 302 | struct AnchorCollectionSize { 303 | fileprivate let width: Anchor 304 | fileprivate let height: Anchor 305 | 306 | /// Set the size of item. 307 | @discardableResult func equal(_ size: CGSize) -> [NSLayoutConstraint] { 308 | [width.equal(size.width), height.equal(size.height)] 309 | } 310 | 311 | /// Set the size of item. 312 | @discardableResult func greaterThanOrEqul(_ size: CGSize) -> [NSLayoutConstraint] { 313 | [width.greaterThanOrEqual(size.width), height.greaterThanOrEqual(size.height)] 314 | } 315 | 316 | /// Set the size of item. 317 | @discardableResult func lessThanOrEqual(_ size: CGSize) -> [NSLayoutConstraint] { 318 | [width.lessThanOrEqual(size.width), height.lessThanOrEqual(size.height)] 319 | } 320 | 321 | /// Makes the size of the item equal to the size of the other item. 322 | @discardableResult func equal(_ item: Item, insets: CGSize = .zero, multiplier: CGFloat = 1) -> [NSLayoutConstraint] { 323 | [width.equal(item.anchors.width * multiplier - insets.width), height.equal(item.anchors.height * multiplier - insets.height)] 324 | } 325 | 326 | @discardableResult func greaterThanOrEqual(_ item: Item, insets: CGSize = .zero, multiplier: CGFloat = 1) -> [NSLayoutConstraint] { 327 | [width.greaterThanOrEqual(item.anchors.width * multiplier - insets.width), height.greaterThanOrEqual(item.anchors.height * multiplier - insets.height)] 328 | } 329 | 330 | @discardableResult func lessThanOrEqual(_ item: Item, insets: CGSize = .zero, multiplier: CGFloat = 1) -> [NSLayoutConstraint] { 331 | [width.lessThanOrEqual(item.anchors.width * multiplier - insets.width), height.lessThanOrEqual(item.anchors.height * multiplier - insets.height)] 332 | } 333 | } 334 | 335 | // MARK: - Constraints 336 | 337 | final class Constraints { 338 | /// Returns all of the created constraints. 339 | private(set) var constraints = [NSLayoutConstraint]() 340 | 341 | /// All of the constraints created in the given closure are automatically 342 | /// activated at the same time. This is more efficient then installing them 343 | /// one-be-one. More importantly, it allows to make changes to the constraints 344 | /// before they are installed (e.g. change `priority`). 345 | /// 346 | /// - parameter activate: Set to `false` to disable automatic activation of 347 | /// constraints. 348 | @discardableResult init(activate: Bool = true, _ closure: () -> Void) { 349 | Constraints._stack.append(self) 350 | closure() // create constraints 351 | Constraints._stack.removeLast() 352 | if activate { NSLayoutConstraint.activate(constraints) } 353 | } 354 | 355 | /// Creates and automatically installs a constraint. 356 | fileprivate static func constrain(item item1: Any, attribute attr1: NSLayoutConstraint.Attribute, relatedBy relation: NSLayoutConstraint.Relation = .equal, toItem item2: Any? = nil, attribute attr2: NSLayoutConstraint.Attribute? = nil, multiplier: CGFloat = 1, constant: CGFloat = 0) -> NSLayoutConstraint { 357 | precondition(Thread.isMainThread, "Align APIs can only be used from the main thread") 358 | #if os(iOS) || os(tvOS) 359 | (item1 as? UIView)?.translatesAutoresizingMaskIntoConstraints = false 360 | #elseif os(macOS) 361 | (item1 as? NSView)?.translatesAutoresizingMaskIntoConstraints = false 362 | #endif 363 | let constraint = NSLayoutConstraint(item: item1, attribute: attr1, relatedBy: relation, toItem: item2, attribute: attr2 ?? .notAnAttribute, multiplier: multiplier, constant: constant) 364 | _install(constraint) 365 | return constraint 366 | } 367 | 368 | /// Creates and automatically installs a constraint between two anchors. 369 | fileprivate static func constrain(_ lhs: Anchor, _ rhs: Anchor, constant: CGFloat = 0, multiplier: CGFloat = 1, relation: NSLayoutConstraint.Relation = .equal) -> NSLayoutConstraint { 370 | constrain(item: lhs.item, attribute: lhs.attribute, relatedBy: relation, toItem: rhs.item, attribute: rhs.attribute, multiplier: (multiplier / lhs.multiplier) * rhs.multiplier, constant: constant - lhs.offset + rhs.offset) 371 | } 372 | 373 | /// Creates and automatically installs a constraint between an anchor and 374 | /// a given item. 375 | fileprivate static func constrain(_ lhs: Anchor, toItem item2: Any?, attribute attr2: NSLayoutConstraint.Attribute?, constant: CGFloat = 0, multiplier: CGFloat = 1, relation: NSLayoutConstraint.Relation = .equal) -> NSLayoutConstraint { 376 | constrain(item: lhs.item, attribute: lhs.attribute, relatedBy: relation, toItem: item2, attribute: attr2, multiplier: multiplier / lhs.multiplier, constant: constant - lhs.offset) 377 | } 378 | 379 | private static var _stack = [Constraints]() // this is what enabled constraint auto-installing 380 | 381 | private static func _install(_ constraint: NSLayoutConstraint) { 382 | if let group = _stack.last { 383 | group.constraints.append(constraint) 384 | } else { 385 | constraint.isActive = true 386 | } 387 | } 388 | } 389 | 390 | extension Constraints { 391 | @discardableResult convenience init(for a: A, _ closure: (LayoutAnchors) -> Void) { 392 | self.init { closure(a.anchors) } 393 | } 394 | 395 | @discardableResult convenience init(for a: A, _ b: B, _ closure: (LayoutAnchors, LayoutAnchors) -> Void) { 396 | self.init { closure(a.anchors, b.anchors) } 397 | } 398 | 399 | @discardableResult convenience init(for a: A, _ b: B, _ c: C, _ closure: (LayoutAnchors, LayoutAnchors, LayoutAnchors) -> Void) { 400 | self.init { closure(a.anchors, b.anchors, c.anchors) } 401 | } 402 | 403 | @discardableResult convenience init(for a: A, _ b: B, _ c: C, _ d: D, _ closure: (LayoutAnchors, LayoutAnchors, LayoutAnchors, LayoutAnchors) -> Void) { 404 | self.init { closure(a.anchors, b.anchors, c.anchors, d.anchors) } 405 | } 406 | } 407 | 408 | // MARK: - Misc 409 | 410 | #if os(iOS) || os(tvOS) 411 | typealias EdgeInsets = UIEdgeInsets 412 | #elseif os(macOS) 413 | typealias EdgeInsets = NSEdgeInsets 414 | 415 | extension NSEdgeInsets { 416 | static let zero = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) 417 | } 418 | #endif 419 | 420 | extension NSLayoutConstraint.Relation { 421 | fileprivate var inverted: NSLayoutConstraint.Relation { 422 | switch self { 423 | case .greaterThanOrEqual: return .lessThanOrEqual 424 | case .lessThanOrEqual: return .greaterThanOrEqual 425 | case .equal: return self 426 | @unknown default: return self 427 | } 428 | } 429 | } 430 | 431 | extension EdgeInsets { 432 | fileprivate func inset(for attribute: NSLayoutConstraint.Attribute, edge: Bool = false) -> CGFloat { 433 | switch attribute { 434 | case .top: return top; case .bottom: return edge ? -bottom : bottom 435 | case .left, .leading: return left 436 | case .right, .trailing: return edge ? -right : right 437 | default: return 0 438 | } 439 | } 440 | } 441 | 442 | // MARK: - Extensions 443 | 444 | extension Anchor where Type: AnchorType.Edge { 445 | @discardableResult func safeAreaPin(inset: CGFloat = 0) -> NSLayoutConstraint { 446 | pin(to: item.superview!.safeAreaLayoutGuide, inset: inset) 447 | } 448 | 449 | @discardableResult func readableContentPin(inset: CGFloat = 0) -> NSLayoutConstraint { 450 | pin(to: item.superview!.readableContentGuide, inset: inset) 451 | } 452 | 453 | @discardableResult func marginsPin(inset: CGFloat = 0) -> NSLayoutConstraint { 454 | pin(to: item.superview!.layoutMarginsGuide, inset: inset) 455 | } 456 | } 457 | 458 | extension AnchorCollectionEdges { 459 | @discardableResult func safeAreaPin(insets: EdgeInsets = .zero, axis: Axis? = nil, alignment: Alignmment = .fill) -> [NSLayoutConstraint] { 460 | pin(to: item.superview!.safeAreaLayoutGuide, insets: insets, axis: axis, alignment: alignment) 461 | } 462 | 463 | @discardableResult func readableContentPin(insets: EdgeInsets = .zero, axis: Axis? = nil, alignment: Alignmment = .fill) -> [NSLayoutConstraint] { 464 | pin(to: item.superview!.readableContentGuide, insets: insets, axis: axis, alignment: alignment) 465 | } 466 | 467 | @discardableResult func marginsPin(insets: EdgeInsets = .zero, axis: Axis? = nil, alignment: Alignmment = .fill) -> [NSLayoutConstraint] { 468 | pin(to: item.superview!.layoutMarginsGuide, insets: insets, axis: axis, alignment: alignment) 469 | } 470 | } 471 | --------------------------------------------------------------------------------