合并 P0 UI 管理器
# Conflicts: # My project/Assets/FlowScope/Tests.meta # My project/Assets/FlowScope/Tests/PlayMode.meta
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)]
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
public sealed class UIPanelAttribute : Attribute
|
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 layer,
|
||||||
string path = null,
|
string path,
|
||||||
PanelStrategy? overrideStrategy = null)
|
PanelStrategy? overrideStrategy)
|
||||||
{
|
{
|
||||||
Layer = layer;
|
Layer = layer;
|
||||||
Path = path;
|
Path = path;
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace FlowScope.UI
|
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 TViewModel ViewModel { get; private set; }
|
||||||
protected CompositeDisposable Disposables { get; } = new();
|
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/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