成果演示
将这个功能应用到具体的业务上,比如:一个类思维导图的节点添加功能:
我们还可以通过定义环绕图元的各种行为,来让它们本身具有某些样式或者独特的功能,但在本篇文章中,我主要就“如何使得图元环绕在其他图元四周,并且这个环绕效果会根据选择的图元的不同而环绕在不同的图元之上”这个问题进行一些理论上的探讨。
理论讲解
我们的需求是这样的,当用户点击某个图元时,这个图元的四周(上下左右)会出现一些“环绕”在该图元周围的其他功能性图元。
这些功能性图元提供某些功能操作,为了便于用户点击这些按键,我们将其环绕在操作的图元附近,而判断某个图元是否正在被操作的方式就是通过判断这个图元是否处于“焦点”状态。
由于这些环绕在被选择图元的功能性图元实际上不属于被操作图元的一部分,所以它们无法作为子图元而存在,但二者之间又存在着某种联系,于是我想到使用Controller
模式,建立功能图元和被操作图元之间的联系(原本的图元之间除了相交之外是没有这种强联系的)。
因为,假设我们只是需要环绕的效果,而不需要点击环绕图元存在某些效果,那么我们应该如何完成这个“环绕”的效果呢?这是我们今天要探讨的问题。
首先,我们得明白一个问题,就是同一时间内,对于同一个Scene中,只会有一个图元处于focus状态,而选中可以是多个的(使用橡皮筋框)。我这里不讨论选中多个的情况,只关心使用鼠标点击所产生的选中单个的情况。于是,同一时间,最多只有一个图元被功能图元所环绕。
我们可以将选择控制器和环绕功能图元形成的一个组件看做一个“资源”,图元之间会通过选择状态的轮替,而交替获得这个资源的使用权。这有点像操作系统中的轮转调度算法,不过我们不是以时间为标志进行轮替,而是以哪个图元被选中这个条件作为轮替的条件,被选中的图元就获得了“资源”的使用权:
同一时间,我们会控制有且仅有一个Item获得对接SelectController
从而获得环绕功能图元的使用权的权力,这个权力来源于该Item是否被选中。
当一个Item被选中时,可能会经历以下过程:
-
只有Item本身最清楚自己是否被选中,因为有专门的一些事件来证明它自身被选中了,这样我们就能够及时地向
SelectController
申请:“我已经被选中了,获得了你的使用权,请尽快和我对接”。 -
SelectController
获知了当前被选中Item的对接请求,但它很大可能之前服务着其他Item。类似于操作系统中的进程转换,操作系统需要记录前一个进程的运行状态,然后加载另一个进程的运行状态。这里的对接也需要分为两步:- 与前一个Item彻底断去联系,前一个Item无法通过任何形式获得环绕功能图元的服务(如果没有彻底断去联系,那么它可能还以为自己和环绕功能图元有联系,功能图元实际上会为两个Item提供服务)。
- 加载当前被选中图元,将该图元和环绕功能图元连接起来,正是为其提供服务。
上述过程实际上并不完整,比如:当用户选中一个Item之后,点击空白区域,那么就没有人发出“对接”请求,此时实际上前一个Item已经不处于被选中的状态,但是依然享有SelectController
所提供的服务,这是不正常的现象。因此,使用权的获取可以有Item检测自身是否被Select的状态而得来,使用权的丢失也必须由失去被选中状态的Item来发出。
- 在前一个Item意识到自己即将失去被选中的状态时,它需要提前告诉
SelectController
与我断联,下一个被选中者快要来了。不管下一个被选中的是什么东西,它都必须这样做。 - 如果下一个Item存在的话,那么在它被选中之后,它需要告诉
SelectController
并进行对接,实际在这之前SelectController
就因为前一个Item的通知而与其断联了。 - 如果下一个Item不存在,那么根本没有谁与
SelectController
对接,SelectController
处于空闲状态,当前的Scene中不会存有未处于被选中状态,但却占有SelectController
的情况。
这个过程实际上要求Item必须有感知自己是否要失去“被选中”状态的能力,而这个能力可以使用itemChange(change,value)
来赋予:
-
若
change = ItemSelectedChanged
,则表明itemChange()
函数要么是在“当下处于被选中状态,但可能在执行完itemChange()之后可能就失去了被选中状态”的时刻被调用;要么是在“当下处于未被选中状态,但可能在执行完itemChange()之后就会变成被选中状态”。- 若是前面那种状况,则说明该Item将要失去被选中状态,此时正是通过信号之类的方式和
SelectController
彻底断联的好时机。 - 若是后面那种状况,则说明该Item将要进入选中状态,我们可以选择在此时和
SelectController
进行对接。或者这样可能不保险,我们可以等到change = ItemSelectedHasChanged
,这个Item正好进入了选中/未选中状态的调用,并在选中的那个情况中,与SelectController
进行对接。
- 若是前面那种状况,则说明该Item将要失去被选中状态,此时正是通过信号之类的方式和
我们的想法似乎很美好,Item之间的对接和断联似乎没有什么问题,至少在将SelectController
和环绕功能图元抽象成一个资源的情况下是完全行得通的,这个资源的使用权的替换是没问题的。
但↑是↓,当我们将环绕功能图元加入进来之后,你就会发现,环绕功能图元也加入了以争夺SelectController
使用权的图元。本来作为资源的它,现在却同时兼任了资源的使用者和资源本身,自己使用自己的结果就是啥也没得。为什么这样说呢?
我们使用功能图元的方式按照大部分人的使用习惯来说,就是通过点击来使用功能图元的,这样就会产生一个问题:本来点击功能图元是为了发送信号,让被服务的图元产生某些变化。但现在为了服务被选中的Item,功能必须被点击从而处于了选中状态,然而此时会迫使当前被选中图元的断联,功能图元后面所发送的信号最终只能由它自己接收(然而该信号对它来说没有任何意义)。
这是一个矛盾,我们为了产生正确的环绕效果,必须让前一个Item自己进行断联操作。然而我们最终使用资源的方式却在服务达到目标之前,斩断了与服务目标的连接。我们必须解决这个矛盾,否则我们无法前进。
这里我们将获取到SelectController
服务的Item称为SelectedItem
,而将环绕功能Item称为SatelliteItem
。
在我们点击SatelliteItem
发送功能信号在点击事件之后,而关于选中状态的转换实际上在点击事件传递到SatelliteItem
之前(由Scene来处理了)。能否让SatelliteItem
在被选中之前,并且SelectedItem
未离开被选中状态之前,SatelliteItem
向SelectController
发送一个信号,告诉Controller忽略下一个的断联信号。这样的话,虽然实际上的被选中状态交给了SatelliteItem
,但由于信号未断联,所以服务对象并不会因此接收不到功能信号,并且由于SatelliteItem
不会发出对接信号,因此也不会导致SelectedItem
断联(实际对接和断联是分开来,二者不该相互影响)。
但现在,我们实际上并不知道两个Item的选中状态切换期间,itemChange()
的调用顺序是怎样的。接下来,为了验证上述方法是否可行,我们需要进行实验,实验的最终目的是:Item间Selected状态的改变状况下,itemChange()
的调用顺序的问题。
但是很遗憾,经过实验,我发现我们的忽略请求无法先于断联请求到达控制器。无论是未选中之前发送断联,还是未选中之后发送断联信号,都位于下一个被选中Item的前面,这意味着,我们的SatelliteItem
的忽略请求无法先于断联请求到达SelectController
,这个顺序决定了这个方法无法走通。
由于操作的顺序已经注定了,SelectItem
的断联注定是在SatelliteItem
的任何信号发出之前的,那么我们接受这个事实。
既然如此,我们不妨在SelectController
中存有两个Item,一个是前一个被选中的PreItem
,另外一个是当前服务的SelectItem
。当我们不点击SatelliteItem
,只在剩余的其他Item相互点击时,PreItem
永远也用不上。
但↑是↓,当我们点击SatelliteItem
之后,PreItem
让我们有了反悔的机会。当我们点击SatelliteItem
之后,断联已成定局这是固定死的,并且点击SatelliteItem
之前肯定有一个Item被选中了(即PreItem
肯定非空),那么我们不妨直接让SatelliteItem
直接和PreItem
进行交流(至于这中间搞什么样的逻辑来转换,全盘交给SelectController
肯定存在这么一种方法的)。大概率可行,这已经解决了我们服务对象丢失的问题。
由于我们的SatelliteItem
不会发送对接请求,因此虽然SatelliteItem
被选中了,但是由于SatelliteItem
一直没有接收到对接请求,所以它的SelectItem
一直处于无效状态。
这里需要注意的是:
- 断联:断开某些连接,然后刷新
PreItem
,无效化SelectItem
。- 对接:进行某些连接,然后设置
SelectItem
。
这样无论我们点击SatelliteItem
多少次,PreItem
都不会改变。SatelliteItem
发出的功能信号也都能发送到PreItem
。
接下来讨论PreItem
的设置问题:
- 最初的时候,由于没有任何Item被选中过,因此
PreItem
为空。 - 当第一个
SelectItem
被选中,并且在这些非SatelliteItem
中切换时,PreItem
会一直更新。 - 当用户停留在某个
SelectItem
时,他想要对SelectItem
进行操作,于是该SelectItem
不可避免地被设置成PreItem
,然后当前的SelectItem
空缺。此时用户尽情地按需点击各种SatelliteItem
。 - 用户点击其他的
SelectItem
,PreItem
和SelectItem
不断更新,要么重新进行第3步,要么进入第5步。 - 用户点击空白区域,
PreItem
被设置,SelectItem
空缺,这和点击SatelliteItem
的效果一样,不过此时我们不应该显示SatelliteItem
给用户点击。 - 用户点击某个Item,
PreItem = SelectItem = nullptr
,SelectItem
被设置。
在上面的步骤中,点击空白区域和点击SatelliteItem
其实都算点击了空白区域,它们的操作步骤实际上一致,只不过点击SatelliteItem
后,这些SatelliteItem
必须环绕在PreItem
周围,并且在点击SatelliteItem
之后,实际上SatelliteItem
还需要决定下一个选中权给谁,是回到PreItem
手中,还是其他什么Item手中。总之,SatelliteItem
必须根据自身的业务需求,重新环绕在某个Item之上。
为什么我会这么说?因为我们的SatelliteItem
除了要发送一些功能信号之外,它具有一个非常重要的特性:只在Item被选中时呈现出来。
按照正常的思路来说,SatelliteItem
应该随着Item的断联而消失,等到另外一个Item被选中之后重新呈现出来。但是由于存在SatelliteItem
自身也是下一个Item的情况,如果它因为PreItem
的断联而消失了,那么它不能够接收事件。因此,我这里采用的解决方案是:在断联之后,SatelliteItem
不必消失,而是等到功能按键点击之后,再由SatelliteItem
或者SelectController
来决定下一个SelectItem
在哪里。
但是,由于点击空白区域和点击SatelliteItem
在SelectController
呈现出来的效果是一样的,因此我们无法在点击空白区域后,主动使SatelliteItem
消失。
这个时候,我们就不得不借助QGraphicsScene::focusItemChanged()
这个信号了,由SelectController
关联到某个Scene
上,然后连接focusItemChanged()
信号,来裁定最终的SatelliteItem
是否需要消失。正常Item直接的focus转换是不需要SatelliteItem
消失的,即使是点击SatelliteItem
那也是有目标的选中;然而,点击空白区域一定会导致新的focusItem为空指针,因此当SelectController
观测到这个情况的时候,直接化身死亡判官,将所有的SatelliteItem
都隐藏起来。直到下一个SelectItem
出现发出了对接请求,才使用让这些SatelliteItem
重新出来。
因此,最终的SelectController
所形成的系统大概率是这样的:
通讯方式
一开始,我是想着SelectItem
和SatelliteItem
一边定义一个接口然后对应的Item实现这些接口的方式来的:
SelectInterface
对SelectItem
;SatelliteInterface
对SatelliteItem
;
但是我想要在SelectInterface
定义一些信号,方便后续继承的Item可以不需要自己定义这些信号,也方便SelectController
来使用,这就要求SelectInterface
是一个QObject。
但↑是↓,这发生了QObject
的菱形继承,简单来说就是:
C++中实际上是可以使用虚继承来一定程度上解决多继承的(但最好还是不要多继承),但是Qt中由于某些原因虚继承没作用(建议不要花时间深究到底为什么,会很耗时间,这种东西我也不太明白)。于是,基本可以放弃接口了。
实际上,我们搞不了接口,那干脆直接让SelectInterface
和SatelliteInterface
就让它以对象的形式存在算了。
你可能会说,你这不对呀,接口的话不是有虚方法让子类来实现吗?你将它作为成员变量,我还怎么重写虚方法啊?NoNoNo。
首先明确,我们一开始定义接口的目的是什么?是为了让SelectItem
和SatelliteItem
能够正确地接入SelectController
是吧。我们实际上不需要子类继承子类再继承子类这种一层套一层的多态性。我们只是想要一个东西,正确地接入SelectController
而已,那么这个东西就可以是一个单纯的对象,这里命名为SelectAdapter
。实际上这个接入对象是由SelectController
的开发者定义的,所以他必然会定义好SelectAdapter
的相关东西,并告诉你如何使用它。
那么在这里,我是SelectController
的开发者,我这样定义SelectAdapter
。
SelectAdapter
在SelectAdapter
我会定义两组信号,从SelectController
的视角来看:
- 输入信号,这是
SelectController
必须要使用槽函数来监听的信号,比如对接信号、断联信号等。 - 输出信号,这个
SelectController
根据情况来发出的信号,比如当SatelliteItem
发出功能信号后,SelectController
需要在功能信号的监听操作完成后,相应地发送这些信号给SelectAdapter
。
从SelectItem
的视角来看:
- 输出信号,对于
SelectController
的输入信号,恰恰是SelectItem
的输出信号,可能来自它的某个事件函数发出。 - 输入信号,来自
SelectController
的输出信号,SelectItem
必须使用槽函数对该信号做出反馈。
用图片来直观表示即:
实际上,对SelectAdapter
加些料还可以作为一个中间商:
这可以通过在我所给的SelectAdapter
中,封装一层SelectAdapterExtend_xxx
或者继承SelectAdapter
并添加一些东西完将其变成上面的模样。
题外话:
第二种方法的图是将
SelectAdapter
作为成员变量放在SelectAdapterExtend_xxx
中实现的,需要注意的是,在接入SelectController
的时候需要将SelectAdapter
给Controller,而不是SelectAdapterExtend_xxx
,而接入SelectItem
时需要传入的是SelectAdapterExtend_xxx
。怎样利用SelectAdapter
实际上取决于开发SelectItem
的人本身,我们不需要太过关心
我们的Controller
只负责业务范围内的信号转接,至于你们这些SelectItem
拿到功能信号之后要做什么,这信号最终交给哪个槽做出处理,我们不需要管。
对于SatelliteAdapter
实际上也是一样的道理。
接下来无非就是各自定义出符合业务需求的信号,然后让SelectItem
和SatelliteItem
接收并处理这些信号。
定义业务所需的Adapter的信号
SelectAdapter
我们从SelectItem
的视角来看,它需要以下信号:
-
断联信号
giveUpSelectToken
,自身即将处于未选中状态需要发送该信号; -
对接信号
requestSelectToken
,自身进入了选中状态需要和SelectController
连接; -
自定义的功能信号,我们对这些功能信号按照方位进行分类,上下左右,可以各有一个功能信号。当我们点击某个
SatelliteItem
时,它会发出自身被点击的信号,这个信号交由SelectController
处理,由SelectController
判断这个SatelliteItem
在哪个方位,然后将信号转发给SelectAdapter
中该方位的信号。 -
被选中信号
receiveSelectToken
,由于SatelliteItem
会在点击之后抢夺选中状态,因此,我们需要将被选中信号还给PreItem
,这里采用信号的方式通知他自己设置自己为被选中状态。此方法是可行的,经过简单的实验,可以达成这样的效果: -
自身的坐标信息
geometryChanged
,我们知道SatelliteItem
需要环绕在SelectItem
的四周,那么我们就必须有信号通知SatelliteItem
告诉它Item在哪,发生了变化之后我们需要发送这个坐标信息给SatelliteItem
。 -
sendGeometry
,在SatelliteItem
初始连接到SelectItem
的时候,由于SelectItem
的坐标可能根本就没有改变,这时需要SatelliteItem
主动请求SelectItem
让他发送自己的坐标信息,以便SatelliteItem
定义自身的位置。 -
requestGeometry
,为了让外界可以主动请求获取SelectItem
的坐标信息,向外界提供这样一个信号让其可以发出获取的请求。
SatelliteAdapter
我们从SatelliteItem
的视角来看,它需要以下信号:
- 功能信号
functionalSignal
,点击SatelliteItem
发出的信号。 sendSelectToken
,将select状态还给PreItem
,由PreItem
决定谁来获得select。receiveGeometry
,接收SelectItem
的坐标信息,然后根据坐标信息设置自身的位置。对于刚接入SelectController但坐标和大小都没有变化的SelectItem
,由SelectController
发送信号获取其坐标信息,并在接收sendGeometry()
信号的槽中手动调用一次所有SatelliteItem
的receiveGeometry
从而设置好SatelliteItem
的初始位置。invisible
,输入信号,当接收到这个信号时说明SelectController
通过focusItemChanged
检测到了现在没有Item处于被选中状态,则需要SatelliteItem
进入不可视状态。
简易的交互图
这里仅探讨了实现这样一种东西的理论,具体能否实现我这里还在验证中。