diff --git a/src/Controls/src/Core/Interactivity/EventTrigger.cs b/src/Controls/src/Core/Interactivity/EventTrigger.cs index bd8038d4e806..f2f48281f783 100644 --- a/src/Controls/src/Core/Interactivity/EventTrigger.cs +++ b/src/Controls/src/Core/Interactivity/EventTrigger.cs @@ -1,7 +1,7 @@ #nullable disable using System; using System.Collections.Generic; -using System.Linq; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Extensions.Logging; using Microsoft.Maui.Controls.Internals; @@ -14,26 +14,90 @@ namespace Microsoft.Maui.Controls [ContentProperty("Actions")] public sealed class EventTrigger : TriggerBase { - static readonly MethodInfo s_handlerinfo = typeof(EventTrigger).GetRuntimeMethods().Single(mi => mi.Name == "OnEventTriggered" && mi.IsPublic == false); readonly List> _associatedObjects = new List>(); - - EventInfo _eventinfo; - + readonly IEventSubscriptionStrategy _strategy; string _eventname; - Delegate _handlerdelegate; /// /// Initializes a new instance. /// - public EventTrigger() : base(typeof(BindableObject)) + [RequiresUnreferencedCode("EventTrigger uses reflection to subscribe to events. Use EventTrigger.Create() factory methods for trimming-safe alternatives.")] +#if !NETSTANDARD + [RequiresDynamicCode("EventTrigger uses reflection to subscribe to events. Use EventTrigger.Create() factory methods for trimming-safe alternatives.")] +#endif + public EventTrigger() + : base(typeof(BindableObject)) + { + _strategy = new ReflectionStrategy(this); + } + + private EventTrigger(Func strategyFactory) + : base(typeof(BindableObject)) + { + _strategy = strategyFactory(this); + } + +#nullable enable + /// + /// Creates an AOT-safe EventTrigger for events using . + /// + /// The type of the bindable object that owns the event. + /// The name of the event to respond to. + /// A static lambda to subscribe to the event. + /// A static lambda to unsubscribe from the event. + /// A new EventTrigger instance. + /// + /// + /// EventTrigger.Create<Button>("Clicked", + /// static (b, h) => b.Clicked += h, + /// static (b, h) => b.Clicked -= h); + /// + /// + public static EventTrigger Create( + string eventName, + Action addHandler, + Action removeHandler) where TBindable : BindableObject + { + return new EventTrigger(trigger => new StaticStrategy(addHandler, removeHandler, trigger)) + { + Event = eventName + }; + } + + /// + /// Creates an AOT-safe EventTrigger for events using . + /// + /// The type of the bindable object that owns the event. + /// The type of the event arguments. + /// The name of the event to respond to. + /// A static lambda to subscribe to the event. + /// A static lambda to unsubscribe from the event. + /// A new EventTrigger instance. + /// + /// + /// EventTrigger.Create<Entry, TextChangedEventArgs>("TextChanged", + /// static (e, h) => e.TextChanged += h, + /// static (e, h) => e.TextChanged -= h); + /// + /// + public static EventTrigger Create( + string eventName, + Action> addHandler, + Action> removeHandler) + where TBindable : BindableObject + where TEventArgs : EventArgs { - Actions = new SealedList(); + return new EventTrigger(trigger => new StaticStrategy(addHandler, removeHandler, trigger)) + { + Event = eventName + }; } +#nullable disable /// /// Gets the collection of objects to invoke when the event fires. /// - public IList Actions { get; } + public IList Actions { get; } = new SealedList(); /// /// Gets or sets the name of the event that triggers the actions. @@ -56,8 +120,7 @@ public string Event internal override void OnAttachedTo(BindableObject bindable) { base.OnAttachedTo(bindable); - if (!string.IsNullOrEmpty(Event)) - AttachHandlerTo(bindable); + _strategy.AttachHandlerTo(bindable); _associatedObjects.Add(new WeakReference(bindable)); } @@ -67,7 +130,7 @@ internal override void OnDetachingFrom(BindableObject bindable) { if (wr.TryGetTarget(out var target) && target == bindable) { - DetachHandlerFrom(bindable); + _strategy.DetachHandlerFrom(bindable); return true; } return false; @@ -81,32 +144,118 @@ internal override void OnSeal() ((SealedList)Actions).IsReadOnly = true; } - void AttachHandlerTo(BindableObject bindable) + private void InvokeActions(object sender) + { + if (sender is not BindableObject bindable) + return; + + foreach (TriggerAction action in Actions) + action.DoInvoke(bindable); + } + +#nullable enable + private interface IEventSubscriptionStrategy { - try + void AttachHandlerTo(BindableObject target); + void DetachHandlerFrom(BindableObject target); + } + + [RequiresUnreferencedCode("Uses reflection to subscribe to events.")] +#if !NETSTANDARD + [RequiresDynamicCode("Uses reflection to subscribe to events.")] +#endif + private sealed class ReflectionStrategy(EventTrigger trigger) : IEventSubscriptionStrategy + { + private EventInfo? _eventInfo; + private Delegate? _handler; + + public void AttachHandlerTo(BindableObject target) { - _eventinfo = bindable.GetType().GetRuntimeEvent(Event); - _handlerdelegate = ((EventHandler)OnEventTriggered).Method.CreateDelegate(_eventinfo.EventHandlerType, this); + if (string.IsNullOrEmpty(trigger.Event)) + return; + + try + { + _eventInfo = target.GetType().GetRuntimeEvent(trigger.Event); + if (_eventInfo == null) + { + LogWarning(target); + return; + } + + // Create a delegate that matches the event's signature + var handlerMethod = typeof(ReflectionStrategy).GetMethod(nameof(OnEventTriggered), BindingFlags.NonPublic | BindingFlags.Instance); + _handler = handlerMethod!.CreateDelegate(_eventInfo.EventHandlerType!, this); + _eventInfo.AddEventHandler(target, _handler); + } + catch (Exception) + { + LogWarning(target); + } } - catch (Exception) + + public void DetachHandlerFrom(BindableObject target) + { + if (_eventInfo != null && _handler != null) + _eventInfo.RemoveEventHandler(target, _handler); + } + + private void OnEventTriggered(object? sender, EventArgs e) + { + trigger.InvokeActions(sender); + } + + private void LogWarning(BindableObject target) { - MauiLogger.Log(LogLevel.Warning, $"Cannot attach EventTrigger to {bindable.GetType()}.{Event}. Check if the handler exists and if the signature is right."); + Application.Current?.FindMauiContext()?.CreateLogger()? + .LogWarning("Cannot attach EventTrigger to {Type}.{Event}. Check if the event exists and the signature is correct.", + target.GetType(), trigger.Event); } - if (_eventinfo != null && _handlerdelegate != null) - _eventinfo.AddEventHandler(bindable, _handlerdelegate); } - void DetachHandlerFrom(BindableObject bindable) + private sealed class StaticStrategy( + Action addHandler, + Action removeHandler, + EventTrigger trigger) + : IEventSubscriptionStrategy + where TBindable : BindableObject { - if (_eventinfo != null && _handlerdelegate != null) - _eventinfo.RemoveEventHandler(bindable, _handlerdelegate); + public void AttachHandlerTo(BindableObject target) + { + if (target is TBindable typedTarget) + addHandler(typedTarget, InvokeActions); + } + + public void DetachHandlerFrom(BindableObject target) + { + if (target is TBindable typedTarget) + removeHandler(typedTarget, InvokeActions); + } + + private void InvokeActions(object? sender, EventArgs e) => trigger.InvokeActions(sender); } - void OnEventTriggered(object sender, EventArgs e) + private sealed class StaticStrategy( + Action> addHandler, + Action> removeHandler, + EventTrigger trigger) + : IEventSubscriptionStrategy + where TBindable : BindableObject + where TEventArgs : EventArgs { - var bindable = (BindableObject)sender; - foreach (TriggerAction action in Actions) - action.DoInvoke(bindable); + public void AttachHandlerTo(BindableObject target) + { + if (target is TBindable typedTarget) + addHandler(typedTarget, InvokeActions); + } + + public void DetachHandlerFrom(BindableObject target) + { + if (target is TBindable typedTarget) + removeHandler(typedTarget, InvokeActions); + } + + private void InvokeActions(object? sender, TEventArgs e) => trigger.InvokeActions(sender); } } } \ No newline at end of file diff --git a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt index 591649f38abb..f4f3fa858a83 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -47,3 +47,5 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnInterceptTouchEvent(Android.Views.MotionEvent e) -> bool ~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnTouchEvent(Android.Views.MotionEvent e) -> bool ~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action! addHandler, System.Action! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action!>! addHandler, System.Action!>! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index e649eae29ccc..f2bc04f3b0f7 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -48,3 +48,5 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~const Microsoft.Maui.Controls.AppThemeBinding.AppThemeResource = "__MAUI_ApplicationTheme__" -> string ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void ~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action! addHandler, System.Action! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action!>! addHandler, System.Action!>! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index e649eae29ccc..f2bc04f3b0f7 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -48,3 +48,5 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~const Microsoft.Maui.Controls.AppThemeBinding.AppThemeResource = "__MAUI_ApplicationTheme__" -> string ~override Microsoft.Maui.Controls.Platform.Compatibility.ShellFlyoutRenderer.ViewWillTransitionToSize(CoreGraphics.CGSize toSize, UIKit.IUIViewControllerTransitionCoordinator coordinator) -> void ~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action! addHandler, System.Action! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action!>! addHandler, System.Action!>! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! diff --git a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 831d00a4415b..439045d0dcab 100644 --- a/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -45,3 +45,5 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~Microsoft.Maui.Controls.ResourceDictionary.AddFactory(string key, System.Func factory, bool shared = true) -> void ~const Microsoft.Maui.Controls.AppThemeBinding.AppThemeResource = "__MAUI_ApplicationTheme__" -> string ~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action! addHandler, System.Action! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action!>! addHandler, System.Action!>! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! diff --git a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 831d00a4415b..439045d0dcab 100644 --- a/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -45,3 +45,5 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~Microsoft.Maui.Controls.ResourceDictionary.AddFactory(string key, System.Func factory, bool shared = true) -> void ~const Microsoft.Maui.Controls.AppThemeBinding.AppThemeResource = "__MAUI_ApplicationTheme__" -> string ~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action! addHandler, System.Action! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action!>! addHandler, System.Action!>! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! diff --git a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt index 831d00a4415b..439045d0dcab 100644 --- a/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net/PublicAPI.Unshipped.txt @@ -45,3 +45,5 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~Microsoft.Maui.Controls.ResourceDictionary.AddFactory(string key, System.Func factory, bool shared = true) -> void ~const Microsoft.Maui.Controls.AppThemeBinding.AppThemeResource = "__MAUI_ApplicationTheme__" -> string ~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action! addHandler, System.Action! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action!>! addHandler, System.Action!>! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt index fe52f8e15a93..91e620dfcc97 100644 --- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -36,3 +36,5 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~Microsoft.Maui.Controls.ResourceDictionary.AddFactory(System.Type targetType, System.Func factory, bool shared = true) -> void ~Microsoft.Maui.Controls.ResourceDictionary.AddFactory(string key, System.Func factory, bool shared = true) -> void ~static Microsoft.Maui.Controls.VisualStateManager.GetVisualStateGroups(Microsoft.Maui.Controls.VisualElement visualElement) -> Microsoft.Maui.Controls.VisualStateGroupList +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action! addHandler, System.Action! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! +static Microsoft.Maui.Controls.EventTrigger.Create(string! eventName, System.Action!>! addHandler, System.Action!>! removeHandler) -> Microsoft.Maui.Controls.EventTrigger! diff --git a/src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md b/src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md index 561c857d919a..470f1a080112 100644 --- a/src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md +++ b/src/Controls/src/SourceGen/AnalyzerReleases.Unshipped.md @@ -31,3 +31,5 @@ MAUIX2011 | XamlParsing | Warning | AmbiguousMemberWithStaticType MAUIX2012 | XamlParsing | Error | CSharpExpressionsRequirePreviewFeatures MAUIX2013 | XamlParsing | Error | AsyncLambdaNotSupported MAUIX2015 | XamlInflation | Warning | DuplicatePropertyAssignment +MAUIX2016 | XamlInflation | Error | EventTriggerEventNotFound +MAUIX2017 | XamlInflation | Warning | EventTriggerTargetTypeNotResolved diff --git a/src/Controls/src/SourceGen/Descriptors.cs b/src/Controls/src/SourceGen/Descriptors.cs index a338f9c64d2d..2af6f6207ffd 100644 --- a/src/Controls/src/SourceGen/Descriptors.cs +++ b/src/Controls/src/SourceGen/Descriptors.cs @@ -333,6 +333,22 @@ public static class Descriptors defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); + public static DiagnosticDescriptor EventTriggerEventNotFound = new DiagnosticDescriptor( + id: "MAUIX2016", + title: new LocalizableResourceString(nameof(MauiGResources.EventTriggerEventNotFoundTitle), MauiGResources.ResourceManager, typeof(MauiGResources)), + messageFormat: new LocalizableResourceString(nameof(MauiGResources.EventTriggerEventNotFoundMessage), MauiGResources.ResourceManager, typeof(MauiGResources)), + category: "XamlInflation", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static DiagnosticDescriptor EventTriggerTargetTypeNotResolved = new DiagnosticDescriptor( + id: "MAUIX2017", + title: new LocalizableResourceString(nameof(MauiGResources.EventTriggerTargetTypeNotResolvedTitle), MauiGResources.ResourceManager, typeof(MauiGResources)), + messageFormat: new LocalizableResourceString(nameof(MauiGResources.EventTriggerTargetTypeNotResolvedMessage), MauiGResources.ResourceManager, typeof(MauiGResources)), + category: "XamlInflation", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + // public static BuildExceptionCode TypeResolution = new BuildExceptionCode("XC", 0000, nameof(TypeResolution), ""); // public static BuildExceptionCode PropertyResolution = new BuildExceptionCode("XC", 0001, nameof(PropertyResolution), ""); // public static BuildExceptionCode MissingEventHandler = new BuildExceptionCode("XC", 0002, nameof(MissingEventHandler), ""); diff --git a/src/Controls/src/SourceGen/EventTriggerValueProvider.cs b/src/Controls/src/SourceGen/EventTriggerValueProvider.cs new file mode 100644 index 000000000000..f0d34191a5b1 --- /dev/null +++ b/src/Controls/src/SourceGen/EventTriggerValueProvider.cs @@ -0,0 +1,173 @@ +using System.CodeDom.Compiler; +using System.Linq; +using System.Xml; +using Microsoft.CodeAnalysis; +using Microsoft.Maui.Controls.Xaml; +using static Microsoft.Maui.Controls.SourceGen.LocationHelpers; + +namespace Microsoft.Maui.Controls.SourceGen; + +/// +/// Provides AOT-safe EventTrigger creation using the EventTrigger.Create factory methods. +/// +/// Unlike other value providers, EventTrigger is handled specially: +/// - GenerateCreateInstanceCall is called from CreateValuesVisitor to emit the declaration early +/// - TryProvideValue returns the pre-existing variable name +/// +/// The declaration must happen in CreateValuesVisitor because EventTrigger has children +/// (TriggerActions) that need the variable to exist before they're processed in +/// SetPropertiesVisitor (which uses bottom-up visiting order). +/// +internal class EventTriggerValueProvider : IKnownMarkupValueProvider +{ + public bool CanProvideValue(ElementNode node, SourceGenContext context) + { + // Return true when Event property exists + return HasEventProperty(node); + } + + public bool TryProvideValue(ElementNode node, IndentedTextWriter writer, SourceGenContext context, NodeSGExtensions.GetNodeValueDelegate? getNodeValue, out ITypeSymbol? returnType, out string value) + { + // The variable was already declared via GenerateCreateInstanceCall called from CreateValuesVisitor. + // Return true with the variable name to prevent any additional code generation. + if (context.Variables.TryGetValue(node, out var variable)) + { + returnType = variable.Type; + value = variable.ValueAccessor; + return true; + } + + // Shouldn't happen - CreateValuesVisitor always registers the variable + returnType = null; + value = string.Empty; + return false; + } + + /// + /// Generates the EventTrigger creation expression (without variable declaration). + /// Writes either "EventTrigger.Create<...>(...);" or "new EventTrigger { ... };". + /// + internal static void GenerateCreateExpression(ElementNode node, SourceGenContext context) + { + var writer = context.Writer; + + // Get the Event property value and line info for diagnostics + string? eventName = null; + IXmlLineInfo? eventLineInfo = null; + if (node.Properties.TryGetValue(new XmlName("", "Event"), out var eventNode) && eventNode is ValueNode eventValueNode) + { + eventName = (string)eventValueNode.Value; + eventLineInfo = eventValueNode as IXmlLineInfo; + } + + // Find target type from XAML tree (parent elements) + ITypeSymbol? targetType = FindTargetType(node, context); + + if (eventName != null && targetType != null) + { + // Look up event on target type. + // GetAllMembers walks from most-derived to base, so .First() returns the + // most-derived event declaration — matching runtime GetRuntimeEvent() behavior. + // This is also consistent with ConnectEvent in SetPropertyHelpers.cs. + // If a derived class shadows a base event (new event Foo), we correctly pick + // the derived version since it appears first in the member walk. + var eventSymbols = targetType.GetAllEvents(eventName, context).ToList(); + if (eventSymbols.Count > 0) + { + var eventSymbol = eventSymbols.First(); + var eventType = eventSymbol.Type; + var invoke = eventType.GetAllMethods("Invoke", context).FirstOrDefault(); + + if (invoke != null && invoke.Parameters.Length == 2) + { + var eventArgsType = invoke.Parameters[1].Type; + var isGenericEventHandler = !eventArgsType.Equals( + context.Compilation.GetTypeByMetadataName("System.EventArgs"), + SymbolEqualityComparer.Default); + + var targetTypeName = targetType.ToFQDisplayString(); + + if (isGenericEventHandler) + { + var eventArgsTypeName = eventArgsType.ToFQDisplayString(); + writer.WriteLine($"global::Microsoft.Maui.Controls.EventTrigger.Create<{targetTypeName}, {eventArgsTypeName}>(\"{eventName}\", static (target, handler) => target.{eventName} += handler, static (target, handler) => target.{eventName} -= handler);"); + } + else + { + writer.WriteLine($"global::Microsoft.Maui.Controls.EventTrigger.Create<{targetTypeName}>(\"{eventName}\", static (target, handler) => target.{eventName} += handler, static (target, handler) => target.{eventName} -= handler);"); + } + + // Skip the Event property - it's already set by Create() + node.SkipProperties.Add(new XmlName("", "Event")); + return; + } + } + + // Event not found on target type - report error + ReportEventNotFound(context, eventLineInfo ?? node, eventName, targetType); + } + + // Fallback: use reflection-based EventTrigger + if (eventName != null) + { + // Warn that AOT-safe path wasn't taken + if (targetType == null) + { + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.EventTriggerTargetTypeNotResolved, + LocationCreate(context.ProjectItem.RelativePath!, eventLineInfo ?? node, eventName), + eventName)); + } + + writer.WriteLine($"new global::Microsoft.Maui.Controls.EventTrigger {{ Event = \"{eventName}\" }};"); + node.SkipProperties.Add(new XmlName("", "Event")); + } + else + { + writer.WriteLine($"new global::Microsoft.Maui.Controls.EventTrigger();"); + } + } + + private static void ReportEventNotFound(SourceGenContext context, IXmlLineInfo lineInfo, string eventName, ITypeSymbol targetType) + { + context.ReportDiagnostic(Diagnostic.Create( + Descriptors.EventTriggerEventNotFound, + LocationCreate(context.ProjectItem.RelativePath!, lineInfo, eventName), + eventName, + targetType.ToFQDisplayString())); + } + + /// + /// Finds the target type for an EventTrigger by walking up the XAML tree. + /// EventTrigger is typically inside: Element.Triggers -> Element (e.g., Button.Triggers -> Button) + /// + private static ITypeSymbol? FindTargetType(ElementNode eventTriggerNode, SourceGenContext context) + { + INode? current = eventTriggerNode; + + while (current != null) + { + current = current.Parent; + + // Skip ListNodes (they're the collection wrapper) + if (current is ListNode listNode) + current = listNode.Parent; + + // Found an ElementNode - this should be our target + if (current is ElementNode parentElement && parentElement != eventTriggerNode) + return parentElement.XmlType.GetTypeSymbol(context); + } + + return null; + } + + internal static bool HasEventProperty(ElementNode node) + { + foreach (var key in node.Properties.Keys) + { + if (key is XmlName xmlName && xmlName.LocalName == "Event") + return true; + } + return false; + } +} diff --git a/src/Controls/src/SourceGen/MauiGResources.resx b/src/Controls/src/SourceGen/MauiGResources.resx index da1831797582..0980cdf5d81a 100644 --- a/src/Controls/src/SourceGen/MauiGResources.resx +++ b/src/Controls/src/SourceGen/MauiGResources.resx @@ -247,4 +247,18 @@ Property '{0}' is being set multiple times. Only the last value will be used. 0 is a property name (e.g., "Border.Content", "Label.Text") + + Event not found + + + Event '{0}' not found on type '{1}'. The EventTrigger will not fire. + 0 is event name, 1 is type name + + + EventTrigger target type not resolved + + + Could not determine the target type for EventTrigger with Event='{0}'. The trigger will use reflection at runtime and will not be AOT-safe. Ensure the EventTrigger is nested directly inside an element's Triggers collection. + 0 is event name + diff --git a/src/Controls/src/SourceGen/NodeSGExtensions.cs b/src/Controls/src/SourceGen/NodeSGExtensions.cs index b637b25a5953..602577c4729f 100644 --- a/src/Controls/src/SourceGen/NodeSGExtensions.cs +++ b/src/Controls/src/SourceGen/NodeSGExtensions.cs @@ -84,6 +84,7 @@ public static Dictionary GetKnownValuePr => context.knownSGValueProviders ??= new(SymbolEqualityComparer.Default) { {context.Compilation.GetTypeByMetadataName("Microsoft.Maui.Controls.Setter")!, new SetterValueProvider()}, + {context.Compilation.GetTypeByMetadataName("Microsoft.Maui.Controls.EventTrigger")!, new EventTriggerValueProvider()}, }; diff --git a/src/Controls/src/SourceGen/Visitors/CreateValuesVisitor.cs b/src/Controls/src/SourceGen/Visitors/CreateValuesVisitor.cs index 168f8e03ef61..0c5adee4e337 100644 --- a/src/Controls/src/SourceGen/Visitors/CreateValuesVisitor.cs +++ b/src/Controls/src/SourceGen/Visitors/CreateValuesVisitor.cs @@ -286,6 +286,17 @@ public static void CreateValue(ElementNode node, IndentedTextWriter writer, IDic if (NodeSGExtensions.GetKnownValueProviders(Context).TryGetValue(type, out var valueProvider) && valueProvider.CanProvideValue(node, Context)) { + // EventTrigger is special: it has children (TriggerActions) that need the variable + // to exist before they're processed. Emit the declaration here. + if (valueProvider is EventTriggerValueProvider) + { + var varName = NamingHelpers.CreateUniqueVariableName(Context, type); + variables[node] = new LocalVariable(type, varName); + writer.Write($"var {varName} = "); + EventTriggerValueProvider.GenerateCreateExpression(node, Context); + return; + } + // This element can be fully inlined without property assignments or variable creation. // Skip setting all simple value properties since they'll be handled // by inline initialization in TryProvideValue. diff --git a/src/Controls/src/SourceGen/xlf/MauiGResources.Designer.cs b/src/Controls/src/SourceGen/xlf/MauiGResources.Designer.cs index 2d51af07e9c0..e2b141bf81fa 100644 --- a/src/Controls/src/SourceGen/xlf/MauiGResources.Designer.cs +++ b/src/Controls/src/SourceGen/xlf/MauiGResources.Designer.cs @@ -272,5 +272,9 @@ internal static string DuplicateTypeError internal static string MissingEventHandler => ResourceManager.GetString("MissingEventHandler", resourceCulture); internal static string DuplicatePropertyAssignmentTitle => ResourceManager.GetString("DuplicatePropertyAssignmentTitle", resourceCulture); internal static string DuplicatePropertyAssignmentMessage => ResourceManager.GetString("DuplicatePropertyAssignmentMessage", resourceCulture); + internal static string EventTriggerEventNotFoundTitle => ResourceManager.GetString("EventTriggerEventNotFoundTitle", resourceCulture); + internal static string EventTriggerEventNotFoundMessage => ResourceManager.GetString("EventTriggerEventNotFoundMessage", resourceCulture); + internal static string EventTriggerTargetTypeNotResolvedTitle => ResourceManager.GetString("EventTriggerTargetTypeNotResolvedTitle", resourceCulture); + internal static string EventTriggerTargetTypeNotResolvedMessage => ResourceManager.GetString("EventTriggerTargetTypeNotResolvedMessage", resourceCulture); } } diff --git a/src/Controls/tests/Core.UnitTests/Triggers/EventTriggerBaseTests.cs b/src/Controls/tests/Core.UnitTests/Triggers/EventTriggerBaseTests.cs new file mode 100644 index 000000000000..13f6635ab84b --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/Triggers/EventTriggerBaseTests.cs @@ -0,0 +1,190 @@ +using System; +using Xunit; + +namespace Microsoft.Maui.Controls.Core.UnitTests.Triggers +{ + /// + /// Unit tests for EventTrigger and EventTrigger.Create factory methods. + /// Validates core functionality, lifecycle management, and AOT-safe behavior. + /// + public class EventTriggerTests + { +#pragma warning disable CS0618 // Type or member is obsolete (we're testing reflection-based EventTrigger) + /// + /// Validates that EventTrigger can be instantiated and initialized. + /// + [Fact] + public void EventTrigger_CreatesSuccessfully() + { + var trigger = new EventTrigger(); + + Assert.NotNull(trigger); + Assert.NotNull(trigger.Actions); + Assert.Empty(trigger.Actions); + } + + /// + /// Validates that Event property can be set and retrieved. + /// + [Fact] + public void EventTrigger_EventProperty_CanBeSetAndRetrieved() + { + var trigger = new EventTrigger(); + const string eventName = "Clicked"; + trigger.Event = eventName; + + Assert.Equal(eventName, trigger.Event); + } +#pragma warning restore CS0618 + + /// + /// Validates that EventTrigger.Create can be created with lambdas. + /// + [Fact] + public void EventTrigger_Create_CanBeCreatedWithLambdas() + { + var trigger = EventTrigger.Create + + + """; + + var code = + """ + using Microsoft.Maui.Controls; + using Microsoft.Maui.Controls.Xaml; + + namespace Test; + + public class TestTriggerAction : TriggerAction + { + protected override void Invoke(VisualElement sender) { } + } + + [XamlProcessing(XamlInflator.SourceGen)] + public partial class EventTriggerPage : ContentPage + { + public EventTriggerPage() + { + InitializeComponent(); + } + } + """; + + // Expected generated code snapshot + // This is the full generated InitializeComponent() method for EventTrigger XAML + // Key patterns to verify: + // 1. EventTrigger.Create + + diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591.xaml.cs new file mode 100644 index 000000000000..03c2e3aee5e0 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591.xaml.cs @@ -0,0 +1,62 @@ +using System.Linq; +using Xunit; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests; + +/// +/// Simple test TriggerAction that tracks invocation count. +/// +public class Maui33591TestTriggerAction : TriggerAction +{ + public int InvokeCount { get; private set; } + + protected override void Invoke(VisualElement sender) + { + InvokeCount++; + } +} + +public partial class Maui33591 : ContentPage +{ + public Maui33591() + { + InitializeComponent(); + } + + [Collection("Issue")] + public class Tests + { + [Theory] + [InlineData(XamlInflator.Runtime)] + [InlineData(XamlInflator.SourceGen)] + internal void EventTriggerHasEventPropertySet(XamlInflator inflator) + { + var page = new Maui33591(inflator); + + var trigger = page.TestButton!.Triggers.OfType().Single(); + Assert.Equal("Clicked", trigger.Event); + Assert.Single(trigger.Actions); + } + + [Theory] + [InlineData(XamlInflator.Runtime)] + [InlineData(XamlInflator.SourceGen)] + internal void EventTriggerFiresWhenEventOccurs(XamlInflator inflator) + { + var page = new Maui33591(inflator); + + var trigger = page.TestButton!.Triggers.OfType().Single(); + var action = (Maui33591TestTriggerAction)trigger.Actions[0]; + + Assert.Equal(0, action.InvokeCount); + + page.TestButton.SendClicked(); + + Assert.Equal(1, action.InvokeCount); + + page.TestButton.SendClicked(); + + Assert.Equal(2, action.InvokeCount); + } + } +} diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591_ShadowedEvent.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591_ShadowedEvent.xaml new file mode 100644 index 000000000000..9282431dca09 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591_ShadowedEvent.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591_ShadowedEvent.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591_ShadowedEvent.xaml.cs new file mode 100644 index 000000000000..cc6c44c81a33 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui33591_ShadowedEvent.xaml.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using Xunit; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests; + +/// +/// A Button subclass that shadows the base Clicked event with a new one. +/// This tests that EventTrigger source generation correctly picks the +/// most-derived event (matching runtime GetRuntimeEvent behavior). +/// +public class Maui33591DerivedButton : Button +{ + // Shadow the base Button.Clicked event with a new event + public new event EventHandler Clicked; + + public int DerivedClickedCount { get; private set; } + + public void FireDerivedClicked() + { + DerivedClickedCount++; + Clicked?.Invoke(this, EventArgs.Empty); + } +} + +public partial class Maui33591_ShadowedEvent : ContentPage +{ + public Maui33591_ShadowedEvent() + { + InitializeComponent(); + } + + [Collection("Issue")] + public class Tests + { + /// + /// Verifies that EventTrigger on a derived type with a shadowed event + /// correctly subscribes to the derived event (not the base event). + /// All inflators should behave the same way. + /// + [Theory] + [InlineData(XamlInflator.Runtime)] + [InlineData(XamlInflator.SourceGen)] + internal void EventTriggerUsesCorrectShadowedEvent(XamlInflator inflator) + { + var page = new Maui33591_ShadowedEvent(inflator); + + var trigger = page.TestDerivedButton!.Triggers.OfType().Single(); + var action = (Maui33591TestTriggerAction)trigger.Actions[0]; + + Assert.Equal("Clicked", trigger.Event); + Assert.Equal(0, action.InvokeCount); + + // Fire the derived Clicked event (not the base Button.Clicked) + page.TestDerivedButton.FireDerivedClicked(); + + Assert.Equal(1, action.InvokeCount); + } + } +}