SHA256
407 lines
13 KiB
C#
407 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using O5Kit.Behaviour;
|
|
using O5Kit.Core;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
using UnityEngine.UI;
|
|
|
|
namespace ADOFAIRenderer.UI;
|
|
|
|
/// <summary>
|
|
/// Definition of a single option within a <see cref="SegmentedControl{T}"/>.
|
|
/// Supports static strings, dynamic providers, or localization keys.
|
|
/// </summary>
|
|
internal class SegmentOption<T>
|
|
{
|
|
public T Value { get; set; }
|
|
public string? LabelKey { get; set; }
|
|
public Func<string>? DynamicLabel { get; set; }
|
|
public string? StaticLabel { get; set; }
|
|
|
|
public SegmentOption(T value, string labelKey)
|
|
{
|
|
Value = value;
|
|
LabelKey = labelKey;
|
|
}
|
|
|
|
public SegmentOption(T value, Func<string> dynamicLabel)
|
|
{
|
|
Value = value;
|
|
DynamicLabel = dynamicLabel;
|
|
}
|
|
|
|
public SegmentOption(T value, string? labelKey = null, string? staticLabel = null)
|
|
{
|
|
Value = value;
|
|
LabelKey = labelKey;
|
|
StaticLabel = staticLabel;
|
|
}
|
|
|
|
public static implicit operator SegmentOption<T>((T value, string labelKey) tuple)
|
|
=> new(tuple.value, tuple.labelKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A generalized rounded segmented button group / picker UI component.
|
|
/// Visual styling, colors, and dimensions are pulled directly from <see cref="O5Theme"/> (matching <see cref="O5Kit.Control.O5Button"/>).
|
|
/// Automatically updates localized labels via <see cref="LocalizedText"/>.
|
|
/// </summary>
|
|
internal class SegmentedControl<T> : O5Object
|
|
{
|
|
private readonly List<SegmentItem> _items = new();
|
|
private T _value;
|
|
private bool _blocked;
|
|
|
|
public T Value => _value;
|
|
public T DefaultValue { get; }
|
|
public IReadOnlyList<SegmentOption<T>> Options { get; }
|
|
public Action<T>? OnChanged { get; set; }
|
|
|
|
/// <summary>Resting background color for unselected segments (defaults to Theme.ObjectButton).</summary>
|
|
public Color NormalColor { get; set; }
|
|
|
|
/// <summary>Resting background color for the selected segment (defaults to Theme.ObjectActive).</summary>
|
|
public Color ActiveColor { get; set; }
|
|
|
|
/// <summary>Border and outline color (defaults to Theme.ControlOutlineIdle).</summary>
|
|
public Color BorderColor { get; set; }
|
|
|
|
private class SegmentItem
|
|
{
|
|
public SegmentOption<T> Option { get; }
|
|
public GameObject RootObject { get; }
|
|
public Image Background { get; }
|
|
public TextMeshProUGUI Label { get; }
|
|
public ITweenHandle? HoverTween { get; set; }
|
|
public bool Hovered { get; set; }
|
|
public bool Pressed { get; set; }
|
|
|
|
public SegmentItem(SegmentOption<T> option, GameObject root, Image bg, TextMeshProUGUI label)
|
|
{
|
|
Option = option;
|
|
RootObject = root;
|
|
Background = bg;
|
|
Label = label;
|
|
}
|
|
|
|
public void Tint(O5Context ctx, Color target, float duration)
|
|
{
|
|
HoverTween?.Kill();
|
|
var bg = Background;
|
|
HoverTween = ctx.Tween.TweenColor(() => bg.color, v => bg.color = v, target, duration);
|
|
}
|
|
}
|
|
|
|
public SegmentedControl(
|
|
O5Context ctx,
|
|
string id,
|
|
RectTransform rect,
|
|
T defaultValue,
|
|
T initialValue,
|
|
IReadOnlyList<SegmentOption<T>> options,
|
|
Action<T>? onChanged = null)
|
|
: base(ctx, id, rect)
|
|
{
|
|
DefaultValue = defaultValue;
|
|
_value = initialValue;
|
|
Options = options;
|
|
OnChanged = onChanged;
|
|
|
|
// Pull colors directly from O5Theme, identical to O5Button / O5Toggle
|
|
NormalColor = Ctx.Theme.ObjectButton;
|
|
ActiveColor = Ctx.Theme.ObjectActive;
|
|
BorderColor = Ctx.Theme.ControlOutlineIdle;
|
|
|
|
BuildSegments();
|
|
UpdateVisual(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates and attaches a new rounded <see cref="SegmentedControl{T}"/> to <paramref name="parent"/>.
|
|
/// Height matches standard buttons and sliders (<see cref="O5Theme.ControlHeightFor"/>).
|
|
/// </summary>
|
|
public static SegmentedControl<T> Create(
|
|
O5Context ctx,
|
|
Transform parent,
|
|
string id,
|
|
T defaultValue,
|
|
T initialValue,
|
|
IReadOnlyList<SegmentOption<T>> options,
|
|
float? height = null,
|
|
Action<T>? onChanged = null)
|
|
{
|
|
var h = height ?? ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody);
|
|
var controlObj = new GameObject($"SegmentedControl_{id}");
|
|
controlObj.transform.SetParent(parent, false);
|
|
|
|
var rect = controlObj.AddComponent<RectTransform>();
|
|
var le = controlObj.AddComponent<LayoutElement>();
|
|
le.minHeight = h;
|
|
le.preferredHeight = h;
|
|
le.flexibleWidth = 1f;
|
|
|
|
return new SegmentedControl<T>(ctx, id, rect, defaultValue, initialValue, options, onChanged);
|
|
}
|
|
|
|
private void BuildSegments()
|
|
{
|
|
var theme = Ctx.Theme;
|
|
|
|
// 1. Masked container for rounded corners
|
|
var maskObj = new GameObject("MaskContainer");
|
|
maskObj.transform.SetParent(Rect, false);
|
|
|
|
var maskRect = maskObj.AddComponent<RectTransform>();
|
|
maskRect.anchorMin = Vector2.zero;
|
|
maskRect.anchorMax = Vector2.one;
|
|
maskRect.offsetMin = Vector2.zero;
|
|
maskRect.offsetMax = Vector2.zero;
|
|
|
|
var maskImg = maskObj.AddComponent<Image>();
|
|
maskImg.sprite = Ctx.Sprites.RoundedControl;
|
|
maskImg.type = Image.Type.Sliced;
|
|
maskImg.color = NormalColor;
|
|
|
|
var mask = maskObj.AddComponent<Mask>();
|
|
mask.showMaskGraphic = true;
|
|
|
|
// Continuous horizontal layout with no middle divider line
|
|
var layout = maskObj.AddComponent<HorizontalLayoutGroup>();
|
|
layout.padding = new RectOffset(0, 0, 0, 0);
|
|
layout.spacing = 0f;
|
|
layout.childControlWidth = true;
|
|
layout.childControlHeight = true;
|
|
layout.childForceExpandWidth = true;
|
|
layout.childForceExpandHeight = true;
|
|
|
|
for (var i = 0; i < Options.Count; i++)
|
|
{
|
|
var opt = Options[i];
|
|
var segmentObj = new GameObject($"Segment_{i}_{opt.Value}");
|
|
segmentObj.transform.SetParent(maskRect, false);
|
|
|
|
var segRect = segmentObj.AddComponent<RectTransform>();
|
|
// ReSharper disable once InconsistentNaming
|
|
var segLE = segmentObj.AddComponent<LayoutElement>();
|
|
segLE.minWidth = 0f;
|
|
segLE.preferredWidth = 0f;
|
|
segLE.flexibleWidth = 1f;
|
|
|
|
var bgImage = segmentObj.AddComponent<Image>();
|
|
bgImage.color = NormalColor;
|
|
bgImage.raycastTarget = true;
|
|
|
|
var textObj = new GameObject("Text");
|
|
textObj.transform.SetParent(segmentObj.transform, false);
|
|
|
|
var textRect = textObj.AddComponent<RectTransform>();
|
|
textRect.anchorMin = Vector2.zero;
|
|
textRect.anchorMax = Vector2.one;
|
|
textRect.offsetMin = Vector2.zero;
|
|
textRect.offsetMax = Vector2.zero;
|
|
|
|
var tmp = textObj.AddComponent<TextMeshProUGUI>();
|
|
tmp.font = Ctx.Fonts.Regular;
|
|
tmp.fontSize = theme.FontSizeBody;
|
|
tmp.alignment = TextAlignmentOptions.Center;
|
|
tmp.characterSpacing = -1f;
|
|
tmp.raycastTarget = false;
|
|
|
|
// Bind localization
|
|
if (!string.IsNullOrEmpty(opt.LabelKey))
|
|
{
|
|
LocalizedText.Bind(tmp, opt.LabelKey);
|
|
}
|
|
else if (opt.DynamicLabel != null)
|
|
{
|
|
LocalizedText.Bind(tmp, opt.DynamicLabel);
|
|
}
|
|
else
|
|
{
|
|
tmp.text = opt.StaticLabel ?? opt.Value?.ToString() ?? string.Empty;
|
|
}
|
|
|
|
var item = new SegmentItem(opt, segmentObj, bgImage, tmp);
|
|
_items.Add(item);
|
|
|
|
// Wire pointer events like O5Button
|
|
var trigger = segmentObj.AddComponent<EventTrigger>();
|
|
UnityUtils.AddEvents(trigger,
|
|
(EventTriggerType.PointerEnter, _ => OnSegmentHoverEnter(item)),
|
|
(EventTriggerType.PointerExit, _ => OnSegmentHoverExit(item)),
|
|
(EventTriggerType.PointerDown, e =>
|
|
{
|
|
if (IsLeftPress(e))
|
|
{
|
|
OnSegmentPressEnter(item);
|
|
}
|
|
}),
|
|
(EventTriggerType.PointerUp, _ => OnSegmentPressExit(item)),
|
|
(EventTriggerType.PointerClick, e =>
|
|
{
|
|
if (IsLeftPress(e))
|
|
{
|
|
SelectOption(item.Option.Value);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
// 2. Rounded outline overlay on top of masked container
|
|
var outlineObj = new GameObject("Outline");
|
|
outlineObj.transform.SetParent(Rect, false);
|
|
|
|
var outlineRect = outlineObj.AddComponent<RectTransform>();
|
|
outlineRect.anchorMin = Vector2.zero;
|
|
outlineRect.anchorMax = Vector2.one;
|
|
outlineRect.offsetMin = Vector2.zero;
|
|
outlineRect.offsetMax = Vector2.zero;
|
|
|
|
var outlineImg = outlineObj.AddComponent<Image>();
|
|
outlineImg.sprite = Ctx.Sprites.RoundedOutline;
|
|
outlineImg.type = Image.Type.Sliced;
|
|
outlineImg.color = BorderColor;
|
|
outlineImg.raycastTarget = false;
|
|
}
|
|
|
|
private static bool IsLeftPress(BaseEventData e) =>
|
|
e is PointerEventData ped && ped.button == PointerEventData.InputButton.Left;
|
|
|
|
private void OnSegmentHoverEnter(SegmentItem item)
|
|
{
|
|
if (IsDisposed || _blocked) return;
|
|
item.Hovered = true;
|
|
if (item.Pressed) return;
|
|
|
|
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
|
|
var targetColor = isSelected ? Ctx.Theme.ObjectActiveBright : Ctx.Theme.ButtonHover;
|
|
item.Tint(Ctx, targetColor, 0.12f);
|
|
}
|
|
|
|
private void OnSegmentHoverExit(SegmentItem item)
|
|
{
|
|
if (IsDisposed) return;
|
|
item.Hovered = false;
|
|
if (item.Pressed) return;
|
|
|
|
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
|
|
var targetColor = isSelected ? ActiveColor : NormalColor;
|
|
item.Tint(Ctx, targetColor, 0.12f);
|
|
}
|
|
|
|
private void OnSegmentPressEnter(SegmentItem item)
|
|
{
|
|
if (IsDisposed || _blocked) return;
|
|
item.Pressed = true;
|
|
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
|
|
var targetColor = isSelected ? Ctx.Theme.ObjectActiveBright : Ctx.Theme.ButtonHover;
|
|
item.Tint(Ctx, targetColor, 0.08f);
|
|
}
|
|
|
|
private void OnSegmentPressExit(SegmentItem item)
|
|
{
|
|
if (IsDisposed || !item.Pressed) return;
|
|
item.Pressed = false;
|
|
|
|
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
|
|
Color settle;
|
|
if (isSelected)
|
|
{
|
|
settle = item.Hovered ? Ctx.Theme.ObjectActiveBright : ActiveColor;
|
|
}
|
|
else
|
|
{
|
|
settle = item.Hovered ? Ctx.Theme.ButtonHover : NormalColor;
|
|
}
|
|
item.Tint(Ctx, settle, 0.12f);
|
|
}
|
|
|
|
public void SelectOption(T newValue, bool notify = true)
|
|
{
|
|
if (IsDisposed) return;
|
|
if (EqualityComparer<T>.Default.Equals(_value, newValue)) return;
|
|
|
|
_value = newValue;
|
|
UpdateVisual();
|
|
|
|
if (notify)
|
|
{
|
|
OnChanged?.Invoke(_value);
|
|
}
|
|
}
|
|
|
|
public void Set(T value, bool notify = true)
|
|
{
|
|
if (IsDisposed) return;
|
|
if (EqualityComparer<T>.Default.Equals(_value, value)) return;
|
|
|
|
_value = value;
|
|
UpdateVisual();
|
|
|
|
if (notify)
|
|
{
|
|
OnChanged?.Invoke(_value);
|
|
}
|
|
}
|
|
|
|
public void Reset(bool notify = true)
|
|
{
|
|
Set(DefaultValue, notify);
|
|
}
|
|
|
|
public override void SetBlocked(bool blocked, bool noAnimate = false)
|
|
{
|
|
_blocked = blocked;
|
|
base.SetBlocked(blocked, noAnimate);
|
|
}
|
|
|
|
public void UpdateVisual(bool noAnimate = false)
|
|
{
|
|
if (IsDisposed) return;
|
|
|
|
foreach (var item in _items)
|
|
{
|
|
item.Pressed = false;
|
|
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
|
|
Color targetBg;
|
|
if (isSelected)
|
|
{
|
|
targetBg = item.Hovered ? Ctx.Theme.ObjectActiveBright : ActiveColor;
|
|
item.Label.color = Ctx.Theme.Text;
|
|
item.Label.fontStyle = FontStyles.Bold;
|
|
}
|
|
else
|
|
{
|
|
targetBg = item.Hovered ? Ctx.Theme.ButtonHover : NormalColor;
|
|
item.Label.color = Ctx.Theme.TextDim;
|
|
item.Label.fontStyle = FontStyles.Normal;
|
|
}
|
|
|
|
if (noAnimate)
|
|
{
|
|
item.HoverTween?.Kill();
|
|
item.Background.color = targetBg;
|
|
}
|
|
else
|
|
{
|
|
item.Tint(Ctx, targetBg, 0.15f);
|
|
}
|
|
}
|
|
}
|
|
|
|
public override void Dispose()
|
|
{
|
|
if (IsDisposed) return;
|
|
|
|
foreach (var item in _items)
|
|
{
|
|
item.HoverTween?.Kill();
|
|
item.HoverTween = null;
|
|
}
|
|
OnChanged = null;
|
|
base.Dispose();
|
|
}
|
|
}
|