Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Feat: Migrate Theme to InheritedModel #165289

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from

Conversation

rkishan516
Copy link
Contributor

@rkishan516 rkishan516 commented Mar 17, 2025

Feat: Migrate Theme to InheritedModel
Currently continuation of #163733

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

@github-actions github-actions bot added a: text input Entering text in a text field or keyboard related problems framework flutter/packages/flutter repository. See also f: labels. f: material design flutter/packages/flutter/material repository. f: scrolling Viewports, list views, slivers, etc. f: cupertino flutter/packages/flutter/cupertino repository labels Mar 17, 2025
@rkishan516
Copy link
Contributor Author

@navaronbracke Would you mind reviewing this ? I have done basic implementation of migrating theme to inherited model.
I went with selector pattern, because it gives flexibility for what exactly is needed.

@rkishan516 rkishan516 marked this pull request as ready for review April 3, 2025 01:35
Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

Thanks for taking this on. This PR has big implications so we'll have to be careful to get it right, but overall I do think we need to move in the direction of expanding the use of InheritedModel.

This PR is missing tests. I think probably for each InheritedTheme subclass, you should write a test that depends on only one aspect, and expect that changing it rebuilds and changing something else does not rebuild. Sorry I know that's a lot of work.

Seeing those tests will also help me get a better picture of what it's like to use this.

@@ -87,10 +99,10 @@ abstract class InheritedTheme extends InheritedWidget {
static CapturedThemes capture({required BuildContext from, required BuildContext? to}) {
if (from == to) {
// Nothing to capture.
return CapturedThemes._(const <InheritedTheme>[]);
return CapturedThemes._(const <InheritedTheme<dynamic, dynamic>>[]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

@rkishan516 rkishan516 Apr 12, 2025

Choose a reason for hiding this comment

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

Okay, I will migrate to may be Object?. Going as per doc. I understand that is not also correct solution but I don't see other option than that. Can you suggest ?

@@ -21,7 +21,7 @@ import 'inherited_theme.dart';
/// Controls the default properties of icons in a widget subtree.
///
/// The icon theme is honored by [Icon] and [ImageIcon] widgets.
class IconTheme extends InheritedTheme {
class IconTheme extends InheritedTheme<IconThemeData, Object?> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Now I guess developers should typically avoid IconTheme.of unless they're using the whole IconTheme, and otherwise use InheritedModel.inheritFrom<InheritedTheme<IconThemeData, Object?>>`? Can you add something to the docs of IconTheme.of to point people in that direction?

Same for each other InheritedTheme subclass.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, I will do.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would it be a bad idea to add select method to all InheritedTheme subclass ?

///
/// This interface allows for selecting specific aspects of theme data without
/// coupling to specific theme implementations like Material or Cupertino.
abstract interface class ThemeSelector<T, V> {
Copy link
Contributor

Choose a reason for hiding this comment

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

What actually implements ThemeSelector and select? It's late on a Friday but I'm not seeing it 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is for end user, they create selector.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

class CupertinoThemeBrightness implements ThemeSelector<CupertinoThemeData, Brightness> {
  const CupertinoThemeBrightness();

  @override
  Brightness select(CupertinoThemeData theme) => theme.brightness ?? Brightness.light;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated implementation a little bit, now ThemeSelector is being used select method. For user, its just Simple pattern :-

final brightness = CupertinoTheme.select(context, (data) => data.brightness ?? Brightness.light);

@rkishan516
Copy link
Contributor Author

Thanks for taking this on. This PR has big implications so we'll have to be careful to get it right, but overall I do think we need to move in the direction of expanding the use of InheritedModel.

This PR is missing tests. I think probably for each InheritedTheme subclass, you should write a test that depends on only one aspect, and expect that changing it rebuilds and changing something else does not rebuild. Sorry I know that's a lot of work.

Seeing those tests will also help me get a better picture of what it's like to use this.

Yaa, make sense. I will add tests.

@rkishan516 rkishan516 force-pushed the inherited-theme branch 10 times, most recently from c9d9e17 to 7eeb01d Compare April 13, 2025 16:45
@rkishan516 rkishan516 requested a review from justinmc April 13, 2025 16:45
@rkishan516 rkishan516 force-pushed the inherited-theme branch 3 times, most recently from 2a05a0f to f1178b1 Compare April 22, 2025 13:47
@justinmc
Copy link
Contributor

I should be able to review this by the end of the week.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

This is a really exciting and ambitious PR @rkishan516, I appreciate you taking the time to open this!

I think the biggest question mark that I have with this PR is the proliferation of Object? that I mentioned in my last review. I left a comment about possibly getting rid of some type parameters, but I'm not sure if that's feasible. I might need to check out this branch locally and play around with it.

Anyway, I want to summarize this problem here for my own notes and to make sure that we are aligned:

The Object? type problem

The typical InheritedModel pattern is to define an "aspect" enum that maps to the properties of the InheritiedModel. For theming, that might look like:

// Kinda following the pattern in this PR, but using aspects:
ActionIconTheme.select(context, ActionIconAspect.backButtonIconBuilder);
// Or even worse, just using the classic InheritedModel pattern:
ActionIconTheme.backButtonIconBuilderOf(context);
  • (-) We'd have to define an aspect enum for every Theme subclass.
  • (-) Every time a new property is added to a Theme subclass, we have to remember to add it to the enum, too. And to update updateShouldNotifyDependents.
  • (-) If we use the classic approach, we'd also have to write a static method for every property (and similarly have to remember to add this for new properties).

Versus currently in this PR:

ActionIconTheme.select(
  context,
  (ActionIconThemeData theme) => theme.backButtonIconBuilder,
);
  • (+) No need to define an aspect enum or of method for each property.
  • (+) Nothing to do if a new property is added.
  • (-) Use of Object? for the return type of select methods.

Comment on lines 69 to 70
/// If you're only interested in specific theme properties, consider using [select] instead,
/// which will only rebuild your widget when the selected property changes.
Copy link
Contributor

Choose a reason for hiding this comment

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

WidgetTester tester,
) async {
int buildCount = 0;
late Color? primaryColor; // Use nullable Color?
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you mean to remove this comment?

Comment on lines +329 to +332
primaryColor: color1, // Selected property unchanged
barBackgroundColor: CupertinoColors.systemGreen, // Non-selected property changed
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for adding comments like these 👍

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you explain the changes in this file and the deleted tests? The diff is kind of hard to read.

@@ -33,7 +60,7 @@ import 'framework.dart';
///
/// ** See code in examples/api/lib/widgets/inherited_theme/inherited_theme.0.dart **
/// {@end-tool}
abstract class InheritedTheme extends InheritedWidget {
abstract class InheritedTheme<T, V> extends InheritedModel<ThemeSelector<T, V>> {
Copy link
Contributor

Choose a reason for hiding this comment

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

I was thinking that this has the potential to cause breaking changes, and looking at the flutter_plugins check it seems like it did, in flutter_svg:

  error - lib/src/default_theme.dart:7:7 - Missing concrete implementation of 'abstract class InheritedModel<T> extends InheritedWidget.updateShouldNotifyDependent'. Try implementing the missing method, or make the class abstract. - non_abstract_class_inherits_abstract_member
warning - lib/src/default_theme.dart:7:31 - The generic type 'InheritedTheme<dynamic, dynamic>' should have explicit type arguments but doesn't. Use explicit type arguments for 'InheritedTheme<dynamic, dynamic>'. - strict_raw_type
   info - lib/src/default_theme.dart:7:31 - Missing type annotation. Try adding a type annotation. - always_specify_types

That alone is probably fixable, but I'll also trigger the Google tests to see if it breaks anything there. We should expect to make a breaking change announcement for this PR if we land it (https://docs.flutter.dev/release/compatibility-policy).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I think it will be a breaking change.

Set<ThemeSelector<DefaultTextStyle, Object?>> dependencies,
) {
for (final ThemeSelector<DefaultTextStyle, Object?> selector in dependencies) {
final Object? oldValue = selector.select(oldWidget);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Seeing selector.select made me think of the static Theme.select methods, which add a dependency, while ThemeSelector.select does not. I wonder if there is a better name to discourage confusing the two? Maybe the static Theme methods should be selectOf?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What about selectFrom ?

///
/// When this value changes, a notification is sent to the [context]
/// to trigger an update.
static T select<T>(BuildContext context, T Function(ActionIconThemeData) selector) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I think maybe these static select methods should be called selectOf instead, in order to show their similarity to of methods and to distinguish them from ThemeSelector.of. I left another comment about this elsewhere because seeing selector.select made me think a dependency would be added, before I looked it up.

@@ -87,10 +114,10 @@ abstract class InheritedTheme extends InheritedWidget {
static CapturedThemes capture({required BuildContext from, required BuildContext? to}) {
if (from == to) {
// Nothing to capture.
return CapturedThemes._(const <InheritedTheme>[]);
return CapturedThemes._(const <InheritedTheme<Object?, Object?>>[]);
Copy link
Contributor

Choose a reason for hiding this comment

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

What about the use of T and V in this file? Should any of these InheritedTheme<Object?, Object?> should be InheritedTheme<T, V>? Or would it be possible to get rid of either/both type parameters?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think since capture takes all themes, w/o type boundary we can't use T and V.

Comment on lines 169 to 182
@override
bool updateShouldNotifyDependent(
InheritedCupertinoTheme oldWidget,
Set<ThemeSelector<CupertinoThemeData, Object?>> dependencies,
) {
for (final ThemeSelector<CupertinoThemeData, Object?> selector in dependencies) {
final Object? oldValue = selector.select(oldWidget.theme.data);
final Object? newValue = selector.select(theme.data);
if (oldValue != newValue) {
return true;
}
}
return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think it would be possible to do this in InheritedTheme to avoid needing to repeat it in each InheritedTheme subclass? It looks like it might be possible...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup, Will try to do this change.

@rkishan516 rkishan516 force-pushed the inherited-theme branch 4 times, most recently from 27cb43e to 2eeeeb0 Compare April 30, 2025 02:14
@gnprice
Copy link
Member

gnprice commented Apr 30, 2025

A workflow note: @rkishan516 would you try pushing your revisions as additional commits on top, instead of by force-pushing?

That's helpful because it makes it easy to see on GitHub exactly what was revised.

When the PR is ready, we'll squash it all into a single commit anyway. (Or rather the automerge bot will do so.) So the end result in the merged history will be a nice clean single commit, but at the same time we'll have on the PR thread a history of the revisions.

@gnprice
Copy link
Member

gnprice commented Apr 30, 2025

Stepping back a bit from the implementation questions:

Do we want InheritedTheme to be an InheritedModel, with aspects? Is it a good idea for developers to have their widgets depend on specific aspects of a theme?

I think the answer is that that will rarely be a good idea. The thing about themes is:

  • A given theme object has a giant number of properties. Any given widget (that uses a theme at all) is very likely to use at least a handful of them.

  • When the theme changes at all, it usually means changes to the values of most of those properties.

    In particular, the paradigmatic case of different themes is a light theme vs. a dark theme, plus animating through a transition between light and dark. When that transition happens, every frame will have a new theme — and almost every color value in the theme will have changed, as well as every text style that involves such a color.

So when the theme does change, almost every widget that depended on any part of the theme will need to rebuild, even if it used aspects.

And given that that's going to be the answer, the most efficient thing to do is to jump straight to that answer, by having the dependencies not involve any aspects. If instead a build method used 6 different aspects and 4 of them turn out to have changed, then iterating through those is more work and still has the same result: the widget needs to rebuild.

@gnprice
Copy link
Member

gnprice commented Apr 30, 2025

By contrast, MediaQuery is a good fit for InheritedModel because:

  • A given widget that uses it typically uses just one or two aspects: like the insets, or the device pixel ratio, or the device brightness setting.
  • When one aspect changes, that's not particularly correlated with other aspects changing. For example, changes to the insets are driven by totally different causes (in the device's world outside the app) from changes to the brightness.

@rkishan516
Copy link
Contributor Author

A workflow note: @rkishan516 would you try pushing your revisions as additional commits on top, instead of by force-pushing?

Sure.

@rkishan516
Copy link
Contributor Author

Do we want InheritedTheme to be an InheritedModel, with aspects? Is it a good idea for developers to have their widgets depend on specific aspects of a theme?

Taking an example of IconTheme, may times what developers use is color, size and opacity. So if fill value changes in theme, it shouldn't rebuild that particular icon. And think about a scenario for heavy widget tree based app there these rebuilds will improve performance.

Also, FYI InheritedTheme is already InheritedModel. We are just moving towards selector pattern.

By contrast, MediaQuery is a good fit for InheritedModel because:

100%, I agree.

@rkishan516
Copy link
Contributor Author

Thanks for review @justinmc and @gnprice. I have addressed approx all the reviews. Please feel free to review again.

@gnprice
Copy link
Member

gnprice commented Apr 30, 2025

Taking an example of IconTheme, may times what developers use is color, size and opacity. So if fill value changes in theme, it shouldn't rebuild that particular icon.

But why would that happen? That doesn't seem like something that's likely to change often.

And conversely the cost gets paid every time the IconThemeData changes at all. Every widget that depended on the IconTheme will have to evaluate updateShouldNotifyDependent. That will then iterate through the different selectors the widget used, for example comparing the size to see that didn't change, then the opacity to see that didn't change, before getting to the color and seeing that that changed.

Also, FYI InheritedTheme is already InheritedModel

Hmm — what I see in main is:

abstract class InheritedTheme extends InheritedWidget {

@rkishan516
Copy link
Contributor Author

Hmm — what I see in main is:

abstract class InheritedTheme extends InheritedWidget {

Yes, you're correct. Even PR title is the same 😂.

@rkishan516
Copy link
Contributor Author

I feel usage will be like

final (color, size) = IconTheme.selectFrom(context, (data) => (data.color, data.size));

So, in actual its just one selector not multiple.

Copy link
Contributor

@chunhtai chunhtai left a comment

Choose a reason for hiding this comment

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

I overall like this approach than the having an API for each aspect, but I am concerning the aspect may grow very quick in this approach

final V Function(T) _selector;

@override
V selectFrom(T themeData) => _selector(themeData);
Copy link
Contributor

Choose a reason for hiding this comment

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

probably better to implement an equal function and hashcode so that at least the same _selector will equal each other

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, I will add.

/// Creates a [ThemeSelector] from a function.
///
/// This factory constructor allows for creating a selector using a lambda syntax.
factory ThemeSelector.from(V Function(T) selector) = _FunctionThemeSelector<T, V>;
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like each look up will create a different aspect? and the updateShouldNotifyDependent runtime will also grow with number of aspects. Not sure how bad this will be.

Copy link
Contributor

Choose a reason for hiding this comment

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

every rebuild may also stack the aspect on top

Copy link
Contributor Author

@rkishan516 rkishan516 Apr 30, 2025

Choose a reason for hiding this comment

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

Yes, aspect and updateShouldNotifyDependent will be linearly growing with each lookup.

every rebuild may also stack the aspect on top

I think that will be resolved with equal and hashcode implementation.

Copy link
Contributor

Choose a reason for hiding this comment

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

equal and hashcode

It only helps for those who are careful to use private method as getter.

so only this case will be fine

double _myGetter(IconTheme theme) => theme.size;

IconTheme.selectFrom(
      context,
      _myGetter
    );

If they go lazy and use inline closure, the equality won't work

IconTheme.selectFrom(
      context,
      (IconTheme theme) => theme.size,
    );

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And many times, they will go for inline closure only.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@chunhtai For this specific case, can we use selector.toString().hashCode for equality ?

@gnprice
Copy link
Member

gnprice commented Apr 30, 2025

I feel usage will be like

final (color, size) = IconTheme.selectFrom(context, (data) => (data.color, data.size));

So, in actual its just one selector not multiple.

In a complex widget, the build method can get long, and it'd be awkward to centralize all the lookups in a single place — people are much more likely to do each lookup where it's needed. Often the build method even ends up having a number of helper methods it calls, some of which may need to look up something from a theme.

Even when looking up several things in one spot in the code, this form would get cumbersome very rapidly as soon as there are three things, or their names get a bit longer. The example above is 87 characters long — so reaches at least 91 columns after indentation. With a third item, or with longer names like opticalSize, it'd go past 100 columns and spill onto multiple lines:

    final (color, opticalSize) = IconTheme.selectFrom(
      context,
      (data) => (data.color, data.opticalSize),
    );

At that point it'd look quite a lot cleaner, and more compact, to say

    final color = IconTheme.selectFrom(context, (data) => data.color);
    final size = IconTheme.selectFrom(context, (data) => data.size);

so that's what I'd expect people to do.

Also cleaner and more compact, in this example, would be what one says now, using the existing API:

    final iconTheme =IconTheme.of(context);
    final color = iconTheme.color;
    final size = iconTheme.size;

@rkishan516
Copy link
Contributor Author

rkishan516 commented Apr 30, 2025

Yes @gnprice Make sense. And in complex app, with nesting, it can be quite big number of aspects being handled in updateShouldNotifyDependent.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

My current thinking is that it wouldn't actually be a good idea to make InheritedTheme and its subclasses use InheritedModel — that (if developers were to use the resulting API) it'd be more likely to hurt performance than to help, for the reasons above.

That would be true already under the approach that's used in MediaQuery (where the aspects come from an enum), for the reasons in #165289 (comment) and #165289 (comment). Then the risk of making performance worse is heightened with this "selector"-based API, for the reasons @chunhtai points out in the thread at #165289 (comment).

And although the motivation for this proposed change isn't given, I take it to be about a hope of better performance, not about simplifying the API. The status quo is that if one wants, for example, the color scheme, one writes Theme.of(context).colorScheme — which is going to be hard to beat for concision and clarity. Or if the same theme is used several times in a given method body, one might write:

  final theme = Theme.of(context);

and then say theme.colorScheme and theme.textTheme.bodyMedium and so on, which is again hard to beat.

If someone has profile or benchmark results suggesting that this would be helpful for performance after all, then that would naturally be a reason to investigate further. I think an issue thread, or perhaps a design doc, would be a better venue for that than a PR thread — in a PR thread such a discussion naturally gets intermingled with discussing details of the implementation, which can make it harder to follow.

Otherwise, I think probably the most helpful change we might make in the tree on this subject would be to add a "Performance considerations" section in the docs of InheritedTheme, Theme, and/or Theme.of, explaining why (unlike MediaQuery) themes don't use aspects, with some combination of the reasoning in #165289 (comment), #165289 (comment), and the thread #165289 (comment). (Or maybe InheritedModel itself would be the right home for most of that: explaining when it's helpful and when it's unhelpful, compared to plain InheritedWidget.) I could prepare such a PR if people would be interested.

In any case, thanks @rkishan516 for exploring this API direction with us! This was an informative discussion.

@rkishan516
Copy link
Contributor Author

@gnprice I agree with your point of view, but I have some points to make. Lets discuss in discord thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a: text input Entering text in a text field or keyboard related problems f: cupertino flutter/packages/flutter/cupertino repository f: material design flutter/packages/flutter/material repository. f: scrolling Viewports, list views, slivers, etc. framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants