I'm attempting to add a Flutter module to a native iOS app. However, I'm having an issue where Flutter's navigation stack is maintained when presenting a FlutterViewController more than once (i.e. the details screen is shown instead of the landing page).
How do I reset the navigation stack while using a Flutter engine?
Here is the code for my demo.
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
initialRoute: 'example',
routes: {
'example': (context) => const LandingPage(),
},
);
}
}
class LandingPage extends StatelessWidget {
const LandingPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Landing screen')),
body: Center(
child: TextButton(
child: const Text('Go to details'),
onPressed: () => _navigateToDetails(context),
),
),
);
}
void _navigateToDetails(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const DetailsPage()),
);
}
}
class DetailsPage extends StatelessWidget {
const DetailsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Details screen')),
body: const Center(child: Text('Details')),
);
}
}
Here is my native Swift code.
@main
class AppDelegate: FlutterAppDelegate {
lazy var sharedEngine = FlutterEngine(name: "shared")
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
sharedEngine.run();
GeneratedPluginRegistrant.register(with: sharedEngine);
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
class ViewController: UIViewController {
@IBAction private func onButtonTapped(_ sender: UIButton) {
let page = FlutterViewController(
engine: AppDelegate.current.sharedEngine,
nibName: nil,
bundle: nil
)
present(page, animated: true)
}
}
I ended up solving this by dismissing the modal manually.
I added a close button to the AppBar
only if running on iOS, which invokes a method on a MethodChannel
. I created a custom ViewController that will listen for calls to dismiss_modal
. The key is to present this modal by setting modalPresentationStyle
to .overCurrentContext
.
class LandingPage extends StatelessWidget {
static const platform = MethodChannel('dev.example/native_channel');
const LandingPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Landing screen'),
leading: _closeButton,
),
body: Center(
child: TextButton(
child: const Text('Go to details'),
onPressed: () => _navigateToDetails(context),
),
),
);
}
Widget? get _closeButton {
if (defaultTargetPlatform != TargetPlatform.iOS) {
return null;
}
return IconButton(
icon: const Icon(Icons.close),
onPressed: () => _dismissModal(),
);
}
void _dismissModal() async {
try {
await platform.invokeMethod('dismiss_modal');
} on PlatformException {
return;
}
}
void _navigateToDetails(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const DetailsPage()),
);
}
}
class CustomFlutterViewController: FlutterViewController {
private lazy var methodChannel = FlutterMethodChannel(
name: "dev.example/native_channel",
binaryMessenger: binaryMessenger
)
override func viewDidLoad() {
super.viewDidLoad()
methodChannel.setMethodCallHandler { [weak self] methodCall, result in
switch methodCall.method {
case "dismiss_modal":
self?.dismiss(animated: true)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
}
class ViewController: UIViewController {
@IBAction private func onButtonTapped(_ sender: UIButton) {
let viewController = CustomFlutterViewController(
engine: AppDelegate.current.sharedEngine,
nibName: nil,
bundle: nil
)
viewController.modalPresentationStyle = .overCurrentContext
present(viewController, animated: true)
}
}