Cocos Creator 弹窗交互:实现“点击空白关闭”与“按钮切换”
从节点结构到代码实现一篇搞定 Cocos Creator 中的弹窗遮罩层方案一、背景在游戏和应用的 UI 开发中弹窗是一个非常常见的交互组件。最近在 Cocos Creator 项目中遇到这样一个需求点击按钮弹出一个筛选弹窗除了再次点击按钮可以关闭外点击弹窗外的任何空白区域也要能关闭弹窗。这个需求看起来简单但在 Cocos Creator 中实现时有几个关键问题需要考虑如何定义“空白区域”如何避免点击弹窗内部内容时误关闭如何保证弹窗在不同分辨率下都能正常显示本文将分享一套完整的解决方案——基于遮罩层的点击关闭机制并提供一个可复用的弹窗组件。二、为什么需要遮罩层很多初学者的第一反应是给整个场景添加点击监听判断点击的节点是否是弹窗本身。// ❌ 这种思路有问题this.node.on(Node.EventType.TOUCH_END,(event){// 如何判断点击的不是弹窗内容// 很容易误判});这种方法有几个痛点难以准确判断点击目标是否是弹窗内部需要为大量 UI 元素单独添加监听代码耦合度高不利于维护正确的思路创建一个全屏遮罩层只有点击遮罩层才关闭弹窗点击弹窗主体则不影响。三、解决方案设计3.1 核心原理遮罩层Mask一个全屏的透明/半透明节点位于弹窗内容下方事件冒泡控制弹窗内容节点阻止事件冒泡防止点击内容时触发遮罩层统一关闭逻辑遮罩层点击和按钮关闭都调用同一个关闭方法3.2 节点结构设计Canvas (根节点) ├── MainUI (主界面) │ └── OpenBtn (打开弹窗按钮) └── PopupRoot (弹窗根节点 - 动态添加) └── Mask (遮罩层 - 全屏可点击关闭) └── Panel (弹窗面板 - 阻止冒泡) ├── CloseBtn (关闭按钮) └── Content (弹窗内容)四、具体实现步骤4.1 创建弹窗预制体Prefab首先创建一个弹窗预制体包含以下结构步骤 1创建遮罩层节点在层级管理器中创建一个空节点作为弹窗根节点命名为PopupMask添加UITransform组件设置宽高为全屏可以后续通过代码动态设置添加Sprite组件设置颜色为半透明黑色例如rgba(0,0,0,0.5)添加Button组件用于接收点击事件添加BlockInputEvents组件防止事件穿透关于全屏适配为了让遮罩层在所有分辨率下都能完全覆盖屏幕可以通过代码动态获取屏幕尺寸来设置节点大小。步骤 2创建弹窗面板节点在PopupMask下创建一个空节点命名为PopupPanel设置锚点为中心(0.5, 0.5)位置为(0, 0)添加背景图或 Sprite 组件添加Button组件用于阻止事件冒泡步骤 3创建关闭按钮在PopupPanel下创建一个按钮节点命名为CloseBtn用于手动关闭弹窗。最终的节点结构如下图所示PopupMask (全屏遮罩) ├── PopupPanel (弹窗内容面板) ├── CloseBtn (关闭按钮) └── Content (你的弹窗内容)4.2 编写弹窗组件脚本创建PopupBase.ts脚本作为所有弹窗的基类// PopupBase.tsimport{_decorator,Component,Node,Button,UITransform,view,EventHandler,director}fromcc;const{ccclass,property}_decorator;ccclass(PopupBase)exportclassPopupBaseextendsComponent{property({tooltip:是否允许点击遮罩层关闭})closeOnMask:booleantrue;property({tooltip:是否在关闭时销毁节点})destroyOnClose:booleantrue;privatemaskNode:Nodenull;privatepanelNode:Nodenull;privatecloseCallback:Functionnull;onLoad(){// 获取遮罩层节点弹窗根节点this.maskNodethis.node;// 获取弹窗面板节点this.panelNodethis.node.getChildByName(PopupPanel);// 设置遮罩层全屏this.setMaskFullScreen();// 绑定遮罩层点击事件if(this.closeOnMask){this.bindMaskClick();}// 绑定关闭按钮事件this.bindCloseButton();// 阻止面板上的事件冒泡到遮罩层this.blockPanelEvent();}/** * 设置遮罩层全屏 */privatesetMaskFullScreen(){constuiTransformthis.maskNode.getComponent(UITransform);if(uiTransform){constsizeview.getVisibleSize();uiTransform.setContentSize(size.width,size.height);}}/** * 绑定遮罩层点击事件 */privatebindMaskClick(){constmaskButtonthis.maskNode.getComponent(Button);if(maskButton){maskButton.node.on(Button.EventType.CLICK,this.onMaskClick,this);}}/** * 遮罩层点击处理 */privateonMaskClick(){this.close();}/** * 绑定关闭按钮事件 */privatebindCloseButton(){if(!this.panelNode)return;constcloseBtnthis.panelNode.getChildByName(CloseBtn);if(closeBtn){constbtncloseBtn.getComponent(Button);if(btn){btn.node.on(Button.EventType.CLICK,this.onCloseBtnClick,this);}}}/** * 关闭按钮点击处理 */privateonCloseBtnClick(){this.close();}/** * 阻止面板上的事件冒泡到遮罩层 * 这是实现点击弹窗内容不关闭的关键 */privateblockPanelEvent(){if(!this.panelNode)return;// 为面板及其所有子节点添加触摸吞噬this.blockNodeEvent(this.panelNode);}/** * 递归阻止节点的事件冒泡 */privateblockNodeEvent(node:Node){// 为节点添加 BlockInputEvents 组件if(!node.getComponent(BlockInputEvents)){node.addComponent(BlockInputEvents);}// 递归处理子节点node.children.forEach(child{this.blockNodeEvent(child);});}/** * 打开弹窗 * param callback 关闭时的回调函数 */publicopen(callback?:Function){this.closeCallbackcallback;this.node.activetrue;this.onOpen();}/** * 关闭弹窗 */publicclose(){this.node.activefalse;if(this.closeCallback){this.closeCallback();}this.onClose();if(this.destroyOnClose){this.node.destroy();}}/** * 弹窗打开时的钩子函数子类可重写 */protectedonOpen(){}/** * 弹窗关闭时的钩子函数子类可重写 */protectedonClose(){}}4.3 创建弹窗管理器可选为了更好地管理多个弹窗可以创建一个弹窗管理器// PopupManager.tsimport{_decorator,Component,Node,Prefab,instantiate,director}fromcc;const{ccclass,property}_decorator;ccclass(PopupManager)exportclassPopupManagerextendsComponent{privatestaticinstance:PopupManagernull;// 弹窗根节点privatepopupRoot:Nodenull;// 弹窗缓存privatepopupCache:Mapstring,NodenewMap();staticgetInstance():PopupManager{returnthis.instance;}onLoad(){PopupManager.instancethis;this.initPopupRoot();}/** * 初始化弹窗根节点 */privateinitPopupRoot(){this.popupRootnewNode(PopupRoot);director.getScene().addChild(this.popupRoot);// 确保弹窗在最上层this.popupRoot.setSiblingIndex(this.popupRoot.parent.children.length-1);}/** * 显示弹窗 * param prefab 弹窗预制体 * param callback 关闭回调 * returns 弹窗节点 */publicshowPopup(prefab:Prefab,callback?:Function):Node{letpopupNode:Node;// 从缓存获取或实例化新弹窗constprefabNameprefab.name;if(this.popupCache.has(prefabName)){popupNodethis.popupCache.get(prefabName);popupNode.activetrue;}else{popupNodeinstantiate(prefab);this.popupCache.set(prefabName,popupNode);}// 添加到弹窗根节点this.popupRoot.addChild(popupNode);// 获取弹窗组件并打开constpopupComppopupNode.getComponent(PopupBase);if(popupComp){popupComp.open(callback);}returnpopupNode;}/** * 关闭所有弹窗 */publiccloseAllPopups(){this.popupRoot.children.forEach(child{constpopupCompchild.getComponent(PopupBase);if(popupComp){popupComp.close();}});}}4.4 使用示例创建具体的弹窗组件// FilterPopup.tsimport{_decorator,Label,EditBox}fromcc;import{PopupBase}from./PopupBase;const{ccclass,property}_decorator;ccclass(FilterPopup)exportclassFilterPopupextendsPopupBase{property(Label)titleLabel:Labelnull;property(EditBox)dateInput:EditBoxnull;privateonConfirmCallback:Functionnull;/** * 设置弹窗数据 */publicsetData(title:string,confirmCallback:Function){if(this.titleLabel){this.titleLabel.stringtitle;}this.onConfirmCallbackconfirmCallback;}/** * 确认按钮点击 */publiconConfirmClick(){constdateValuethis.dateInput?this.dateInput.string:;if(this.onConfirmCallback){this.onConfirmCallback(dateValue);}this.close();}protectedonOpen(){console.log(弹窗已打开);}protectedonClose(){console.log(弹窗已关闭);}}在场景中使用// GameScene.tsimport{_decorator,Component,Button,Prefab}fromcc;import{PopupManager}from./PopupManager;import{FilterPopup}from./FilterPopup;const{ccclass,property}_decorator;ccclass(GameScene)exportclassGameSceneextendsComponent{property(Prefab)filterPopupPrefab:Prefabnull;property(Button)openBtn:Buttonnull;start(){// 绑定打开弹窗按钮事件if(this.openBtn){this.openBtn.node.on(Button.EventType.CLICK,this.onOpenBtnClick,this);}}privateonOpenBtnClick(){// 通过弹窗管理器显示弹窗constpopupNodePopupManager.Instance.showPopup(this.filterPopupPrefab,(){console.log(弹窗已关闭);});// 设置弹窗数据constpopupComppopupNode.getComponent(FilterPopup);if(popupComp){popupComp.setData(筛选条件,(date){console.log(选择的日期:,date);// 执行筛选逻辑this.applyFilter(date);});}}privateapplyFilter(date:string){// 筛选逻辑实现console.log(应用筛选:,date);}}五、关键技术点详解5.1 事件冒泡处理这是实现“点击弹窗内容不关闭”的核心。在 Cocos Creator 中事件会沿着节点树向上冒泡。如果不做处理点击弹窗面板时事件会冒泡到遮罩层导致弹窗关闭。解决方案是为弹窗面板及其子节点添加BlockInputEvents组件该组件会阻止输入事件继续传递。// 阻止事件冒泡的关键代码privateblockNodeEvent(node:Node){if(!node.getComponent(BlockInputEvents)){node.addComponent(BlockInputEvents);}node.children.forEach(child{this.blockNodeEvent(child);});}5.2 全屏适配为了让遮罩层在所有分辨率下都能完全覆盖屏幕需要动态获取屏幕尺寸privatesetMaskFullScreen(){constuiTransformthis.maskNode.getComponent(UITransform);if(uiTransform){constsizeview.getVisibleSize();uiTransform.setContentSize(size.width,size.height);}}5.3 键盘支持ESC 键关闭为了提升用户体验可以添加按 ESC 键关闭弹窗的功能// 在 PopupBase 中添加onEnable(){input.on(Input.EventType.KEY_DOWN,this.onKeyDown,this);}onDisable(){input.off(Input.EventType.KEY_DOWN,this.onKeyDown,this);}privateonKeyDown(event:EventKeyboard){if(event.keyCodeKeyCode.ESCAPE){this.close();}}六、方案对比与总结方案优点缺点适用场景全屏遮罩冒泡阻止实现简单维护方便性能好需要预制体支持推荐适用于大多数场景全局点击监听灵活难以准确判断目标代码复杂不推荐透明按钮覆盖简单直观需要手动管理按钮显示隐藏简单弹窗场景七、完整代码获取本文的完整代码示例已整理好主要文件包括PopupBase.ts- 弹窗基类PopupManager.ts- 弹窗管理器FilterPopup.ts- 具体弹窗示例八、参考资料Cocos Creator 官方文档 - 事件系统Cocos Creator 官方文档 - BlockInputEvents 组件Cocos 中文社区讨论如果你在实现过程中遇到任何问题欢迎在评论区交流讨论