【Swift 5.6】RichPushNotification - 有錢人的推播功能?

受人點滴當湧泉以報,這次來介紹推播的功能,其實除了顯示文字之外,在iOS 12之後加上顯示圖片的功能,不過這個功能在Android好像很久以前就有了?而這個功能也是網頁不可取代的必備功能,在此特別感謝Nick大大的簡報,話不多說,就來實作一下吧。

作業環境

項目 版本
CPU Apple M1
macOS Big Sur 12.4 arm64
Xcode 13.4.1 arm64
iOS 支援iOS 13以上

作一個長得像這樣的推播功能

基本推播設定

加入推播功能

import UIKit
import WWPrint

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
        
    var window: UIWindow?
    var pushToken: String?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        Utility.shared.userNotificationSetting(delegate: self) {
            wwPrint("Granted")
        } rejectedHandler: {
            wwPrint("Reject")
        } result: { (status) in
            wwPrint(status.rawValue)
        }
        
        return true
    }
    
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        pushToken = deviceToken._hexString()
    }
}
import UIKit

// MARK: - 推播相關 (AppDelegate)
extension Utility {

    func userNotificationSetting(delegate: UNUserNotificationCenterDelegate? = nil, grantedHandler: @escaping () -> Void, rejectedHandler: @escaping () -> Void, result: @escaping (UNAuthorizationStatus) -> Void) {

        let center = UNUserNotificationCenter.current()
        let authorizationOptions: UNAuthorizationOptions = [.alert, .badge, .sound]

        center.getNotificationSettings { (settings) in

            let authorizationStatus = settings.authorizationStatus
            
            switch (authorizationStatus) {
            case .notDetermined:
                center.requestAuthorization(options: authorizationOptions) { (isGranted, error) in
                    guard isGranted else { rejectedHandler(); return }
                    DispatchQueue.main.async { self.registerForRemoteNotifications() }
                    grantedHandler()
                }
            case .authorized:
                DispatchQueue.main.async { self.registerForRemoteNotifications() }
                center.delegate = delegate ?? self
            case .ephemeral:
                DispatchQueue.main.async { self.registerForRemoteNotifications() }
                center.delegate = delegate ?? self
            case .denied: print("denied")
            case .provisional: print("provisional")
            @unknown default: fatalError()
            }
            
            result(authorizationStatus)
        }
    }
}

推播測試

  • 除了可以用實機取得Token測試之外,在Xcode 11.4之後可以直接使用模擬器去做測試,非常的方便,這裡就來測試,取得推送過來的資訊…
// MARK: - UNUserNotificationCenterDelegate
extension AppDelegate: UNUserNotificationCenterDelegate {
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        self.userInfo = notification.request.content.userInfo
        completionHandler([.badge, .sound, .alert])
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        self.userInfo = response.notification.request.content.userInfo
        completionHandler()
    }
}
import UIKit
import WWLog

final class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func test(_ sender: UIButton) {
        guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
        WWLog.shared.log(appDelegate.userInfo)
    }
}
// Simulater.apns
{
  "Simulator Target Bundle": "<BundleId>",
  "aps": {
    "alert": {
      "title": "<標題>",
      "subtitle": "<副標題>",
      "body": "<內文>"
    }
  }
}

UNNotificationContentExtension

新增一個Notification Content Extension Target

設計畫面

import UIKit
import UserNotifications
import UserNotificationsUI

final class NotificationViewController: UIViewController, UNNotificationContentExtension {
    
    @IBOutlet weak var myImageView: UIImageView!
    @IBOutlet weak var heightConstraint: NSLayoutConstraint!
    
    override func viewDidLoad() { super.viewDidLoad() }
    
    func didReceive(_ notification: UNNotification) { notificationAction(notification) }
}

// MARK: - 小工具
private extension NotificationViewController {
    
    func notificationAction(_ notification: UNNotification) {
        
        guard let userInfo = notification.request.content.userInfo as? [String: Any] ,
              let imageUrlString = userInfo["image"] as? String,
              let imageUrl = URL(string: imageUrlString)
        else {
            return
        }
        
        self.downloadImage(url: imageUrl) { result in
            
            switch result {
            case .failure(let error): print(error)
            case .success(let data):
                
                guard let data = data,
                      let image = UIImage(data: data)
                else {
                    return
                }

                DispatchQueue.main.async {
                    self.myImageView.image = image
                    self.heightConstraint.constant = self.view.frame.width * (image.size.height / image.size.width)
                }
            }
        }
    }
    
    func downloadImage(url: URL, result: @escaping (Result<Data?, Error>) -> Void) {
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error { result(.failure(error)); return }
            result(.success(data))
        }.resume()
    }
}

註冊Category

  • 這裡有點像註冊UITableViewCell的感覺,可以去選擇要使用那一種長相的推播視窗
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    
    let CategoryId: String = "MyCategory"
    
    var window: UIWindow?
    var pushToken: String?
    var userInfo: [AnyHashable: Any]?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        self.notificationCategorySetting(identifier: CategoryId)
        return true
    }
}

// MARK: - 推播相關
extension AppDelegate {
    
    func notificationCategorySetting(identifier: String) {
        let category = UNNotificationCategory(identifier: identifier, actions: [], intentIdentifiers: [])
        UNUserNotificationCenter.current().setNotificationCategories([category])
    }
}

模擬器測試

  • 這裡使用模擬器去做測試,payload要注意的是category的名字,要跟上面設定的一樣才行。
  • payload的部分,就要去跟後台人員溝通一下了。
{
  "Simulator Target Bundle": "idv.william.RichPushNotification",
  "aps": {
    "alert": {
      "title": "SPY×FAMILY 間諜家家酒",
      "subtitle": "是由日本漫畫家遠藤達哉所創作的作品,在2019年3月25日起於日本《少年Jump+》上定期連載",
      "body": "本作敘述一名身為間諜的男性、另一位工作是殺手的女性,以及一個能讀心的超能力者女孩,三人互相隱瞞真實身分所組成的虛假家庭間的家庭喜劇。"
    },
    "category": "MyCategory"
  },
  "image": "https://media.gq.com.tw/photos/628b2ec34824d010bb0b3cd4/master/pass/165306325038.jpeg"
}

本地推播

// MARK: - UNUserNotificationCenterDelegate
extension AppDelegate: UNUserNotificationCenterDelegate {
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        
        var options: UNNotificationPresentationOptions = [.sound, .list, .badge, .banner]
        self.userInfo = notification.request.content.userInfo
        
        guard let triggerType = Constant.NotificationTriggerType.parse(with: notification) else { return }
        
        switch triggerType {
        case .push: options = [.sound]; localNotification()
        case .location: break
        case .timeInterval: break
        case .calendar: break
        }
        
        WWLog.shared.log(self.userInfo?._jsonData()?._string())
        completionHandler(options)
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        self.userInfo = response.notification.request.content.userInfo
        completionHandler()
    }

    func localNotification() {
        
        let imageURL = Bundle.main.url(forResource: "ダイ", withExtension: "png")
        let attachment = try! UNNotificationAttachment(identifier: "", url: imageURL!, options: nil)
        let content = UNMutableNotificationContent()
        let trigger  =  UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
        
        content.title = "DRAGON QUESTドラゴンクエスト - ダイの大冒険-"
        content.subtitle = "勇者鬥惡龍 達伊的大冒險"
        content.body = "(日語:DRAGON QUESTドラゴンクエスト - ダイの大冒険-)是採用遊戲《勇者鬥惡龍系列》之世界觀創作的日本漫畫作品,由三條陸負責原作,稻田浩司負責作畫。於集英社漫畫雜誌《週刊少年JUMP》1989年第45號至1996年第52號期間進行連載。單行本全37卷。系列漫畫的單行本累計發行量超過4700萬本。"
        content.badge = 1
        content.sound = UNNotificationSound.default
        content.attachments = [attachment]
        
        let request = UNNotificationRequest(identifier: "notification", content: content, trigger: trigger)
        
        UNUserNotificationCenter.current().add(request) { error in
            if let error = error { wwPrint(error); return }
            wwPrint("Success")
        }
    }
}

範例程式碼下載

後記

終於撐到了WWDC 2022,可愛的MacBook Air M2也出現,萬眾注目的MagSafe 3 充電埠回來了,個人是Air的愛好者,除了輕便之外,加上個人不喜歡風扇太大聲,而M1 / M2的MacBook Air正好是無風扇的設計,而且效能也是夠強大的,加上自己寫程式的功力也一般般,用這個就好…