├── .gitignore ├── License.md ├── Package.swift ├── Readme.md ├── Sources └── NotificationTask │ └── NotificationTask.swift └── Tests └── NotificationTaskTests ├── NotificationTaskTests.swift └── ReadmeExamples.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Sami Samhuri, https://samhuri.net 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "NotificationTask", 8 | platforms: [ 9 | .iOS(.v16), 10 | .macOS(.v12), 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, making them visible to other packages. 14 | .library( 15 | name: "NotificationTask", 16 | targets: ["NotificationTask"]), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package, defining a module or a test suite. 20 | // Targets can depend on other targets in this package and products from dependencies. 21 | .target( 22 | name: "NotificationTask"), 23 | .testTarget( 24 | name: "NotificationTaskTests", 25 | dependencies: ["NotificationTask"] 26 | ), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | **Obsoleted by [AsyncMonitor](https://github.com/samsonjs/AsyncMonitor) and no longer maintained** 2 | 3 | ---- 4 | 5 | # NotificationTask 6 | 7 | [![0 dependencies!](https://0dependencies.dev/0dependencies.svg)](https://0dependencies.dev) 8 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsamsonjs%2FNotificationTask%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/samsonjs/NotificationTask) 9 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsamsonjs%2FNotificationTask%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/samsonjs/NotificationTask) 10 | 11 | ## Overview 12 | 13 | NotificationTask is a Swift library that provides a simple and easy-to-use way to manage Swift concurrency `Task`s that observe notifications. The `NotificationTask` class allows you to create tasks that receive notifications and call the given closure with each notification, and optionally also with a context parameter so you don't have to manage its lifetime. 14 | 15 | It uses a Swift `Task` to ensure that all resources are properly cleaned up when the `NotificationTask` is cancelled or deallocated. This library is designed for use in view layer code, making it easy to integrate with your app's user interface and/or view-model layer. 16 | 17 | That's it. It's pretty trivial. I just got tired of writing it over and over. 18 | 19 | ## Installation 20 | 21 | Honestly you should probably just copy [NotificationTask.swift]() into your project. 22 | 23 | The only way to install this package is with Swift Package Manager (SPM). Please [file a new issue][] or submit a pull-request if you want to use something else. 24 | 25 | [NotificationTask.swift]: https://github.com/samsonjs/NotificationTask/blob/main/Sources/NotificationTask/NotificationTask.swift 26 | [file a new issue]: https://github.com/samsonjs/NotificationTask/issues/new 27 | 28 | ### Supported Platforms 29 | 30 | This package is supported on iOS 16.0+ and macOS 12.0+. 31 | 32 | ### Xcode 33 | 34 | When you're integrating this into an app with Xcode then go to your project's Package Dependencies and enter the URL `https://github.com/samsonjs/NotificationTask` and then go through the usual flow for adding packages. 35 | 36 | ### Swift Package Manager (SPM) 37 | 38 | When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file: 39 | 40 | ```swift 41 | .package(url: "https://github.com/samsonjs/NotificationTask.git", .upToNextMajor(from: "0.1.0")) 42 | ``` 43 | 44 | and then add `"NotificationTask"` to the list of dependencies in your target as well. 45 | 46 | ## Usage 47 | 48 | The simplest example uses a closure that receives the notification: 49 | 50 | ```swift 51 | import NotificationTask 52 | 53 | @MainActor class SimplestVersion { 54 | let task = NotificationTask(name: .NSCalendarDayChanged) { _ in 55 | print("The date is now \(Date.now)") 56 | } 57 | } 58 | ``` 59 | 60 | This example uses the context parameter to avoid reference cycles with `self`: 61 | 62 | ```swift 63 | import NotificationTask 64 | 65 | @MainActor class WithContext { 66 | var notificationTask: NotificationTask? 67 | 68 | init() { 69 | notificationTask = NotificationTask(name: .NSCalendarDayChanged, context: self) { _self, _ in 70 | _self.dayChanged() 71 | } 72 | } 73 | 74 | func dayChanged() { 75 | print("The date is now \(Date.now)") 76 | } 77 | } 78 | ``` 79 | 80 | The closure is async so you can await in there if you need to. 81 | 82 | ## License 83 | 84 | Copyright © 2025 [Sami Samhuri](https://samhuri.net) . Released under the terms of the [MIT License][MIT]. 85 | 86 | [MIT]: https://sjs.mit-license.org 87 | -------------------------------------------------------------------------------- /Sources/NotificationTask/NotificationTask.swift: -------------------------------------------------------------------------------- 1 | public import Foundation 2 | 3 | extension Notification: @unchecked @retroactive Sendable {} 4 | 5 | /// Manages a task that observes notifications. The tasks's lifetime is tied to the lifetime of the `NotificationTask` instance, so you 6 | /// don't need to explicitly cancel anything. As long as you don't create a reference cycle in the given closure/block then everything will 7 | /// work smoothly. 8 | /// 9 | /// When you don't need to worry about reference cycles because the closure is dead simple then just pass in the notification name to 10 | /// ``init(name:center:performing:)`` and then do your work in the closure. 11 | /// 12 | /// In other cases you need to be more careful, and there's a second initializer that accepts a context object (typically self) and holds a 13 | /// weak reference to it. Whenever that context object is deallocated then everything stops and is cleaned up automatically. Your closure 14 | /// always receives a strong reference. This one is called ``init(name:context:center:performing:)``. 15 | /// 16 | /// ``NotificationTask`` is bound to the main actor and is intended to be used in your view layer. This keeps it simple 17 | @MainActor public final class NotificationTask: Hashable { 18 | let task: Task 19 | 20 | init(task: Task) { 21 | self.task = task 22 | } 23 | 24 | public init( 25 | name: Notification.Name, 26 | center: NotificationCenter = .default, 27 | performing block: @escaping (Notification) async -> Void 28 | ) { 29 | self.task = Task { 30 | for await notification in center.notifications(named: name) { 31 | await block(notification) 32 | } 33 | } 34 | } 35 | 36 | /// Manages the weak reference to your context so you don't leak by mistake. 37 | public init( 38 | name: Notification.Name, 39 | context: Context, 40 | center: NotificationCenter = .default, 41 | performing block: @escaping (Context, Notification) async -> Void 42 | ) { 43 | self.task = Task { [weak context] in 44 | for await notification in center.notifications(named: name) { 45 | guard let context else { break } 46 | await block(context, notification) 47 | } 48 | } 49 | } 50 | 51 | deinit { 52 | task.cancel() 53 | } 54 | 55 | public func store(in set: inout Set) { 56 | set.insert(self) 57 | } 58 | } 59 | 60 | // MARK: - Hashable conformance 61 | 62 | public extension NotificationTask { 63 | nonisolated static func == (lhs: NotificationTask, rhs: NotificationTask) -> Bool { 64 | ObjectIdentifier(lhs) == ObjectIdentifier(rhs) 65 | } 66 | 67 | nonisolated func hash(into hasher: inout Hasher) { 68 | hasher.combine(task) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/NotificationTaskTests/NotificationTaskTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | @testable import NotificationTask 4 | 5 | @MainActor class NotificationTaskTests { 6 | let center = NotificationCenter() 7 | let name = Notification.Name("a random notification") 8 | 9 | private var subject: NotificationTask? 10 | 11 | @Test func callsBlockWhenNotificationsArePosted() async throws { 12 | await withCheckedContinuation { [center, name] continuation in 13 | subject = NotificationTask(name: name, center: center) { notification in 14 | #expect(notification.name == name) 15 | continuation.resume() 16 | } 17 | Task { 18 | center.post(name: name, object: nil) 19 | } 20 | } 21 | } 22 | 23 | @Test func doesNotCallBlockWhenOtherNotificationsArePosted() async throws { 24 | subject = NotificationTask(name: name, center: center) { notification in 25 | Issue.record("Called for irrelevant notification \(notification.name)") 26 | } 27 | Task { 28 | center.post(name: Notification.Name("something else"), object: nil) 29 | } 30 | try await Task.sleep(for: .milliseconds(10)) 31 | } 32 | 33 | @Test func stopsCallingBlockWhenDeallocated() async throws { 34 | subject = NotificationTask(name: name, center: center) { notification in 35 | Issue.record("Called after deallocation") 36 | } 37 | 38 | Task { 39 | subject = nil 40 | center.post(name: name, object: nil) 41 | } 42 | 43 | try await Task.sleep(for: .milliseconds(10)) 44 | } 45 | 46 | class Owner { 47 | let deinitHook: () -> Void 48 | 49 | private var task: NotificationTask? 50 | 51 | @MainActor init(center: NotificationCenter, deinitHook: @escaping () -> Void) { 52 | self.deinitHook = deinitHook 53 | let name = Notification.Name("irrelevant name") 54 | self.task = NotificationTask(name: name, context: self, center: center) { _, _ in } 55 | } 56 | 57 | deinit { 58 | deinitHook() 59 | } 60 | } 61 | 62 | private var owner: Owner? 63 | 64 | @Test(.timeLimit(.minutes(1))) func doesNotCreateReferenceCyclesWithContext() async throws { 65 | await withCheckedContinuation { continuation in 66 | self.owner = Owner(center: center) { 67 | continuation.resume() 68 | } 69 | self.owner = nil 70 | } 71 | } 72 | 73 | @Test func stopsCallingBlockWhenContextIsDeallocated() async throws { 74 | var context: NSObject? = NSObject() 75 | subject = NotificationTask(name: name, context: context!, center: center) { context, notification in 76 | Issue.record("Called after context was deallocated") 77 | } 78 | context = nil 79 | Task { 80 | center.post(name: name, object: nil) 81 | } 82 | try await Task.sleep(for: .milliseconds(10)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tests/NotificationTaskTests/ReadmeExamples.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import NotificationTask 3 | 4 | @MainActor class SimplestVersion { 5 | let task = NotificationTask(name: .NSCalendarDayChanged) { _ in 6 | print("The date is now \(Date.now)") 7 | } 8 | } 9 | 10 | @MainActor class WithContext { 11 | var notificationTask: NotificationTask? 12 | 13 | init() { 14 | notificationTask = NotificationTask(name: .NSCalendarDayChanged, context: self) { _self, _ in 15 | _self.dayChanged() 16 | } 17 | } 18 | 19 | func dayChanged() { 20 | print("The date is now \(Date.now)") 21 | } 22 | } 23 | --------------------------------------------------------------------------------