-
Notifications
You must be signed in to change notification settings - Fork 28.5k
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
base: master
Are you sure you want to change the base?
Conversation
3f9255d
to
d36c92b
Compare
@navaronbracke Would you mind reviewing this ? I have done basic implementation of migrating theme to inherited model. |
d36c92b
to
d0aeb20
Compare
d0aeb20
to
7f6bce1
Compare
There was a problem hiding this 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>>[]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All of the dynamics here are a red flag at first glance at least. https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md#avoid-using-var-and-dynamic
There was a problem hiding this comment.
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?> { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I will do.
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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 😄
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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;
}
There was a problem hiding this comment.
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);
Yaa, make sense. I will add tests. |
c9d9e17
to
7eeb01d
Compare
7eeb01d
to
ae3d417
Compare
2a05a0f
to
f1178b1
Compare
I should be able to review this by the end of the week. |
There was a problem hiding this 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 ofselect
methods.
/// If you're only interested in specific theme properties, consider using [select] instead, | ||
/// which will only rebuild your widget when the selected property changes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another styleguide nitpick: don't say "you". https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md#use-the-passive-voice-recommend-do-not-require-never-say-things-are-simple
WidgetTester tester, | ||
) async { | ||
int buildCount = 0; | ||
late Color? primaryColor; // Use nullable Color? |
There was a problem hiding this comment.
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?
primaryColor: color1, // Selected property unchanged | ||
barBackgroundColor: CupertinoColors.systemGreen, // Non-selected property changed |
There was a problem hiding this comment.
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 👍
There was a problem hiding this comment.
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>> { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?>>[]); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
@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; | ||
} |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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.
27cb43e
to
2eeeeb0
Compare
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. |
Stepping back a bit from the implementation questions: Do we want I think the answer is that that will rarely be a good idea. The thing about themes is:
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. |
By contrast,
|
Sure. |
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.
100%, I agree. |
5840003
to
f1a30c7
Compare
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
Hmm — what I see in main is: abstract class InheritedTheme extends InheritedWidget { |
Yes, you're correct. Even PR title is the same 😂. |
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. |
There was a problem hiding this 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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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,
);
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ?
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 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; |
Yes @gnprice Make sense. And in complex app, with nesting, it can be quite big number of aspects being handled in |
…to InheritedTheme
70bd0a8
to
bb2c136
Compare
There was a problem hiding this 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.
@gnprice I agree with your point of view, but I have some points to make. Lets discuss in discord thread. |
Feat: Migrate Theme to InheritedModel
Currently continuation of #163733
Pre-launch Checklist
///
).