Limits and SwiftUI – Custom Currency Button

Limits by Money Master is a personal finance app, so currency symbols are an obvious part of any design.  Early on in the UI design for Limits, I decided that I wanted a currency button with a badged ‘plus’ in the corner, to represent a button to add a Debit.  The problem, of course, is that currencies vary all over the world, and it needed to be the currency of the current Locale of the phone, or else I would risk alienating my users immediately.

The great SF Symbols package has several currency-based glyphs, however it didn’t appear that it would be a great fit for a few reasons:

  1. They aren’t organized or named by Locale, so I would need to manually map every possible Locale to a glyph
  2. Each of the glyphs are in either a filled or empty circle, and none of them are badged – I felt the ‘plus’ sign was important to convey the intended meaning

SwiftUI’s Button view is extremely customizable – the ‘view’ of a Button can be more than just a Text label and an Image, but it can be any SwiftUI view.  Perfect for a building a custom control that would be sensitive to taps.

The Finished View

Before we break down how to build the view, let’s start with the finished product:

Screen Shot 2020-02-17 at 11.47.27 AM

This is, of course, 8 different representations of the Button, each in a different Locale:

  • en_US
  • en_GB
  • fr_CH
  • ro_RO
  • cs_CS
  • zh_CN
  • ar_AE
  • ms_MY

This selection of Locales is intended to show both that a variety of common currency symbols look good, but also that symbols with multiple characters will scale appropriately.

Breaking this down further, it is built in three parts — the outlined circle, the ‘plus’ badge, and the currency symbol.  Let’s take them one at a time.

The Circle

Calling it a circle isn’t quite accurate.  The space behind the badge isn’t actually drawn, because the badge is actually transparent – the circle would show in the negative space of the plus sign if it were.  Instead, it’s an arc, drawn using a Path:

GeometryReader { geometry in
    Path { path in
        path.addArc(
            center: CGPoint(x: self.halfHeight(geometry), y: self.halfHeight(geometry)),
            radius: self.halfHeight(geometry) - self.strokeWidth(geometry),, 
            startAngle: .degrees(120),
            endAngle: .degrees(160),
            clockwise: true)
    }.stroke(lineWidth: self.strokeWidth(geometry))
}
...

func halfHeight(_ geometry: GeometryProxy) -> CGFloat {
    geometry.size.height * 0.5
}

func strokeWidth(_ geometry: GeometryProxy) -> CGFloat {
    geometry.size.height * 0.06
}

This simple Path draws an arc around the center point of the component, with the radius filling out the space nearly from end to end.

The Badge

I did end up making use of the SF Symbols package — the badge itself is one of the available images:

Image(systemName: "plus.circle.fill")
    .resizable()
    .frame(width: self.badgeHeight(geometry), height: self.badgeHeight(geometry))
    .offset(x: self.badgeOffset(geometry) * -1, y: self.badgeOffset(geometry))
...

func badgeHeight(_ geometry: GeometryProxy) -> CGFloat {
    geometry.size.height * 0.4
}

func badgeOffset(_ geometry: GeometryProxy) -> CGFloat {
    halfHeight(geometry) - (badgeHeight(geometry) * 0.5)
}

The glyphs that make up the SF Symbols package makes the process of finding icons for your app simple – it saved me hours of hunting down freely available to use glyphs, and sizing them appropriately, but they can also be handy when composing Views.  The frame and offset here is to size the badge, and move it to the lower left corner of the view, over the gap in the circle.

One more interesting note here – this is actually inside the same GeometryReader shown above, and sizing the badge relative to that enclosing container allows the view to scale up and down smoothly, rendering appropriately at any size.

The Currency Symbol

Finally, we draw the currency symbol in the center of the view:

@State
var locale = Locale.current
...

Text(self.currencySymbol())
    .font(Font.system(size: self.currencySize(geometry), weight: .semibold))
...

func currencySymbol() -> String {
    self.locale.currencySymbol ?? "$"
}    

func currencySize(_ geometry: GeometryProxy) -> CGFloat {
    var modifier: CGFloat = 0.25
    if currencySymbol().count == 2 {
        modifier = 0.33
    } else if currencySymbol().count == 1 {
        modifier = 0.5
    }
    return geometry.size.height * modifier
}

Again, this is enclosed in the GeometryReader, so it scales up and down appropriately.  The locale variable is constructed as a @State in this case for testing – it is never varied in the app, but can be set to any value in Previews.  This is how I created the various previews, above.

Finally, the currencySize method varies the font size, allowing those 2 and 3 character symbols to be drawn smaller, to fit properly.

Putting it all together

Each of those three components are further combined inside a ZStack, so they’re drawn on top of each other, forming the final component.

A few final points before showing the entire code:

  • There is an optional label drawn on the Main Screen of Limits, showing the text ‘Add Debit’ to the right of the button.  This is controlled by changing the value of the showLabel property
  • By default, the size of the component will be 50×50, but can be adjusted up or down with the size property
  • Of course, pressing the button needs to actually do something – in this case, it will open a Sheet showing the Add Debit/Credit View, which is attached directly to the button itself.  Attaching the sheet directly to the button is a simple way ensure the button behaves the same way, no matter where it’s placed in the app!
struct NewItemButton: View {
    @State
    var showItemSheet = false
    
    @State
    var showLabel = false
    
    @State
    var size: CGFloat = 50
    
    @State
    var locale = Locale.current
    
    var body: some View {
        Button(action: {
            self.showItemSheet = true
        }) {
            HStack {
                GeometryReader { geometry in
                    ZStack {
                        //Draw the Circle
                        Path { path in
                            path.addArc(
                                center: CGPoint(x: self.halfHeight(geometry), y: self.halfHeight(geometry)),
                                radius: self.halfHeight(geometry) - self.strokeWidth(geometry),
                                startAngle: .degrees(120),
                                endAngle: .degrees(160),
                                clockwise: true)
                        }.stroke(lineWidth: self.strokeWidth(geometry))
                        
                        //Draw the Badge
                        Image(systemName: "plus.circle.fill")
                            .resizable()
                            .frame(width: self.badgeHeight(geometry), height: self.badgeHeight(geometry))
                            .offset(x: self.badgeOffset(geometry) * -1, y: self.badgeOffset(geometry))
                        
                        //Draw the Currency Symbol
                        Text(self.currencySymbol())
                            .font(Font.system(size: self.currencySize(geometry), weight: .semibold))
                    }
                }.frame(width: size, height: size)
                
                //Optionally show the Label
                if showLabel {
                    Text("Add Debit")
                }
            }
        }.sheet(isPresented: $showItemSheet) {
            Text("A Form Would Go Here!")
        }
    }
    
    func currencySymbol() -> String {
        self.locale.currencySymbol ?? "$"
    }
    
    func currencySize(_ geometry: GeometryProxy) -> CGFloat {
        var modifier: CGFloat = 0.25
        if currencySymbol().count == 2 {
            modifier = 0.33
        } else if currencySymbol().count == 1 {
            modifier = 0.5
        }
        return geometry.size.height * modifier
    }
    
   func halfHeight(_ geometry: GeometryProxy) -> CGFloat {
        geometry.size.height * 0.5
    }
    
    func strokeWidth(_ geometry: GeometryProxy) -> CGFloat {
        geometry.size.height * 0.06
    }
    
    func badgeHeight(_ geometry: GeometryProxy) -> CGFloat {
        geometry.size.height * 0.4
    }
    
    func badgeOffset(_ geometry: GeometryProxy) -> CGFloat {
        halfHeight(geometry) - (badgeHeight(geometry) * 0.5)
    }
}

And that’s it!  SwiftUI makes a relatively complex component very straight forward to build – had I doing this in UIKit, it’s likely I would not have attempted this, as the drawing API simply is not accessible as it is SwiftUI, and the ability to compose a view is not as simple.  It’s a big win!

Core Data + CloudKit – Syncing Existing Data

Anyone who has an app that uses both Core Data and CloudKit to sync the data across devices will understand why NSPersistentCloudKitContainer just might be a sleeper hit of iOS 13.  For Money Master, I was able to remove almost 1000 lines of my most complex code, and I have more reliable sync as a result!  It’s not all roses, though – if you already have users with data in the field, you may have noticed that the data isn’t pushed to the new CloudKit Zone, while new data is – this is apparently an intentional design choice, although Apple doesn’t provide any guidance, nor do the new API’s make it obvious how this should be done.  With a little experimentation, I’ve found that the following solution seems to be a way forward.

Duplication, Duplication, Duplic…

My first pass, suggested by Apple, was to simply change my NSPersistentContainer to NSPersistentCloudKitContainer.  This allowed new data to sync over, but the existing data didn’t go anywhere – if I modified any of the existing records, however, those sync’d just fine.  Sweet – just do this to all records on the first start, and we’ll be good to go!  Sadly, my joy was short lived, as this caused a full copy of the data to be synced for each device – not so good…

Taking a step back, this makes sense – when I built iCloud support into Money Master, I had the exact same problem.  I needed to implement deduplication myself, by adding a unique UUID to each record.  Core Data, of course, knows about this property, but it has no idea what it’s used for.  Each device essentially has a separate, independent database, that just happens to have the same data in each – the deduplication logic is implemented in my code!

It would have been awesome for Apple to provide a way to mark a property on each record to use as the CKRecord ID – seems to me that this would have been an elegant solution, but alas, it’s not available.

A Tale of Two Persistent Containers

Instead of modifying my existing Persistence Container, I decided to create a second one – this would allow me to use the original container to store certain configuration data that was best left on device, while also allowing me to pick and chose what records I copied into the new container.  My Core Data Stack looks something like this:

//Original Local Container
lazy var localContainer: NSPersistentContainer = {
  let container = NSPersistentContainer(name: "MyCoreDataModel")
  container.loadPeristentStores(completionHandler: { (storeDescription, error) in 
    if let error = error as NSError? {
      fatalError("Unresolved error \(error), \(error.userInfo)")
    }
  })
}()

//New Mirrored Container
lazy var mirroredContainer: NSPersistentCloudKitContainer = {
  let container = NSPersistentCloudKitContainer(name: "MyCoreDataModel")
  let storeDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!

  let cloudStoreLocation = storeDirectory.appendingPathComponent("MirredData.sqlite")
  let cloudStoreDescription = NSPersistentStoreDescription(url: cloudStoreLocation)

  //"Cloud" is the name of a second configuration in my Core Data Model
  cloudStoreDescription.configuration = "Cloud"

  // Set the container options on the cloud store
  cloudStoreDescription.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.")
  
  container.persistentStoreDescriptions = [
    cloudStoreDescription
  ]
  container.loadPersistentStores { (storeDescription, error) in 
    guard error == nil else {
      fatalError("Could not load persistent stores \(error!)")
    }
  }
}()

And what about those dupes?

Of course, that deduplication UUID is still there, so why not give it a second life?  But how do we know what data is where?  The new NSPersistentCloudKitContainer is designed as an ‘eventually consistent’ distributed system, and as such, it doesn’t provide a way to ask it if you have all of the data.  After many (many!) different attempts, I realized the answer was much simpler than adapting the new API to a use it’s not designed for — CloudKit itself hasn’t gone away, and can be used to inspect the new containers!

From here, the way forward was fairly simple – load the data from CloudKit (in batches, if you need), and load data from the same batch from my original PersistenceContainer.  For every local record, if there is no associated record with the same UUID from CloudKit, that means it hasn’t been sync’d, and I can store that same record in my new and improved Mirrored Persistent Container — if it does have a match, I can leave it where it is.  And there we have deduplication!

func migrate() {
  let privateDB = CKContainer.default().privateCloudDatabase
  let query = CKQuery(recordType: "CD_RecordName", predicate: NSPredicate(value: true)) //Core Data appends 'CD_' to the name
  let zoneId = CKRecordZone.ID(zoneName: "com.apple.coredata.cloudkit.zone", ownerName: CKCurrentUserDefaultName) //Yep, Core Data creates it's own CloudKit Zone

  privateDB.perform(query, inZoneWith: zoneId, completionHandler: { (cloudRecords, error) in 
    if let error = error {
      ....
    }

    var cloudUUIDs = cloudRecords.map { record in 
      return UUID(uuidString: record["CD_uuid"]!.description)! //Another 'CD_' prefix...
    }

    let localContainer = CoreDataStack.instance.localContainer
    let fetchRequest = NSFetchRequest(entityName: "RecordName") //Same as 'CD_RecordName' above, but without the 'CD_'

    let context = localContainer.newBackgroundContext()
    context.performAndWait {
      do {
        let records = try context.fetch(fetchRequest) as! [RecordName]

        records.forEach { record in 
          if cloudUUID.filter { $0 == record.uuid }.count == 0 {
            recordRepository.create(record) //This has been updated to use the Mirrored Persistent Context
          }
        }
      } catch {
        fatalError("blah, blah, blah...")
      }
    }
  }
}

Your Mileage May Vary

A few random points:

  • Make sure you only run this migration once – I use UserPreferences to track this sort of thing, and I only mark it as ‘complete’ if it failed without a network error
  • In order to query the new records in CloudKit, you’ll need to pre-create the ‘CD_RecordName’ type in CloudKit, and assign a Queryable Index on the recordName property.  This is standard procedure for any CloudKit record, if not a bit annoying.  Luckily, you don’t need to add any other properties on it — Core Data will handle the rest.
  • I used pseudo-code here because my migration may not be applicable across the board.  In Money Master it’s likely that you’ll have up to a few hundred records — if your app has thousands, you’ll need to be much more careful about the data you’re copying down to the devices.

One More Thing…?

One last tip — if your previous CloudKit integration had a CKSubscription created named ‘cloud’… well, that’s going to cause some problems.  You’ll get errors from the Core Data infrastructure, and you won’t see any data sync’d to the new iCloud Zone.  The solution to this is simple, just delete the existing subscription before creating the NSPersistentCloudKitContainer.  It just seems like Apple could have chosen a subscription name here that’s slightly less likely to have this kind of collision…