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