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