SplitButton.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. using System;
  2. using System.Windows.Input;
  3. using Avalonia.Controls.Metadata;
  4. using Avalonia.Controls.Primitives;
  5. using Avalonia.Input;
  6. using Avalonia.Interactivity;
  7. using Avalonia.LogicalTree;
  8. using Avalonia.Reactive;
  9. namespace Avalonia.Controls
  10. {
  11. /// <summary>
  12. /// A button with primary and secondary parts that can each be pressed separately.
  13. /// The primary part behaves like a <see cref="Button"/> and the secondary part opens a flyout.
  14. /// </summary>
  15. [TemplatePart("PART_PrimaryButton", typeof(Button))]
  16. [TemplatePart("PART_SecondaryButton", typeof(Button))]
  17. [PseudoClasses(pcFlyoutOpen, pcPressed)]
  18. public class SplitButton : ContentControl, ICommandSource
  19. {
  20. protected const string pcChecked = ":checked";
  21. protected const string pcPressed = ":pressed";
  22. protected const string pcFlyoutOpen = ":flyout-open";
  23. /// <summary>
  24. /// Raised when the user presses the primary part of the <see cref="SplitButton"/>.
  25. /// </summary>
  26. public event EventHandler<RoutedEventArgs>? Click
  27. {
  28. add => AddHandler(ClickEvent, value);
  29. remove => RemoveHandler(ClickEvent, value);
  30. }
  31. /// <summary>
  32. /// Defines the <see cref="Click"/> event.
  33. /// </summary>
  34. public static readonly RoutedEvent<RoutedEventArgs> ClickEvent =
  35. RoutedEvent.Register<SplitButton, RoutedEventArgs>(
  36. nameof(Click),
  37. RoutingStrategies.Bubble);
  38. /// <summary>
  39. /// Defines the <see cref="Command"/> property.
  40. /// </summary>
  41. public static readonly StyledProperty<ICommand?> CommandProperty =
  42. Button.CommandProperty.AddOwner<SplitButton>();
  43. /// <summary>
  44. /// Defines the <see cref="CommandParameter"/> property.
  45. /// </summary>
  46. public static readonly StyledProperty<object?> CommandParameterProperty =
  47. Button.CommandParameterProperty.AddOwner<SplitButton>();
  48. /// <summary>
  49. /// Defines the <see cref="Flyout"/> property
  50. /// </summary>
  51. public static readonly StyledProperty<FlyoutBase?> FlyoutProperty =
  52. Button.FlyoutProperty.AddOwner<SplitButton>();
  53. private Button? _primaryButton = null;
  54. private Button? _secondaryButton = null;
  55. private bool _commandCanExecute = true;
  56. private bool _isAttachedToLogicalTree = false;
  57. private bool _isFlyoutOpen = false;
  58. private bool _isKeyboardPressed = false;
  59. private IDisposable? _flyoutPropertyChangedDisposable;
  60. /// <summary>
  61. /// Initializes a new instance of the <see cref="SplitButton"/> class.
  62. /// </summary>
  63. public SplitButton()
  64. {
  65. }
  66. /// <summary>
  67. /// Gets or sets the <see cref="ICommand"/> invoked when the primary part is pressed.
  68. /// </summary>
  69. public ICommand? Command
  70. {
  71. get => GetValue(CommandProperty);
  72. set => SetValue(CommandProperty, value);
  73. }
  74. /// <summary>
  75. /// Gets or sets a parameter to be passed to the <see cref="Command"/>.
  76. /// </summary>
  77. public object? CommandParameter
  78. {
  79. get => GetValue(CommandParameterProperty);
  80. set => SetValue(CommandParameterProperty, value);
  81. }
  82. /// <summary>
  83. /// Gets or sets the <see cref="FlyoutBase"/> that is shown when the secondary part is pressed.
  84. /// </summary>
  85. public FlyoutBase? Flyout
  86. {
  87. get => GetValue(FlyoutProperty);
  88. set => SetValue(FlyoutProperty, value);
  89. }
  90. /// <summary>
  91. /// Gets a value indicating whether the button is currently checked.
  92. /// </summary>
  93. /// <remarks>
  94. /// This property exists only for the derived <see cref="ToggleSplitButton"/> and is
  95. /// unused (set to false) within <see cref="SplitButton"/>. Doing this allows the
  96. /// two controls to share a default style.
  97. /// </remarks>
  98. internal virtual bool InternalIsChecked => false;
  99. /// <inheritdoc/>
  100. protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute;
  101. /// <inheritdoc/>
  102. void ICommandSource.CanExecuteChanged(object sender, EventArgs e) => this.CanExecuteChanged(sender, e);
  103. /// <inheritdoc cref="ICommandSource.CanExecuteChanged"/>
  104. private void CanExecuteChanged(object? sender, EventArgs e)
  105. {
  106. var canExecute = Command == null || Command.CanExecute(CommandParameter);
  107. if (canExecute != _commandCanExecute)
  108. {
  109. _commandCanExecute = canExecute;
  110. UpdateIsEffectivelyEnabled();
  111. }
  112. }
  113. /// <summary>
  114. /// Updates the visual state of the control by applying latest PseudoClasses.
  115. /// </summary>
  116. protected void UpdatePseudoClasses()
  117. {
  118. PseudoClasses.Set(pcFlyoutOpen, _isFlyoutOpen);
  119. PseudoClasses.Set(pcPressed, _isKeyboardPressed);
  120. PseudoClasses.Set(pcChecked, InternalIsChecked);
  121. }
  122. /// <summary>
  123. /// Opens the secondary button's flyout.
  124. /// </summary>
  125. protected void OpenFlyout()
  126. {
  127. if (Flyout != null)
  128. {
  129. Flyout.ShowAt(this);
  130. }
  131. }
  132. /// <summary>
  133. /// Closes the secondary button's flyout.
  134. /// </summary>
  135. protected void CloseFlyout()
  136. {
  137. if (Flyout != null)
  138. {
  139. Flyout.Hide();
  140. }
  141. }
  142. /// <summary>
  143. /// Registers all flyout events.
  144. /// </summary>
  145. /// <param name="flyout">The flyout to connect events to.</param>
  146. private void RegisterFlyoutEvents(FlyoutBase? flyout)
  147. {
  148. if (flyout != null)
  149. {
  150. flyout.Opened += Flyout_Opened;
  151. flyout.Closed += Flyout_Closed;
  152. _flyoutPropertyChangedDisposable = flyout.GetPropertyChangedObservable(Popup.PlacementProperty).Subscribe(Flyout_PlacementPropertyChanged);
  153. }
  154. }
  155. /// <summary>
  156. /// Explicitly unregisters all flyout events.
  157. /// </summary>
  158. /// <param name="flyout">The flyout to disconnect events from.</param>
  159. private void UnregisterFlyoutEvents(FlyoutBase? flyout)
  160. {
  161. if (flyout != null)
  162. {
  163. flyout.Opened -= Flyout_Opened;
  164. flyout.Closed -= Flyout_Closed;
  165. _flyoutPropertyChangedDisposable?.Dispose();
  166. _flyoutPropertyChangedDisposable = null;
  167. }
  168. }
  169. /// <summary>
  170. /// Explicitly unregisters all events related to the two buttons in OnApplyTemplate().
  171. /// </summary>
  172. private void UnregisterEvents()
  173. {
  174. if (_primaryButton != null)
  175. {
  176. _primaryButton.Click -= PrimaryButton_Click;
  177. }
  178. if (_secondaryButton != null)
  179. {
  180. _secondaryButton.Click -= SecondaryButton_Click;
  181. }
  182. }
  183. /// <inheritdoc/>
  184. protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
  185. {
  186. base.OnApplyTemplate(e);
  187. UnregisterEvents();
  188. UnregisterFlyoutEvents(Flyout);
  189. _primaryButton = e.NameScope.Find<Button>("PART_PrimaryButton");
  190. _secondaryButton = e.NameScope.Find<Button>("PART_SecondaryButton");
  191. if (_primaryButton != null)
  192. {
  193. _primaryButton.Click += PrimaryButton_Click;
  194. }
  195. if (_secondaryButton != null)
  196. {
  197. _secondaryButton.Click += SecondaryButton_Click;
  198. }
  199. RegisterFlyoutEvents(Flyout);
  200. UpdatePseudoClasses();
  201. }
  202. /// <inheritdoc/>
  203. protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e)
  204. {
  205. base.OnAttachedToLogicalTree(e);
  206. if (Command != null)
  207. {
  208. Command.CanExecuteChanged += CanExecuteChanged;
  209. CanExecuteChanged(this, EventArgs.Empty);
  210. }
  211. _isAttachedToLogicalTree = true;
  212. }
  213. /// <inheritdoc/>
  214. protected override void OnDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e)
  215. {
  216. base.OnDetachedFromLogicalTree(e);
  217. if (Command != null)
  218. {
  219. Command.CanExecuteChanged -= CanExecuteChanged;
  220. }
  221. _isAttachedToLogicalTree = false;
  222. }
  223. /// <inheritdoc/>
  224. protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs e)
  225. {
  226. if (e.Property == CommandProperty)
  227. {
  228. if (_isAttachedToLogicalTree)
  229. {
  230. // Must unregister events here while a reference to the old command still exists
  231. var (oldValue, newValue) = e.GetOldAndNewValue<ICommand?>();
  232. if (oldValue is ICommand oldCommand)
  233. {
  234. oldCommand.CanExecuteChanged -= CanExecuteChanged;
  235. }
  236. if (newValue is ICommand newCommand)
  237. {
  238. newCommand.CanExecuteChanged += CanExecuteChanged;
  239. }
  240. }
  241. CanExecuteChanged(this, EventArgs.Empty);
  242. }
  243. else if (e.Property == CommandParameterProperty)
  244. {
  245. CanExecuteChanged(this, EventArgs.Empty);
  246. }
  247. else if (e.Property == FlyoutProperty)
  248. {
  249. var (oldFlyout, newFlyout) = e.GetOldAndNewValue<FlyoutBase?>();
  250. // If flyout is changed while one is already open, make sure we
  251. // close the old one first
  252. // This is the same behavior as Button
  253. if (oldFlyout != null &&
  254. oldFlyout.IsOpen)
  255. {
  256. oldFlyout.Hide();
  257. }
  258. // Must unregister events here while a reference to the old flyout still exists
  259. UnregisterFlyoutEvents(oldFlyout);
  260. RegisterFlyoutEvents(newFlyout);
  261. UpdatePseudoClasses();
  262. }
  263. base.OnPropertyChanged(e);
  264. }
  265. /// <inheritdoc/>
  266. protected override void OnKeyDown(KeyEventArgs e)
  267. {
  268. var key = e.Key;
  269. if (key == Key.Space || key == Key.Enter) // Key.GamepadA is not currently supported
  270. {
  271. _isKeyboardPressed = true;
  272. UpdatePseudoClasses();
  273. }
  274. base.OnKeyDown(e);
  275. }
  276. /// <inheritdoc/>
  277. protected override void OnKeyUp(KeyEventArgs e)
  278. {
  279. var key = e.Key;
  280. if (key == Key.Space || key == Key.Enter) // Key.GamepadA is not currently supported
  281. {
  282. _isKeyboardPressed = false;
  283. UpdatePseudoClasses();
  284. // Consider this a click on the primary button
  285. if (IsEffectivelyEnabled)
  286. {
  287. OnClickPrimary(null);
  288. e.Handled = true;
  289. }
  290. }
  291. else if (key == Key.Down && e.KeyModifiers.HasAllFlags(KeyModifiers.Alt) && IsEffectivelyEnabled)
  292. {
  293. OpenFlyout();
  294. e.Handled = true;
  295. }
  296. else if (key == Key.F4 && IsEffectivelyEnabled)
  297. {
  298. OpenFlyout();
  299. e.Handled = true;
  300. }
  301. else if (e.Key == Key.Escape && _isFlyoutOpen)
  302. {
  303. // If Flyout doesn't have focusable content, close the flyout here
  304. // This is the same behavior as Button
  305. CloseFlyout();
  306. e.Handled = true;
  307. }
  308. base.OnKeyUp(e);
  309. }
  310. /// <summary>
  311. /// Invokes the <see cref="Click"/> event when the primary button part is clicked.
  312. /// </summary>
  313. /// <param name="e">The event args from the internal Click event.</param>
  314. protected virtual void OnClickPrimary(RoutedEventArgs? e)
  315. {
  316. // Note: It is not currently required to check enabled status; however, this is a failsafe
  317. if (IsEffectivelyEnabled)
  318. {
  319. var eventArgs = new RoutedEventArgs(ClickEvent);
  320. RaiseEvent(eventArgs);
  321. if (!eventArgs.Handled && Command?.CanExecute(CommandParameter) == true)
  322. {
  323. Command.Execute(CommandParameter);
  324. eventArgs.Handled = true;
  325. }
  326. }
  327. }
  328. /// <summary>
  329. /// Invoked when the secondary button part is clicked.
  330. /// </summary>
  331. /// <param name="e">The event args from the internal Click event.</param>
  332. protected virtual void OnClickSecondary(RoutedEventArgs? e)
  333. {
  334. // Note: It is not currently required to check enabled status; however, this is a failsafe
  335. if (IsEffectivelyEnabled)
  336. {
  337. OpenFlyout();
  338. }
  339. }
  340. /// <summary>
  341. /// Invoked when the split button's flyout is opened.
  342. /// </summary>
  343. protected virtual void OnFlyoutOpened()
  344. {
  345. // Available for derived types
  346. }
  347. /// <summary>
  348. /// Invoked when the split button's flyout is closed.
  349. /// </summary>
  350. protected virtual void OnFlyoutClosed()
  351. {
  352. // Available for derived types
  353. }
  354. /// <summary>
  355. /// Event handler for when the internal primary button part is pressed.
  356. /// </summary>
  357. private void PrimaryButton_Click(object? sender, RoutedEventArgs e)
  358. {
  359. // Handle internal button click, so it won't bubble outside together with SplitButton.ClickEvent.
  360. e.Handled = true;
  361. OnClickPrimary(e);
  362. }
  363. /// <summary>
  364. /// Event handler for when the internal secondary button part is pressed.
  365. /// </summary>
  366. private void SecondaryButton_Click(object? sender, RoutedEventArgs e)
  367. {
  368. // Handle internal button click, so it won't bubble outside.
  369. e.Handled = true;
  370. OnClickSecondary(e);
  371. }
  372. /// <summary>
  373. /// Called when the <see cref="PopupFlyoutBase.Placement"/> property changes.
  374. /// </summary>
  375. private void Flyout_PlacementPropertyChanged(AvaloniaPropertyChangedEventArgs e)
  376. {
  377. UpdatePseudoClasses();
  378. }
  379. /// <summary>
  380. /// Event handler for when the split button's flyout is opened.
  381. /// </summary>
  382. private void Flyout_Opened(object? sender, EventArgs e)
  383. {
  384. var flyout = sender as FlyoutBase;
  385. // It is possible to share flyouts among multiple controls including SplitButton.
  386. // This can cause a problem here since all controls that share a flyout receive
  387. // the same Opened/Closed events at the same time.
  388. // For SplitButton that means they all would be updating their pseudoclasses accordingly.
  389. // In other words, all SplitButtons with a shared Flyout would have the backgrounds changed together.
  390. // To fix this, only continue here if the Flyout target matches this SplitButton instance.
  391. if (object.ReferenceEquals(flyout?.Target, this))
  392. {
  393. _isFlyoutOpen = true;
  394. UpdatePseudoClasses();
  395. OnFlyoutOpened();
  396. }
  397. }
  398. /// <summary>
  399. /// Event handler for when the split button's flyout is closed.
  400. /// </summary>
  401. private void Flyout_Closed(object? sender, EventArgs e)
  402. {
  403. var flyout = sender as FlyoutBase;
  404. // See comments in Flyout_Opened
  405. if (object.ReferenceEquals(flyout?.Target, this))
  406. {
  407. _isFlyoutOpen = false;
  408. UpdatePseudoClasses();
  409. OnFlyoutClosed();
  410. }
  411. }
  412. }
  413. }