diff --git a/My project/Assets/FlowScope/Runtime/UI/UILayer.cs b/My project/Assets/FlowScope/Runtime/UI/UILayer.cs new file mode 100644 index 0000000..ea2e6af --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UILayer.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace FlowScope.UI +{ + internal sealed class UILayer + { + private readonly Stack _stack = new(); + private readonly Dictionary _cachedPanels = new(); + + public UILayer(string name, Canvas canvas, int sortOrder, PanelStrategy strategy) + { + Name = name; + Canvas = canvas; + Strategy = strategy; + Canvas.sortingOrder = sortOrder; + } + + public string Name { get; } + public Canvas Canvas { get; } + public PanelStrategy Strategy { get; } + public int Count => _stack.Count; + + public void Push(UIPanelRecord record) + { + record.IsOpen = true; + _stack.Push(record); + } + + public UIPanelRecord Peek() + { + return _stack.Peek(); + } + + public UIPanelRecord Pop() + { + var record = _stack.Pop(); + record.IsOpen = false; + return record; + } + + public bool TryGetCached(Type panelType, out UIPanelRecord record) + { + return _cachedPanels.TryGetValue(panelType, out record); + } + + public void Cache(UIPanelRecord record) + { + _cachedPanels[record.PanelType] = record; + } + + public void RemoveCached(Type panelType) + { + _cachedPanels.Remove(panelType); + } + + public bool ContainsOpen(Type panelType, out UIPanelRecord record) + { + foreach (var current in _stack) + { + if (current.PanelType == panelType) + { + record = current; + return true; + } + } + + record = null; + return false; + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/UI/UILayer.cs.meta b/My project/Assets/FlowScope/Runtime/UI/UILayer.cs.meta new file mode 100644 index 0000000..3780179 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UILayer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8bf04ebbbc2f4785b5daf63b334d1a6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/UI/UIManager.cs b/My project/Assets/FlowScope/Runtime/UI/UIManager.cs new file mode 100644 index 0000000..4078f5e --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UIManager.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Resources; +using UnityEngine; + +namespace FlowScope.UI +{ + public sealed class UIManager + { + private readonly IResourceService _resources; + private readonly Dictionary _layers = new(); + + public UIManager(IResourceService resources) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + } + + public void RegisterLayer( + string name, + Canvas canvas, + int sortOrder, + PanelStrategy strategy = PanelStrategy.Destroy) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Layer name is required.", nameof(name)); + } + + if (canvas == null) + { + throw new ArgumentNullException(nameof(canvas)); + } + + _layers[name] = new UILayer(name, canvas, sortOrder, strategy); + } + + public async Task OpenAsync( + TViewModel viewModel, + CancellationToken cancellationToken) + where TPanel : UIPanelBase + { + cancellationToken.ThrowIfCancellationRequested(); + + var attribute = GetPanelAttribute(typeof(TPanel)); + if (!_layers.TryGetValue(attribute.Layer, out var layer)) + { + throw new InvalidOperationException($"UI layer is not registered: {attribute.Layer}"); + } + + var strategy = attribute.OverrideStrategy ?? layer.Strategy; + if (strategy == PanelStrategy.Cache && + layer.TryGetCached(typeof(TPanel), out var cached) && + !cached.IsOpen) + { + return ReopenCached(cached, layer, viewModel); + } + + var path = ResolvePath(typeof(TPanel), attribute); + var handle = await _resources.LoadAsync(path, cancellationToken); + if (handle == null || handle.Asset == null) + { + handle?.Dispose(); + throw new InvalidOperationException($"UI panel resource is missing: {path}"); + } + + GameObject instance = null; + try + { + instance = UnityEngine.Object.Instantiate(handle.Asset, layer.Canvas.transform, false); + var panel = instance.GetComponent(); + if (panel == null) + { + throw new InvalidOperationException( + $"UI panel prefab '{path}' does not contain component {typeof(TPanel).Name}."); + } + + panel.Bind(viewModel); + instance.SetActive(true); + + var record = new UIPanelRecord(typeof(TPanel), layer.Name, panel, handle, strategy); + if (strategy == PanelStrategy.Cache) + { + layer.Cache(record); + } + + layer.Push(record); + return panel; + } + catch + { + if (instance != null) + { + UnityEngine.Object.DestroyImmediate(instance); + } + + handle.Dispose(); + throw; + } + } + + public void Close() + { + var panelType = typeof(TPanel); + foreach (var layer in _layers.Values) + { + if (!layer.ContainsOpen(panelType, out var record)) + { + continue; + } + + if (!ReferenceEquals(layer.Peek(), record)) + { + throw new InvalidOperationException( + $"Cannot close non-top UI panel: {panelType.Name}"); + } + + CloseTop(layer); + return; + } + } + + public void CloseLayer(string layerName) + { + if (!_layers.TryGetValue(layerName, out var layer)) + { + throw new InvalidOperationException($"UI layer is not registered: {layerName}"); + } + + while (layer.Count > 0) + { + CloseTop(layer); + } + } + + public void CloseAll() + { + foreach (var layer in _layers.Values) + { + while (layer.Count > 0) + { + CloseTop(layer); + } + } + } + + public bool IsOpen() + { + var panelType = typeof(TPanel); + foreach (var layer in _layers.Values) + { + if (layer.ContainsOpen(panelType, out _)) + { + return true; + } + } + + return false; + } + + private static TPanel ReopenCached( + UIPanelRecord record, + UILayer layer, + TViewModel viewModel) + where TPanel : UIPanelBase + { + var panel = (TPanel)record.Panel; + panel.Bind(viewModel); + panel.gameObject.SetActive(true); + layer.Push(record); + return panel; + } + + private static void CloseTop(UILayer layer) + { + var record = layer.Pop(); + record.Panel.Unbind(); + + if (record.Strategy == PanelStrategy.Cache) + { + record.Panel.gameObject.SetActive(false); + return; + } + + layer.RemoveCached(record.PanelType); + UnityEngine.Object.DestroyImmediate(record.Panel.gameObject); + record.Handle.Dispose(); + } + + private static UIPanelAttribute GetPanelAttribute(Type panelType) + { + var attribute = (UIPanelAttribute)Attribute.GetCustomAttribute( + panelType, + typeof(UIPanelAttribute)); + + if (attribute == null) + { + throw new InvalidOperationException( + $"UI panel must declare UIPanelAttribute: {panelType.Name}"); + } + + if (string.IsNullOrWhiteSpace(attribute.Layer)) + { + throw new InvalidOperationException( + $"UI panel layer is required: {panelType.Name}"); + } + + return attribute; + } + + private static string ResolvePath(Type panelType, UIPanelAttribute attribute) + { + if (!string.IsNullOrWhiteSpace(attribute.Path)) + { + return attribute.Path; + } + + var name = panelType.Name; + const string suffix = "Panel"; + if (name.EndsWith(suffix, StringComparison.Ordinal)) + { + name = name.Substring(0, name.Length - suffix.Length); + } + + return $"UI/{name}/Prefab"; + } + } +} diff --git a/My project/Assets/FlowScope/Runtime/UI/UIManager.cs.meta b/My project/Assets/FlowScope/Runtime/UI/UIManager.cs.meta new file mode 100644 index 0000000..1ae0cc5 --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UIManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c83637921703494f8fd6eb6e2eda6c70 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Runtime/UI/UIPanelAttribute.cs b/My project/Assets/FlowScope/Runtime/UI/UIPanelAttribute.cs index 633e1d8..bee8408 100644 --- a/My project/Assets/FlowScope/Runtime/UI/UIPanelAttribute.cs +++ b/My project/Assets/FlowScope/Runtime/UI/UIPanelAttribute.cs @@ -5,10 +5,30 @@ namespace FlowScope.UI [AttributeUsage(AttributeTargets.Class)] public sealed class UIPanelAttribute : Attribute { - public UIPanelAttribute( + public UIPanelAttribute(string layer) + : this(layer, null, null) + { + } + + public UIPanelAttribute(string layer, string path) + : this(layer, path, null) + { + } + + public UIPanelAttribute(string layer, PanelStrategy overrideStrategy) + : this(layer, null, overrideStrategy) + { + } + + public UIPanelAttribute(string layer, string path, PanelStrategy overrideStrategy) + : this(layer, path, (PanelStrategy?)overrideStrategy) + { + } + + private UIPanelAttribute( string layer, - string path = null, - PanelStrategy? overrideStrategy = null) + string path, + PanelStrategy? overrideStrategy) { Layer = layer; Path = path; diff --git a/My project/Assets/FlowScope/Runtime/UI/UIPanelBase.cs b/My project/Assets/FlowScope/Runtime/UI/UIPanelBase.cs index 91febab..e4cec66 100644 --- a/My project/Assets/FlowScope/Runtime/UI/UIPanelBase.cs +++ b/My project/Assets/FlowScope/Runtime/UI/UIPanelBase.cs @@ -3,7 +3,14 @@ using UnityEngine; namespace FlowScope.UI { - public abstract class UIPanelBase : MonoBehaviour + internal interface IUIPanel + { + GameObject gameObject { get; } + Transform transform { get; } + void Unbind(); + } + + public abstract class UIPanelBase : MonoBehaviour, IUIPanel { protected TViewModel ViewModel { get; private set; } protected CompositeDisposable Disposables { get; } = new(); diff --git a/My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs b/My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs new file mode 100644 index 0000000..ddc9dcf --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs @@ -0,0 +1,28 @@ +using System; + +namespace FlowScope.UI +{ + internal sealed class UIPanelRecord + { + public UIPanelRecord( + Type panelType, + string layerName, + IUIPanel panel, + IDisposable handle, + PanelStrategy strategy) + { + PanelType = panelType; + LayerName = layerName; + Panel = panel; + Handle = handle; + Strategy = strategy; + } + + public Type PanelType { get; } + public string LayerName { get; } + public IUIPanel Panel { get; } + public IDisposable Handle { get; } + public PanelStrategy Strategy { get; } + public bool IsOpen { get; set; } + } +} diff --git a/My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs.meta b/My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs.meta new file mode 100644 index 0000000..45adfff --- /dev/null +++ b/My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a5aee9195e642a1a6f72d4ce5390885 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests.meta b/My project/Assets/FlowScope/Tests.meta new file mode 100644 index 0000000..b85aba0 --- /dev/null +++ b/My project/Assets/FlowScope/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aafcdfa86c224eddb0b9ae13ea8553ca +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode.meta b/My project/Assets/FlowScope/Tests/PlayMode.meta new file mode 100644 index 0000000..48daf3b --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5f632cf86b7f487fbcf5dbd454f5ec08 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI.meta b/My project/Assets/FlowScope/Tests/PlayMode/UI.meta new file mode 100644 index 0000000..bbac3e1 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b728c07f84e74a16ad0edf2a549f65f9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI/UIManagerTests.cs b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIManagerTests.cs new file mode 100644 index 0000000..a28e0b8 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIManagerTests.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Resources; +using FlowScope.UI; +using NUnit.Framework; +using UnityEngine; + +namespace FlowScope.Tests.PlayMode.UI +{ + public sealed class UIManagerTests + { + private readonly List _objects = new(); + + [TearDown] + public void TearDown() + { + foreach (var obj in _objects) + { + if (obj != null) + { + UnityEngine.Object.DestroyImmediate(obj); + } + } + + _objects.Clear(); + } + + [Test] + public async Task OpenAsync_UsesAttributePathAndMountsPanelToRegisteredLayer() + { + var canvas = CreateCanvas("popup"); + var prefab = CreatePrefab("ExplicitPathPrefab"); + var resources = new FakeResourceService(); + resources.Register("Custom/Explicit", prefab); + var manager = new UIManager(resources); + manager.RegisterLayer("popup", canvas, 120); + var viewModel = new TestViewModel("first"); + + var panel = await manager.OpenAsync(viewModel, CancellationToken.None); + + Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "Custom/Explicit" })); + Assert.That(panel.transform.parent, Is.EqualTo(canvas.transform)); + Assert.That(canvas.sortingOrder, Is.EqualTo(120)); + Assert.That(panel.BoundModels, Is.EqualTo(new[] { viewModel })); + Assert.That(panel.gameObject.activeSelf, Is.True); + Assert.That(manager.IsOpen(), Is.True); + } + + [Test] + public async Task OpenAsync_WhenPathIsMissing_UsesPanelNameConvention() + { + var canvas = CreateCanvas("hud"); + var prefab = CreatePrefab("MainHudPrefab"); + var resources = new FakeResourceService(); + resources.Register("UI/MainHud/Prefab", prefab); + var manager = new UIManager(resources); + manager.RegisterLayer("hud", canvas, 10); + + await manager.OpenAsync(new TestViewModel("hud"), CancellationToken.None); + + Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "UI/MainHud/Prefab" })); + } + + [Test] + public async Task Close_WhenPanelIsNotLayerTop_ThrowsInvalidOperationException() + { + var canvas = CreateCanvas("popup"); + var resources = new FakeResourceService(); + resources.Register("Custom/Explicit", CreatePrefab("FirstPrefab")); + resources.Register("UI/Second/Prefab", CreatePrefab("SecondPrefab")); + var manager = new UIManager(resources); + manager.RegisterLayer("popup", canvas, 0); + await manager.OpenAsync(new TestViewModel("first"), CancellationToken.None); + await manager.OpenAsync(new TestViewModel("second"), CancellationToken.None); + + Assert.Throws(() => manager.Close()); + Assert.That(manager.IsOpen(), Is.True); + Assert.That(manager.IsOpen(), Is.True); + } + + [Test] + public async Task Close_WithDestroyStrategy_UnbindsDestroysInstanceAndDisposesHandle() + { + var canvas = CreateCanvas("popup"); + var prefab = CreatePrefab("ExplicitPathPrefab"); + var resources = new FakeResourceService(); + resources.Register("Custom/Explicit", prefab); + var manager = new UIManager(resources); + manager.RegisterLayer("popup", canvas, 0, PanelStrategy.Destroy); + var panel = await manager.OpenAsync(new TestViewModel("first"), CancellationToken.None); + var handle = resources.Handles[0]; + + manager.Close(); + + Assert.That(panel.UnbindCount, Is.EqualTo(1)); + Assert.That(handle.IsDisposed, Is.True); + Assert.That(panel == null, Is.True); + Assert.That(manager.IsOpen(), Is.False); + } + + [Test] + public async Task OpenAsync_WithCacheStrategy_ReusesHiddenInstanceAndBindsAgain() + { + var canvas = CreateCanvas("dialog"); + var prefab = CreatePrefab("CachedPrefab"); + var resources = new FakeResourceService(); + resources.Register("UI/Cached/Prefab", prefab); + var manager = new UIManager(resources); + manager.RegisterLayer("dialog", canvas, 0, PanelStrategy.Destroy); + var firstViewModel = new TestViewModel("first"); + var secondViewModel = new TestViewModel("second"); + + var first = await manager.OpenAsync(firstViewModel, CancellationToken.None); + manager.Close(); + var second = await manager.OpenAsync(secondViewModel, CancellationToken.None); + + Assert.That(second, Is.SameAs(first)); + Assert.That(second.gameObject.activeSelf, Is.True); + Assert.That(second.BoundModels, Is.EqualTo(new[] { firstViewModel, secondViewModel })); + Assert.That(second.UnbindCount, Is.EqualTo(1)); + Assert.That(resources.LoadedKeys, Is.EqualTo(new[] { "UI/Cached/Prefab" })); + Assert.That(resources.Handles[0].IsDisposed, Is.False); + } + + [Test] + public async Task CloseLayer_OnlyClosesRequestedLayer() + { + var popupCanvas = CreateCanvas("popup"); + var hudCanvas = CreateCanvas("hud"); + var resources = new FakeResourceService(); + resources.Register("Custom/Explicit", CreatePrefab("PopupPrefab")); + resources.Register("UI/MainHud/Prefab", CreatePrefab("HudPrefab")); + var manager = new UIManager(resources); + manager.RegisterLayer("popup", popupCanvas, 0); + manager.RegisterLayer("hud", hudCanvas, 0); + await manager.OpenAsync(new TestViewModel("popup"), CancellationToken.None); + await manager.OpenAsync(new TestViewModel("hud"), CancellationToken.None); + + manager.CloseLayer("popup"); + + Assert.That(manager.IsOpen(), Is.False); + Assert.That(manager.IsOpen(), Is.True); + } + + [Test] + public async Task OpenAsync_WhenBindThrows_DisposesHandleAndDestroysCreatedInstance() + { + var canvas = CreateCanvas("popup"); + var prefab = CreatePrefab("ThrowingPrefab"); + var resources = new FakeResourceService(); + resources.Register("UI/ThrowingBind/Prefab", prefab); + var manager = new UIManager(resources); + manager.RegisterLayer("popup", canvas, 0); + + Assert.ThrowsAsync( + () => manager.OpenAsync(new TestViewModel("boom"), CancellationToken.None)); + + Assert.That(resources.Handles[0].IsDisposed, Is.True); + Assert.That(canvas.transform.childCount, Is.EqualTo(0)); + Assert.That(manager.IsOpen(), Is.False); + } + + private Canvas CreateCanvas(string name) + { + var gameObject = new GameObject(name); + _objects.Add(gameObject); + return gameObject.AddComponent(); + } + + private GameObject CreatePrefab(string name) + where TPanel : Component + { + var gameObject = new GameObject(name); + _objects.Add(gameObject); + gameObject.AddComponent(); + return gameObject; + } + + private sealed class FakeResourceService : IResourceService + { + private readonly Dictionary _assets = new(); + + public List LoadedKeys { get; } = new(); + public List> Handles { get; } = new(); + + public void Register(string key, GameObject asset) + { + _assets.Add(key, asset); + } + + public Task> LoadAsync(string key, CancellationToken cancellationToken) + where T : class + { + cancellationToken.ThrowIfCancellationRequested(); + LoadedKeys.Add(key); + + if (!_assets.TryGetValue(key, out var asset)) + { + throw new InvalidOperationException($"Missing resource: {key}"); + } + + var handle = new FakeResourceHandle(key, asset); + Handles.Add(handle); + return Task.FromResult>((IResourceHandle)(object)handle); + } + + public IResourceGroup CreateGroup() + { + throw new NotSupportedException(); + } + } + + private sealed class FakeResourceHandle : IResourceHandle + where T : class + { + public FakeResourceHandle(string key, T asset) + { + Key = key; + Asset = asset; + } + + public string Key { get; } + public T Asset { get; private set; } + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + } + } + + public sealed class TestViewModel + { + public TestViewModel(string name) + { + Name = name; + } + + public string Name { get; } + } + + [UIPanel("popup", "Custom/Explicit")] + public sealed class ExplicitPathPanel : RecordingPanel + { + } + + [UIPanel("hud")] + public sealed class MainHudPanel : RecordingPanel + { + } + + [UIPanel("popup")] + public sealed class SecondPanel : RecordingPanel + { + } + + [UIPanel("dialog", overrideStrategy: PanelStrategy.Cache)] + public sealed class CachedPanel : RecordingPanel + { + } + + [UIPanel("popup")] + public sealed class ThrowingBindPanel : UIPanelBase + { + protected override void OnBind(TestViewModel viewModel) + { + throw new InvalidOperationException("bind failed"); + } + } + + public abstract class RecordingPanel : UIPanelBase + { + public List BoundModels { get; } = new(); + public int UnbindCount { get; private set; } + + public override void Unbind() + { + UnbindCount++; + base.Unbind(); + } + + protected override void OnBind(TestViewModel viewModel) + { + BoundModels.Add(viewModel); + } + } +} diff --git a/My project/Assets/FlowScope/Tests/PlayMode/UI/UIManagerTests.cs.meta b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIManagerTests.cs.meta new file mode 100644 index 0000000..7f8bed5 --- /dev/null +++ b/My project/Assets/FlowScope/Tests/PlayMode/UI/UIManagerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4fd5a8a6f9ca4b1fbed6810776ea7fec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: