Rebuilding a Native Mobile App in Flutter From the Inside Out: Part II - Add-to-App
In this article
- Part I: Why?
- Part II: Add-to-App
- Part III: Entry Points
- Part IV: Method Channels
In our first installment of the Rebuilding a Native App in Flutter series, Wes highlighted some of the benefits of rebuilding a native app in Flutter. For most teams, the general benefits of building with a cross-platform framework include faster development times and lower expenses. For our agile team working in a corporate environment, we found that the increased parity between our iOS and Android projects was enabled by Flutter and was the strongest pull-factor for the Flutter framework.
In the next three installments, we will take a more technical look at the steps a team would take to migrate their iOS and Android codebases into a single, multi-platform code base. Included will be a sample project that you can clone and follow along with. By the end of these articles, you should have a better idea of how to begin migrating native iOS and Android projects into a single Flutter project.
Integration
If you have a larger app like us, you might not want to wait until your entire app is rewritten in Flutter before releasing. It makes sense to integrate Flutter pages into your existing application over time, releasing updates along the way. Unfortunately, most Flutter tutorials I've come across assume that the reader will be building their Flutter app completely from scratch. Because of this assumption, there are many add-to-app concepts that developers wanting to integrate Flutter may not have considered. The rest of this article, as well as the following two articles, will demonstrate how to set your iOS and Android projects up to integrate a Flutter project. Provided is an example project you can follow along with. The project has four branches:
- Starter: You'll want to check out this branch if you are starting the example project from the beginning.
- Part-iii: You'll want to check out this branch if you are picking up on Part III of this series.
- Part-iv: You'll want to check out this branch if you are picking up on Part IV of this series.
- Final: The final state of the example project, after having followed all of the steps outlined in the articles.
Requirements
- Flutter
- Cocoapods
- Xcode
- Android Studio
- Familiarity with Swift and/or Kotlin
- Familiarity with Dart and Flutter
Project setup
In order to follow along with the tutorial, you will need to download the starter project. After using git to download or clone the starter project, cd into the project root and run flutter create --template module FlutterIntegration_Flutter
from the terminal. This will create your FlutterIntegration_Flutter
subdirectory and Flutter module project stub. Now, cd into FlutterIntegration_iOS
and run pod install
from the terminal. This will add Flutter and any other dependencies to the iOS workspace. You should now be able to open up Xcode and launch the iOS project through FlutterIntegration_iOS/FlutterIntegrationExample.xcworkspace
. You should also be able to open Android Studio and launch the Android project through FlutterIntegration_Android
. Note that clicking the buttons won't do anything at this point.
Setting up a project that incorporates Flutter into an existing native project is this easy. You simply need to run this flutter create --template module {your flutter project name}
command such that the directory of the created Flutter module is siblings with your existing iOS or Android project's directory. Now, there are some behind-the-scenes steps we took in our starter project to make the setup as easy as possible. For example, the podfile
in our iOS project specifies where the integrated Flutter module is located. This is done through the following line in the podfile
: flutter_application_path = '../FlutterIntegration_Flutter'
. Then, when we run pod install
, the iOS project knows it has to go up a directory and look for FlutterIntegration_Flutter to find its associated Flutter module. On Android, the path to the Flutter module is outlined in the settings.gradle
file:
include ':app'
setBinding(new Binding([gradle: this]))
//Here, we have to specify the path to the include_flutter.groovy file in our Flutter module's directory
evaluate(new File(
settingsDir,
'../FlutterIntegration_Flutter/.android/include_flutter.groovy'
))
rootProject.name = "FlutterIntegrationExample"
//Here, we specify the path to our Flutter module's directory
include ':FlutterIntegration_Flutter'
project(':FlutterIntegration_Flutter').projectDir = new File('../FlutterIntegration_Flutter')
Before we get working on making our buttons actually present a Flutter page, let's update our Flutter project to display a "hello world" message to the user. To do this, update both your FlutterIntegration_Flutter/lib/main.dart
file to look something like this:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Integration Example',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: HelloWorldPage(),
);
}
}
class HelloWorldPage extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Hello World!'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text('This is a fully Flutter page, integrated into your native app!',
textAlign: TextAlign.center,
),
),
),
);
}
}
Incorporating FlutterViewControllers (iOS)
Because our team incorporates multiple Flutter Engines across our native app, the first step we took to integrate our Flutter pages into iOS was creating a singleton class for managing these engines. For our hello world example, this is very simple. We just make a static "shared" instance of our Flutter engine manager and create a "hello world" engine. Open up FlutterEngineManager.swift
and make it look like this:
import Foundation
import Flutter
class FlutterEngineManager {
static var shared: FlutterEngineManager = FlutterEngineManager()
public var helloWorldEngine: FlutterEngine
init() {
helloWorldEngine = FlutterEngine(name: "hello_world")
}
}
Next, we head over to our AppDelegate.swift
file so that we can make sure that our helloWorldEngine
is running from the moment our app finishes launching. This ensures that the engine is "warmed up" and ready to go once the user navigates to the FlutterViewController
. To do this, we simply add FlutterEngineManager.shared.helloWorldEngine.run()
to our application(_:didFinishLaunchingWithOptions:)
method:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
...
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOption: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
FlutterEngineManager.shared.helloWorldEngine.run()
return true
}
...
}
Next, let's go into HelloWorldFlutterViewController.swift
, whose class inherits FlutterViewController
, and add the init
method so we can construct the HelloWorldFlutterViewController
with a Flutter Engine:
import UIKit
import Flutter
class HelloWorldFlutterViewController: FlutterViewController {
init(engine: FlutterEngine) {
super.init(engine: engine, nibName: nil, bundle: nil)
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Finally, we can see our FlutterViewController
in action by simply constructing and presenting a HelloWorldFlutterViewController
when the appropriate button is pressed:
class ViewController: UIViewController {
...
@IBAction func checkOutButtonPressed(_ sender: Any) {
let helloWorldFlutterVC = HelloWorldFlutterViewController(engine: FlutterEngineManager.shared.helloWorldEngine)
present(helloWorldFlutterVC, animated: true, completion: nil)
}
...
}
Run the app and click on the button to see your Flutter code integrated into your iOS project!
Incorporating FlutterActivities/FlutterFragments (Android)
Since both Android and Flutter are developed by Google, integrating individual Flutter pages into an otherwise native Android app is simple. The first step is to create a FlutterEngine
and register it to the FlutterCacheManager
that comes packaged with the Flutter dependency. I chose to do this in the MainActivity.onCreate()
method. Let's open up the Android project and add the following to our MainActivity.kt
file:
package com.wwt.flutterintegrationexample
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val helloWorldEngine = FlutterEngine(applicationContext)
FlutterEngineCache.getInstance().put("hello_world", helloWorldEngine)
setContentView(R.layout.activity_main)
}
}
Next, we'll go ahead and configure our custom FlutterActivity
. The Android project has a file called HelloWorldFlutterActivity.kt
. Let's open that up and pass our newly-cached "hello world" Flutter engine to HelloWorldFlutterActivity
's provideFlutterEngine()
method:
package com.wwt.flutterintegrationexample
import android.content.Context
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
class HelloWorldFlutterActivity : FlutterActivity() {
override fun provideFlutterEngine(context: Context): FlutterEngine? {
return FlutterEngineCache.getInstance().get("hello_world")
}
}
Finally, we simply must create an intent and start our HelloWorldFlutterActivity
. Our main activity's XML already has a button with the "button" identifier. Let's just specify that we want to start the HelloWorldFlutterActivity
when the button is tapped:
package com.wwt.flutterintegrationexample
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val helloWorldEngine = FlutterEngine(applicationContext)
FlutterEngineCache.getInstance().put("hello_world", helloWorldEngine)
setContentView(R.layout.activity_main)
val button: Button = findViewById(R.id.button)
button.setOnClickListener { _ ->
val intent = Intent(this, HelloWorldFlutterActivity::class.java)
startActivity(intent)
}
}
}