Home About Contact

 

Scheduling local notification the MVVM way in SwiftUI

6 min read

In the previous tutorial, we’ve shown you how we can send, receive and handle local notifications with SwiftUI and now we are going to look at a practical example to build a simple app to schedule notification by using the MVVM design approach.

The app we are going to build here is to let the user schedule sending “one” simple reminder message at a specified time of the day and it’ll repeat the task at the same time everyday.

In MVVM design approach, we have our Model, ViewModel and View as follows:

The Model

For simplicity, we have our model, ReminderMessage, which basically contains three properties :

struct ReminderMessage : Codable {
    
    var date : Date = Date()
    
    var addToNotfication : Bool = false 
    
    var text : String = "Take Your Breakfast"

}
  • date – which is a Date object that holds the time that the message should be sent. And we are just interested in the hour and minute components of the date here.
  • addToNotification – a boolean value indicates if the message should be added to the notification center
  • text – the text of the reminder message.

Please take note that our model ReminderMessage conforms to the Codable protocol, which allows it to be encoded and decoded to and from JSON.

A simple persistency for our data model

For simplicity, we provide a simple persistency for our model ReminderMessage by storing it in the UserDefaults.

So, we build a struct ReminderMessageDataStore as follows, which basically consists of two methods. They are to save the ReminderMessage to and load it from the UserDefaults.

The ReminderMessage struct is first encoded and decoded to and from JSON before storing and after reading it from the UserDefaults respectively.

typealias DS = ReminderMessageDataStore

struct ReminderMessageDataStore {
    
    static let shared = ReminderMessageDataStore()

    private let key = "com.techchee.savedReminderMessage"
   
    func save(_ message : ReminderMessage ){
        
        let encoder = JSONEncoder()
        if let encoded = try? encoder.encode(message) {
            let defaults = UserDefaults.standard
            defaults.set(encoded, forKey: key)
        }
    }
    
    func load() -> ReminderMessage{
        
        let defaults = UserDefaults.standard
        
        if let savedMessage = defaults.object(forKey: key) as? Data {
            let decoder = JSONDecoder()
            if let loadedMessage = try? decoder.decode(ReminderMessage.self, from: savedMessage) {
                
                return loadedMessage
            }
        }
        
        return ReminderMessage()
    }
}

The load() method will instantiate a new ReminderMessage if it’s not previously stored in the UserDefaults.

And we provide a static shared singleton variable for the convenience of instantiating it.

There is also a Swift typealias DS for the ReminderMessageDataStore for the ease of writing and reading the code.

The ViewModel

The ReminderMessageViewModel is a wrapper of our model, it is the one directly used by our views and reflects the views’ states and updates the model when necessary.

final class ReminderMessageViewModel : ObservableObject {
  
    @Published private var reminderMessage = DS.shared.load() {
        didSet {
            saveReminderMessage()
            manageReminderMessage()
        }
    }
    
    var date : Date {
        get {
            return reminderMessage.date
        }
        
        set(newDate){    
            reminderMessage.date = newDate
            // force to true when date is set
            reminderMessage.addToNotfication = true 
        }
    }
    
    var addToNotification : Bool {
        get {
            reminderMessage.addToNotfication
        }
        set (addToNotification){
            reminderMessage.addToNotfication = addToNotification
        }
    }

    var text : String {
        get {
            reminderMessage.text
        }
        
        set(newText){
            reminderMessage.text = newText
        }
    }
}

The ReminderMessageViewModel has proxy properties for the date, addToNotification and text properties of the ReminderMessage model with both setter and getter.

Please take note, the set new date value will also set the addToNotification to true.

@Published property wrapper and didSet property observer

The property ReminderMessage is our model and given a @Published property wrapper. Its initial value is provided by DS.shared.load(), which loads it from UserDefaults or instantiates a new one if it’s not previously stored.

And we use a property observer didSet to monitor the change, so we can execute some code when its value has just been set. In here we invoke the methods saveReminderMessage() and manageMessageNotification()

The saveReminderMessage() is as simple as follows, which basically makes use of our ReminderMessageDataStore to save the ReminderMessage.

extension ReminderMessageViewModel {
    func saveReminderMessage(){
        DS.shared.save(reminderMessage)
    }   
}

The manageMessageNotification() method is as follows, basically, it makes use of our NotificationHandler class introduced in our previous tutorial to add or remove the notification to or from the current UNUserNotificationCenter.

For simplicity of this tutorial, the app is just good enough to let the user to schedule one reminder message at a specified time of the day.

So, in manageMessageNotification() all pending or delivered notifications of the specified array of indetifiers will be removed first.

If the addToNotification is true, it’ll create notification request in the current notification center with a trigger of the specified time.

To schedule a notification at a specific time, we create a UNCalendarNotificationTrigger with a DateComponents that has the hour and minute only and it’s set to be repeated.

The static method toHourAndMinute is for the convenience of converting any Date object into DateComponents with hour and minute.

typealias VM = ReminderMessageViewModel
    
extension ReminderMessageViewModel {
    
    static let notificationId = "com.techchee.ReminderMessage"
    
    // just a convenient method to convert a Date object
    // to DateComponents with hour and minute only  
    static func toHourAndMinute(date : Date) -> DateComponents {
        
        let calendar = Calendar.current
        let components = calendar.dateComponents([.hour, .minute], from: date )
        
        return components
    }
   
    func manageMessageNotification(){     
        // remove the notifications first
        NotificationHandler.shared.removeNotifications([VM.notificationId])
 
        if self.addToNotification {
        
            let trigger = UNCalendarNotificationTrigger(
                dateMatching: VM.toHourAndMinute(date: self.date)  , repeats: true)
            
            NotificationHandler.shared.addNotification(id : 
                VM.notificationId,
                title:"A Simple Reminder!" ,
                subtitle: ReminderMessage.text, trigger : trigger)
        }
    }
}

Please note a typealias VM is used here as a global shorthand for the convenience of accessing the ReminderMessageViewModel for its static variables and functions etc. It’s a way to make the code more readable.

And we provide a static variable of a Notification.Publisher created with the notificationId for the convenience of use in our views later, as follows:

extension ReminderMessageViewModel {    
    static var notificationPublisher : NotificationCenter.Publisher  {        
        NotificationCenter.default.publisher(for: 
                  Notification.Name(M.notificationId))        
    }    
}

So now, we have our ViewModel encapsulating everything we need, let’s get started to build our View.

The View

In our ReminderMessageView, we simply present a SwiftUI Form, which consists of a TextField for the user to input the text message of the reminder, a DatePicker for choosing the time and a Toggle switch for indication if to add the message to the notification center.

import SwiftUI
struct ReminderMessageView : View {
    
    @ObservedObject private var viewModel = VM()

    @State private var toShowAlert : Bool = false
    
    @State private var toShowPopup : Bool = false
    
    @State private var notificationContent : UNNotificationContent?
   
    var body: some View {
        Form {
            TextField("Reminder :",text: $viewModel.text)
            
            HStack(spacing: 20) {
                DatePicker("",selection: $viewModel.date,
                  displayedComponents: [.hourAndMinute])
            
                Toggle("", isOn: $viewModel.addToNotification)
                  .toggleStyle(SwitchToggleStyle(tint: .green))
                
            }
            .frame(width: 150, height: 50, alignment: .center)
        }
   }
   ...
   ...
}

Each Form control is passed with the respective viewModel property as Binding (with $ prefix).

The three @State properties :

  • toShowAlert is meant for showing a SwiftUI Alert when the notification is disabled in the device Settings.
  • toShowPopup is meant for showing a custom popup view with the received notification message.
  • The notificationContent is meant for temporarily holding the received message of notification.

The below is how it’s shown on the Xcode preview

Here is how it's shown on Xcode preview

We request the user’s permission for notification in the onAppear modifier of the form. It’ll set the @State toShowAlert property to true to show an alert for leading the user to the device’s Settings to enable notification for the app if it’s been disabled. For more details, you can refer to our previous tutorial.

Form {
   ...
}
.onAppear{            
    NotificationHandler.shared.requestPermission( onDeny: {
       self.toShowAlert.toggle()
    })
}
.alert(isPresented: $toShowAlert) { 
    Alert(title: Text("Notification has been disabled for this app"),
       message: Text("Please go to settings to enable it now"),
       primaryButton: .default(Text("Go To Settings")) {
          self.goToSettings()
       },
       secondaryButton: .cancel())
}
...

The onReceive is monitoring a publisher provided by the VM.notificationPublisher. Inside the block of onReceive, it sets the @State toShowPopup property and store the received notification content, which are required for showing a custom popup view with the received message.

Form {
   ...
}
.onAppear {
   ...
}
.onReceive (VM.notificationPublisher){ data in 
   self.toShowPopup.toggle()
   // store the notification content
   if let content = (data.object as? UNNotificationContent){
      self.notificationContent = content        
   }        
}
.popup(isPresented: $toShowPopup, 
   title: notificationContent?.title, 
      subtitle: notificationContent?.subtitle)     
...

The popup(isPresented: title: subtitle:) is a custom View modifier which presents a custom popup with the reminder message received via local notification.

The code of the custom modifier – PopupModifier is as follows:

struct PopupModifier : ViewModifier {
   @Binding var isPresented : Bool

   var title : String

   var subtitle : String 

   func body(content : Content) -> some View {

       ZStack {
           content 

           if (isPresented){
               popupView()
           }
       }
   }
}

Basically, in the body(content:) function, a ZStack is used to present the “popup view” in front of the content if the @Binding variable is true.

For better code readability, we put the code of the “popup View” which consists of a VStack of views in a function that returns some View – popupView() as follows:

extension PopupModifier {
   func popupView() -> some View {
       VStack {
           Text(title)
           .font(.system(size: 26, weight: .bold, design: .default))
          
           ZStack {
               Capsule()
               .fill(randomColor())
               .frame(width: 220, height: 100)
                 
               Text(subtitle)
               .font(.system(size: 20, weight: .bold, design: .rounded))
               .padding()
               .frame(width:210)
               .foregroundColor(.white)
               .multilineTextAlignment(.center)
           }
           // a button that sets the isPresented back to false 
           // when tapped to close the popup 
           Button(action: {  
                self.$isPresented.wrappedValue.toggle()  
            }){ 
                Text("Bye Now")
           }
       }
       ...  // some styling goes here
   }
}

If you’re unfamiliar with how to build a custom modifier, you can refer to our previous tutorial on how to build custom View modifier.

The result is as follows :

When a reminder message is scheduled at a time:

And when notification received, the popup view is presented as below :

That’s all for now! Thanks for reading and happy learning. The sample source code of the examples used in this and the previous tutorials are available on GitHub.

Spread the love
Posted on March 15, 2021 By Christopher Chee

Please leave us your comments below, if you find any errors or mistakes with this post. Or you have better idea to suggest for better result etc.


Our FB Twitter Our IG Copyright © 2024