paint-brush
Use Flutter UI inside iOS App Extensionsby@nemi
559 reads
559 reads

Use Flutter UI inside iOS App Extensions

by Nemi ShahFebruary 23rd, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Learn how to add iOS app extensions in your app and use Flutter to build its UI instead of Swift or SwiftUI.
featured image - Use Flutter UI inside iOS App Extensions
Nemi Shah HackerNoon profile picture

In this article, we will explore the basics of setting up an extension and how to use Flutter in it.


Flutter has become a very popular choice for mobile developers; its style of UI code and the fact that it renders the same on both platforms makes it a strong choice. One of the limitations Flutter did have was with iOS app extensions.


If you are not aware, iOS app extensions are a way for you to “extend” your functionality outside of your own app and allow users to interact with it through other apps on their devices. This could be the browser, notes app, or pretty much any other app. One problem with Flutter was that you would still need to develop the functionality and the UI of your extension using native code while the majority of your app was in Flutter code. This may not be a problem for some of us, but I think we can all admit that it is annoying to still have to maintain this native code.


Well, Flutter 3.16 changed all this, and you can now have your extension UI built with Flutter!

What we will be building

Our goal in this article will be rather simple:

  • Users will select some text from anywhere on their device and share it
  • Our app should be displayed in the list of choices in the share sheet the OS shows
  • When the user selects our app, they should be shown a simple screen that displays the text they selected

Adding a Share Extension to your Flutter app

Alright, so this part is the same whether you were using Flutter or just plain iOS, so I won’t go into too much detail but just cover the overall steps you need to take:


Open the Runner.xcworkspace file from inside your PROJECT/ios folder. This should launch Xcode. Use File -> New -> Target from Xcode’s menu, and you should be presented with something like this:


Either find or search for Share Extension and press next.


Set the product name to what you want the extension to be called and press “Finish.” After this point, you should see a new target in Xcode.


Xcode will generate some boilerplate for your extension, and at this point, you should be able to test it out by running it in a simulator (or device) and trying to share something.

A few gotchas

  • If you see an error similar to Error (Xcode): Cycle inside Runner when running your app navigate to your main target’s build phases and make sure the Embed Foundation Extensions step is immediately after the Copy Bundle Resources step.
  • If Xcode prompts you to “Activate” your extension after you create it, opt in to activate it. Opting out can result in errors that are unclear.

Using Flutter UI in the iOS app extension

Now that we have our extension boilerplate setup, we need to make it use Flutter for its UI instead of the Storyboard that it currently uses.


First, we need to copy the Flutter framework into our extension. If you navigate to FLUTTER_SDK_LOCATION/bin/cache/artifacts/engine/ios/extension_safe you will find a Flutter.xcframework package. Copy this into the frameworks for your extension in Xcode, and make sure that the Embed option is set to Embed & Sign .


Now modify the info.plist file for your extension to look similar to:


  • NSExtensionPrincipalClass tells the OS which class to use as the root UI controller for this extension. It uses the format ExtensionName.ClassName . Note that by default there should be an entry for the main storyboard file, you will need to remove that entry to make this work.
  • NSExtensionActivationRule informs the OS about when this extension should be included in the share sheet. Setting it to the string TRUEPREDICATE makes it always be included, but you can filter this further to only be included for specific types (for example, images only). To learn more about how to configure this, visit the official documentation.

Configuring the View Controller to use Flutter

Now let’s modify our ShareViewController to not use the boilerplate code and use Flutter instead. Replace all of its contents with this:


import UIKit
import Flutter
import UniformTypeIdentifiers

class ShareViewController: UIViewController {
    override func viewDidLoad() {
         super.viewDidLoad()
         showFlutter()
     }

     func showFlutter() {
         let flutterViewController = FlutterViewController(
            project: nil, 
            initialRoute: "/share", 
            nibName: nil, 
            bundle: nil
          )         
         addChild(flutterViewController)
         view.addSubview(flutterViewController.view)
         flutterViewController.view.frame = view.bounds
     }
}


ShareViewController is now a simple UIViewController that loads a FlutterViewController as its child. The initialRoute property let’s us configure what route the Flutter app should open on launch, without this it would default to whatever the home or main route you have configured for your app.


Now let's modify our Flutter app to account for this and modify the main function to return something similar to this:

return MaterialApp(
  // ...
  routes: {
    '/': (context) => const MyHomePage(title: 'Flutter Demo Home Page'),
    "/share": (_) => const ShareWidget(),
  },
);

I have omitted the boilerplate that Flutter adds during app creation, but we added a route for the /share route that we configured on the Swift side of things. This will make sure that when the extension launches the Flutter engine, the user sees our share widget instead of the app's landing screen.


For now, our share widget is a simple Widget with some text in the center.


import 'package:flutter/material.dart';

class ShareWidget extends StatefulWidget {
  const ShareWidget({super.key});

  @override
  State<ShareWidget> createState() => _ShareWidgetState();
}

class _ShareWidgetState extends State<ShareWidget> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text("Hello!"),
      ),
    );
  }
}


Displaying the text from the share extension in Flutter

Next, we want to take the text the user shared and display it in our ShareWidget. First, we need to read the text in our Swift class.

Reading shared text inside the share extension view controller

if let item: NSExtensionItem = self.extensionContext?.inputItems[0] as? NSExtensionItem, let itemProvider: NSItemProvider = item.attachments?[0] {
     if itemProvider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
         itemProvider.loadItem(forTypeIdentifier: UTType.text.identifier) {result,error in
             if error == nil {
                // TODO: Send data to Flutter
             }
         }
     }
 }


This code gets the text data from the extensionContext and, for now, does nothing. Eventually, we will add logic here to send it to the Flutter side of things.


Note: This piece of code works for this example, but it is not the safest way of writing this. Ideally, you want to iterate all items in self.extensionContext?.inputItems instead of assuming it’s always just one item.

Sending data from share extension view controller to Flutter

If you have worked with passing data between Flutter and Native you know that one of the go-to methods is to use a MethodChannel. Let’s start by setting it up on the Swift side:


func showFlutter() {
     let flutterViewController = FlutterViewController(project: nil, initialRoute: "/share", nibName: nil, bundle: nil)
     
     let methodChannel = FlutterMethodChannel(name: "examples.share", binaryMessenger: flutterViewController.binaryMessenger)
     
     if let item: NSExtensionItem = self.extensionContext?.inputItems[0] as? NSExtensionItem, let itemProvider: NSItemProvider = item.attachments?[0] {
         if itemProvider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
             itemProvider.loadItem(forTypeIdentifier: UTType.text.identifier) {result,error in
                 if error == nil {
                     methodChannel.invokeMethod("setData", arguments: result as! String)
                 }
             }
         }
     }
     
     addChild(flutterViewController)
     view.addSubview(flutterViewController.view)
     flutterViewController.view.frame = view.bounds
 }


We create a FlutterMethodChannel with the name examples.share , this name is important because its value needs to match exactly with the name configured on the channel in Flutter. We then use invokeMethod to call a function setData and send the text to it as an argument.


Next, lets modify our ShareWidget in Flutter:


import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class ShareWidget extends StatefulWidget {
  // ...
}

class _ShareWidgetState extends State<ShareWidget> {
  var methodChannel = const MethodChannel('examples.share');
  var text = "";

  @override
  void initState() {
    methodChannel.setMethodCallHandler((call) async {
      if (call.method == "setData") {
        setState(() {
          text = call.arguments as String;
        });
      }
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text(text),
      ),
    );
  }
}


We introduced a text variable in the state so we can render the text being sent from Swift. We create a method channel with the name examples.share and then we add a method call handler. This handler will get invoked every time native tries to call a method on the MethodChannel , but in this example, we only need to worry about the setData function. To keep this example simple, we just take the arguments (we know there is only one in this case) and set the value of text to it.


And that’s it! You now have a share extension set on iOS that uses Flutter for its UI instead of Swift. If you like this article, be sure to check out my other work. Also, it always helps if you share this with other people.


You can also follow me on Twitter or check out my website.