[Unity 3d] UIBlocker - 解决 UGUI 层
前言:
在 Unity UGUI 中,处理 UI 层级管理时,我们想让一些 UI 元素浮在最上面,但是又不想破坏其他 UI 元素的层级关系,当如果不做点什么,我们往往陷入困境。
举个实例:我们有一个表格,表格上方是数据筛选器,筛选器里面有一个日期选择器,我们想让日期选择器显示时覆盖表格的内容,但是又不想改变把日期选择器拖到这个面板的最底层,因为那样会影响我们的 UI 布局。这该如何实现呢? 这便是本文要解决的痛点!
被 SheetContent 遮蔽的只剩一角的 Calender
启发:
看到 Unity 的 Dropdown 组件,我发现了一个妙招。Dropdown 运行时生成一个名为 "Blocker" 的组件,自动占据当前层级,无需手动调整。这启发了我,我们也可以创建类似的 "Blocker" 组件,来管理我们的 UI 元素。
设计:
通用 Blocker 组件的构想:
基于 Dropdown 的启发,我设计了通用的 "Blocker" 组件。它不仅仅可以阻止鼠标事件,还能自动管理 UI 层级。比如,无论面板在那个层级下,都可以自动将此面板置顶显示,并且点击面板外区域时支持隐藏面板,避免误触其他组件,或者抖动面板,模拟模态面板独占并强提示的行为。
此外,该组件在运行时会自动充满当前图层,无需手动调整。支持多个 "Blocker" 组件的嵌套使用,我还添加了一个 IBlockable 接口,让继承了 IBlockable 的面板能够响应 Blocker 的点击。
最后,Blocker 应该可以淡入淡出,和修改配色。
实现:
一个通用的 "Blocker" 组件的实现的原理简单来说,就是巧妙的使用了 Canvas 组件的 sortingOrder 实现了 新增 Blocker 以及 Root Canvas 和 继承了 IBlockable 的面板三者的渲染先后关系,这样一来这个面板唤起时就会自动创建 Blocker 把自己展示到最顶层而不用关系自己位于 UI 层级树的哪个层级了!
public Blocker(IBlockable target, Color color)
{
this.target = target;
blockers.Add(target, this);
// check target wether its UI or not
var go = target as MonoBehaviour;
var rect = go.GetComponent<RectTransform>();
if (!go || !rect)
{
throw new Exception("target must be a UI component");
}
// should not blocked before
innercanvas = go.GetComponent<Canvas>();
if (innercanvas && innercanvas.enabled)
{
throw new Exception("target should not be blocked before");
}
// get target's root canvas
rootCanvas = go.GetComponentsInParent<Canvas>()
.Where(c => c.isRootCanvas)
.FirstOrDefault();
if (!rootCanvas)
{
throw new Exception("target must be in a canvas");
}
// 1. Create blocker GameObject.
blocker = new GameObject("Blocker", typeof(RectTransform));
// 2. Set blocker's RectTransform properties.
var rectTransform = blocker.GetComponent<RectTransform>();
rectTransform.SetParent(rootCanvas.transform, false);
rectTransform.SetAsLastSibling();
rectTransform.anchorMin = Vector3.zero;
rectTransform.anchorMax = Vector3.one;
rectTransform.sizeDelta = Vector2.zero;
// 3. Add Canvas component.
Canvas canvas = blocker.AddComponent<Canvas>();
blocker.AddComponent<GraphicRaycaster>();
canvas.overrideSorting = true;
// 4. Add Canvas component for target panel.
innercanvas = go.gameObject.AddComponent<Canvas>();
innercanvas.overrideSorting = true;
innercanvas.sortingOrder = 25000 + blockers.Count;
raycaster = go.gameObject.AddComponent<GraphicRaycaster>();
// 5. Set the sorting layer of blocker's Canvas to be Lower just one unit than the target panel's Canvas.
canvas.sortingLayerID = innercanvas.sortingLayerID;
canvas.sortingOrder = innercanvas.sortingOrder - 1;
background = blocker.AddComponent<Image>();
color.a = 0f;
background.color = color;
button = blocker.AddComponent<Button>();
button.onClick.AddListener(target.HandleBlockClickedAsync);
blocker.hideFlags = HideFlags.HideInHierarchy;
}
示例:
以下是一个使用了 Blocker 组件的模态窗口 (NotificationPanel)示例代码,它展示了如何优雅的通过 Blcoker 来解决层级问题:
NotificationPanel
using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using zFramework.Ex;
using zFramework.UI;
namespace zFramework.Example
{
public class NotificationPanel : MonoBehaviour, IBlockable
{
public Text title;
public Text content;
public Button confirmButton;
public Button cancelButton;
public Toggle toggle;
private CancellationTokenSource cts;
private void Start() => toggle.onValueChanged.AddListener((value) => closeByBlock = value);
public bool closeByBlock = false;
public bool useBlocker = true;
public async Task<int> ShowAsync(string title, string content)
{
cts = new CancellationTokenSource();
this.title.text = title;
this.content.text = content;
// reset panel
transform.localScale = Vector3.one * 0.1f;
gameObject.SetActive(true);
// must blocker first, other wise you may click the other button before the panel fadein
// delay 0.1f means wait for panel show about 0.1f then blocker start fadein
// You set the block fade-in duration to 0.3f and delay to 0.1f, so the blocker will appear along with the panel suddenly.
if (useBlocker) await this.BlockAsync(Color.black, 0.8f, 0.3f, 0.1f);
await transform.DoScaleAsync(Vector3.one, 0.5f, Ease.OutBack);
var index = await TaskExtension.WhenAny(confirmButton.OnClickAsync(cts.Token), cancelButton.OnClickAsync(cts.Token));
// if you want blocker fadeout along with panel , you should use "_= " to make them run in parallel
_ = transform.DoScaleAsync(Vector3.one * 0.01f, 0.5f, Ease.InBack);
//If the panel fadeout duration is less than that of the blocker, the blocker will fade out first and then the panel will suddenly become inactive.
// so that blocker fadeout duration should same to panel fadeout duration
await this.UnblockAsync(0.5f);
gameObject.SetActive(false);
cts?.Dispose();
return index; // result should never be wait
}
public async void HandleBlockClickedAsync()
{
if (closeByBlock)
{
cts?.Cancel();
}
else
{
await transform.DoShackPositionAsync(0.3f, Vector3.one * 20);
}
}
}
}
PanelManager : 简单的 panel 唤起演示脚本
using System;
using UnityEngine;
using UnityEngine.UI;
using zFramework.Example;
public class PanelController : MonoBehaviour
{
public Button button;
public Button button2;
public NotificationPanel panel;
public NotificationPanel panel2;
private void Start()
{
button.onClick.AddListener(OnClick);
button2.onClick.AddListener(OnClick2);
}
// Open a panel which is not blocked by blocker
private async void OnClick2()
{
if (!panel2.gameObject.activeSelf)
{
var title = "Panel without a Blocker";
var content = "This panel will be overlaid by other UI as it does not use a blocker!";
var idx = await panel2.ShowAsync(title, content);
Debug.Log("user selected : " +idx + (idx == 0 ? "确定" : (idx == -1 ? "用户取消操作" : "取消")));
}
}
// Open a panel which is blocked by blocker
public async void OnClick()
{
if (!panel.gameObject.activeSelf)
{
var title = "Panel with a Blocker";
var content = "This panel will be rendered on the top layer!";
var idx = await panel.ShowAsync(title, content);
Debug.Log("user selected : " + idx + (idx == 0 ? "确定" : (idx == -1 ? "用户取消操作" : "取消")));
}
}
}
这个 Panel 示例脚本充分利用了 Blocker 组件。点击 "Open Panel" 按钮,模态窗口无论在哪个层级都将置顶显示,其他 UI 元素被阻挡。点击 "Close Panel" 按钮或模态窗口外区域,即可关闭模态窗口。
没有使用 Blocker | 使用了Blocker |
---|---|
这个层级下的面板完全被遮挡 | 实现了面板不 care 层级永远置顶 |
值得一提的是,NotificationPanel 模态窗口具备一项有趣特性:通过面板右下角 toggle 开关可以切换点击模态窗口外区域后面板自身的行为:关闭或者抖动窗口,使操作更灵活。
结论
- 利用 "Blocker" 组件,我们轻松管理 Unity UGUI 的 UI 层级,提升用户操作体验,避免常见的 必须改变层级才能改变渲染先后关系的问题
- 希望这篇博客能为你解决问题提供帮助。欢迎在评论区分享你的问题或建议。
开源代码
- https://github.com/Bian-Sh/UGUI-Blocker - 本文配套实现的开源仓库地址
愿你的编程之路愉快!