diff --git a/examples/demo/Assets/Resources/Theme.uss b/examples/demo/Assets/Resources/Theme.uss index ba6b937c..22922518 100644 --- a/examples/demo/Assets/Resources/Theme.uss +++ b/examples/demo/Assets/Resources/Theme.uss @@ -493,6 +493,39 @@ color: #34A853; } +.language-dropdown-icon { + font-size: 18px; + color: var(--os-grey-600); + margin-left: 4px; +} + +.dialog-overlay.dropdown-overlay { + background-color: rgba(0, 0, 0, 0); + padding-top: 0; +} + +.dialog-container.dropdown-menu { + position: absolute; + width: 240px; + margin: 0; + padding: 8px 0; + border-radius: 8px; + border-width: 1px; + border-color: var(--os-card-border); +} + +.dropdown-item { + height: 48px; + padding: 0 16px; + -unity-text-align: middle-left; + font-size: 16px; +} + +.dropdown-item--selected { + color: var(--os-primary); + -unity-font-style: bold; +} + .guidance-banner { background-color: var(--os-warning-background); } diff --git a/examples/demo/Assets/Scripts/Services/PreferencesService.cs b/examples/demo/Assets/Scripts/Services/PreferencesService.cs index bf77bfc4..dbf7409d 100644 --- a/examples/demo/Assets/Scripts/Services/PreferencesService.cs +++ b/examples/demo/Assets/Scripts/Services/PreferencesService.cs @@ -9,6 +9,7 @@ public class PreferencesService private const string KeyExternalUserId = "external_user_id"; private const string KeyLocationShared = "location_shared"; private const string KeyIamPaused = "iam_paused"; + private const string KeyLanguage = "language"; public bool ConsentRequired { @@ -40,6 +41,12 @@ public bool IamPaused set => SetBool(KeyIamPaused, value); } + public string Language + { + get => GetString(KeyLanguage, ""); + set => SetString(KeyLanguage, value); + } + private bool GetBool(string key, bool defaultValue) => PlayerPrefs.GetInt(key, defaultValue ? 1 : 0) == 1; diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/DialogBase.cs b/examples/demo/Assets/Scripts/UI/Dialogs/DialogBase.cs index fb64a938..34fa3f28 100644 --- a/examples/demo/Assets/Scripts/UI/Dialogs/DialogBase.cs +++ b/examples/demo/Assets/Scripts/UI/Dialogs/DialogBase.cs @@ -22,6 +22,8 @@ public abstract class DialogBase protected VisualElement Container { get; private set; } private VisualElement _parent; + protected VisualElement Parent => _parent; + public void Show(VisualElement parent) { if (parent?.Q(className: "dialog-overlay") != null) diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/DropdownMenuDialog.cs b/examples/demo/Assets/Scripts/UI/Dialogs/DropdownMenuDialog.cs new file mode 100644 index 00000000..d48580c8 --- /dev/null +++ b/examples/demo/Assets/Scripts/UI/Dialogs/DropdownMenuDialog.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using OneSignalDemo.Services; +using UnityEngine; +using UnityEngine.UIElements; + +namespace OneSignalDemo.UI.Dialogs +{ + /// + /// Menu anchored under the element that opened it, dismissed by tapping anywhere outside it. + /// + public class DropdownMenuDialog : DialogBase + { + private const float MenuMaxHeight = 320f; + private const float ScreenMargin = 16f; + private const float ItemHeight = 48f; + private const float ListPadding = 8f; + private const float MouseDragThreshold = 8f; + + private readonly VisualElement _anchor; + private readonly IReadOnlyList> _options; + private readonly string _selectedValue; + private readonly string _namePrefix; + private readonly Action _onSelected; + + /// Display label to value pairs. + public DropdownMenuDialog( + VisualElement anchor, + IReadOnlyList> options, + string selectedValue, + string namePrefix, + Action onSelected + ) + { + _anchor = anchor; + _options = options; + _selectedValue = selectedValue ?? ""; + _namePrefix = namePrefix; + _onSelected = onSelected; + } + + protected override void BuildContent(VisualElement container) + { + Overlay.AddToClassList("dropdown-overlay"); + container.AddToClassList("dropdown-menu"); + + var list = new ScrollView(ScrollViewMode.Vertical); + list.name = $"{_namePrefix}_list"; + list.style.maxHeight = MenuMaxHeight - ListPadding * 2; + + VisualElement selectedItem = null; + foreach (var option in _options) + { + var value = option.Value; + var item = new Label(option.Key); + item.name = $"{_namePrefix}_{(value.Length == 0 ? "default" : value)}"; + item.AddToClassList("dropdown-item"); + item.AddToClassList("text-card-label"); + if (value == _selectedValue) + { + item.AddToClassList("dropdown-item--selected"); + selectedItem = item; + } + + void Select() => OnSelected(value); + item.AddManipulator(new Clickable(Select)); + AccessibilityBridge.RegisterE2ETapTarget(item, () => true, Select); + list.Add(item); + } + container.Add(list); + EnableMouseDragScroll(list); + + if (selectedItem != null) + CenterOnceLaidOut(list, selectedItem); + + Position(container); + } + + private void Position(VisualElement container) + { + var bounds = Parent.worldBound; + var anchor = _anchor.worldBound; + float anchorTop = anchor.yMin - bounds.yMin; + float anchorBottom = anchor.yMax - bounds.yMin; + float menuHeight = Mathf.Min( + MenuMaxHeight, + _options.Count * ItemHeight + ListPadding * 2 + ); + + container.style.right = Mathf.Max(ScreenMargin, bounds.xMax - anchor.xMax); + if (anchorBottom + menuHeight > bounds.height - ScreenMargin) + container.style.bottom = bounds.height - anchorTop; + else + container.style.top = anchorBottom; + } + + private static void CenterOnceLaidOut(ScrollView list, VisualElement item) + { + void Center(GeometryChangedEvent _) + { + list.UnregisterCallback(Center); + float viewport = list.contentViewport.layout.height; + float maxOffset = Mathf.Max(0, list.contentContainer.layout.height - viewport); + float offset = item.layout.y - (viewport - item.layout.height) / 2; + list.scrollOffset = new Vector2(0, Mathf.Clamp(offset, 0, maxOffset)); + } + list.RegisterCallback(Center); + } + + // ScrollView only drag-scrolls touch and pen, and Android emulators report clicks as mouse. + private static void EnableMouseDragScroll(ScrollView list) + { + int pointerId = PointerId.invalidPointerId; + float startY = 0; + float startOffset = 0; + bool dragging = false; + + void Reset() + { + pointerId = PointerId.invalidPointerId; + dragging = false; + } + + list.RegisterCallback( + e => + { + if (e.pointerType != UnityEngine.UIElements.PointerType.mouse || e.button != 0) + return; + pointerId = e.pointerId; + startY = e.position.y; + startOffset = list.scrollOffset.y; + dragging = false; + }, + TrickleDown.TrickleDown + ); + list.RegisterCallback( + e => + { + if (e.pointerId != pointerId) + return; + float delta = e.position.y - startY; + if (!dragging) + { + if (Mathf.Abs(delta) < MouseDragThreshold) + return; + dragging = true; + // Taking capture from the item's Clickable cancels its pending selection. + list.CapturePointer(pointerId); + } + float maxOffset = Mathf.Max( + 0, + list.contentContainer.layout.height - list.contentViewport.layout.height + ); + list.scrollOffset = new Vector2( + 0, + Mathf.Clamp(startOffset - delta, 0, maxOffset) + ); + e.StopPropagation(); + }, + TrickleDown.TrickleDown + ); + list.RegisterCallback( + e => + { + if (e.pointerId != pointerId) + return; + if (dragging) + { + list.ReleasePointer(pointerId); + e.StopPropagation(); + } + Reset(); + }, + TrickleDown.TrickleDown + ); + list.RegisterCallback(e => + { + if (e.target == list) + Reset(); + }); + } + + private void OnSelected(string value) + { + Dismiss(); + _onSelected?.Invoke(value); + } + } +} diff --git a/examples/demo/Assets/Scripts/UI/Dialogs/DropdownMenuDialog.cs.meta b/examples/demo/Assets/Scripts/UI/Dialogs/DropdownMenuDialog.cs.meta new file mode 100644 index 00000000..c6d9e76b --- /dev/null +++ b/examples/demo/Assets/Scripts/UI/Dialogs/DropdownMenuDialog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e571aa67f2c84f85b3910f12b251117f diff --git a/examples/demo/Assets/Scripts/UI/MaterialIcons.cs b/examples/demo/Assets/Scripts/UI/MaterialIcons.cs index ff4bfc36..7186b5e2 100644 --- a/examples/demo/Assets/Scripts/UI/MaterialIcons.cs +++ b/examples/demo/Assets/Scripts/UI/MaterialIcons.cs @@ -16,5 +16,6 @@ public static class MaterialIcons public const string Fullscreen = "\uE5D0"; public const string ExpandMore = "\uE5CF"; public const string ExpandLess = "\uE5CE"; + public const string Check = "\uE5CA"; } } diff --git a/examples/demo/Assets/Scripts/UI/Sections/PushSectionController.cs b/examples/demo/Assets/Scripts/UI/Sections/PushSectionController.cs index d9f314d0..7f1b031f 100644 --- a/examples/demo/Assets/Scripts/UI/Sections/PushSectionController.cs +++ b/examples/demo/Assets/Scripts/UI/Sections/PushSectionController.cs @@ -10,6 +10,7 @@ public class PushSectionController private readonly AppViewModel _viewModel; private readonly VisualElement _root; private Label _pushIdLabel; + private Label _statusLabel; private SwitchToggle _enabledToggle; private Button _promptButton; @@ -43,8 +44,19 @@ private VisualElement BuildSection() card.Add(SectionBuilder.CreateDivider()); + var statusRow = SectionBuilder.CreateInlineKeyValue( + "Status", + FormatStatus(_viewModel.IsPushSubscribed) + ); + // The shared E2E ID names the status text itself, not the row. + _statusLabel = statusRow.Q