Qt-GraphicsView框架中环绕着图元的图元选择控制器的实现原理探讨

成果演示

本章实验用例代码

请添加图片描述
将这个功能应用到具体的业务上,比如:一个类思维导图的节点添加功能:
在这里插入图片描述

我们还可以通过定义环绕图元的各种行为,来让它们本身具有某些样式或者独特的功能,但在本篇文章中,我主要就“如何使得图元环绕在其他图元四周,并且这个环绕效果会根据选择的图元的不同而环绕在不同的图元之上”这个问题进行一些理论上的探讨。

理论讲解

我们的需求是这样的,当用户点击某个图元时,这个图元的四周(上下左右)会出现一些“环绕”在该图元周围的其他功能性图元。

这些功能性图元提供某些功能操作,为了便于用户点击这些按键,我们将其环绕在操作的图元附近,而判断某个图元是否正在被操作的方式就是通过判断这个图元是否处于“焦点”状态。

由于这些环绕在被选择图元的功能性图元实际上不属于被操作图元的一部分,所以它们无法作为子图元而存在,但二者之间又存在着某种联系,于是我想到使用Controller模式,建立功能图元和被操作图元之间的联系(原本的图元之间除了相交之外是没有这种强联系的)。

因为,假设我们只是需要环绕的效果,而不需要点击环绕图元存在某些效果,那么我们应该如何完成这个“环绕”的效果呢?这是我们今天要探讨的问题。

首先,我们得明白一个问题,就是同一时间内,对于同一个Scene中,只会有一个图元处于focus状态,而选中可以是多个的(使用橡皮筋框)。我这里不讨论选中多个的情况,只关心使用鼠标点击所产生的选中单个的情况。于是,同一时间,最多只有一个图元被功能图元所环绕。

我们可以将选择控制器和环绕功能图元形成的一个组件看做一个“资源”,图元之间会通过选择状态的轮替,而交替获得这个资源的使用权。这有点像操作系统中的轮转调度算法,不过我们不是以时间为标志进行轮替,而是以哪个图元被选中这个条件作为轮替的条件,被选中的图元就获得了“资源”的使用权:

在这里插入图片描述
同一时间,我们会控制有且仅有一个Item获得对接SelectController从而获得环绕功能图元的使用权的权力,这个权力来源于该Item是否被选中。

当一个Item被选中时,可能会经历以下过程:

  1. 只有Item本身最清楚自己是否被选中,因为有专门的一些事件来证明它自身被选中了,这样我们就能够及时地向SelectController申请:“我已经被选中了,获得了你的使用权,请尽快和我对接”。

  2. SelectController获知了当前被选中Item的对接请求,但它很大可能之前服务着其他Item。类似于操作系统中的进程转换,操作系统需要记录前一个进程的运行状态,然后加载另一个进程的运行状态。这里的对接也需要分为两步:

    1. 与前一个Item彻底断去联系,前一个Item无法通过任何形式获得环绕功能图元的服务(如果没有彻底断去联系,那么它可能还以为自己和环绕功能图元有联系,功能图元实际上会为两个Item提供服务)。
    2. 加载当前被选中图元,将该图元和环绕功能图元连接起来,正是为其提供服务。

上述过程实际上并不完整,比如:当用户选中一个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之间的对接和断联似乎没有什么问题,至少在将SelectController和环绕功能图元抽象成一个资源的情况下是完全行得通的,这个资源的使用权的替换是没问题的。

但↑是↓,当我们将环绕功能图元加入进来之后,你就会发现,环绕功能图元也加入了以争夺SelectController使用权的图元。本来作为资源的它,现在却同时兼任了资源的使用者和资源本身,自己使用自己的结果就是啥也没得。为什么这样说呢?

我们使用功能图元的方式按照大部分人的使用习惯来说,就是通过点击来使用功能图元的,这样就会产生一个问题:本来点击功能图元是为了发送信号,让被服务的图元产生某些变化。但现在为了服务被选中的Item,功能必须被点击从而处于了选中状态,然而此时会迫使当前被选中图元的断联,功能图元后面所发送的信号最终只能由它自己接收(然而该信号对它来说没有任何意义)。

这是一个矛盾,我们为了产生正确的环绕效果,必须让前一个Item自己进行断联操作。然而我们最终使用资源的方式却在服务达到目标之前,斩断了与服务目标的连接。我们必须解决这个矛盾,否则我们无法前进。


这里我们将获取到SelectController服务的Item称为SelectedItem,而将环绕功能Item称为SatelliteItem

在我们点击SatelliteItem发送功能信号在点击事件之后,而关于选中状态的转换实际上在点击事件传递到SatelliteItem之前(由Scene来处理了)。能否让SatelliteItem在被选中之前,并且SelectedItem未离开被选中状态之前,SatelliteItemSelectController发送一个信号,告诉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的设置问题:

  1. 最初的时候,由于没有任何Item被选中过,因此PreItem为空。
  2. 当第一个SelectItem被选中,并且在这些非SatelliteItem中切换时,PreItem会一直更新。
  3. 当用户停留在某个SelectItem时,他想要对SelectItem进行操作,于是该SelectItem不可避免地被设置成PreItem,然后当前的SelectItem空缺。此时用户尽情地按需点击各种SatelliteItem
  4. 用户点击其他的SelectItemPreItemSelectItem不断更新,要么重新进行第3步,要么进入第5步。
  5. 用户点击空白区域,PreItem被设置,SelectItem空缺,这和点击SatelliteItem的效果一样,不过此时我们不应该显示SatelliteItem给用户点击。
  6. 用户点击某个Item,PreItem = SelectItem = nullptrSelectItem被设置。

在上面的步骤中,点击空白区域和点击SatelliteItem其实都算点击了空白区域,它们的操作步骤实际上一致,只不过点击SatelliteItem后,这些SatelliteItem必须环绕在PreItem周围,并且在点击SatelliteItem之后,实际上SatelliteItem还需要决定下一个选中权给谁,是回到PreItem手中,还是其他什么Item手中。总之,SatelliteItem必须根据自身的业务需求,重新环绕在某个Item之上。

为什么我会这么说?因为我们的SatelliteItem除了要发送一些功能信号之外,它具有一个非常重要的特性:只在Item被选中时呈现出来。

按照正常的思路来说,SatelliteItem应该随着Item的断联而消失,等到另外一个Item被选中之后重新呈现出来。但是由于存在SatelliteItem自身也是下一个Item的情况,如果它因为PreItem的断联而消失了,那么它不能够接收事件。因此,我这里采用的解决方案是:在断联之后,SatelliteItem不必消失,而是等到功能按键点击之后,再由SatelliteItem或者SelectController来决定下一个SelectItem在哪里。

但是,由于点击空白区域和点击SatelliteItemSelectController呈现出来的效果是一样的,因此我们无法在点击空白区域后,主动使SatelliteItem消失。

这个时候,我们就不得不借助QGraphicsScene::focusItemChanged()这个信号了,由SelectController关联到某个Scene上,然后连接focusItemChanged()信号,来裁定最终的SatelliteItem是否需要消失。正常Item直接的focus转换是不需要SatelliteItem消失的,即使是点击SatelliteItem那也是有目标的选中;然而,点击空白区域一定会导致新的focusItem为空指针,因此当SelectController观测到这个情况的时候,直接化身死亡判官,将所有的SatelliteItem都隐藏起来。直到下一个SelectItem出现发出了对接请求,才使用让这些SatelliteItem重新出来。


因此,最终的SelectController所形成的系统大概率是这样的:

在这里插入图片描述

通讯方式

一开始,我是想着SelectItemSatelliteItem一边定义一个接口然后对应的Item实现这些接口的方式来的:

  • SelectInterfaceSelectItem
  • SatelliteInterfaceSatelliteItem

但是我想要在SelectInterface定义一些信号,方便后续继承的Item可以不需要自己定义这些信号,也方便SelectController来使用,这就要求SelectInterface是一个QObject。

但↑是↓,这发生了QObject的菱形继承,简单来说就是:

在这里插入图片描述
C++中实际上是可以使用虚继承来一定程度上解决多继承的(但最好还是不要多继承),但是Qt中由于某些原因虚继承没作用(建议不要花时间深究到底为什么,会很耗时间,这种东西我也不太明白)。于是,基本可以放弃接口了。

实际上,我们搞不了接口,那干脆直接让SelectInterfaceSatelliteInterface就让它以对象的形式存在算了。

在这里插入图片描述
你可能会说,你这不对呀,接口的话不是有虚方法让子类来实现吗?你将它作为成员变量,我还怎么重写虚方法啊?NoNoNo。

首先明确,我们一开始定义接口的目的是什么?是为了让SelectItemSatelliteItem能够正确地接入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实际上也是一样的道理。

接下来无非就是各自定义出符合业务需求的信号,然后让SelectItemSatelliteItem接收并处理这些信号。

定义业务所需的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()信号的槽中手动调用一次所有SatelliteItemreceiveGeometry从而设置好SatelliteItem的初始位置。
  • invisible,输入信号,当接收到这个信号时说明SelectController通过focusItemChanged检测到了现在没有Item处于被选中状态,则需要SatelliteItem进入不可视状态。

简易的交互图

在这里插入图片描述
这里仅探讨了实现这样一种东西的理论,具体能否实现我这里还在验证中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值