I am trying to create a website with Flutter using Navigation 2.0 and BLoC pattern. To do so, I read the following guides:
https://medium.com/#JalalOkbi/flutter-navigator-2-0-with-bloc-the-ultimate-guide-6672b115adf
https://lucasdelsol01.medium.com/flutter-navigator-2-0-for-mobile-dev-bloc-state-management-integration-3a180b4d25b3
and this repo: https://lucasdelsol01.medium.com/flutter-navigator-2-0-for-mobile-dev-bloc-state-management-integration-3a180b4d25b3 (which implements the first guide).
However I am facing an issue where I am trying to push a new page from one of my website displayed page: the new page is never displayed!
To understand:
Each pages are pushed via a MainNavigationCubit. This cubit's state (meaning pages) is maintained within the NavigationStack.
My MainNavigationCubit is responsible for building the Navigator in my custom RouterDelegate (see code below). So upon a state change it rebuilds the Navigator with the proper list of pages.
The problem context:
I have a "Book" page which displays the details about a specific book.
In order to get the details, it expects a book id.
If the book id is invalid or not found, then the "404 not found page" is pushed via MainNavigationCubit.
This can happen, eg, if the user is manually inputting a correct URL to the book page but with an invalid ID.
However the "404 not found page" is never displayed although the MainNavigationCubit properly emits a new NavigationStack with relevant pages.
This is the code from my custom RouterDelegate:
#override
GlobalKey<NavigatorState> get navigatorKey => GlobalKey<NavigatorState>(debugLabel: 'main_navigation_key');
#override
Future<void> setNewRoutePath(PageConfig configuration) {
if (configuration.route != homeRoute) {
mainNavigationCubit.push(configuration.route, configuration.args);
} else {
mainNavigationCubit.clearToHome();
}
return SynchronousFuture(null);
}
#override
Widget build(BuildContext context) {
return BlocBuilder<MainNavigationCubit, NavigationStack>(
builder: (context, stack) {
return Navigator(
pages: stack.pages,
key: navigatorKey,
onPopPage: (route, result) => _onPopPage.call(route, result),
);
},
);
#override
PageConfig get currentConfiguration => mainNavigationCubit.state.last;
bool _onPopPage(Route<dynamic> route, dynamic result) {
final didPop = route.didPop(result);
if (!didPop) {
return false;
}
if (mainNavigationCubit.canPop()) {
mainNavigationCubit.pop();
return true;
} else {
return false;
}
}
And this is the code from my "Book" StatelessWidget page:
#override
Widget build(BuildContext context) {
if (bookId == -1) {
context.read<MainNavigationCubit>().showNotFound(); // let's assume this will be properly handled when I'll be creating this page's BLoC.
}
return // full book details UI;
}
And just in case the code of MainNavigationCubit.showNotFound():
void showNotFound() {
clearAndPush(notFound);
}
void clearAndPush(String path, [Map<String, dynamic>? args]) {
final PageConfig pageConfig = PageConfig(location: path, args: args);
emit(state.clearAndPush(pageConfig));
}
OK, so after a lot of investigation I have found the reason for my issue.
As the documentation says: a Cubit won't notify listeners upon emitting a new state that is equal to the current state.
In my case, my MainNavigationCubit's state is a NavigationStack which I took from this guide: https://medium.com/#JalalOkbi/flutter-navigator-2-0-with-bloc-the-ultimate-guide-6672b115adf
Looking at the code, the NavigationStack exposes methods that mutates an internal list of pages.
The problem is this list belongs to the current state, therefore modifying it means to also modify the current state.
As both current and new state rely on the same exact list, the Cubit won't emit the new state.
Related
I am working on a Flutter Web App using Getx for navigation and state management. One of the routes in my flutter app has two query parameters. Let us call these parameters Dataset and Index. When the Dataset parameter is changed through the URL, I want to make an API call to retrieve the new dataset, and when the Index parameter is changed, I want to display the data from the dataset at that particular index on the app. Index in this case is an observable RxInt variable defined in the controller.
However, the default behavior when I change the URL and press enter is for the Flutter app to push a new page on to the navigation stack. The behavior I prefer is to simply update the values and make a new API call if necessary. The API call may be done by simply refreshing the page since it is handled by the Getx controller onInit function.
I'm not very familiar with how routing in flutter works and I haven't found a solution to change the behavior for routing itself. I've tried a few ways to update the values despite pushing the new page on to the stack, such as setting the value for index through the initState or build calls on my widgets but those changes aren't visible on my UI. I've also tried reinitializing the controller by deleting it but that didn't work either.
EDIT: I have added a code example:
Widget:
class MainscreenView extends StatefulWidget {
const MainscreenView({Key? key}) : super(key: key);
#override
State<MainscreenView> createState() => _MainscreenViewState();
}
class _MainscreenViewState extends State<MainscreenView> {
late MainscreenController mainscreenController;
#override
Widget build(BuildContext context) {
return GetX<MainscreenController>(
init: MainscreenController(),
initState: (_) {
mainscreenController = Get.find<MainscreenController>();
},
builder: (_) {
return Scaffold(
body: Center(
child: Text(
'Current index is ${mainscreenController.index.value}',
style: const TextStyle(fontSize: 20),
),
),
);
});
}
}
Controller:
class MainscreenController extends GetxController {
final index = 0.obs;
late String? dataset;
#override
void onInit() {
super.onInit();
final String? datasetQuery = Get.parameters['dataset'];
if (datasetQuery != null) {
dataset = datasetQuery; //API call goes here
} else {
throw Exception('Dataset is null');
}
final String? indexString = Get.parameters['index'];
if (indexString == null) {
throw Exception('Index is null');
} else {
final int? indexParsed = int.tryParse(indexString);
if (indexParsed == null) {
throw Exception('Index Cannot be parsed');
} else {
index.value = indexParsed;
}
}
}
}
The initial route is /mainscreen?dataset=datasetA&index=0. If I were to modify the route in the address bar to /mainscreen?dataset=datasetA&index=5 for example and press enter, The current behavior of Flutter is to push a new page onto the navigation stack. I would like to update the value of index instead and display it on the same page, but I haven't found a way to accomplish this. Also, if dataset parameter is updated I would like to again avoid pushing a new page onto the stack and refresh the current page instead so that the onInit function is run again and the API call is made automatically.
I'd like to implement Survey Platform
A survey has many questions and each of them has a part (ex. Part1, Part2, and so on...)
Each part is displayed in one page. (One page equals to one page)
After user finished the survey, they could check the result in one page. That page has a SingleChildScrollView and the SingleChildScrollView contains all of the question's answers. (This is a client's request, so it could not be revised)
Also, I have selected GetX library to administrate State and used RxDart to manage async.
At the onInit method in a answers page's controller, I call the result api and save it to RxList variable.
In answers page's view, if the RxList variable's value is null, it builds CircularProgressIndicator. And in a case of not null, it builds SingleChildScrollView.
I'd like to make the answers page right away pop up when the Get.toNamed method is called and the CircularProgressIndicator has been displayed until the RxList variable is initialized.
But, when I called the Get.toNamed method, the screen had been stoped in the page that called Get.toNamed method. And a few seconds later, the answers page finally pop up.
What should I do to solve this problem?? My code is like belows.
// Survey Answers View
class SurveyResultDetailView extends BaseView<SurveyResultDetailController> {
#override
PreferredSizeWidget? appBar(BuildContext context) {
#override
Widget body(BuildContext context) {
return Obx(
() {
if (controller.surveyDiagnosisUiModel == null) {
return CircularProgressIndicator();
}
return SingleChildScrollView(
child: Column(
children: [
So long childrens~~~
],
),
);
}
);
}
}
// Survey Answers Controller
class SurveyResultDetailController extends BaseController {
final PreferenceManager _preferenceManager =
Get.find(tag: (PreferenceManager).toString());
late final String surveyConfigId;
final Rxn<SurveyDiagnosisResultUiModel> _rxModel =
Rxn<SurveyDiagnosisResultUiModel>(null);
SurveyDiagnosisResultUiModel? get surveyDiagnosisUiModel => _rxModel.value;
void setSurveyDiagnosisUiModel(SurveyDiagnosisResultUiModel? value) {
_rxModel.value = value;
}
final RxList<SurveyQuestionListUiModel> _surveyQuestionListUiModel =
RxList<SurveyQuestionListUiModel>();
List<SurveyQuestionListUiModel> get surveyQuestionListUiModel =>
_surveyQuestionListUiModel.toList();
void getDiagnosisResultUiModel() {
Future<String> diagnosisResultFuture =
_preferenceManager.getString('recent_detail_result');
callDataService(
diagnosisResultFuture,
onSuccess: _handleDiagnosisResultResponseSuccess,
);
}
void _handleDiagnosisResultResponseSuccess(String response) {
~~~~~ response entity mapping to ui model code
_surveyQuestionListUiModel.refresh();
}
List<SurveyQuestionListUiModel> parseQuestionListByPartNumber(int number) {
return surveyQuestionListUiModel
.where((element) => element.partNumber == number)
.toList();
}
/// ------------> 생애설계 행동 진단 결과 관련 값
#override
void onInit() {
surveyConfigId = Get.arguments as String;
getDiagnosisResultUiModel();
super.onInit();
}
}
I have an app with a BottomNavigationBar and an IndexedStack which shows the tab content. Each tab has its own Router with its own RouterDelegate to mimic iOS-style tab behavior (where each tab has its own navigation controller).
Before, this app was only published on iOS. I'm now working on the Android version and need to correctly support the Android hardware back button. I did this by implementing a ChildBackButtonDispatchers per tab, which are a child of the parent RootBackButtonDispatcher. This works.
The issue I'm having now is that I use WillPopScope widgets to save a user's input when they leave a screen. This works correctly if the user taps the back button in the AppBar, but the callback isn't triggered when the user taps the hardware back button. I implemented BackButtonListeners on these screens as well, but this means I have to wrap the screens in both WillPopScopes and BackButtonListeners, both calling the same callback.
It this how it's supposed to be, or am I doing something wrong?
Relevant widget hierarchy:
MaterialApp
Navigator
tab interface with IndexedStack
the selected tab Widget the tab's Router
Navigator
multiple pages, with on the last page in the stack...
BackButtonListener
WillPopScope
Scaffold
My (simplified) router delegate looks like this:
class AppRouterDelegate extends RouterDelegate<AppRoute>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<AppRoute> {
AppRouterDelegate({
List<MaterialPage> initialPages = const [],
}) : _pages = initialPages;
final navigatorKey = GlobalKey<NavigatorState>();
final List<MaterialPage> _pages;
List<MaterialPage> get pages => List.unmodifiable(_pages);
void push(AppRoute route) {
final shouldAddPage = _pages.isEmpty || (_pages.last.arguments as AppRoute != route);
if (!shouldAddPage) {
return;
}
_pages.add(route.page);
notifyListeners();
}
#override
Future<void> setNewRoutePath(AppRoute route) async {
_pages.clear();
_pages.add(route.page);
notifyListeners();
return SynchronousFuture(null);
}
#override
Future<bool> popRoute() {
if (canPop) {
pop();
return SynchronousFuture(true);
}
return SynchronousFuture(false);
}
bool get canPop => _pages.length > 1;
void pop() {
if (canPop) {
_pages.remove(_pages.last);
notifyListeners();
}
}
void popTillRoot() {
while (canPop) {
_pages.remove(_pages.last);
}
notifyListeners();
}
bool _onPopPage(Route<dynamic> route, result) {
final didPop = route.didPop(result);
if (!didPop) {
return false;
}
if (canPop) {
pop();
return true;
} else {
return false;
}
}
#override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onPopPage: _onPopPage,
pages: pages,
);
}
}
I found this Flutter issue which makes me think I shouldn't have the WillPopScope at all, but without it the taps in the AppBar are not caught...
I know this question is old, but here's an answer for others who arrive here.
From the AppBar leading documentation (emphasis mine):
If this is null and automaticallyImplyLeading is set to true, the AppBar will imply an appropriate widget. For example, if the AppBar is in a Scaffold that also has a Drawer, the Scaffold will fill this widget with an IconButton that opens the drawer (using Icons.menu). If there's no Drawer and the parent Navigator can go back, the AppBar will use a BackButton that calls Navigator.maybePop.
So in order to make the Android back button work the same way as the App Bar's back button, you need to use the Navigator.maybePop method, which will respect WillPopScope.
Conveniently, Flutter provides PopNavigatorRouterDelegateMixin to make this easy; it provides an implementation of popRoute that uses maybePop and therefore will work identically to the App Bar's automatically-generated back/dismiss button. The nice thing about Flutter being open source is that you can jump into the Flutter code to verify what the mixin is doing:
mixin PopNavigatorRouterDelegateMixin<T> on RouterDelegate<T> {
/// The key used for retrieving the current navigator.
///
/// When using this mixin, be sure to use this key to create the navigator.
GlobalKey<NavigatorState>? get navigatorKey;
#override
Future<bool> popRoute() {
final NavigatorState? navigator = navigatorKey?.currentState;
if (navigator == null)
return SynchronousFuture<bool>(false);
return navigator.maybePop();
}
}
So I think the only mistake in your code is that, even though you've mixed-in PopNavigatorRouterDelegateMixin on your router delegate, you are also providing your own override of popRoute. When the user taps the Android back button, your popRoute implementation is called, and it just pops the last page. If you delete your popRoute override and let the mixin do its thing, then the Android back button will function identically to the App Bar back/dismiss button.
I´m using Navigation 2.0 for flutter navigation development, however, there´s only one topic for me to understand it properly. I´m using a custom RootBackButtonDispatcher:
class AppBackButtonDispatcher extends RootBackButtonDispatcher {
final AppRouteDelegate routerDelegate;
AppBackButtonDispatcher({
#required this.routerDelegate,
}) : super();
#override
Future<bool> didPopRoute() {
return routerDelegate.popRoute();
}
}
When pressing the back button, the function that gets called is (a function that is inside my AppRouteDelegate):
#override
Future<bool> popRoute() {
if (_canPop()) {
_removePage(viewModel.pages.last);
viewModel.rebuild;
return SynchronousFuture(true);
}
return SynchronousFuture(false); // This will exit the application
}
The previous code handles properly the back button functionality, but the Navigator´s onPopPage never gets called:
#override
Widget build(BuildContext context) => Navigator(
key: navigatorKey,
onPopPage: _onPopPage, => WHY DON´T YOU GET CALLED!
pages: _buildPages(),
);
Am I handling this code in a wrong way, or by adding the custon back button dispatcher, the forementioned function never gets called?
So I have a similar issue as the person who asked this older question, except with different requirements that none of the answers there help with.
When a user opens the app, I want them to be greeted with the login page if they haven't logged in or the home page (a bottom nav bar view) if they did. I can define this in the MaterialApp as follows:
MaterialApp(
initialRoute: authProvider.isAuthenticated
? '/home'
: '/login',
routes: {
'/home': (_) =>
ChangeNotifierProvider<BottomNavigationBarProvider>(
child: AppBottomNavigationBar(),
create: (_) => BottomNavigationBarProvider()),
'/login': (_) => LoginView()
},
)
So far so good. Except I want this to work on the web, and now even though the default screen when a user first opens myapp.com is myapp.com/#/login, any user can bypass the login screen by simply accessing myapp.com/#/home.
Now I tried to redirect the user to the login page in the initState() of the bottom navigation bar (and setting the initialRoute to be /home), but on mobile this has undesirable behaviour.
If I try this:
void initState() {
super.initState();
if (!Provider.of<AuthProvider>(context, listen: false).isAuthenticated) {
SchedulerBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushNamed('/login');
});
}
}
then simply pressing back will return the user to the home page, again bypassing the login. If I try to use popAndPushNamed instead of just pushing, pressing back will open a blank screen (instead of closing the app).
Is there any way to do this correctly so it works on both web and mobile?
If you use the RouteAware mixin on your widget classes, they will be notified when they are navigated to (or away from). You can use this to check if the user is supposed to be there and to navigate them away if they are not:
To use it, first you need some global instance of RouteObserver that all your widget classes can access:
final routeObserver = RouteObserver<PageRoute>();
Then you need to register it with your MaterialApp:
MaterialApp(
routeObservers: [routeObserver],
)
Then register your widget to the route observer:
class HomeViewState extends State<HomeView> with RouteAware {
#override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context));
}
#override
void dispose() {
routeObserver.unsubscribe(this);
super.dispose();
}
void didPop() {
// This gets called when this widget gets popped
}
void didPopNext() {
// This gets called when another route gets popped making this widget visible
}
void didPush() {
// This gets called when this widget gets pushed
}
void didPushNext() {
// This gets called with another widget gets pushed making this widget hidden
}
...
}
In your case, you can use the didPush route to navigate the user to the login page if they get to that page in error:
void didPush() {
if (checkLoginStateSomehow() == notLoggedIn) {
Navigator.of(context).pushReplacementNamed('login');
}
}