Last year the Flutter Team released an excellent codelab that explained the process of adding an iOS or Android “Home Widget” to your Flutter app. As it turns out, it’s surprisingly easy!
Adding Widgets is a fairly happy path as they can be added using the built-in UI flows in XCode or Android Studio. The development can also be done in the respective IDEs, complete with robust code-hinting, debug and hot(ish) reload support!
Inspired by this great potential workflow, we partnered with the Flutter team on a couple initiatives to further showcase what can be done with Home Widgets on iOS.
CounterWidget
The first idea was to create a simple Home Widget Vignette that developers could use as a basic reference. Something slightly more advanced than the codelab, but still relatively simple, especially on the iOS / SwiftUI side.
To accomplish this we decided to take the basic counter app, spruce up the design a bit, and add a Home Widget, you can see the result below:
The code is fully open source at https://github.com/gskinnerTeam/flutter-home-widget-vignette/tree/master. Most of the interesting .dart
code can be found in the HomeWidgetController.dart file, while the relevant .swift
code is located in the /ios/CounterWidget folder.
Wonderous Home Widget
The second goal was to show a more ambitious and complex example by adding a Home Widget to our showcase Flutter App Wonderous which is live in the app store now! See the results below:
The source code for wonderous is available at https://github.com/gskinnerTeam/flutter-wonderous-app. For home widget related code, check out the /logic/native_widget_service.dart file and the /ios/WonderousWidget folder.
We’re hopeful that the two examples above will provide you plenty of reference material for building your own Flutter Widgets on iOS! In the remainder of this post we’re going to do a deep dive into how Home Widgets work, specifically on iOS, and explore how we can communicate, and share assets, with the Flutter App.
Exploring Home Widgets on iOS
Before getting into the code examples, it’s worth spending a bit of time to understand the basic structure of a Home Widgets on iOS.
Structure of iOS Home Widgets
Adding the widget via XCode as described in the codelab is pretty simple, but the resulting source code can be a bit tricky to understand. As you look through the generated swift files in XCode you will find 4 high-level components that work together to define a widget on iOS:
- View – A Swift UI view that accepts a
TimelineEntry
and renders the widget itself. - WidgetConfiguration – Defines which types of widgets you support (small, medium, large, etc.), as well as high-level properties like title, description, and outer margins. It also defines the primary
View
which will render theTimelineEntry
. - TimelineEntry – A data class that your
View
will eventually consume. It must contain adate
field, but you can add any additional fields you need. - TimelineProvider – Loads any shared data and returns a
TimelineEntry
object. It also provides the OS a Schedule for future widget updates.
Now that you have a grasp on the core components that make up an iOS Widget, let’s dive into the code!
/ios/CounterWidget
To see all of these working together let’s jump into the CounterWidgetConfiguration.swift file.
The first thing you’ll find as you step through the file is the CounterEntry
. You can see that we have extended TimelineEntry
and created our own custom type. We’ve added the required date
field as well as any additional fields that we want to pass to the View
. In this case count:int
and bgImgUrl:String
.
struct CounterEntry : TimelineEntry { let date: Date // Required let count: Int var bgImgUrl: String? = nil }
Next in the file, is the WidgetConfiguration
. As mentioned above, it sets various properties of the Widget, including which sizes are supported, and also returns the CounterWidgetView
which is the view that ultimately renders the widget:
struct CounterWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: widgetKind, provider: Provider()) { entry in CounterWidgetView(entry: entry) } .contentMarginsDisabled() // Allow full-screen background image .configurationDisplayName("Counter Widget") // Title .description("Displays the current count of the counter app.") // Desc // Set which sizes your widget supports .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } }
Finally, you’ll find the TimelineProvider which is responsible for populating the CounterEntry objects and providing a future update schedule for iOS:
struct Provider: TimelineProvider { // Provide an entry for a placeholder version of the widget func placeholder(in context: Context) -> CounterEntry { CounterEntry(date: Date(), count: 0) } // Provide an entry for the current time and state of the widget func getSnapshot(in context: Context, completion: @escaping (CounterEntry) -> ()) { // Load shared data from Flutter app using UserDefaults API let userDefaults = UserDefaults(suiteName: groupId) let count = userDefaults?.integer(forKey: counterId) ?? 3 let bgImgPath = userDefaults?.string(forKey: bgRenderKey) // Return an entry with the count, bgImg and themeColor. // The [CounterWidgetView] will get this data and use it to render. completion( CounterEntry( date: Date(), count: count, bgImgUrl: bgImgPath ) ) } // Provide an array of entries for the current time and, optionally, any future times func getTimeline(in context: Context, completion: @escaping (Timeline<CounterEntry>) -> ()) { getSnapshot(in: context) { (entry) in // Update us any time after the current entry time let timeline = Timeline(entries: [entry], policy: .atEnd) completion(timeline) } } }
Some important things to note when looking at the TimelineProvider
:
- It implements 3 methods:
placeholder
,getSnapshot
andgetTimeline
placeholder
returns an initial/emptyEntry
getSnapshot
uses the UserDefaults API on iOS to load any shared data and returns anEntry
getTimeline
returns a schedule for future updates that the OS will consider. In this example, we request an update at any time in the future (.atEnd
).
Swift UI
The final component on the iOS side is the CounterWidgetView.swift file. This is where we finally get into some SwiftUI Code!
The actual component gets a little complicated with some first-run edge cases, so let’s look at a simplified version that shows the gist of the view:
struct CounterWidgetView : View { @Environment(\.widgetFamily) var family: WidgetFamily var entry: Provider.Entry var body: some View { let imgPath = entry.bgImgUrl; let bgColor = Color(red: 0.2, green: 0.2, blue: 0.2); if let uiImage = UIImage(contentsOfFile: imgPath) { return AnyView( ZStack{ // Equivalent to Flutter Stack Image(uiImage: uiImage) .aspectRatio(contentMode: .fill) Text("\(entry.count)") .font(Font.custom(fontName, size: family == .systemSmall ? 48 : 72)); }.widgetBackground(bgColor) ) } return AnyView(Text("Unable to create a UIImage from the imgPath")) } }
Some key takeaways from the above code:
- The view takes in the
Entry
object and (through some compiler magic) can access the relevant fields from ourCounterEntry
likebgImgUrl
andcount
- Note the similarity with Flutter layout code, with constructs like
Text
andImage
as well as the use of conditional statements within the layout code - The layout can adapt to the current form factor of the widget by checking
family == .systemSmall
For an example of some more advanced SwiftUI we encourage you to check out the Wonderous source code. Specifically, the WonderousWidgetView shows an example of a much more complex Swift UI layout, while the GaugeProgressStyle shows how we were able to restyle the built-in iOS ProgressView with minimal effort.
Sharing Data
Sharing data between the Flutter App and the Home Widget is most easily done using the excellent home_widget package:
// Flutter await HomeWidget.saveWidgetData<int>('count', count); // Swift let count = userDefaults?.integer(forKey: 'count') ?? 3
It allows for sharing of primitive values like int
and string
. More complex constructs such as Colors or Images can be serialized to strings and shared that way.
For example, in the vignette, we serialized the current themeColor
of the app into a comma-separated list of numbers and reconstruct them on the other side:
// Flutter final colorString = [color.red / 255, color.green / 255, color.blue / 255].join(','); await HomeWidget.saveWidgetData<String>('color', colorString); // Swift var themeColor:Color?; let colorString = userDefaults?.string(forKey: 'color') let colors = colorString?.components(separatedBy: ",").compactMap {Double($0)} if(colors != nil && colors?.count == 3){ themeColor = Color(red: colors![0], green: colors![1], blue: colors![2]) }
Not the prettiest code in the world… but it works!
You can also serialize images this way. In Wonderous we encoded images to base64 strings and decoded them on the other side:
// Flutter var bytes = await http.readBytes(Uri.parse(imageUrl)); final imageBase64 = base64Encode(bytes); HomeWidget.saveWidgetData(imageBase64, 'image1'); // Swift let imageData = userDefaults?.string(forKey: "image1") ?? "" if(!imageData.isEmpty) return UIImage(data: Data(base64Encoded: imageData)!)
Sharing Assets
In addition to sharing data, it’s possible to share assets like images and fonts. In order to do this you first need to create a boilerplate .swift
function for accessing the Flutter bundle:
func getflutterAssetUrl(_ path: String) -> URL { let url = Bundle.main.bundleURL if url.pathExtension == "appex" { // Peel off two directory levels - MY_APP.app/PlugIns/MY_APP_EXTENSION.appex url = url.deletingLastPathComponent().deletingLastPathComponent() url.append(component: "Frameworks/App.framework/flutter_assets") } return url.appending(path: path) }
With this in place, we can now load images from the Flutter bundle right into SwiftUIs UIImage
:
let bgImgUrl = getflutterAssetUrl("/assets/images/bg.png") return UIImage(contentsOfFile: bgImgUrl.path)
With a little more boilerplate we can do a similar thing with fonts:
let fontAssetUrl = getflutterAssetUrl("/assets/fonts/RubikGlitch-Regular.ttf") let fontName = "Rubik Glitch" struct CounterWidgetView : View { // Implement init() so we can register a font included in Flutter Asset Bundle init(entry: Provider.Entry) { CTFontManagerRegisterFontsForURL( fontAssetUrl as CFURL, CTFontManagerScope.process, nil) } ... return Text("Hello").font(Font.custom(fontName, size: 48)); }
Rendering Widgets
Not only can you embed static images from the Flutter app, you can also render Flutter Widgets and display them in the iOS widget. This is great as it lets you have a beautiful Home Widget without having to write much .swift
code.
In the vignette we rendered a CustomPainter
that drew a representation of the current count. Each time the count is changed we call the HomeWidget.renderFlutterWidget
and load the new image on the iOS side.
final size = Size(600, 600); // Render HomeWidgetBgImage with the current count await HomeWidget.renderFlutterWidget( HomeWidgetBgImage(count: count, size: size), logicalSize: size, key: 'bgRender', ); HomeWidget.updateWidget(iOSName: 'CounterWidget'); // Notifies OS to update this widget
That’s a wrap!
We hope you found this deep-dive helpful and look forward to seeing what the Flutter community will come up with when it comes to HomeWidgets! In the future, if time permits, we would love to add Android support, but in the meantime we are always open for Pull Requests 🙂
If you have any questions or comments please let us know below.
That’s pretty cool, thanks for bringing this.