当前位置: 首页 > news >正文

Cocos Creator 弹窗交互:实现“点击空白关闭”与“按钮切换”

从节点结构到代码实现,一篇搞定 Cocos Creator 中的弹窗遮罩层方案

一、背景

在游戏和应用的 UI 开发中,弹窗是一个非常常见的交互组件。最近在 Cocos Creator 项目中遇到这样一个需求:

点击按钮弹出一个筛选弹窗,除了再次点击按钮可以关闭外,点击弹窗外的任何空白区域也要能关闭弹窗。

这个需求看起来简单,但在 Cocos Creator 中实现时,有几个关键问题需要考虑:

  1. 如何定义“空白区域”?
  2. 如何避免点击弹窗内部内容时误关闭?
  3. 如何保证弹窗在不同分辨率下都能正常显示?

本文将分享一套完整的解决方案——基于遮罩层的点击关闭机制,并提供一个可复用的弹窗组件。

二、为什么需要遮罩层?

很多初学者的第一反应是:给整个场景添加点击监听,判断点击的节点是否是弹窗本身。

// ❌ 这种思路有问题this.node.on(Node.EventType.TOUCH_END,(event)=>{// 如何判断点击的不是弹窗内容?// 很容易误判});

这种方法有几个痛点:

  • 难以准确判断点击目标是否是弹窗内部
  • 需要为大量 UI 元素单独添加监听
  • 代码耦合度高,不利于维护

正确的思路:创建一个全屏遮罩层,只有点击遮罩层才关闭弹窗,点击弹窗主体则不影响。

三、解决方案设计

3.1 核心原理

  1. 遮罩层(Mask):一个全屏的透明/半透明节点,位于弹窗内容下方
  2. 事件冒泡控制:弹窗内容节点阻止事件冒泡,防止点击内容时触发遮罩层
  3. 统一关闭逻辑:遮罩层点击和按钮关闭都调用同一个关闭方法

3.2 节点结构设计

Canvas (根节点) ├── MainUI (主界面) │ └── OpenBtn (打开弹窗按钮) └── PopupRoot (弹窗根节点 - 动态添加) └── Mask (遮罩层 - 全屏,可点击关闭) └── Panel (弹窗面板 - 阻止冒泡) ├── CloseBtn (关闭按钮) └── Content (弹窗内容)

四、具体实现步骤

4.1 创建弹窗预制体(Prefab)

首先,创建一个弹窗预制体,包含以下结构:

步骤 1:创建遮罩层节点

  1. 在层级管理器中创建一个空节点作为弹窗根节点,命名为PopupMask
  2. 添加UITransform组件,设置宽高为全屏(可以后续通过代码动态设置)
  3. 添加Sprite组件,设置颜色为半透明黑色(例如rgba(0,0,0,0.5)
  4. 添加Button组件,用于接收点击事件
  5. 添加BlockInputEvents组件,防止事件穿透

关于全屏适配:为了让遮罩层在所有分辨率下都能完全覆盖屏幕,可以通过代码动态获取屏幕尺寸来设置节点大小。

步骤 2:创建弹窗面板节点

  1. PopupMask下创建一个空节点,命名为PopupPanel
  2. 设置锚点为中心(0.5, 0.5),位置为(0, 0)
  3. 添加背景图或 Sprite 组件
  4. 添加Button组件(用于阻止事件冒泡)

步骤 3:创建关闭按钮

PopupPanel下创建一个按钮节点,命名为CloseBtn,用于手动关闭弹窗。

最终的节点结构如下图所示:

PopupMask (全屏遮罩) ├── PopupPanel (弹窗内容面板) ├── CloseBtn (关闭按钮) └── Content (你的弹窗内容)

4.2 编写弹窗组件脚本

创建PopupBase.ts脚本,作为所有弹窗的基类:

// PopupBase.tsimport{_decorator,Component,Node,Button,UITransform,view,EventHandler,director}from'cc';const{ccclass,property}=_decorator;@ccclass('PopupBase')exportclassPopupBaseextendsComponent{@property({tooltip:'是否允许点击遮罩层关闭'})closeOnMask:boolean=true;@property({tooltip:'是否在关闭时销毁节点'})destroyOnClose:boolean=true;privatemaskNode:Node=null;privatepanelNode:Node=null;privatecloseCallback:Function=null;onLoad(){// 获取遮罩层节点(弹窗根节点)this.maskNode=this.node;// 获取弹窗面板节点this.panelNode=this.node.getChildByName('PopupPanel');// 设置遮罩层全屏this.setMaskFullScreen();// 绑定遮罩层点击事件if(this.closeOnMask){this.bindMaskClick();}// 绑定关闭按钮事件this.bindCloseButton();// 阻止面板上的事件冒泡到遮罩层this.blockPanelEvent();}/** * 设置遮罩层全屏 */privatesetMaskFullScreen(){constuiTransform=this.maskNode.getComponent(UITransform);if(uiTransform){constsize=view.getVisibleSize();uiTransform.setContentSize(size.width,size.height);}}/** * 绑定遮罩层点击事件 */privatebindMaskClick(){constmaskButton=this.maskNode.getComponent(Button);if(maskButton){maskButton.node.on(Button.EventType.CLICK,this.onMaskClick,this);}}/** * 遮罩层点击处理 */privateonMaskClick(){this.close();}/** * 绑定关闭按钮事件 */privatebindCloseButton(){if(!this.panelNode)return;constcloseBtn=this.panelNode.getChildByName('CloseBtn');if(closeBtn){constbtn=closeBtn.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.closeCallback=callback;this.node.active=true;this.onOpen();}/** * 关闭弹窗 */publicclose(){this.node.active=false;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}from'cc';const{ccclass,property}=_decorator;@ccclass('PopupManager')exportclassPopupManagerextendsComponent{privatestaticinstance:PopupManager=null;// 弹窗根节点privatepopupRoot:Node=null;// 弹窗缓存privatepopupCache:Map<string,Node>=newMap();staticgetInstance():PopupManager{returnthis.instance;}onLoad(){PopupManager.instance=this;this.initPopupRoot();}/** * 初始化弹窗根节点 */privateinitPopupRoot(){this.popupRoot=newNode('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;// 从缓存获取或实例化新弹窗constprefabName=prefab.name;if(this.popupCache.has(prefabName)){popupNode=this.popupCache.get(prefabName);popupNode.active=true;}else{popupNode=instantiate(prefab);this.popupCache.set(prefabName,popupNode);}// 添加到弹窗根节点this.popupRoot.addChild(popupNode);// 获取弹窗组件并打开constpopupComp=popupNode.getComponent(PopupBase);if(popupComp){popupComp.open(callback);}returnpopupNode;}/** * 关闭所有弹窗 */publiccloseAllPopups(){this.popupRoot.children.forEach(child=>{constpopupComp=child.getComponent(PopupBase);if(popupComp){popupComp.close();}});}}

4.4 使用示例

创建具体的弹窗组件
// FilterPopup.tsimport{_decorator,Label,EditBox}from'cc';import{PopupBase}from'./PopupBase';const{ccclass,property}=_decorator;@ccclass('FilterPopup')exportclassFilterPopupextendsPopupBase{@property(Label)titleLabel:Label=null;@property(EditBox)dateInput:EditBox=null;privateonConfirmCallback:Function=null;/** * 设置弹窗数据 */publicsetData(title:string,confirmCallback:Function){if(this.titleLabel){this.titleLabel.string=title;}this.onConfirmCallback=confirmCallback;}/** * 确认按钮点击 */publiconConfirmClick(){constdateValue=this.dateInput?this.dateInput.string:'';if(this.onConfirmCallback){this.onConfirmCallback(dateValue);}this.close();}protectedonOpen(){console.log('弹窗已打开');}protectedonClose(){console.log('弹窗已关闭');}}
在场景中使用
// GameScene.tsimport{_decorator,Component,Button,Prefab}from'cc';import{PopupManager}from'./PopupManager';import{FilterPopup}from'./FilterPopup';const{ccclass,property}=_decorator;@ccclass('GameScene')exportclassGameSceneextendsComponent{@property(Prefab)filterPopupPrefab:Prefab=null;@property(Button)openBtn:Button=null;start(){// 绑定打开弹窗按钮事件if(this.openBtn){this.openBtn.node.on(Button.EventType.CLICK,this.onOpenBtnClick,this);}}privateonOpenBtnClick(){// 通过弹窗管理器显示弹窗constpopupNode=PopupManager.Instance.showPopup(this.filterPopupPrefab,()=>{console.log('弹窗已关闭');});// 设置弹窗数据constpopupComp=popupNode.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(){constuiTransform=this.maskNode.getComponent(UITransform);if(uiTransform){constsize=view.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.keyCode===KeyCode.ESCAPE){this.close();}}

六、方案对比与总结

方案优点缺点适用场景
全屏遮罩+冒泡阻止实现简单,维护方便,性能好需要预制体支持推荐,适用于大多数场景
全局点击监听灵活难以准确判断目标,代码复杂不推荐
透明按钮覆盖简单直观需要手动管理按钮显示隐藏简单弹窗场景

七、完整代码获取

本文的完整代码示例已整理好,主要文件包括:

  • PopupBase.ts- 弹窗基类
  • PopupManager.ts- 弹窗管理器
  • FilterPopup.ts- 具体弹窗示例

八、参考资料

  • Cocos Creator 官方文档 - 事件系统
  • Cocos Creator 官方文档 - BlockInputEvents 组件
  • Cocos 中文社区讨论

如果你在实现过程中遇到任何问题,欢迎在评论区交流讨论!

http://www.gsyq.cn/news/1582897.html

相关文章:

  • 伽罗瓦理论平话 引言 第一章 藏在一元二次方程里的秘密
  • 2026年企业私有大模型方案:训练、推理、部署全链路解析
  • 数字化导板引导种植的精度评估与误差控制策略研究
  • 手把手教你学Simulink——基于滑模变结构控制(SMC / Sliding Mode Control)的 Buck 变换器鲁棒控制仿真
  • 鸿蒙PC适配llvm-gcc-compat编译安装第三方库chrono,打造Rust 第三方日期时间处理库
  • 智能硬件产品 App 全球发布 第 6 章:IoT App 特殊审核体系
  • 16-Redis 与 Redisson 采集:缓存节点如何参与问题定位
  • 关于GraalVM的说明
  • 无人机航拍输电线路缺陷检测开源数据集|电力电缆散股异物识别YOLODETR双格式图像库10452期
  • 基于U2-Net与深度度量学习的自动化花粉显微图像分析系统实践
  • 豆包导出pdf怎么调顺序?试试AI 导出鸭智能排序
  • 联邦学习实战:破解非独立同分布数据困局的算法策略与调优指南
  • AI开发-多路径写入一致性:从一次 Debug 到系统性防御
  • 【硬核长文】万字拆解无线网络核心:AP(无线访问接入点)从底层原理到企业级实战调优指南
  • 无人机遥感国土目标检测数据集 无人机耕地数据集 无人机道路农田检测 国土遥感地物实例分割数据集 yolo数据集第10759期
  • 五、进程控制
  • RFID 仓库管理系统 项目总结
  • 基于用户画像的AI内容生成与安全检测闭环系统实践
  • 问卷也能做高颜值?问卷星、金数据等5大平台美工设计能力实测
  • 高级java每日一道面试题-2026年02月26日-实战篇[Docker]-如何实现镜像的合规性检查(如金融行业的基线要求)?
  • MyBatis SQL映射配置全解析:XML配置、动态SQL与注解驱动深度实战指南
  • 基于近红外与隐式神经表示的低光图像去噪:频率解耦融合技术详解
  • 外部中断EXTI和NVIC
  • Human-in-the-Loop 场景应用
  • 微服务链路追踪的智能采样:从随机到语义感知的演进与实践
  • 融合物理约束与热图监督的视觉目标跟踪:提升鲁棒性的工程实践
  • Python之richtqdm包语法、参数和实际应用案例
  • GRAND解码算法:原理、优化与并行实现
  • 动态调度优化LDGM码有损编码:软硬BPGD算法性能提升实践
  • DeepSeek从入门到精通,2026年最值得用的国产AI