Skip to content
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

Switch Expander.IsExpanded to a StyledProperty #9979

Merged
merged 7 commits into from
Jan 26, 2023

Conversation

robloo
Copy link
Contributor

@robloo robloo commented Jan 15, 2023

What does the pull request do?

Switches the Expander.IsExpanded property to a StyledProperty and adds a new CancelRoutedEventArgs based on CancelEventArgs.

This is an update to PR #9555 based on discussion in #9944

What is the current behavior?

Expander.IsExpanded is a DirectProperty and cannot be used with Styling.

What is the updated/expected behavior with this PR?

  1. Expander.IsExpanded is now a full StyledProperty and usable in the styling system.
  2. New CancelRoutedEventArgs used for both the Expanding and Collapsing events based on CancelEventArgs.

How was the solution implemented (if it's not obvious)?

See code.

I had to go a different direction than what @tomenscape has done in #9944's working branch. It was necessary to add back some of the original code to:

  1. Call OnExpanding()/OnCollapsing() internally
  2. Notify the property has changed even when it is cancelled. This ensures the control templates toggle button doesn't desync and the animation states are correct.

Checklist

Breaking changes

Yes, StyledProperty instead of DirectProperty but functionality is largely only expanded for app developers.

Obsoletions / Deprecations

None

Fixed issues

Relates to #9944

@avaloniaui-team
Copy link
Contributor

You can test this PR using the following package version. 11.0.999-cibuild0028643-beta. (feed url: https://pkgs.dev.azure.com/AvaloniaUI/AvaloniaUI/_packaging/avalonia-all/nuget/v3/index.json) [PRBUILDID]

Comment on lines +319 to +324
RaisePropertyChanged(
IsExpandedProperty,
oldValue: value,
newValue: !value,
BindingPriority.LocalValue,
isEffectiveValue: true);
Copy link
Contributor

Choose a reason for hiding this comment

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

It's pretty weird that this can be done for a StyledProperty, which is meant to automatically manage value storage and change events.

This method call is claiming that a) the new value has local priority and b) that it's an effective value change. But the actual property might not agree with this. What happens when IsExpanded has a two-way binding within a template, for example?

Copy link
Contributor

Choose a reason for hiding this comment

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

Thinking about it some more, this is a local solution to a general problem. The problem is that when two-way bindings transfer a new value in either direction, they assume that it was accepted by the recipient.

If the recipient object changes the value to something other than the one that was set, a property changed event is raised and the binding can update the sender object. But if the recipient object instead ignores the new value (as is the case here), then no event is raised and no action is taken.

Hopefully we can go with the local fix here, but this is something that should also be considered.

Copy link
Contributor Author

@robloo robloo Jan 15, 2023

Choose a reason for hiding this comment

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

I think everyone agrees that this is non-ideal and kind-of 'hacky'. It was first discussed here: #9555 (comment).

This is why I was hoping coercion would handle this automatically for us but unfortunately it doesn't. Therefore, I had to add this back (you can remove it and test the control catalog to see the issues). I think WPF has issues here as well but for sure it would be better to improve coercion and actually handle the real property value that comes back after coercion itself.

For now, it must be done to ensure external code state is correct. (It is essentially just used to notify of a property changed event and force updates) Keep in mind the expander itself isn't the main 'source' of the IsExpanded property -- it is actually the checked state of a toggle button in the control template... so we have to be careful about this.

The problem is that when two-way bindings transfer a new value in either direction, they assume that it was accepted by the recipient.

Yes, that's the real issue. One I think exists in WPF as well.

the new value has local priority

That's something to think about some more I suppose. I copy pasted the code and when it was a direct-property LocalValue made sense. Now perhaps it doesn't. Since it isn't actually setting the value here though -- maybe it's harmless the value used here.

Copy link
Member

Choose a reason for hiding this comment

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

This is why I was hoping coercion would handle this automatically for us but unfortunately it doesn't.

Yeah I think we should probably make coercion handle this if it's not too difficult. What doesn't work with coercion?

Copy link
Contributor Author

@robloo robloo Jan 16, 2023

Choose a reason for hiding this comment

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

What doesn't work with coercion?

Here was a nice summary:

If the recipient object changes the value to something other than the one that was set, a property changed event is raised and the binding can update the sender object. But if the recipient object instead ignores the new value (as is the case here), then no event is raised and no action is taken.

Basically when a cancel happens in the coerce method the property value is reverted back to its original value. This means the value hasn't actually changed and no change notification happens -- seems to make sense. However, if two controls are two-way bound this actually causes a desync. Because the sender will still change its state.

My simple understanding (without looking at any control flow) is:

  1. Sender changes value (say true -> false)
  2. Receiver coerces value
  3. Coercion raises cancel events and external code cancels the change (value switch false -> true)
  4. The value 'true' is set to the control... but this is the same as it was before. Therefore no change notification and updates.
  5. The sender still has a false value instead of true because it never got a change notification because the value didn't actually change.

So an additional check is needed to see if coercion changes the value to a value other than what went into the coerce method. If so, and there is a two-way binding, it needs to notify the sender of a change even when the value set to the control is actually the same.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, and the ViewModel case is the issue I've seen in WPF before if I recall correctly. So this is an old problem.

Copy link
Member

Choose a reason for hiding this comment

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

Still having a bit of trouble understanding. What are the sender and receiver in the case of Expander.IsExpanded?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@grokys In the example I gave:

  • sender : This is the IsChecked property of the ToggleButton within the control template.
  • receiver : The actual IsExpanded property of the Expander control itself

This is a general problem though with two-way binding if the "receiver" doesn't accept the sender's value for whatever reason.

Copy link
Member

Choose a reason for hiding this comment

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

Ok, thanks for the clarification, that makes sense.

The problem with the RaisePropertyChanged hack here is that this method is internal so the hack will only work within Avalonia itself. We probably need to work out how to do this properly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Supporting cancellation in events is a fairly common practice from my experience and the API is mostly standardized as discussed above. It really should be supported in a UI framework IMO. Flyout is another example. If it currently requires an internal-use-only work-around for now that seems justifiable to me (especially a harmless one). The issue with the property system has existed for almost 20 years.

It's common in some cases to need to raise a property changed notification "hack" in apps as well. Especially due to the view model example above. It might also be possible to promote this idea from a "hack" into a "feature" by allowing a clean way to force a refresh.

@robloo
Copy link
Contributor Author

robloo commented Jan 16, 2023

Tests are failing because this:

        public void AddHandler<TEventArgs>(
            RoutedEvent<TEventArgs> routedEvent,
            EventHandler<TEventArgs>? handler,
            RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble,
            bool handledEventsToo = false) where TEventArgs : RoutedEventArgs

Is not matching for the CancelRoutedEventArgs handler... which derives directly from RoutedEventArgs instead this method is called:

        public void AddHandler(
            RoutedEvent routedEvent,
            Delegate handler,
            RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble,
            bool handledEventsToo = false)

This almost seems like a compiler bug to me. If this really doesn't work there is no point in even having that method generic.

@grokys grokys mentioned this pull request Jan 16, 2023
2 tasks
@grokys
Copy link
Member

grokys commented Jan 16, 2023

Is not matching for the CancelRoutedEventArgs handler... which derives directly from RoutedEventArgs instead this method is called:

I think we can probably remove the overload that accepts a Delegate?

@robloo
Copy link
Contributor Author

robloo commented Jan 16, 2023

Is not matching for the CancelRoutedEventArgs handler... which derives directly from RoutedEventArgs instead this method is called:

I think we can probably remove the overload that accepts a Delegate?

Unfortunately, it's a deeper than that. The overload that accept a delegate is required to implement IInputElement. I suspect it exists there for a reason as well. I'm not sure the method matching precedence in the compiler but this really doesn't make sense.

However, it should be possible to fix this by adding a third method that works only with CancelRoutedEventArgs. It's kind-of a waste but seems to be a necessary work-around.

This hopefully fixes unit tests due to wrong overload being selected by the compiler.
@TomEdwardsEnscape
Copy link
Contributor

This almost seems like a compiler bug to me.

Perhaps it is. I ran all tests locally on the previous commit in your branch, i.e. without AddHandler, and they succeeded. I'm using the latest build (17.4.4) of VS 2022. Which test was failing for you?

@robloo
Copy link
Contributor Author

robloo commented Jan 16, 2023

Which test was failing for you?

All tests are failing in the PR (which you might not be able to see) because it won't compile -- it isn't an actual test failure:

image

[ERR] Compile: D:\a\1\s\src\Avalonia.Controls\Expander.cs(157,48): error CS8604: Possible null reference argument for parameter 'handler' in 'void Interactive.AddHandler(RoutedEvent routedEvent, Delegate handler, RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble, bool handledEventsToo = false)'. [D:\a\1\s\src\Avalonia.Controls\Avalonia.Controls.csproj::TargetFramework=net6.0]
[ERR] Compile: D:\a\1\s\src\Avalonia.Controls\Expander.cs(158,54): error CS8604: Possible null reference argument for parameter 'handler' in 'void Interactive.RemoveHandler(RoutedEvent routedEvent, Delegate handler)'. [D:\a\1\s\src\Avalonia.Controls\Avalonia.Controls.csproj::TargetFramework=net6.0]
[ERR] Compile: D:\a\1\s\src\Avalonia.Controls\Expander.cs(166,46): error CS8604: Possible null reference argument for parameter 'handler' in 'void Interactive.AddHandler(RoutedEvent routedEvent, Delegate handler, RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble, bool handledEventsToo = false)'. [D:\a\1\s\src\Avalonia.Controls\Avalonia.Controls.csproj::TargetFramework=net6.0]
[ERR] Compile: D:\a\1\s\src\Avalonia.Controls\Expander.cs(167,52): error CS8604: Possible null reference argument for parameter 'handler' in 'void Interactive.RemoveHandler(RoutedEvent routedEvent, Delegate handler)'. [D:\a\1\s\src\Avalonia.Controls\Avalonia.Controls.csproj::TargetFramework=net6.0]

Setting a breakpoint on the AddHandler methods and running the code confirmed that even on my local computer the wrong method is getting called. It's possible that the build server is set to treat these warnings as errors too which is why it isn't showing up locally (I didn't have a compile problem either).

Unfortunately, even the new AddHandler method with explicit CancelRoutedEventArgs didn't fix it -- which is very, very strange. Not sure where to go from here and will think about it some more.

@TomEdwardsEnscape
Copy link
Contributor

I explicitly added generic parameters to the AddHandler calls and intellisense revealed the problem: you changed ExpandedEvent to CancelRoutedEventArgs, when you meant to change CollapsingEvent. These two events are on the lines mentioned by the compiler error.

Not sure why error CS8604 is appearing on the CI but not in local builds. Another thing to fix!

@robloo
Copy link
Contributor Author

robloo commented Jan 17, 2023

@tomenscape Well, I feel dumb and should have double checked it wasnt something simple. Thanks a lot for taking a look!

@avaloniaui-team
Copy link
Contributor

You can test this PR using the following package version. 11.0.999-cibuild0028774-beta. (feed url: https://pkgs.dev.azure.com/AvaloniaUI/AvaloniaUI/_packaging/avalonia-all/nuget/v3/index.json) [PRBUILDID]

@avaloniaui-team
Copy link
Contributor

You can test this PR using the following package version. 11.0.999-cibuild0028791-beta. (feed url: https://pkgs.dev.azure.com/AvaloniaUI/AvaloniaUI/_packaging/avalonia-all/nuget/v3/index.json) [PRBUILDID]

@maxkatz6
Copy link
Member

Do I understand it right, the only problem with this PR is RaisePropertyChanged hack?
If so, can we merge it and move RaisePropertyChanged discussion to the new issue?

@robloo
Copy link
Contributor Author

robloo commented Jan 26, 2023

Do I understand it right, the only problem with this PR is RaisePropertyChanged hack?

Yes

If so, can we merge it and move RaisePropertyChanged discussion to the new issue?

I've created a new issue: #10092.

It will take a while before I could dig into the property system and attempt a fix. Otherwise, I'm probably not the best person to do it (I do have to learn the internals here better someday though). This really does seem harmless for now to me.

@maxkatz6 maxkatz6 merged commit 2072b19 into AvaloniaUI:master Jan 26, 2023
@avaloniaui-team
Copy link
Contributor

You can test this PR using the following package version. 11.0.999-cibuild0029150-beta. (feed url: https://pkgs.dev.azure.com/AvaloniaUI/AvaloniaUI/_packaging/avalonia-all/nuget/v3/index.json) [PRBUILDID]

@robloo robloo deleted the expander-isexpanded-styled-prop branch January 26, 2023 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants