diff --git a/sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Services/UIEditorController.cs b/sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Services/UIEditorController.cs index 888ae6c5ec..a7f90c1b87 100644 --- a/sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Services/UIEditorController.cs +++ b/sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Services/UIEditorController.cs @@ -116,6 +116,8 @@ public override async Task CreateScene() return false; rootElements.ForEach(r => RootElements[r.Id] = r); + foreach (var root in rootElements) + DisableDropDownInteraction(root); var resolution = uiDesign.Resolution; var size = resolution / DesignDensity; @@ -209,6 +211,7 @@ public override Task AddPart([NotNull] UIHierarchyItemViewModel parent, UIElemen EnsureAssetAccess(); var gameSidePart = ClonePartForGameSide(parent.Asset.Asset, assetSidePart); + DisableDropDownInteraction(gameSidePart); return InvokeAsync(() => { Logger.Debug($"Adding element {assetSidePart.Id} to game-side scene"); @@ -424,6 +427,23 @@ private UIComponent GetUIComponent() return GetEntityByName(UIEntityName)?.Get(); } + /// + /// Traverses the visual tree rooted at and sets + /// to false on every + /// found, preventing the control from responding to touch input in the editor viewport. + /// + private static void DisableDropDownInteraction(UIElement root) + { + if (root is DropDown dropDown) + dropDown.IsInteractable = false; + + foreach (var child in root.VisualChildren.BreadthFirst(e => e.VisualChildren)) + { + if (child is DropDown childDropDown) + childDropDown.IsInteractable = false; + } + } + /// /// Iterates through the visual tree of and returns the first element matching the . /// diff --git a/sources/engine/Stride.Engine/AssetPackage/Assets/Shared/StrideUILibrary.sduilib b/sources/engine/Stride.Engine/AssetPackage/Assets/Shared/StrideUILibrary.sduilib index e4b2c8bcd0..a0f0957be5 100644 --- a/sources/engine/Stride.Engine/AssetPackage/Assets/Shared/StrideUILibrary.sduilib +++ b/sources/engine/Stride.Engine/AssetPackage/Assets/Shared/StrideUILibrary.sduilib @@ -11,6 +11,7 @@ PublicUIElements: 6a778e64-1d79-44c8-b209-be1779349abb: TextBlock 72d195ac-472a-4f9b-8540-9f4f7d5fc53e: Grid 7d51286a-f844-48de-afe1-cb6b00e2dcb8: ScrollingText + 8fa7caaf-4e9c-4efb-80ad-58f7f61c25e0: DropDown 97a0fc3a-e627-4122-b585-d33a6a7cd785: StackPanel 9b996723-bbd3-44c3-9406-fdc49875d2a2: Canvas a35da784-9d42-4fe0-bd28-3eb547bc2812: ContentDecorator @@ -25,6 +26,7 @@ Hierarchy: RootParts: - !Button ref!! e8985e3a-8d82-40a6-adb1-76619eb19cd8 - !EditText ref!! eef44bd6-bd2d-4abe-8335-0971d5d83b10 + - !DropDown ref!! 8fa7caaf-4e9c-4efb-80ad-58f7f61c25e0 - !Slider ref!! fcd540ef-6f9e-4cab-b24d-ba1533b0fdb9 - !ToggleButton ref!! 1dfd7e9f-ab5a-4849-b6e3-1c50b0974f5f - !Border ref!! 606486b0-61b1-498a-beed-5425893fc384 @@ -120,6 +122,42 @@ Hierarchy: Text: 'Scrolling text! ' Font: c90f3988-0544-4cbe-993f-13af7d9c23c6:StrideDefaultFont TextColor: {R: 240, G: 240, B: 240, A: 255} + - UIElement: !DropDown + Id: 8fa7caaf-4e9c-4efb-80ad-58f7f61c25e0 + DependencyProperties: {} + BackgroundColor: {R: 0, G: 0, B: 0, A: 0} + DrawLayerNumber: 3 + CanBeHitByUser: true + Margin: {} + Name: DropDown + Padding: {} + OpenedImage: !SpriteFromSheet + Sheet: 1be3340c-b797-4557-bff7-d9be92dfdb42:StrideUIDesigns + CurrentFrame: 2 + ClosedImage: !SpriteFromSheet + Sheet: 1be3340c-b797-4557-bff7-d9be92dfdb42:StrideUIDesigns + CurrentFrame: 1 + MouseOverImage: !SpriteFromSheet + Sheet: 1be3340c-b797-4557-bff7-d9be92dfdb42:StrideUIDesigns + Font: c90f3988-0544-4cbe-993f-13af7d9c23c6:StrideDefaultFont + TextSize: 20.0 + TextColor: {R: 240, G: 240, B: 240, A: 255} + ListBackground: !SpriteFromSheet + Sheet: 1be3340c-b797-4557-bff7-d9be92dfdb42:StrideUIDesigns + CurrentFrame: 4 + ListItemNotPressedImage: !SpriteFromSheet + Sheet: 1be3340c-b797-4557-bff7-d9be92dfdb42:StrideUIDesigns + CurrentFrame: 1 + ListItemPressedImage: !SpriteFromSheet + Sheet: 1be3340c-b797-4557-bff7-d9be92dfdb42:StrideUIDesigns + CurrentFrame: 2 + ListItemMouseOverImage: !SpriteFromSheet + Sheet: 1be3340c-b797-4557-bff7-d9be92dfdb42:StrideUIDesigns + ItemTextColor: {R: 240, G: 240, B: 240, A: 255} + ItemTextSize: 20.0 + ScrollBarColor: {R: 25, G: 25, B: 25, A: 255} + PlaceholderText: Select an option + Items: {} - UIElement: !StackPanel Id: 97a0fc3a-e627-4122-b585-d33a6a7cd785 DependencyProperties: {} diff --git a/sources/engine/Stride.UI/Controls/DropDown.cs b/sources/engine/Stride.UI/Controls/DropDown.cs new file mode 100644 index 0000000000..b9f170ea6b --- /dev/null +++ b/sources/engine/Stride.UI/Controls/DropDown.cs @@ -0,0 +1,1017 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System; +using System.ComponentModel; +using System.Diagnostics; +using Stride.Core; +using Stride.Core.Annotations; +using Stride.Core.Collections; +using Stride.Core.Mathematics; +using Stride.Engine; +using Stride.Graphics; +using Stride.UI.Attributes; +using Stride.UI.Events; +using Stride.UI.Panels; + +namespace Stride.UI.Controls +{ + /// + /// Represents a dropdown control that presents a collapsible, scrollable list of selectable items. + /// Clicking the header opens the list; selecting an item closes the list and updates the header to + /// display the current selection. + /// + [DataContract(nameof(DropDown))] + [DataContractMetadataType(typeof(DropDownMetadata))] + [DebuggerDisplay("DropDown - Name={Name}")] + [Display(category: InputCategory)] + public class DropDown : Control + { + private int selectedIndex = -1; + private bool isOpen; + private float maxDropDownHeight = 300f; + private Vector2 listOffset; + private bool isPressed; + private bool sizeToContent = true; + private StretchType imageStretchType = StretchType.Uniform; + private StretchDirection imageStretchDirection = StretchDirection.Both; + private float headerArrangedWidth; + private UIElement dismissTouchRoot; + private EventHandler dismissTouchHandler; + private readonly BackdropElement backdropElement; + + private ISpriteProvider openedImage; + private ISpriteProvider closedImage; + private ISpriteProvider mouseOverImage; + private ISpriteProvider listBackground; + private ISpriteProvider listItemNotPressedImage; + private ISpriteProvider listItemPressedImage; + private ISpriteProvider listItemMouseOverImage; + private Color listItemBackgroundColor = new Color(0.1f, 0.1f, 0.1f, 1f); + + private readonly TrackingCollection items = new TrackingCollection(); + + private Matrix headerContentWorldMatrix; + + private readonly TextBlock headerTextBlock; + private readonly ScrollViewer popupScrollViewer; + private readonly StackPanel itemStackPanel; + + private static readonly PropertyKey HeaderArrangeMatrixPropertyKey = + DependencyPropertyFactory.RegisterAttached(nameof(HeaderArrangeMatrixPropertyKey), typeof(DropDown), Matrix.Identity); + + static DropDown() + { + EventManager.RegisterClassHandler(typeof(DropDown), SelectionChangedEvent, SelectionChangedClassHandler); + EventManager.RegisterClassHandler(typeof(DropDown), DropDownOpenedEvent, DropDownOpenedClassHandler); + EventManager.RegisterClassHandler(typeof(DropDown), DropDownClosedEvent, DropDownClosedClassHandler); + } + + /// + /// Creates a new instance of . + /// + public DropDown() + { + DrawLayerNumber += 2; // header image + list background image + CanBeHitByUser = true; // Warning: this must also match in DropDownMetadata + + itemStackPanel = new StackPanel + { + Orientation = Orientation.Vertical, + Name = "DropDownItemStackPanel", + }; + + popupScrollViewer = new ScrollViewer + { + ScrollMode = ScrollingMode.Vertical, + ClipToBounds = true, + Name = "DropDownPopupScrollViewer", + Visibility = Visibility.Collapsed, + Content = itemStackPanel, + ScrollBarColor = ScrollBarColor, + ScrollBarThickness = ScrollBarThickness, + }; + + headerTextBlock = new TextBlock + { + Name = "DropDownHeaderTextBlock", + TextAlignment = TextAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + }; + + SetVisualParent(headerTextBlock, this); + + backdropElement = new BackdropElement + { + Name = "DropDownBackdrop", + CanBeHitByUser = true, + Visibility = Visibility.Collapsed, + }; + backdropElement.AddHandler(TouchDownEvent, (EventHandler)OnBackdropTouched); + SetVisualParent(backdropElement, this); + + SetVisualParent(popupScrollViewer, this); + + MouseOverStateChanged += (sender, args) => InvalidateHeaderImage(); + items.CollectionChanged += OnItemsCollectionChanged; + } + + // ---- Appearance ---- + + /// + /// Gets or sets the image displayed on the header when the dropdown list is open. + /// + /// Image displayed on the header when the dropdown list is open. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public ISpriteProvider OpenedImage + { + get => openedImage; + set + { + if (openedImage == value) + return; + + openedImage = value; + OnHeaderImageInvalidated(); + } + } + + /// + /// Gets or sets the image displayed on the header when the dropdown list is closed. + /// + /// Image displayed on the header when the dropdown list is closed. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public ISpriteProvider ClosedImage + { + get => closedImage; + set + { + if (closedImage == value) + return; + + closedImage = value; + OnHeaderImageInvalidated(); + } + } + + /// + /// Gets or sets the image displayed on the header when the mouse hovers over it. + /// + /// Image displayed on the header when the mouse hovers over it. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public ISpriteProvider MouseOverImage + { + get => mouseOverImage; + set + { + if (mouseOverImage == value) + return; + + mouseOverImage = value; + OnHeaderImageInvalidated(); + } + } + + /// + /// Gets or sets the color used to tint the header image. Default value is White. + /// + /// The initial image color is multiplied by this color. + /// The color used to tint the header image. The default value is white. + [DataMember] + [Display(category: AppearanceCategory)] + public Color HeaderColor { get; set; } = Color.White; + + /// + /// Gets or sets a value that describes how the header image should be stretched to fill the destination rectangle. + /// + /// This property has no effect if is true. + /// Describes how the header image should be stretched to fill the destination rectangle. + [DataMember] + [Display(category: LayoutCategory)] + [DefaultValue(StretchType.Uniform)] + public StretchType ImageStretchType + { + get => imageStretchType; + set + { + imageStretchType = value; + InvalidateMeasure(); + } + } + + /// + /// Gets or sets a value that indicates how the header image is scaled. + /// + /// This property has no effect if is true. + /// Indicates how the header image is scaled. + [DataMember] + [Display(category: LayoutCategory)] + [DefaultValue(StretchDirection.Both)] + public StretchDirection ImageStretchDirection + { + get => imageStretchDirection; + set + { + imageStretchDirection = value; + InvalidateMeasure(); + } + } + + /// + /// Gets or sets whether the size of the control is driven by its text content. The default is true. + /// + /// True if the control sizes to its text content; false if it sizes to the header image. + [DataMember] + [Display(category: LayoutCategory)] + [DefaultValue(true)] + public bool SizeToContent + { + get => sizeToContent; + set + { + if (sizeToContent == value) + return; + + sizeToContent = value; + headerArrangedWidth = 0f; + InvalidateMeasure(); + } + } + + /// + /// Gets or sets the color of the header text. Default value is White. + /// + /// The color of the header text. The default value is white. + [DataMember] + [Display(category: AppearanceCategory)] + public Color TextColor + { + get => headerTextBlock.TextColor; + set => headerTextBlock.TextColor = value; + } + + /// + /// Gets or sets the font used to render the header text. + /// + /// The font used to render the header text. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public SpriteFont Font + { + get => headerTextBlock.Font; + set => headerTextBlock.Font = value; + } + + /// + /// Gets or sets the size of the header text in virtual pixels. + /// + /// The size of the header text in virtual pixels. + [DataMember] + [DataMemberRange(0.0f, 3)] + [Display(category: AppearanceCategory)] + [DefaultValue(float.NaN)] + public float TextSize + { + get => headerTextBlock.TextSize; + set => headerTextBlock.TextSize = value; + } + + /// + /// Gets or sets the background image of the dropdown list popup. + /// + /// The background image of the dropdown list popup. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public ISpriteProvider ListBackground + { + get => listBackground; + set + { + if (listBackground == value) + return; + + listBackground = value; + OnListImageInvalidated(); + } + } + + /// + /// Gets or sets the color used to tint the list background image. Default value is White. + /// + /// The color used to tint the list background image. The default value is white. + [DataMember] + [Display(category: AppearanceCategory)] + public Color ListColor { get; set; } = Color.White; + + /// + /// Gets or sets the font used to render item text in the dropdown list. + /// When null, falls back to . + /// + /// The font used to render item text in the dropdown list. Falls back to Font when null. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public SpriteFont ItemFont { get; set; } + + /// + /// Gets or sets the color of item text in the dropdown list. Default value is White. + /// + /// The color of item text in the dropdown list. + [DataMember] + [Display(category: AppearanceCategory)] + public Color ItemTextColor { get; set; } = Color.White; + + /// + /// Gets or sets the size of item text in virtual pixels. Uses the font's default size when NaN. + /// + /// The size of item text in virtual pixels. Uses the font's default size when NaN. + [DataMember] + [DataMemberRange(0.0f, 3)] + [Display(category: AppearanceCategory)] + [DefaultValue(float.NaN)] + public float ItemTextSize { get; set; } = float.NaN; + + /// + /// Gets or sets the image displayed on list items in their default (not pressed) state. + /// + /// Image displayed on list items in their default state. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public ISpriteProvider ListItemNotPressedImage + { + get => listItemNotPressedImage; + set + { + if (listItemNotPressedImage == value) + return; + + listItemNotPressedImage = value; + OnListItemImageInvalidated(); + } + } + + /// + /// Gets or sets the image displayed on list items when they are pressed. + /// + /// Image displayed on list items when they are pressed. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public ISpriteProvider ListItemPressedImage + { + get => listItemPressedImage; + set + { + if (listItemPressedImage == value) + return; + + listItemPressedImage = value; + OnListItemImageInvalidated(); + } + } + + /// + /// Gets or sets the image displayed on list items when the mouse hovers over them. + /// + /// Image displayed on list items when the mouse hovers over them. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(null)] + public ISpriteProvider ListItemMouseOverImage + { + get => listItemMouseOverImage; + set + { + if (listItemMouseOverImage == value) + return; + + listItemMouseOverImage = value; + OnListItemImageInvalidated(); + } + } + + /// + /// Gets or sets the color used to tint list item images. Default value is White. + /// + /// The color used to tint list item images. The default value is white. + [DataMember] + [Display(category: AppearanceCategory)] + public Color ListItemColor { get; set; } = Color.White; + + /// + /// Gets or sets the background color drawn behind each list item button. + /// This is visible when no is assigned and provides + /// contrast so items are readable in the editor and in unstyled dropdowns. + /// When a button image is set it renders on top and covers this color. + /// + /// Background color drawn behind each list item. Visible when no item image is assigned. + [DataMember] + [Display(category: AppearanceCategory)] + public Color ListItemBackgroundColor + { + get => listItemBackgroundColor; + set + { + listItemBackgroundColor = value; + if (isOpen) + RebuildItemButtons(); + } + } + + /// + /// Gets or sets the color of the scrollbar inside the dropdown list. + /// + /// Color of the scrollbar inside the dropdown list. + [DataMember] + [Display(category: AppearanceCategory)] + public Color ScrollBarColor + { + get => popupScrollViewer?.ScrollBarColor ?? new Color(0.1f, 0.1f, 0.1f, 1f); + set + { + if (popupScrollViewer != null) + popupScrollViewer.ScrollBarColor = value; + } + } + + /// + /// Gets or sets the thickness of the scrollbar in virtual pixels. + /// + /// The thickness of the scrollbar in virtual pixels. + [DataMember] + [Display(category: AppearanceCategory)] + [DefaultValue(6.0f)] + public float ScrollBarThickness + { + get => popupScrollViewer?.ScrollBarThickness ?? 6.0f; + set + { + if (popupScrollViewer != null) + popupScrollViewer.ScrollBarThickness = value; + } + } + + // ---- Behavior ---- + + /// + /// Gets or sets the text shown in the header when no item is selected. + /// + /// The text shown in the header when no item is selected. + [DataMember] + [Display(category: BehaviorCategory)] + [DefaultValue(null)] + public string PlaceholderText + { + get => placeholderText; + set + { + if (placeholderText == value) + return; + + placeholderText = value; + headerArrangedWidth = 0f; + UpdateHeaderText(); + InvalidateMeasure(); + } + } + private string placeholderText; + + /// + /// Gets or sets the padding applied to each item button in the dropdown list. + /// + /// The padding applied to each item button in the dropdown list. + [DataMember] + [Display(category: BehaviorCategory)] + public Thickness ItemPadding { get; set; } + + /// + /// Gets or sets the maximum height of the dropdown list popup in virtual pixels. + /// When the list content exceeds this height it becomes scrollable. + /// + /// The maximum height of the dropdown list popup in virtual pixels. + [DataMember] + [DataMemberRange(0.0f, 3)] + [Display(category: BehaviorCategory)] + [DefaultValue(300f)] + public float MaxDropDownHeight + { + get => maxDropDownHeight; + set + { + if (float.IsNaN(value)) + return; + + maxDropDownHeight = MathUtil.Clamp(value, 0f, float.MaxValue); + InvalidateMeasure(); + } + } + + /// + /// Gets or sets an offset applied to the dropdown list popup relative to the button. + /// Positive X shifts the list to the right; positive Y shifts it down. + /// + /// An offset applied to the dropdown list popup relative to the button. + [DataMember] + [Display(category: BehaviorCategory)] + public Vector2 ListOffset + { + get => listOffset; + set + { + if (listOffset == value) + return; + + listOffset = value; + InvalidateMeasure(); + } + } + + /// + /// Gets the collection of strings displayed as items in the dropdown list. + /// + /// The items displayed in the dropdown list. + [DataMember] + [MemberCollection(CanReorderItems = true, NotNullItems = true)] + [Display(category: BehaviorCategory)] + public TrackingCollection Items => items; + + /// + /// Gets or sets the zero-based index of the currently selected item. + /// A value of -1 indicates no selection. + /// + /// The zero-based index of the currently selected item. -1 means no selection. + [DataMember] + [Display(category: BehaviorCategory)] + [DefaultValue(-1)] + public int SelectedIndex + { + get => selectedIndex; + set + { + var clamped = (value >= -1 && value < items.Count) ? value : -1; + if (selectedIndex == clamped) + return; + + selectedIndex = clamped; + RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); + } + } + + /// + /// Gets or sets the currently selected item text, or null if no item is selected. + /// Setting this property updates to match the item's position in . + /// + [DataMemberIgnore] + public string SelectedItem + { + get => (selectedIndex >= 0 && selectedIndex < items.Count) ? items[selectedIndex] : null; + set => SelectedIndex = value != null ? items.IndexOf(value) : -1; + } + + /// + /// Gets or sets a value indicating whether the dropdown list is currently visible. + /// + /// True if the dropdown list is currently open, false otherwise. + [DataMemberIgnore] + public bool IsOpen + { + get => isOpen; + set + { + if (isOpen == value) + return; + + isOpen = value; + + if (isOpen) + { + RebuildItemButtons(); + backdropElement.Visibility = Visibility.Visible; + popupScrollViewer.Visibility = Visibility.Visible; + SubscribeToDismissTouchHandler(); + } + else + { + UnsubscribeFromDismissTouchHandler(); + popupScrollViewer.Visibility = Visibility.Collapsed; + backdropElement.Visibility = Visibility.Collapsed; + } + + InvalidateMeasure(); + RaiseEvent(new RoutedEventArgs(isOpen ? DropDownOpenedEvent : DropDownClosedEvent)); + } + } + + /// + /// Gets or sets a value indicating whether this dropdown responds to user input. + /// Set to false by the editor to prevent interaction in the design viewport. + /// + [DataMemberIgnore] + public bool IsInteractable { get; set; } = true; + + // ---- Internal renderer accessors ---- + + /// + /// Gets the appropriate header image provider based on the current open and mouse-over state. + /// + internal ISpriteProvider HeaderImageProvider => + MouseOverState == MouseOverState.MouseOverElement && MouseOverImage != null + ? MouseOverImage + : isOpen ? OpenedImage : ClosedImage; + + /// + /// Gets the resolved header based on the current state. + /// + internal Sprite HeaderImage => HeaderImageProvider?.GetSprite(); + + /// + /// Gets the resolved list background . + /// + internal Sprite ListBackgroundSprite => ListBackground?.GetSprite(); + + /// + /// Gets the popup scroll viewer's world matrix for renderer use. + /// + internal ref Matrix PopupWorldMatrix => ref popupScrollViewer.WorldMatrixInternal; + + /// + /// Gets the popup scroll viewer's render size for renderer use. + /// + internal ref Vector3 PopupRenderSize => ref popupScrollViewer.RenderSizeInternal; + + // ---- Events ---- + + /// + /// Occurs when changes. + /// + /// A SelectionChanged event is bubbling. + public event EventHandler SelectionChanged + { + add { AddHandler(SelectionChangedEvent, value); } + remove { RemoveHandler(SelectionChangedEvent, value); } + } + + /// + /// Identifies the routed event. + /// + public static readonly RoutedEvent SelectionChangedEvent = + EventManager.RegisterRoutedEvent("SelectionChanged", RoutingStrategy.Bubble, typeof(DropDown)); + + /// + /// Occurs when the dropdown list is opened. + /// + /// A DropDownOpened event is bubbling. + public event EventHandler DropDownOpened + { + add { AddHandler(DropDownOpenedEvent, value); } + remove { RemoveHandler(DropDownOpenedEvent, value); } + } + + /// + /// Identifies the routed event. + /// + public static readonly RoutedEvent DropDownOpenedEvent = + EventManager.RegisterRoutedEvent("DropDownOpened", RoutingStrategy.Bubble, typeof(DropDown)); + + /// + /// Occurs when the dropdown list is closed. + /// + /// A DropDownClosed event is bubbling. + public event EventHandler DropDownClosed + { + add { AddHandler(DropDownClosedEvent, value); } + remove { RemoveHandler(DropDownClosedEvent, value); } + } + + /// + /// Identifies the routed event. + /// + public static readonly RoutedEvent DropDownClosedEvent = + EventManager.RegisterRoutedEvent("DropDownClosed", RoutingStrategy.Bubble, typeof(DropDown)); + + // ---- Layout ---- + + /// + protected override Vector3 MeasureOverride(Vector3 availableSizeWithoutMargins) + { + Vector3 headerDesired; + if (sizeToContent) + { + var childAvailable = CalculateSizeWithoutThickness(ref availableSizeWithoutMargins, ref padding); + headerTextBlock.Measure(childAvailable); + var headerDesiredSize = headerTextBlock.DesiredSizeWithMargins; + headerDesired = CalculateSizeWithThickness(ref headerDesiredSize, ref padding); + headerArrangedWidth = Math.Max(headerArrangedWidth, headerDesired.X); + headerDesired.X = headerArrangedWidth; + } + else + { + headerDesired = ImageSizeHelper.CalculateImageSizeFromAvailable(HeaderImage, availableSizeWithoutMargins, imageStretchType, imageStretchDirection, true); + } + + if (isOpen) + { + var popupAvailable = new Vector3(availableSizeWithoutMargins.X, maxDropDownHeight, availableSizeWithoutMargins.Z); + popupScrollViewer.Measure(popupAvailable); + } + + return headerDesired; + } + + /// + protected override Vector3 ArrangeOverride(Vector3 finalSizeWithoutMargins) + { + var arrangeSize = sizeToContent + ? finalSizeWithoutMargins + : ImageSizeHelper.CalculateImageSizeFromAvailable(HeaderImage, finalSizeWithoutMargins, imageStretchType, imageStretchDirection, false); + + var headerChildSize = CalculateSizeWithoutThickness(ref arrangeSize, ref padding); + headerTextBlock.Arrange(headerChildSize, IsCollapsed); + + var headerOffsets = new Vector3(Padding.Left, Padding.Top, Padding.Front) - arrangeSize / 2; + headerTextBlock.DependencyProperties.Set(HeaderArrangeMatrixPropertyKey, Matrix.Translation(headerOffsets)); + + if (isOpen) + { + var popupHeight = MathUtil.Clamp(popupScrollViewer.DesiredSize.Y, 0, maxDropDownHeight); + var popupSize = new Vector3(arrangeSize.X, popupHeight, arrangeSize.Z); + popupScrollViewer.Arrange(popupSize, IsCollapsed); + } + + return arrangeSize; + } + + /// + protected override void UpdateWorldMatrix(ref Matrix parentWorldMatrix, bool parentWorldChanged) + { + var contentMatrixChanged = parentWorldChanged || ArrangeChanged || LocalMatrixChanged; + + base.UpdateWorldMatrix(ref parentWorldMatrix, parentWorldChanged); + + if (contentMatrixChanged) + { + var contentMatrix = headerTextBlock.DependencyProperties.Get(HeaderArrangeMatrixPropertyKey); + Matrix.Multiply(ref contentMatrix, ref WorldMatrixInternal, out headerContentWorldMatrix); + } + ((IUIElementUpdate)headerTextBlock).UpdateWorldMatrix(ref headerContentWorldMatrix, contentMatrixChanged); + + if (isOpen && popupScrollViewer.IsArrangeValid) + { + var popupOffset = Matrix.Translation(-popupScrollViewer.RenderSize.X / 2f + listOffset.X, RenderSize.Y / 2f + listOffset.Y, 0f); + Matrix.Multiply(ref popupOffset, ref WorldMatrixInternal, out var popupMatrix); + ((IUIElementUpdate)popupScrollViewer).UpdateWorldMatrix(ref popupMatrix, true); + } + } + + // ---- Input handling ---- + + /// + protected override void OnTouchDown(TouchEventArgs args) + { + if (!IsInteractable) + return; + base.OnTouchDown(args); + if (ReferenceEquals(args.Source, this)) + isPressed = true; + } + + /// + protected override void OnTouchUp(TouchEventArgs args) + { + if (!IsInteractable) + return; + base.OnTouchUp(args); + + if (isPressed) + IsOpen = !isOpen; + + isPressed = false; + } + + /// + protected override void OnTouchLeave(TouchEventArgs args) + { + if (!IsInteractable) + return; + base.OnTouchLeave(args); + isPressed = false; + } + + // ---- Item management ---- + + private void OnItemsCollectionChanged(object sender, TrackingCollectionChangedEventArgs e) + { + if (selectedIndex >= items.Count) + selectedIndex = items.Count > 0 ? items.Count - 1 : -1; + + UpdateHeaderText(); + + if (isOpen) + RebuildItemButtons(); + } + + private void RebuildItemButtons() + { + itemStackPanel.Children.Clear(); + + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + var index = i; + + UIElement itemContent = new TextBlock + { + Text = item, + Font = ItemFont ?? Font, + TextColor = ItemTextColor, + TextSize = ItemTextSize, + TextAlignment = TextAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + }; + + var itemButton = new Button + { + Content = itemContent, + Padding = ItemPadding, + NotPressedImage = ListItemNotPressedImage, + PressedImage = ListItemPressedImage, + MouseOverImage = ListItemMouseOverImage, + Color = ListItemColor, + BackgroundColor = listItemBackgroundColor, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + itemButton.Click += (s, a) => SelectItemAt(index); + itemStackPanel.Children.Add(itemButton); + } + + if (selectedIndex >= 0) + itemStackPanel.ScrolllToElement(selectedIndex); + } + + private void SelectItemAt(int index) + { + SelectedIndex = index; + IsOpen = false; + } + + private void UpdateHeaderText() + { + if (selectedIndex >= 0 && selectedIndex < items.Count) + headerTextBlock.Text = items[selectedIndex]; + else + headerTextBlock.Text = placeholderText; + } + + private void SubscribeToDismissTouchHandler() + { + UIElement root = this; + while (root.VisualParent != null) + root = root.VisualParent; + + dismissTouchRoot = root; + dismissTouchHandler = OnDismissTouchDown; + dismissTouchRoot.AddHandler(TouchDownEvent, dismissTouchHandler, handledEventsToo: true); + } + + private void UnsubscribeFromDismissTouchHandler() + { + if (dismissTouchRoot == null) + return; + + dismissTouchRoot.RemoveHandler(TouchDownEvent, dismissTouchHandler); + dismissTouchRoot = null; + dismissTouchHandler = null; + } + + private void OnDismissTouchDown(object sender, TouchEventArgs args) + { + var source = args.Source as UIElement; + while (source != null) + { + if (ReferenceEquals(source, this)) + return; + source = source.VisualParent; + } + IsOpen = false; + } + + private void OnBackdropTouched(object sender, TouchEventArgs args) + { + IsOpen = false; + } + + // ---- Protected virtuals ---- + + /// + /// Called when one of the header images (, , + /// or ) is invalidated. + /// This method can be overridden in inherited classes. + /// + protected virtual void OnHeaderImageInvalidated() + { + InvalidateHeaderImage(); + } + + private void InvalidateHeaderImage() + { + if (!sizeToContent) + InvalidateMeasure(); + } + + /// + /// Called when the list background image () is invalidated. + /// This method can be overridden in inherited classes. + /// + protected virtual void OnListImageInvalidated() + { + } + + /// + /// Called when one of the list item images is invalidated. + /// This method can be overridden in inherited classes. + /// + protected virtual void OnListItemImageInvalidated() + { + if (isOpen) + RebuildItemButtons(); + } + + /// + /// The class handler of the event. + /// This method can be overridden in inherited classes to perform actions common to all instances of a class. + /// + /// The arguments of the event. + protected virtual void OnSelectionChanged(RoutedEventArgs args) + { + UpdateHeaderText(); + } + + /// + /// The class handler of the event. + /// This method can be overridden in inherited classes to perform actions common to all instances of a class. + /// + /// The arguments of the event. + protected virtual void OnDropDownOpened(RoutedEventArgs args) + { + } + + /// + /// The class handler of the event. + /// This method can be overridden in inherited classes to perform actions common to all instances of a class. + /// + /// The arguments of the event. + protected virtual void OnDropDownClosed(RoutedEventArgs args) + { + } + + private static void SelectionChangedClassHandler(object sender, RoutedEventArgs args) + { + ((DropDown)sender).OnSelectionChanged(args); + } + + private static void DropDownOpenedClassHandler(object sender, RoutedEventArgs args) + { + ((DropDown)sender).OnDropDownOpened(args); + } + + private static void DropDownClosedClassHandler(object sender, RoutedEventArgs args) + { + ((DropDown)sender).OnDropDownClosed(args); + } + + private sealed class BackdropElement : UIElement + { + protected internal override bool Intersects(ref Ray ray, out Vector3 intersectionPoint) + { + if (LayoutingContext == null) + { + intersectionPoint = Vector3.Zero; + return false; + } + + var resolution = LayoutingContext.VirtualResolution; + var identity = Matrix.Identity; + return CollisionHelper.RayIntersectsRectangle(ref ray, ref identity, ref resolution, 2, out intersectionPoint); + } + } + + private class DropDownMetadata + { + [DefaultThicknessValue(0, 0, 0, 0)] + public Thickness Padding { get; } + + [DefaultValue(true)] + public bool CanBeHitByUser { get; set; } + } + } +} diff --git a/sources/engine/Stride.UI/Renderers/DefaultDropDownRenderer.cs b/sources/engine/Stride.UI/Renderers/DefaultDropDownRenderer.cs new file mode 100644 index 0000000000..d930da2868 --- /dev/null +++ b/sources/engine/Stride.UI/Renderers/DefaultDropDownRenderer.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Stride.Core; +using Stride.Core.Mathematics; +using Stride.UI.Controls; + +namespace Stride.UI.Renderers +{ + /// + /// The default renderer for . + /// + internal class DefaultDropDownRenderer : ElementRenderer + { + public DefaultDropDownRenderer(IServiceRegistry services) + : base(services) + { + } + + public override void RenderColor(UIElement element, UIRenderingContext context) + { + base.RenderColor(element, context); + + var dropDown = (DropDown)element; + + var headerSprite = dropDown.HeaderImage; + if (headerSprite?.Texture != null) + { + var color = element.RenderOpacity * dropDown.HeaderColor; + Batch.DrawImage(headerSprite.Texture, ref element.WorldMatrixInternal, ref headerSprite.RegionInternal, ref element.RenderSizeInternal, ref headerSprite.BordersInternal, ref color, context.DepthBias, headerSprite.Orientation); + } + + context.DepthBias += 1; + + if (!dropDown.IsOpen) + return; + + var listSprite = dropDown.ListBackgroundSprite; + if (listSprite?.Texture != null) + { + var listColor = element.RenderOpacity * dropDown.ListColor; + Batch.DrawImage(listSprite.Texture, ref dropDown.PopupWorldMatrix, ref listSprite.RegionInternal, ref dropDown.PopupRenderSize, ref listSprite.BordersInternal, ref listColor, context.DepthBias, listSprite.Orientation); + } + + context.DepthBias += 1; + } + } +} diff --git a/sources/engine/Stride.UI/Renderers/DefaultRenderersFactory.cs b/sources/engine/Stride.UI/Renderers/DefaultRenderersFactory.cs index 76f44479a9..c1938b7a6e 100644 --- a/sources/engine/Stride.UI/Renderers/DefaultRenderersFactory.cs +++ b/sources/engine/Stride.UI/Renderers/DefaultRenderersFactory.cs @@ -23,6 +23,7 @@ public DefaultRenderersFactory(IServiceRegistry services) defaultRenderer = new ElementRenderer(services); typeToRenderers[typeof(Border)] = new DefaultBorderRenderer(services); typeToRenderers[typeof(Button)] = new DefaultButtonRenderer(services); + typeToRenderers[typeof(DropDown)] = new DefaultDropDownRenderer(services); typeToRenderers[typeof(ContentDecorator)] = new DefaultContentDecoratorRenderer(services); typeToRenderers[typeof(EditText)] = new DefaultEditTextRenderer(services); typeToRenderers[typeof(ImageElement)] = new DefaultImageRenderer(services);