Categories
Combine CoreData iOS Swift SwiftUI WatchOS

Fetching and displaying data on Watch app in SwiftUI

In previous post “Dynamic user notification on Apple Watch with SwiftUI” I was looking into how to add WatchOS app to an existing project and how to create dynamic notifications. This time, I am gonna cover basics how to fetch data from companion iOS app’s CoreData storage using WatchConnectivity framework and displaying the data in SwiftUI view.

Creating session between iOS and WatchOS app

iOS app uses CoreData for storing a list of plants with last and next watering dates. In the current setup, there is no web service and everything is stored locally on device. How to get the data in persistent store to WatchOS app?

We will use WatchConnectivity framework for making iOS and WatchOS app to be aware of each other. Connection is created by activating WCSession both in iOS and WatchOS app. Therefore the first step is to add a class managing WCSession to iOS project, let’s call it WatchConnectivityProvider (later, we’ll add similar class to WatchOS app as well). It’s main responsibility is to set up WCSession and handling WCSessionDelegate which includes fetching data from CoreData store. Therefore, one of the arguments is going to be NSPersistentContainer which gives us access to CoreData stack (access to the performBackgroundTask function).

final class WatchConnectivityProvider: NSObject, WCSessionDelegate {
    private let persistentContainer: NSPersistentContainer
    private let session: WCSession
    
    init(session: WCSession = .default, persistentContainer: NSPersistentContainer) {
        self.persistentContainer = persistentContainer
        self.session = session
        super.init()
        session.delegate = self
    }

WCSession is activated by calling function activate() which will asynchronously activates it. The response of the activitation is returned by session(_:activationDidCompleteWith:error:) delegate method.

func connect() {
	guard WCSession.isSupported() else {
		os_log(.debug, log: .watch, "watch session is not supported")
		return
	}
	os_log(.debug, log: .watch, "activating watch session")
	session.activate()
}
func session(_ session: WCSession, 
             activationDidCompleteWith activationState: WCSessionActivationState, 
             error: Error?) {
	os_log(.debug, 
               log: .watch, 
               "did finish activating session %lu (error: %s)", 
               activationState == .activated, 
               error?.localizedDescription ?? "none") 
}

We’ll add similar class, but with different name, “PhoneConnectivityProvider” to WatchOS extension target. When both classes are created, we’ll need to initialise and call connect. This could be done in SceneDelegate (iOS) and ExtensionDelegate (WatchOS). Note that in iOS app we’ll need to implement two required delegate methods and for now, we can just log when those get called.

func sessionDidBecomeInactive(_ session: WCSession) {
	os_log(.debug, log: .watch, "session became inactive")
}
    
func sessionDidDeactivate(_ session: WCSession) {
	os_log(.debug, log: .watch, "session deactivated")
}

For testing the session we’ll first build and run iOS app and then WatchOS app. If everything goes well, Xcode logs message: “did finish activating session 1 (error: none)”. Meaning, session is up and running and we can send messages between apps. Side note, do not forget to build and run the app where changes were made.

Fetching plants from iOS app

As communication between iOS and WatchOS app relies on dictionaries, then step 1 is to define a set of shared keys what both apps use. This reduces the risk of mistyping keys. Therefore, let’s add a new file and include it in both iOS app target and WatchOS extension target.

struct WatchCommunication {
    static let requestKey = "request"
    static let responseKey = "response"
    
    enum Content: String {
        case allPlants
    }
}

Step 2 is implementing a refreshAllPlants(completionHandler) function in PhoneConnectivityProvider (WatchOS app extension target) which sends a message to iOS app and waits for array of plants. WCSession has a function sendMessage(_:replyHandler:errorHandler:) which we can use for sending a dictionary to iOS app and wait for reply handler. We’ll define the message to have key WatchCommunication.requestKey and the value is raw value of WatchCommunication.Content.allPlants enum case. This schema can be easily expanded later on by adding more cases to the enum. In reply handler we expect to have an array of dictionaries describing all the plants. Let’s take a look on the full implementation for a moment and then discuss how dictionary was converted to Plant value type.

func refreshAllPlants(withCompletionHandler completionHandler: @escaping ([Plant]?) -> Void) {
	guard session.activationState == .activated else {
		os_log(.debug, log: .phone, "session is not active")
		completionHandler(nil)
		return
	}
	let message = [WatchRequest.contentKey: WatchRequest.Content.allPlants.rawValue]
	session.sendMessage(message, replyHandler: { (payload) in
		let plantDictionaries = payload[WatchCommunication.requestKey] as? [[String: Any]]
		os_log(.debug, log: .phone, "received %lu plants", plantDictionaries?.count ?? 0)
            
		let decoder = JSONDecoder()
		decoder.dateDecodingStrategy = .secondsSince1970
		let plants = plantDictionaries?.compactMap({ Plant(dictionary: $0, decoder: decoder) })
		DispatchQueue.main.async {
			completionHandler(plants)
		}
	}, errorHandler: { error in
		os_log(.debug, log: .phone, "sending message failed: %s", error.localizedDescription)
	})
}

iOS app deals with CoreData and Plant type is NSManagedObject subclass. WatchOS app extension defines its own Plant value type because it does not have CoreData stack. For converting dictionary to value type we can use approach described in “Storing struct in UserDefault”. Only addition is configuring the JSONDecoder to use dateDecodingStrategy secondsSince1970. Reason is that we’ll going to store dates as seconds since 1970. Converting dictionary to value type involves using JSONSerialization and it supports only NSString, NSNumber, NSArray, NSDictionary, or NSNull.

// Plant value type in WatchOS app extension
struct Plant: Identifiable, Decodable, DictionaryDecodable {
    let id: String
    let name: String
    let lastWateringDate: Date
    let nextWateringDate: Date
}
// Plant class in iOS app
final class Plant: NSManagedObject, Identifiable {
    @NSManaged var id: String
    @NSManaged var name: String
    
    @NSManaged var lastWateringDate: Date
    @NSManaged var nextWateringDate: Date
}

Step 3 is handling the message on the iOS app side and providing data for WatchOS app. What we need to do is implementing session delegate and fetching dictionary data from CoreData store. Let’s take a look on the full implementation and then break it down.

func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
	os_log(.debug, log: .watch, "did receive message: %s", message[WatchCommunication.requestKey] as? String ?? "unknown")
	guard let contentString = message[WatchCommunication.requestKey] as? String , let content = WatchCommunication.Content(rawValue: contentString) else {
		replyHandler([:])
		return
	}
	switch content {
	case .allPlants:
		persistentContainer.performBackgroundTask { (managedObjectContext) in            
			let all = Plant.allPlantsDictionaryRepresentation() as! [[String: Any]]
			// Replace Date with Double
			let converted = all.map { (plantDictionary) -> [String: Any] in
				plantDictionary.mapValues { (value) -> Any in
					if let date = value as? Date {
						return date.timeIntervalSince1970
					}
					else {
						return value
					}
				}
			}                
			let response = [WatchCommunication.responseKey: converted]
			replyHandler(response)
		}
	}
}

The first step is to look into the received dictionary and see which content is being asked by the WatchOS app. Then we’ll access persistent store, fetch dictionary representations of Plant, convert Date to seconds since 1970 (enabling WatchOS app to use JSONSerialization on the dictionary) and then sending the data back to WatchOS app. Note that getting Plants as dictionary is very simple with CoreData: we’ll make a fetch request with result type NSDictionary and set resultType property to .dictionaryResultType. For larger models we could also provide set of properties we need (propertiesToFetch) but at the moment, every property is added to the dictionary.

extension Plant {
	static let entityName = "Plant"
    
	static func makeDictionaryRequest() -> NSFetchRequest<NSDictionary> {
		return NSFetchRequest<NSDictionary>(entityName: entityName)
	}
	static func allPlantsDictionaryRepresentation() -> [NSDictionary] {
		let request = makeDictionaryRequest()
		request.resultType = .dictionaryResultType
		do {
			return try request.execute()
		}
		catch let nsError as NSError {
			os_log(.debug, log: .plants, "failed fetching all plants with error %s %s", nsError, nsError.userInfo)
			return []
		}
	}
}

Setting up UI in WatchOS app using SwiftUI

WatchOS app template in Xcode is hooked up in a way where Storyboard initialises HostingController which is responsible of providing initial SwiftUI view.

class HostingController: WKHostingController<PlantListView> {
    lazy private(set) var connectivityProvider: PhoneConnectivityProvider = {
        let provider = PhoneConnectivityProvider()
        provider.connect()
        return provider
    }()
    
    private lazy var listViewModel = PlantListViewModel(connectivityProvider: connectivityProvider)
    
    override var body: PlantListView {
        return PlantListView(viewModel: listViewModel)
    }
}

PlantListView is a simple view showing a list of plants. It’s view model handles refreshing plants using the PhoneConnectivityProvider’s refreshAllPlants(withCompletionHandler:). SwiftUI view updates automatically when view model changes. This is because view model’s plants property uses @Published property wrapper, view model is ObservableObject and SwiftUI view uses ObservedObject property wrapper for view model (read more about refreshing SwiftUI view in MVVM in SwiftUI). Note that view model refreshes content as soon as SwiftUI view appears.

final class PlantListViewModel: ObservableObject {
    private let connectivityProvider: PhoneConnectivityProvider
    
    init(plants: [Plant] = [], connectivityProvider: PhoneConnectivityProvider) {
        self.plants = plants
        self.connectivityProvider = connectivityProvider
        refresh()
    }
    @Published private(set) var plants: [Plant]
    
    func refresh() {
        connectivityProvider.refreshAllPlants { [weak self] (plants) in
            guard let plants = plants else { return }
            self?.plants = plants
        }
    }    
}
struct PlantListView: View {
    @ObservedObject var viewModel: PlantListViewModel
    
    var body: some View {
        VStack {
            List(self.viewModel.plants) { plant in
                PlantCell(viewModel: PlantCellViewModel(plant: plant))
            }
        }.onAppear {
            self.viewModel.refresh()
        }
    }
}

PlantListView uses PlantCell for displaying individual views. PlantCell has two labels and makes itself as wide as possible.

struct PlantCell: View {
    let viewModel: PlantCellViewModel
    
    var body: some View {
        VStack(spacing: 4) {
            Text(viewModel.title).font(.headline).multilineTextAlignment(.center)
            Text(viewModel.subtitle).font(.footnote).multilineTextAlignment(.center)
        }.padding(8)
            .frame(minWidth: 0, maxWidth: .greatestFiniteMagnitude)
    }
}
struct PlantCellViewModel {
    let plant: Plant
    
    var title: String {
        return plant.name
    }
    
    private static let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "dMMMM", options: 0, locale: .current)
        return formatter
    }()
    
    var subtitle: String {
        let format = NSLocalizedString("PlantCellView_NextWatering", comment: "Next watering date.")
        return String(format: format, Self.dateFormatter.string(from: plant.nextWateringDate))
    }
}

Summary

We added WCSessions to both iOS and WatchOS app and implemented delegate methods handling session and received messages. Then, we defined simple communication schema for communication and implemented refresh plants method in the WatchOS app and CoreData integration on the iOS app side. When data access was created, we added SwiftUI view displaying list of plants in the WatchOS app.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example Project

WaterMyPlants (GitHub)

Categories
iOS Swift SwiftUI WatchOS

Dynamic user notification on Apple Watch with SwiftUI

Apps which integrate push or local notifications can customise notifications on Apple Watch. Let’s go through steps required for adding dynamic notifications on Apple Watch. Sample use case is an app which reminds when a plant needs watering. We’ll only concentrate on adding dynamic notification view and leave out sending local notifications from the iOS app.

Adding build target for rich notifications on Apple Watch

If Apple Watch app does not exist in the project the first step is to add it. In Xcode, we’ll add a new build target and configure it to include notification scene. In Xcode, open new target view: File>Target and select Watch App for iOS App template.

Watch App for iOS App template in File>Target.

Make sure SwiftUI is selected in the user interface selection and “Include Notification Scene” is also selected. We’ll embed it in the companion app so make sure current iOS app target is set to “Embed in Companion App”. As a side note, since iOS 13 and WatchOS 6, Apple Watch apps can be independent as well.

Watch App for iOS app build target configuration with notification scene.

Click on Finish and then Xcode will ask about activating the new scheme, click on active. It will just select the new target and we can build it right away. When inspecting the project, Xcode added two targets: watch app and extension. App contains storyboard and extension contains all the code. Storyboard is wired up so that the scene displays HostingController which is WKHostingController subclass and is responsible of hosting your SwiftUI view in the Apple Watch app. In addition, there are scenes for static and dynamic notifications. We are interested in creating dynamic notifications and in the Storyboard we can see that the dynamic view is provided by NotificationController (subclass of WKUserNotificationHostingController) which hosts SwiftUI view for the notification. There we can provide the custom interface for our user notification. Dynamic notification view is selected if the notification category matches with the one defined in the Storyboard.

If you need more information how to set up Xcode project please check: “Setting Up a watchOS Project”.

Parsing notification payload and setting up dynamic notification view

NotificationController’s responsibility is to consume user notification’s payload and configuring the SwiftUI view with it. User notification is provided by didReceive function and there we can extract the information needed for showing the view. When it comes to locally testing the dynamic view, we can add required data to the PushNotificationPayload.apns file. As we show information about plants, let’s add example plant object to the file. Also, we change category to something meaningful. Make sure to update Storyboard when setting new category.

{
    "aps": {
        "alert": {
            "body": "Test message",
            "title": "Optional title",
            "subtitle": "Optional subtitle"
        },
        "category": "WATERING_REMINDER",
        "thread-id": "plantid123"
    },
    
    "plant": {
        "id": "plantid123",
        "name": "Aloe",
        "lastDate": 1579937802,
        "nextDate": 1580515200
    }
}
Notification category in Storyboard for defining the connection to dynamic notification view.

Plant related information is available when accessing UNNotification’s request.content.userInfo. We can use Decodable and JSONDecoder for converting Dictionary representing the plant into value type. As JSONDecoder requires JSON data then we can first use JSONSerialization and then passing the JSON data to JSONDecoder. Alternatively we could also manually read values from the userInfo dictionary and then creating the value type. Note that we use view model for providing data to SwiftUI view and not the Plant type directly.

struct Plant: Decodable {
    let id: String
    let name: String
    let lastDate: Date
    let nextDate: Date
}

do {
	let plantInfo = notification.request.content.userInfo["plant"] as! [String: Any]
	let data = try JSONSerialization.data(withJSONObject: plantInfo, options: [])
	let decoder = JSONDecoder()
	decoder.dateDecodingStrategy = .secondsSince1970
	let plant = try decoder.decode(Plant.self, from: data)
	viewModel = NotificationViewModel(plant: plant)
}
catch let nsError as NSError {
	print(nsError.localizedDescription)
}

In addition, we would like to add 3 actions user can take: marking the plant as watered, deferring the reminder for couple of hours, or scheduling it for tomorrow. Actions are represented with instances of UNNotificationAction and when user taps on any of those, UNUserNotificationCenter’s delegate method is called with the identifier in the companion iOS app (userNotificationCenter(_:didReceive:withCompletionHandler:)).

let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.")
let laterTitle = NSLocalizedString("NotificationAction_Later", comment: "Later button title in notification.")
let tomorrowTitle = NSLocalizedString("NotificationAction_Tomorrow", comment: "Tomorrow button title in notification.")
notificationActions = [
	UNNotificationAction(identifier: "water_done", title: doneTitle, options: []),
	UNNotificationAction(identifier: "water_later", title: laterTitle, options: []),
	UNNotificationAction(identifier: "water_tomorrow", title: tomorrowTitle, options: [])
]

The full implementation of the NotificationController becomes like this (including the creation of SwiftUI):

final class NotificationController: WKUserNotificationHostingController<NotificationView> {
    private var viewModel: NotificationViewModel?
    
    override var body: NotificationView {
        return NotificationView(viewModel: viewModel!)
    }
    
    override func didReceive(_ notification: UNNotification) {
        do {
            let plantInfo = notification.request.content.userInfo["plant"] as! [String: Any]
            let data = try JSONSerialization.data(withJSONObject: plantInfo, options: [])
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .secondsSince1970
            let plant = try decoder.decode(Plant.self, from: data)
            viewModel = NotificationViewModel(plant: plant)
        }
        catch let nsError as NSError {
            print(nsError.localizedDescription)
        }
        
        let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.")
        let laterTitle = NSLocalizedString("NotificationAction_Later", comment: "Later button title in notification.")
        let tomorrowTitle = NSLocalizedString("NotificationAction_Tomorrow", comment: "Tomorrow button title in notification.")
        notificationActions = [
            UNNotificationAction(identifier: "water_done", title: doneTitle, options: []),
            UNNotificationAction(identifier: "water_later", title: laterTitle, options: []),
            UNNotificationAction(identifier: "water_tomorrow", title: tomorrowTitle, options: [])
        ]
    }
}

NotificationView presenting the dynamic notification

Previously mentioned view model NotificationViewModel provides text for NotificationView and it looks pretty simple, mainly dealing with creating strings with formatted dates. As we only want to show day and month then we need to create dateFormat using current locale.

struct NotificationViewModel {
    private let plant: Plant
    
    init(plant: Plant) {
        self.plant = plant
    }
    
    var title: String {
        return plant.name
    }
    
    var subtitle: String {
        return NSLocalizedString("NotificationView_Subtitle", comment: "Notification suggestion text")
    }
    
    private let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "dMMMM", options: 0, locale: .current)
        return formatter
    }()
    
    var lastWatering: String {
        let format = NSLocalizedString("NotificationView_LastWatering", comment: "Last watering date.")
        return String(format: format, dateFormatter.string(from: plant.lastDate))
    }
    
    var nextWatering: String {
        let format = NSLocalizedString("NotificationView_NextWatering", comment: "Next watering date.")
        return String(format: format, dateFormatter.string(from: plant.nextDate))
    }
}

SwiftUI view is also pretty simple with containing 4 text labels and one divider.

struct NotificationView: View {
    let viewModel: NotificationViewModel
    
    var body: some View {
        VStack {
            Text(viewModel.title).font(.title)
            Text(viewModel.subtitle).font(.subheadline)
            Divider()
            Text(viewModel.lastWatering).font(.body).multilineTextAlignment(.center)
            Text(viewModel.nextWatering).font(.body).multilineTextAlignment(.center)
        }
    }
}
Dynamic notification with 4 labels and divider.
Buttons in dynamic notification.

Summary

We added Apple Watch app to an existing iOS app and implemented dynamic notification view for one notification category. We looked into how to parse data associated with the user notification, create SwiftUI view and add action buttons. Next steps would be to handle notification actions in the companion iOS app based on the notification button identifier.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example project

WaterMyPlants (GitHub)