Categories
Foundation iOS Swift SwiftUI Xcode

Alert and LocalizedError in SwiftUI

Everything can’t go exactly as planned and therefore, at some point, there is a need for presenting localized error messages to the user. Let’s take a look at how to add custom error type what provides error description, failure reason and recovery suggestion and presenting it in SwiftUI view.

Adding custom error type

Custom error type is needed when we want to propagate errors using Swift’s error handling mechanism. Custom error types need to, at minimum, conform to Error protocol which defines localizedDescription property. If we would like to provide more information to users, including recovery suggestions, then we need to use LocalizedError instead. LocalizedError inherits from Error and defines additional properties which are intended for describing the error further. Note that LocalizedError is very similar to NSError: errorDescription, failureReason, recoverySuggestion, helpAnchor are all represented by NSError.UserInfoKey.

In the example app we’ll use LoginError currently definiing only one error: incorrectPassword.

enum LoginError: LocalizedError {
    case incorrectPassword // invalidUserName etc
    
    var errorDescription: String? {
        switch self {
        case .incorrectPassword:
            return "Failed logging in account"
        }
    }
    
    var failureReason: String? {
        switch self {
        case .incorrectPassword:
            return "Entered password was incorrect"
        }
    }
    
    var recoverySuggestion: String? {
        switch self {
        case .incorrectPassword:
            return "Please try again with different password"
        }
    }
}

Presenting error in SwiftUI

Custom error defined, the next step is to present the error using SwiftUI’s alert view modifier: alert(isPresented:content:). Alert view modifier requires boolean binding and Alert container defining title, optional message, and buttons. In the example below, error is handled by the view model and Alert itself is created using convenience initializer which we’ll look at a bit later. Convenience initializer makes the view implementation more readable and reduces code duplication.

struct ContentView: View {
    @ObservedObject var viewModel: ContentViewModel
    
    var body: some View {
        VStack(spacing: 16) {
            Text("Alert Views")
            Button(action: viewModel.showAlertView) {
                Text("Show Alert View")
            }
        }.alert(isPresented: viewModel.isPresentingAlert, content: {
            Alert(localizedError: viewModel.activeError!)
        })
    }
}

Alert view modifier requires a boolean binding controlling if the alert is visible or not. When alert is dismissed, SwiftUI automatically calls the binding with false, indicating that the alert should not be visible anymore. Note that force unwrap is safe here because view model makes sure isPresentingAlert never returns true when underlying error is nil.

final class ContentViewModel: ObservableObject {
    @Published private(set) var activeError: LocalizedError?

    var isPresentingAlert: Binding<Bool> {
        return Binding<Bool>(get: {
            return self.activeError != nil
        }, set: { newValue in
            guard !newValue else { return }
            self.activeError = nil
        })
    }
        
    func showAlertView() {
        activeError = LoginError.incorrectPassword
    }
}

Boolean binding is created manually and implemented in such way that when activeError is set, isPresentingAlert returns true. When alert is dismissed, set will clear the current active error. This approach makes it simple to handle any errors conforming to LocalizedError in the view model. Like mentioned before, LocalizedError enables us to add detailed information about the alert and we can use that when creating the Alert. Let’s take a look on it next.

extension Alert {
    init(localizedError: LocalizedError) {
        self = Alert(nsError: localizedError as NSError)
    }
    
    init(nsError: NSError) {
        let message: Text? = {
            let message = [nsError.localizedFailureReason, nsError.localizedRecoverySuggestion].compactMap({ $0 }).joined(separator: "\n\n")
            return message.isEmpty ? nil : Text(message)
        }()
        self = Alert(title: Text(nsError.localizedDescription),
                     message: message,
                     dismissButton: .default(Text("OK")))
    }
}

Alert’s extension has initializers both for LocalizedError and NSError. NSError is used a lot in Objective-C frameworks so there is high probability that we need to present NSError in the future as well. Here, we can use Swift language’s built-in support of converting Swift error type to NSError and therefore we can implement convenience method only once for NSError. LocalizedError can be bridged to NSError and Swift compiler takes care of keeping the information about the error. In this implementation, I decided to include both the failureReason and recoverySuggestion when creating the message for Alert. This enables custom error types to choose how much information they provide (choosing which properties return text). Moreover, it is better to show as much information about the error as possible.

LoginError.incorrectPassword presented by Alert in SwiftUI

Summary

We created a custom error type and used LocalizedError instead of Error for making it suitable for displaying as an alert. We looked into how to use alert view modifier and MVVM together and introduced design pattern for easy alert presentation. If you need action sheet, then follow similar steps but use actionSheet view modifier with ActionSheet container.

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

SwiftUIAlertView (Xcode 11.4 beta 2)

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)

Categories
Combine CoreData Foundation Generics iOS Swift SwiftUI Xcode

Using CoreData with SwiftUI

CoreData is Apple’s object graph and persistence framework. It provides data sources for synchronising data with view. Let’s take a look on how to use those data sources in SwiftUI views. Starting with NSFetchedResultsController what is used for list and collection views, after that observing NSManagedObject directly from SwiftUI view and lastly subscribing to managed object context notifications.

NSFetchedResultsController

NSFetchedResultsController is used for providing data in table and collection views. It supports sorting and filtering data and arranging data into sections. We can use delegate for getting change callbacks. NSFetchedResultsControllerDelegate contains several methods:

// Diffable data source (new in iOS 13)
optional func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshot)
optional func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith diff: CollectionDifference<NSManagedObjectID>)
// Manually updating table view and collection view
optional func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)
optional func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType)
optional func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
optional func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)

In SwiftUI we are not going to directly manipulate views. Only what we need to do is letting SwiftUI view know that data is about to change. Let’s take a look on simple app with list of items stored by CoreData. Data is represented by ColorItem where only stored value is hex string of the color (e.g. #AA22BB).

final class ColorItem: NSManagedObject {
@NSManaged var hex: String
}
view raw ColorItem.swift hosted with ❤ by GitHub

ColorItems are managed by ContentView’s view model. View model creates NSFetchedResultsController, performs fetch and provides array of fetched ColorItems to the SwiftUI’s List. In addition, view model is delegate of the NSFetchedResultsController (requires view model to be NSObject subclass). As view model is ObservableObject, we can very easily let SwiftUI view know that it should refresh. We need to do two things: firstly, implementing controllerWillChangeContent delegate method and calling send() on objectWillChange publisher. Secondly, view model property must use @ObservedObject property wrapper in SwiftUI view. Result is that SwiftUI view subscribes to objectWillChange publisher and refreshes whenever publisher emits an event.

extension ContentView {
final class ViewModel: NSObject, NSFetchedResultsControllerDelegate, ObservableObject {
private let colorController: NSFetchedResultsController<ColorItem>
init(managedObjectContext: NSManagedObjectContext) {
let sortDescriptors = [NSSortDescriptor(keyPath: \ColorItem.hex, ascending: true)]
colorController = ColorItem.resultsController(context: managedObjectContext, sortDescriptors: sortDescriptors)
super.init()
colorController.delegate = self
try? colorController.performFetch()
}
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
objectWillChange.send()
}
var colors: [ColorItem] {
return colorController.fetchedObjects ?? []
}
}
}
extension ColorItem {
static func resultsController(context: NSManagedObjectContext, sortDescriptors: [NSSortDescriptor] = []) -> NSFetchedResultsController<ColorItem> {
let request = NSFetchRequest<ColorItem>(entityName: "ColorItem")
request.sortDescriptors = sortDescriptors.isEmpty ? nil : sortDescriptors
return NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
}
}

And finally let’s see the ContentView implementation. NSManagedObject has objectID property what we can use in List for identifying every ColorItem. Cell is custom view what we’ll take a look at next.

struct ContentView: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
VStack {
List(viewModel.colors, id: \.objectID) { (colorItem) in
Cell(colorItem: colorItem)
}
}.navigationBarTitle("Colors")
}
}
}

NSManagedObject

NSManagedObject implements ObservableObject protocol and therefore it is possible to use it together with @ObservedObject property wrapper and getting SwiftUI view refreshed automatically when any of the ColorItem properties change. NSFetchedResultsController required a little bit of code for setting up delegate but that is not the case with NSManagedObject.

struct Cell: View {
@ObservedObject var colorItem: ColorItem
var body: some View {
HStack {
Text(verbatim: colorItem.hex)
Spacer()
Rectangle().foregroundColor(Color(colorItem.uiColor)).frame(minWidth: 50, maxWidth: 50)
}
}
}
view raw Cell.swift hosted with ❤ by GitHub

In addition, if there are cases where we would like to observe specific property, then Combine provides publisher for key path. Because NSManagedObject supports key-value observing we can use the publisher and subscribe to individual property changes.

let cancellable = color.publisher(for: \.hex).sink { (string) in
print(string)
}

Subscribing to CoreData notifications

When we need to observe changes in persistent store we can observe notifications sent by the framework. As NotificationCenter supports publishers, we can subscribe to it, unpack data from notification and do something with the data. For making this easier we can introduce a separate type. It will unpack the user info dictionary and filter by type. This allows to easily observe, for example, ColorItem insertions. Or, if we would like to receive every possible change, we can specify NSManagedObject as the generic type.

private var cancellables = [AnyCancellable]()
let cancellable = NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: managedObjectContext)
.compactMap({ ManagedObjectContextChanges<ColorItem>(notification: $0) }).sink { (changes) in
print(changes)
}
cancellables.append(cancellable)
struct ManagedObjectContextChanges<T: NSManagedObject> {
let inserted: Set<T>
let deleted: Set<T>
let updated: Set<T>
init?(notification: Notification) {
let unpack: (String) -> Set<T> = { key in
let managedObjects = (notification.userInfo?[key] as? Set<NSManagedObject>) ?? []
return Set(managedObjects.compactMap({ $0 as? T }))
}
deleted = unpack(NSDeletedObjectsKey)
inserted = unpack(NSInsertedObjectsKey)
updated = unpack(NSUpdatedObjectsKey).union(unpack(NSRefreshedObjectsKey))
if deleted.isEmpty, inserted.isEmpty, updated.isEmpty {
return nil
}
}
}

Summary

We took a look at how to use NSFetchedResultsController and NSManagedObject in SwiftUI views. We saw that integrating NSFetchedResultsController requires only a little bit of code and using NSManagedObject even less. In addition, we looked at subscribing to CoreData notifications and unpacking notification payload.

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

CoreDataCombineSwiftUI (Xcode 11.3)

Categories
Combine Foundation iOS Swift SwiftUI

MVVM in SwiftUI

Let’s build a simple app using MVVM (model-view-view model) where every SwiftUI view has its own view model. It’s going to be an app with two views: list of movies and add a movie view what utilises Form view. Added movies are stored in MovieStore which is shared by the two view models. We will use environment for sharing the MovieStore. It will be read from the environment when we need to create AddMovieView with its view model.

Movie and MovieStore representing data

Movie is a small struct and just stores the title and rating. Title and rating are mutable as we are going to update those in AddMovieView. We also conform to protocol Identifiable because we are going to use List view for showing all the movies. List needs a way of identifiyng the content and its the simplest way of satisfiying the requirement.

struct Movie: Equatable, Identifiable {
    let id = UUID()
    var fullTitle: String
    var givenRating: Rating = .notSeen
}

extension Movie {
    enum Rating: Int, CaseIterable {
        case notSeen, terrible, poor, decent, good, excellent
    }
}

MovieStore is also a pretty simple although in a more sophisticated app it would contain much more logic: persistence, deleting etc. We use Published property wrapper which automatically provides a publisher we can use to subscribe against.

final class MovieStore {
    @Published private(set) var allMovies = [Movie]()
    
    func add(_ movie: Movie) {
        allMovies.append(movie)
    }
}

For inserting shared MovieStore to environment, we’ll use custom EnvironmentKey. Custom key is just an object conforming to EnvironmentKey protocol. We need to provide the type and default value.

struct MovieStoreKey: EnvironmentKey {
    typealias Value = MovieStore
    static var defaultValue = MovieStore()
}

extension EnvironmentValues {
    var movieStore: MovieStore {
        get {
            return self[MovieStoreKey]
        }
        set {
            self[MovieStoreKey] = newValue
        }
    }
}

If we do not insert our own instance of MovieStore to the environment, the instance returned by defaultValue is used. Typically we would like to use a specific instance initialised outside of the view hierarchy. Therefore let’s take a look how to do that next.

SceneDelegate and MovieScene presentation

MovieStore dependency is passed into view models with initialiser. We’ll use the instance stored in SceneDelegate. Yet again, in a real app, it would probably live in a separate dependency container or in something similar. MovieListView is the first view we need to present, therefore we’ll initialise view model, view and insert instance of MovieStore to environment for later use (movieStore keypath is the one we just defined in EnvironmentValues‘ extension).

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    private let movieStore = MovieStore()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let viewModel = MovieListView.ViewModel(movieStore: movieStore)
        let contentView = MovieListView(viewModel: viewModel).environment(\.movieStore, movieStore)
        
        guard let windowScene = scene as? UIWindowScene else { return }
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView: contentView)
        self.window = window
        window.makeKeyAndVisible()
    }
}

MovieListView and its ViewModel

We still haven’t taken a look on MovieListView and its view model, let’s do it now. View model conforms to protocol ObservableObject and uses @Published property wrappers. ObservableObject’s default implementation provides objectWillChange publisher. @Published property wrapper automatically fires the publisher when the property value is about to change. On MovieListView we have declared view model property with @ObservedObject property wrapper. This will make the view to subscribe to objectWillChange publisher and will refresh the view when-ever objectWillChange fires.

extension MovieListView {
    final class ViewModel: ObservableObject {
        private let movieStore: MovieStore
        private var cancellables = [AnyCancellable]()
        
        init(movieStore: MovieStore) {
            self.movieStore = movieStore
            cancellables.append(movieStore.$allMovies.assign(to: \.movies, on: self))
        }
        
        @Published private(set) var movies = [Movie]()
        @Published var isPresentingAddMovie = false
    }
}
struct MovieListView: View {
    @Environment(\.self) var environment
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        NavigationView {
            List(self.viewModel.movies) { movie in
                Text(movie.fullTitle)
            }.navigationBarTitle("Movies")
                .navigationBarItems(trailing: navigationBarTrailingItem)
        }
    }
    
    private var navigationBarTrailingItem: some View {
        Button(action: {
            self.viewModel.isPresentingAddMovie = true
        }, label: {
            Image(systemName: "plus").frame(minWidth: 32, minHeight: 32)
        }).sheet(isPresented: self.$viewModel.isPresentingAddMovie) {
            self.makeAddMovieView()
        }
    }
    
    private func makeAddMovieView() -> AddMovieView {
        let movieStore = environment[MovieStoreKey]
        let viewModel = AddMovieView.ViewModel(movieStore: movieStore)
        return AddMovieView(viewModel: viewModel)
    }
}

Changes in MovieStore are observed by subscribing to allMovies subscriber and then assigning the new list of movies to view model’s own property. Note that assignment is triggered on subscribing and when changes happen: like KVO with initial option. Only downside is that now the list is duplicated but that’s OK. We would need to do that anyway when we would like to sort or filter the list later on.

AddMovieView and its view model are created when user taps on the plus button in the navigation bar. Environment property wrapper can be used to get the whole environment or any of the values using a specific key. In current case I went for accessing the whole environment object and then getting MovieStore using a MovieStoreKey later when needed. Then the MovieStore is not available in the whole view scope and only when creating the AddMovieView. Other option would be to use @Environment(\.movieStore) var movieStore instead.

AddMovieView and its ViewModel

AddMovieView’s view model is initialised with MovieStore and internally it represents and instance of Movie. Published property wrapper is used similarly like in MovieListView’s view model. The model object is a private property and instead of direct access, two bindings are provded for TextField and Picker. Binding represents a two way connection between the view and model. In addition, there is canSave property what is used for enabling the save button in the navigation bar. Save button should be enabled only when title is filled. To recap the view update flow: TextField or Picker will use Binding to update private property newMovie. As newMovie property uses @Published property wrapper, it will fire ObservableObject’s objectWillChange publisher. SwiftUI automatically subscribes to objectWillChange because view model’s property uses @ObservedObject.

extension AddMovieView {
    class ViewModel: ObservableObject {
        private let movieStore: MovieStore
        
        init(movieStore: MovieStore) {
            self.movieStore = movieStore
        }
        
        @Published private var newMovie = Movie(fullTitle: "")
        
        lazy var title = Binding<String>(get: {
            self.newMovie.fullTitle
        }, set: {
            self.newMovie.fullTitle = $0
        })
        
        lazy var rating = Binding<Movie.Rating>(get: {
            self.newMovie.givenRating
        }, set: {
            self.newMovie.givenRating = $0
        })
        
        var canSave: Bool {
            return !newMovie.fullTitle.isEmpty
        }
        
        func save() {
            movieStore.add(newMovie)
        }
    }
}

struct AddMovieView: View {
    @Environment(\.presentationMode) private var presentationMode
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        NavigationView {
            Form {
                titleSection
                ratingSection
            }.navigationBarTitle("Add Movie", displayMode: .inline)
                .navigationBarItems(leading: leadingBarItem, trailing: trailingBarItem)
                .navigationViewStyle(StackNavigationViewStyle())
            
        }
    }
    
    private var titleSection: some View {
        Section() {
            TextField("Title", text: viewModel.title)
        }
    }

    private var ratingSection: some View {
        Section() {
            Picker(LocalizedStringKey("Rating"), selection: viewModel.rating) {
                ForEach(Movie.Rating.allCases, id: \.rawValue) {
                    Text($0.localizedName).tag($0)
                }
            }
        }
    }
    
    private var leadingBarItem: some View {
        Button(action: { self.presentationMode.wrappedValue.dismiss() }, label: {
            Text("Cancel")
        })
    }
    
    private var trailingBarItem: some View {
        Button(action: {
            self.viewModel.save()
            self.presentationMode.wrappedValue.dismiss()
        }, label: {
            Text("Save").disabled(!self.viewModel.canSave)
        })
    }
}

Summary

We created a simple app with two views. Both views had its own view model and both view models used the same dependency: MovieStore. One view model triggered changes in MovieStore and those changes were observed by the other view model. In addition, we looked into how to use SwiftUI’s environment and how to trigger view updates from view models.

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

SwiftUICombineMVVMExample (GitHub, Xcode 11.3, Swift 5)

Categories
iOS Swift UIKit

Most visited blog posts in 2019

Thank you to everyone who visted my blog and I really hope that you learnt something new. I am trying to keep my blog post short and having sample code what can be used instantly. But now, its time to take a look back on all the blog posts I wrote in 2019. This year I kept following my schedule of publishing a blog post every second week. This means that in this year I published 27 blog post covering wide variaty of topics from UIKit to SwiftUI, SpriteKit, VisionKit, CryptoKit, app architecture and so on. I finally managed to get noticed by iOS Dev Weekly and the issue 430 contained my post Adding custom attribute to NSAttributedString on iOS. Really happy about it!
All in all, I am very happy to see how my blog grew in 2019. Total view count grew more than 5 times compared to 2018 (started blogging in late 2017).

Let’s take a look on stats from 2019 and list blog posts by the number of vists in 2019. The first list contains blog posts written in 2019 and the second list contains all the other blog posts people read in 2019.

Most read posts written in 2019

Most read posts written before 2019

Have a great coming year! I’ll keep publishing new blog posts every second week so follow me on Twitter @toomasvahter or subscribe to RSS feed. Thank you!

Categories
iOS macOS Swift Xcode

Performance testing using XCTMetric

XCTMetric enables creating tests with measure blocks collecting information about CPU, memory and disk. In this post we’ll write UI-tests measuring a button tap what triggers writing to disk, allocating larger amount of memory and applying filters what requires CPU to do more work. It should be noted that XCTMetric can also be used in unit-tests.

Method under the test

The method we are going to write performance tests against is a simple method dealing with loading an image, writing data to disk, applying CIFilter and writing processed image to disk. In this example case, everything runs on a main thread what probably would not be a case in a real application.

@IBAction func process(_ sender: Any) {
	let image = UIImage(named: "Image")!
	imageStorage.store(image, filename: "original")
        
	let processedImage = ImageProcessor.processImage(image)
	imageStorage.store(image, filename: "processed")
	processedImageView.image = processedImage
}

XCTClockMetric for measuring taken time

XCTClockMetric is for measuring time taken by the block. Useful for catching regressions in longer running operations.

func testCalculateWithClockMetric() {
	let app = XCUIApplication()
	app.launch()
	measure(metrics: [XCTClockMetric()]) {
		app.buttons["Process"].tap()
	}
}

XCTCPUMetric for measuring CPU utilization

XCTCPUMetric measures CPU activity and output 3 different results: CPU time, CPU cycles and CPU instructions retired. CPUs have a feature called speculative execution what means that more instructions are completed than the actual program flow requires. Retired instructions are the instructions which were actually needed by the flow of the program. This feature speeds up the program execution as CPU can process data ahead of time. Example case would be if else where CPU processes both branches but only one branch is valid in the program flow.

func testCalculateWithCPUMetric() {
	let app = XCUIApplication()
	app.launch()
	measure(metrics: [XCTCPUMetric(application: app)]) {
		app.buttons["Process"].tap()
	}
}

XCTMemoryMetric for measuring allocated memory

XCTMemoryMetric measures allocated physical memory useful for testing operation allocating significant amount of memory (processing images).

func testCalculateWithMemoryMetric() {
	let app = XCUIApplication()
	app.launch()
	measure(metrics: [XCTMemoryMetric(application: app)]) {
		app.buttons["Process"].tap()
	}
}

XCTStorageMetric for measuring disk usage

XCTStorageMetric measures bytes written to the disk.

func testCalculateWithStorageMetric() {
	let app = XCUIApplication()
	app.launch()
	measure(metrics: [XCTStorageMetric(application: app)]) {
		app.buttons["Process"].tap()
	}
}

XCTOSSignpostMetric for measuring time between signposts

Apple provides signpost metric for application launch time for making it easy to add performance test measing launch time. In WWDC’19 session “Optimizing app launch” the suggested goal is 400 ms which is the duration of the app launch animation. XCTOSSignpostMetric has initializer for custom signpost as well.

func testLaunchPerformance() {
	if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) {
		measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) {
			XCUIApplication().launch()
		}
	}
}

Summary

XCTMetric enables writing performance tests for performance critical parts of the application. We took a look at CPU, memory, storage and signpost metrics.

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

MeasuringInTests (Xcode 11.3)

Categories
Combine iOS Swift SwiftUI

Animating view transitions in SwiftUI

One building block for navigating from one view to another is NavigationView which is a representation of UINavigationController in UIKit. This time, let’s take a look on how to transition from one SwiftUI view to another one without NavigationView.

AppFlowCoordinator managing choosing the view

The idea is to have a root SwiftUI view with only responsibility of presenting the active view. State is stored in AppFlowCoordinator which can be accessed from other views and therefore other views can trigger navigation. Example case we’ll build, is animating transitions from login view to main view and back. As said, AppFlowCoordinator stores the information about which view should be on-screen at a given moment. All the views are represented with an enum and based on the value in enum, views are created. This coordinator is ObservableObject what makes it easy to bind to a SwiftUI view – whenever activeFlow changes, SwiftUI view is updated. The term flow is used because views can consist of stack of other views and therefore creating a flow of views.

import SwiftUI
final class AppFlowCoordinator: ObservableObject {
@Published var activeFlow: Flow = .login
func showLoginView() {
withAnimation {
activeFlow = .login
}
}
func showMainView() {
withAnimation {
activeFlow = .main
}
}
}
extension AppFlowCoordinator {
enum Flow {
case login, main
}
}
Triggering navigation using flow controller.

RootView displaying active flow

RootView selects which view is currently visible. It accesses coordinator through environment. SwiftUI requires EnvironmentObjects to be ObservableObjects, therefore this view is automatically refreshed when activeFlow changes in the AppFlowCoordinator. RootView’s body is annotated with @ViewBuilder which will enable the view to return a body with type depending on the current state (HStack is also a ViewBuilder). Other options are wrapping the views with AnyView or using Group. In our case the view types are LoginView and ContentView. Both views also define the transition animation what is used when view refresh is triggered in withAnimation closure in AppFlowCoordinator. Asymmetric enables defining different transitions when view is added and removed from the view hierarchy.

let appFlowCoordinator = AppFlowCoordinator()
let rootView = RootView().environmentObject(appFlowCoordinator)
window.rootViewController = UIHostingController(rootView: rootView)
Inserting AppFlowCoordinator to environment
struct RootView: View {
@EnvironmentObject var appFlowCoordinator: AppFlowCoordinator
@ViewBuilder
var body: some View {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
if appFlowCoordinator.activeFlow == .main {
ContentView().transition(.asymmetric(insertion: .scale, removal: .opacity))
}
else if appFlowCoordinator.activeFlow == .login {
LoginView().transition(.asymmetric(insertion: .slide, removal: .opacity))
}
else {
EmptyView()
}
}
}
}
view raw RootView.swift hosted with ❤ by GitHub
Updating currently visible flow with transition animations

Triggering navigation from SwiftUI view

Last piece we need to take a look at is how to trigger transition. As AppFlowCoordinator is in environment, any view can access the coordinator and call any of the navigation methods. When login finishes, LoginView can tell the coordinator to show the main content view.

struct LoginView: View {
@EnvironmentObject var appFlowCoordinator: AppFlowCoordinator
var body: some View {
ZStack {
Button(action: appFlowCoordinator.showMainView) {
Text("Login")
}
}
}
}
view raw LoginView.swift hosted with ❤ by GitHub
Navigating to main view from login view

Summary

We took a look on how to navigate from one SwiftUI view to another by using a coordinator object. Coordinator stored the information about which view we should currently display on screen. We saw how easy it is to trigger navigation from any of the currently visible views.

transition animation
Low FPS GIF representing the transition animation

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

RootViewTransitions Xcode 11.2.1, Swift 5.1

Categories
macOS Swift SwiftUI

Injecting dependencies using environment values and keys in SwiftUI

Instead of initializing SwiftUI views with dependencies, SwiftUI also offers other ways for injecting dependencies. This time let’s take a look on EnvironmentKey which defines a key for inserting objects to environment and vise-versa. We need to create a EnvironmentKey, adding an object to environment and then getting the object in SwiftUI view.

Creating EnvironmentKey and extending EnvironmentValues

Example object we use is DependencyManager what in real app can contains loads of dependencies. EnvironmentKey is a protocol in SwiftUI what requires to define associated type and default value. Default value is used when we have not explicitly inserted an instance of DependencyManager to the environment, more about it a little bit later. EnvironmentValues is a struct containing a collection of environment objects. We’ll add a property to EnvironmentValues which later will be used by @Environment property wrapper and also when setting an instance of the object to the environment.

import Foundation
import SwiftUI
struct DependencyManager {
let identifier: String
let urlSession = URLSession.shared
}
struct DependencyManagerKey: EnvironmentKey {
typealias Value = DependencyManager
static var defaultValue = DependencyManager(identifier: "Default created by environment")
}
extension EnvironmentValues {
var dependencyManager: DependencyManager {
get {
return self[DependencyManagerKey.self]
}
set {
self[DependencyManagerKey.self] = newValue
}
}
}
Custom EnvironmentKey and EnvironmentValues extension for accessing dependencies

Inserting objects to environment

If we would like to insert a DependencyManager to the Environment, we can use func environment(_ keyPath: WritableKeyPath, _ value: V) -> some View using our DependencyManagerKey and an instance created by us. If we do not insert our own, SwiftUI will use the instance returned by defaultValue when the key is first time accessed.

// Setting non-default instance of DependencyManager, otherwise default instance is used created in DependencyManagerKey
let dependencyManager = DependencyManager(identifier: "Scene delegate created")
let contentView = ContentView().environment(\.dependencyManager, dependencyManager)
Setting instance of DependencyManager to SwiftUI environment

Getting objects from environment

Objects can be read from the environment using @Environment property wrapper and specifing the EnvironmentKey.

import SwiftUI
struct ContentView: View {
@Environment(\.dependencyManager) var dependencyManager: DependencyManager
var body: some View {
Text(dependencyManager.identifier)
}
}
Accessing the instance of DependencyManager in environment

Summary

We created an environment key and inserted an object into environment. We looked into how SwiftUI handles default values for environment objects and used @Environment property wrapper for getting the instance from the environment using the created key.

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.

Categories
Foundation iOS Swift UIKit

Adding custom attribute to NSAttributedString on iOS

NSAttributedString is used for displaying rich text on Apple platforms. There are many attributes available for styling the text. Let’s see how to add new attribute and how to draw it in UITextView.

Drawing custom attribute

Defining a new attribute is just a matter of extending NSAttributedString.Key and defining a new static variable. Difficult part is actually drawing the custom attribute. Apple’s documentation says that best way for it is to subclass NSLayoutManager and overriding drawGlyphs(forGlyphRange:at:). This is what we are doing here as well. Whenever glyphs are drawn, we check if the glyph range contains the custom attribute, if it does, then we get the rects for glyphs containing the custom attribute. When we know rects, we can draw the custom styling. In this case, we’ll mimic tokens in text and therefore go for rectangle with rounded corners. Should be noted that enumerating custom attribute, longestEffectiveRangeNotRequired should be specified. Otherwise, if we have two different tokens next to each-other, then this attribute is considered as one.

UITextView has by default text container inset set. For being able to draw the custom styling exactly behind the text, this offset must be taken account. Easiest is to propagate insets to layout manager what we are doing here.

extension NSAttributedString.Key {
    static let token = NSAttributedString.Key("Token")
}

final class TokenLayoutManager: NSLayoutManager {
    var textContainerOriginOffset: CGSize = .zero
    
    override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
        let characterRange = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
        textStorage?.enumerateAttribute(.token, in: characterRange, options: .longestEffectiveRangeNotRequired, using: { (value, subrange, _) in
            guard let token = value as? String, !token.isEmpty else { return }
            let tokenGlypeRange = glyphRange(forCharacterRange: subrange, actualCharacterRange: nil)
            drawToken(forGlyphRange: tokenGlypeRange)
        })
        super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
    }
    
    private func drawToken(forGlyphRange tokenGlypeRange: NSRange) {
        guard let textContainer = textContainer(forGlyphAt: tokenGlypeRange.location, effectiveRange: nil) else { return }
        let withinRange = NSRange(location: NSNotFound, length: 0)
        enumerateEnclosingRects(forGlyphRange: tokenGlypeRange, withinSelectedGlyphRange: withinRange, in: textContainer) { (rect, _) in
            let tokenRect = rect.offsetBy(dx: self.textContainerOriginOffset.width, dy: self.textContainerOriginOffset.height)
            UIColor(hue: 175.0/360.0, saturation: 0.24, brightness: 0.88, alpha: 1).setFill()
            UIBezierPath(roundedRect: tokenRect, cornerRadius: 4).fill()
        }
    }
}

Using custom layout manager in UITextView requires a little bit of setup what we can put into UITextView’s subclass.

final class TokenTextView: UITextView {
    init(frame: CGRect) {
        let layoutManager = TokenLayoutManager()
        let textStorage = NSTextStorage()
        textStorage.addLayoutManager(layoutManager)
        let textContainer = NSTextContainer()
        textContainer.heightTracksTextView = true
        textContainer.widthTracksTextView = true
        layoutManager.addTextContainer(textContainer)
        super.init(frame: frame, textContainer: textContainer)
        updateLayoutManager()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override var textContainerInset: UIEdgeInsets {
        didSet {
            updateLayoutManager()
        }
    }
    
    private func updateLayoutManager() {
        guard let layoutManager = layoutManager as? TokenLayoutManager else { return }
        layoutManager.textContainerOriginOffset = CGSize(width: textContainerInset.left, height: textContainerInset.top)
        layoutManager.invalidateDisplay(forCharacterRange: NSRange(location: 0, length: attributedText.length))
    }
}
let textView = TokenTextView(frame: .zero)
textView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(textView)
NSLayoutConstraint.activate([
    textView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
    textView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
    textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
    textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
])
        
let string = "The quick brown fox jumps over the lazy dog"
let attributedString = NSMutableAttributedString(string: string)
let value = "value"
attributedString.addAttribute(.token, value: value, range: NSRange(location: 4, length: 5))
textView.attributedText = attributedString

Summary

We defined a new attribute by extending NSAttributedString.Key. Then we created a new NSLayoutManager subclass and added custom styling to the attribute. Lastly, we configured the UITextView to use custom layout manager and set text with custom attribute to it.

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

NSAttributedStringCustomAttribute Xcode 11.2, Swift 5.1