swiftflutterflutter-add-to-app

Flutter: How do I reset navigation stack on a Flutter engine?


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)
    }
}

Solution

  • 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)
        }
    }