Skip to content

Conversation

@RootHex200
Copy link
Contributor

@RootHex200 RootHex200 commented Sep 13, 2025

Introduces a new route class, CupertinoPageRouteWithDynamicTitle, which extends CupertinoPageRoute to allow dynamic updates of the route title using a ValueListenable<String?>. This change enables neighboring routes to be notified of title changes, enhancing navigation bar functionality in iOS-designed apps.

Additionally, updates to the Route class's changedInternalState method allow for notifying adjacent routes about state changes. Comprehensive tests have been added to ensure the correct behavior of dynamic title updates and neighbor notifications.

Fix: #175129

Demo Code
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'dart:async';

import 'package:flutter/material.dart';

void main() {
  runApp(CupertinoTitleBugApp());
}

class CupertinoTitleBugApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoApp(
      title: 'Cupertino Title Bug Reproduction',
      theme: CupertinoThemeData(
        primaryColor: CupertinoColors.systemBlue,
      ),
      home: HomePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

final valueNotifier = ValueNotifier<String>("Loading...");

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text('Home'),
        trailing: CupertinoButton(
          padding: EdgeInsets.zero,
          child: Text('Next'),
          onPressed: () {
            Navigator.of(context).push(
              CupertinoPageRouteWithDynamicTitle(
                titleListenable: valueNotifier,
                builder: (context) => DynamicTitlePage(),
              ),
            );
          },
        ),
      ),
      child: SafeArea(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(
                CupertinoIcons.home,
                size: 64,
                color: CupertinoColors.systemGrey,
              ),
              SizedBox(height: 16),
              Text(
                'ValueNotifier Demo',
                style: CupertinoTheme.of(context).textTheme.navTitleTextStyle,
                textAlign: TextAlign.center,
              ),
              SizedBox(height: 8),
              Text(
                'Tap "Next" to see the demo',
                style: CupertinoTheme.of(context).textTheme.textStyle,
                textAlign: TextAlign.center,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class DynamicTitlePage extends StatefulWidget {
  @override
  _DynamicTitlePageState createState() => _DynamicTitlePageState();
}

class _DynamicTitlePageState extends State<DynamicTitlePage> {
  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: ValueListenableBuilder(valueListenable: valueNotifier, builder: (context,value,child)=> Text(value)),
        trailing: CupertinoButton(
          padding: EdgeInsets.zero,
          child: Text('Next'),
          onPressed: () {
            Navigator.of(context).push(
              CupertinoPageRoute(
                  title: "Another Page", builder: (context) => AnotherPage()),
            );
          },
        ),
      ),
      child: SafeArea(
        child: Padding(
          padding: EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // Simple demo section
              Container(
                padding: EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: CupertinoColors.systemBlue.withOpacity(0.1),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Column(
                  children: [
                    Text(
                      '📱 Demo: Dynamic Title',
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                        fontSize: 18,
                      ),
                    ),
                    SizedBox(height: 8),
                    ValueListenableBuilder(
                      valueListenable: valueNotifier,
                      builder: (context,value,child)=> Text(
                        'Current title: "${valueNotifier.value}"',
                        style: TextStyle(fontSize: 16),
                      ),
                    ),
                    SizedBox(height: 8),
                    Text(
                      'Tap "Next" to see the issue!',
                      style: TextStyle(color: CupertinoColors.systemGrey),
                    ),
                  ],
                ),
              ),

              SizedBox(height: 24),

              ElevatedButton(
                  onPressed: () {
                    valueNotifier.value = "Hello World";
                  },
                  child: Text("Update Page Title"))
            ],
          ),
        ),
      ),
    );
  }
}

class AnotherPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      navigationBar: CupertinoNavigationBar(
        middle: Text("Another Page"),
      ),
      child: SafeArea(
        child: Padding(
          padding: EdgeInsets.all(16),
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(
                  CupertinoIcons.info_circle,
                  size: 64,
                  color: CupertinoColors.systemBlue,
                ),
                SizedBox(height: 24),
                Text(
                  'Check the Navigation Bar!',
                  style: TextStyle(
                    fontSize: 24,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                SizedBox(height: 16),
                Text(
                  'The back button should show "Hello World" but it shows "Loading..." instead.',
                  style: TextStyle(fontSize: 16),
                  textAlign: TextAlign.center,
                ),
                SizedBox(height: 24),
                CupertinoButton.filled(
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                  child: Text('Go Back'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Pre-launch Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

@google-cla
Copy link

google-cla bot commented Sep 13, 2025

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions github-actions bot added framework flutter/packages/flutter repository. See also f: labels. f: cupertino flutter/packages/flutter/cupertino repository f: routes Navigator, Router, and related APIs. labels Sep 13, 2025
@RootHex200 RootHex200 closed this Sep 13, 2025
@RootHex200 RootHex200 reopened this Sep 13, 2025
@fluttergithubbot
Copy link
Contributor

An existing Git SHA, 8fc001b3c17b7351eec96c2592910ce4eb9d1a4b, was detected, and no actions were taken.

To re-trigger presubmits after closing or re-opeing a PR, or pushing a HEAD commit (i.e. with --force) that already was pushed before, push a blank commit (git commit --allow-empty -m "Trigger Build") or rebase to continue.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces CupertinoPageRouteWithDynamicTitle, a new route class that supports dynamic title updates via a ValueListenable. This is a valuable addition for creating more dynamic and responsive iOS-style navigation. The changes also include modifications to Route.changedInternalState to allow notifying adjacent routes of state changes, which is essential for the dynamic title feature to work correctly. The implementation is well-documented and includes a comprehensive set of tests. My review includes suggestions for improving code clarity in the new route class and enhancing the test suite to ensure full coverage of the new functionality.

Comment on lines 378 to 432
}) {
assert(opaque);
_titleListener = () {
// Notify neighboring routes when title changes
changedInternalState(notifyNeighbors: true);
};
titleListenable.addListener(_titleListener!);
}

@override
DelegatedTransitionBuilder? get delegatedTransition =>
CupertinoPageTransition.delegatedTransition;

/// Builds the primary contents of the route.
final WidgetBuilder builder;

@override
Widget buildContent(BuildContext context) => builder(context);

/// The dynamic title for this route.
///
/// When the value of this [ValueListenable] changes, neighboring routes
/// will be notified to update their references to this route's title.
final ValueListenable<String?> titleListenable;

@override
String? get title => titleListenable.value;

@override
final bool maintainState;

@override
String get debugLabel => '${super.debugLabel}(${settings.name})';

VoidCallback? _titleListener;

@override
void dispose() {
if (_titleListener != null) {
titleListenable.removeListener(_titleListener!);
}
super.dispose();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The listener management for titleListenable can be simplified for better readability and robustness. Instead of using a nullable VoidCallback? _titleListener field, you can define a private method to handle title changes. This method can then be directly passed to addListener and removeListener, avoiding the need for a nullable field, null checks, and force unwraps.

  }) {
    assert(opaque);
    titleListenable.addListener(_handleTitleChanged);
  }

  @override
  DelegatedTransitionBuilder? get delegatedTransition =>
      CupertinoPageTransition.delegatedTransition;

  /// Builds the primary contents of the route.
  final WidgetBuilder builder;

  @override
  Widget buildContent(BuildContext context) => builder(context);

  /// The dynamic title for this route.
  ///
  /// When the value of this [ValueListenable] changes, neighboring routes
  /// will be notified to update their references to this route's title.
  final ValueListenable<String?> titleListenable;

  @override
  String? get title => titleListenable.value;

  @override
  final bool maintainState;

  @override
  String get debugLabel => '${super.debugLabel}(${settings.name})';

  void _handleTitleChanged() {
    // Notify neighboring routes when title changes
    changedInternalState(notifyNeighbors: true);
  }

  @override
  void dispose() {
    titleListenable.removeListener(_handleTitleChanged);
    super.dispose();
  }

Comment on lines 142 to 153
// Verify initial state
// Check that the navigation bar is present
expect(find.byType(CupertinoNavigationBar), findsOneWidget);
// Check that the second page content is displayed
expect(find.text('Second Page'), findsOneWidget);

// Update first route title
firstTitleNotifier.value = 'Updated First Page';
await tester.pump();

// The second route should now show the updated first page title in its back button
// Verify that the navigation bar is still present after title update
expect(find.byType(CupertinoNavigationBar), findsOneWidget);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test correctly sets up the scenario for dynamic title updates but doesn't verify that the back button's title on the second page actually updates. To make the test more robust, you should add assertions to check for the updated title text in the back button.

Suggested change
// Verify initial state
// Check that the navigation bar is present
expect(find.byType(CupertinoNavigationBar), findsOneWidget);
// Check that the second page content is displayed
expect(find.text('Second Page'), findsOneWidget);
// Update first route title
firstTitleNotifier.value = 'Updated First Page';
await tester.pump();
// The second route should now show the updated first page title in its back button
// Verify that the navigation bar is still present after title update
expect(find.byType(CupertinoNavigationBar), findsOneWidget);
// Verify initial state
final Finder backButton = find.byType(CupertinoNavigationBarBackButton);
expect(backButton, findsOneWidget);
expect(find.descendant(of: backButton, matching: find.text('First Page')), findsOneWidget);
expect(find.text('Second Page'), findsOneWidget);
// Update first route title
firstTitleNotifier.value = 'Updated First Page';
await tester.pump();
// The second route should now show the updated first page title in its back button.
expect(find.descendant(of: backButton, matching: find.text('Updated First Page')), findsOneWidget);
expect(find.descendant(of: backButton, matching: find.text('First Page')), findsNothing);

Comment on lines 204 to 242
testWidgets('works with CupertinoNavigationBar', (WidgetTester tester) async {
final ValueNotifier<String?> titleNotifier = ValueNotifier<String?>('Initial Title');

await tester.pumpWidget(
CupertinoApp(
home: Builder(
builder: (BuildContext context) => CupertinoButton(
onPressed: () {
Navigator.push(
context,
CupertinoPageRouteWithDynamicTitle<void>(
builder: (BuildContext context) => const CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text('Navigation Bar'),
),
child: Text('Page Content'),
),
titleListenable: titleNotifier,
),
);
},
child: const Text('Push Route'),
),
),
),
);

await tester.tap(find.text('Push Route'));
await tester.pumpAndSettle();

// Check that the navigation bar is present
expect(find.byType(CupertinoNavigationBar), findsOneWidget);

// Update the title
titleNotifier.value = 'Updated Title';
await tester.pump();

// Check that the navigation bar is still present after title update
expect(find.byType(CupertinoNavigationBar), findsOneWidget);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test case, 'works with CupertinoNavigationBar', appears to be redundant as it has nearly identical logic to the 'updates title dynamically' test case. To improve test suite maintainability and reduce duplication, consider removing this test.

Comment on lines 247 to 246
testWidgets('notifies neighboring routes when called with notifyNeighbors=true', (WidgetTester tester) async {
final ValueNotifier<String?> titleNotifier = ValueNotifier<String?>('Initial Title');
bool didChangePreviousCalled = false;
bool didChangeNextCalled = false;

// Create a custom route that tracks when didChangePrevious/didChangeNext are called
final _TestRoute customRoute = _TestRoute(
titleListenable: titleNotifier,
onDidChangePrevious: () => didChangePreviousCalled = true,
onDidChangeNext: () => didChangeNextCalled = true,
);

await tester.pumpWidget(
CupertinoApp(
home: Builder(
builder: (BuildContext context) => CupertinoButton(
onPressed: () {
Navigator.push(context, customRoute);
},
child: const Text('Push Route'),
),
),
),
);

await tester.tap(find.text('Push Route'));
await tester.pumpAndSettle();

// Reset flags
didChangePreviousCalled = false;
didChangeNextCalled = false;

// Call changedInternalState with notifyNeighbors=true
customRoute.changedInternalState(notifyNeighbors: true);
await tester.pump();

// Since this is the only route, no neighbors should be notified
expect(didChangePreviousCalled, isFalse);
expect(didChangeNextCalled, isFalse);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test 'notifies neighboring routes when called with notifyNeighbors=true' only verifies the scenario where the route has no neighbors. To ensure the feature is fully tested, consider adding another test case where the route has both a previous and a next route, and assert that didChangePrevious and didChangeNext are called on the respective neighbors.

@RootHex200 RootHex200 closed this Sep 13, 2025
@RootHex200 RootHex200 reopened this Sep 13, 2025
@fluttergithubbot
Copy link
Contributor

An existing Git SHA, 8fc001b3c17b7351eec96c2592910ce4eb9d1a4b, was detected, and no actions were taken.

To re-trigger presubmits after closing or re-opeing a PR, or pushing a HEAD commit (i.e. with --force) that already was pushed before, push a blank commit (git commit --allow-empty -m "Trigger Build") or rebase to continue.

@RootHex200 RootHex200 force-pushed the fix175129 branch 4 times, most recently from 3cd8950 to f750c90 Compare September 14, 2025 13:51
This commit introduces the `CupertinoPageRouteWithDynamicTitle` class, which extends `CupertinoPageRoute` to allow dynamic updates to the route title using a `ValueListenable<String?>`. When the title changes, neighboring routes are notified to update their references, enhancing navigation bar title management in iOS-designed apps.

Additionally, the `changedInternalState` method in the `Route` class is updated to support notifying neighboring routes about state changes. Tests are added to ensure the correct behavior of dynamic title updates and neighbor notifications.

- Added `CupertinoPageRouteWithDynamicTitle` for dynamic title management.
- Updated `changedInternalState` to notify neighboring routes.
- Comprehensive tests for dynamic title updates and neighbor notifications included.
Copy link
Contributor

@MitchellGoodwin MitchellGoodwin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that this is something we want, or at least that this is the approach we want. This is a pretty engineered change for a specific case. The title of a route is generally not something that is considered to change while open very often, similar to how the url of the web page you're on isn't expected to change without you redirecting. Having the previousPageTitle change depending on state from the previous page can make some amount of sense, but I don't think we want that to be done this way. It might be better done through state management set up by the app developer. I don't know if we want to open that can of worms. There's a lot of edge cases that I'm not sure we want to take on, like the page title updating mid page transition, it updating while off screen, etc.

@protected
@mustCallSuper
void changedInternalState() {}
void changedInternalState({bool notifyNeighbors = false}) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty big change to the core navigator behavior that I'm not sure we want to change for this specific case.

@RootHex200
Copy link
Contributor Author

I'm not sure that this is something we want, or at least that this is the approach we want. This is a pretty engineered change for a specific case. The title of a route is generally not something that is considered to change while open very often, similar to how the url of the web page you're on isn't expected to change without you redirecting. Having the previousPageTitle change depending on state from the previous page can make some amount of sense, but I don't think we want that to be done this way. It might be better done through state management set up by the app developer. I don't know if we want to open that can of worms. There's a lot of edge cases that I'm not sure we want to take on, like the page title updating mid page transition, it updating while off screen, etc.

thanks, it make sense . i am leaving from it

@Zekfad
Copy link

Zekfad commented Oct 22, 2025

It might be better done through state management set up by the app developer.

@MitchellGoodwin That's the problem. I've tried to do it in go_router and came to a conclusion that you simply cant. Not without a proper support from the framework. Once page object goes to router it's no longer changes and you cant use state management because pages/routes are not widgets.

I ask you to reconsider, since this is not possible to to from userland currently.

See #175129 for a use case. (Update title when data arrives). Another use case is localization change, so your back button will update for a new language while you're still in a settings page.

As a side note

similar to how the url of the web page you're on isn't expected to change without you redirecting

That is not universally true for search forms. It is expected that url changes while you type, so hard reload wont reset your query, same with tags selection and chips that have query parameter equivalent. You're staying on same page, changing your filters and URL updates accordingly without redirecting to any new destination.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

f: cupertino flutter/packages/flutter/cupertino repository f: routes Navigator, Router, and related APIs. framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a way to update CupertinoPageRoute.title

4 participants