Once and Only Once (Part 2 : Widgets)
In Part One of this article we created an Xcode Project that builds an app for macOS, tvOS, iOS and watchOS and removed all the (to us) redundant duplication that Xcode had added for each template. In this part we'll add widgets for Mac, Phone and Watch and try to do the same job of sharing files where can to give us a single place to change things.
Step 7: Adding Widgets
In the targets list in the project file we'll add a "Widget Extension" from each of macOS, iOS and watchOS (no widgets on tvOS).
We name the widgets appropriately but otherwise accept the default. Configuration Intents are common for configuring widgets so we'll say yes to those. We don't need a Live Activity at the moment because my reminders don't have any duration. Mac and Phone get default embedded in JustOneThing, watch gets embedded in "Just One Thing WatchApp".
After we're done the navigator looks like this.
So much duplication!
- Once again the Assets, Info.plist and Intent Definition File are identical so we should be able to share those.
- Only the Mac Widget project has an entitlements file.
- Only the iOS Widget project has a WidgetBundle tagged with @Main. Watch and Mac widgets have the @Main tag on the XXXWidget classes themselves.
- The XXXWidget.swift files differ only in the names, and watch has some "recommendations" since Watch Widgets don't use a full configuration UI like Mac+iOS.
There's a problem though. We'll commit anyway, just for posterity, but the code won't currently build as all three Intent Definitions care a ConfigurationIntent class and Xcode complains.
For purposes of this article I was tempted to rename all three to get to a "it builds" state for this step. We'd need to go change the timeline code in each widget to use the new individual name for the Intent. Since the goal in the end is to make them more the same, not more different, I'm just going to leave them as is, broken, and commit so I know a known "starting point" when I begin the surgery to unify it all.
Step 8: Extract a WidgetShared to MATCH AppShared.
First we'll make a new Group (with Folder) called WidgetShared.
We can pick any one of the widgets (say Mac) and drag the Info. the Intent and the Assets to the WidgetShared folder and delete the other copies (from say Phone and watch).
We should check the intent definition is added to all three targets widget targets, and both app targets.
The assets file we'll rename to WidgetAssets just so we're clear its different from the app and make sure its added to all three Widget targets.
The Info.plist is more fiddly. We have to go into the build settings for each widget target and change the Packaging-> info.plist file to WidgetShared/Info.plist
Lastly we have the same problem we had in step 3 where the watch extension was being included in the Mac app. Here we need to ensure the iPhone and Mac widget extensions are only included in the relevant builds (remember this is supposed to be a single app, but we have to be aware of the distinct platforms when it comes to embedding).
For the main app target, make sure the Mac and iOS widget filters get set appropriately.
OK, it should all build for all platforms and we can go for another commit. We now have:
- App
- a Mac+iOS+TV app
- a watch app
- an AppShared folder for the bulk of the sources.
- Widgets
- a Mac Widget Extension
- a Phone Widget Extension
- a Watch Widget Extension
- a WidgetShared folder for the bulk of the sources
Step 9: Just Like Step 6
In step 6 I asked you to trust me and we added an entitlements file for each target. We're going to do the same for the widgets but its extra fiddly because Xcode puts them at the root of the project structure, so some manual move is required.
The Mac already has entitlements, so select first the Phone and then the Watch widget targets and add a new capability - App Groups.
These will be added to the root of the project (right at the top of the navigator) and so we'll have to move them (and I renamed them too, because I think PhoneWidget and WatchWidget is quite enough).
Forst we can drag each entitlements file to the right Group, then we rename each by removing the word Extension from the filename.
We have to tell the build settings about our change too, so got to the target settings for each of the Phone and Watch widgets and use the search bar to find" entitlements".
We'll replace "PhoneWidgetExtension.entitlements" with "PhoneWidget/PhoneWidget.entitlements" and do the same for the watch.
Finally we are where we want to be. Each widget has its own entitlements and they are named consistently. Lets commit.
Step 10: Sharing Code
The work we've done on the widgets so far has shared configuration files and assets. Like we did on the App targets it would be nice to share as much code as possible.
I'm not going to draw it all out, you can look for yourself, but if you compare the swift files PhoneWidget.swift, WatchWidget.swift and MacWidget.swift they are essentially identical except for the different names, and two distinct differences.
Firstly, the Provid
er struct in the Watch widget has an additional method recommendatio
ns( ) that's used for configuration on the watch where the UI is limited and a full configuration interface is not possible. In that situation the watch provides a few "pre-canned" configurations to choose from - the recommendations.
This is on the Provider, *not* the actual Widget, but still it doesnt hurt if that method appears on the Phone or Mac, its only Watch that needs it, so we'd be ok to pick the Watch version of Provider
to share. Phone and Mac will just ignore the method.
The second difference is directly related to the Widget however.
On Mac and Watch, there's a @main
decorating the Widget struct. It s not there on Phone. Why not? On the Phone there's an additional file WidgetBundle
which as the @main
.
WidgetBundle is used if the extension exports more than one kind of widget and it seems that Apple almost expect this of the Phone, but not of the Mac or Watch.
Oversight? Or maybe they'd prefer we make a new extension per widget on the Watch and Mac. In either case, the distinction is arbitrary and so again we can choose the Phone version to share - all platforms with have a @main WidgetBundle wrapping an non-@main Widget.
Lets get started.
First lets Drag the WatchWidget.swift file to WidgetShared. We' ll pick that one just because it has the extra Provider method and will save us a few seconds.
Check its available in all three widget targets.
Delete the PhoneWidget and WatchWidget files,
Then rename the WatchWidget.swift file to JustOneThingWidget.swift and edit the contents to remove the preview (for now) and rename the WatchWidgetEntryView and WatchWidget to JustOneThingWidgetEntryView and JustOneThingWidget.
The last thing we are going to do in this stage is bring across the WidgetBundle to make it shared as well. We won't need the file, but we'll copy and paste the code.
First select the code in PhoneWidgetBundle.swift and copy it it over to the top of JustOneThingWidget.swift.
Replace the PhoneWidget parts with JustOneThingWidget
And we'll need to remove the @main from the JustOneThingWidget struct below because we have it on the bundle now.
The last thing we need to do is delete the PhoneWidgetBundle.swift file because we don't need it any more.
And that's the end of Step 10! Like the App we now have moved pretty much everything to a shared folder and all that's left as target specific are the entitlements files.
This will change as we add support for different widget types, but for now, we have a single widget that displays and works on all of our platforms (no widgets on TV).
Lets commit and then move on to Part 3.