unity

[Unity 3d] UIBlocker - 解决 UGUI 层

2023-12-26  本文已影响0人  雨落随风

前言:

在 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 开关可以切换点击模态窗口外区域后面板自身的行为:关闭或者抖动窗口,使操作更灵活。

结论

开源代码

愿你的编程之路愉快!

上一篇 下一篇

猜你喜欢

热点阅读