NiceNotifications

NiceNotifications 重新构想了 Apple 平台上的本地通知。

它为开发者提供了一种管理通知调度、权限和分组的新方式。

最基本的形式是,它可以帮助以声明式的方式轻松调度本地通知。

在其最先进的形式中,它引入了一种全新的本地通知查看方式,具有类似于 WidgetKitClockKit API 的“通知时间线”概念。

作者 & 维护者:@dreymonde

警告! 目前,NiceNotifications 处于早期 beta 阶段。某些 API 可能在版本之间发生变化。预计会出现重大更改。非常欢迎对 API 提出反馈!

展示

import NiceNotifications

LocalNotifications.schedule(permissionStrategy: .askSystemPermissionIfNeeded) {
    EveryMonth(forMonths: 12, starting: .thisMonth)
        .first(.friday)
        .at(hour: 20, minute: 15)
        .schedule(title: "First Friday", body: "Oakland let's go!")
}

安装

Swift Package Manager

  1. 点击 File → Swift Packages → Add Package Dependency。
  2. 输入 http://github.com/nicephoton/NiceNotifications.git

基础指南

调度一次性通知

// `NotificationContent` is a subclass of `UNNotificationContent`.
// You can also use `UNNotificationContent` directly
let content = NotificationContent(
    title: "Test Notification",
    body: "This one is for a README",
    sound: .default
)

LocalNotifications.schedule(
    content: content,
    at: Tomorrow().at(hour: 20, minute: 15),
    permissionStrategy: .scheduleIfSystemAllowed
)

什么是 permissionStrategy

在大多数情况下,NiceNotifications 会为您处理所有权限相关事宜。因此,您可以随时安排通知,权限策略将负责处理权限。

基本权限策略

  1. askSystemPermissionIfNeeded - 如果已经授予权限,将继续调度。如果尚未请求权限,它将请求系统权限,然后成功后继续。如果之前被拒绝,则不会继续。
  2. scheduleIfSystemAllowed - 仅当之前已授予权限时才会继续调度。否则,将不执行任何操作。

什么是 Tomorrow().at( ... )

NiceNotifications 使用 DateBuilder 以简单、清晰且易于阅读的方式帮助定义通知触发日期。请参阅 DateBuilder README 获取完整详细信息。

这是一个简短的参考

Today()
    .at(hour: 20, minute: 15)

NextWeek()
    .weekday(.saturday)
    .at(hour: 18, minute: 50)

EveryWeek(forWeeks: 10, starting: .thisWeek)
    .weekendStartDay
    .at(hour: 9, minute: 00)

EveryDay(forDays: 30, starting: .today)
    .at(hour: 19, minute: 15)
    
ExactlyAt(account.createdAt)
    .addingDays(15)
    
WeekOf(account.createdAt)
    .addingWeeks(1)
    .lastDay
    .at(hour: 10, minute: 00)

EveryMonth(forMonths: 12, starting: .thisMonth)
    .lastDay
    .at(hour: 23, minute: 50)

NextYear().addingYears(2)
    .firstMonth.addingMonths(3) // April (in Gregorian)
    .first(.thursday)

ExactDay(year: 2020, month: 10, day: 5)
    .at(hour: 10, minute: 15)

ExactYear(year: 2020)
    .lastMonth
    .lastDay

调度多个通知

LocalNotifications.schedule(permissionStrategy: .scheduleIfSystemAllowed) {
    Today()
        .at(hour: 20, minute: 30)
        .schedule(title: "Hello today", sound: .default)
    Tomorrow()
        .at(hour: 20, minute: 45)
        .schedule(title: "Hello tomorrow", sound: .default)
} completion: { result in
    if result.isSuccess {
        print("Scheduled!")
    }
}

调度循环通知

警告! iOS 只允许您最多安排 64 个本地通知,其余的将被静默丢弃。(文档)

func randomContent() -> NotificationContent {
    return NotificationContent(title: String(Int.random(in: 0...100)))
}

LocalNotifications.schedule(permissionStrategy: .askSystemPermissionIfNeeded) {
    EveryDay(forDays: 30, starting: .today)
        .at(hour: 20, minute: 30, second: 30)
        .schedule(with: randomContent)
}

对于基于日期的循环内容

func content(forTriggerDate date: Date) -> NotificationContent {
    // create content based on date
}

LocalNotifications.schedule(permissionStrategy: .askSystemPermissionIfNeeded) {
    EveryDay(forDays: 30, starting: .today)
        .at(hour: 20, minute: 30, second: 30)
        .schedule(with: content(forTriggerDate:))
}

取消通知组

let group = LocalNotifications.schedule(permissionStrategy: .askSystemPermissionIfNeeded) {
    EveryDay(forDays: 30, starting: .today)
        .at(hour: 15, minute: 30)
        .schedule(title: "Hello!")
}

// later:

LocalNotifications.remove(group: group)

在不调度的情况下请求权限

LocalNotifications.requestPermission(strategy: .askSystemPermissionIfNeeded) { success in
    if success {
        print("Allowed")
    }
}

获取当前系统权限状态

LocalNotifications.SystemAuthorization.getCurrent { status in
    switch status {
    case .allowed:
        print("allowed")
    case .deniedNow:
        print("denied")
    case .deniedPreviously:
        print("denied and needs to enable in settings")
    case .undetermined:
        print("not asked yet")
    }
    if status.isAllowed {
        print("can schedule!")
    }
}

直接使用 UNNotificationRequest 进行调度

如果您只想使用 NiceNotifications 的权限部分并自己创建 UNNotificationRequest 实例,请使用 .directSchedule 函数

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 360, repeats: true)

let content = UNMutableNotificationContent()
content.title = "Repeating"
content.body = "Every 6 minutes"
content.sound = .default

let request = UNNotificationRequest(
    identifier: "repeating_360",
    content: content,
    trigger: trigger
)

LocalNotifications.directSchedule(
    request: request,
    permissionStrategy: .askSystemPermissionIfNeeded
) // completion is optional

高级指南

通知时间线

NiceNotifications 最强大的功能是通知组内的时间线,它允许您以类似 WidgetKit 的方式描述您的整个本地通知体验。

案例研究:“每日名言”通知

假设我们有一个应用程序,每天早上从列表中显示不同的名言。 用户还可以禁用/启用某些名言,或添加自己的名言。

为此,我们需要定义一个新类来实现 LocalNotificationsGroup 协议

public protocol LocalNotificationsGroup {
    var groupIdentifier: String { get }    
    func getTimeline(completion: @escaping (NotificationsTimeline) -> ())
}

组不仅允许您在不同的体验之间进行清晰的逻辑分离,而且还允许您在每个组的基础上拥有用户权限(我们稍后会介绍)。

让我们实现我们的 DailyQuoteGroup

final class DailyQuoteGroup: LocalNotificationsGroup {
    let groupIdentifier: String = "dailyQuote"

    func getTimeline(completion: @escaping (NotificationsTimeline) -> ()) {
        let timeline = NotificationsTimeline {
            EveryDay(forDays: 50, starting: .today)
                .at(hour: 9, minute: 00)
                .schedule(title: "Storms make oaks take deeper root.")
        }
        completion(timeline)
    }
}

但这只会让我们在接下来的 50 天内获得 50 个相同的名言。 让我们让它更有趣,每天给用户一个真正的随机名言

final class DailyQuoteGroup: LocalNotificationsGroup {
    let groupIdentifier: String = "dailyQuote"

    func getTimeline(completion: @escaping (NotificationsTimeline) -> ()) {
        let timeline = NotificationsTimeline {
            EveryDay(forDays: 50, starting: .today)
                .at(hour: 9, minute: 00)
                .schedule(with: makeRandomQuoteContent)
        }
        completion(timeline)
    }

    private func makeRandomQuoteContent() -> NotificationContent? {
        guard let randomQuote = QuoteStore.enabledQuotes.randomElement() else {
            return nil
        }

        return NotificationContent(
            title: randomQuote,
            body: "Tap here for more daily inspiration"
        )
    }
}

看起来很棒! 每次调用 makeRandomQuoteContent 时,我们都会得到一个不同的名言,这正是我们想要的。

好的,那么我们现在用它做什么呢?

“重新调度”通知组

调度通知组很容易

LocalNotifications.reschedule(
    group: DailyQuoteGroup(),
    permissionStrategy: .askSystemPermissionIfNeeded
) // completion is optional

为什么称之为“重新调度”? 因为每次我们用同一个组调用这个函数时,整个时间线将被清除并重新创建。

为什么这很有用? 首先,假设用户已禁用其中一个名言的显示。 但它可能已经被安排好了! 没问题:我们将简单地再次调用 reschedule,它将不再显示

QuoteStore.disableQuote(userDisabledQuote)

LocalNotifications.reschedule(
    group: DailyQuoteGroup(),
    permissionStrategy: .scheduleIfSystemAllowed
)

由于 DailyQuoteGroup 使用 QuoteStore.enabledQuotes 生成随机名言,因此新重新调度的组将不再具有禁用的名言!

其次,您已经注意到,我们只安排了从“今天”开始的 50 天。 这是因为我们无法使用系统循环通知(因为它只允许我们对每个通知使用相同的内容),并且 iOS 只允许我们一次最多 64 个已调度的通知。

所以是的,它将要求我们定期重新调度该组以“重置”这 50 天。 最好的地方之一是 applicationDidFinishLaunchingWithOptions

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    LocalNotifications.reschedule(
        group: DailyQuoteGroup(),
        permissionStrategy: .scheduleIfSystemAllowed
    )

    return true
}

或者,您可以安排后台执行任务以定期刷新通知。

组级别权限

权限策略有两个不同的级别

以下是如何创建您自己的自定义权限策略

LocalNotifications.PermissionStrategy(
    groupLevel: PermissionStrategy.GroupLevel,
    systemLevel: PermissionStrategy.SystemLevel
)

权限策略将始终首先执行组级别策略,如果成功,将继续执行系统级别策略。

组级别

.bypass:

将跳过组级别权限检查并直接进入系统级别。 这不会更改现有的组级别权限(如果存在)。

.allowAutomatically:

将在组级别启用权限并保存该决定,然后继续进行系统级别检查。

如果用户之前禁用/拒绝了此组权限,则 .allowAutomatically 将覆盖该决定。

.askPermission(AskPermissionMode, PermissionAsker):

将在继续进行系统级别检查之前询问用户的许可,并将保存该决定。 只有在之前未授予许可的情况下才会询问许可,否则将直接继续进行系统级别检查。

AskPermissionMode:

PermissionAsker:

此类负责询问组级别权限。 您可以使用 .defaultAlert(on:) 显示预先制作的警报(仅限英语),使用 .alert(on:title:message:noActionTitle:yesActionTitle:),或创建您自己的警报

let permissionAsker = LocalNotifications.ApplicationLevelPermissionAsker { (completion) in
    // ask permission, then call completion with Result<Bool, Error>
}
.ifAlreadyAllowed:

仅当之前允许该类别时才会继续进行系统级别检查

.ifAllowed(other:):

仅当允许其他指定的类别时才会继续进行系统级别检查

系统级别

.askPermission:

如果需要,将请求系统通知权限

.ifAlreadyAllowed:

仅当系统已经允许时才会继续调度通知; 否则将不会继续

通知权限开关

对于 UIKitNiceNotifications 提供了 NotificationsPermissionSwitch,这是一个自定义 UIView,显示并允许修改通知组的组级别权限

let toggle = NotificationsPermissionSwitch(group: DailyQuoteGroup())
toggle.onEnabled = { _ in ... }
toggle.onDisabled = { _ in ... }
toggle.onDeniedBySystem = { _ in /* show "Open Settings" alert to user */ }

这为您节省了很多通常需要自己实现的复杂性。

如果您想在用户尝试启用该类别时显示自己的预先权限,则可以使用 .permissionAsker 属性

// make sure to not introduce retain cycles here
toggle.permissionAsker = { .defaultAlert(on: viewController) }

如果您想使用任何其他控件而不是系统 UISwitch,您可以为 NotificationPermissionView 编写自己的适配器。 有关参考,请参阅 NotificationsPermissionView.swift 中的 __UISwitchAdapter

禁用通知组

禁用组将删除所有待处理的通知,并阻止新的重新调度,直到再次授予权限

LocalNotifications.disable(group: DailyQuoteGroup())

获取组级别授权信息

let status = LocalNotifications.GroupLevelAuthorization.getCurrent(forGroup: DailyQuoteGroup().groupIdentifier)

switch status {
case .allowed: /* ... */
case .denied: /* ... */
case .notAsked: /* ... */
}

性能改进

1. 异步生成内容

NotificationsTimeline 允许使用可用的 schedule(with:) 重载之一异步创建内容

final class DailyQuoteGroup: LocalNotificationsGroup {
    let groupIdentifier: String = "dailyQuote"

    func getTimeline(completion: @escaping (NotificationsTimeline) -> ()) {
        let timeline = NotificationsTimeline {
            EveryDay(forDays: 50, starting: .today)
                .at(hour: 9, minute: 00)
                .schedule(with: makeRandomQuoteContent(completion:))
        }
        completion(timeline)
    }

    private func makeRandomQuoteContent(completion: @escaping (NotificationContent) -> ()) {
        QuoteStore.fetchRandom { (quote) in
            let content = NotificationContent(
                title: quote,
                body: "Open app for more quotes",
                sound: .default
            )
            completion(content)
        }
    }
}

其他可用的 .schedule 重载

.schedule(title: String? = nil, subtitle: String? = nil, body: String? = nil, sound: UNNotificationSound? = .default)
.schedule(with maker: @escaping () -> UNMutableNotificationContent?)
.schedule(with maker: @escaping (LocalNotifications.Trigger) -> UNMutableNotificationContent?)
.schedule(with maker: @escaping (_ nextTriggerDate: Date) -> UNMutableNotificationContent?)
.schedule(with asyncMaker: @escaping (_ trigger: LocalNotifications.Trigger, _ completion: @escaping (UNMutableNotificationContent?) -> Void) -> Void)
.schedule(with asyncMaker: @escaping (_ nextTriggerDate: Date, _ completion: @escaping (UNMutableNotificationContent?) -> Void) -> Void)
.schedule(with asyncMaker: @escaping (_ completion: @escaping (UNMutableNotificationContent?) -> Void) -> Void)
.schedule(with content: @autoclosure @escaping () -> UNMutableNotificationContent?)

2. 在后台队列上创建时间线

默认情况下,getTimeline 将始终在主线程上调用。 如果您的应用程序逻辑允许在后台队列上调用 getTimeline,请将 preferredExecutionContext 设置为 .canRunOnAnyQueue

final class DailyQuoteGroup: LocalNotificationsGroup {
    let groupIdentifier: String = "dailyQuote"

    var preferredExecutionContext: LocalNotificationsGroupContextPreference {
        return .canRunOnAnyQueue
    }

    func getTimeline(completion: @escaping (NotificationsTimeline) -> ()) {
        ...
    }
}

使用 NiceNotifications 的应用程序

  1. Ask Yourself Everyday
  2. Time and Again: Track Routines
  3. 通过打开 PR 提交您的应用程序!

致谢

特别感谢