[Unity 3d] UIBlocker - 解决 UGUI 层级问题的终极利器

前言:

在 Unity UGUI 中,处理 UI 层级管理时,我们想让一些 UI 元素浮在最上面,但是又不想破坏其他 UI 元素的层级关系,当如果不做点什么,我们往往陷入困境。

举个实例:我们有一个表格,表格上方是数据筛选器,筛选器里面有一个日期选择器,我们想让日期选择器显示时覆盖表格的内容,但是又不想改变把日期选择器拖到这个面板的最底层,因为那样会影响我们的 UI 布局。这该如何实现呢? 这便是本文要解决的痛点!


被 SheetContent 遮蔽的只剩一角的 Calender

启发:

看到 Unity 的 Dropdown 组件,我发现了一个妙招。Dropdown 运行时生成一个名为 "Blocker" 的组件,自动占据当前层级,无需手动调整。这启发了我,我们也可以创建类似的 "Blocker" 组件,来管理我们的 UI 元素。

设计:

基于 Dropdown 的启发,我设计了通用的 "Blocker" 组件。它不仅仅可以阻止鼠标事件,还能自动管理 UI 层级。比如,无论面板在那个层级下,都可以自动将此面板置顶显示,并且点击面板外区域时支持隐藏面板,避免误触其他组件,或者抖动面板,模拟模态面板独占并强提示的行为。

此外,该组件在运行时会自动充满当前图层,无需手动调整。支持多个 "Blocker" 组件的嵌套使用,我还添加了一个 IBlockable 接口,让继承了 IBlockable 的面板能够响应 Blocker 的点击。

最后,Blocker 应该可以淡入淡出,和修改配色。

实现:

一个通用的 "Blocker" 组件的实现的原理简单来说,就是巧妙的使用了 Canvas 组件的 sortingOrder 实现了 新增 Blocker 以及 Root Canvas 和 继承了 IBlockable 的面板三者的渲染先后关系,这样一来这个面板唤起时就会自动创建 Blocker 把自己展示到最顶层而不用关心自己位于 UI 层级树的哪个层级了!

Blocker 核心实现

        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 来解决层级问题(使用 Task + async/await语法实现异步):

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);
            //  启用 Blocker 组件:使用黑色、0.8f 半透、0.3秒渐显动画、延迟等待下一个动画 0.1秒后执行
            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));
            // 收起面板
            _ = transform.DoScaleAsync(Vector3.one * 0.01f, 0.5f, Ease.InBack);
            // 延迟收起 Blocker ,避免面板瞬间消失
            await this.UnblockAsync(0.5f);
            gameObject.SetActive(false);
            cts?.Dispose();
            return index;
        }

        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 层级,提升用户操作体验,避免常见的 必须改变层级才能改变渲染先后关系的问题。
  • 模态窗口的实现从未如此简单!
  • 希望这篇博客能为你解决问题提供帮助。欢迎在评论区分享你的问题或建议。

开源代码

愿你的编程之路愉快!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容