diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/UINotifications.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/UINotifications.xcscheme
new file mode 100644
index 0000000..6c46958
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/UINotifications.xcscheme
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Example/UINotifications-Example.xcodeproj/project.pbxproj b/Example/UINotifications-Example.xcodeproj/project.pbxproj
index 495aabd..e1d943b 100644
--- a/Example/UINotifications-Example.xcodeproj/project.pbxproj
+++ b/Example/UINotifications-Example.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 52;
+ objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -120,8 +120,9 @@
50042BC61F18BC85007209B7 /* Project object */ = {
isa = PBXProject;
attributes = {
+ BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0830;
- LastUpgradeCheck = 1220;
+ LastUpgradeCheck = 1500;
ORGANIZATIONNAME = WeTransfer;
TargetAttributes = {
50042BCD1F18BC85007209B7 = {
@@ -166,6 +167,7 @@
/* Begin PBXShellScriptBuildPhase section */
5078C7891F18C5A1006EB23F /* SwiftLint */ = {
isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@@ -216,6 +218,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -249,6 +252,7 @@
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -277,6 +281,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -310,6 +315,7 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
diff --git a/Example/UINotifications-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/UINotifications-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata
index a85f923..919434a 100644
--- a/Example/UINotifications-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata
+++ b/Example/UINotifications-Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -2,6 +2,6 @@
+ location = "self:">
diff --git a/Example/UINotifications-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/UINotifications-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..68b3505
--- /dev/null
+++ b/Example/UINotifications-Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,16 @@
+{
+ "object": {
+ "pins": [
+ {
+ "package": "swift-concurrency-extras",
+ "repositoryURL": "https://github.com/pointfreeco/swift-concurrency-extras.git",
+ "state": {
+ "branch": null,
+ "revision": "ea631ce892687f5432a833312292b80db238186a",
+ "version": "1.0.0"
+ }
+ }
+ ]
+ },
+ "version": 1
+}
diff --git a/Example/UINotifications-Example/ViewController.swift b/Example/UINotifications-Example/ViewController.swift
index 6325823..3de561f 100644
--- a/Example/UINotifications-Example/ViewController.swift
+++ b/Example/UINotifications-Example/ViewController.swift
@@ -49,7 +49,7 @@ enum NotificationStyle: UINotificationStyle {
}
var thumbnailSize: CGSize {
- return .init(width: 50, height: 50)
+ return CGSize(width: 50, height: 50)
}
/// The height of the notification which applies on the notification view.
@@ -113,7 +113,15 @@ final class ViewController: UIViewController {
})
}
- let notification = UINotification(content: UINotificationContent(title: title, subtitle: subtitle, image: image), style: style, action: action)
+ let notification = UINotification(
+ content: UINotificationContent(
+ title: title,
+ subtitle: subtitle,
+ image: image
+ ),
+ style: style,
+ action: action
+ )
if addButtonSwitch.isOn {
let button = UIButton(type: .system)
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..68b3505
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,16 @@
+{
+ "object": {
+ "pins": [
+ {
+ "package": "swift-concurrency-extras",
+ "repositoryURL": "https://github.com/pointfreeco/swift-concurrency-extras.git",
+ "state": {
+ "branch": null,
+ "revision": "ea631ce892687f5432a833312292b80db238186a",
+ "version": "1.0.0"
+ }
+ }
+ ]
+ },
+ "version": 1
+}
diff --git a/Package.swift b/Package.swift
index c0a75f6..6aa16d9 100644
--- a/Package.swift
+++ b/Package.swift
@@ -11,11 +11,17 @@ let package = Package(
products: [
.library(name: "UINotifications", targets: ["UINotifications"])
],
+ dependencies: [
+ .package(url: "https://github.com/pointfreeco/swift-concurrency-extras.git", from: "1.0.0")
+ ],
targets: [
.target(name: "UINotifications", path: "Sources"),
.testTarget(
name: "UINotificationsTests",
- dependencies: ["UINotifications"],
+ dependencies: [
+ "UINotifications",
+ .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras")
+ ],
path: "Tests",
resources: [.copy("Resources/iconToastChevron.png")]
)
diff --git a/README.md b/README.md
index a5ef180..126a2cf 100644
--- a/README.md
+++ b/README.md
@@ -144,7 +144,9 @@ manualDismissTrigger.trigger() // Dismiss
- Set your custom view on the `UINotificationCenter`:
```swift
-UINotificationCenter.current.defaultNotificationViewType = MyCustomNotificationView.self
+UINotificationCenter.current.configuration = UINotificationCenterConfiguration(
+ defaultNotificationViewType: MyCustomNotificationView.self
+)
```
### Use a custom UIButton
@@ -163,7 +165,9 @@ Create a custom presenter to manage presentation and dismiss animations.
- Set your custom presenter on the `UINotificationCenter`:
```swift
-UINotificationCenter.current.presenterType = MyCustomPresenter.self
+UINotificationCenter.current.configuration = UINotificationCenterConfiguration(
+ presenterType: MyCustomPresenter.self
+)
```
*Checkout `UINotificationEaseOutEaseInPresenter` for an example.*
@@ -174,7 +178,9 @@ By default, notifications which are already queued will not be queued again. Thi
To disable this setting:
```swift
-UINotificationCenter.current.isDuplicateQueueingAllowed = true
+UINotificationCenter.current.configuration = UINotificationCenterConfiguration(
+ isDuplicateQueueingAllowed: true
+)
```
## Communication
diff --git a/Sources/UINotification.swift b/Sources/UINotification.swift
index 816876e..37cc17e 100644
--- a/Sources/UINotification.swift
+++ b/Sources/UINotification.swift
@@ -15,7 +15,7 @@ public protocol UINotificationAction {
}
/// Defines a style which will be applied on the notification view.
-public protocol UINotificationStyle {
+public protocol UINotificationStyle: Sendable {
var titleFont: UIFont { get }
var subtitleFont: UIFont { get }
var titleTextColor: UIColor { get }
@@ -39,6 +39,7 @@ public protocol UINotificationStyle {
}
/// Handles changes in UINotification
+@MainActor
protocol UINotificationDelegate: AnyObject {
// Called when Notification is updated.
func didUpdateContent(in notificaiton: UINotification)
@@ -48,14 +49,22 @@ protocol UINotificationDelegate: AnyObject {
}
/// An UINotification which can be showed on top of the `UINavigationBar` and `UIStatusBar`
-public final class UINotification: Equatable {
+/// `@unchecked Sendable` as we synchronize access using a lock queue.
+public final class UINotification: Equatable, @unchecked Sendable {
+
+ static let lockQueue = DispatchQueue(
+ label: "wetransfer.uinotification.lock.queue",
+ qos: .userInitiated,
+ target: .global(qos: .userInitiated)
+ )
/// Defines the height which will be applied on the notification view.
- public enum Height {
+ public enum Height: Sendable {
case statusBar
case navigationBar
case custom(height: CGFloat)
+ @MainActor
internal var value: CGFloat {
switch self {
case .statusBar:
@@ -69,8 +78,13 @@ public final class UINotification: Equatable {
}
/// The content of the notification.
- public var content: UINotificationContent
-
+ public var content: UINotificationContent {
+ Self.lockQueue.sync { notificationContent }
+ }
+
+ /// A private backup property to synchronize access using a lock queue.
+ private var notificationContent: UINotificationContent
+
/// The style of the notification which applies on the notification view.
public let style: UINotificationStyle
@@ -78,7 +92,9 @@ public final class UINotification: Equatable {
/// Setting this property will add the button, even if the notification is already visible.
public var button: UIButton? {
didSet {
- delegate?.didUpdateButton(in: self)
+ Task { @MainActor in
+ delegate?.didUpdateButton(in: self)
+ }
}
}
@@ -88,15 +104,19 @@ public final class UINotification: Equatable {
weak var delegate: UINotificationDelegate?
public init(content: UINotificationContent, style: UINotificationStyle = UINotificationSystemStyle(), action: UINotificationAction? = nil) {
- self.content = content
+ self.notificationContent = content
self.style = style
self.action = action
}
/// Updates the content of the notification
public func update(_ content: UINotificationContent) {
- self.content = content
- delegate?.didUpdateContent(in: self)
+ Self.lockQueue.sync {
+ self.notificationContent = content
+ }
+ Task { @MainActor in
+ delegate?.didUpdateContent(in: self)
+ }
}
public static func == (lhs: UINotification, rhs: UINotification) -> Bool {
@@ -104,7 +124,7 @@ public final class UINotification: Equatable {
}
}
-public struct UINotificationContent: Equatable {
+public struct UINotificationContent: Equatable, Sendable {
/// The title which will be showed inside the notification.
public let title: String
diff --git a/Sources/UINotificationCenter.swift b/Sources/UINotificationCenter.swift
index ad37871..1fe8100 100644
--- a/Sources/UINotificationCenter.swift
+++ b/Sources/UINotificationCenter.swift
@@ -8,26 +8,56 @@
import UIKit
-/// Handles the queueing and presenting of `UINotification`s
-public final class UINotificationCenter {
-
- /// The `UINotificationCenter` for the current application
- public static let current = UINotificationCenter()
-
- // MARK: Public properties
+public struct UINotificationCenterConfiguration {
/// The type of presenter to use for presenting notifications. Change this to change the way notifications need to be presented.
- public var presenterType: UINotificationPresenter.Type = UINotificationEaseOutEaseInPresenter.self
-
+ let presenterType: UINotificationPresenter.Type
+
/// The type of view which will be used to present the notifications if not overriden by the `show` method.
- public var defaultNotificationViewType: UINotificationView.Type = UINotificationView.self
-
+ let defaultNotificationViewType: UINotificationView.Type
+
/// The window level that notification should appear at. The default level is over the status bar.
/// Changing the window level while a notification is displayed might give some issues.
- public var windowLevel: UIWindow.Level = UIWindow.Level.statusBar
-
+ let windowLevel: UIWindow.Level
+
/// If `true`, the same notifications can be queued. This can result in duplicate notifications being presented after each other.
- public var isDuplicateQueueingAllowed: Bool = false
-
+ let isDuplicateQueueingAllowed: Bool
+
+ /// Creates a new notification center configuration.
+ /// - Parameters:
+ /// - presenterType: The type of presenter to use for presenting notifications.
+ /// Change this to change the way notifications need to be presented.
+ /// - defaultNotificationViewType: The type of view which will be used to present the notifications if not
+ /// overriden by the `show` method.
+ /// - windowLevel: The window level that notification should appear at. The default level is over the status bar.
+ /// Changing the window level while a notification is displayed might give some issues.
+ /// - isDuplicateQueueingAllowed: If `true`, the same notifications can be queued. This can result in duplicate notifications
+ /// being presented after each other.
+ public init(
+ presenterType: UINotificationPresenter.Type = UINotificationEaseOutEaseInPresenter.self,
+ defaultNotificationViewType: UINotificationView.Type = UINotificationView.self,
+ windowLevel: UIWindow.Level = UIWindow.Level.statusBar,
+ isDuplicateQueueingAllowed: Bool = false
+ ) {
+ self.presenterType = presenterType
+ self.defaultNotificationViewType = defaultNotificationViewType
+ self.windowLevel = windowLevel
+ self.isDuplicateQueueingAllowed = isDuplicateQueueingAllowed
+ }
+}
+
+/// Handles the queueing and presenting of `UINotification`s
+/// `@unchecked Sendable` since all its mutation happens either on the Main Actor or
+/// is unlikely going to be in sequence with write operations.
+public final class UINotificationCenter: @unchecked Sendable {
+
+ // MARK: Public properties
+
+ /// The `UINotificationCenter` for the current application
+ public static let current = UINotificationCenter()
+
+ /// The configuration to use for presenting notifications.
+ public var configuration = UINotificationCenterConfiguration()
+
// MARK: Private properties
/// The window which will be placed on top of the application window.
@@ -35,7 +65,9 @@ public final class UINotificationCenter {
internal lazy var window: UIWindow = {
let window: UIWindow
- if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
+ if let windowScene = UIApplication.shared.connectedScenes.first(
+ where: { $0.activationState == .foregroundActive }
+ ) as? UIWindowScene {
window = UINotificationPresentationWindow(windowScene: windowScene)
} else {
window = UINotificationPresentationWindow()
@@ -53,15 +85,24 @@ public final class UINotificationCenter {
/// Defines the current running presenter.
internal weak var currentPresenter: UINotificationPresenter?
-
+
/// Request to present the given notification.
///
/// - Parameter notification: The notification to be presented.
/// - Parameter notificationViewType: Optional notification view type which overrides the default notification view type.
/// - Parameter dismissTrigger: Optional dismiss trigger to use for the animation. If `nil` the default trigger will be used.
/// - Returns: An `UINotificationRequest` for the requested notification presentation. Can be cancelled using `cancel()`.
- @discardableResult public func show(notification: UINotification, notificationViewType: UINotificationView.Type? = nil, dismissTrigger: UINotificationDismissTrigger? = nil) -> UINotificationRequest {
- return queue.add(notification, notificationViewType: notificationViewType ?? defaultNotificationViewType, dismissTrigger: dismissTrigger, allowDuplicates: isDuplicateQueueingAllowed)
+ @discardableResult public func show(
+ notification: UINotification,
+ notificationViewType: UINotificationView.Type? = nil,
+ dismissTrigger: UINotificationDismissTrigger? = nil
+ ) -> UINotificationRequest {
+ return queue.add(
+ notification,
+ notificationViewType: notificationViewType ?? configuration.defaultNotificationViewType,
+ dismissTrigger: dismissTrigger,
+ allowDuplicates: configuration.isDuplicateQueueingAllowed
+ )
}
}
@@ -69,19 +110,31 @@ public final class UINotificationCenter {
extension UINotificationCenter: UINotificationQueueDelegate {
/// Handles the request which is ready to be presented. Links the presenter to the `UINotification` and `UINotificationView`.
internal func handle(_ request: UINotificationRequest) {
- let notificationView = request.notificationViewType.init(notification: request.notification)
- let presentationContext = UINotificationPresentationContext(request: request, containerWindow: window, windowLevel: windowLevel, notificationView: notificationView)
- let presenter = presenterType.init(presentationContext: presentationContext, dismissTrigger: request.dismissTrigger)
- notificationView.presenter = presenter
- currentPresenter = presenter
- presenter.present()
+ Task { @MainActor in
+ let notificationView = request.notificationViewType.init(notification: request.notification)
+ let presentationContext = UINotificationPresentationContext(
+ request: request,
+ containerWindow: window,
+ windowLevel: configuration.windowLevel,
+ notificationView: notificationView
+ )
+ let presenter = configuration.presenterType.init(presentationContext: presentationContext, dismissTrigger: request.dismissTrigger)
+ notificationView.presenter = presenter
+ currentPresenter = presenter
+ presenter.present()
+ }
}
}
-/// A custom `UIWindow` to use for the presentatation of the notifications. Will make sure the UI touches are only forwarded when inside any presented notification.
+/// A custom `UIWindow` to use for the presentatation of the notifications.
+/// Will make sure the UI touches are only forwarded when inside any presented notification.
internal final class UINotificationPresentationWindow: UIWindow {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
- guard let rootViewController = self.rootViewController, let notificationView = rootViewController.view.subviews.first(where: { $0 is UINotificationView }) else { return false }
+ guard
+ let rootViewController = self.rootViewController,
+ let notificationView = rootViewController.view.subviews.first(where: { $0 is UINotificationView }) else {
+ return false
+ }
return notificationView.frame.contains(point)
}
}
diff --git a/Sources/UINotificationDismissTrigger.swift b/Sources/UINotificationDismissTrigger.swift
index a62852a..8750889 100644
--- a/Sources/UINotificationDismissTrigger.swift
+++ b/Sources/UINotificationDismissTrigger.swift
@@ -9,18 +9,21 @@
import Foundation
/// Defines a dismissable view.
-public protocol Dismissable: AnyObject {
+@MainActor
+public protocol Dismissable: AnyObject, Sendable {
/// Dimisses the view.
func dismiss()
}
/// A trigger which can be used to dismiss an `UINotificationView`.
-public protocol UINotificationDismissTrigger: AnyObject {
+@MainActor
+public protocol UINotificationDismissTrigger: AnyObject, Sendable {
/// The target to dismiss.
var target: Dismissable? { get set }
}
/// A trigger which is schedulable and therefor cancelable.
+@MainActor
public protocol UINotificationSchedulableDismissTrigger: UINotificationDismissTrigger {
/// Schedules the dismiss trigger to let the notification animate out after the `displayDuration`.
func schedule()
diff --git a/Sources/UINotificationDismissTriggers/UINotificationDurationDismissTrigger.swift b/Sources/UINotificationDismissTriggers/UINotificationDurationDismissTrigger.swift
index 9578bb5..0a2781a 100644
--- a/Sources/UINotificationDismissTriggers/UINotificationDurationDismissTrigger.swift
+++ b/Sources/UINotificationDismissTriggers/UINotificationDurationDismissTrigger.swift
@@ -22,7 +22,7 @@ public final class UINotificationDurationDismissTrigger: UINotificationSchedulab
public init(duration: TimeInterval) {
self.duration = duration
}
-
+
public func schedule() {
let dismissWorkItem = DispatchWorkItem { [weak self] in
self?.target?.dismiss()
diff --git a/Sources/UINotificationPresentationContext.swift b/Sources/UINotificationPresentationContext.swift
index 844f864..5cb9778 100644
--- a/Sources/UINotificationPresentationContext.swift
+++ b/Sources/UINotificationPresentationContext.swift
@@ -9,6 +9,7 @@
import UIKit
/// Provides information about an in-progress notification presentation.
+@MainActor
public final class UINotificationPresentationContext {
/// The window in which the `UINotificationView` will be presented.
diff --git a/Sources/UINotificationPresenter.swift b/Sources/UINotificationPresenter.swift
index a75f860..b6e2e1d 100644
--- a/Sources/UINotificationPresenter.swift
+++ b/Sources/UINotificationPresenter.swift
@@ -9,7 +9,7 @@
import UIKit
/// The state of a notification presenter.
-public enum UINotificationPresenterState {
+public enum UINotificationPresenterState: Sendable {
/// Ready to be presented.
case idle
/// Currently animating out.
@@ -23,7 +23,8 @@ public enum UINotificationPresenterState {
}
/// Defines a protocol for a UINotification presenter & dismisser.
-public protocol UINotificationPresenter: Dismissable {
+@MainActor
+public protocol UINotificationPresenter: Dismissable, Sendable {
/// Provides information about an in-progress notification presentation.
var presentationContext: UINotificationPresentationContext { get }
diff --git a/Sources/UINotificationPresenters/UINotificationEaseInOutPresenter.swift b/Sources/UINotificationPresenters/UINotificationEaseInOutPresenter.swift
index 80a9a2a..1a709f0 100644
--- a/Sources/UINotificationPresenters/UINotificationEaseInOutPresenter.swift
+++ b/Sources/UINotificationPresenters/UINotificationEaseInOutPresenter.swift
@@ -55,17 +55,19 @@ public final class UINotificationEaseOutEaseInPresenter: UINotificationPresenter
})
}
- public func dismiss() {
- guard state == .presented else { return }
- state = .dismissing
+ public nonisolated func dismiss() {
+ Task { @MainActor in
+ guard state == .presented else { return }
+ state = .dismissing
- presentationContext.notificationView.topConstraint?.constant = -presentationContext.notification.style.height.value
+ presentationContext.notificationView.topConstraint?.constant = -presentationContext.notification.style.height.value
- UIView.animate(withDuration: outDuration, delay: 0, options: UIView.AnimationOptions.curveEaseIn, animations: {
- self.presentationContext.containerWindow.layoutIfNeeded()
- }, completion: { (_) in
- self.state = .idle
- self.presentationContext.completePresentation()
- })
+ UIView.animate(withDuration: outDuration, delay: 0, options: UIView.AnimationOptions.curveEaseIn, animations: {
+ self.presentationContext.containerWindow.layoutIfNeeded()
+ }, completion: { (_) in
+ self.state = .idle
+ self.presentationContext.completePresentation()
+ })
+ }
}
}
diff --git a/Sources/UINotificationQueue.swift b/Sources/UINotificationQueue.swift
index df0afb6..23baf19 100644
--- a/Sources/UINotificationQueue.swift
+++ b/Sources/UINotificationQueue.swift
@@ -9,7 +9,7 @@
import Foundation
internal protocol UINotificationQueueDelegate: AnyObject {
-
+
/// Will be called when a new request is ready to be handled.
/// Will be called immediately if no request is currently running.
/// If a request is currently handled, this will be called when that request is finished or cancelled.
@@ -18,19 +18,19 @@ internal protocol UINotificationQueueDelegate: AnyObject {
func handle(_ request: UINotificationRequest)
}
-internal final class UINotificationQueue {
-
+internal final class UINotificationQueue: @unchecked Sendable {
+
/// The currently queued requests.
internal var requests = [UINotificationRequest]()
private weak var delegate: UINotificationQueueDelegate?
-
+
/// The queue which is used to make sure the requests array is only modified serially.
private let lockQueue = DispatchQueue(label: "com.uinotifications.LockQueue") // Defaults to a serial queue
-
+
init(delegate: UINotificationQueueDelegate) {
self.delegate = delegate
}
-
+
/// Adds the given notification to the queue. If `allowDuplicates` is `false`, the returned notification request can have a cancelled state directly after creation.
///
/// - Parameters:
@@ -41,13 +41,13 @@ internal final class UINotificationQueue {
/// - Returns: The created notification request.
@discardableResult func add(_ notification: UINotification, notificationViewType: UINotificationView.Type, dismissTrigger: UINotificationDismissTrigger? = nil, allowDuplicates: Bool = false) -> UINotificationRequest {
let request = UINotificationRequest(notification: notification, delegate: self, notificationViewType: notificationViewType, dismissTrigger: dismissTrigger)
-
+
if !allowDuplicates, requests.contains(where: { (queuedRequest) -> Bool in
return queuedRequest.notification == request.notification
}) {
request.cancel()
}
-
+
lockQueue.sync {
if request.state == .idle {
requests.append(request)
@@ -56,24 +56,24 @@ internal final class UINotificationQueue {
updateRunningRequest()
return request
}
-
+
internal func remove(_ request: UINotificationRequest) {
lockQueue.sync {
requests.removeAll(where: { $0 == request })
}
updateRunningRequest()
}
-
+
internal func updateRunningRequest() {
guard requestIsRunning() == false, let request = nextRequestToRun() else { return }
request.start()
delegate?.handle(request)
}
-
+
internal func requestIsRunning() -> Bool {
return requests.lazy.contains(where: { $0.state == .running })
}
-
+
internal func nextRequestToRun() -> UINotificationRequest? {
return requests.lazy.first(where: { $0.state == .idle })
}
diff --git a/Sources/UINotificationRequest.swift b/Sources/UINotificationRequest.swift
index 1495bba..f50f676 100644
--- a/Sources/UINotificationRequest.swift
+++ b/Sources/UINotificationRequest.swift
@@ -20,12 +20,10 @@ protocol UINotificationRequestDelegate: AnyObject {
/// Defines the request of a notification presentation.
/// Can be in idle, running or finished state. Can also be in a cancelled state if `cancel()` is called.
-public final class UINotificationRequest: Equatable {
-
- struct WeakRequestDelegate {
- weak var target: UINotificationRequestDelegate?
- }
-
+public final class UINotificationRequest: Equatable, @unchecked Sendable {
+ /// The queue which is used to make sure the requests array is only modified serially.
+ private static let lockQueue = DispatchQueue(label: "com.uinotifications.request.LockQueue")
+
public enum State {
/// Waiting to run
case idle
@@ -44,8 +42,8 @@ public final class UINotificationRequest: Equatable {
public let notification: UINotification
/// Optional dismiss trigger to use for the animation. If `nil` the default trigger will be used.
- public weak var dismissTrigger: UINotificationDismissTrigger?
-
+ public let dismissTrigger: UINotificationDismissTrigger?
+
/// The type of view to use for this notification.
public let notificationViewType: UINotificationView.Type
@@ -53,18 +51,25 @@ public final class UINotificationRequest: Equatable {
private let identifier: UUID
/// The current state of the request.
- public private(set) var state: UINotificationRequest.State = .idle {
- didSet {
- delegates.forEach { $0.target?.notificationRequest(self, didChangeStateTo: state) }
+ private(set) var state: UINotificationRequest.State {
+ get {
+ Self.lockQueue.sync { _state }
+ }
+ set {
+ Self.lockQueue.sync { _state = newValue }
+ delegate.notificationRequest(self, didChangeStateTo: newValue)
}
}
-
+ private var _state: UINotificationRequest.State = .idle
+
/// An array of listener to delegate callbacks.
- var delegates = [WeakRequestDelegate]()
-
+ /// Note: we're not referencing this weakly to make this type `Sendable`. We can do this
+ /// since the delegate will always exist since it's the `UINotificationQueue`.
+ private let delegate: UINotificationRequestDelegate
+
internal init(notification: UINotification, delegate: UINotificationRequestDelegate, notificationViewType: UINotificationView.Type, dismissTrigger: UINotificationDismissTrigger? = nil) {
self.notification = notification
- self.delegates.append(WeakRequestDelegate(target: delegate))
+ self.delegate = delegate
self.notificationViewType = notificationViewType
self.dismissTrigger = dismissTrigger
self.identifier = UUID()
@@ -87,7 +92,7 @@ public final class UINotificationRequest: Equatable {
state = .finished
}
- public static func == (lhs: UINotificationRequest, rhs: UINotificationRequest) -> Bool {
+ nonisolated public static func == (lhs: UINotificationRequest, rhs: UINotificationRequest) -> Bool {
return lhs.identifier == rhs.identifier
}
}
diff --git a/Submodules/WeTransfer-iOS-CI b/Submodules/WeTransfer-iOS-CI
index 5dce608..7ea02f9 160000
--- a/Submodules/WeTransfer-iOS-CI
+++ b/Submodules/WeTransfer-iOS-CI
@@ -1 +1 @@
-Subproject commit 5dce608636ba27f0e51ee7c01bcec1d36fcac58e
+Subproject commit 7ea02f9596d12e32a24832b0f9c6ac485cdac1c2
diff --git a/Tests/Extensions/XCTestExtensions.swift b/Tests/Extensions/XCTestExtensions.swift
index 32958ca..eed3178 100644
--- a/Tests/Extensions/XCTestExtensions.swift
+++ b/Tests/Extensions/XCTestExtensions.swift
@@ -11,28 +11,29 @@ import XCTest
extension XCTestCase {
/// Checks for the callback to be the expected value within the given timeout.
+ /// Awaiting result using a Task.sleep.
+ /// - Note: See `waitForConditionUsingMainActor` to validate the condition on the main actor.
///
/// - Parameters:
/// - condition: The condition to check for.
/// - timeout: The timeout in which the callback should return true.
/// - description: A string to display in the test log for this expectation, to help diagnose failures.
- func waitFor(_ condition: @autoclosure () -> Bool, timeout: TimeInterval, description: String) {
- let expectation = self.expectation(description: description)
-
- let end = Date().addingTimeInterval(timeout)
-
- while !condition() && 0 < end.timeIntervalSinceNow {
- if RunLoop.current.run(mode: RunLoop.Mode.default, before: Date(timeIntervalSinceNow: 0.002)) {
- Thread.sleep(forTimeInterval: 0.002)
- }
- }
-
- if !condition() {
- XCTFail("Timed out waiting for condition to be true")
- } else {
- expectation.fulfill()
- }
+ func waitForCondition(
+ _ condition: @autoclosure () throws -> Bool,
+ timeout: TimeInterval,
+ description: String = "",
+ file: StaticString = #file,
+ line: UInt = #line
+ ) async rethrows {
+ let end: Date = Date().addingTimeInterval(timeout)
- waitForExpectations(timeout: 5.0, handler: nil)
+ var value = false
+
+ repeat {
+ value = try condition()
+ try? await Task.sleep(nanoseconds: 1_000_000) // 0.001 second
+ } while !value && end.timeIntervalSinceNow > 0
+
+ XCTAssertTrue(value, "➡️🚨 Timed out waiting for condition to be true: \"\(description)\"", file: file, line: line)
}
}
diff --git a/Tests/UINotificationCenterTests.swift b/Tests/UINotificationCenterTests.swift
index e2ab9cf..2180854 100644
--- a/Tests/UINotificationCenterTests.swift
+++ b/Tests/UINotificationCenterTests.swift
@@ -8,9 +8,17 @@
import XCTest
@testable import UINotifications
+import ConcurrencyExtras
+@MainActor
final class UINotificationCenterTests: UINotificationTestCase {
+ override func invokeTest() {
+ withMainSerialExecutor {
+ super.invokeTest()
+ }
+ }
+
/// When a notification is requested, it should be added to the queue.
func testShowNotificationQueue() {
let notificationCenter = UINotificationCenter()
@@ -26,36 +34,45 @@ final class UINotificationCenterTests: UINotificationTestCase {
}
/// When a custom default notification view is set, it should be used for presentation.
- func testCustomDefaultNotificationView() {
+ func testCustomDefaultNotificationView() async {
let notificationCenter = UINotificationCenter()
- notificationCenter.defaultNotificationViewType = MockNotificationView.self
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ defaultNotificationViewType: MockNotificationView.self
+ )
notificationCenter.show(notification: notification)
+ await Task.yield()
+
XCTAssert(notificationCenter.currentPresenter?.presentationContext.notificationView is MockNotificationView, "The custom view should be used")
}
/// When a custom notification view is passed inside the presentation method, it should be used for presentation.
- func testCustomNotificationView() {
+ func testCustomNotificationView() async {
let notificationCenter = UINotificationCenter()
notificationCenter.show(notification: notification, notificationViewType: MockNotificationView.self)
+ await Task.yield()
XCTAssert(notificationCenter.currentPresenter?.presentationContext.notificationView is MockNotificationView, "The custom view should be used")
}
/// When a custom presenter is set, it should be used for presenting.
- func testCustomNotificationPresenter() {
+ func testCustomNotificationPresenter() async {
let notificationCenter = UINotificationCenter()
- notificationCenter.presenterType = MockPresenter.self
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ presenterType: MockPresenter.self
+ )
notificationCenter.show(notification: notification)
-
- waitFor(notificationCenter.currentPresenter is MockPresenter, timeout: 5.0, description: "Custom presenter should be used for presenting")
+
+ await waitForCondition(notificationCenter.currentPresenter is MockPresenter, timeout: 5.0, description: "Custom presenter should be used for presenting")
}
- /// When a presentation is finished, the presenter should be releasted.
- func testPresenterReleasing() {
+ /// When a presentation is finished, the presenter should be released.
+ func testPresenterReleasing() async {
let notificationCenter = UINotificationCenter()
- notificationCenter.presenterType = MockPresenter.self
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ presenterType: MockPresenter.self
+ )
notificationCenter.show(notification: notification)
- waitFor(notificationCenter.currentPresenter == nil, timeout: 5.0, description: "Current presenter should be released and nil")
+ await waitForCondition(notificationCenter.currentPresenter == nil, timeout: 5.0, description: "Current presenter should be released and nil")
}
}
diff --git a/Tests/UINotificationDefaultElementsTests.swift b/Tests/UINotificationDefaultElementsTests.swift
index ad4718d..b9b257b 100644
--- a/Tests/UINotificationDefaultElementsTests.swift
+++ b/Tests/UINotificationDefaultElementsTests.swift
@@ -8,7 +8,9 @@
import XCTest
@testable import UINotifications
+import ConcurrencyExtras
+@MainActor
final class UINotificationDefaultElementsTests: UINotificationTestCase {
struct CustomStyle: UINotificationStyle {
@@ -34,7 +36,13 @@ final class UINotificationDefaultElementsTests: UINotificationTestCase {
self.thumbnailSize = thumbnailSize
}
}
-
+
+ override func invokeTest() {
+ withMainSerialExecutor {
+ super.invokeTest()
+ }
+ }
+
/// When the notification callback action is executed, the callback should be triggered.
func testNotificationCallbackAction() {
let expectation = self.expectation(description: "The callback should be triggered")
@@ -47,17 +55,20 @@ final class UINotificationDefaultElementsTests: UINotificationTestCase {
}
/// When using the easeOutEaseInPresenter, the presenter should be released correctly.
- func testEaseOutEaseInPresenter() {
+ func testEaseOutEaseInPresenter() async throws {
let notificationCenter = UINotificationCenter()
- notificationCenter.presenterType = UINotificationEaseOutEaseInPresenter.self
- notificationCenter.isDuplicateQueueingAllowed = true
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ presenterType: UINotificationEaseOutEaseInPresenter.self,
+ isDuplicateQueueingAllowed: true
+ )
notificationCenter.show(notification: notification)
-
- let presenter = notificationCenter.currentPresenter as! UINotificationEaseOutEaseInPresenter
+ await Task.yield()
+
+ let presenter = try XCTUnwrap(notificationCenter.currentPresenter as? UINotificationEaseOutEaseInPresenter)
XCTAssert(notificationCenter.queue.requests.first?.state == .running, "We should have a running notification")
- waitFor(notificationCenter.queue.requests.isEmpty, timeout: 5.0, description: "All requests should be cleaned up after presentation")
-
+ await waitForCondition(notificationCenter.queue.requests.isEmpty, timeout: 5.0, description: "All requests should be cleaned up after presentation")
+
presenter.state = .dismissing
presenter.present()
XCTAssert(presenter.state == .dismissing, "Presentation should not be possible when not in idle")
@@ -67,67 +78,77 @@ final class UINotificationDefaultElementsTests: UINotificationTestCase {
}
/// When passing a notification style with a custom height, this should be applied to the presented view.
- func testCustomNotificationViewHeight() {
+ func testCustomNotificationViewHeight() async {
let notificationCenter = UINotificationCenter()
- notificationCenter.isDuplicateQueueingAllowed = true
- notificationCenter.presenterType = MockPresenter.self
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ presenterType: MockPresenter.self,
+ isDuplicateQueueingAllowed: true
+ )
let customHeight: CGFloat = 500
let notification = UINotification(content: UINotificationContent(title: "test"), style: CustomStyle(customHeight: customHeight))
notificationCenter.show(notification: notification)
- waitFor(notificationCenter.currentPresenter?.presentationContext.notificationView.frame.size.height == customHeight, timeout: 5.0, description: "Custom height should be applied to the view")
+ await waitForCondition(notificationCenter.currentPresenter?.presentationContext.notificationView.frame.size.height == customHeight, timeout: 5.0, description: "Custom height should be applied to the view")
}
/// When passing a notification style with a custom thumbnail size, this should be applied to the presented view.
- func testCustomNotificationThumbnailSize() {
+ func testCustomNotificationThumbnailSize() async {
let notificationCenter = UINotificationCenter()
- notificationCenter.isDuplicateQueueingAllowed = true
- notificationCenter.presenterType = MockPresenter.self
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ presenterType: MockPresenter.self,
+ isDuplicateQueueingAllowed: true
+ )
let customSize = CGSize(width: 25, height: 25)
let notification = UINotification(content: UINotificationContent(title: "test"), style: CustomStyle(thumbnailSize: customSize))
notificationCenter.show(notification: notification)
- waitFor(notificationCenter.currentPresenter?.presentationContext.notificationView.imageView.frame.size == customSize, timeout: 5.0, description: "Custom height should be applied to the view")
+ await waitForCondition(notificationCenter.currentPresenter?.presentationContext.notificationView.imageView.frame.size == customSize, timeout: 5.0, description: "Custom height should be applied to the view")
}
/// When passing a notification style with a max width, this should be applied to the presented view.
- func testNotificationViewMaxWidth() {
+ func testNotificationViewMaxWidth() async {
let notificationCenter = UINotificationCenter()
- notificationCenter.isDuplicateQueueingAllowed = true
- notificationCenter.presenterType = MockPresenter.self
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ presenterType: MockPresenter.self,
+ isDuplicateQueueingAllowed: true
+ )
let customWidth: CGFloat = 100
let notification = UINotification(content: UINotificationContent(title: "test"), style: CustomStyle(maxWidth: customWidth))
notificationCenter.show(notification: notification)
- waitFor(notificationCenter.currentPresenter?.presentationContext.notificationView.frame.size.width == customWidth, timeout: 5.0, description: "Max width should be applied to the view")
+ await waitForCondition(notificationCenter.currentPresenter?.presentationContext.notificationView.frame.size.width == customWidth, timeout: 5.0, description: "Max width should be applied to the view")
}
/// When using the manual dismiss trigger, the notification should only dismiss after manually called.
- func testManualDismissTrigger() {
+ func testManualDismissTrigger() async {
let notificationCenter = UINotificationCenter()
- notificationCenter.isDuplicateQueueingAllowed = true
- notificationCenter.presenterType = MockPresenter.self
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ presenterType: MockPresenter.self,
+ isDuplicateQueueingAllowed: true
+ )
let dismissTrigger = UINotificationManualDismissTrigger()
notificationCenter.show(notification: notification, dismissTrigger: dismissTrigger)
- waitFor(notificationCenter.currentPresenter?.dismissTrigger.target != nil, timeout: 5.0, description: "Dismiss trigger target should be set")
+ await waitForCondition(notificationCenter.currentPresenter?.dismissTrigger.target != nil, timeout: 5.0, description: "Dismiss trigger target should be set")
XCTAssert((notificationCenter.currentPresenter as! MockPresenter).presented == true, "Notification should be presented")
XCTAssert((notificationCenter.currentPresenter as! MockPresenter).dismissed == false, "Notification should not be dismissed")
dismissTrigger.trigger()
- waitFor(notificationCenter.currentPresenter == nil, timeout: 5.0, description: "The presenter should be nil after dismiss is finished")
+ await waitForCondition(notificationCenter.currentPresenter == nil, timeout: 5.0, description: "The presenter should be nil after dismiss is finished")
}
/// When a touch is within the notification UIWindow bounds, it should only be handled when inside a presented notification.
- func testNotificationWindowTouches() {
+ func testNotificationWindowTouches() async {
let notificationCenter = UINotificationCenter()
- notificationCenter.isDuplicateQueueingAllowed = true
- notificationCenter.presenterType = MockPresenter.self
+ notificationCenter.configuration = UINotificationCenterConfiguration(
+ presenterType: MockPresenter.self,
+ isDuplicateQueueingAllowed: true
+ )
let dismissTrigger = UINotificationManualDismissTrigger()
XCTAssert(notificationCenter.window.point(inside: CGPoint(x: 0, y: 10), with: nil) == false, "When not presenting anything, the window should not handle touches.")
@@ -135,7 +156,7 @@ final class UINotificationDefaultElementsTests: UINotificationTestCase {
notificationCenter.show(notification: notification, dismissTrigger: dismissTrigger)
// Wait till the notification is presented
- waitFor(notificationCenter.currentPresenter?.dismissTrigger.target != nil, timeout: 5.0, description: "Dismiss trigger target should be set")
+ await waitForCondition(notificationCenter.currentPresenter?.dismissTrigger.target != nil, timeout: 5.0, description: "Dismiss trigger target should be set")
let notificationViewFrameOrigin = notificationCenter.currentPresenter!.presentationContext.notificationView.frame.origin
diff --git a/Tests/UINotificationDefaultViewTests.swift b/Tests/UINotificationDefaultViewTests.swift
index f5831f5..5c8dcce 100644
--- a/Tests/UINotificationDefaultViewTests.swift
+++ b/Tests/UINotificationDefaultViewTests.swift
@@ -8,9 +8,17 @@
import XCTest
@testable import UINotifications
+import ConcurrencyExtras
+@MainActor
final class UINotificationViewTests: UINotificationTestCase {
+ override func invokeTest() {
+ withMainSerialExecutor {
+ super.invokeTest()
+ }
+ }
+
/// When a notification view is tapped, the action trigger should be called.
func testTapGesture() {
let expectation = self.expectation(description: "Action should be triggered")
@@ -62,7 +70,7 @@ final class UINotificationViewTests: UINotificationTestCase {
}
/// When the notification content updates, the view should inherit these changes.
- func testNotificationContentUpdate() {
+ func testNotificationContentUpdate() async {
let notificationView = UINotificationView(notification: notification)
XCTAssert(notificationView.titleLabel.text == notification.content.title, "Title should match initial content")
@@ -70,31 +78,34 @@ final class UINotificationViewTests: UINotificationTestCase {
let updatedContent = UINotificationContent(title: "Updated title", subtitle: "Updated subtitle", image: LargeChevronStyle().chevronImage)
notification.update(updatedContent)
-
+ await Task.yield()
+
XCTAssert(notificationView.titleLabel.text == updatedContent.title, "Title of the notification view should update accordingly")
XCTAssert(notificationView.subtitleLabel.text == updatedContent.subtitle, "Subtitle of the notification view should update accordingly")
XCTAssert(notificationView.imageView.image == updatedContent.image, "Image of the notification view should update accordingly")
}
/// It should add the button as a subview when set.
- func testButton() {
+ func testButton() async {
let notificationView = UINotificationView(notification: notification)
XCTAssertNil(notificationView.button)
notification.button = UIButton(type: .system)
+ await Task.yield()
XCTAssertNotNil(notificationView.button)
XCTAssertNotNil(notificationView.button?.superview)
}
/// It should add the button as a subview when set, even after the notification is shown.
- func testButtonAfterShow() {
+ func testButtonAfterShow() async {
let notificationView = UINotificationView(notification: notification)
UINotificationCenter.current.show(notification: notification, dismissTrigger: nil)
XCTAssertNil(notificationView.button)
notification.button = UIButton(type: .system)
+ await Task.yield()
XCTAssertNotNil(notificationView.button)
XCTAssertNotNil(notificationView.button?.superview)
}
diff --git a/Tests/UINotificationTestCase.swift b/Tests/UINotificationTestCase.swift
index e0f6af5..6b95501 100644
--- a/Tests/UINotificationTestCase.swift
+++ b/Tests/UINotificationTestCase.swift
@@ -27,7 +27,7 @@ class UINotificationTestCase: XCTestCase {
self.presentationContext = presentationContext
self.dismissTrigger = dismissTrigger ?? UINotificationDurationDismissTrigger(duration: 0.0)
}
-
+
func present() {
dismissTrigger.target = self
(dismissTrigger as? UINotificationSchedulableDismissTrigger)?.schedule()
diff --git a/UINotifications.xctestplan b/UINotifications.xctestplan
new file mode 100644
index 0000000..db53cb7
--- /dev/null
+++ b/UINotifications.xctestplan
@@ -0,0 +1,24 @@
+{
+ "configurations" : [
+ {
+ "id" : "333C68F8-A6C1-435C-903A-129E4BBD2F51",
+ "name" : "Test Scheme Action",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+
+ },
+ "testTargets" : [
+ {
+ "target" : {
+ "containerPath" : "container:",
+ "identifier" : "UINotificationsTests",
+ "name" : "UINotificationsTests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index b6086f6..6da6406 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -1,7 +1,7 @@
# Fastlane requirements
fastlane_version "1.109.0"
-import "./../Submodules/WeTransfer-iOS-CI/Fastlane/Fastfile"
+import "./../Submodules/WeTransfer-iOS-CI/Fastlane/testing_lanes.rb"
import "./../Submodules/WeTransfer-iOS-CI/Fastlane/shared_lanes.rb"
desc "Run the tests and prepare for Danger"