实现 P0 UI 管理器
This commit is contained in:
73
My project/Assets/FlowScope/Runtime/UI/UILayer.cs
Normal file
73
My project/Assets/FlowScope/Runtime/UI/UILayer.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FlowScope.UI
|
||||
{
|
||||
internal sealed class UILayer
|
||||
{
|
||||
private readonly Stack<UIPanelRecord> _stack = new();
|
||||
private readonly Dictionary<Type, UIPanelRecord> _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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
My project/Assets/FlowScope/Runtime/UI/UILayer.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/UI/UILayer.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8bf04ebbbc2f4785b5daf63b334d1a6a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
229
My project/Assets/FlowScope/Runtime/UI/UIManager.cs
Normal file
229
My project/Assets/FlowScope/Runtime/UI/UIManager.cs
Normal file
@@ -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<string, UILayer> _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<TPanel> OpenAsync<TPanel, TViewModel>(
|
||||
TViewModel viewModel,
|
||||
CancellationToken cancellationToken)
|
||||
where TPanel : UIPanelBase<TViewModel>
|
||||
{
|
||||
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<TPanel, TViewModel>(cached, layer, viewModel);
|
||||
}
|
||||
|
||||
var path = ResolvePath(typeof(TPanel), attribute);
|
||||
var handle = await _resources.LoadAsync<GameObject>(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<TPanel>();
|
||||
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<TPanel>()
|
||||
{
|
||||
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<TPanel>()
|
||||
{
|
||||
var panelType = typeof(TPanel);
|
||||
foreach (var layer in _layers.Values)
|
||||
{
|
||||
if (layer.ContainsOpen(panelType, out _))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static TPanel ReopenCached<TPanel, TViewModel>(
|
||||
UIPanelRecord record,
|
||||
UILayer layer,
|
||||
TViewModel viewModel)
|
||||
where TPanel : UIPanelBase<TViewModel>
|
||||
{
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
11
My project/Assets/FlowScope/Runtime/UI/UIManager.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/UI/UIManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c83637921703494f8fd6eb6e2eda6c70
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,14 @@ using UnityEngine;
|
||||
|
||||
namespace FlowScope.UI
|
||||
{
|
||||
public abstract class UIPanelBase<TViewModel> : MonoBehaviour
|
||||
internal interface IUIPanel
|
||||
{
|
||||
GameObject gameObject { get; }
|
||||
Transform transform { get; }
|
||||
void Unbind();
|
||||
}
|
||||
|
||||
public abstract class UIPanelBase<TViewModel> : MonoBehaviour, IUIPanel
|
||||
{
|
||||
protected TViewModel ViewModel { get; private set; }
|
||||
protected CompositeDisposable Disposables { get; } = new();
|
||||
|
||||
28
My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs
Normal file
28
My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
11
My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/UI/UIPanelRecord.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a5aee9195e642a1a6f72d4ce5390885
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
My project/Assets/FlowScope/Tests.meta
Normal file
8
My project/Assets/FlowScope/Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aafcdfa86c224eddb0b9ae13ea8553ca
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
My project/Assets/FlowScope/Tests/PlayMode.meta
Normal file
8
My project/Assets/FlowScope/Tests/PlayMode.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f632cf86b7f487fbcf5dbd454f5ec08
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
My project/Assets/FlowScope/Tests/PlayMode/UI.meta
Normal file
8
My project/Assets/FlowScope/Tests/PlayMode/UI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b728c07f84e74a16ad0edf2a549f65f9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
290
My project/Assets/FlowScope/Tests/PlayMode/UI/UIManagerTests.cs
Normal file
290
My project/Assets/FlowScope/Tests/PlayMode/UI/UIManagerTests.cs
Normal file
@@ -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<GameObject> _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<ExplicitPathPanel>("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<ExplicitPathPanel, TestViewModel>(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<ExplicitPathPanel>(), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OpenAsync_WhenPathIsMissing_UsesPanelNameConvention()
|
||||
{
|
||||
var canvas = CreateCanvas("hud");
|
||||
var prefab = CreatePrefab<MainHudPanel>("MainHudPrefab");
|
||||
var resources = new FakeResourceService();
|
||||
resources.Register("UI/MainHud/Prefab", prefab);
|
||||
var manager = new UIManager(resources);
|
||||
manager.RegisterLayer("hud", canvas, 10);
|
||||
|
||||
await manager.OpenAsync<MainHudPanel, TestViewModel>(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<ExplicitPathPanel>("FirstPrefab"));
|
||||
resources.Register("UI/Second/Prefab", CreatePrefab<SecondPanel>("SecondPrefab"));
|
||||
var manager = new UIManager(resources);
|
||||
manager.RegisterLayer("popup", canvas, 0);
|
||||
await manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None);
|
||||
await manager.OpenAsync<SecondPanel, TestViewModel>(new TestViewModel("second"), CancellationToken.None);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => manager.Close<ExplicitPathPanel>());
|
||||
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.True);
|
||||
Assert.That(manager.IsOpen<SecondPanel>(), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Close_WithDestroyStrategy_UnbindsDestroysInstanceAndDisposesHandle()
|
||||
{
|
||||
var canvas = CreateCanvas("popup");
|
||||
var prefab = CreatePrefab<ExplicitPathPanel>("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<ExplicitPathPanel, TestViewModel>(new TestViewModel("first"), CancellationToken.None);
|
||||
var handle = resources.Handles[0];
|
||||
|
||||
manager.Close<ExplicitPathPanel>();
|
||||
|
||||
Assert.That(panel.UnbindCount, Is.EqualTo(1));
|
||||
Assert.That(handle.IsDisposed, Is.True);
|
||||
Assert.That(panel == null, Is.True);
|
||||
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OpenAsync_WithCacheStrategy_ReusesHiddenInstanceAndBindsAgain()
|
||||
{
|
||||
var canvas = CreateCanvas("dialog");
|
||||
var prefab = CreatePrefab<CachedPanel>("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<CachedPanel, TestViewModel>(firstViewModel, CancellationToken.None);
|
||||
manager.Close<CachedPanel>();
|
||||
var second = await manager.OpenAsync<CachedPanel, TestViewModel>(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<ExplicitPathPanel>("PopupPrefab"));
|
||||
resources.Register("UI/MainHud/Prefab", CreatePrefab<MainHudPanel>("HudPrefab"));
|
||||
var manager = new UIManager(resources);
|
||||
manager.RegisterLayer("popup", popupCanvas, 0);
|
||||
manager.RegisterLayer("hud", hudCanvas, 0);
|
||||
await manager.OpenAsync<ExplicitPathPanel, TestViewModel>(new TestViewModel("popup"), CancellationToken.None);
|
||||
await manager.OpenAsync<MainHudPanel, TestViewModel>(new TestViewModel("hud"), CancellationToken.None);
|
||||
|
||||
manager.CloseLayer("popup");
|
||||
|
||||
Assert.That(manager.IsOpen<ExplicitPathPanel>(), Is.False);
|
||||
Assert.That(manager.IsOpen<MainHudPanel>(), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OpenAsync_WhenBindThrows_DisposesHandleAndDestroysCreatedInstance()
|
||||
{
|
||||
var canvas = CreateCanvas("popup");
|
||||
var prefab = CreatePrefab<ThrowingBindPanel>("ThrowingPrefab");
|
||||
var resources = new FakeResourceService();
|
||||
resources.Register("UI/ThrowingBind/Prefab", prefab);
|
||||
var manager = new UIManager(resources);
|
||||
manager.RegisterLayer("popup", canvas, 0);
|
||||
|
||||
Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => manager.OpenAsync<ThrowingBindPanel, TestViewModel>(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<ThrowingBindPanel>(), Is.False);
|
||||
}
|
||||
|
||||
private Canvas CreateCanvas(string name)
|
||||
{
|
||||
var gameObject = new GameObject(name);
|
||||
_objects.Add(gameObject);
|
||||
return gameObject.AddComponent<Canvas>();
|
||||
}
|
||||
|
||||
private GameObject CreatePrefab<TPanel>(string name)
|
||||
where TPanel : Component
|
||||
{
|
||||
var gameObject = new GameObject(name);
|
||||
_objects.Add(gameObject);
|
||||
gameObject.AddComponent<TPanel>();
|
||||
return gameObject;
|
||||
}
|
||||
|
||||
private sealed class FakeResourceService : IResourceService
|
||||
{
|
||||
private readonly Dictionary<string, GameObject> _assets = new();
|
||||
|
||||
public List<string> LoadedKeys { get; } = new();
|
||||
public List<FakeResourceHandle<GameObject>> Handles { get; } = new();
|
||||
|
||||
public void Register(string key, GameObject asset)
|
||||
{
|
||||
_assets.Add(key, asset);
|
||||
}
|
||||
|
||||
public Task<IResourceHandle<T>> LoadAsync<T>(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<GameObject>(key, asset);
|
||||
Handles.Add(handle);
|
||||
return Task.FromResult<IResourceHandle<T>>((IResourceHandle<T>)(object)handle);
|
||||
}
|
||||
|
||||
public IResourceGroup CreateGroup()
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeResourceHandle<T> : IResourceHandle<T>
|
||||
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<TestViewModel>
|
||||
{
|
||||
protected override void OnBind(TestViewModel viewModel)
|
||||
{
|
||||
throw new InvalidOperationException("bind failed");
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class RecordingPanel : UIPanelBase<TestViewModel>
|
||||
{
|
||||
public List<TestViewModel> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4fd5a8a6f9ca4b1fbed6810776ea7fec
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user