In certain scenarios, undertaking the extensive task of migrating or rewriting an entire existing application using Flutter may demand a considerable amount of effort, a factor that companies usually consider before deciding to use Flutter for their projects. To support this, Flutter offers an option of integrating Flutter as a module within an existing application, a.k.a add-to-app.
This article provides a comprehensive step-by-step guide to seamlessly integrate Flutter module into your iOS SwiftUI project. Furthermore, you will gain insights into establishing communication between Flutter and your existing app through the use of MethodChannel. Let's start!
1. Create an iOS native app with SwiftUI from XCode
Begin by setting up a native iOS project, which we'll name iOSCounterApp
. In this example, let's modify the main view defined in ContentView.swift
as follows:
import SwiftUI
struct ContentView: View {
@State var counter = 0
var body: some View {
VStack {
Text("Counter: \(counter)").font(.largeTitle)
Button(action: {
increaseCounter()
}) {
Text("Increase counter")
.font(.headline)
.padding()
.background(Color.yellow)
.foregroundColor(.white)
.cornerRadius(10)
}.padding(.vertical, 8)
Button(action: {
//TODO: handle opening Flutter screen here
}) {
Text("Open Flutter View")
.font(.headline)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}.padding(.vertical, 10)
}
}
func increaseCounter() {
self.counter+=1
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The application class, iOSCounterApp.swift
, can remain as is:
import SwiftUI
@main
struct iOSCounterApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Run the application on an iOS device or simulator to see the following result:
2. Setting up Flutter module
2.1. Create a Flutter module
Let's create it in the directory at the same level as your existing iOS project (sibling directories):
➜ iOSCounterApp pwd
/Users/huynq/Desktop/iOSCounterApp
➜ iOSCounterApp flutter create --template module flutter_counter
➜ iOSCounterApp tree -L 1
.
├── flutter_counter
└── iOSCounterApp
3 directories, 0 files
2.2. Exploring Flutter module
Open flutter_counter
in IDE and observe something new. Generally, a Flutter module resembles a standard Flutter project.
- In
pubspec.yaml
, scroll to the end to find a newmodule
section. This section identifies the Flutter module:
module:
androidX: true
androidPackage: com.example.flutter_counter
iosBundleIdentifier: com.example.flutterCounter
- Inside the hidden
.ios
directory, you'll notice a new file namedpodhelper.rb
. This script manages Pod installation (Flutter engine, plugins, application) and.framework
(Flutter.framework, App.framework).
Whenever you add a new plugin to the Flutter module, this script will update it in your existing application.
- The location of the
.xcconfig
files is slightly different from a typical Flutter project. In a standard Flutter project, you'll find the.xcconfig
files in theios/Flutter
directory:
➜ Flutter tree -L 1
.
├── AppFrameworkInfo.plist
├── Debug.xcconfig
├── Flutter.podspec
├── Generated.xcconfig
├── Release.xcconfig
└── flutter_export_environment.sh
However, in the Flutter module, they are located in two separate directories:
➜ .ios tree -L 2
.
├── Config
│ ├── Debug.xcconfig
│ ├── Flutter.xcconfig
│ └── Release.xcconfig
├── Flutter
│ ├── AppFrameworkInfo.plist
│ ├── FlutterPluginRegistrant
│ ├── Generated.xcconfig
│ ├── README.md
│ ├── flutter_export_environment.sh
│ └── podhelper.rb
2.3. Adding dependencies to Flutter module (optional)
You can add Flutter dependencies to module on pubspec.yaml
file and also implement your code in the lib/
directory. However, for now, let's keep it as the default counter app and revisit to module directory later.
3. Embedding the Flutter module to existing application
First, let's create Podfile in your existing project:
➜ iOSCounterApp cd iOSCounterApp/
➜ iOSCounterApp pod init
Result:
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'
target 'iOSCounterApp' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for iOSCounterApp
end
Then, update the generated Podfile as follows:
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'
flutter_application_path = '../flutter_counter'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
target 'iOSCounterApp' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for iOSCounterApp
install_all_flutter_pods(flutter_application_path)
end
post_install do |installer|
flutter_post_install(installer) if defined?(flutter_post_install)
end
Run the following command:
pod install
Note: Whenever you add a new plugin to the Flutter module, you'll need to run pod install
again in the existing project to refresh it.
There is .xcworkspace
file as the pod is installed successfully. Now, let's close the opening .xcodeproj
and open .xcworkspace
in XCode. You can try running the project again to make sure nothing is broken. If it goes well, you will see it is still the same as before.
You'll notice a new .xcworkspace
file, indicating that the Pod was successfully installed. Close the existing .xcodeproj
and open the .xcworkspace
in Xcode.
Try running the project to ensure that everything works as expected. If it runs smoothly, the application should appear unchanged as the begins. Now, let's proceed to the final steps.
4. Starting Flutter from existing application
To initiate a Flutter view from your existing application, we'll utilize FlutterEngine
and FlutterViewController
. The FlutterEngine
manages the Dart VM and Flutter runtime, while the FlutterViewController
connects to the FlutterEngine
and displays rendered frames.
We'll initialize the FlutterEngine
before using it, aka pre-warming
. This approach is recommended. Okay, let's do it!
First, create a FlutterEngine
in iOSCounterApp.swift
:
import SwiftUI
import Flutter
class FlutterDependencies: ObservableObject {
let flutterEngine = FlutterEngine(name: "flutter-engine")
init() {
flutterEngine.run()
// If you added a plugin to Flutter module, you also need to register plugin to flutter engine
GeneratedPluginRegistrant.register(with: self.flutterEngine)
}
}
@main
struct iOSCounterApp: App {
@StateObject var flutterDependencies = FlutterDependencies()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(flutterDependencies)
}
}
}
Next, update ContentView.swift
as follows:
import SwiftUI
import Flutter
struct ContentView: View {
// Flutter dependencies are passed in an EnvironmentObject.
@EnvironmentObject var flutterDependencies: FlutterDependencies
@State var counter = 0
var body: some View {
VStack {
Text("Counter: \(counter)").font(.largeTitle)
Button(action: {
increaseCounter()
}) {
Text("Increase counter")
.font(.headline)
.padding()
.background(Color.yellow)
.foregroundColor(.white)
.cornerRadius(10)
}.padding(.vertical, 8)
Button(action: {
showFlutter()
}) {
Text("Open Flutter View")
.font(.headline)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}.padding(.vertical, 10)
}
}
func increaseCounter() {
self.counter+=1
}
func showFlutter() {
// Get RootViewController from window scene
guard
let windowScene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive && $0 is UIWindowScene }) as? UIWindowScene,
let window = windowScene.windows.first(where: \.isKeyWindow),
let rootViewController = window.rootViewController
else { return }
// Create a FlutterViewController from pre-warm FlutterEngine
let flutterViewController = FlutterViewController(
engine: flutterDependencies.flutterEngine,
nibName: nil,
bundle: nil)
flutterViewController.modalPresentationStyle = .overCurrentContext
flutterViewController.isViewOpaque = false
rootViewController.present(flutterViewController, animated: true)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Now, we need to update Flutter module code:
appBar: AppBar(
title: Text(widget.title),
actions: [
IconButton(
onPressed: () => SystemNavigator.pop(animated: true),
icon: const Icon(Icons.exit_to_app),
),
],
),
We added a button that enables you to exit the Flutter view with SystemNavigator.pop
. SystemNavigator.pop
is used to request Flutter application be popped off navigation stack and return control to the platform-specific host (e.g, Android Activity or iOS ViewController). It's a well-suited API for this purpose.
Now, let's return to the Xcode project with your existing app and run it.
Voila! You can now open a Flutter screen and seamlessly close it.
However, you might have noticed that data is stored separately between the Flutter module and the existing app. If you're wondering how to synchronize or share data between these two portions (Flutter and the existing app), let's delve into the next parts to explore this further.
5. Share data between Flutter and existing application
5.1. Existing app implementation
The solution is to use MethodChannel. You usually see FlutterMethodChannel
initiation in UIViewController's viewDidLoad
(UIKit), but there is no equivalent viewDidLoad
in SwiftUI.
Here's how we address this.
SwiftUI provides onAppear
which closely resembles viewDidLoad
. However, it has limitation, especially when your existing app has multiple views. onAppear
triggers each time we navigate forward and back to the original view where we intend to initialize the method channel. On Android, it behaves similarly to Activity's onResume
callback.
To work around this, we will "cook" onAppear
to "simulate" viewDidLoad
. I'd like to acknowledge Sarun for his tutorial on this issue, available here.
Add the following code to ContentView.swift
:
struct ViewDidLoadModifier: ViewModifier {
@State private var viewDidLoad = false
let action: (() -> Void)?
func body(content: Content) -> some View {
content
.onAppear {
if viewDidLoad == false {
viewDidLoad = true
action?()
}
}
}
}
extension View {
func onViewDidLoad(perform action: (() -> Void)? = nil) -> some View {
self.modifier(ViewDidLoadModifier(action: action))
}
}
Next, apply onViewDidLoad
to outermost view, which in this case is VStack
:
VStack {
// existing code, not paste here as it's long
}.onViewDidLoad {
handleMethodChannel()
}
Now, we've effectively managed the lifecycle issue. Let's complete the remaining steps in handleMethodChannel()
:
func handleMethodChannel() {
flutterMethodChannel = FlutterMethodChannel(name: "flutter_channel/counter", binaryMessenger: flutterDependencies.flutterEngine.binaryMessenger)
flutterMethodChannel?.setMethodCallHandler({
(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch(call.method) {
case "increaseCounter":
counter += 1
submitCounter()
case "getCounter":
submitCounter()
default:
print("Unrecognized method: \(call.method)")
}
})
}
func submitCounter() {
flutterMethodChannel?.invokeMethod("submitCounter", arguments: counter)
}
Additionally, remember to update the changed value (counter) through the method channel as well:
func increaseCounter() {
self.counter+=1
submitCounter()
}
5.1. Flutter module implementation
On Flutter side, let's declare a MethodChannel and handle all method calls:
final _methodChannel = const MethodChannel('flutter_channel/counter');
@override
void initState() {
super.initState();
_methodChannel.setMethodCallHandler((call) => _handleMethodCall(call));
_methodChannel.invokeMethod('getCounter');
}
_handleMethodCall(MethodCall call) {
if (call.method == 'submitCounter') {
setState(() {
_counter = call.arguments as int;
});
}
}
Also, invoke increaseCounter
method when increasing counter by tapping on FAB as well:
void _incrementCounter() {
setState(() {
_counter++;
});
_methodChannel.invokeMethod<void>('increaseCounter');
}
Final result
Voila! Now you can seamlessly communicate between Flutter module and existing app.
Check out the complete sample code at https://github.com/huynguyennovem/flutter-add-to-app-ios-swiftui
Conclusion
This article has walked you through the process of integrating a Flutter module into your iOS application, allowing for seamless communication and data exchange. By incorporating Flutter's capabilities within your native iOS project, your team won't have to worry about rewriting your entire app with Flutter, streamlining development and enhancing the user experience.
Stay in touch with me for updates:
Twitter: https://twitter.com/HuyNguyenTw
LinkedIn: https://linkedin.com/in/huynguyennovem