Add Segmented Control UI for Output Mode Option

This commit is contained in:
copecone committed 2026-10-22 11:00:05 +09:00
1 parent 5efe1d0dd2
commit 4179ed6696
6 files changed
+684 -72

No files matched your search

+405
View File
@@ -0,0 +1,405 @@
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>();
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(false);
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(false);
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();
}
}