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"