Flutter: iOS Home Widgets Deep Dive

Last year the Flutter Team release an excellent codelab that explained to 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 added using the built-in UI Wizards in XCode or Android Studio, and development can also be done in the respective IDEs, complete with robust code-hinting, debug and hot(ish) reload support!

Inspired by this 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 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 demonstrate a more complex example by adding Home Widget support 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.

In the remainder of this post we’re going to do a deep dive into how Home Widgets work 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 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 the TimelineEntry.
  • TimelineEntry – A data class that your View will eventually consume. It must contain a date 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 an updated 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 and getTimeline
  • placeholder returns an initial/empty Entry
  • getSnapshot uses the UserDefaults API on iOS to load any shared data and returns an Entry
  • 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 our CounterEntry like bgImgUrl and count
  • Note the similarity with Flutter layout code, with constructs like Text and Image as well as the use of conditional statements within the layout code
  • The layout adapts to the current size of the widget by checking family == .systemSmall, this is how you can create widgets for different form factors

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") ?? ""
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.

shawn.blais

Shawn has worked as programmer and product designer for over 20 years, shipping several games on Steam and Playstation and dozens of apps on mobile. He has extensive programming experience with Dart, C#, ActionScript, SQL, PHP and Javascript and a deep proficiency with motion graphics and UX design. Shawn is currently the Technical Director for gskinner.

@tree_fortress

Leave a Reply

Your email address will not be published. Required fields are marked *