Duilib多标签选项卡拖拽效果:添加动画特效!

动画是小型界面库的“难题”、“通病”

请添加图片描述

几年前就有人分享了如何用direct UI制作多标签选项卡界面的方法。还有人出了一个简易的浏览器demo。但是他们的标签栏都没有Chrome浏览器那样的动画特效。

如何给界面添加布局是的动画特效呢?

动画使界面看起来高大上,使用起来也更直观。

我调查了一些小型界面库,包括imgui、lcui等,都没有内置这样的组件。

难道仅仅为了这一个小的控件效果,真的要内置一个浏览器?(sortablejs?)


多标签选项卡拖拽效果 【三百行精简版本】

Duilib多标签选项卡拖拽效果 - 知乎
洋洋洒洒八百行 —— 大多是图标啊,背景啊之类的。然后他还特别设计了。子控件类型和父控件配套使用。太麻烦了。

我简化一番,将原理呈现,只需三百行:


class CTabBarUI :public CHorizontalLayoutUI
{
public:
    CTabBarUI();
    ~CTabBarUI();

    LPCTSTR GetClass() const;
    LPVOID GetInterface(LPCTSTR pstrName);

    //添加一个
    CControlUI* AddItem(LPCTSTR pstrText);

    //drag
    void DoDragBegin(CControlUI *pTab);
    void DoDragMove(CControlUI *pTab, const RECT& rcPaint);
    void DoDragEnd(CControlUI *pTab, const POINT& Pt);

private:
	CControlUI *m_pZhanWeiOption = NULL;
    CControlUI *m_pDragOption = NULL;

};


#define DUI_MSGTYPE_OPTIONTABCLOSE 		   	(_T("closeitem_tabbar"))


//


std::function<bool(CControlUI* this_, HDC hDC, const RECT& rcPaint)> postDraw;
std::function<bool(CControlUI* this_, TEventUI& evt)> evtListener;

POINT m_ptLastMouse;
POINT m_ptLButtonDownMouse;
RECT m_rcNewPos;

//判断开始拖拽
bool m_bFirstDrag = true;

//判断是否忽略拖拽,首次需要鼠标按住拖拽一定距离才触发拖拽
bool m_bIgnoreDrag = true;

//
//
CTabBarUI::CTabBarUI()
{
	m_pZhanWeiOption = new CControlUI();
	m_pZhanWeiOption->SetMaxWidth(0);
	m_pZhanWeiOption->SetForeColor(0x000000ff);
	m_pZhanWeiOption->SetEnabled(false);

	Add(m_pZhanWeiOption);
	auto box = this;
	postDraw = [box](CControlUI* this_, HDC hDC, const RECT& rcPaint)
	{
		return true;
	};

	evtListener = [box](CControlUI* this_, TEventUI& event)
	{
		//if (!this_->IsMouseEnabled() && event.Type > UIEVENT__MOUSEBEGIN && event.Type < UIEVENT__MOUSEEND) {
		//	if (box != NULL) box->DoEvent(event);
		//	else COptionUI::DoEvent(event);
		//	return true;
		//}

		auto _manager = box->GetManager();
		auto & m_rcItem = this_->GetPos();
		if (event.Type == UIEVENT_BUTTONDOWN)
		{
			if (::PtInRect(&this_->GetPos(), event.ptMouse) && this_->IsEnabled())
			{
				this_->m_uButtonState |= UISTATE_PUSHED | UISTATE_CAPTURED;
				this_->Invalidate();
				if (this_->IsRichEvent()) _manager->SendNotify(this_, DUI_MSGTYPE_BUTTONDOWN);

				if (::PtInRect(&this_->GetPos(), event.ptMouse)/* && !::PtInRect(&rcClose, event.ptMouse)*/)
				{
					this_->Activate();
				}

				m_bIgnoreDrag = true;
				m_ptLButtonDownMouse = event.ptMouse;
				m_ptLastMouse = event.ptMouse;
				m_rcNewPos = m_rcItem;
				if (_manager)
				{
					_manager->RemovePostPaint(this_);
					_manager->AddPostPaint(this_);
				}

			}
		}
		else if (event.Type == UIEVENT_MOUSEMOVE)
		{
			if ((this_->m_uButtonState & UISTATE_CAPTURED) != 0)
			{
				LONG cx = event.ptMouse.x - m_ptLastMouse.x;
				LONG cy = event.ptMouse.y - m_ptLastMouse.y;

				m_ptLastMouse = event.ptMouse;

				RECT rcCurPos = m_rcNewPos;

				rcCurPos.left += cx;
				rcCurPos.right += cx;
				rcCurPos.top += cy;
				rcCurPos.bottom += cy;

				//将当前拖拽块的位置 和 当前拖拽块的前一时刻的位置,刷新
				CDuiRect rcInvalidate = m_rcNewPos;
				m_rcNewPos = rcCurPos;
				rcInvalidate.Join(m_rcNewPos);
				if (_manager) _manager->Invalidate(rcInvalidate);

				this_->NeedParentUpdate();
			}
		}
		else if (event.Type == UIEVENT_BUTTONUP)
		{
			if ((this_->m_uButtonState & UISTATE_CAPTURED) != 0)
			{
				this_->m_uButtonState &= ~(UISTATE_PUSHED | UISTATE_CAPTURED);
				this_->Invalidate();

				CTabBarUI* pParent = static_cast<CTabBarUI*>(box);
				if (pParent)
				{
					pParent->DoDragEnd(this_, m_ptLastMouse);
				}

				if (_manager)
				{
					_manager->RemovePostPaint(this_);
					_manager->Invalidate(m_rcNewPos);
				}
				this_->NeedParentUpdate();

				m_bFirstDrag = true;
			}
		}


		if ((this_->m_uButtonState & UISTATE_CAPTURED) != 0)
		{
			auto & m_rcItem = this_->GetPos();
			lxxx(m_bIgnoreDrag dd, 13)
				if (m_bIgnoreDrag && abs(m_ptLastMouse.x - m_ptLButtonDownMouse.x) < 15)
				{
					return true;
				}
			m_bIgnoreDrag = false;
			lxxx(dd, 13)

				CTabBarUI* pParent = static_cast<CTabBarUI*>(box);
			//if (!pParent) return true;

			if (m_bFirstDrag)
			{
				pParent->DoDragBegin(this_);
				m_bFirstDrag = false;
				return true;
			}

			CDuiRect rcParent = box->GetPos();
			RECT rcUpdate = { 0 };
			rcUpdate.left = m_rcNewPos.left < rcParent.left ? rcParent.left : m_rcNewPos.left;
			rcUpdate.top = m_rcItem.top < rcParent.top ? rcParent.top : m_rcItem.top;
			rcUpdate.right = m_rcNewPos.right > rcParent.right ? rcParent.right : m_rcNewPos.right;
			rcUpdate.bottom = m_rcItem.bottom > rcParent.bottom ? rcParent.bottom : m_rcItem.bottom;
			//CRenderEngine::DrawColor(hDC, rcUpdate, 0xAAFFFFFF);

			pParent->DoDragMove(this_, rcUpdate);

		}
		return true;
	};


}


CTabBarUI::~CTabBarUI()
{
}

LPCTSTR CTabBarUI::GetClass() const
{
	return _T("TabBarUI");
}

LPVOID CTabBarUI::GetInterface(LPCTSTR pstrName)
{
	if (_tcsicmp(pstrName, _T("TabBar")) == 0) return static_cast<CTabBarUI*>(this);
	return CHorizontalLayoutUI::GetInterface(pstrName);
}

CControlUI* CTabBarUI::AddItem(LPCTSTR pstrText)
{
	if (!pstrText)
	{
		return NULL;
	}

	CLabelUI* pTab = new CLabelUI();
	pTab->evtListeners.push_back(evtListener);
	pTab->postDraws.push_back(postDraw);
	pTab->SetRichEvent(true);

	//pTab->SetName(_T("tabbaritem"));
	//pTab->SetGroup(_T("tabbaritem"));
	pTab->SetTextColor(0xff333333);
	//pTab->SetNormalImage(_T("file='img/bk_tabbar_item.png' source='0,0,10,8' corner='4,4,4,2'"));
	//pTab->SetHotImage(_T("file='img/bk_tabbar_item.png' source='10,0,20,8' corner='4,4,4,2'"));
	//pTab->SetSelectedImage(_T("file='img/bk_tabbar_item.png' source='20,0,30,8' corner='4,4,4,2'"));
	pTab->SetMaxWidth(226);
	//pTab->SetFixedWidth(100);
	pTab->SetMinWidth(20);
	//pTab->SetBorderRound({ 2, 2 });
	pTab->SetText(pstrText);

	pTab->SetAttribute(_T("align"), _T("left"));
	pTab->SetAttribute(_T("textpadding"), _T("28,0,16,0"));
	pTab->SetAttribute(_T("iconsize"), _T("16,16"));
	pTab->SetAttribute(_T("iconpadding"), _T("6,0,0,0"));
	pTab->SetAttribute(_T("iconimage"), _T("img/icon_360.png"));
	pTab->SetAttribute(_T("selectediconimage"), _T("img/icon_baidu.png"));
	pTab->SetAttribute(_T("endellipsis"), _T("true"));

	pTab->SetAttribute(_T("haveclose"), _T("true"));
	pTab->SetAttribute(_T("closepadding"), _T("0,0,6,0"));
	pTab->SetAttribute(_T("closesize"), _T("16,16"));
	pTab->SetAttribute(_T("closeimage"), _T("file='img/btn_tabbaritem.png' source='0,0,16,16'"));
	pTab->SetAttribute(_T("closehotimage"), _T("file='img/btn_tabbaritem.png' source='16,0,32,16'"));
	pTab->SetAttribute(_T("closepushimage"), _T("file='img/btn_tabbaritem.png' source='32,0,48,16'"));

	//pTab->OnNotify += MakeDelegate(this, &CTabBarUI::OnItemClose);

	if (Add(pTab))
	{
		return pTab;
	}
	return NULL;
}

void CTabBarUI::DoDragBegin(CControlUI *pTab)
{
	if (!pTab)
	{
		return;
	}

	int index = GetItemIndex(pTab);
	if (index < 0)
	{
		return;
	}

	int index_blue = GetItemIndex(m_pZhanWeiOption);
	if (index_blue < 0)
	{
		return;
	}

	m_pDragOption = pTab;

	m_items.SetAt(index, m_pZhanWeiOption);
	m_items.SetAt(index_blue, m_pDragOption);

	m_pZhanWeiOption->SetMaxWidth(m_pDragOption->GetWidth());
	m_pDragOption->SetMaxWidth(0);
}

void CTabBarUI::DoDragMove(CControlUI *pTab, const RECT& rcPaint)
{
	if (m_pDragOption != pTab)
	{
		return;
	}

	int x = rcPaint.left + (rcPaint.right - rcPaint.left) / 2;
	int y = rcPaint.top + (rcPaint.bottom - rcPaint.top) / 2;
	if (x < m_rcItem.left || x > m_rcItem.right)
	{
		return;
	}

	int index = -1;
	for (int it1 = 0; it1 < m_items.GetSize(); it1++) 
	{
		CControlUI* pControl = static_cast<CControlUI*>(m_items[it1]);
		if (!pControl) continue;
		if(pControl!=m_pZhanWeiOption)
		if (/*_tcsicmp(pControl->GetClass(), _T("tabbaritemui")) == 0 && */::PtInRect(&pControl->GetPos(), { x, y }))
		{
			index = it1;
			break;
		}
	}

	if (index == -1)
	{
		return;
	}

	CControlUI *pOption = static_cast<CControlUI*>(GetItemAt(index));
	int index_blue = GetItemIndex(m_pZhanWeiOption);

	m_items.SetAt(index, m_pZhanWeiOption);
	m_items.SetAt(index_blue, pOption);

}

void CTabBarUI::DoDragEnd(CControlUI *pTab, const POINT& Pt)
{
	if (m_pDragOption != pTab)
	{
		return;
	}

	int index = GetItemIndex(m_pDragOption);
	if (index < 0)
	{
		return;
	}

	int index_blue = GetItemIndex(m_pZhanWeiOption);
	if (index_blue < 0)
	{
		return;
	}

	m_items.SetAt(index, m_pZhanWeiOption);
	m_items.SetAt(index_blue, m_pDragOption);

	m_pDragOption->SetMaxWidth(m_pZhanWeiOption->GetWidth());
	m_pZhanWeiOption->SetMaxWidth(0);
}

和chrome浏览器不同的是他没有使用标准的拖拽事件,而是分别处理了点击触摸移动事件。


DirectUI 动画方案入门

Direct是比较早的,他的技术比较老。他是直接用那个hdc绘制。和普通的win程序是一样的。区别仅仅是使用自己的布局系统。然后他的控件大多是没有句柄的。所以说比较直接。

最初的DirectUI 公开方案里的动画。那个是dx插特效,是不一样的,在播放dx特效之时,会有一个阻塞之类的,特效组合也不是很自由。

其实很简单,无非是三种方法:

  1. 最简单的timer
  2. 循环Invalidate
  3. 用一个新的线程去控制它刷新。

第三和第二很相似。第二个循环Invalidate是一个折中。

为了入门,简单实现上面动图中的滚动跑马灯特效:

float xx;
int tick;

			auto updateFun = [newbar, menu](float spd){
				int t = GetTickCount64(), dt = t-tick[i];
				xx += dt * spd;
				tick = t;
				menu->SetFixedXY({(int)round(xx),0});
				if (xx>newbar->GetWidth()-menu->GetWidth())
				{
					xx = 0;
				}
				return dt;
			};

			if (开始滚动)
			{
				newbar->postDraws.push_back([updateFun, newbar](CControlUI* thiz, HDC hDC, const RECT& rcPaint){
					int dt = updateFun(.45f);
					newbar->NeedUpdate(); 
					Sleep(1);
					return true;
				});
			}

这个需要修改界面库代码在绘制之后调用传进去的函数:

DuiLib\Core\UIControl.cpp

bool CControlUI::DoPaint(HDC hDC, const RECT& rcPaint, CControlUI* pStopControl)
	{
	...
	
		if (postDraws.size())
		{
			for (size_t i = 0; i < postDraws.size(); i++)
			{
				auto ret = postDraws[i](this, hDC, rcPaint);
				if (!ret)
				{
					postDraws.erase(postDraws.begin()+i);
				}
			}
		}
		return true;
	}

类似于安卓的循环postInvalidate。

注意需要睡眠一秒钟。不然跑的太快,CPU飙升过于明显。当然最大值也不是很大,就是sleep调度一下的话,性能变得很轻盈。


WinQkUI 标签动画

有了这个基础之后,我们就可以实现界面拖拽排序之时的动画效果。

也是需要修改这个源代码库。循环Invalidate还是在dopaint方法内部末尾调用,但是设置位置偏移的话,须在setpos之后调用。


void CTabBarUI::DoDragMove(CControlUI *pTab, const RECT& rcPaint)
{
	...

	AnimationJob* job = new AnimationJob{true, pItem->GetPos().left, pItem->GetPos().top
			, GetTickCount64(), 200};

	auto animator = [job](CControlUI* this_, RECT& rcItem)
	{
		int ww = rcItem.right - rcItem.left;
		int hh = rcItem.bottom - rcItem.top;
		int time = GetTickCount64() - job->start;
		if (time>job->duration)
			time = job->duration;
		if (time>=job->duration)
			job->active = false;
		rcItem.left = job->xx + (rcItem.left - job->xx)*1.f/job->duration*time;
		rcItem.top = job->yy + (rcItem.top - job->yy)*1.f/job->duration*time;
		rcItem.right = rcItem.left + ww;
		rcItem.bottom = rcItem.top + hh;
		//this_->NeedParentUpdate();
		//this_->GetParent()->NeedUpdate();
		//Sleep(1);
		return job->active;
	};

	pItem->postSize.resize(0);
	pItem->postSize.push_back(animator);	
	//if (1)
	//{
	//	return;
	//}
	pItem->_view_states |= VIEWSTATEMASK_IsAnimating;
	pItem->postDraws.push_back([job](CControlUI* thiz, HDC hDC, const RECT& rcPaint)
	{
		if (job->active)
		{
			//RECT* rcItem = (RECT*)&thiz->GetPos();
			int time = GetTickCount64() - job->start;
			if (time>job->duration)
				time = job->duration;
			//if (time>=job->duration)
			//	job->active = false;
			thiz->GetParent()->NeedUpdate();
			//Sleep(1);
		} else {
			thiz->postSize.resize(0);
			thiz->_view_states &= ~VIEWSTATEMASK_IsAnimating;
			delete job;

		}
		return job->active;
	});

}

后面的代码不是很完整,但原理已经讲得十分清楚了。待我整理一番再上传。

只需在DoDragMove方法。在触发交换元素位置的时候,为每个被移动的元素安排动画 AnimationJob 就行。

struct AnimationJob{
	bool active;
	LONG xx;
	LONG yy;
	ULONGLONG start;
	int duration;
};

AnimationJob 结构体记录起始位置,然后根据一个动画时长,一路插值到目标位置即可。

目标位置由父容器布局,由 setPos 决定。

在postSize的循环中,实时修改动画过程中控件的位置,不直接采用setPos 的值,从而实现布局动画,原理十分的简单。

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/691042.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

C++笔试强训day41

目录 1.棋子翻转 2.宵暗的妖怪 3.过桥 1.棋子翻转 链接https://www.nowcoder.com/practice/a8c89dc768c84ec29cbf9ca065e3f6b4?tpId128&tqId33769&ru/exam/oj &#xff08;简单题&#xff09;对题意进行简单模拟即可&#xff1a; class Solution { public:int dx[…

2024年政治经济学与社会科学国际会议(ICPESS 2024)

2024年政治经济学与社会科学国际会议 2024 International Conference on Political Economy and Social Sciences 会议简介 2024年政治经济学与社会科学国际会议是一个致力于探讨政治经济学与社会科学交叉领域前沿问题的国际盛会。本次会议汇聚了全球顶尖的专家学者、研究人员和…

lubuntu / ubuntu 配置静态ip

一、查看原始网络配置信息 1、获取网卡名称 ifconfig 2、查询网关IP route -n 二、编辑配置文件 去/etc/netplan目录找到配置文件&#xff0c;配置文件名一般为01-network-manager-all.yaml sudo vim /etc/netplan/01-network-manager-all.yaml文件打开后内容如下 # This …

【优化过往代码】关于vue自定义事件的运用

【优化过往代码】关于vue自定义事件的运用 需求说明过往代码优化思路优化后代码&#xff08;Vue2&#xff09;遇到问题记录 Vue2官方自定义指令说明文档 Vue3官方自定义指令说明文档 需求说明 进入某些页面需要加载一些外部资源&#xff0c;并在资源加载完后进行一些处理&…

Flink⼤状态作业调优实践指南:状态报错与启停慢篇

摘要&#xff1a;本文整理自俞航翔、陈婧敏、黄鹏程老师所撰写的大状态作业调优实践指南。由于内容丰富&#xff0c;本文分享终篇状态报错与启停慢篇&#xff0c;主要分为以下四个部分&#xff1a; 检查点和快照超时的诊断与调优 作业快速启动和扩缩容方案 总结 阿里云企业级…

图解支付系统全自动化渠道开关设计与实现

大家好&#xff0c;我是隐墨星辰&#xff0c;前几天在渠道路由章节中提到过自动化渠道开关&#xff0c;今天聊聊支付系统中全自动化渠道开关的设计与实现。主要讲清楚在什么情况下需要考虑建设自动化渠道开关&#xff0c;以及如何设计并实现一个平衡灵敏度和噪音的自动化渠道开…

用python编撰一个电脑清理程序

自制一个电脑清理程序&#xff0c;有啥用呢&#xff1f;在电脑不装有清理软件的时候&#xff0c;可以解决自己电脑内存不足的情况。 1、设想需要删除指定文件夹中的临时文件和缓存文件。以下是代码。 import os import shutil def clean_folder(folder_path): for root,…

Qt基于SQLite数据库的增删查改demo

一、效果展示 在Qt创建如图UI界面&#xff0c;主要包括“查询”、“添加”、“删除”、“更新”&#xff0c;四个功能模块。 查询&#xff1a;从数据库中查找所有数据的所有内容&#xff0c;并显示在左边的QListWidget控件上。 添加&#xff1a;在右边的QLineEdit标签上输入需…

分享一个按钮代码,主要有html,svg及css动画实现

按钮展示: Switch by Galahhad made with CSS | Uiverse.io 源代码: css .theme-switch {--toggle-size: 30px;/* the size is adjusted using font-size,this is not transform scale,so you can choose any size */--container-width: 5.625em;--container-height: 2.5em;-…

Linux安装Qt5.14.2

下载 qt 5.14.2下载网址 下载qt-opensource-linux-x64-5.14.2.run Linux系统下载.run文件&#xff08;runfile文件&#xff09;&#xff0c;windows系统下载.exe文件&#xff0c;mac系统下载.dmg文件。 md5sums.txt中是各个文件对应的MD5校验码。 验证MD5校验码 md5sum是li…

UE4 使用样条线做鱼儿封闭路径动画

描述&#xff1a;鱼儿的游动动画的特点 1.通常是始终保持Y (Pitch)轴角度不变 2.调头的时候改变的是Z轴角度 效果&#xff1a;调头的时候比较自然 蓝图&#xff1a; 为了让鱼儿有恒定的游动速度&#xff0c;增加以下蓝图节点&#xff0c;游动速度为50 最后&#xff0c;让鱼…

Day53 动态规划part12

LC309买卖股票的最佳时机含冷冻期 与LC122类似&#xff0c;都是可无限次购买股票&#xff0c;只不过引入了冷冻期的概念dp[i][0] 第i天持有股票收益&#xff1b;dp[i][1] 第i天不持有股票收益;情况一&#xff1a;第i天是冷静期&#xff0c;不能以dp[i-1][1]购买股票,所以以dp[…

019、有序集合_命令

它保留了集合不能有重复,有序集合中的元素可以排序。 但是它和列表使用索引下标作为排序依据不同的是,它给每个元素设置一个分数(score)作为排序的依据。如图 该有序集合包含kris、mike、frank、tim、martin、tom,它们的分数分别是1、91、200、220、250、251,有序集合提…

Windows下对于Qt中带 / 的路径的处理

在Windows下&#xff0c;如果你想使用操作系统的分隔符显示用户的路径&#xff0c;请使用 toNativeSeparators()。 请看以下代码&#xff1a; void Player::on_playBtn_clicked() {if (this->m_url.isEmpty()) {openMedia();if (this->m_url.isEmpty())return;}qDebug(…

使用 Scapy 库编写 ICMP 不可达攻击脚本

一、介绍 ICMP不可达攻击是一种利用ICMP&#xff08;Internet Control Message Protocol&#xff09;不可达消息来干扰或中断目标系统的网络通信的攻击类型。通过发送伪造的ICMP不可达消息&#xff0c;攻击者可以诱使目标系统认为某些网络路径或主机不可达&#xff0c;从而导致…

idea2023如何创建普通maven工程项目

解决 1.创建新项目 1.进入创建项目 File -> new -> project 2&#xff0c;project 中有 build system 选择maven 2.在已有项目中创建普通maven工程 1.右键项目选择 new -> Module 2.选择 new Module 其实与新建maven工程没什么区别 em:问题 idea以前的版本是在Mav…

C++三大特性之多态

1.多态 1.1多态的概念 在面向对象方法中一般是这样表述多态性的:向不同的对象发送同一个消息&#xff0c;不同的对象在接收时会产生不同的行为(即方法)也就是说&#xff0c;每个对象可以用自己的方式去响应共同的消息。所谓消息&#xff0c;就是调用函数&#xff0c;不同的行…

C/C++中内存开辟与柔性数组

C/C中内存的开辟 在C中&#xff0c;我们都知道有三个区&#xff1a; 1. 栈区&#xff08;stack&#xff09;&#xff1a;在执行函数时&#xff0c;函数内局部变量的存储单元都可以在栈上创建&#xff0c;函数执行结 束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指…

【庞加莱几何-02】反演定理和证明

文章目录 一、说明二、 inversion和 reflection三、圆反演的定义四、广义的圆反演成圆 关键词&#xff1a;inversion、reflection 一、说明 这里是庞加莱几何的第二篇文章&#xff0c;是庞加莱基本几何属性的研究。本篇主要说清楚&#xff0c;什么是反演&#xff0c;在反演情况…

【启明智显芯片应用】Model3C芯片4.3寸拼图机应用方案

数据显示&#xff0c;618前期&#xff0c;早教启智、智能玩具、科学启蒙、数字阅读类产品销量增长迅猛。当下&#xff0c;90后新生代父母对于孩子的科学启蒙教育愈发重视&#xff0c;他们在给孩子选择学习产品时&#xff0c;越来越倾向于选择寓教于乐的益智类产品&#xff0c;而…